diff --git a/.env.example b/.env.example index b971f513..2a1b98e2 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,8 @@ WECHAT_MOCK_USER_ID="wx-mock-user" WECHAT_MOCK_UNION_ID="wx-mock-union" WECHAT_MOCK_DISPLAY_NAME="微信旅人" WECHAT_MOCK_AVATAR_URL="" +WECHAT_MINIPROGRAM_MESSAGE_TOKEN="" +WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY="" # Model name for chat completions. VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index 98a2c115..8414dc14 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -39,3 +39,5 @@ GENARRATIVE_LLM_PROVIDER=openai-compatible GENARRATIVE_LLM_BASE_URL= GENARRATIVE_LLM_API_KEY= GENARRATIVE_LLM_MODEL= +WECHAT_MINIPROGRAM_MESSAGE_TOKEN= +WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY= diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 5dacb809..1434d8be 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -109,6 +109,8 @@ WECHAT_AUTHORIZE_ENDPOINT=https://open.weixin.qq.com/connect/qrconnect WECHAT_ACCESS_TOKEN_ENDPOINT=https://api.weixin.qq.com/sns/oauth2/access_token WECHAT_USER_INFO_ENDPOINT=https://api.weixin.qq.com/sns/userinfo WECHAT_STATE_TTL_MINUTES=15 +WECHAT_MINIPROGRAM_MESSAGE_TOKEN= +WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY= ALIYUN_OSS_BUCKET= ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index cf10599d..a886c557 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -8,7 +8,7 @@ - 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment` 的 `short_series_goods` 模式,并在 `signData` 内带 `productId` 与 `goodsPrice`。 - H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。 - `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。 -- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端微信通知或查询确认后写入订单为准。 +- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。 - 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。 ## 关键文件 @@ -30,6 +30,8 @@ WECHAT_PAY_PROVIDER=real WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID=<微信虚拟支付 offerId> WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY=<现网 AppKey> WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey,可选> +WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token> +WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey> WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 ``` @@ -40,6 +42,9 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 - `signature`:`HMAC-SHA256(session_key, signData)` 的小写 hex。 - 泥点属于微信虚拟支付代币(coin),`short_series_coin` 的 `buyQuantity` 必须使用当前泥点商品的 `points_amount`;例如 60 泥点商品应传 `buyQuantity: 60`。 - 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。 +- 微信小程序“开发者服务器接收消息推送”必须配置为安全模式,数据格式选 JSON,URL 统一指向 `/api/profile/recharge/wechat/virtual-notify`。 +- `WECHAT_MINIPROGRAM_MESSAGE_TOKEN` 和 `WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY` 由环境变量注入;后端会先校验 `signature/msg_signature`,再用 `EncodingAESKey` 解密 `Encrypt`,然后按虚拟支付事件入账。 +- 安全模式下,GET 验证会直接返回解密后的 `echostr`;POST 推送会先解密再解析 `xpay_goods_deliver_notify` / `xpay_coin_pay_notify`。 ## 验收命令 @@ -59,5 +64,6 @@ npm run check:encoding - 后台新增的会员类充值商品会直接把商品 `productId` 作为微信 `short_series_goods` 的道具 ID;例如微信后台道具 ID 为 `item01` 时,后台会员商品 `productId` 也应配置为 `item01`,且商品价格需要与微信后台道具价格一致。 - 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 `wx.requestPayment` 或 `wx.requestVirtualPayment`。 - 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。 +- 微信虚拟支付消息推送使用独立后端入口 `/api/profile/recharge/wechat/virtual-notify`,按 `xpay_goods_deliver_notify` 和 `xpay_coin_pay_notify` 推进充值订单入账;回包需按入站格式返回 `ErrCode=0` / `ErrMsg=success`(JSON 入站回 JSON,XML 入站回 XML),错误时带具体 `ErrMsg` 便于微信侧重试与排障。 - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 - Web 侧在“正在支付”状态下会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 diff --git a/miniprogram/config.js b/miniprogram/config.js index 2c24a054..f46cb39e 100644 --- a/miniprogram/config.js +++ b/miniprogram/config.js @@ -1,11 +1,11 @@ // 中文注释:这里填写已经在“小程序后台-开发-开发设置-业务域名”配置过的 H5 入口。 // 示例:https://game.example.com/ // 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。 -const WEB_VIEW_ENTRY_URL = 'https://www.genarrative.world/'; +const WEB_VIEW_ENTRY_URL = 'https://outfield-outlook-reprocess.ngrok-free.dev'; // 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。 // 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。 -const API_BASE_URL = 'https://www.genarrative.world/'; +const API_BASE_URL = 'https://outfield-outlook-reprocess.ngrok-free.dev'; // 中文注释:这里填写微信小程序 AppID,用于后端记录会话来源;project.config.json 里的 appid 也要保持一致。 const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65'; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index becd7daa..e730ce64 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -17,6 +17,17 @@ dependencies = [ "pom", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -78,12 +89,15 @@ checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25" name = "api-server" version = "0.1.0" dependencies = [ + "aes", "async-stream", "axum", "base64 0.22.1", "bytes", + "cbc", "dotenvy", "futures-util", + "hex", "hmac", "http-body-util", "image", @@ -118,6 +132,7 @@ dependencies = [ "ring", "serde", "serde_json", + "sha1", "sha2", "shared-contracts", "shared-kernel", @@ -351,6 +366,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "brotli" version = "3.5.0" @@ -415,6 +439,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.60" @@ -453,6 +486,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "console" version = "0.16.3" @@ -1461,6 +1504,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "insta" version = "1.47.2" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 84e0da98..90394758 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -89,16 +89,19 @@ shared-logging = { path = "crates/shared-logging", default-features = false } spacetime-client = { path = "crates/spacetime-client", default-features = false } argon2 = "0.5" +aes = "0.8" async-stream = "0.3" async-trait = "0.1" axum = "0.8" base64 = "0.22" +cbc = { version = "0.1", features = ["alloc"] } bytes = "1" dotenvy = "0.15" flate2 = "1" futures-util = "0.3" hmac = "0.12" http-body-util = "0.1" +hex = "0.4" image = { version = "0.25", default-features = false } jsonwebtoken = "9" langchainrust = "0.2.18" @@ -109,6 +112,7 @@ ring = "0.17" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" +sha1 = "0.10" sha2 = "0.10" socket2 = "0.6" spacetimedb = "2.2.0" diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index f295d8c3..c07bdcf4 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -5,11 +5,14 @@ version.workspace = true license.workspace = true [dependencies] +aes = { workspace = true } async-stream = { workspace = true } axum = { workspace = true, features = ["ws"] } base64 = { workspace = true } +cbc = { workspace = true } bytes = { workspace = true } dotenvy = { workspace = true } +hex = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } http-body-util = { workspace = true } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } @@ -44,6 +47,7 @@ hmac = { workspace = true } ring = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha1 = { workspace = true } sha2 = { workspace = true } shared-contracts = { workspace = true, features = ["oss-contracts"] } shared-kernel = { workspace = true } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 142d4725..e3f92246 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -41,7 +41,10 @@ use crate::{ start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message, submit_visual_novel_message, update_visual_novel_work, }, - wechat_pay::handle_wechat_pay_notify, + wechat_pay::{ + handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify, + handle_wechat_virtual_payment_notify, + }, }; // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 @@ -70,6 +73,11 @@ pub fn build_router(state: AppState) -> Router { "/api/profile/recharge/wechat/notify", post(handle_wechat_pay_notify), ) + .route( + "/api/profile/recharge/wechat/virtual-notify", + get(handle_wechat_virtual_payment_message_push_verify) + .post(handle_wechat_virtual_payment_notify), + ) .route( "/api/runtime/sessions/{runtime_session_id}/inventory", get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index 5aa1028d..08adcaa2 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -94,13 +94,11 @@ pub async fn generate_character_visual( .map_err(|error| character_visual_error_response(&request_context, error))?; let result = async { - let settings = require_openai_image_settings(&state)? - .with_external_api_audit_context( - &request_context, - Some(owner_user_id.clone()), - Some(character_id.clone()), - ) - ; + let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( + &request_context, + Some(owner_user_id.clone()), + Some(character_id.clone()), + ); let http_client = build_openai_image_http_client(&settings)?; state @@ -324,10 +322,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile( &model, &prompt, )?; - let settings = require_openai_image_settings(state)?.with_external_api_audit_actor( - Some(owner_user_id.to_string()), - Some(character_id.clone()), - ); + let settings = require_openai_image_settings(state)? + .with_external_api_audit_actor(Some(owner_user_id.to_string()), Some(character_id.clone())); let http_client = build_openai_image_http_client(&settings)?; state .ai_task_service() diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 63937e26..263c7556 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -97,6 +97,8 @@ pub struct AppConfig { pub wechat_mini_program_virtual_payment_offer_id: Option, pub wechat_mini_program_virtual_payment_app_key: Option, pub wechat_mini_program_virtual_payment_sandbox_app_key: Option, + pub wechat_mini_program_message_token: Option, + pub wechat_mini_program_message_encoding_aes_key: Option, pub wechat_mini_program_virtual_payment_env: u8, pub oss_bucket: Option, pub oss_endpoint: Option, @@ -244,6 +246,8 @@ impl Default for AppConfig { wechat_mini_program_virtual_payment_offer_id: None, wechat_mini_program_virtual_payment_app_key: None, wechat_mini_program_virtual_payment_sandbox_app_key: None, + wechat_mini_program_message_token: None, + wechat_mini_program_message_encoding_aes_key: None, wechat_mini_program_virtual_payment_env: 0, oss_bucket: None, oss_endpoint: None, @@ -598,8 +602,11 @@ impl AppConfig { read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"]); config.wechat_mini_program_virtual_payment_sandbox_app_key = read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"]); - if let Some(env) = - read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"]) + config.wechat_mini_program_message_token = + read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]); + config.wechat_mini_program_message_encoding_aes_key = + read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]); + if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"]) && env <= 1 { config.wechat_mini_program_virtual_payment_env = env; @@ -1396,6 +1403,8 @@ mod tests { std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); + std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); + std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"); std::env::set_var("WECHAT_PAY_ENABLED", "true"); std::env::set_var("WECHAT_PAY_PROVIDER", "real"); @@ -1412,18 +1421,17 @@ mod tests { "WECHAT_PAY_NOTIFY_URL", "https://api.example.com/api/profile/recharge/wechat/notify", ); - std::env::set_var( - "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID", - "offer-001", - ); - std::env::set_var( - "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY", - "app-key-001", - ); + std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID", "offer-001"); + std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY", "app-key-001"); std::env::set_var( "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY", "sandbox-app-key-001", ); + std::env::set_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN", "message-token-001"); + std::env::set_var( + "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", + "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", + ); std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1"); } @@ -1448,13 +1456,27 @@ mod tests { Some("platform-serial-001") ); assert_eq!( - config.wechat_mini_program_virtual_payment_offer_id.as_deref(), + config + .wechat_mini_program_virtual_payment_offer_id + .as_deref(), Some("offer-001") ); assert_eq!( - config.wechat_mini_program_virtual_payment_app_key.as_deref(), + config + .wechat_mini_program_virtual_payment_app_key + .as_deref(), Some("app-key-001") ); + assert_eq!( + config.wechat_mini_program_message_token.as_deref(), + Some("message-token-001") + ); + assert_eq!( + config + .wechat_mini_program_message_encoding_aes_key + .as_deref(), + Some("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") + ); assert_eq!( config .wechat_mini_program_virtual_payment_sandbox_app_key @@ -1476,6 +1498,8 @@ mod tests { std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); + std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); + std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"); } } 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 0aa81311..932f5099 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -553,12 +553,11 @@ pub async fn generate_custom_world_scene_image( "scene_image", asset_id.as_str(), async { - let settings = require_openai_image_settings(&state)? - .with_external_api_audit_context( - &request_context, - Some(owner_user_id.to_string()), - normalized.profile_id.clone(), - ); + let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( + &request_context, + Some(owner_user_id.to_string()), + normalized.profile_id.clone(), + ); let http_client = build_openai_image_http_client(&settings)?; let reference_image = if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { diff --git a/server-rs/crates/api-server/src/edutainment_baby_object.rs b/server-rs/crates/api-server/src/edutainment_baby_object.rs index dda85bf5..abfb5349 100644 --- a/server-rs/crates/api-server/src/edutainment_baby_object.rs +++ b/server-rs/crates/api-server/src/edutainment_baby_object.rs @@ -1052,6 +1052,7 @@ mod tests { external_api_audit_state: None, external_api_audit_user_id: None, external_api_audit_profile_id: None, + external_api_audit_request_id: None, }); assert_eq!( diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 910c18f2..9dd69508 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -414,12 +414,11 @@ async fn maybe_generate_jump_hop_assets( let settings = require_openai_image_settings(state) .map(|settings| { - settings - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.clone()), - ) + settings.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.clone()), + ) }) .map_err(|error| { jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 726f0c7e..f21ecbbf 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -755,12 +755,11 @@ async fn generate_match3d_material_sheet_from_level_scene( config: &Match3DConfigJson, background_asset: Option<&Match3DGeneratedBackgroundAsset>, ) -> Result { - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let prompt = build_match3d_item_spritesheet_prompt(); let reference = load_match3d_level_scene_reference_image(state, background_asset).await?; diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index 99d4ef06..bcdea311 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -304,12 +304,11 @@ pub(super) async fn generate_match3d_cover_image_asset( reference_image_srcs: Vec, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let cover_prompt = build_match3d_cover_generation_prompt(config, prompt); let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit( @@ -459,12 +458,11 @@ pub(super) async fn generate_match3d_level_asset_bundle( prompt: &str, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let level_scene_prompt = build_match3d_level_scene_generation_prompt(config); @@ -607,12 +605,11 @@ pub(super) async fn generate_match3d_container_image( prompt: &str, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let reference_image = load_match3d_container_reference_image()?; let container_prompt = build_match3d_container_generation_prompt(config, prompt); diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs index a17766f7..c03ad4bf 100644 --- a/server-rs/crates/api-server/src/puzzle/generation.rs +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -310,12 +310,11 @@ pub(crate) async fn generate_puzzle_level_asset_bundle( level_name: &str, puzzle_image: &PuzzleDownloadedImage, ) -> Result { - let settings = require_puzzle_vector_engine_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(session_id.to_string()), - ); + let settings = require_puzzle_vector_engine_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(session_id.to_string()), + ); let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?; let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image); let scene_generated = create_puzzle_vector_engine_image_generation( diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 4c6f9b2f..4338966b 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -117,11 +117,9 @@ impl PuzzleVectorEngineSettings { ) -> Self { self.external_api_audit_user_id = user_id; self.external_api_audit_profile_id = profile_id; - self.external_api_audit_request_id = - Some(request_context.request_id().to_string()); + self.external_api_audit_request_id = Some(request_context.request_id().to_string()); self } - } pub(crate) struct ParsedPuzzleImageDataUrl { diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 52823013..a9e010bb 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1240,8 +1240,7 @@ fn build_wechat_virtual_pay_params( } let sign_data = sign_data.to_string(); let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?; - let signature = - calc_wechat_virtual_payment_user_signature_with_key(&session_key, &sign_data)?; + let signature = calc_wechat_virtual_payment_user_signature_with_key(&session_key, &sign_data)?; Ok(WechatMiniProgramVirtualPayParamsResponse { mode: mode.to_string(), @@ -2342,8 +2341,9 @@ mod tests { has_points_recharged: false, }; - let params = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-00000001") - .expect("membership virtual pay params should build"); + let params = + build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-00000001") + .expect("membership virtual pay params should build"); let sign_data: Value = serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json"); let attach: Value = serde_json::from_str( @@ -2439,13 +2439,9 @@ mod tests { has_points_recharged: true, }; - let params = build_wechat_virtual_pay_params( - &state, - ¢er, - &order, - "openid-user-points-60", - ) - .expect("points virtual pay params should build"); + let params = + build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-points-60") + .expect("points virtual pay params should build"); let sign_data: Value = serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json"); let attach: Value = serde_json::from_str( @@ -2554,9 +2550,8 @@ mod tests { fn wechat_virtual_payment_signatures_match_official_examples() { let post_body = r#"{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}"#; - let pay_sig = - calc_wechat_virtual_payment_pay_signature_with_key("12345", post_body) - .expect("pay signature should build"); + let pay_sig = calc_wechat_virtual_payment_pay_signature_with_key("12345", post_body) + .expect("pay signature should build"); let signature = calc_wechat_virtual_payment_user_signature_with_key( "9hAb/NEYUlkaMBEsmFgzig==", post_body, diff --git a/server-rs/crates/api-server/src/square_hole/visual_assets.rs b/server-rs/crates/api-server/src/square_hole/visual_assets.rs index 75ad863e..3379f2a4 100644 --- a/server-rs/crates/api-server/src/square_hole/visual_assets.rs +++ b/server-rs/crates/api-server/src/square_hole/visual_assets.rs @@ -398,12 +398,11 @@ async fn generate_square_hole_image_data_url( size: &str, failure_context: &str, ) -> Result { - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index c3b38abd..4b21f255 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -1,11 +1,15 @@ use std::{fs, path::Path, sync::Arc}; +use aes::Aes256; use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, + Json, + extract::{Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use bytes::Bytes; +use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding}; use ring::{ aead, rand::{SecureRandom, SystemRandom}, @@ -13,11 +17,13 @@ use ring::{ }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +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 url::Url; @@ -43,6 +49,10 @@ const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45; const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi"; const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5"; const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native"; +const WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES: usize = 43; +const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES: usize = 32; +const WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES: usize = 16; +const WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES: usize = 4; #[derive(Clone, Debug)] pub enum WechatPayClient { @@ -92,6 +102,22 @@ pub struct WechatPayNotifyOrder { pub success_time: Option, } +#[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, +} + #[derive(Debug)] pub enum WechatPayError { Disabled, @@ -220,6 +246,45 @@ struct WechatPayQueryOrderResponse { success_time: Option, } +#[derive(Deserialize)] +struct WechatVirtualPaymentNotifyBody { + #[serde(rename = "Event", alias = "event")] + event: String, + #[serde(rename = "OutTradeNo", alias = "out_trade_no", default)] + out_trade_no: Option, + #[serde(rename = "MchOrderId", alias = "mch_order_id", default)] + mch_order_id: Option, + #[serde(rename = "WeChatPayInfo", alias = "wechat_pay_info", default)] + wechat_pay_info: Option, +} + +#[derive(Deserialize)] +struct WechatVirtualPaymentNotifyPayInfo { + #[serde(rename = "MchOrderNo", alias = "mch_order_no", default)] + mch_order_no: Option, + #[serde(rename = "TransactionId", alias = "transaction_id", default)] + transaction_id: Option, + #[serde(rename = "PaidTime", alias = "paid_time", default)] + paid_time: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct WechatMiniProgramMessagePushQuery { + signature: Option, + timestamp: Option, + nonce: Option, + echostr: Option, + msg_signature: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatMiniProgramEncryptedMessage { + #[serde(rename = "ToUserName", alias = "to_user_name", default)] + to_user_name: Option, + #[serde(rename = "Encrypt", alias = "encrypt")] + encrypt: String, +} + impl WechatPayClient { pub fn from_config(config: &crate::config::AppConfig) -> Result { if !config.wechat_pay_enabled { @@ -806,6 +871,172 @@ pub async fn handle_wechat_pay_notify( 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), + }; + 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(""); + let echostr = query.echostr.as_deref().map(str::trim).unwrap_or(""); + if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() || echostr.is_empty() { + return build_wechat_message_push_verify_error_response(WechatPayError::InvalidRequest( + "微信消息推送校验参数不完整".to_string(), + )); + } + if !verify_wechat_message_push_signature(token, timestamp, nonce, echostr, signature) { + return build_wechat_message_push_verify_error_response(WechatPayError::InvalidSignature( + "微信消息推送校验签名无效".to_string(), + )); + } + + match decrypt_wechat_message_push_ciphertext( + aes_key, + echostr, + state + .config + .wechat_mini_program_app_id + .as_deref() + .or(state.config.wechat_app_id.as_deref()), + ) { + 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, + ); + } + + 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) @@ -875,6 +1106,320 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { 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 parse_wechat_mini_program_message_push_payload( + body: &[u8], +) -> Result { + serde_json::from_slice(body).map_err(|error| { + WechatPayError::Deserialize(format!("微信消息推送 JSON 解析失败:{error}")) + }) +} + +fn verify_wechat_message_push_signature( + token: &str, + timestamp: &str, + nonce: &str, + value: &str, + signature: &str, +) -> bool { + let mut parts = [token, timestamp, nonce, value]; + parts.sort_unstable(); + let mut hasher = Sha1::new(); + hasher.update(parts.join("").as_bytes()); + let expected = hex::encode(hasher.finalize()); + expected.eq_ignore_ascii_case(signature) +} + +fn decrypt_wechat_message_push_ciphertext( + encoding_aes_key: &str, + ciphertext: &str, + expected_app_id: Option<&str>, +) -> Result { + let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key)?; + let ciphertext = BASE64_STANDARD + .decode(ciphertext.as_bytes()) + .map_err(|error| { + WechatPayError::Crypto(format!("微信消息推送密文 Base64 解码失败:{error}")) + })?; + let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES]; + let cipher = cbc::Decryptor::::new_from_slices(&key, iv) + .map_err(|error| WechatPayError::Crypto(format!("微信消息推送 AES 初始化失败:{error}")))?; + let decrypted = cipher + .decrypt_padded_vec_mut::(&ciphertext) + .map_err(|error| WechatPayError::Crypto(format!("微信消息推送密文解密失败:{error}")))?; + let plaintext = remove_wechat_message_push_pkcs7_padding(&decrypted)?; + let payload = parse_wechat_message_push_plaintext(&plaintext)?; + if let Some(app_id) = expected_app_id + .map(str::trim) + .filter(|value| !value.is_empty()) + && payload.app_id != app_id + { + return Err(WechatPayError::InvalidSignature( + "微信消息推送明文 appid 校验失败".to_string(), + )); + } + Ok(payload.message) +} + +fn decode_wechat_message_push_encoding_aes_key( + encoding_aes_key: &str, +) -> Result, WechatPayError> { + if encoding_aes_key.chars().count() != WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES { + return Err(WechatPayError::InvalidConfig(format!( + "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 必须是 {WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES} 位" + ))); + } + let padded_key = format!("{encoding_aes_key}="); + let key = BASE64_STANDARD + .decode(padded_key.as_bytes()) + .map_err(|error| { + WechatPayError::InvalidConfig(format!( + "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY Base64 解析失败:{error}" + )) + })?; + if key.len() != WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES { + return Err(WechatPayError::InvalidConfig( + "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 解码后长度必须为 32 字节".to_string(), + )); + } + Ok(key) +} + +fn remove_wechat_message_push_pkcs7_padding(plaintext: &[u8]) -> Result, WechatPayError> { + let Some(&pad_len) = plaintext.last() else { + return Err(WechatPayError::Deserialize( + "微信消息推送明文为空".to_string(), + )); + }; + let pad_len = pad_len as usize; + if pad_len == 0 || pad_len > 32 || pad_len > plaintext.len() { + return Err(WechatPayError::Deserialize( + "微信消息推送 PKCS7 填充无效".to_string(), + )); + } + if plaintext[plaintext.len() - pad_len..] + .iter() + .any(|byte| *byte as usize != pad_len) + { + return Err(WechatPayError::Deserialize( + "微信消息推送 PKCS7 填充校验失败".to_string(), + )); + } + Ok(plaintext[..plaintext.len() - pad_len].to_vec()) +} + +struct WechatMessagePushPlaintext { + message: String, + app_id: String, +} + +fn parse_wechat_message_push_plaintext( + plaintext: &[u8], +) -> Result { + if plaintext.len() + < WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES + WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES + 1 + { + return Err(WechatPayError::Deserialize( + "微信消息推送明文长度不足".to_string(), + )); + } + let len_offset = WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES; + let length_bytes: [u8; WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES] = plaintext + [len_offset..len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES] + .try_into() + .map_err(|_| WechatPayError::Deserialize("微信消息推送长度字段解析失败".to_string()))?; + let message_len = u32::from_be_bytes(length_bytes) as usize; + let message_start = len_offset + WECHAT_MINIPROGRAM_MESSAGE_LENGTH_BYTES; + let message_end = message_start + message_len; + if plaintext.len() <= message_end { + return Err(WechatPayError::Deserialize( + "微信消息推送明文长度与内容不匹配".to_string(), + )); + } + let app_id_start = message_end; + let message = + String::from_utf8(plaintext[message_start..message_end].to_vec()).map_err(|error| { + WechatPayError::Deserialize(format!("微信消息推送明文不是合法 UTF-8:{error}")) + })?; + let app_id = + String::from_utf8(plaintext[app_id_start..plaintext.len()].to_vec()).map_err(|error| { + WechatPayError::Deserialize(format!("微信消息推送 appid 不是合法 UTF-8:{error}")) + })?; + Ok(WechatMessagePushPlaintext { message, app_id }) +} + +fn parse_virtual_payment_notify( + body: &[u8], +) -> Result { + if let Ok(notify) = serde_json::from_slice::(body) { + return build_virtual_payment_notify_order( + notify.event, + notify.out_trade_no, + notify.mch_order_id, + notify.wechat_pay_info, + ); + } + + let text = std::str::from_utf8(body).map_err(|error| { + WechatPayError::Deserialize(format!("微信虚拟支付推送不是合法 UTF-8:{error}")) + })?; + let event = extract_virtual_payment_text_value(text, "Event") + .ok_or_else(|| WechatPayError::InvalidRequest("微信虚拟支付推送缺少 Event".to_string()))?; + let out_trade_no = extract_virtual_payment_text_value(text, "OutTradeNo"); + let mch_order_id = extract_virtual_payment_text_value(text, "MchOrderId"); + let wechat_pay_info = extract_virtual_payment_block(text, "WeChatPayInfo").map(|inner| { + WechatVirtualPaymentNotifyPayInfo { + mch_order_no: extract_virtual_payment_text_value(&inner, "MchOrderNo"), + transaction_id: extract_virtual_payment_text_value(&inner, "TransactionId"), + paid_time: extract_virtual_payment_text_value(&inner, "PaidTime") + .and_then(|value| value.parse::().ok()), + } + }); + + build_virtual_payment_notify_order(event, out_trade_no, mch_order_id, wechat_pay_info) +} + +fn build_virtual_payment_notify_order( + event: String, + out_trade_no: Option, + mch_order_id: Option, + wechat_pay_info: Option, +) -> Result { + let event = event.trim().to_string(); + if event.is_empty() { + return Err(WechatPayError::InvalidRequest( + "微信虚拟支付推送缺少 Event".to_string(), + )); + } + let out_trade_no = out_trade_no + .or(mch_order_id) + .or_else(|| { + wechat_pay_info + .as_ref() + .and_then(|info| info.mch_order_no.clone()) + }) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::InvalidRequest("微信虚拟支付推送缺少 OutTradeNo".to_string()) + })?; + let transaction_id = wechat_pay_info + .as_ref() + .and_then(|info| info.transaction_id.clone()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let paid_at_micros = wechat_pay_info + .and_then(|info| info.paid_time) + .map(|paid_time| paid_time.saturating_mul(1_000_000)); + + Ok(WechatVirtualPaymentNotifyOrder { + out_trade_no, + transaction_id, + paid_at_micros, + event, + }) +} + +fn extract_virtual_payment_text_value(text: &str, tag: &str) -> Option { + let open = format!("<{tag}>"); + let close = format!(""); + let start = text.find(&open)? + open.len(); + let end = text[start..].find(&close)? + start; + let raw = &text[start..end]; + Some(trim_virtual_payment_text_value(raw)) +} + +fn extract_virtual_payment_block(text: &str, tag: &str) -> Option { + let open = format!("<{tag}>"); + let close = format!(""); + let start = text.find(&open)? + open.len(); + let end = text[start..].find(&close)? + start; + Some(text[start..end].to_string()) +} + +fn trim_virtual_payment_text_value(value: &str) -> String { + let trimmed = value.trim(); + if let Some(inner) = trimmed + .strip_prefix("")) + { + return inner.trim().to_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, @@ -965,6 +1510,45 @@ fn parse_mock_notify(body: &[u8]) -> Result, +) -> 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) @@ -1330,6 +1914,7 @@ impl std::error::Error for WechatPayError {} #[cfg(test)] mod tests { use super::*; + use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding}; #[test] fn mock_pay_params_use_request_payment_shape() { @@ -1551,4 +2136,141 @@ mod tests { assert_eq!(notify.transaction_id, None); assert_eq!(notify.trade_state, "SUCCESS"); } + + #[test] + fn parse_virtual_payment_notify_supports_goods_event_json() { + let notify = parse_virtual_payment_notify( + br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1","WeChatPayInfo":{"TransactionId":"wx-1","PaidTime":1710000000}}"#, + ) + .expect("virtual payment notify should parse"); + + assert_eq!(notify.event, "xpay_goods_deliver_notify"); + assert_eq!(notify.out_trade_no, "order-1"); + assert_eq!(notify.transaction_id.as_deref(), Some("wx-1")); + assert_eq!(notify.paid_at_micros, Some(1_710_000_000_000_000)); + } + + #[test] + fn parse_virtual_payment_notify_supports_coin_event_xml() { + let notify = parse_virtual_payment_notify( + br#"1710000001"#, + ) + .expect("virtual payment xml notify should parse"); + + assert_eq!(notify.event, "xpay_coin_pay_notify"); + assert_eq!(notify.out_trade_no, "order-2"); + assert_eq!(notify.transaction_id.as_deref(), Some("wx-2")); + assert_eq!(notify.paid_at_micros, Some(1_710_000_001_000_000)); + } + + #[test] + fn parse_virtual_payment_notify_rejects_missing_order_no() { + let error = parse_virtual_payment_notify(br#"{"Event":"xpay_goods_deliver_notify"}"#) + .expect_err("missing order id should fail"); + + match error { + WechatPayError::InvalidRequest(message) => { + assert!(message.contains("OutTradeNo")); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn wechat_message_push_signature_uses_sorted_sha1_parts() { + let token = "token-1"; + let timestamp = "1710000000"; + let nonce = "nonce-1"; + let encrypt = "encrypted-payload"; + let signature = build_wechat_message_push_test_signature(token, timestamp, nonce, encrypt); + + assert!(verify_wechat_message_push_signature( + token, timestamp, nonce, encrypt, &signature + )); + assert!(!verify_wechat_message_push_signature( + token, + timestamp, + nonce, + "tampered-payload", + &signature + )); + } + + #[test] + fn wechat_message_push_decrypts_safe_mode_ciphertext() { + let app_id = "wx-test-app"; + let message = r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#; + let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key(); + let encrypted = + encrypt_wechat_message_push_test_ciphertext(&encoding_aes_key, message, app_id); + + let decrypted = + decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some(app_id)) + .expect("encrypted message should decrypt"); + + assert_eq!(decrypted, message); + } + + #[test] + fn wechat_message_push_rejects_mismatched_app_id() { + let encoding_aes_key = build_wechat_message_push_test_encoding_aes_key(); + let encrypted = encrypt_wechat_message_push_test_ciphertext( + &encoding_aes_key, + r#"{"Event":"xpay_coin_pay_notify","OutTradeNo":"order-1"}"#, + "wx-real-app", + ); + + let error = + decrypt_wechat_message_push_ciphertext(&encoding_aes_key, &encrypted, Some("wx-other")) + .expect_err("mismatched app id should fail"); + + match error { + WechatPayError::InvalidSignature(message) => { + assert!(message.contains("appid")); + } + other => panic!("unexpected error: {other:?}"), + } + } + + fn build_wechat_message_push_test_signature( + token: &str, + timestamp: &str, + nonce: &str, + value: &str, + ) -> String { + let mut parts = [token, timestamp, nonce, value]; + parts.sort_unstable(); + let mut hasher = Sha1::new(); + hasher.update(parts.join("").as_bytes()); + hex::encode(hasher.finalize()) + } + + fn build_wechat_message_push_test_encoding_aes_key() -> String { + let raw_key = std::array::from_fn::<_, 32, _>(|index| index as u8); + BASE64_STANDARD + .encode(raw_key) + .trim_end_matches('=') + .to_string() + } + + fn encrypt_wechat_message_push_test_ciphertext( + encoding_aes_key: &str, + message: &str, + app_id: &str, + ) -> String { + let key = decode_wechat_message_push_encoding_aes_key(encoding_aes_key) + .expect("test aes key should decode"); + let mut plaintext = Vec::new(); + plaintext.extend_from_slice(b"0123456789abcdef"); + plaintext.extend_from_slice(&(message.as_bytes().len() as u32).to_be_bytes()); + plaintext.extend_from_slice(message.as_bytes()); + plaintext.extend_from_slice(app_id.as_bytes()); + let pad_len = 32 - (plaintext.len() % 32); + plaintext.extend(std::iter::repeat(pad_len as u8).take(pad_len)); + let iv = &key[..WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES]; + let cipher = cbc::Encryptor::::new_from_slices(&key, iv) + .expect("test aes cipher should init"); + let encrypted = cipher.encrypt_padded_vec_mut::(&plaintext); + BASE64_STANDARD.encode(encrypted) + } } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index a181489e..b5fe1132 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -1461,8 +1461,10 @@ mod tests { assert_eq!(asset.prompt.as_deref(), Some("默认木鱼音")); } - #[test] - fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() { + #[tokio::test] + async fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() { + let state = crate::state::AppState::new(crate::config::AppConfig::default()) + .expect("state should build"); let payload = WoodenFishWorkspaceCreateRequest { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), work_title: "今日敲木鱼".to_string(), @@ -1475,7 +1477,9 @@ mod tests { floating_words: vec![], }; - let draft = build_wooden_fish_draft(&payload); + let draft = build_wooden_fish_draft(&payload, &state) + .await + .expect("draft should build"); assert!(draft.hit_sound_prompt.is_none()); let asset = draft diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index d88c07f8..bc7d3dcb 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -1524,8 +1524,14 @@ mod tests { payload["wechatMiniProgramPayParams"]["signData"], json!("{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}") ); - assert_eq!(payload["wechatMiniProgramPayParams"]["paySig"], json!("pay-sig")); - assert_eq!(payload["wechatMiniProgramPayParams"]["signature"], json!("user-sig")); + assert_eq!( + payload["wechatMiniProgramPayParams"]["paySig"], + json!("pay-sig") + ); + assert_eq!( + payload["wechatMiniProgramPayParams"]["signature"], + json!("user-sig") + ); } #[test] diff --git a/vite.config.ts b/vite.config.ts index ac0b1e69..7606c97f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -61,6 +61,7 @@ export default defineConfig(({mode}) => { chunkSizeWarningLimit: 1000, }, server: { + allowedHosts: ["outfield-outlook-reprocess.ngrok-free.dev"], // HMR is disabled in AI Studio via DISABLE_HMR env var. // Do not modify; file watching is disabled to prevent flickering during agent edits. hmr: process.env.DISABLE_HMR !== 'true',