From 088470a31510b24e20b6c4831ac1b0b8fa4f2927 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:05:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=BE=AE=E4=BF=A1=E9=A2=86?= =?UTF-8?q?=E5=9F=9F=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 api-server 微信 HTTP/BFF 适配统一迁移到 wechat 目录。 将微信支付和虚拟支付消息协议细节下沉到 platform-wechat。 拆分 platform-wechat 的订阅消息与支付模块并补齐依赖。 修正微信相关测试的用户 ID 夹具并同步后端架构文档。 --- .hermes/shared-memory/decision-log.md | 8 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +- ...【技术方案】微信虚拟支付接入-2026-05-26.md | 4 +- server-rs/Cargo.lock | 10 + server-rs/crates/api-server/src/app.rs | 8 +- server-rs/crates/api-server/src/auth_me.rs | 2 +- server-rs/crates/api-server/src/config.rs | 2 +- .../crates/api-server/src/custom_world.rs | 10 +- .../crates/api-server/src/custom_world_ai.rs | 2 +- .../api-server/src/generated_asset_sheets.rs | 4 +- server-rs/crates/api-server/src/jump_hop.rs | 19 +- .../crates/api-server/src/login_options.rs | 2 +- server-rs/crates/api-server/src/main.rs | 5 +- server-rs/crates/api-server/src/match3d.rs | 4 +- .../crates/api-server/src/modules/auth.rs | 4 +- .../crates/api-server/src/modules/jump_hop.rs | 8 +- .../api-server/src/modules/wooden_fish.rs | 10 +- .../crates/api-server/src/platform_errors.rs | 2 +- server-rs/crates/api-server/src/puzzle.rs | 4 +- .../api-server/src/puzzle_gallery_cache.rs | 4 +- .../crates/api-server/src/runtime_profile.rs | 27 +- .../crates/api-server/src/square_hole.rs | 17 +- server-rs/crates/api-server/src/state.rs | 10 +- .../crates/api-server/src/visual_novel.rs | 21 +- server-rs/crates/api-server/src/wechat.rs | 4 + .../src/{wechat_auth.rs => wechat/auth.rs} | 2 +- server-rs/crates/api-server/src/wechat/pay.rs | 423 ++++++++++++ .../provider.rs} | 2 +- .../subscribe_message.rs} | 0 .../crates/api-server/src/wooden_fish.rs | 4 +- server-rs/crates/platform-wechat/Cargo.toml | 10 + server-rs/crates/platform-wechat/src/lib.rs | 243 +------ .../src/pay.rs} | 650 ++++-------------- .../platform-wechat/src/subscribe_message.rs | 234 +++++++ 34 files changed, 925 insertions(+), 837 deletions(-) create mode 100644 server-rs/crates/api-server/src/wechat.rs rename server-rs/crates/api-server/src/{wechat_auth.rs => wechat/auth.rs} (99%) create mode 100644 server-rs/crates/api-server/src/wechat/pay.rs rename server-rs/crates/api-server/src/{wechat_provider.rs => wechat/provider.rs} (98%) rename server-rs/crates/api-server/src/{wechat_subscribe_message.rs => wechat/subscribe_message.rs} (100%) rename server-rs/crates/{api-server/src/wechat_pay.rs => platform-wechat/src/pay.rs} (81%) create mode 100644 server-rs/crates/platform-wechat/src/subscribe_message.rs diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 8f2850a5..0cbdb334 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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、黑底闪动,或切卡后反向回弹。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index aa61d3c8..ed2a1676 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 构造。 验证: diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index b91c8647..61057acd 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -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` diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 1f661268..f285faaf 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -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]] diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 05f4f6d1..759fa842 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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!( diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs index bab8c434..32cdc1a0 100644 --- a/server-rs/crates/api-server/src/auth_me.rs +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Json, extract::{Extension, State}, http::StatusCode, diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 6ca7896d..e9f6ec68 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -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, diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index cd6d3240..634ece0e 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -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}; diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 74b93c70..d235f028 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -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, diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs index 5c800414..953de5a5 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -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, diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 0c08129a..f76aa730 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -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( diff --git a/server-rs/crates/api-server/src/login_options.rs b/server-rs/crates/api-server/src/login_options.rs index f71f4d51..19fe4bde 100644 --- a/server-rs/crates/api-server/src/login_options.rs +++ b/server-rs/crates/api-server/src/login_options.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Json, extract::{Extension, State}, }; diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index aaecd923..480d88db 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index a37c44a1..5517c6bd 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -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, }, diff --git a/server-rs/crates/api-server/src/modules/auth.rs b/server-rs/crates/api-server/src/modules/auth.rs index 784cdad2..5cb67df2 100644 --- a/server-rs/crates/api-server/src/modules/auth.rs +++ b/server-rs/crates/api-server/src/modules/auth.rs @@ -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, }, }; diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index 2ed65a3b..26cd3ab8 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -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, }; diff --git a/server-rs/crates/api-server/src/modules/wooden_fish.rs b/server-rs/crates/api-server/src/modules/wooden_fish.rs index e377e235..de9d5df7 100644 --- a/server-rs/crates/api-server/src/modules/wooden_fish.rs +++ b/server-rs/crates/api-server/src/modules/wooden_fish.rs @@ -24,9 +24,7 @@ pub fn router(state: AppState) -> Router { "/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 { "/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 { "/api/runtime/wooden-fish/gallery/{public_work_code}", get(get_wooden_fish_gallery_detail), ) -} \ No newline at end of file +} diff --git a/server-rs/crates/api-server/src/platform_errors.rs b/server-rs/crates/api-server/src/platform_errors.rs index 6da6f87c..792fb881 100644 --- a/server-rs/crates/api-server/src/platform_errors.rs +++ b/server-rs/crates/api-server/src/platform_errors.rs @@ -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}; diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 6aef4f88..b428f2f2 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -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, }, diff --git a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs index 4c66badb..77a6bbfc 100644 --- a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs +++ b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs @@ -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}; diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index c02efa28..65d67d45 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -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 + } } diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index d8ed28b4..dcfd3ddd 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -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 { diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index e03ca441..68aa3805 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -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 真相源。 diff --git a/server-rs/crates/api-server/src/visual_novel.rs b/server-rs/crates/api-server/src/visual_novel.rs index cc25deda..228c147b 100644 --- a/server-rs/crates/api-server/src/visual_novel.rs +++ b/server-rs/crates/api-server/src/visual_novel.rs @@ -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( diff --git a/server-rs/crates/api-server/src/wechat.rs b/server-rs/crates/api-server/src/wechat.rs new file mode 100644 index 00000000..5d17d3bd --- /dev/null +++ b/server-rs/crates/api-server/src/wechat.rs @@ -0,0 +1,4 @@ +pub(crate) mod auth; +pub(crate) mod pay; +pub(crate) mod provider; +pub(crate) mod subscribe_message; diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat/auth.rs similarity index 99% rename from server-rs/crates/api-server/src/wechat_auth.rs rename to server-rs/crates/api-server/src/wechat/auth.rs index 165dc5e7..28446f33 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Json, extract::{Extension, Query, State}, http::{HeaderMap, StatusCode}, diff --git a/server-rs/crates/api-server/src/wechat/pay.rs b/server-rs/crates/api-server/src/wechat/pay.rs new file mode 100644 index 00000000..438a439d --- /dev/null +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -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, + headers: HeaderMap, + body: Bytes, +) -> Result { + 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, + Query(query): Query, +) -> 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, + headers: HeaderMap, + Query(query): Query, + 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, +) -> 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!( + "{err_code}" + ); + 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, +) -> 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, + } +} diff --git a/server-rs/crates/api-server/src/wechat_provider.rs b/server-rs/crates/api-server/src/wechat/provider.rs similarity index 98% rename from server-rs/crates/api-server/src/wechat_provider.rs rename to server-rs/crates/api-server/src/wechat/provider.rs index 60722cb8..94c3a117 100644 --- a/server-rs/crates/api-server/src/wechat_provider.rs +++ b/server-rs/crates/api-server/src/wechat/provider.rs @@ -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, diff --git a/server-rs/crates/api-server/src/wechat_subscribe_message.rs b/server-rs/crates/api-server/src/wechat/subscribe_message.rs similarity index 100% rename from server-rs/crates/api-server/src/wechat_subscribe_message.rs rename to server-rs/crates/api-server/src/wechat/subscribe_message.rs diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index e89e8c34..a8c46668 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -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, }, diff --git a/server-rs/crates/platform-wechat/Cargo.toml b/server-rs/crates/platform-wechat/Cargo.toml index 0b0f5db4..5d1dd7b8 100644 --- a/server-rs/crates/platform-wechat/Cargo.toml +++ b/server-rs/crates/platform-wechat/Cargo.toml @@ -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 } diff --git a/server-rs/crates/platform-wechat/src/lib.rs b/server-rs/crates/platform-wechat/src/lib.rs index 0935554e..e1c4d5a5 100644 --- a/server-rs/crates/platform-wechat/src/lib.rs +++ b/server-rs/crates/platform-wechat/src/lib.rs @@ -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, - pub app_secret: Option, - 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, - pub miniprogram_state: Option, - pub lang: Option, - pub data: BTreeMap, -} - -#[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, - errcode: Option, - errmsg: Option, -} - -#[derive(Debug, Deserialize)] -struct WechatSubscribeMessageResponse { - errcode: i64, - errmsg: Option, -} - -#[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::>(); - 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::() - .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 { - 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::() - .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 { - 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, +}; diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/platform-wechat/src/pay.rs similarity index 81% rename from server-rs/crates/api-server/src/wechat_pay.rs rename to server-rs/crates/platform-wechat/src/pay.rs index 2b6cffd3..44c5aa51 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/platform-wechat/src/pay.rs @@ -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, + pub mch_id: Option, + pub merchant_serial_no: Option, + pub private_key_pem: Option, + pub private_key_path: Option, + pub platform_public_key_pem: Option, + pub platform_public_key_path: Option, + pub platform_serial_no: Option, + pub api_v3_key: Option, + pub notify_url: Option, + 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, - paid_at_micros: Option, - 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, + pub paid_at_micros: Option, + pub event: String, } #[derive(Debug)] @@ -276,30 +280,30 @@ struct WechatVirtualPaymentNotifyPayInfo { } #[derive(Debug, Deserialize)] -pub(crate) struct WechatMiniProgramMessagePushQuery { - signature: Option, - timestamp: Option, - nonce: Option, - echostr: Option, - msg_signature: Option, +pub struct WechatMiniProgramMessagePushQuery { + pub signature: Option, + pub timestamp: Option, + pub nonce: Option, + pub echostr: Option, + pub msg_signature: Option, } #[derive(Debug, Deserialize)] -struct WechatMiniProgramEncryptedMessage { +pub struct WechatMiniProgramEncryptedMessage { #[serde(rename = "ToUserName", alias = "to_user_name", default)] _to_user_name: Option, #[serde(rename = "Encrypt", alias = "encrypt")] - encrypt: String, + pub encrypt: String, } impl WechatPayClient { - pub fn from_config(config: &crate::config::AppConfig) -> Result { - if !config.wechat_pay_enabled { + pub fn from_config(config: &WechatPayConfig) -> Result { + 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, - headers: HeaderMap, - body: Bytes, -) -> Result { - 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, - Query(query): Query, -) -> 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, - headers: HeaderMap, - Query(query): Query, - 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 { + let value = serde_json::from_slice::(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 { 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 { if let Ok(notify) = serde_json::from_slice::(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, -) -> 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!( - "{err_code}" - ); - 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 { - let value = serde_json::from_slice::(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, -) -> 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 { 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() { diff --git a/server-rs/crates/platform-wechat/src/subscribe_message.rs b/server-rs/crates/platform-wechat/src/subscribe_message.rs new file mode 100644 index 00000000..0935554e --- /dev/null +++ b/server-rs/crates/platform-wechat/src/subscribe_message.rs @@ -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, + pub app_secret: Option, + 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, + pub miniprogram_state: Option, + pub lang: Option, + pub data: BTreeMap, +} + +#[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, + errcode: Option, + errmsg: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatSubscribeMessageResponse { + errcode: i64, + errmsg: Option, +} + +#[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::>(); + 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::() + .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 { + 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::() + .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 { + if value.trim().is_empty() { + None + } else { + Some(value) + } +}