diff --git a/.env.example b/.env.example index 30a8aca29..f9a6f0423 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,7 @@ 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_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT="https://api.weixin.qq.com/xpay/notify_provide_goods" 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 5732cfb02..15f59ed04 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4009,7 +4009,7 @@ ## 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` 不发放权益。 +- 决策:`platform-wechat` 按官方协议调用 `POST /xpay/query_order`,使用小程序 `access_token` 与 `HMAC-SHA256(appKey, "/xpay/query_order&" + 实际 JSON body)` 支付签名。用户确认和订单到期补偿都可查虚拟支付订单,但只在单号、支付类型 `order_type=0/7`、金额、合法 `paid_time` 一致且微信状态为 `2/3/4` 时补入账;退款类型 `1/8` 不发放权益。`short_series_goods` 的 `status=2` 在本地入账后必须补调 `/xpay/notify_provide_goods`,失败可从本地 `paid` 状态只重试发货;token 明确失效时强制刷新并最多重放一次。 - 边界:查单使用订单所属用户的小程序 `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 3dd2f5e2e..42818aef7 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` 只记录检查结果。`wechat_mp_virtual` 使用小程序 `access_token` 和虚拟支付 AppKey 调用官方 `/xpay/query_order`,只在返回单号、支付类型 `order_type=0/7`、金额与本地契约一致且状态为 `2/3/4` 时补入账;退款类型 `1/8` 不得触发充值,其余已知状态只记录检查结果。`external-generation-worker` / controller 不处理充值过期。 +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`、金额、合法 `paid_time` 与本地契约一致且状态为 `2/3/4` 时补入账;退款类型 `1/8` 不得触发充值,其余已知状态只记录检查结果。`short_series_goods` 从 `status=2` 恢复时先幂等入账,再调用 `/xpay/notify_provide_goods`,失败后允许基于本地 `paid` 状态只重试发货;`short_series_coin` 不调用现金单发货接口。`external-generation-worker` / controller 不处理充值过期。 ## 创作入口泥点扣费契约 diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index ef2bb8a3a..acf9cc4d8 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -35,6 +35,7 @@ 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_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT=https://api.weixin.qq.com/xpay/notify_provide_goods WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token> WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey> WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED=true @@ -49,9 +50,10 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 - `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` 只记录供应方状态,不直接发放泥点或会员权益。 +- 查单返回 `status=2/3/4` 分别表示“已支付待发货 / 发货中 / 已发货”,只有这三种状态可以补确认本地订单入账。入账前必须同时校验 `order_id`、支付类型 `order_type=0/7`、`order_fee` 与合法 `paid_time`;不得用本机当前时间伪造结算时间。`status=0/1/5..10` 只记录供应方状态,不直接发放泥点或会员权益。 +- `short_series_goods` 查单恢复到 `status=2` 时,必须先完成本地幂等入账,再调用官方 `/xpay/notify_provide_goods` 补发货确认;`status=3/4` 不重复通知。发货确认失败时,本地订单已是 `paid`,后续用户确认、到期补偿重试或历史脚本必须允许只重试发货,不得再次发放权益。`short_series_coin` 不调用该现金单发货接口。 - `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 路径。 +- `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT` 与 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT` 默认使用微信官方地址,仅用于测试注入 mock endpoint,生产不应覆盖。`WechatConfig` 支持注入 stable token、query order 与 notify provide goods endpoint;缓存 token 遇到 `40001/40014/42001` 时只清除本次失败 token,强制刷新并最多重放一次。 - 泥点属于微信虚拟支付代币(coin),`short_series_coin` 的 `buyQuantity` 必须使用当前泥点商品的 `points_amount`;例如 60 泥点商品应传 `buyQuantity: 60`。 - 会员直购 `signData` 额外包含 `productId` 和 `goodsPrice`;`goodsPrice` 使用后端商品配置价,和微信后台道具价格校验保持一致。 - 微信小程序“开发者服务器接收消息推送”必须配置为安全模式,数据格式选 JSON,URL 统一指向 `/api/profile/recharge/wechat/virtual-notify`。 @@ -85,6 +87,7 @@ npm run spacetime:wechat-virtual-payment:reconcile -- \ --env-file /etc/genarrative/api-server.env # 只有 dry-run 显示 eligibleForCredit=true 时,使用同一订单追加 --apply 和当次指纹。 +# dry-run 结束会删除 openid 临时文件;apply 前需按同样的 0600 要求重新准备该文件。 npm run spacetime:wechat-virtual-payment:reconcile -- \ --database genarrative-prod \ --server-url http://127.0.0.1:3101 \ @@ -95,7 +98,7 @@ npm run spacetime:wechat-virtual-payment:reconcile -- \ --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 文件。 +脚本每次重新读取本地订单并重新向微信查单;`--apply` 要求事实指纹与前一次 dry-run 完全一致,只对 `status=2/3/4` 且单号、金额、支付类型 `order_type=0/7` 和 `paid_time` 一致的订单调用既有 `mark_profile_recharge_order_paid_and_return`。本地已 `paid` 的 `short_series_goods` 订单仍允许重跑同一 dry-run/apply 门禁,以便对微信 `status=2` 只补发货确认。脚本使用 `/etc/genarrative/api-server.env` 内的 `GENARRATIVE_SPACETIME_TOKEN` 通过显式 `--server-url` 调用 procedure,不复用 migration operator 身份;不输出 openid、AppSecret、AppKey、access token 或 SpacetimeDB token,且 apply 默认拒绝非微信官方 endpoint。读取成功后的临时 openid 文件无论处理成功失败都会删除。 ## 注意事项 diff --git a/scripts/check-wechat-virtual-payment-reconcile.mjs b/scripts/check-wechat-virtual-payment-reconcile.mjs index ff481920a..9c7fef36c 100644 --- a/scripts/check-wechat-virtual-payment-reconcile.mjs +++ b/scripts/check-wechat-virtual-payment-reconcile.mjs @@ -6,6 +6,7 @@ import { buildReconcileFingerprint, calcPaySig, normalizeLocalOrderSnapshot, + notifyWechatGoodsDelivered, paidAtMicrosFromWechatOrder, parseEnvText, validateQueryResult, @@ -54,6 +55,10 @@ assert.equal( 1_777_111_200_000_000, ); assert.throws(() => paidAtMicrosFromWechatOrder({}), /paid_time/u); +assert.throws( + () => paidAtMicrosFromWechatOrder({ paid_time: Number.MAX_SAFE_INTEGER }), + /安全整数范围/u, +); assert.throws( () => validateQueryResult(localOrder, { ...paidOrder, order_fee: 601 }), /金额/u, @@ -104,4 +109,34 @@ assert.deepEqual( }, ); +const originalFetch = globalThis.fetch; +let notifyRequest; +globalThis.fetch = async (url, init) => { + notifyRequest = { url: String(url), init }; + return { + ok: true, + status: 200, + text: async () => '', + }; +}; +try { + await notifyWechatGoodsDelivered( + { + accessToken: 'access-token-001', + notifyProvideGoodsEndpoint: + 'https://api.weixin.qq.com/xpay/notify_provide_goods', + paymentEnv: 0, + }, + 'order-001', + ); +} finally { + globalThis.fetch = originalFetch; +} +assert.equal( + notifyRequest.url, + 'https://api.weixin.qq.com/xpay/notify_provide_goods?access_token=access-token-001', +); +assert.equal(notifyRequest.init.method, 'POST'); +assert.equal(notifyRequest.init.body, '{"order_id":"order-001","env":0}'); + 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 index 718e1ca21..eb1abba0d 100644 --- a/scripts/reconcile-wechat-virtual-payment-order.mjs +++ b/scripts/reconcile-wechat-virtual-payment-order.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { createHash, createHmac } from 'node:crypto'; -import { lstat, readFile } from 'node:fs/promises'; +import { lstat, readFile, unlink } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -14,6 +14,8 @@ 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 OFFICIAL_NOTIFY_PROVIDE_GOODS_ENDPOINT = + 'https://api.weixin.qq.com/xpay/notify_provide_goods'; const QUERY_ORDER_URI = '/xpay/query_order'; const REQUEST_TIMEOUT_MS = 15_000; @@ -21,12 +23,12 @@ 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 查单,不修改数据库。 +默认只对一条 wechat_mp_virtual pending / expired / paid 订单执行 dry-run 查单,不修改数据库;paid 仅用于重试待发货会员单。 --database 目标数据库(必填) --server-url 显式 SpacetimeDB URL(必填) --order-id 本次只核对的订单 ID(必填) - --openid-file 只包含该订单用户 openid 的 0600 普通文件(必填) + --openid-file 只包含该订单用户 openid 的 0600 普通文件(必填,读取后自动删除) --env-file api-server 生产 env 文件(必填) --apply 符合入账条件时调用既有 mark_profile_recharge_order_paid_and_return --confirm --apply 必填;使用前一次 dry-run 输出的 applyFingerprint @@ -273,9 +275,9 @@ function parseLocalOrder(result, expectedOrderId) { if (paymentChannel !== 'wechat_mp_virtual') { throw new Error('目标订单不是 wechat_mp_virtual 渠道。'); } - if (!['pending', 'expired'].includes(status)) { + if (!['pending', 'expired', 'paid'].includes(status)) { throw new Error( - `目标订单当前状态是 ${status || ''},只允许核对 pending / expired。`, + `目标订单当前状态是 ${status || ''},只允许核对 pending / expired / paid。`, ); } if (!['points', 'membership'].includes(kind)) { @@ -293,7 +295,7 @@ function parseLocalOrder(result, expectedOrderId) { }; } -async function postJson(url, payload, label) { +async function postJson(url, payload, label, { allowEmpty = false } = {}) { let response; try { response = await fetch(url, { @@ -309,6 +311,7 @@ async function postJson(url, payload, label) { } const text = await response.text(); if (!response.ok) throw new Error(`${label}返回 HTTP ${response.status}。`); + if (allowEmpty && !text.trim()) return {}; try { return JSON.parse(text); } catch { @@ -346,6 +349,9 @@ async function queryWechatOrder(env, openid, orderId) { const queryEndpoint = env.WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT || OFFICIAL_QUERY_ORDER_ENDPOINT; + const notifyProvideGoodsEndpoint = + env.WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT || + OFFICIAL_NOTIFY_PROVIDE_GOODS_ENDPOINT; const tokenResponse = await postJson( stableTokenEndpoint, { @@ -374,7 +380,28 @@ async function queryWechatOrder(env, openid, orderId) { if (!queryResponse.order || typeof queryResponse.order !== 'object') { throw new Error('微信虚拟支付查单响应缺少 order。'); } - return { order: queryResponse.order, queryEndpoint, stableTokenEndpoint }; + return { + accessToken, + notifyProvideGoodsEndpoint, + order: queryResponse.order, + paymentEnv, + queryEndpoint, + stableTokenEndpoint, + }; +} + +export async function notifyWechatGoodsDelivered(queried, orderId) { + const url = new URL(queried.notifyProvideGoodsEndpoint); + url.searchParams.set('access_token', queried.accessToken); + const response = await postJson( + url, + JSON.stringify({ order_id: orderId, env: queried.paymentEnv }), + '微信虚拟支付发货确认', + { allowEmpty: true }, + ); + if (Number(response.errcode ?? 0) !== 0) { + throw new Error(`微信虚拟支付发货确认返回错误:${response.errcode}。`); + } } export function paidAtMicrosFromWechatOrder(order) { @@ -393,7 +420,9 @@ function assertApplyEndpoints(options, endpoints) { if (!options.apply || options.allowNonOfficialEndpoint) return; if ( endpoints.queryEndpoint !== OFFICIAL_QUERY_ORDER_ENDPOINT || - endpoints.stableTokenEndpoint !== OFFICIAL_STABLE_TOKEN_ENDPOINT + endpoints.stableTokenEndpoint !== OFFICIAL_STABLE_TOKEN_ENDPOINT || + endpoints.notifyProvideGoodsEndpoint !== + OFFICIAL_NOTIFY_PROVIDE_GOODS_ENDPOINT ) { throw new Error( '--apply 默认只允许微信官方 endpoint;受控测试才可追加 --allow-non-official-endpoint。', @@ -420,90 +449,122 @@ export async function run(options) { 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, - }; + try { + 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 creditRequired = + validation.eligibleForCredit && localOrder.status !== 'paid'; + const paidAtMicros = creditRequired + ? 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 provideGoodsRequired = + facts.localKind === 'membership' && facts.wechatStatus === 2; + const hasApplyAction = creditRequired || provideGoodsRequired; + const applyFingerprint = buildReconcileFingerprint(facts); + const output = { + apply: options.apply, + applyFingerprint, + creditRequired, + dryRun: !options.apply, + eligibleForCredit: facts.eligibleForCredit, + hasApplyAction, + localKind: facts.localKind, + localStatus: facts.localStatus, + orderId: facts.orderId, + paidAtMicros: facts.paidAtMicros, + providerTransactionId: facts.providerTransactionId, + provideGoodsRequired, + wechatOrderType: facts.wechatOrderType, + wechatStatus: facts.wechatStatus, + }; - if (!options.apply) { - console.log(JSON.stringify(output, null, 2)); - return output; + 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} 不允许补入账。`); + } + if (!hasApplyAction) { + throw new Error('本次复核没有需要执行的入账或发货动作。'); + } + let appliedStatus = localOrder.status; + let credited = false; + if (creditRequired) { + 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), + ); + appliedStatus = normalizeVariant(appliedOrder?.status, [ + 'pending', + 'paid', + 'failed', + 'closed', + 'refunded', + 'expired', + ]); + credited = true; + } + if (appliedStatus !== 'paid') { + throw new Error( + `入账 procedure 返回状态 ${appliedStatus || ''},未确认为 paid。`, + ); + } + if (provideGoodsRequired) { + await notifyWechatGoodsDelivered(queried, options.orderId); + } + const appliedOutput = { + ...output, + appliedStatus, + credited, + provideGoodsNotified: provideGoodsRequired, + }; + console.log(JSON.stringify(appliedOutput, null, 2)); + return appliedOutput; + } finally { + await unlink(resolve(options.openidFile)).catch((error) => { + if (error?.code !== 'ENOENT') throw error; + }); } - 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() { diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 8c08c11ed..62421d40a 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -146,6 +146,7 @@ pub struct AppConfig { 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_virtual_payment_notify_provide_goods_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, @@ -403,6 +404,8 @@ impl Default for AppConfig { 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_virtual_payment_notify_provide_goods_endpoint: + "https://api.weixin.qq.com/xpay/notify_provide_goods".to_string(), wechat_mini_program_message_token: None, wechat_mini_program_message_encoding_aes_key: None, wechat_mini_program_subscribe_message_enabled: true, @@ -949,6 +952,11 @@ impl AppConfig { { config.wechat_mini_program_virtual_payment_query_order_endpoint = endpoint; } + if let Some(endpoint) = read_first_non_empty_env(&[ + "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT", + ]) { + config.wechat_mini_program_virtual_payment_notify_provide_goods_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 = @@ -2092,6 +2100,9 @@ mod tests { 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_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_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"); @@ -2123,6 +2134,10 @@ mod tests { "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT", "http://127.0.0.1:18080/xpay/query_order", ); + std::env::set_var( + "WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT", + "http://127.0.0.1:18080/xpay/notify_provide_goods", + ); std::env::set_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN", "message-token-001"); std::env::set_var( "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", @@ -2198,6 +2213,10 @@ mod tests { config.wechat_mini_program_virtual_payment_query_order_endpoint, "http://127.0.0.1:18080/xpay/query_order" ); + assert_eq!( + config.wechat_mini_program_virtual_payment_notify_provide_goods_endpoint, + "http://127.0.0.1:18080/xpay/notify_provide_goods" + ); unsafe { std::env::remove_var("WECHAT_PAY_ENABLED"); @@ -2213,6 +2232,9 @@ mod tests { 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_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_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 f9a7b1dda..5804203c3 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 @@ -3,6 +3,7 @@ use std::{collections::HashSet, time::Duration}; use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductKind, }; use platform_wechat::WechatError; use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder}; @@ -13,6 +14,7 @@ use tracing::{debug, info, warn}; use crate::{ state::AppState, wechat::pay::{ + build_wechat_virtual_payment_notify_provide_goods_request, 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, @@ -288,7 +290,8 @@ async fn process_expired_virtual_payment_order( 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); + let wechat_status = 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( @@ -298,9 +301,19 @@ async fn process_expired_virtual_payment_order( ) .await?; state.publish_profile_recharge_order_update(order.order_id.clone()); + if order.kind == RuntimeProfileRechargeProductKind::Membership && wechat_status == 2 { + let notify_request = build_wechat_virtual_payment_notify_provide_goods_request( + &state.config, + order.order_id.clone(), + )?; + state + .wechat_client() + .notify_virtual_payment_goods_delivered(notify_request) + .await?; + } info!( order_id = order.order_id.as_str(), - virtual_payment_status = wechat_order.status, + virtual_payment_status = wechat_status, "expired virtual payment recharge order compensated as paid" ); return Ok(()); diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index d5a1a745b..726d0f533 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -93,9 +93,9 @@ use crate::{ request_context::RequestContext, state::AppState, wechat::pay::{ - 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, + build_wechat_payment_request, build_wechat_virtual_payment_notify_provide_goods_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, }, }; @@ -376,7 +376,9 @@ pub async fn confirm_wechat_profile_recharge_order( .with_message("该充值订单不是微信支付订单"), )); } - if order.status == RuntimeProfileRechargeOrderStatus::Paid { + let is_virtual_payment = + order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL; + if order.status == RuntimeProfileRechargeOrderStatus::Paid && !is_virtual_payment { return Ok(json_success_body( Some(&request_context), build_wechat_profile_recharge_order_confirmation(center, order), @@ -385,13 +387,14 @@ pub async fn confirm_wechat_profile_recharge_order( if !matches!( order.status, RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired - ) { + ) && !(is_virtual_payment && order.status == RuntimeProfileRechargeOrderStatus::Paid) + { return Ok(json_success_body( Some(&request_context), build_wechat_profile_recharge_order_confirmation(center, order), )); } - if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL { + if is_virtual_payment { let openid = resolve_wechat_identity_for_payment(&state, &order.user_id) .await .map_err(|error| runtime_profile_error_response(&request_context, error))?; @@ -421,22 +424,47 @@ pub async fn confirm_wechat_profile_recharge_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), + let wechat_status = wechat_order.status; + let (center, order) = if order.status == RuntimeProfileRechargeOrderStatus::Paid { + (center, order) + } else { + let paid_at_micros = paid_at_micros_from_wechat_virtual_payment_order(&wechat_order) + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_error(error)) + })?; + let paid = 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(paid.1.order_id.clone()); + paid + }; + if should_notify_virtual_payment_goods_delivery(order.kind, wechat_status) { + let notify_request = build_wechat_virtual_payment_notify_provide_goods_request( + &state.config, + order.order_id.clone(), + ) + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_error(error)) })?; - state.publish_profile_recharge_order_update(order.order_id.clone()); + state + .wechat_client() + .notify_virtual_payment_goods_delivered(notify_request) + .await + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_error(error)) + })?; + } return Ok(json_success_body( Some(&request_context), @@ -502,6 +530,13 @@ pub async fn confirm_wechat_profile_recharge_order( )) } +fn should_notify_virtual_payment_goods_delivery( + kind: RuntimeProfileRechargeProductKind, + wechat_status: i64, +) -> bool { + kind == RuntimeProfileRechargeProductKind::Membership && wechat_status == 2 +} + pub async fn stream_wechat_profile_recharge_order_events( State(state): State, Extension(request_context): Extension, @@ -2270,7 +2305,7 @@ mod tests { calc_wechat_virtual_payment_user_signature_with_key, format_profile_wallet_ledger_source_type, is_wechat_profile_recharge_order_terminal_for_confirmation, - normalize_admin_invite_code_metadata, + normalize_admin_invite_code_metadata, should_notify_virtual_payment_goods_delivery, }; use axum::{ @@ -3424,6 +3459,22 @@ mod tests { ); } + #[test] + fn only_paid_pending_membership_goods_require_delivery_confirmation() { + assert!(should_notify_virtual_payment_goods_delivery( + RuntimeProfileRechargeProductKind::Membership, + 2 + )); + assert!(!should_notify_virtual_payment_goods_delivery( + RuntimeProfileRechargeProductKind::Membership, + 3 + )); + assert!(!should_notify_virtual_payment_goods_delivery( + RuntimeProfileRechargeProductKind::Points, + 2 + )); + } + fn build_test_recharge_order( status: RuntimeProfileRechargeOrderStatus, expiration_checked_at_micros: Option, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index ba483dacb..d6e8d09e8 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1886,6 +1886,9 @@ fn build_wechat_client(config: &AppConfig) -> WechatClient { virtual_payment_query_order_endpoint: config .wechat_mini_program_virtual_payment_query_order_endpoint .clone(), + virtual_payment_notify_provide_goods_endpoint: config + .wechat_mini_program_virtual_payment_notify_provide_goods_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 9fb8855f2..7a9b0846f 100644 --- a/server-rs/crates/api-server/src/wechat/pay.rs +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -12,7 +12,8 @@ use platform_wechat::pay::{ resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature, }; use platform_wechat::{ - WechatError, WechatVirtualPaymentOrder, WechatVirtualPaymentQueryOrderRequest, + WechatError, WechatVirtualPaymentNotifyProvideGoodsRequest, WechatVirtualPaymentOrder, + WechatVirtualPaymentQueryOrderRequest, }; use serde::Serialize; use serde_json::json; @@ -286,6 +287,22 @@ pub fn build_wechat_virtual_payment_query_order_request( }) } +pub fn build_wechat_virtual_payment_notify_provide_goods_request( + config: &AppConfig, + order_id: String, +) -> Result { + if config.wechat_mini_program_virtual_payment_env > 1 { + return Err(WechatError::InvalidConfig(format!( + "微信虚拟支付发货确认 env 只允许 0 或 1,当前为 {}", + config.wechat_mini_program_virtual_payment_env + ))); + } + Ok(WechatVirtualPaymentNotifyProvideGoodsRequest { + order_id, + env: config.wechat_mini_program_virtual_payment_env, + }) +} + pub fn validate_wechat_virtual_payment_order( expected_order_id: &str, expected_amount_cents: u64, @@ -318,11 +335,16 @@ 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 +pub fn paid_at_micros_from_wechat_virtual_payment_order( + order: &WechatVirtualPaymentOrder, +) -> Result { + let paid_time = order .paid_time - .and_then(|seconds| seconds.checked_mul(1_000_000)) - .unwrap_or_else(current_unix_micros) + .filter(|value| *value > 0) + .ok_or_else(|| WechatError::Upstream("微信已支付虚拟订单缺少合法 paid_time".to_string()))?; + paid_time.checked_mul(1_000_000).ok_or_else(|| { + WechatError::Upstream("微信已支付虚拟订单 paid_time 超出安全范围".to_string()) + }) } pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { @@ -426,7 +448,7 @@ fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Res mod tests { use super::{ build_wechat_virtual_payment_query_order_request, is_wechat_virtual_payment_order_paid, - validate_wechat_virtual_payment_order, + paid_at_micros_from_wechat_virtual_payment_order, validate_wechat_virtual_payment_order, }; use crate::config::AppConfig; use platform_wechat::WechatVirtualPaymentOrder; @@ -462,6 +484,29 @@ mod tests { assert!(!is_wechat_virtual_payment_order_paid(6)); } + #[test] + fn virtual_payment_query_requires_a_stable_provider_paid_time() { + let mut order = WechatVirtualPaymentOrder { + order_id: "order-001".to_string(), + status: 2, + order_fee: 600, + order_type: 0, + paid_time: Some(1_777_111_300), + wx_order_id: None, + wxpay_order_id: None, + }; + assert_eq!( + paid_at_micros_from_wechat_virtual_payment_order(&order) + .expect("paid_time should convert"), + 1_777_111_300_000_000 + ); + + order.paid_time = None; + assert!(paid_at_micros_from_wechat_virtual_payment_order(&order).is_err()); + order.paid_time = Some(i64::MAX); + assert!(paid_at_micros_from_wechat_virtual_payment_order(&order).is_err()); + } + #[test] fn virtual_payment_query_rejects_a_mismatched_amount_before_crediting() { let order = WechatVirtualPaymentOrder { diff --git a/server-rs/crates/platform-wechat/src/lib.rs b/server-rs/crates/platform-wechat/src/lib.rs index 01e2dfcf5..d2dde23c6 100644 --- a/server-rs/crates/platform-wechat/src/lib.rs +++ b/server-rs/crates/platform-wechat/src/lib.rs @@ -7,7 +7,8 @@ pub use pay::{ }; pub use subscribe_message::{ DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT, + DEFAULT_WECHAT_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT, DEFAULT_WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT, WechatClient, WechatConfig, WechatError, - WechatErrorKind, WechatSubscribeMessageRequest, WechatVirtualPaymentOrder, - WechatVirtualPaymentQueryOrderRequest, + WechatErrorKind, WechatSubscribeMessageRequest, WechatVirtualPaymentNotifyProvideGoodsRequest, + 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 ff265b9ab..c1b302b1f 100644 --- a/server-rs/crates/platform-wechat/src/subscribe_message.rs +++ b/server-rs/crates/platform-wechat/src/subscribe_message.rs @@ -20,8 +20,11 @@ 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"; +pub const DEFAULT_WECHAT_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT: &str = + "https://api.weixin.qq.com/xpay/notify_provide_goods"; const WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI: &str = "/xpay/query_order"; const WECHAT_ACCESS_TOKEN_REFRESH_SAFETY_MARGIN: Duration = Duration::from_secs(5 * 60); +const WECHAT_ACCESS_TOKEN_ERROR_CODES: [i64; 3] = [40001, 40014, 42001]; #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatConfig { @@ -30,6 +33,7 @@ pub struct WechatConfig { pub stable_access_token_endpoint: String, pub subscribe_message_endpoint: String, pub virtual_payment_query_order_endpoint: String, + pub virtual_payment_notify_provide_goods_endpoint: String, } #[derive(Clone, Debug)] @@ -74,6 +78,12 @@ pub struct WechatVirtualPaymentOrder { pub wxpay_order_id: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatVirtualPaymentNotifyProvideGoodsRequest { + pub order_id: String, + pub env: u8, +} + #[derive(Debug, PartialEq, Eq)] pub enum WechatError { InvalidConfig(String), @@ -111,6 +121,19 @@ struct WechatVirtualPaymentQueryOrderBody<'a> { order_id: &'a str, } +#[derive(Debug, Serialize)] +struct WechatVirtualPaymentNotifyProvideGoodsBody<'a> { + order_id: &'a str, + env: u8, +} + +#[derive(Debug, Deserialize)] +struct WechatVirtualPaymentNotifyProvideGoodsResponse { + #[serde(default)] + errcode: i64, + errmsg: Option, +} + #[derive(Debug, Deserialize)] struct WechatVirtualPaymentQueryOrderResponse { errcode: i64, @@ -163,15 +186,6 @@ impl WechatClient { .and_then(non_empty) .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?; - let access_token = self.request_access_token(app_id, app_secret).await?; - let mut send_url = - Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| { - WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}")) - })?; - send_url - .query_pairs_mut() - .append_pair("access_token", &access_token); - let data = request .data .into_iter() @@ -185,22 +199,18 @@ impl WechatClient { "lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()), "data": data, }); - let response = self - .client - .post(send_url.as_str()) - .json(&payload) - .send() - .await - .map_err(|error| { - warn!(error = %error, "微信订阅消息请求失败"); - WechatError::RequestFailed("微信订阅消息请求失败".to_string()) - })? - .json::() - .await - .map_err(|error| { - warn!(error = %error, "微信订阅消息响应解析失败"); - WechatError::DeserializeFailed("微信订阅消息响应非法".to_string()) - })?; + let mut access_token = self.request_access_token(app_id, app_secret, false).await?; + let mut response = self + .send_subscribe_message_with_token(&payload, &access_token) + .await?; + if is_access_token_error(response.errcode) { + access_token = self + .refresh_access_token_after_failure(app_id, app_secret, &access_token) + .await?; + response = self + .send_subscribe_message_with_token(&payload, &access_token) + .await?; + } if response.errcode != 0 { return Err(WechatError::Upstream(format!( @@ -257,42 +267,18 @@ impl WechatClient { 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 mut access_token = self.request_access_token(app_id, app_secret, false).await?; + let mut response = self + .query_virtual_payment_order_with_token(&body, &pay_sig, &access_token) + .await?; + if is_access_token_error(response.errcode) { + access_token = self + .refresh_access_token_after_failure(app_id, app_secret, &access_token) + .await?; + response = self + .query_virtual_payment_order_with_token(&body, &pay_sig, &access_token) + .await?; } - 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!( "微信虚拟支付查单返回错误:{}", @@ -322,13 +308,213 @@ impl WechatClient { }) } + pub async fn notify_virtual_payment_goods_delivered( + &self, + request: WechatVirtualPaymentNotifyProvideGoodsRequest, + ) -> Result<(), WechatError> { + let app_id = self + .config + .app_id + .as_deref() + .and_then(non_empty) + .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?; + let app_secret = self + .config + .app_secret + .as_deref() + .and_then(non_empty) + .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?; + let order_id = non_empty(&request.order_id).ok_or_else(|| { + WechatError::InvalidConfig("微信虚拟支付发货确认缺少 order_id".to_string()) + })?; + if request.env > 1 { + return Err(WechatError::InvalidConfig( + "微信虚拟支付发货确认 env 只允许 0 或 1".to_string(), + )); + } + let body = serde_json::to_string(&WechatVirtualPaymentNotifyProvideGoodsBody { + order_id, + env: request.env, + }) + .map_err(|error| { + WechatError::DeserializeFailed(format!("微信虚拟支付发货确认请求序列化失败:{error}")) + })?; + + let mut access_token = self.request_access_token(app_id, app_secret, false).await?; + let mut response = self + .notify_virtual_payment_goods_delivered_with_token(&body, &access_token) + .await?; + if is_access_token_error(response.errcode) { + access_token = self + .refresh_access_token_after_failure(app_id, app_secret, &access_token) + .await?; + response = self + .notify_virtual_payment_goods_delivered_with_token(&body, &access_token) + .await?; + } + if response.errcode != 0 { + return Err(WechatError::Upstream(format!( + "微信虚拟支付发货确认返回错误:{}", + response + .errmsg + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| format!("errcode={}", response.errcode)) + ))); + } + Ok(()) + } + + async fn send_subscribe_message_with_token( + &self, + payload: &serde_json::Value, + access_token: &str, + ) -> Result { + let mut send_url = + Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| { + WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}")) + })?; + send_url + .query_pairs_mut() + .append_pair("access_token", access_token); + self.client + .post(send_url.as_str()) + .json(payload) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信订阅消息请求失败"); + WechatError::RequestFailed("微信订阅消息请求失败".to_string()) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信订阅消息响应解析失败"); + WechatError::DeserializeFailed("微信订阅消息响应非法".to_string()) + }) + } + + async fn query_virtual_payment_order_with_token( + &self, + body: &str, + pay_sig: &str, + access_token: &str, + ) -> Result { + 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.to_string()) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信虚拟支付查单请求失败"); + 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}" + ))); + } + serde_json::from_str::(&response_text).map_err( + |error| { + warn!(error = %error, "微信虚拟支付查单响应解析失败"); + WechatError::DeserializeFailed("微信虚拟支付查单响应非法".to_string()) + }, + ) + } + + async fn notify_virtual_payment_goods_delivered_with_token( + &self, + body: &str, + access_token: &str, + ) -> Result { + let mut url = Url::parse(&self.config.virtual_payment_notify_provide_goods_endpoint) + .map_err(|error| { + WechatError::InvalidConfig(format!("微信虚拟支付发货确认地址非法:{error}")) + })?; + url.query_pairs_mut() + .append_pair("access_token", access_token); + let response = self + .client + .post(url.as_str()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body.to_string()) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信虚拟支付发货确认请求失败"); + 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}" + ))); + } + if response_text.trim().is_empty() { + return Ok(WechatVirtualPaymentNotifyProvideGoodsResponse { + errcode: 0, + errmsg: None, + }); + } + serde_json::from_str::(&response_text) + .map_err(|error| { + warn!(error = %error, "微信虚拟支付发货确认响应解析失败"); + WechatError::DeserializeFailed("微信虚拟支付发货确认响应非法".to_string()) + }) + } + + async fn refresh_access_token_after_failure( + &self, + app_id: &str, + app_secret: &str, + failed_access_token: &str, + ) -> Result { + let force_refresh = { + let mut cache = self.access_token_cache.lock().await; + if let Some(entry) = cache.as_ref() + && entry.access_token != failed_access_token + && Instant::now() < entry.expires_at + { + return Ok(entry.access_token.clone()); + } + if cache + .as_ref() + .is_some_and(|entry| entry.access_token == failed_access_token) + { + *cache = None; + } + true + }; + self.request_access_token(app_id, app_secret, force_refresh) + .await + } + async fn request_access_token( &self, app_id: &str, app_secret: &str, + force_refresh: bool, ) -> Result { let mut cache = self.access_token_cache.lock().await; - if let Some(entry) = cache.as_ref() + if !force_refresh + && let Some(entry) = cache.as_ref() && Instant::now() < entry.expires_at { return Ok(entry.access_token.clone()); @@ -344,7 +530,7 @@ impl WechatClient { "grant_type": "client_credential", "appid": app_id, "secret": app_secret, - "force_refresh": false, + "force_refresh": force_refresh, })) .send() .await @@ -434,6 +620,10 @@ fn non_empty_owned_option(value: Option) -> Option { value.and_then(non_empty_owned) } +fn is_access_token_error(errcode: i64) -> bool { + WECHAT_ACCESS_TOKEN_ERROR_CODES.contains(&errcode) +} + 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()) @@ -450,8 +640,8 @@ mod tests { use super::{ WECHAT_VIRTUAL_PAYMENT_QUERY_ORDER_URI, WechatClient, WechatConfig, - WechatVirtualPaymentQueryOrderBody, WechatVirtualPaymentQueryOrderRequest, - calc_virtual_payment_pay_signature, + WechatVirtualPaymentNotifyProvideGoodsRequest, WechatVirtualPaymentQueryOrderBody, + WechatVirtualPaymentQueryOrderRequest, calc_virtual_payment_pay_signature, }; #[test] @@ -518,6 +708,9 @@ mod tests { 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"), + virtual_payment_notify_provide_goods_endpoint: format!( + "http://{address}/mock/notify-provide-goods" + ), }); let order = client @@ -547,6 +740,120 @@ mod tests { server.join().expect("mock server should finish"); } + #[tokio::test] + async fn virtual_payment_goods_delivery_accepts_an_empty_success_response() { + 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}"#, + "", + ] { + 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 = test_wechat_client(address); + + client + .notify_virtual_payment_goods_delivered(WechatVirtualPaymentNotifyProvideGoodsRequest { + order_id: "order-001".to_string(), + env: 0, + }) + .await + .expect("empty 200 response should confirm delivery"); + + let _token_request = request_receiver + .recv() + .expect("stable token request should be recorded"); + let notify_request = request_receiver + .recv() + .expect("provide goods request should be recorded"); + assert!( + notify_request.starts_with( + "POST /mock/notify-provide-goods?access_token=access-token-001 HTTP/1.1" + ) + ); + assert!(notify_request.ends_with(r#"{"order_id":"order-001","env":0}"#)); + server.join().expect("mock server should finish"); + } + + #[tokio::test] + async fn virtual_payment_query_refreshes_an_invalid_cached_access_token_once() { + 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":"stale-token","expires_in":7200}"#, + r#"{"errcode":40014,"errmsg":"invalid access token"}"#, + r#"{"access_token":"fresh-token","expires_in":7200}"#, + r#"{"errcode":0,"errmsg":"ok","order":{"order_id":"order-001","status":2,"order_fee":600,"order_type":0,"paid_time":1777111300}}"#, + ] { + 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 = test_wechat_client(address); + + 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("query should retry with a refreshed token"); + assert_eq!(order.status, 2); + + let first_token_request = request_receiver.recv().expect("first token request"); + let first_query_request = request_receiver.recv().expect("first query request"); + let second_token_request = request_receiver.recv().expect("second token request"); + let second_query_request = request_receiver.recv().expect("second query request"); + assert!(first_token_request.contains(r#""force_refresh":false"#)); + assert!(first_query_request.contains("access_token=stale-token")); + assert!(second_token_request.contains(r#""force_refresh":true"#)); + assert!(second_query_request.contains("access_token=fresh-token")); + server.join().expect("mock server should finish"); + } + + fn test_wechat_client(address: std::net::SocketAddr) -> WechatClient { + 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"), + virtual_payment_notify_provide_goods_endpoint: format!( + "http://{address}/mock/notify-provide-goods" + ), + }) + } + fn read_http_request(stream: &mut std::net::TcpStream) -> String { let mut bytes = Vec::new(); let mut buffer = [0_u8; 4096];