收口微信领域能力
将 api-server 微信 HTTP/BFF 适配统一迁移到 wechat 目录。 将微信支付和虚拟支付消息协议细节下沉到 platform-wechat。 拆分 platform-wechat 的订阅消息与支付模块并补齐依赖。 修正微信相关测试的用户 ID 夹具并同步后端架构文档。
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-08 微信能力按领域收口
|
||||
|
||||
- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。
|
||||
- 决策:`api-server` 内微信相关 HTTP/BFF 适配统一收在 `server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*`;`platform-wechat` 负责微信订阅消息、微信支付 V3、虚拟支付消息推送的协议 client、header、签名、验签、解密、mock 和 payload 解析;`api-server::wechat` 只负责 AppConfig 映射、Axum handler、用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。微信 OAuth / 小程序登录 provider 暂继续在 `platform-auth`,通过 `api-server::wechat::provider` 作为组合根 adapter 接入。
|
||||
- 影响范围:`server-rs/crates/api-server/src/wechat.rs`、`server-rs/crates/api-server/src/wechat/*`、`server-rs/crates/platform-wechat/src/*`、微信支付 / 订阅消息 / 小程序消息推送文档。
|
||||
- 验证方式:执行 `cargo check --manifest-path server-rs/Cargo.toml -p platform-wechat`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server`、微信相关定向测试和编码检查;新增微信协议细节优先落到 `platform-wechat`。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
||||
|
||||
## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐
|
||||
|
||||
- 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。
|
||||
|
||||
@@ -22,7 +22,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`
|
||||
|
||||
- HTTP 服务:`api-server`。
|
||||
- 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。
|
||||
- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-speech`。
|
||||
- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-wechat`、`platform-speech`。
|
||||
- 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。
|
||||
- SpacetimeDB:`spacetime-client`、`spacetime-module`。
|
||||
- 测试支撑:`tests-support`。
|
||||
@@ -35,6 +35,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`
|
||||
4. 后端访问 SpacetimeDB 必须经 `spacetime-client` facade。
|
||||
5. HTTP 鉴权、BFF 聚合、SSE、外部模型编排、OSS 上传和第三方回调在 `api-server`。
|
||||
6. 前端共享 DTO 通过 `shared-contracts` 和 `packages/shared` 对齐,不在页面内重新发明旧接口。
|
||||
7. 微信能力按两层收口:`server-rs/crates/platform-wechat` 承载微信协议 client、订阅消息 `stable_token` / `subscribeMessage.send`、微信支付 V3 / 虚拟支付消息推送的 HTTP header、签名、验签、解密、mock 响应和协议 payload 解析;`server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*` 承载 Axum handler、AppConfig 到平台配置的映射、Genarrative 用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。`platform-auth` 当前仍承载微信 OAuth / 小程序登录 provider 协议,`api-server::wechat::provider` 只作为组合根 adapter,不在业务 handler 内散落 provider 构造。
|
||||
|
||||
验证:
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
- 充值入口:`src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||
- 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js`
|
||||
- API 契约:`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts/src/runtime.rs`
|
||||
- 后端下单与签名:`server-rs/crates/api-server/src/runtime_profile.rs`
|
||||
- 后端下单与订单编排:`server-rs/crates/api-server/src/runtime_profile.rs`、`server-rs/crates/api-server/src/wechat/pay.rs`
|
||||
- 微信支付 / 虚拟支付协议适配:`server-rs/crates/platform-wechat/src/pay.rs`
|
||||
- 微信订阅消息协议适配:`server-rs/crates/platform-wechat/src/subscribe_message.rs`
|
||||
- WebView 回流确认:`GET /api/profile/recharge/orders/{orderId}/wechat/events`、`POST /api/profile/recharge/orders/{orderId}/wechat/confirm`
|
||||
- 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs`、`server-rs/crates/module-auth/src/lib.rs`
|
||||
|
||||
|
||||
10
server-rs/Cargo.lock
generated
10
server-rs/Cargo.lock
generated
@@ -2513,11 +2513,21 @@ dependencies = [
|
||||
name = "platform-wechat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
"cbc",
|
||||
"hex",
|
||||
"reqwest 0.12.28",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"shared-contracts",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -41,7 +41,7 @@ use crate::{
|
||||
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
|
||||
submit_visual_novel_message, update_visual_novel_work,
|
||||
},
|
||||
wechat_pay::{
|
||||
wechat::pay::{
|
||||
handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify,
|
||||
handle_wechat_virtual_payment_notify,
|
||||
},
|
||||
@@ -1507,8 +1507,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn wooden_fish_session_creation_accepts_legacy_audio_body_above_default_limit() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user =
|
||||
seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_audio_body");
|
||||
let app = build_router(state);
|
||||
let request_body = format!(
|
||||
@@ -1548,8 +1547,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn wooden_fish_actions_accept_legacy_audio_body_above_default_limit() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user =
|
||||
seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_action_body");
|
||||
let app = build_router(state);
|
||||
let request_body = format!(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
|
||||
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
|
||||
|
||||
use platform_llm::{
|
||||
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
|
||||
@@ -37,11 +37,11 @@ use spacetime_client::{
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldLibraryEntryRecord,
|
||||
CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput,
|
||||
CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput,
|
||||
CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord,
|
||||
CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||
CustomWorldLibraryEntryRecord, CustomWorldProfileLikeReportRecordInput,
|
||||
CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput,
|
||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
||||
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||
};
|
||||
use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
@@ -10,9 +10,9 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType};
|
||||
#[cfg(test)]
|
||||
use image::ImageFormat;
|
||||
use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
|
||||
@@ -9,8 +9,8 @@ use crate::{
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use generated_asset_sheets_impl::{
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetSliceImage,
|
||||
GeneratedAssetSheetUpload,
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
|
||||
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
|
||||
crop_generated_asset_sheet_view_edge_matte,
|
||||
crop_generated_asset_sheet_view_edge_matte_with_options,
|
||||
|
||||
@@ -14,10 +14,9 @@ use shared_contracts::jump_hop::{
|
||||
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
||||
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
||||
JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
|
||||
JumpHopRunResponse,
|
||||
JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
|
||||
JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||||
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
|
||||
JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse,
|
||||
JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse,
|
||||
JumpHopWorkMutationResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
@@ -45,7 +44,7 @@ use crate::{
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat_subscribe_message::{
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
@@ -295,10 +294,7 @@ pub async fn get_jump_hop_work_detail(
|
||||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||||
let work = state
|
||||
.spacetime_client()
|
||||
.get_jump_hop_work_profile(
|
||||
profile_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
)
|
||||
.get_jump_hop_work_profile(profile_id, authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
jump_hop_error_response(
|
||||
@@ -323,10 +319,7 @@ pub async fn delete_jump_hop_work(
|
||||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||||
let works = state
|
||||
.spacetime_client()
|
||||
.delete_jump_hop_work(
|
||||
profile_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
)
|
||||
.delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
jump_hop_error_response(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
};
|
||||
|
||||
@@ -89,10 +89,7 @@ mod tracking_outbox;
|
||||
mod vector_engine_audio_generation;
|
||||
mod visual_novel;
|
||||
mod volcengine_speech;
|
||||
mod wechat_auth;
|
||||
mod wechat_pay;
|
||||
mod wechat_provider;
|
||||
mod wechat_subscribe_message;
|
||||
mod wechat;
|
||||
mod wooden_fish;
|
||||
mod work_author;
|
||||
mod work_play_tracking;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
convert::Infallible,
|
||||
future::Future,
|
||||
@@ -84,7 +84,7 @@ use crate::{
|
||||
vector_engine_audio_generation::{
|
||||
GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation,
|
||||
},
|
||||
wechat_subscribe_message::{
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
phone_auth::{phone_login, send_phone_code},
|
||||
refresh_session::refresh_session,
|
||||
state::AppState,
|
||||
wechat_auth::{
|
||||
wechat::auth::{
|
||||
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use axum::{
|
||||
middleware,
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -9,9 +8,8 @@ use crate::{
|
||||
jump_hop::{
|
||||
create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
|
||||
get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
|
||||
get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump,
|
||||
list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run,
|
||||
start_jump_hop_run,
|
||||
get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump, list_jump_hop_gallery,
|
||||
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -24,9 +24,7 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/creation/wooden-fish/sessions",
|
||||
post(create_wooden_fish_session)
|
||||
// 中文注释:兼容旧小程序把参考图或录音 Data URL 放进创作 JSON 的请求;新前端音频会先直传 OSS。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
WOODEN_FISH_CREATION_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
@@ -43,9 +41,7 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/creation/wooden-fish/sessions/{session_id}/actions",
|
||||
post(execute_wooden_fish_action)
|
||||
// 中文注释:compile/regenerate 会携带参考图旧兼容输入,避免 Axum 默认 2MB 先于 handler 拦截。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
WOODEN_FISH_CREATION_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
@@ -98,4 +94,4 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/wooden-fish/gallery/{public_work_code}",
|
||||
get(get_wooden_fish_gallery_detail),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use platform_auth::{AuthPlatformErrorKind, WechatProviderError};
|
||||
use platform_llm::{LlmError, LlmErrorKind};
|
||||
use platform_oss::{OssError, OssErrorKind};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Instant, SystemTime, UNIX_EPOCH},
|
||||
@@ -105,7 +105,7 @@ use crate::{
|
||||
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
|
||||
request_context::RequestContext,
|
||||
state::{AppState, PuzzleApiState},
|
||||
wechat_subscribe_message::{
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
|
||||
@@ -9,12 +9,12 @@ use shared_contracts::{
|
||||
puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse},
|
||||
puzzle_works::PuzzleWorkSummaryResponse,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
use tokio::{
|
||||
sync::{Mutex, MutexGuard, RwLock},
|
||||
time,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
|
||||
use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ use module_runtime::{
|
||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||
RuntimeTrackingScopeKind,
|
||||
};
|
||||
use platform_wechat::pay::WechatPayNotifyOrder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::Sha256;
|
||||
@@ -81,9 +82,9 @@ use crate::{
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat_pay::{
|
||||
WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request,
|
||||
current_unix_micros, map_wechat_pay_error,
|
||||
wechat::pay::{
|
||||
build_wechat_payment_request, build_wechat_web_payment_request, current_unix_micros,
|
||||
map_wechat_pay_error,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3056,11 +3057,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn issue_access_token(state: &AppState) -> String {
|
||||
let user_id = test_authenticated_user_id(state);
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
user_id: user_id.clone(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"),
|
||||
.seed_test_refresh_session_for_user_id(&user_id, "sess_runtime_profile"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
@@ -3081,11 +3083,11 @@ mod tests {
|
||||
client_platform: &str,
|
||||
session_id: &str,
|
||||
) -> String {
|
||||
let user_id = test_authenticated_user_id(state);
|
||||
let claims = AccessTokenClaims::from_input_with_device(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", session_id),
|
||||
user_id: user_id.clone(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id),
|
||||
provider: AuthProvider::Wechat,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
@@ -3105,4 +3107,13 @@ mod tests {
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
|
||||
fn test_authenticated_user_id(state: &AppState) -> String {
|
||||
state
|
||||
.auth_user_service()
|
||||
.get_user_by_public_user_code("SY-00000001")
|
||||
.expect("test user lookup should succeed")
|
||||
.expect("seeded test user should exist")
|
||||
.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ use crate::{
|
||||
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
|
||||
},
|
||||
state::AppState,
|
||||
wechat_subscribe_message::{
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
@@ -1119,12 +1119,15 @@ async fn compile_square_hole_draft_for_session(
|
||||
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
|
||||
|
||||
let resolved_game_name = game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text)));
|
||||
let generation_points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state,
|
||||
SQUARE_HOLE_TEMPLATE_ID,
|
||||
u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
|
||||
)
|
||||
.await;
|
||||
let generation_points_cost =
|
||||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state,
|
||||
SQUARE_HOLE_TEMPLATE_ID,
|
||||
u64::from(
|
||||
shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.compile_square_hole_draft(SquareHoleCompileDraftRecordInput {
|
||||
|
||||
@@ -27,7 +27,7 @@ use platform_auth::{
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
use platform_wechat::{WechatClient, WechatConfig};
|
||||
use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
||||
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
|
||||
@@ -39,8 +39,8 @@ use tracing::{info, warn};
|
||||
use crate::config::AppConfig;
|
||||
use crate::puzzle_gallery_cache::PuzzleGalleryCache;
|
||||
use crate::tracking_outbox::TrackingOutbox;
|
||||
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
||||
use crate::wechat_provider::build_wechat_provider;
|
||||
use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error};
|
||||
use crate::wechat::provider::build_wechat_provider;
|
||||
use crate::work_author::{
|
||||
ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID,
|
||||
};
|
||||
@@ -388,8 +388,8 @@ impl AppState {
|
||||
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
|
||||
let wechat_provider = build_wechat_provider(&config);
|
||||
let wechat_client = build_wechat_client(&config);
|
||||
let wechat_pay_client =
|
||||
WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?;
|
||||
let wechat_pay_client = WechatPayClient::from_config(&build_wechat_pay_config(&config))
|
||||
.map_err(map_wechat_pay_init_error)?;
|
||||
let refresh_session_service =
|
||||
RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days);
|
||||
// AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。
|
||||
|
||||
@@ -35,7 +35,7 @@ use crate::{
|
||||
prompt::visual_novel as vn_prompt,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat_subscribe_message::{
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
@@ -1748,12 +1748,15 @@ async fn compile_visual_novel_session_inner(
|
||||
);
|
||||
let projection = project_draft_for_work(&draft, &profile_id)?;
|
||||
let notification_work_name = projection.work_title.clone();
|
||||
let generation_points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state,
|
||||
VISUAL_NOVEL_RUNTIME_KIND,
|
||||
u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
|
||||
)
|
||||
.await;
|
||||
let generation_points_cost =
|
||||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state,
|
||||
VISUAL_NOVEL_RUNTIME_KIND,
|
||||
u64::from(
|
||||
shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let author = resolve_work_author_by_user_id(state, &owner_user_id, None, None);
|
||||
let compile_result = state
|
||||
.spacetime_client()
|
||||
@@ -1770,9 +1773,7 @@ async fn compile_visual_novel_session_inner(
|
||||
compiled_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
visual_novel_error_response(request_context, map_spacetime_error(error))
|
||||
});
|
||||
.map_err(|error| visual_novel_error_response(request_context, map_spacetime_error(error)));
|
||||
let compiled_session = match compile_result {
|
||||
Ok(session) => {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
|
||||
4
server-rs/crates/api-server/src/wechat.rs
Normal file
4
server-rs/crates/api-server/src/wechat.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod auth;
|
||||
pub(crate) mod pay;
|
||||
pub(crate) mod provider;
|
||||
pub(crate) mod subscribe_message;
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
423
server-rs/crates/api-server/src/wechat/pay.rs
Normal file
423
server-rs/crates/api-server/src/wechat/pay.rs
Normal file
@@ -0,0 +1,423 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use platform_wechat::pay::{
|
||||
WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayConfig,
|
||||
WechatPayError, WechatWebOrderRequest, decrypt_wechat_message_push_ciphertext,
|
||||
parse_virtual_payment_notify, parse_wechat_mini_program_message_push_payload,
|
||||
resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{config::AppConfig, http_error::AppError, state::AppState};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum VirtualPaymentNotifyResponseFormat {
|
||||
Json,
|
||||
Xml,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ApiWechatVirtualPaymentNotifyResponse {
|
||||
#[serde(rename = "ErrCode")]
|
||||
err_code: i32,
|
||||
#[serde(rename = "ErrMsg")]
|
||||
err_msg: String,
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_pay_notify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let notify = state
|
||||
.wechat_pay_client()
|
||||
.parse_notify(&headers, &body)
|
||||
.map_err(map_wechat_pay_notify_error)?;
|
||||
if notify.trade_state != "SUCCESS" {
|
||||
info!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
trade_state = notify.trade_state.as_str(),
|
||||
"收到非成功微信支付通知"
|
||||
);
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
let paid_at_micros = notify
|
||||
.success_time
|
||||
.as_deref()
|
||||
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
|
||||
.map(offset_datetime_to_unix_micros)
|
||||
.unwrap_or_else(current_unix_micros);
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
notify.out_trade_no.clone(),
|
||||
paid_at_micros,
|
||||
notify.transaction_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("确认微信支付订单失败:{error}"))
|
||||
})?;
|
||||
info!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"微信支付通知已确认订单入账"
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_virtual_payment_message_push_verify(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||
) -> Response {
|
||||
let token = match read_wechat_message_push_config(
|
||||
state.config.wechat_mini_program_message_token.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||
};
|
||||
let aes_key = match read_wechat_message_push_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_message_encoding_aes_key
|
||||
.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||
};
|
||||
match resolve_wechat_message_push_verify_response(
|
||||
token,
|
||||
aes_key,
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_app_id
|
||||
.as_deref()
|
||||
.or(state.config.wechat_app_id.as_deref()),
|
||||
&query,
|
||||
) {
|
||||
Ok(plaintext) => (StatusCode::OK, plaintext).into_response(),
|
||||
Err(error) => build_wechat_message_push_verify_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_virtual_payment_notify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let response_format = detect_virtual_payment_notify_response_format(&headers, &body);
|
||||
let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let token = match read_wechat_message_push_config(
|
||||
state.config.wechat_mini_program_message_token.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let aes_key = match read_wechat_message_push_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_message_encoding_aes_key
|
||||
.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let signature = query
|
||||
.msg_signature
|
||||
.as_deref()
|
||||
.or(query.signature.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("");
|
||||
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
|
||||
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
|
||||
if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() {
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
if !verify_wechat_message_push_signature(
|
||||
token,
|
||||
timestamp,
|
||||
nonce,
|
||||
encrypted_payload.encrypt.as_str(),
|
||||
signature,
|
||||
) {
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
let notify_body = match decrypt_wechat_message_push_ciphertext(
|
||||
aes_key,
|
||||
encrypted_payload.encrypt.as_str(),
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_app_id
|
||||
.as_deref()
|
||||
.or(state.config.wechat_app_id.as_deref()),
|
||||
) {
|
||||
Ok(body) => body,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) {
|
||||
Ok(notify) => notify,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" {
|
||||
info!(
|
||||
event = notify.event.as_str(),
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"收到非订单入账虚拟支付推送"
|
||||
);
|
||||
return build_virtual_payment_notify_success_response(response_format);
|
||||
}
|
||||
|
||||
let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros);
|
||||
if state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
notify.out_trade_no.clone(),
|
||||
paid_at_micros,
|
||||
notify.transaction_id.clone(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"确认微信虚拟支付订单失败"
|
||||
);
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
|
||||
state.publish_profile_recharge_order_update(notify.out_trade_no.clone());
|
||||
|
||||
info!(
|
||||
event = notify.event.as_str(),
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"微信虚拟支付推送已确认订单入账"
|
||||
);
|
||||
|
||||
build_virtual_payment_notify_success_response(response_format)
|
||||
}
|
||||
|
||||
pub fn build_wechat_pay_config(config: &AppConfig) -> WechatPayConfig {
|
||||
WechatPayConfig {
|
||||
enabled: config.wechat_pay_enabled,
|
||||
provider: config.wechat_pay_provider.clone(),
|
||||
app_id: config
|
||||
.wechat_mini_program_app_id
|
||||
.clone()
|
||||
.or_else(|| config.wechat_app_id.clone()),
|
||||
mch_id: config.wechat_pay_mch_id.clone(),
|
||||
merchant_serial_no: config.wechat_pay_merchant_serial_no.clone(),
|
||||
private_key_pem: config.wechat_pay_private_key_pem.clone(),
|
||||
private_key_path: config.wechat_pay_private_key_path.clone(),
|
||||
platform_public_key_pem: config.wechat_pay_platform_public_key_pem.clone(),
|
||||
platform_public_key_path: config.wechat_pay_platform_public_key_path.clone(),
|
||||
platform_serial_no: config.wechat_pay_platform_serial_no.clone(),
|
||||
api_v3_key: config.wechat_pay_api_v3_key.clone(),
|
||||
notify_url: config.wechat_pay_notify_url.clone(),
|
||||
jsapi_endpoint: config.wechat_pay_jsapi_endpoint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
match error {
|
||||
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("微信支付暂未启用")
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidConfig(message) => {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" }))
|
||||
}
|
||||
WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature(message) => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("微信支付通知签名无效")
|
||||
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError {
|
||||
crate::state::AppStateInitError::WechatPay(error.to_string())
|
||||
}
|
||||
|
||||
pub fn build_wechat_payment_request(
|
||||
order_id: String,
|
||||
product_title: String,
|
||||
amount_cents: u64,
|
||||
payer_openid: String,
|
||||
) -> WechatMiniProgramOrderRequest {
|
||||
WechatMiniProgramOrderRequest {
|
||||
order_id,
|
||||
description: format!("陶泥儿 - {product_title}"),
|
||||
amount_cents,
|
||||
payer_openid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_wechat_web_payment_request(
|
||||
order_id: String,
|
||||
product_title: String,
|
||||
amount_cents: u64,
|
||||
payer_client_ip: String,
|
||||
) -> WechatWebOrderRequest {
|
||||
WechatWebOrderRequest {
|
||||
order_id,
|
||||
description: format!("陶泥儿 - {product_title}"),
|
||||
amount_cents,
|
||||
payer_client_ip,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_unix_micros() -> i64 {
|
||||
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
i64::try_from(value).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||||
warn!(error = %error, "微信支付通知处理失败");
|
||||
map_wechat_pay_error(error)
|
||||
}
|
||||
|
||||
fn read_wechat_message_push_config<'a>(
|
||||
value: Option<&'a str>,
|
||||
key: &str,
|
||||
) -> Result<&'a str, WechatPayError> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
|
||||
}
|
||||
|
||||
fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response {
|
||||
let message = match error {
|
||||
WechatPayError::Disabled => "微信消息推送暂未启用".to_string(),
|
||||
WechatPayError::InvalidConfig(message)
|
||||
| WechatPayError::InvalidRequest(message)
|
||||
| WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message)
|
||||
| WechatPayError::InvalidSignature(message) => message,
|
||||
};
|
||||
(StatusCode::BAD_REQUEST, message).into_response()
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_error_response(
|
||||
error: WechatPayError,
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
) -> Response {
|
||||
warn!(error = %error, "微信虚拟支付通知处理失败");
|
||||
let message = match error {
|
||||
WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(),
|
||||
WechatPayError::InvalidConfig(message)
|
||||
| WechatPayError::InvalidRequest(message)
|
||||
| WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message)
|
||||
| WechatPayError::InvalidSignature(message) => message,
|
||||
};
|
||||
build_virtual_payment_notify_response(response_format, 1, message)
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_success_response(
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
) -> Response {
|
||||
build_virtual_payment_notify_response(response_format, 0, "success")
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_response(
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
err_code: i32,
|
||||
err_msg: impl Into<String>,
|
||||
) -> Response {
|
||||
let err_msg = err_msg.into();
|
||||
match response_format {
|
||||
VirtualPaymentNotifyResponseFormat::Json => Json(
|
||||
build_wechat_virtual_payment_notify_response(err_code, err_msg),
|
||||
)
|
||||
.into_response(),
|
||||
VirtualPaymentNotifyResponseFormat::Xml => {
|
||||
let body = format!(
|
||||
"<xml><ErrCode>{err_code}</ErrCode><ErrMsg><![CDATA[{err_msg}]]></ErrMsg></xml>"
|
||||
);
|
||||
let mut response = (StatusCode::OK, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/xml; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wechat_virtual_payment_notify_response(
|
||||
err_code: i32,
|
||||
err_msg: impl Into<String>,
|
||||
) -> ApiWechatVirtualPaymentNotifyResponse {
|
||||
ApiWechatVirtualPaymentNotifyResponse {
|
||||
err_code,
|
||||
err_msg: err_msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_virtual_payment_notify_response_format(
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> VirtualPaymentNotifyResponseFormat {
|
||||
let content_type = headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if content_type.contains("xml") {
|
||||
return VirtualPaymentNotifyResponseFormat::Xml;
|
||||
}
|
||||
let body_trimmed = body
|
||||
.iter()
|
||||
.copied()
|
||||
.skip_while(|byte| byte.is_ascii_whitespace())
|
||||
.next();
|
||||
match body_trimmed {
|
||||
Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml,
|
||||
_ => VirtualPaymentNotifyResponseFormat::Json,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use platform_auth::{
|
||||
use platform_auth::{
|
||||
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
|
||||
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT,
|
||||
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT,
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
@@ -43,7 +43,7 @@ use crate::{
|
||||
platform_errors::map_oss_error,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat_subscribe_message::{
|
||||
wechat::subscribe_message::{
|
||||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||||
send_generation_result_subscribe_message_after_completion,
|
||||
},
|
||||
|
||||
@@ -5,8 +5,18 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aes = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
cbc = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
|
||||
ring = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
shared-contracts = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
|
||||
@@ -1,234 +1,11 @@
|
||||
use std::{collections::BTreeMap, error::Error, fmt};
|
||||
pub mod pay;
|
||||
pub mod subscribe_message;
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str =
|
||||
"https://api.weixin.qq.com/cgi-bin/stable_token";
|
||||
pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str =
|
||||
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WechatConfig {
|
||||
pub app_id: Option<String>,
|
||||
pub app_secret: Option<String>,
|
||||
pub stable_access_token_endpoint: String,
|
||||
pub subscribe_message_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WechatClient {
|
||||
client: Client,
|
||||
config: WechatConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WechatSubscribeMessageRequest {
|
||||
pub touser: String,
|
||||
pub template_id: String,
|
||||
pub page: Option<String>,
|
||||
pub miniprogram_state: Option<String>,
|
||||
pub lang: Option<String>,
|
||||
pub data: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum WechatError {
|
||||
InvalidConfig(String),
|
||||
RequestFailed(String),
|
||||
DeserializeFailed(String),
|
||||
Upstream(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WechatErrorKind {
|
||||
InvalidConfig,
|
||||
RequestFailed,
|
||||
DeserializeFailed,
|
||||
Upstream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatStableAccessTokenResponse {
|
||||
access_token: Option<String>,
|
||||
errcode: Option<i64>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatSubscribeMessageResponse {
|
||||
errcode: i64,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WechatTemplateDataValue {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl WechatClient {
|
||||
pub fn new(config: WechatConfig) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_subscribe_message(
|
||||
&self,
|
||||
request: WechatSubscribeMessageRequest,
|
||||
) -> Result<(), WechatError> {
|
||||
let app_id = self
|
||||
.config
|
||||
.app_id
|
||||
.as_deref()
|
||||
.and_then(non_empty)
|
||||
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?;
|
||||
let app_secret = self
|
||||
.config
|
||||
.app_secret
|
||||
.as_deref()
|
||||
.and_then(non_empty)
|
||||
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?;
|
||||
|
||||
let access_token = self.request_access_token(app_id, app_secret).await?;
|
||||
let mut send_url =
|
||||
Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| {
|
||||
WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}"))
|
||||
})?;
|
||||
send_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("access_token", &access_token);
|
||||
|
||||
let data = request
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, WechatTemplateDataValue { value }))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let payload = json!({
|
||||
"touser": request.touser,
|
||||
"template_id": request.template_id,
|
||||
"page": request.page,
|
||||
"miniprogram_state": request.miniprogram_state,
|
||||
"lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()),
|
||||
"data": data,
|
||||
});
|
||||
let response = self
|
||||
.client
|
||||
.post(send_url.as_str())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信订阅消息请求失败");
|
||||
WechatError::RequestFailed("微信订阅消息请求失败".to_string())
|
||||
})?
|
||||
.json::<WechatSubscribeMessageResponse>()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信订阅消息响应解析失败");
|
||||
WechatError::DeserializeFailed("微信订阅消息响应非法".to_string())
|
||||
})?;
|
||||
|
||||
if response.errcode != 0 {
|
||||
return Err(WechatError::Upstream(format!(
|
||||
"微信订阅消息发送失败:{}",
|
||||
response.errmsg.unwrap_or_else(|| format!(
|
||||
"subscribeMessage.send 返回错误 {}",
|
||||
response.errcode
|
||||
))
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn request_access_token(
|
||||
&self,
|
||||
app_id: &str,
|
||||
app_secret: &str,
|
||||
) -> Result<String, WechatError> {
|
||||
let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| {
|
||||
WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}"))
|
||||
})?;
|
||||
let payload = self
|
||||
.client
|
||||
.post(url.as_str())
|
||||
.json(&json!({
|
||||
"grant_type": "client_credential",
|
||||
"appid": app_id,
|
||||
"secret": app_secret,
|
||||
"force_refresh": false,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信 stable_token 请求失败");
|
||||
WechatError::RequestFailed("微信 stable_token 请求失败".to_string())
|
||||
})?
|
||||
.json::<WechatStableAccessTokenResponse>()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信 stable_token 响应解析失败");
|
||||
WechatError::DeserializeFailed("微信 stable_token 响应非法".to_string())
|
||||
})?;
|
||||
|
||||
if let Some(errcode) = payload.errcode.filter(|value| *value != 0) {
|
||||
return Err(WechatError::Upstream(format!(
|
||||
"微信 stable_token 返回错误:{}",
|
||||
payload
|
||||
.errmsg
|
||||
.unwrap_or_else(|| format!("errcode={errcode}"))
|
||||
)));
|
||||
}
|
||||
|
||||
payload
|
||||
.access_token
|
||||
.and_then(|value| non_empty_owned(value))
|
||||
.ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl WechatError {
|
||||
pub fn kind(&self) -> WechatErrorKind {
|
||||
match self {
|
||||
Self::InvalidConfig(_) => WechatErrorKind::InvalidConfig,
|
||||
Self::RequestFailed(_) => WechatErrorKind::RequestFailed,
|
||||
Self::DeserializeFailed(_) => WechatErrorKind::DeserializeFailed,
|
||||
Self::Upstream(_) => WechatErrorKind::Upstream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WechatError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidConfig(message)
|
||||
| Self::RequestFailed(message)
|
||||
| Self::DeserializeFailed(message)
|
||||
| Self::Upstream(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for WechatError {}
|
||||
|
||||
fn non_empty(value: &str) -> Option<&str> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_owned(value: String) -> Option<String> {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
pub use pay::{
|
||||
WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayClient,
|
||||
WechatPayConfig, WechatPayError, WechatPayNotifyOrder, WechatWebOrderRequest,
|
||||
};
|
||||
pub use subscribe_message::{
|
||||
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT,
|
||||
WechatClient, WechatConfig, WechatError, WechatErrorKind, WechatSubscribeMessageRequest,
|
||||
};
|
||||
|
||||
@@ -1,38 +1,33 @@
|
||||
use std::{fs, path::Path, sync::Arc};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use aes::Aes256;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::{
|
||||
Engine as _, alphabet,
|
||||
engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
|
||||
use reqwest::header::HeaderMap;
|
||||
use ring::{
|
||||
aead,
|
||||
rand::{SecureRandom, SystemRandom},
|
||||
signature,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_contracts::runtime::{
|
||||
WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse,
|
||||
};
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use std::convert::TryInto;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
|
||||
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
|
||||
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
|
||||
@@ -61,6 +56,23 @@ const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64: GeneralPurpose = GeneralPurpose
|
||||
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WechatPayConfig {
|
||||
pub enabled: bool,
|
||||
pub provider: String,
|
||||
pub app_id: Option<String>,
|
||||
pub mch_id: Option<String>,
|
||||
pub merchant_serial_no: Option<String>,
|
||||
pub private_key_pem: Option<String>,
|
||||
pub private_key_path: Option<PathBuf>,
|
||||
pub platform_public_key_pem: Option<String>,
|
||||
pub platform_public_key_path: Option<PathBuf>,
|
||||
pub platform_serial_no: Option<String>,
|
||||
pub api_v3_key: Option<String>,
|
||||
pub notify_url: Option<String>,
|
||||
pub jsapi_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WechatPayClient {
|
||||
Disabled,
|
||||
@@ -110,19 +122,11 @@ pub struct WechatPayNotifyOrder {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct WechatVirtualPaymentNotifyOrder {
|
||||
out_trade_no: String,
|
||||
transaction_id: Option<String>,
|
||||
paid_at_micros: Option<i64>,
|
||||
event: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WechatVirtualPaymentNotifyResponse {
|
||||
#[serde(rename = "ErrCode")]
|
||||
err_code: i32,
|
||||
#[serde(rename = "ErrMsg")]
|
||||
err_msg: String,
|
||||
pub struct WechatVirtualPaymentNotifyOrder {
|
||||
pub out_trade_no: String,
|
||||
pub transaction_id: Option<String>,
|
||||
pub paid_at_micros: Option<i64>,
|
||||
pub event: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -276,30 +280,30 @@ struct WechatVirtualPaymentNotifyPayInfo {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct WechatMiniProgramMessagePushQuery {
|
||||
signature: Option<String>,
|
||||
timestamp: Option<String>,
|
||||
nonce: Option<String>,
|
||||
echostr: Option<String>,
|
||||
msg_signature: Option<String>,
|
||||
pub struct WechatMiniProgramMessagePushQuery {
|
||||
pub signature: Option<String>,
|
||||
pub timestamp: Option<String>,
|
||||
pub nonce: Option<String>,
|
||||
pub echostr: Option<String>,
|
||||
pub msg_signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatMiniProgramEncryptedMessage {
|
||||
pub struct WechatMiniProgramEncryptedMessage {
|
||||
#[serde(rename = "ToUserName", alias = "to_user_name", default)]
|
||||
_to_user_name: Option<String>,
|
||||
#[serde(rename = "Encrypt", alias = "encrypt")]
|
||||
encrypt: String,
|
||||
pub encrypt: String,
|
||||
}
|
||||
|
||||
impl WechatPayClient {
|
||||
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||||
if !config.wechat_pay_enabled {
|
||||
pub fn from_config(config: &WechatPayConfig) -> Result<Self, WechatPayError> {
|
||||
if !config.enabled {
|
||||
return Ok(Self::Disabled);
|
||||
}
|
||||
|
||||
if config
|
||||
.wechat_pay_provider
|
||||
.provider
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_MOCK)
|
||||
{
|
||||
@@ -307,7 +311,7 @@ impl WechatPayClient {
|
||||
}
|
||||
|
||||
if !config
|
||||
.wechat_pay_provider
|
||||
.provider
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_REAL)
|
||||
{
|
||||
@@ -317,52 +321,43 @@ impl WechatPayClient {
|
||||
}
|
||||
|
||||
let app_id = config
|
||||
.wechat_mini_program_app_id
|
||||
.app_id
|
||||
.as_ref()
|
||||
.or(config.wechat_app_id.as_ref())
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| WechatPayError::InvalidConfig("微信支付缺少小程序 AppID".to_string()))?
|
||||
.to_string();
|
||||
let mch_id = required_config(config.wechat_pay_mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?;
|
||||
let mch_id = required_config(config.mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?;
|
||||
let merchant_serial_no = required_config(
|
||||
config.wechat_pay_merchant_serial_no.as_deref(),
|
||||
config.merchant_serial_no.as_deref(),
|
||||
"WECHAT_PAY_MERCHANT_SERIAL_NO",
|
||||
)?;
|
||||
let private_key_pem = read_private_key_pem(
|
||||
config.wechat_pay_private_key_pem.as_deref(),
|
||||
config.wechat_pay_private_key_path.as_deref(),
|
||||
config.private_key_pem.as_deref(),
|
||||
config.private_key_path.as_deref(),
|
||||
)?;
|
||||
let private_key = Arc::new(parse_rsa_private_key(&private_key_pem)?);
|
||||
let platform_public_key_pem = read_pem(
|
||||
config.wechat_pay_platform_public_key_pem.as_deref(),
|
||||
config.wechat_pay_platform_public_key_path.as_deref(),
|
||||
config.platform_public_key_pem.as_deref(),
|
||||
config.platform_public_key_path.as_deref(),
|
||||
"WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH 未配置",
|
||||
"读取微信支付平台公钥失败",
|
||||
)?;
|
||||
let platform_public_key_der = parse_public_key_pem(&platform_public_key_pem)?;
|
||||
let platform_serial_no = required_config(
|
||||
config.wechat_pay_platform_serial_no.as_deref(),
|
||||
config.platform_serial_no.as_deref(),
|
||||
"WECHAT_PAY_PLATFORM_SERIAL_NO",
|
||||
)?;
|
||||
let api_v3_key = required_config(
|
||||
config.wechat_pay_api_v3_key.as_deref(),
|
||||
"WECHAT_PAY_API_V3_KEY",
|
||||
)?;
|
||||
let api_v3_key = required_config(config.api_v3_key.as_deref(), "WECHAT_PAY_API_V3_KEY")?;
|
||||
if api_v3_key.as_bytes().len() != 32 {
|
||||
return Err(WechatPayError::InvalidConfig(
|
||||
"WECHAT_PAY_API_V3_KEY 必须是 32 字节字符串".to_string(),
|
||||
));
|
||||
}
|
||||
let notify_url = required_config(
|
||||
config.wechat_pay_notify_url.as_deref(),
|
||||
"WECHAT_PAY_NOTIFY_URL",
|
||||
)?;
|
||||
let notify_url = required_config(config.notify_url.as_deref(), "WECHAT_PAY_NOTIFY_URL")?;
|
||||
validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?;
|
||||
let jsapi_endpoint = normalize_required_url(
|
||||
&config.wechat_pay_jsapi_endpoint,
|
||||
"WECHAT_PAY_JSAPI_ENDPOINT",
|
||||
)?;
|
||||
let jsapi_endpoint =
|
||||
normalize_required_url(&config.jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT")?;
|
||||
let h5_endpoint =
|
||||
resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_H5_PATH)?;
|
||||
let native_endpoint =
|
||||
@@ -833,293 +828,97 @@ impl RealWechatPayClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_pay_notify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let notify = state
|
||||
.wechat_pay_client()
|
||||
.parse_notify(&headers, &body)
|
||||
.map_err(map_wechat_pay_notify_error)?;
|
||||
if notify.trade_state != "SUCCESS" {
|
||||
info!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
trade_state = notify.trade_state.as_str(),
|
||||
"收到非成功微信支付通知"
|
||||
);
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
let paid_at_micros = notify
|
||||
.success_time
|
||||
.as_deref()
|
||||
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
|
||||
.map(offset_datetime_to_unix_micros)
|
||||
.unwrap_or_else(current_unix_micros);
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
notify.out_trade_no.clone(),
|
||||
paid_at_micros,
|
||||
notify.transaction_id.clone(),
|
||||
fn with_wechat_pay_json_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
builder
|
||||
.header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER)
|
||||
.header(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
WECHAT_PAY_CONTENT_TYPE_HEADER,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("确认微信支付订单失败:{error}"))
|
||||
})?;
|
||||
info!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"微信支付通知已确认订单入账"
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
.header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT)
|
||||
.header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no)
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_virtual_payment_message_push_verify(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||
) -> Response {
|
||||
let token = match read_wechat_message_push_config(
|
||||
state.config.wechat_mini_program_message_token.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||
};
|
||||
let aes_key = match read_wechat_message_push_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_message_encoding_aes_key
|
||||
.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return build_wechat_message_push_verify_error_response(error),
|
||||
};
|
||||
match resolve_wechat_message_push_verify_response(
|
||||
token,
|
||||
aes_key,
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_app_id
|
||||
.as_deref()
|
||||
.or(state.config.wechat_app_id.as_deref()),
|
||||
&query,
|
||||
) {
|
||||
Ok(plaintext) => (StatusCode::OK, plaintext).into_response(),
|
||||
Err(error) => build_wechat_message_push_verify_error_response(error),
|
||||
fn with_wechat_pay_jsapi_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
with_wechat_pay_json_headers(builder, platform_serial_no)
|
||||
}
|
||||
|
||||
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
|
||||
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||
let nonce_str = "mock-nonce".to_string();
|
||||
let package = format!("prepay_id=mock-{order_id}");
|
||||
let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes());
|
||||
|
||||
WechatMiniProgramPayParamsResponse {
|
||||
time_stamp,
|
||||
nonce_str,
|
||||
package,
|
||||
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
|
||||
pay_sign,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_wechat_virtual_payment_notify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WechatMiniProgramMessagePushQuery>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let response_format = detect_virtual_payment_notify_response_format(&headers, &body);
|
||||
let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let token = match read_wechat_message_push_config(
|
||||
state.config.wechat_mini_program_message_token.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_TOKEN",
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let aes_key = match read_wechat_message_push_config(
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_message_encoding_aes_key
|
||||
.as_deref(),
|
||||
"WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY",
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let signature = query
|
||||
.msg_signature
|
||||
.as_deref()
|
||||
.or(query.signature.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("");
|
||||
let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or("");
|
||||
let nonce = query.nonce.as_deref().map(str::trim).unwrap_or("");
|
||||
if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() {
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
if !verify_wechat_message_push_signature(
|
||||
token,
|
||||
timestamp,
|
||||
nonce,
|
||||
encrypted_payload.encrypt.as_str(),
|
||||
signature,
|
||||
) {
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
let notify_body = match decrypt_wechat_message_push_ciphertext(
|
||||
aes_key,
|
||||
encrypted_payload.encrypt.as_str(),
|
||||
state
|
||||
.config
|
||||
.wechat_mini_program_app_id
|
||||
.as_deref()
|
||||
.or(state.config.wechat_app_id.as_deref()),
|
||||
) {
|
||||
Ok(body) => body,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) {
|
||||
Ok(notify) => notify,
|
||||
Err(error) => return build_virtual_payment_notify_error_response(error, response_format),
|
||||
};
|
||||
if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" {
|
||||
info!(
|
||||
event = notify.event.as_str(),
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"收到非订单入账虚拟支付推送"
|
||||
);
|
||||
return build_virtual_payment_notify_success_response(response_format);
|
||||
}
|
||||
|
||||
let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros);
|
||||
if state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
notify.out_trade_no.clone(),
|
||||
paid_at_micros,
|
||||
notify.transaction_id.clone(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"确认微信虚拟支付订单失败"
|
||||
);
|
||||
return build_virtual_payment_notify_error_response(
|
||||
WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()),
|
||||
response_format,
|
||||
);
|
||||
}
|
||||
|
||||
state.publish_profile_recharge_order_update(notify.out_trade_no.clone());
|
||||
|
||||
info!(
|
||||
event = notify.event.as_str(),
|
||||
order_id = notify.out_trade_no.as_str(),
|
||||
"微信虚拟支付推送已确认订单入账"
|
||||
);
|
||||
|
||||
build_virtual_payment_notify_success_response(response_format)
|
||||
}
|
||||
|
||||
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
match error {
|
||||
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("微信支付暂未启用")
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidConfig(message) => {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" }))
|
||||
}
|
||||
WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature(message) => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("微信支付通知签名无效")
|
||||
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
|
||||
}
|
||||
fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse {
|
||||
WechatH5PaymentResponse {
|
||||
h5_url: format!(
|
||||
"https://mock.wechat-pay.local/h5?out_trade_no={}",
|
||||
urlencoding::encode(order_id)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError {
|
||||
crate::state::AppStateInitError::WechatPay(error.to_string())
|
||||
}
|
||||
|
||||
pub fn build_wechat_payment_request(
|
||||
order_id: String,
|
||||
product_title: String,
|
||||
amount_cents: u64,
|
||||
payer_openid: String,
|
||||
) -> WechatMiniProgramOrderRequest {
|
||||
WechatMiniProgramOrderRequest {
|
||||
order_id,
|
||||
description: format!("陶泥儿 - {product_title}"),
|
||||
amount_cents,
|
||||
payer_openid,
|
||||
fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse {
|
||||
WechatNativePaymentResponse {
|
||||
code_url: format!(
|
||||
"weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}",
|
||||
hex_sha256(order_id.as_bytes())
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_wechat_web_payment_request(
|
||||
order_id: String,
|
||||
product_title: String,
|
||||
amount_cents: u64,
|
||||
payer_client_ip: String,
|
||||
) -> WechatWebOrderRequest {
|
||||
WechatWebOrderRequest {
|
||||
order_id,
|
||||
description: format!("陶泥儿 - {product_title}"),
|
||||
amount_cents,
|
||||
payer_client_ip,
|
||||
}
|
||||
fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||
let value = serde_json::from_slice::<Value>(body).map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}"))
|
||||
})?;
|
||||
Ok(WechatPayNotifyOrder {
|
||||
out_trade_no: value
|
||||
.get("outTradeNo")
|
||||
.or_else(|| value.get("out_trade_no"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string())
|
||||
})?
|
||||
.to_string(),
|
||||
transaction_id: value
|
||||
.get("transactionId")
|
||||
.or_else(|| value.get("transaction_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
trade_state: value
|
||||
.get("tradeState")
|
||||
.or_else(|| value.get("trade_state"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("SUCCESS")
|
||||
.to_string(),
|
||||
success_time: value
|
||||
.get("successTime")
|
||||
.or_else(|| value.get("success_time"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current_unix_micros() -> i64 {
|
||||
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
i64::try_from(value).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||||
warn!(error = %error, "微信支付通知处理失败");
|
||||
map_wechat_pay_error(error)
|
||||
}
|
||||
|
||||
fn read_wechat_message_push_config<'a>(
|
||||
value: Option<&'a str>,
|
||||
key: &str,
|
||||
) -> Result<&'a str, WechatPayError> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
|
||||
}
|
||||
|
||||
fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response {
|
||||
let message = match error {
|
||||
WechatPayError::Disabled => "微信消息推送暂未启用".to_string(),
|
||||
WechatPayError::InvalidConfig(message)
|
||||
| WechatPayError::InvalidRequest(message)
|
||||
| WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message)
|
||||
| WechatPayError::InvalidSignature(message) => message,
|
||||
};
|
||||
(StatusCode::BAD_REQUEST, message).into_response()
|
||||
}
|
||||
|
||||
fn resolve_wechat_message_push_verify_response(
|
||||
pub fn resolve_wechat_message_push_verify_response(
|
||||
token: &str,
|
||||
aes_key: &str,
|
||||
expected_app_id: Option<&str>,
|
||||
@@ -1161,7 +960,7 @@ fn resolve_wechat_message_push_verify_response(
|
||||
Ok(echostr.to_string())
|
||||
}
|
||||
|
||||
fn parse_wechat_mini_program_message_push_payload(
|
||||
pub fn parse_wechat_mini_program_message_push_payload(
|
||||
body: &[u8],
|
||||
) -> Result<WechatMiniProgramEncryptedMessage, WechatPayError> {
|
||||
serde_json::from_slice(body).map_err(|error| {
|
||||
@@ -1169,7 +968,7 @@ fn parse_wechat_mini_program_message_push_payload(
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_wechat_message_push_signature(
|
||||
pub fn verify_wechat_message_push_signature(
|
||||
token: &str,
|
||||
timestamp: &str,
|
||||
nonce: &str,
|
||||
@@ -1184,7 +983,7 @@ fn verify_wechat_message_push_signature(
|
||||
expected.eq_ignore_ascii_case(signature)
|
||||
}
|
||||
|
||||
fn decrypt_wechat_message_push_ciphertext(
|
||||
pub fn decrypt_wechat_message_push_ciphertext(
|
||||
encoding_aes_key: &str,
|
||||
ciphertext: &str,
|
||||
expected_app_id: Option<&str>,
|
||||
@@ -1302,7 +1101,7 @@ fn parse_wechat_message_push_plaintext(
|
||||
Ok(WechatMessagePushPlaintext { message, app_id })
|
||||
}
|
||||
|
||||
fn parse_virtual_payment_notify(
|
||||
pub fn parse_virtual_payment_notify(
|
||||
body: &[u8],
|
||||
) -> Result<WechatVirtualPaymentNotifyOrder, WechatPayError> {
|
||||
if let Ok(notify) = serde_json::from_slice::<WechatVirtualPaymentNotifyBody>(body) {
|
||||
@@ -1402,184 +1201,6 @@ fn trim_virtual_payment_text_value(value: &str) -> String {
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_error_response(
|
||||
error: WechatPayError,
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
) -> Response {
|
||||
warn!(error = %error, "微信虚拟支付通知处理失败");
|
||||
let message = match error {
|
||||
WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(),
|
||||
WechatPayError::InvalidConfig(message)
|
||||
| WechatPayError::InvalidRequest(message)
|
||||
| WechatPayError::RequestFailed(message)
|
||||
| WechatPayError::Upstream(message)
|
||||
| WechatPayError::Deserialize(message)
|
||||
| WechatPayError::Crypto(message)
|
||||
| WechatPayError::InvalidSignature(message) => message,
|
||||
};
|
||||
build_virtual_payment_notify_response(response_format, 1, message)
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_success_response(
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
) -> Response {
|
||||
build_virtual_payment_notify_response(response_format, 0, "success")
|
||||
}
|
||||
|
||||
fn build_virtual_payment_notify_response(
|
||||
response_format: VirtualPaymentNotifyResponseFormat,
|
||||
err_code: i32,
|
||||
err_msg: impl Into<String>,
|
||||
) -> Response {
|
||||
let err_msg = err_msg.into();
|
||||
match response_format {
|
||||
VirtualPaymentNotifyResponseFormat::Json => Json(
|
||||
build_wechat_virtual_payment_notify_response(err_code, err_msg),
|
||||
)
|
||||
.into_response(),
|
||||
VirtualPaymentNotifyResponseFormat::Xml => {
|
||||
let body = format!(
|
||||
"<xml><ErrCode>{err_code}</ErrCode><ErrMsg><![CDATA[{err_msg}]]></ErrMsg></xml>"
|
||||
);
|
||||
let mut response = (StatusCode::OK, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/xml; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_wechat_pay_json_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
builder
|
||||
.header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER)
|
||||
.header(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
WECHAT_PAY_CONTENT_TYPE_HEADER,
|
||||
)
|
||||
.header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT)
|
||||
.header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no)
|
||||
}
|
||||
|
||||
fn with_wechat_pay_jsapi_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
with_wechat_pay_json_headers(builder, platform_serial_no)
|
||||
}
|
||||
|
||||
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
|
||||
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||
let nonce_str = "mock-nonce".to_string();
|
||||
let package = format!("prepay_id=mock-{order_id}");
|
||||
let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes());
|
||||
|
||||
WechatMiniProgramPayParamsResponse {
|
||||
time_stamp,
|
||||
nonce_str,
|
||||
package,
|
||||
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
|
||||
pay_sign,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse {
|
||||
WechatH5PaymentResponse {
|
||||
h5_url: format!(
|
||||
"https://mock.wechat-pay.local/h5?out_trade_no={}",
|
||||
urlencoding::encode(order_id)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse {
|
||||
WechatNativePaymentResponse {
|
||||
code_url: format!(
|
||||
"weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}",
|
||||
hex_sha256(order_id.as_bytes())
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||
let value = serde_json::from_slice::<Value>(body).map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}"))
|
||||
})?;
|
||||
Ok(WechatPayNotifyOrder {
|
||||
out_trade_no: value
|
||||
.get("outTradeNo")
|
||||
.or_else(|| value.get("out_trade_no"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string())
|
||||
})?
|
||||
.to_string(),
|
||||
transaction_id: value
|
||||
.get("transactionId")
|
||||
.or_else(|| value.get("transaction_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
trade_state: value
|
||||
.get("tradeState")
|
||||
.or_else(|| value.get("trade_state"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("SUCCESS")
|
||||
.to_string(),
|
||||
success_time: value
|
||||
.get("successTime")
|
||||
.or_else(|| value.get("success_time"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_wechat_virtual_payment_notify_response(
|
||||
err_code: i32,
|
||||
err_msg: impl Into<String>,
|
||||
) -> WechatVirtualPaymentNotifyResponse {
|
||||
WechatVirtualPaymentNotifyResponse {
|
||||
err_code,
|
||||
err_msg: err_msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum VirtualPaymentNotifyResponseFormat {
|
||||
Json,
|
||||
Xml,
|
||||
}
|
||||
|
||||
fn detect_virtual_payment_notify_response_format(
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> VirtualPaymentNotifyResponseFormat {
|
||||
let content_type = headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if content_type.contains("xml") {
|
||||
return VirtualPaymentNotifyResponseFormat::Xml;
|
||||
}
|
||||
let body_trimmed = body
|
||||
.iter()
|
||||
.copied()
|
||||
.skip_while(|byte| byte.is_ascii_whitespace())
|
||||
.next();
|
||||
match body_trimmed {
|
||||
Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml,
|
||||
_ => VirtualPaymentNotifyResponseFormat::Json,
|
||||
}
|
||||
}
|
||||
|
||||
fn required_config(value: Option<&str>, key: &str) -> Result<String, WechatPayError> {
|
||||
value
|
||||
.map(str::trim)
|
||||
@@ -1946,6 +1567,7 @@ impl std::error::Error for WechatPayError {}
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn mock_pay_params_use_request_payment_shape() {
|
||||
234
server-rs/crates/platform-wechat/src/subscribe_message.rs
Normal file
234
server-rs/crates/platform-wechat/src/subscribe_message.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use std::{collections::BTreeMap, error::Error, fmt};
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str =
|
||||
"https://api.weixin.qq.com/cgi-bin/stable_token";
|
||||
pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str =
|
||||
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WechatConfig {
|
||||
pub app_id: Option<String>,
|
||||
pub app_secret: Option<String>,
|
||||
pub stable_access_token_endpoint: String,
|
||||
pub subscribe_message_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WechatClient {
|
||||
client: Client,
|
||||
config: WechatConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WechatSubscribeMessageRequest {
|
||||
pub touser: String,
|
||||
pub template_id: String,
|
||||
pub page: Option<String>,
|
||||
pub miniprogram_state: Option<String>,
|
||||
pub lang: Option<String>,
|
||||
pub data: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum WechatError {
|
||||
InvalidConfig(String),
|
||||
RequestFailed(String),
|
||||
DeserializeFailed(String),
|
||||
Upstream(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WechatErrorKind {
|
||||
InvalidConfig,
|
||||
RequestFailed,
|
||||
DeserializeFailed,
|
||||
Upstream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatStableAccessTokenResponse {
|
||||
access_token: Option<String>,
|
||||
errcode: Option<i64>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatSubscribeMessageResponse {
|
||||
errcode: i64,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WechatTemplateDataValue {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl WechatClient {
|
||||
pub fn new(config: WechatConfig) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_subscribe_message(
|
||||
&self,
|
||||
request: WechatSubscribeMessageRequest,
|
||||
) -> Result<(), WechatError> {
|
||||
let app_id = self
|
||||
.config
|
||||
.app_id
|
||||
.as_deref()
|
||||
.and_then(non_empty)
|
||||
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?;
|
||||
let app_secret = self
|
||||
.config
|
||||
.app_secret
|
||||
.as_deref()
|
||||
.and_then(non_empty)
|
||||
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?;
|
||||
|
||||
let access_token = self.request_access_token(app_id, app_secret).await?;
|
||||
let mut send_url =
|
||||
Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| {
|
||||
WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}"))
|
||||
})?;
|
||||
send_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("access_token", &access_token);
|
||||
|
||||
let data = request
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, WechatTemplateDataValue { value }))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let payload = json!({
|
||||
"touser": request.touser,
|
||||
"template_id": request.template_id,
|
||||
"page": request.page,
|
||||
"miniprogram_state": request.miniprogram_state,
|
||||
"lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()),
|
||||
"data": data,
|
||||
});
|
||||
let response = self
|
||||
.client
|
||||
.post(send_url.as_str())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信订阅消息请求失败");
|
||||
WechatError::RequestFailed("微信订阅消息请求失败".to_string())
|
||||
})?
|
||||
.json::<WechatSubscribeMessageResponse>()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信订阅消息响应解析失败");
|
||||
WechatError::DeserializeFailed("微信订阅消息响应非法".to_string())
|
||||
})?;
|
||||
|
||||
if response.errcode != 0 {
|
||||
return Err(WechatError::Upstream(format!(
|
||||
"微信订阅消息发送失败:{}",
|
||||
response.errmsg.unwrap_or_else(|| format!(
|
||||
"subscribeMessage.send 返回错误 {}",
|
||||
response.errcode
|
||||
))
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn request_access_token(
|
||||
&self,
|
||||
app_id: &str,
|
||||
app_secret: &str,
|
||||
) -> Result<String, WechatError> {
|
||||
let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| {
|
||||
WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}"))
|
||||
})?;
|
||||
let payload = self
|
||||
.client
|
||||
.post(url.as_str())
|
||||
.json(&json!({
|
||||
"grant_type": "client_credential",
|
||||
"appid": app_id,
|
||||
"secret": app_secret,
|
||||
"force_refresh": false,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信 stable_token 请求失败");
|
||||
WechatError::RequestFailed("微信 stable_token 请求失败".to_string())
|
||||
})?
|
||||
.json::<WechatStableAccessTokenResponse>()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(error = %error, "微信 stable_token 响应解析失败");
|
||||
WechatError::DeserializeFailed("微信 stable_token 响应非法".to_string())
|
||||
})?;
|
||||
|
||||
if let Some(errcode) = payload.errcode.filter(|value| *value != 0) {
|
||||
return Err(WechatError::Upstream(format!(
|
||||
"微信 stable_token 返回错误:{}",
|
||||
payload
|
||||
.errmsg
|
||||
.unwrap_or_else(|| format!("errcode={errcode}"))
|
||||
)));
|
||||
}
|
||||
|
||||
payload
|
||||
.access_token
|
||||
.and_then(|value| non_empty_owned(value))
|
||||
.ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl WechatError {
|
||||
pub fn kind(&self) -> WechatErrorKind {
|
||||
match self {
|
||||
Self::InvalidConfig(_) => WechatErrorKind::InvalidConfig,
|
||||
Self::RequestFailed(_) => WechatErrorKind::RequestFailed,
|
||||
Self::DeserializeFailed(_) => WechatErrorKind::DeserializeFailed,
|
||||
Self::Upstream(_) => WechatErrorKind::Upstream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WechatError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidConfig(message)
|
||||
| Self::RequestFailed(message)
|
||||
| Self::DeserializeFailed(message)
|
||||
| Self::Upstream(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for WechatError {}
|
||||
|
||||
fn non_empty(value: &str) -> Option<&str> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_owned(value: String) -> Option<String> {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user