补齐微信虚拟支付查单与补偿
接入微信官方虚拟支付查单并缓存稳定接口令牌 让用户确认与订单过期补偿统一核对支付类型、状态、金额和单号 增加单笔历史订单的 dry-run 与确认指纹补单工具 隔离运行时身份与敏感 openid 文件并补充配置契约和回归测试
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 不处理充值过期。
|
||||
|
||||
## 创作入口泥点扣费契约
|
||||
|
||||
|
||||
@@ -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 <orderId> \
|
||||
--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 <orderId> \
|
||||
--openid-file /run/genarrative-virtual-payment-openid \
|
||||
--env-file /etc/genarrative/api-server.env \
|
||||
--apply \
|
||||
--confirm <applyFingerprint>
|
||||
```
|
||||
|
||||
脚本每次重新读取本地订单并重新向微信查单;`--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 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+1
@@ -4547,6 +4547,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"shared-contracts",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
|
||||
@@ -145,6 +145,7 @@ pub struct AppConfig {
|
||||
pub wechat_mini_program_virtual_payment_offer_id: Option<String>,
|
||||
pub wechat_mini_program_virtual_payment_app_key: Option<String>,
|
||||
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
|
||||
pub wechat_mini_program_virtual_payment_query_order_endpoint: String,
|
||||
pub wechat_mini_program_message_token: Option<String>,
|
||||
pub wechat_mini_program_message_encoding_aes_key: Option<String>,
|
||||
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");
|
||||
|
||||
@@ -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<WechatError> for ExpirationCompensationError {
|
||||
fn from(error: WechatError) -> Self {
|
||||
Self::Wechat(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WechatPayError> for ExpirationCompensationError {
|
||||
fn from(error: WechatPayError) -> Self {
|
||||
Self::WechatPay(error)
|
||||
@@ -350,6 +405,7 @@ impl From<spacetime_client::SpacetimeClientError> 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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WechatVirtualPaymentQueryOrderRequest, WechatError> {
|
||||
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,
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<String>,
|
||||
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<Mutex<Option<WechatAccessTokenCacheEntry>>>,
|
||||
}
|
||||
|
||||
#[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<String, String>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
pub wx_order_id: Option<String>,
|
||||
pub wxpay_order_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum WechatError {
|
||||
InvalidConfig(String),
|
||||
@@ -54,6 +93,7 @@ pub enum WechatErrorKind {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatStableAccessTokenResponse {
|
||||
access_token: Option<String>,
|
||||
expires_in: Option<u64>,
|
||||
errcode: Option<i64>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
@@ -64,6 +104,34 @@ struct WechatSubscribeMessageResponse {
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WechatVirtualPaymentQueryOrderBody<'a> {
|
||||
openid: &'a str,
|
||||
env: u8,
|
||||
order_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatVirtualPaymentQueryOrderResponse {
|
||||
errcode: i64,
|
||||
errmsg: Option<String>,
|
||||
order: Option<WechatVirtualPaymentQueryOrderPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatVirtualPaymentQueryOrderPayload {
|
||||
order_id: String,
|
||||
status: i64,
|
||||
order_fee: u64,
|
||||
order_type: i64,
|
||||
#[serde(default)]
|
||||
paid_time: Option<i64>,
|
||||
#[serde(default)]
|
||||
wx_order_id: Option<String>,
|
||||
#[serde(default)]
|
||||
wxpay_order_id: Option<String>,
|
||||
}
|
||||
|
||||
#[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<WechatVirtualPaymentOrder, WechatError> {
|
||||
let app_id = self
|
||||
.config
|
||||
.app_id
|
||||
.as_deref()
|
||||
.and_then(non_empty)
|
||||
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?;
|
||||
let app_secret = self
|
||||
.config
|
||||
.app_secret
|
||||
.as_deref()
|
||||
.and_then(non_empty)
|
||||
.ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?;
|
||||
let 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::<WechatVirtualPaymentQueryOrderResponse>(&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<String, WechatError> {
|
||||
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<String> {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_owned_option(value: Option<String>) -> Option<String> {
|
||||
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::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
if bytes.len() >= header_end + 4 + content_length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(bytes).expect("mock request should be UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user