diff --git a/.env.example b/.env.example index 6b611bebd..30a8aca29 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,7 @@ 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_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session" WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token" +WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT="https://api.weixin.qq.com/xpay/query_order" WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber" WECHAT_STATE_TTL_MINUTES="15" WECHAT_MOCK_USER_ID="wx-mock-user" diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index de8d780f7..5732cfb02 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4005,3 +4005,11 @@ - 验真与清理边界:Complete 后必须发送签名 HEAD,并严格核对 OSS `Content-Length` 与本地归档大小;Complete 响应不确定时也先用 HEAD 判定对象是否已经完整落盘。只有验真成功后才能把 manifest 标记为 `uploaded`,并按 `keepLocal` 决定是否删除本地归档;失败时 best-effort AbortMultipartUpload,不得提前更新 manifest 或清理本地文件。 - 影响范围:数据库备份 OSS 上传实现、备份回归门禁、release 本地归档保留与 timer 恢复流程。 - 验证方式:`npm run check:database-backup`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`;线上先对既有归档使用 `--upload-archive ... --keep-local`,确认 OSS 对象长度和可恢复性后再清理积压并恢复 timer。 + +## 2026-07-13 微信虚拟支付使用官方查单补偿 + +- 背景:`wechat_mp_virtual` 原先被误认为没有服务端查单能力,导致消息推送遗漏后订单只能停在 pending / expired,历史订单也无法按微信真实状态核对。 +- 决策:`platform-wechat` 按官方协议调用 `POST /xpay/query_order`,使用小程序 `access_token` 与 `HMAC-SHA256(appKey, "/xpay/query_order&" + 实际 JSON body)` 支付签名。用户确认和订单到期补偿都可查虚拟支付订单,但只在单号、支付类型 `order_type=0/7`、金额一致且微信状态为 `2/3/4` 时补入账;退款类型 `1/8` 不发放权益。 +- 边界:查单使用订单所属用户的小程序 `openid`,不把 AppKey、AppSecret、access token 或 `openid` 下发前端;虚拟支付不得误用微信支付 V3 查单。 +- 历史单:升级前遗留的 pending 订单不会被 expiration catch-up 覆盖,使用 `spacetime:wechat-virtual-payment:reconcile` 逐单 dry-run,再使用当次 `applyFingerprint` 明确 `--apply`。脚本每次重读本地订单与微信查单结果,指纹漂移、非 `2/3/4`、单号/金额/支付类型 `order_type=0/7` 不一致或非官方 endpoint 时默认拒绝入账。 +- 验证方式:`cargo test -p platform-wechat --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml virtual_payment_query`、`npm run check:wechat-virtual-payment-reconcile`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a9301e365..3dd2f5e2e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -202,7 +202,7 @@ npm run check:server-rs-ddd 12. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;移动网页和微信内 H5 走 `wechat_h5`;桌面网页和桌面微信走 `wechat_native`;`wechat_jsapi` 仅保留后端能力,未接微信开放平台前不由前端自动选择。历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。 13. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。 14. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`,格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。 -15. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 只订阅这张活跃 timer 表的删除事件,收到 `order_id` 后通过 procedure 重新读取订单,只有状态确认为 `expired` 才执行微信查单补偿;支付或主动关闭同样会删除 timer,但会被状态判断忽略。监听断线期间遗漏的删除事件由未检查过期订单 catch-up 补齐,不订阅完整 `profile_recharge_order` 历史表。`SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`external-generation-worker` / controller 不处理充值过期;`wechat_mp_virtual` 到期只记录虚拟渠道不可查,后续真实支付通知仍允许 `Expired -> Paid`。 +15. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 只订阅这张活跃 timer 表的删除事件,收到 `order_id` 后通过 procedure 重新读取订单,只有状态确认为 `expired` 才执行微信查单补偿;支付或主动关闭同样会删除 timer,但会被状态判断忽略。监听断线期间遗漏的删除事件由未检查过期订单 catch-up 补齐,不订阅完整 `profile_recharge_order` 历史表。普通微信支付查单中 `SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`wechat_mp_virtual` 使用小程序 `access_token` 和虚拟支付 AppKey 调用官方 `/xpay/query_order`,只在返回单号、支付类型 `order_type=0/7`、金额与本地契约一致且状态为 `2/3/4` 时补入账;退款类型 `1/8` 不得触发充值,其余已知状态只记录检查结果。`external-generation-worker` / controller 不处理充值过期。 ## 创作入口泥点扣费契约 diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index 1cd3fcf81..ef2bb8a3a 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`。 - 微信内浏览器走 `wechat_jsapi`,复用微信支付 V3 JSAPI 下单返回的预支付参数并通过 `WeixinJSBridge.invoke('getBrandWCPayRequest')` 调起支付;普通 Web 统一走 `wechat_native` 二维码支付,不进入虚拟支付链路,也不依赖 H5 产品权限。`wechat_h5` 仅作为未来 H5 产品权限明确开通后的保留渠道。 - `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。 -- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相,不再用普通微信支付 V3 查单。 +- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账以后端虚拟支付消息推送或官方 `/xpay/query_order` 查单结果为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付不得误用普通微信支付 V3 查单。 - 小程序 WebView 普通进入不预登录;H5 触发受保护入口或支付前必须保留 `clientRuntime=wechat_mini_program` 等宿主上下文,并用 `MicroMessenger + miniProgram` User-Agent 兜底识别首点 bridge 未就绪场景,再跳转小程序原生授权态,确保后端拿到带 `session_key` 的微信登录态。 ## 关键文件 @@ -34,6 +34,7 @@ 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_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT=https://api.weixin.qq.com/xpay/query_order WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token> WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey> WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED=true @@ -47,6 +48,10 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 - `signData`:传给 `wx.requestVirtualPayment` 的订单数据。 - `paySig`:`HMAC-SHA256(appKey, "requestVirtualPayment&" + signData)` 的小写 hex。 - `signature`:`HMAC-SHA256(session_key, signData)` 的小写 hex。 +- 服务端虚拟支付查单使用 `POST https://api.weixin.qq.com/xpay/query_order`:请求体固定携带 `openid`、`env` 和 `order_id`,查询参数携带小程序 `access_token` 与 `pay_sig`。`pay_sig` 按官方算法计算为 `HMAC-SHA256(appKey, "/xpay/query_order&" + 实际发送的 JSON body)` 的小写 hex;参与签名的 body 必须与 HTTP 实际发送字节一致。 +- 查单返回 `status=2/3/4` 分别表示“已支付待发货 / 发货中 / 已发货”,只有这三种状态可以补确认本地订单入账。入账前必须同时校验 `order_id`、支付类型 `order_type=0/7` 和 `order_fee`;`status=0/1/5..10` 只记录供应方状态,不直接发放泥点或会员权益。 +- `order_type` 的官方枚举是 `0=普通虚拟支付`、`1=普通退款`、`7=iOS 支付`、`8=iOS 退款`,不区分 `short_series_coin` 与 `short_series_goods`。普通和 iOS 的支付单分别使用 `0` 和 `7`;退款类型 `1/8` 绝不可触发充值入账。官方查单响应本身不提供可反查 coin / goods 的字段,不得自行发明数值映射;商品契约继续以本地订单快照和 `order_fee` 一致性作防线。 +- `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT` 默认是微信官方地址,仅用于测试注入 mock endpoint;生产不应覆盖。`WechatConfig` 同时支持注入 stable token 和 query order 两个 endpoint,平台层集成测试必须校验真实发起请求的 mock 路径。 - 泥点属于微信虚拟支付代币(coin),`short_series_coin` 的 `buyQuantity` 必须使用当前泥点商品的 `points_amount`;例如 60 泥点商品应传 `buyQuantity: 60`。 - 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。 - 微信小程序“开发者服务器接收消息推送”必须配置为安全模式,数据格式选 JSON,URL 统一指向 `/api/profile/recharge/wechat/virtual-notify`。 @@ -63,6 +68,35 @@ npm run typecheck npm run check:encoding ``` +## 历史订单逐单核对 + +升级前已存在的 `wechat_mp_virtual` pending 订单没有 expiration timer,不会被到期 catch-up 自动遍历。这类订单使用受控脚本逐单处理,不得批量改状态: + +```bash +# openid 只放入当次临时文件,不作为 CLI 参数或仓库文件。 +install -m 0600 /dev/null /run/genarrative-virtual-payment-openid + +# 第一次固定 dry-run;人工核对订单、金额、微信状态和 applyFingerprint。 +npm run spacetime:wechat-virtual-payment:reconcile -- \ + --database genarrative-prod \ + --server-url http://127.0.0.1:3101 \ + --order-id \ + --openid-file /run/genarrative-virtual-payment-openid \ + --env-file /etc/genarrative/api-server.env + +# 只有 dry-run 显示 eligibleForCredit=true 时,使用同一订单追加 --apply 和当次指纹。 +npm run spacetime:wechat-virtual-payment:reconcile -- \ + --database genarrative-prod \ + --server-url http://127.0.0.1:3101 \ + --order-id \ + --openid-file /run/genarrative-virtual-payment-openid \ + --env-file /etc/genarrative/api-server.env \ + --apply \ + --confirm +``` + +脚本每次重新读取本地订单并重新向微信查单;`--apply` 要求事实指纹与前一次 dry-run 完全一致,只对 `status=2/3/4` 且单号、金额、支付类型 `order_type=0/7` 一致的订单调用既有 `mark_profile_recharge_order_paid_and_return`。它使用 `/etc/genarrative/api-server.env` 内的 `GENARRATIVE_SPACETIME_TOKEN` 通过显式 `--server-url` 调用 procedure,不复用 migration operator 身份;不输出 openid、AppSecret、AppKey、access token 或 SpacetimeDB token,且 apply 默认拒绝非微信官方 endpoint。每单完成后立即删除临时 openid 文件。 + ## 注意事项 - 旧微信登录快照可能没有 `session_key`;普通进入小程序 WebView 仍允许匿名打开,虚拟支付会由后端拦截并提示用户在小程序内重新登录。H5 内部导航不得清理 `clientType`、`clientRuntime`、`miniProgramEnv`,且首点登录要用小程序 User-Agent 兜底识别,否则登录和支付会误判为普通网页环境。 @@ -74,6 +108,6 @@ npm run check:encoding - 微信虚拟支付消息推送使用独立后端入口 `/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`,也必须展示回写的微信错误内容。 -- WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底;后端收到虚拟支付消息推送并入账后会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 `done`。 +- WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底;虚拟支付确认接口会使用当前用户后端保存的小程序 `openid` 调用官方 `/xpay/query_order`,查到已支付且契约校验通过后写入订单。后端通过消息推送或查单入账后都会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 `done`。 - 小程序订阅消息用于 AI 创作生成结果通知:H5 在生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在玩法草稿生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。模板 `thing1` 发送玩法模板名,`number6` 发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`;模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm`。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/package.json b/package.json index 65e810076..28f7de087 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "admin-web:preview": "npm --prefix apps/admin-web run preview --", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", "spacetime:external-generation:maintain": "node scripts/spacetime-maintain-external-generation-jobs.mjs", + "spacetime:wechat-virtual-payment:reconcile": "node scripts/reconcile-wechat-virtual-payment-order.mjs", + "check:wechat-virtual-payment-reconcile": "node scripts/check-wechat-virtual-payment-reconcile.mjs", "check:api-server-env": "node scripts/check-api-server-env.mjs", "check:spacetime-runtime-access": "node scripts/check-spacetime-runtime-access.mjs", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", diff --git a/scripts/check-wechat-virtual-payment-reconcile.mjs b/scripts/check-wechat-virtual-payment-reconcile.mjs new file mode 100644 index 000000000..ff481920a --- /dev/null +++ b/scripts/check-wechat-virtual-payment-reconcile.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; + +import { + buildReconcileFingerprint, + calcPaySig, + normalizeLocalOrderSnapshot, + paidAtMicrosFromWechatOrder, + parseEnvText, + validateQueryResult, +} from './reconcile-wechat-virtual-payment-order.mjs'; + +assert.equal( + calcPaySig( + '12345', + '/xpay/query_user_balance', + '{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}', + ), + 'c37809f27c6d7fd1837ad2500a04512b66b34fd793a39a385fade56dca89a4b5', +); + +assert.deepEqual( + parseEnvText( + 'WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 # 现网\nWECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY="app-key"\n', + ), + { + WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY: 'app-key', + WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV: '0', + }, +); + +const localOrder = { amountCents: 600, orderId: 'order-001' }; +const paidOrder = { + order_fee: 600, + order_id: 'order-001', + order_type: 0, + status: 2, +}; +assert.deepEqual(validateQueryResult(localOrder, paidOrder), { + eligibleForCredit: true, + status: 2, +}); +assert.deepEqual(validateQueryResult(localOrder, { ...paidOrder, status: 1 }), { + eligibleForCredit: false, + status: 1, +}); +assert.deepEqual( + validateQueryResult(localOrder, { ...paidOrder, order_type: 7 }), + { eligibleForCredit: true, status: 2 }, +); +assert.equal( + paidAtMicrosFromWechatOrder({ paid_time: 1_777_111_200 }), + 1_777_111_200_000_000, +); +assert.throws(() => paidAtMicrosFromWechatOrder({}), /paid_time/u); +assert.throws( + () => validateQueryResult(localOrder, { ...paidOrder, order_fee: 601 }), + /金额/u, +); +assert.throws( + () => validateQueryResult(localOrder, { ...paidOrder, order_type: 1 }), + /可入账虚拟支付单/u, +); +assert.throws( + () => validateQueryResult(localOrder, { ...paidOrder, order_type: 8 }), + /可入账虚拟支付单/u, +); +assert.equal( + buildReconcileFingerprint({ orderId: 'order-001', status: 2 }), + buildReconcileFingerprint({ orderId: 'order-001', status: 2 }), +); +assert.notEqual( + buildReconcileFingerprint({ orderId: 'order-001', status: 2 }), + buildReconcileFingerprint({ orderId: 'order-001', status: 1 }), +); +assert.deepEqual( + normalizeLocalOrderSnapshot([ + 'order-001', + 'user-001', + 'points_60', + '60 泥点', + [0], + 600, + [0], + 'wechat_mp_virtual', + [1], + [1], + 1_777_111_200_000_000, + 0, + [1], + [1], + [1], + [1], + [1], + ]), + { + amount_cents: 600, + kind: [0], + order_id: 'order-001', + payment_channel: 'wechat_mp_virtual', + status: [0], + user_id: 'user-001', + }, +); + +console.log('wechat virtual payment reconcile checks passed'); diff --git a/scripts/reconcile-wechat-virtual-payment-order.mjs b/scripts/reconcile-wechat-virtual-payment-order.mjs new file mode 100644 index 000000000..718e1ca21 --- /dev/null +++ b/scripts/reconcile-wechat-virtual-payment-order.mjs @@ -0,0 +1,530 @@ +#!/usr/bin/env node + +import { createHash, createHmac } from 'node:crypto'; +import { lstat, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + encodeSpacetimeCliOption, + ensureProcedureOk, +} from './spacetime-migration-common.mjs'; + +const OFFICIAL_STABLE_TOKEN_ENDPOINT = + 'https://api.weixin.qq.com/cgi-bin/stable_token'; +const OFFICIAL_QUERY_ORDER_ENDPOINT = + 'https://api.weixin.qq.com/xpay/query_order'; +const QUERY_ORDER_URI = '/xpay/query_order'; +const REQUEST_TIMEOUT_MS = 15_000; + +function usage() { + return `用法: + node scripts/reconcile-wechat-virtual-payment-order.mjs --database --server-url --order-id --openid-file --env-file [选项] + +默认只对一条 wechat_mp_virtual pending / expired 订单执行 dry-run 查单,不修改数据库。 + + --database 目标数据库(必填) + --server-url 显式 SpacetimeDB URL(必填) + --order-id 本次只核对的订单 ID(必填) + --openid-file 只包含该订单用户 openid 的 0600 普通文件(必填) + --env-file api-server 生产 env 文件(必填) + --apply 符合入账条件时调用既有 mark_profile_recharge_order_paid_and_return + --confirm --apply 必填;使用前一次 dry-run 输出的 applyFingerprint + --allow-non-official-endpoint + 允许 apply 使用非官方 stable_token / query_order endpoint,仅限受控测试 + --help 显示帮助 + +脚本使用 env 文件内的 GENARRATIVE_SPACETIME_TOKEN 调用既有 procedure,不使用 migration operator 身份;不输出 openid、AppSecret、AppKey、access_token 或 SpacetimeDB token。`; +} + +function parseOptions(argv) { + const options = { + allowNonOfficialEndpoint: false, + apply: false, + confirm: '', + database: process.env.GENARRATIVE_SPACETIME_DATABASE || '', + envFile: '', + openidFile: '', + orderId: '', + serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${arg} 缺少参数值。`); + } + index += 1; + return value.trim(); + }; + + if (arg === '--database') options.database = readValue(); + else if (arg === '--server-url') options.serverUrl = readValue(); + else if (arg === '--order-id') options.orderId = readValue(); + else if (arg === '--openid-file') options.openidFile = readValue(); + else if (arg === '--env-file') options.envFile = readValue(); + else if (arg === '--confirm') options.confirm = readValue().toLowerCase(); + else if (arg === '--apply') options.apply = true; + else if (arg === '--allow-non-official-endpoint') { + options.allowNonOfficialEndpoint = true; + } else if (arg === '--help' || arg === '-h') options.help = true; + else throw new Error(`未知参数: ${arg}`); + } + + if (options.help) return options; + for (const [name, value] of [ + ['--database', options.database], + ['--server-url', options.serverUrl], + ['--order-id', options.orderId], + ['--openid-file', options.openidFile], + ['--env-file', options.envFile], + ]) { + if (!value) throw new Error(`${name} 必填。`); + } + if (!/^https?:\/\/[^\s]+$/u.test(options.serverUrl)) { + throw new Error('--server-url 必须是显式 HTTP(S) URL。'); + } + if (options.apply && !/^[0-9a-f]{64}$/u.test(options.confirm)) { + throw new Error('--apply 必须同时传入 dry-run 生成的 64 位 --confirm。'); + } + return options; +} + +export function parseEnvText(text) { + const values = {}; + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); + if (!match) continue; + const [, key, rawValue] = match; + let value = rawValue.trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } else { + value = value.replace(/\s+#.*$/u, '').trim(); + } + values[key] = value; + } + return values; +} + +export function calcPaySig(appKey, uri, body) { + return createHmac('sha256', appKey).update(`${uri}&${body}`).digest('hex'); +} + +export function validateQueryResult(localOrder, wechatOrder) { + if (wechatOrder.order_id !== localOrder.orderId) { + throw new Error('微信查单返回的订单号与本地订单不一致。'); + } + if (![0, 7].includes(Number(wechatOrder.order_type))) { + throw new Error('微信查单返回的不是可入账虚拟支付单。'); + } + if (Number(wechatOrder.order_fee) !== localOrder.amountCents) { + throw new Error('微信查单返回的金额与本地订单不一致。'); + } + const status = Number(wechatOrder.status); + if (!Number.isInteger(status) || status < 0 || status > 10) { + throw new Error('微信查单返回了未知订单状态。'); + } + return { + eligibleForCredit: status >= 2 && status <= 4, + status, + }; +} + +export function buildReconcileFingerprint(facts) { + return createHash('sha256').update(JSON.stringify(facts)).digest('hex'); +} + +async function readRegularFile( + filePath, + label, + { requirePrivate = false } = {}, +) { + const resolved = resolve(filePath); + const metadata = await lstat(resolved); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`${label} 必须是普通文件且不能是符号链接。`); + } + if (requirePrivate && (metadata.mode & 0o077) !== 0) { + throw new Error(`${label} 权限必须为 0600(或更严格)。`); + } + return readFile(resolved, 'utf8'); +} + +function requiredEnv(env, keys, label) { + for (const key of keys) { + const value = String(env[key] ?? '').trim(); + if (value) return value; + } + throw new Error(`${label} 未配置。`); +} + +function normalizeVariant(value, variants = []) { + if (typeof value === 'string') return value.toLowerCase(); + if (Array.isArray(value) && Number.isInteger(value[0])) { + return String(variants[value[0]] ?? '').toLowerCase(); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + if (typeof value.tag === 'string') return value.tag.toLowerCase(); + const key = Object.keys(value)[0]; + if (key) return key.toLowerCase(); + } + return String(value ?? '').toLowerCase(); +} + +export function normalizeLocalOrderSnapshot(value) { + if (!Array.isArray(value)) return value; + if (value.length < 17) return value; + return { + order_id: value[0], + user_id: value[1], + kind: value[4], + amount_cents: value[5], + status: value[6], + payment_channel: value[7], + }; +} + +function unwrapOption(value) { + if (Array.isArray(value)) { + if (value.length === 2 && value[0] === 0) return value[1]; + if (value.length === 0 || value[0] === 1) return null; + } + return value ?? null; +} + +async function callProfileRechargeProcedure(options, procedureName, input) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(options.database)) { + throw new Error('--database 不是合法 SpacetimeDB 数据库名。'); + } + const serverUrl = options.serverUrl.replace(/\/+$/u, ''); + const url = `${serverUrl}/v1/database/${encodeURIComponent(options.database)}/call/${encodeURIComponent(procedureName)}`; + let response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${options.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify([input]), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new Error( + `SpacetimeDB ${procedureName} 请求失败:${error instanceof Error ? error.message : String(error)}`, + ); + } + const text = await response.text(); + if (!response.ok) { + throw new Error( + `SpacetimeDB ${procedureName} 返回 HTTP ${response.status}。`, + ); + } + let payload; + try { + payload = JSON.parse(text); + } catch { + throw new Error(`SpacetimeDB ${procedureName} 响应不是合法 JSON。`); + } + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + return payload; + } + if (!Array.isArray(payload) || payload.length !== 4) { + throw new Error(`SpacetimeDB ${procedureName} 响应结构不符合契约。`); + } + return { + ok: payload[0], + record: unwrapOption(payload[1]), + order: unwrapOption(payload[2]), + error_message: unwrapOption(payload[3]), + }; +} + +function parseLocalOrder(result, expectedOrderId) { + ensureProcedureOk(result); + const order = normalizeLocalOrderSnapshot(unwrapOption(result.order)); + if (!order || typeof order !== 'object') { + throw new Error('读取本地充值订单失败:procedure 响应缺少 order。'); + } + const orderId = String(order.order_id ?? '').trim(); + const paymentChannel = String(order.payment_channel ?? '').trim(); + const status = normalizeVariant(order.status, [ + 'pending', + 'paid', + 'failed', + 'closed', + 'refunded', + 'expired', + ]); + const kind = normalizeVariant(order.kind, ['points', 'membership']); + const amountCents = Number(order.amount_cents); + if (orderId !== expectedOrderId) + throw new Error('本地 procedure 返回了其它订单。'); + if (paymentChannel !== 'wechat_mp_virtual') { + throw new Error('目标订单不是 wechat_mp_virtual 渠道。'); + } + if (!['pending', 'expired'].includes(status)) { + throw new Error( + `目标订单当前状态是 ${status || ''},只允许核对 pending / expired。`, + ); + } + if (!['points', 'membership'].includes(kind)) { + throw new Error(`目标订单商品类型 ${kind || ''} 不在允许范围内。`); + } + if (!Number.isSafeInteger(amountCents) || amountCents < 0) { + throw new Error('本地订单 amount_cents 无效。'); + } + return { + amountCents, + kind, + orderId, + status, + userId: String(order.user_id ?? ''), + }; +} + +async function postJson(url, payload, label) { + let response; + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: typeof payload === 'string' ? payload : JSON.stringify(payload), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new Error( + `${label}请求失败:${error instanceof Error ? error.message : String(error)}`, + ); + } + const text = await response.text(); + if (!response.ok) throw new Error(`${label}返回 HTTP ${response.status}。`); + try { + return JSON.parse(text); + } catch { + throw new Error(`${label}响应不是合法 JSON。`); + } +} + +async function queryWechatOrder(env, openid, orderId) { + const appId = requiredEnv( + env, + ['WECHAT_MINI_PROGRAM_APP_ID', 'WECHAT_APP_ID'], + '小程序 AppID', + ); + const appSecret = requiredEnv( + env, + ['WECHAT_MINI_PROGRAM_APP_SECRET', 'WECHAT_APP_SECRET'], + '小程序 AppSecret', + ); + const paymentEnvText = String( + env.WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV ?? '0', + ).trim(); + if (!/^[01]$/u.test(paymentEnvText)) { + throw new Error('WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV 只允许 0 或 1。'); + } + const paymentEnv = Number(paymentEnvText); + const appKey = requiredEnv( + env, + paymentEnv === 1 + ? ['WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY'] + : ['WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY'], + paymentEnv === 1 ? '虚拟支付沙箱 AppKey' : '虚拟支付 AppKey', + ); + const stableTokenEndpoint = + env.WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT || OFFICIAL_STABLE_TOKEN_ENDPOINT; + const queryEndpoint = + env.WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT || + OFFICIAL_QUERY_ORDER_ENDPOINT; + const tokenResponse = await postJson( + stableTokenEndpoint, + { + grant_type: 'client_credential', + appid: appId, + secret: appSecret, + force_refresh: false, + }, + '微信 stable_token ', + ); + if (Number(tokenResponse.errcode ?? 0) !== 0) { + throw new Error(`微信 stable_token 返回错误:${tokenResponse.errcode}。`); + } + const accessToken = String(tokenResponse.access_token ?? '').trim(); + if (!accessToken) + throw new Error('微信 stable_token 响应缺少 access_token。'); + + const body = JSON.stringify({ openid, env: paymentEnv, order_id: orderId }); + const url = new URL(queryEndpoint); + url.searchParams.set('access_token', accessToken); + url.searchParams.set('pay_sig', calcPaySig(appKey, QUERY_ORDER_URI, body)); + const queryResponse = await postJson(url, body, '微信虚拟支付查单'); + if (Number(queryResponse.errcode ?? 0) !== 0) { + throw new Error(`微信虚拟支付查单返回错误:${queryResponse.errcode}。`); + } + if (!queryResponse.order || typeof queryResponse.order !== 'object') { + throw new Error('微信虚拟支付查单响应缺少 order。'); + } + return { order: queryResponse.order, queryEndpoint, stableTokenEndpoint }; +} + +export function paidAtMicrosFromWechatOrder(order) { + const seconds = Number(order.paid_time); + if (!Number.isSafeInteger(seconds) || seconds <= 0) { + throw new Error('微信已支付订单缺少合法 paid_time。'); + } + const micros = seconds * 1_000_000; + if (!Number.isSafeInteger(micros)) { + throw new Error('微信已支付订单 paid_time 超出安全整数范围。'); + } + return micros; +} + +function assertApplyEndpoints(options, endpoints) { + if (!options.apply || options.allowNonOfficialEndpoint) return; + if ( + endpoints.queryEndpoint !== OFFICIAL_QUERY_ORDER_ENDPOINT || + endpoints.stableTokenEndpoint !== OFFICIAL_STABLE_TOKEN_ENDPOINT + ) { + throw new Error( + '--apply 默认只允许微信官方 endpoint;受控测试才可追加 --allow-non-official-endpoint。', + ); + } +} + +export async function run(options) { + const env = parseEnvText( + await readRegularFile(options.envFile, '--env-file'), + ); + const spacetimeToken = requiredEnv( + env, + ['GENARRATIVE_SPACETIME_TOKEN'], + 'GENARRATIVE_SPACETIME_TOKEN', + ); + const spacetimeOptions = { ...options, token: spacetimeToken }; + const openid = ( + await readRegularFile(options.openidFile, '--openid-file', { + requirePrivate: true, + }) + ).trim(); + if (!openid || openid.length > 256 || /\s/u.test(openid)) { + throw new Error('--openid-file 内容不是合法单行 openid。'); + } + + const localResult = await callProfileRechargeProcedure( + spacetimeOptions, + 'get_profile_recharge_order_and_return', + { order_id: options.orderId }, + ); + const localOrder = parseLocalOrder(localResult, options.orderId); + const queried = await queryWechatOrder(env, openid, options.orderId); + assertApplyEndpoints(options, queried); + const validation = validateQueryResult(localOrder, queried.order); + const paidAtMicros = validation.eligibleForCredit + ? paidAtMicrosFromWechatOrder(queried.order) + : null; + const providerTransactionId = + String(queried.order.wxpay_order_id ?? '').trim() || + String(queried.order.wx_order_id ?? '').trim() || + null; + const facts = { + amountCents: localOrder.amountCents, + eligibleForCredit: validation.eligibleForCredit, + localKind: localOrder.kind, + localStatus: localOrder.status, + orderId: localOrder.orderId, + paidAtMicros, + providerTransactionId, + userId: localOrder.userId, + wechatOrderType: Number(queried.order.order_type), + wechatStatus: validation.status, + }; + const applyFingerprint = buildReconcileFingerprint(facts); + const output = { + apply: options.apply, + applyFingerprint, + dryRun: !options.apply, + eligibleForCredit: facts.eligibleForCredit, + localKind: facts.localKind, + localStatus: facts.localStatus, + orderId: facts.orderId, + paidAtMicros: facts.paidAtMicros, + providerTransactionId: facts.providerTransactionId, + wechatOrderType: facts.wechatOrderType, + wechatStatus: facts.wechatStatus, + }; + + if (!options.apply) { + console.log(JSON.stringify(output, null, 2)); + return output; + } + if (options.confirm !== applyFingerprint) { + throw new Error( + '本次复核事实与 --confirm 不一致,请重新 dry-run 并人工审核。', + ); + } + if (!validation.eligibleForCredit) { + throw new Error(`微信订单状态 ${validation.status} 不允许补入账。`); + } + const applyResult = await callProfileRechargeProcedure( + spacetimeOptions, + 'mark_profile_recharge_order_paid_and_return', + { + order_id: options.orderId, + paid_at_micros: paidAtMicros, + provider_transaction_id: encodeSpacetimeCliOption(providerTransactionId), + }, + ); + ensureProcedureOk(applyResult); + const appliedOrder = normalizeLocalOrderSnapshot( + unwrapOption(applyResult.order), + ); + const appliedStatus = normalizeVariant(appliedOrder?.status, [ + 'pending', + 'paid', + 'failed', + 'closed', + 'refunded', + 'expired', + ]); + if (appliedStatus !== 'paid') { + throw new Error( + `入账 procedure 返回状态 ${appliedStatus || ''},未确认为 paid。`, + ); + } + const appliedOutput = { ...output, appliedStatus }; + console.log(JSON.stringify(appliedOutput, null, 2)); + return appliedOutput; +} + +async function main() { + try { + const options = parseOptions(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + await run(options); + } catch (error) { + console.error( + `[wechat-virtual-payment-reconcile] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + await main(); +} diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 8333ba60b..96361942e 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -4547,6 +4547,7 @@ dependencies = [ "sha2", "shared-contracts", "time", + "tokio", "tracing", "url", "urlencoding", diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index c30af9396..8c08c11ed 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -145,6 +145,7 @@ 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_virtual_payment_query_order_endpoint: String, pub wechat_mini_program_message_token: Option, pub wechat_mini_program_message_encoding_aes_key: Option, pub wechat_mini_program_subscribe_message_enabled: bool, @@ -400,6 +401,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_virtual_payment_query_order_endpoint: + "https://api.weixin.qq.com/xpay/query_order".to_string(), wechat_mini_program_message_token: None, wechat_mini_program_message_encoding_aes_key: None, wechat_mini_program_subscribe_message_enabled: true, @@ -941,6 +944,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(endpoint) = + read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT"]) + { + config.wechat_mini_program_virtual_payment_query_order_endpoint = endpoint; + } config.wechat_mini_program_message_token = read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]); config.wechat_mini_program_message_encoding_aes_key = @@ -2083,6 +2091,7 @@ 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_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"); @@ -2110,6 +2119,10 @@ mod tests { "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY", "sandbox-app-key-001", ); + std::env::set_var( + "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT", + "http://127.0.0.1:18080/xpay/query_order", + ); std::env::set_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN", "message-token-001"); std::env::set_var( "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", @@ -2181,6 +2194,10 @@ mod tests { ); assert_eq!(config.wechat_mini_program_subscribe_message_state, "trial"); assert_eq!(config.wechat_mini_program_virtual_payment_env, 1); + assert_eq!( + config.wechat_mini_program_virtual_payment_query_order_endpoint, + "http://127.0.0.1:18080/xpay/query_order" + ); unsafe { std::env::remove_var("WECHAT_PAY_ENABLED"); @@ -2195,6 +2212,7 @@ 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_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"); diff --git a/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs b/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs index 976870934..f9a7b1dda 100644 --- a/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs +++ b/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs @@ -4,12 +4,20 @@ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, }; +use platform_wechat::WechatError; use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder}; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use tokio::time::sleep; use tracing::{debug, info, warn}; -use crate::{state::AppState, wechat::pay::current_unix_micros}; +use crate::{ + state::AppState, + wechat::pay::{ + build_wechat_virtual_payment_query_order_request, current_unix_micros, + is_wechat_virtual_payment_order_paid, paid_at_micros_from_wechat_virtual_payment_order, + validate_wechat_virtual_payment_order, + }, +}; const PROFILE_RECHARGE_EXPIRATION_LISTENER_RECONNECT_DELAY: Duration = Duration::from_secs(5); const PROFILE_RECHARGE_EXPIRATION_CATCH_UP_LIMIT: u32 = 100; @@ -184,16 +192,7 @@ async fn process_expired_profile_recharge_order_once( } if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL { - mark_profile_recharge_expiration_checked( - state, - &order.order_id, - Some(current_unix_micros()), - Some("VIRTUAL_UNQUERYABLE".to_string()), - Some("wechat_mp_virtual has no v3 transaction query".to_string()), - ) - .await?; - state.publish_profile_recharge_order_update(order.order_id.clone()); - return Ok(()); + return process_expired_virtual_payment_order(state, order).await; } let wechat_order = match state @@ -260,6 +259,65 @@ async fn process_expired_profile_recharge_order_once( } } +async fn process_expired_virtual_payment_order( + state: &AppState, + order: &RuntimeProfileRechargeOrderRecord, +) -> Result<(), ExpirationCompensationError> { + let identity = state + .wechat_auth_service() + .get_identity_by_user_id(&order.user_id) + .map_err(|error| { + ExpirationCompensationError::Runtime(format!( + "failed to read WeChat identity for virtual payment query: {error}" + )) + })? + .ok_or_else(|| { + ExpirationCompensationError::Runtime( + "virtual payment query requires the user's WeChat identity".to_string(), + ) + })?; + let query_request = build_wechat_virtual_payment_query_order_request( + &state.config, + identity.provider_uid, + order.order_id.clone(), + )?; + let wechat_order = state + .wechat_client() + .query_virtual_payment_order(query_request) + .await?; + validate_wechat_virtual_payment_order(&order.order_id, order.amount_cents, &wechat_order)?; + + if is_wechat_virtual_payment_order_paid(wechat_order.status) { + let paid_at_micros = paid_at_micros_from_wechat_virtual_payment_order(&wechat_order); + state + .spacetime_client() + .mark_profile_recharge_order_paid( + order.order_id.clone(), + paid_at_micros, + wechat_order.wxpay_order_id.or(wechat_order.wx_order_id), + ) + .await?; + state.publish_profile_recharge_order_update(order.order_id.clone()); + info!( + order_id = order.order_id.as_str(), + virtual_payment_status = wechat_order.status, + "expired virtual payment recharge order compensated as paid" + ); + return Ok(()); + } + + mark_profile_recharge_expiration_checked( + state, + &order.order_id, + Some(current_unix_micros()), + Some(format!("VIRTUAL_STATUS_{}", wechat_order.status)), + None, + ) + .await?; + state.publish_profile_recharge_order_update(order.order_id.clone()); + Ok(()) +} + async fn mark_expired_profile_recharge_order_paid( state: &AppState, order: &RuntimeProfileRechargeOrderRecord, @@ -306,19 +364,9 @@ async fn mark_profile_recharge_expiration_checked( Ok(()) } -async fn record_profile_recharge_expiration_error( - state: &AppState, - order_id: &str, - error: String, -) { - if let Err(mark_error) = mark_profile_recharge_expiration_checked( - state, - order_id, - None, - None, - Some(error), - ) - .await +async fn record_profile_recharge_expiration_error(state: &AppState, order_id: &str, error: String) { + if let Err(mark_error) = + mark_profile_recharge_expiration_checked(state, order_id, None, None, Some(error)).await { warn!( order_id, @@ -330,11 +378,18 @@ async fn record_profile_recharge_expiration_error( #[derive(Debug)] enum ExpirationCompensationError { + Wechat(WechatError), WechatPay(WechatPayError), Spacetime(spacetime_client::SpacetimeClientError), Runtime(String), } +impl From for ExpirationCompensationError { + fn from(error: WechatError) -> Self { + Self::Wechat(error) + } +} + impl From for ExpirationCompensationError { fn from(error: WechatPayError) -> Self { Self::WechatPay(error) @@ -350,6 +405,7 @@ impl From for ExpirationCompensationErro impl std::fmt::Display for ExpirationCompensationError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::Wechat(error) => write!(formatter, "wechat error: {error}"), Self::WechatPay(error) => write!(formatter, "wechat pay error: {error}"), Self::Spacetime(error) => write!(formatter, "spacetime error: {error}"), Self::Runtime(message) => formatter.write_str(message), diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 10601bf1d..d5a1a745b 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -89,11 +89,14 @@ use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + platform_errors::map_wechat_error, request_context::RequestContext, state::AppState, wechat::pay::{ - build_wechat_payment_request, build_wechat_web_payment_request, current_unix_micros, - map_wechat_pay_error, + build_wechat_payment_request, build_wechat_virtual_payment_query_order_request, + build_wechat_web_payment_request, current_unix_micros, + is_wechat_virtual_payment_order_paid, map_wechat_pay_error, + paid_at_micros_from_wechat_virtual_payment_order, validate_wechat_virtual_payment_order, }, }; @@ -389,6 +392,52 @@ pub async fn confirm_wechat_profile_recharge_order( )); } if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL { + let openid = resolve_wechat_identity_for_payment(&state, &order.user_id) + .await + .map_err(|error| runtime_profile_error_response(&request_context, error))?; + let query_request = build_wechat_virtual_payment_query_order_request( + &state.config, + openid, + order.order_id.clone(), + ) + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_error(error)) + })?; + let wechat_order = state + .wechat_client() + .query_virtual_payment_order(query_request) + .await + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_error(error)) + })?; + validate_wechat_virtual_payment_order(&order.order_id, order.amount_cents, &wechat_order) + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_error(error)) + })?; + if !is_wechat_virtual_payment_order_paid(wechat_order.status) { + return Ok(json_success_body( + Some(&request_context), + build_wechat_profile_recharge_order_confirmation(center, order), + )); + } + + let paid_at_micros = paid_at_micros_from_wechat_virtual_payment_order(&wechat_order); + let (center, order) = state + .spacetime_client() + .mark_profile_recharge_order_paid( + wechat_order.order_id, + paid_at_micros, + wechat_order.wxpay_order_id.or(wechat_order.wx_order_id), + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + state.publish_profile_recharge_order_update(order.order_id.clone()); + return Ok(json_success_body( Some(&request_context), build_wechat_profile_recharge_order_confirmation(center, order), diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index dc0da4fc0..ba483dacb 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1883,6 +1883,9 @@ fn build_wechat_client(config: &AppConfig) -> WechatClient { subscribe_message_endpoint: config .wechat_mini_program_subscribe_message_endpoint .clone(), + virtual_payment_query_order_endpoint: config + .wechat_mini_program_virtual_payment_query_order_endpoint + .clone(), }) } diff --git a/server-rs/crates/api-server/src/wechat/pay.rs b/server-rs/crates/api-server/src/wechat/pay.rs index 151ba77c0..9fb8855f2 100644 --- a/server-rs/crates/api-server/src/wechat/pay.rs +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -11,6 +11,9 @@ use platform_wechat::pay::{ parse_virtual_payment_notify, parse_wechat_mini_program_message_push_payload, resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature, }; +use platform_wechat::{ + WechatError, WechatVirtualPaymentOrder, WechatVirtualPaymentQueryOrderRequest, +}; use serde::Serialize; use serde_json::json; use shared_kernel::offset_datetime_to_unix_micros; @@ -248,6 +251,80 @@ pub fn build_wechat_pay_config(config: &AppConfig) -> WechatPayConfig { } } +pub fn build_wechat_virtual_payment_query_order_request( + config: &AppConfig, + openid: String, + order_id: String, +) -> Result { + let app_key = match config.wechat_mini_program_virtual_payment_env { + 0 => config + .wechat_mini_program_virtual_payment_app_key + .as_deref(), + 1 => config + .wechat_mini_program_virtual_payment_sandbox_app_key + .as_deref(), + env => { + return Err(WechatError::InvalidConfig(format!( + "微信虚拟支付查单 env 只允许 0 或 1,当前为 {env}" + ))); + } + } + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatError::InvalidConfig(match config.wechat_mini_program_virtual_payment_env { + 1 => "微信虚拟支付沙箱 AppKey 未配置".to_string(), + _ => "微信虚拟支付 AppKey 未配置".to_string(), + }) + })?; + + Ok(WechatVirtualPaymentQueryOrderRequest { + openid, + order_id, + env: config.wechat_mini_program_virtual_payment_env, + app_key: app_key.to_string(), + }) +} + +pub fn validate_wechat_virtual_payment_order( + expected_order_id: &str, + expected_amount_cents: u64, + order: &WechatVirtualPaymentOrder, +) -> Result<(), WechatError> { + if order.order_id != expected_order_id { + return Err(WechatError::Upstream( + "微信虚拟支付查单返回的订单号与本地订单不一致".to_string(), + )); + } + if !matches!(order.order_type, 0 | 7) { + return Err(WechatError::Upstream( + "微信虚拟支付查单返回的不是可入账支付单".to_string(), + )); + } + if order.order_fee != expected_amount_cents { + return Err(WechatError::Upstream( + "微信虚拟支付查单返回的金额与本地订单不一致".to_string(), + )); + } + if !(0..=10).contains(&order.status) { + return Err(WechatError::Upstream( + "微信虚拟支付查单返回了未知订单状态".to_string(), + )); + } + Ok(()) +} + +pub fn is_wechat_virtual_payment_order_paid(status: i64) -> bool { + matches!(status, 2..=4) +} + +pub fn paid_at_micros_from_wechat_virtual_payment_order(order: &WechatVirtualPaymentOrder) -> i64 { + order + .paid_time + .and_then(|seconds| seconds.checked_mul(1_000_000)) + .unwrap_or_else(current_unix_micros) +} + pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { match error { WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST) @@ -345,6 +422,82 @@ fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Res (StatusCode::BAD_REQUEST, message).into_response() } +#[cfg(test)] +mod tests { + use super::{ + build_wechat_virtual_payment_query_order_request, is_wechat_virtual_payment_order_paid, + validate_wechat_virtual_payment_order, + }; + use crate::config::AppConfig; + use platform_wechat::WechatVirtualPaymentOrder; + + #[test] + fn virtual_payment_query_uses_the_key_for_the_selected_environment() { + let config = AppConfig { + wechat_mini_program_virtual_payment_app_key: Some("production-key".to_string()), + wechat_mini_program_virtual_payment_sandbox_app_key: Some("sandbox-key".to_string()), + wechat_mini_program_virtual_payment_env: 1, + ..AppConfig::default() + }; + + let request = build_wechat_virtual_payment_query_order_request( + &config, + "openid-001".to_string(), + "order-001".to_string(), + ) + .expect("sandbox query request should build"); + + assert_eq!(request.app_key, "sandbox-key"); + assert_eq!(request.env, 1); + } + + #[test] + fn virtual_payment_query_only_treats_paid_and_delivery_states_as_paid() { + assert!(!is_wechat_virtual_payment_order_paid(0)); + assert!(!is_wechat_virtual_payment_order_paid(1)); + assert!(is_wechat_virtual_payment_order_paid(2)); + assert!(is_wechat_virtual_payment_order_paid(3)); + assert!(is_wechat_virtual_payment_order_paid(4)); + assert!(!is_wechat_virtual_payment_order_paid(5)); + assert!(!is_wechat_virtual_payment_order_paid(6)); + } + + #[test] + fn virtual_payment_query_rejects_a_mismatched_amount_before_crediting() { + let order = WechatVirtualPaymentOrder { + order_id: "order-001".to_string(), + status: 2, + order_fee: 601, + order_type: 0, + paid_time: Some(1_777_111_300), + wx_order_id: Some("wx-order-001".to_string()), + wxpay_order_id: Some("wxpay-order-001".to_string()), + }; + + assert!(validate_wechat_virtual_payment_order("order-001", 600, &order).is_err()); + } + + #[test] + fn virtual_payment_query_accepts_ios_payment_and_rejects_refunds() { + let mut order = WechatVirtualPaymentOrder { + order_id: "order-001".to_string(), + status: 2, + order_fee: 600, + order_type: 7, + paid_time: Some(1_777_111_300), + wx_order_id: Some("wx-order-001".to_string()), + wxpay_order_id: None, + }; + + validate_wechat_virtual_payment_order("order-001", 600, &order) + .expect("iOS payment order should be eligible for confirmation"); + for refund_type in [1, 8] { + order.order_type = refund_type; + assert!(validate_wechat_virtual_payment_order("order-001", 600, &order).is_err()); + } + } +} + fn build_virtual_payment_notify_error_response( error: WechatPayError, response_format: VirtualPaymentNotifyResponseFormat, diff --git a/server-rs/crates/platform-wechat/Cargo.toml b/server-rs/crates/platform-wechat/Cargo.toml index 821b11968..2d6f4d436 100644 --- a/server-rs/crates/platform-wechat/Cargo.toml +++ b/server-rs/crates/platform-wechat/Cargo.toml @@ -17,7 +17,11 @@ sha1 = { workspace = true } sha2 = { workspace = true } shared-contracts = { workspace = true } time = { workspace = true, features = ["formatting"] } +tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } x509-parser = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/server-rs/crates/platform-wechat/src/lib.rs b/server-rs/crates/platform-wechat/src/lib.rs index e1c4d5a53..01e2dfcf5 100644 --- a/server-rs/crates/platform-wechat/src/lib.rs +++ b/server-rs/crates/platform-wechat/src/lib.rs @@ -7,5 +7,7 @@ pub use pay::{ }; pub use subscribe_message::{ DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT, - WechatClient, WechatConfig, WechatError, WechatErrorKind, WechatSubscribeMessageRequest, + DEFAULT_WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT, WechatClient, WechatConfig, WechatError, + WechatErrorKind, WechatSubscribeMessageRequest, WechatVirtualPaymentOrder, + WechatVirtualPaymentQueryOrderRequest, }; diff --git a/server-rs/crates/platform-wechat/src/subscribe_message.rs b/server-rs/crates/platform-wechat/src/subscribe_message.rs index 0935554ec..ff265b9ab 100644 --- a/server-rs/crates/platform-wechat/src/subscribe_message.rs +++ b/server-rs/crates/platform-wechat/src/subscribe_message.rs @@ -1,8 +1,16 @@ -use std::{collections::BTreeMap, error::Error, fmt}; +use std::{ + collections::BTreeMap, + error::Error, + fmt, + sync::Arc, + time::{Duration, Instant}, +}; use reqwest::Client; +use ring::hmac; use serde::{Deserialize, Serialize}; use serde_json::json; +use tokio::sync::Mutex; use tracing::warn; use url::Url; @@ -10,6 +18,10 @@ 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"; +pub const DEFAULT_WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT: &str = + "https://api.weixin.qq.com/xpay/query_order"; +const WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI: &str = "/xpay/query_order"; +const WECHAT_ACCESS_TOKEN_REFRESH_SAFETY_MARGIN: Duration = Duration::from_secs(5 * 60); #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatConfig { @@ -17,12 +29,20 @@ pub struct WechatConfig { pub app_secret: Option, pub stable_access_token_endpoint: String, pub subscribe_message_endpoint: String, + pub virtual_payment_query_order_endpoint: String, } #[derive(Clone, Debug)] pub struct WechatClient { client: Client, config: WechatConfig, + access_token_cache: Arc>>, +} + +#[derive(Clone, Debug)] +struct WechatAccessTokenCacheEntry { + access_token: String, + expires_at: Instant, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -35,6 +55,25 @@ pub struct WechatSubscribeMessageRequest { pub data: BTreeMap, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatVirtualPaymentQueryOrderRequest { + pub openid: String, + pub order_id: String, + pub env: u8, + pub app_key: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatVirtualPaymentOrder { + pub order_id: String, + pub status: i64, + pub order_fee: u64, + pub order_type: i64, + pub paid_time: Option, + pub wx_order_id: Option, + pub wxpay_order_id: Option, +} + #[derive(Debug, PartialEq, Eq)] pub enum WechatError { InvalidConfig(String), @@ -54,6 +93,7 @@ pub enum WechatErrorKind { #[derive(Debug, Deserialize)] struct WechatStableAccessTokenResponse { access_token: Option, + expires_in: Option, errcode: Option, errmsg: Option, } @@ -64,6 +104,34 @@ struct WechatSubscribeMessageResponse { errmsg: Option, } +#[derive(Debug, Serialize)] +struct WechatVirtualPaymentQueryOrderBody<'a> { + openid: &'a str, + env: u8, + order_id: &'a str, +} + +#[derive(Debug, Deserialize)] +struct WechatVirtualPaymentQueryOrderResponse { + errcode: i64, + errmsg: Option, + order: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatVirtualPaymentQueryOrderPayload { + order_id: String, + status: i64, + order_fee: u64, + order_type: i64, + #[serde(default)] + paid_time: Option, + #[serde(default)] + wx_order_id: Option, + #[serde(default)] + wxpay_order_id: Option, +} + #[derive(Debug, Serialize)] struct WechatTemplateDataValue { value: String, @@ -74,6 +142,7 @@ impl WechatClient { Self { client: Client::new(), config, + access_token_cache: Arc::new(Mutex::new(None)), } } @@ -146,11 +215,125 @@ impl WechatClient { Ok(()) } + pub async fn query_virtual_payment_order( + &self, + request: WechatVirtualPaymentQueryOrderRequest, + ) -> Result { + 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 openid = non_empty(&request.openid) + .ok_or_else(|| WechatError::InvalidConfig("微信虚拟支付查单缺少 openid".to_string()))?; + let order_id = non_empty(&request.order_id).ok_or_else(|| { + WechatError::InvalidConfig("微信虚拟支付查单缺少 order_id".to_string()) + })?; + let app_key = non_empty(&request.app_key) + .ok_or_else(|| WechatError::InvalidConfig("微信虚拟支付查单缺少 AppKey".to_string()))?; + if request.env > 1 { + return Err(WechatError::InvalidConfig( + "微信虚拟支付查单 env 只允许 0 或 1".to_string(), + )); + } + + let body = serde_json::to_string(&WechatVirtualPaymentQueryOrderBody { + openid, + env: request.env, + order_id, + }) + .map_err(|error| { + WechatError::DeserializeFailed(format!("微信虚拟支付查单请求序列化失败:{error}")) + })?; + let pay_sig = calc_virtual_payment_pay_signature( + app_key, + WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI, + &body, + ); + let access_token = self.request_access_token(app_id, app_secret).await?; + let mut url = + Url::parse(&self.config.virtual_payment_query_order_endpoint).map_err(|error| { + WechatError::InvalidConfig(format!("微信虚拟支付查单地址非法:{error}")) + })?; + url.query_pairs_mut() + .append_pair("access_token", &access_token) + .append_pair("pay_sig", &pay_sig); + + let response = self + .client + .post(url.as_str()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body) + .send() + .await + .map_err(|_| { + warn!("微信虚拟支付查单请求失败"); + WechatError::RequestFailed("微信虚拟支付查单请求失败".to_string()) + })?; + let status = response.status(); + let response_text = response.text().await.map_err(|error| { + warn!(error = %error, "微信虚拟支付查单响应读取失败"); + WechatError::DeserializeFailed("微信虚拟支付查单响应读取失败".to_string()) + })?; + if !status.is_success() { + return Err(WechatError::Upstream(format!( + "微信虚拟支付查单失败:HTTP {status}" + ))); + } + let response = + serde_json::from_str::(&response_text) + .map_err(|error| { + warn!(error = %error, "微信虚拟支付查单响应解析失败"); + WechatError::DeserializeFailed("微信虚拟支付查单响应非法".to_string()) + })?; + if response.errcode != 0 { + return Err(WechatError::Upstream(format!( + "微信虚拟支付查单返回错误:{}", + response + .errmsg + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| format!("errcode={}", response.errcode)) + ))); + } + let order = response + .order + .ok_or_else(|| WechatError::Upstream("微信虚拟支付查单响应缺少 order".to_string()))?; + if order.order_id != order_id { + return Err(WechatError::Upstream( + "微信虚拟支付查单返回的订单号与请求不一致".to_string(), + )); + } + + Ok(WechatVirtualPaymentOrder { + order_id: order.order_id, + status: order.status, + order_fee: order.order_fee, + order_type: order.order_type, + paid_time: order.paid_time.filter(|value| *value > 0), + wx_order_id: non_empty_owned_option(order.wx_order_id), + wxpay_order_id: non_empty_owned_option(order.wxpay_order_id), + }) + } + async fn request_access_token( &self, app_id: &str, app_secret: &str, ) -> Result { + let mut cache = self.access_token_cache.lock().await; + if let Some(entry) = cache.as_ref() + && Instant::now() < entry.expires_at + { + return Ok(entry.access_token.clone()); + } + let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| { WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}")) })?; @@ -185,10 +368,24 @@ impl WechatClient { ))); } - payload + let expires_in = payload.expires_in.unwrap_or(7_200); + let access_token = payload .access_token .and_then(|value| non_empty_owned(value)) - .ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string())) + .ok_or_else(|| { + WechatError::Upstream("微信 stable_token 缺少 access_token".to_string()) + })?; + let cache_lifetime = Duration::from_secs(expires_in) + .saturating_sub(WECHAT_ACCESS_TOKEN_REFRESH_SAFETY_MARGIN); + if !cache_lifetime.is_zero() + && let Some(expires_at) = Instant::now().checked_add(cache_lifetime) + { + *cache = Some(WechatAccessTokenCacheEntry { + access_token: access_token.clone(), + expires_at, + }); + } + Ok(access_token) } } @@ -232,3 +429,148 @@ fn non_empty_owned(value: String) -> Option { Some(value) } } + +fn non_empty_owned_option(value: Option) -> Option { + value.and_then(non_empty_owned) +} + +fn calc_virtual_payment_pay_signature(app_key: &str, uri: &str, body: &str) -> String { + let key = hmac::Key::new(hmac::HMAC_SHA256, app_key.as_bytes()); + hex::encode(hmac::sign(&key, format!("{uri}&{body}").as_bytes()).as_ref()) +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + net::TcpListener, + sync::mpsc, + thread, + }; + + use super::{ + WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI, WechatClient, WechatConfig, + WechatVirtualPaymentQueryOrderBody, WechatVirtualPaymentQueryOrderRequest, + calc_virtual_payment_pay_signature, + }; + + #[test] + fn virtual_payment_pay_signature_matches_official_example() { + let body = r#"{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}"#; + + assert_eq!( + calc_virtual_payment_pay_signature("12345", "/xpay/query_user_balance", body), + "c37809f27c6d7fd1837ad2500a04512b66b34fd793a39a385fade56dca89a4b5" + ); + } + + #[test] + fn virtual_payment_query_order_signs_the_exact_serialized_body() { + let body = serde_json::to_string(&WechatVirtualPaymentQueryOrderBody { + openid: "openid-001", + env: 0, + order_id: "order-001", + }) + .expect("query body should serialize"); + + assert_eq!( + body, + r#"{"openid":"openid-001","env":0,"order_id":"order-001"}"# + ); + assert_eq!( + calc_virtual_payment_pay_signature( + "app-key-001", + WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI, + &body, + ), + "ca0540a55865df7b4dbd7400d4d7a6551fde4f66fff9f6290f40f23e32d33db8" + ); + } + + #[tokio::test] + async fn virtual_payment_query_order_uses_the_configured_mock_endpoint() { + let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind"); + let address = listener.local_addr().expect("mock address should resolve"); + let (request_sender, request_receiver) = mpsc::channel(); + let server = thread::spawn(move || { + for response_body in [ + r#"{"access_token":"access-token-001","expires_in":7200}"#, + r#"{"errcode":0,"errmsg":"ok","order":{"order_id":"order-001","status":2,"order_fee":600,"order_type":0,"paid_time":1777111300,"wx_order_id":"wx-order-001","wxpay_order_id":"wxpay-order-001"}}"#, + ] { + let (mut stream, _) = listener.accept().expect("mock request should connect"); + let request = read_http_request(&mut stream); + request_sender + .send(request) + .expect("mock request should be recorded"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + stream + .write_all(response.as_bytes()) + .expect("mock response should write"); + } + }); + let client = WechatClient::new(WechatConfig { + app_id: Some("wx-app-001".to_string()), + app_secret: Some("app-secret-001".to_string()), + stable_access_token_endpoint: format!("http://{address}/stable-token"), + subscribe_message_endpoint: format!("http://{address}/subscribe"), + virtual_payment_query_order_endpoint: format!("http://{address}/mock/query-order"), + }); + + let order = client + .query_virtual_payment_order(WechatVirtualPaymentQueryOrderRequest { + openid: "openid-001".to_string(), + order_id: "order-001".to_string(), + env: 0, + app_key: "app-key-001".to_string(), + }) + .await + .expect("mock query should succeed"); + + assert_eq!(order.status, 2); + assert_eq!(order.order_fee, 600); + let token_request = request_receiver + .recv() + .expect("stable token request should be recorded"); + let query_request = request_receiver + .recv() + .expect("query request should be recorded"); + assert!(token_request.starts_with("POST /stable-token HTTP/1.1")); + assert!(query_request.starts_with("POST /mock/query-order?access_token=")); + assert!(query_request.contains("&pay_sig=")); + assert!( + query_request.ends_with(r#"{"openid":"openid-001","env":0,"order_id":"order-001"}"#) + ); + server.join().expect("mock server should finish"); + } + + fn read_http_request(stream: &mut std::net::TcpStream) -> String { + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let count = stream.read(&mut buffer).expect("mock request should read"); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + if let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..header_end + 4]); + let content_length = headers + .lines() + .find_map(|line| { + line.strip_prefix("content-length:") + .or_else(|| line.strip_prefix("Content-Length:")) + }) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + if bytes.len() >= header_end + 4 + content_length { + break; + } + } + } + String::from_utf8(bytes).expect("mock request should be UTF-8") + } +}