补齐微信虚拟支付发货确认

在虚拟支付商品入账后调用微信发货确认接口
允许已入账商品订单只重试发货而不重复发放权益
遇到失效 access token 时强制刷新并最多重放一次
要求使用微信 paid_time 并完善历史补单文件清理和回归测试
This commit is contained in:
2026-07-13 13:39:59 +08:00
parent 6143189dd9
commit 36a8719d0f
13 changed files with 732 additions and 190 deletions
+1
View File
@@ -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"
@@ -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`
@@ -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 不处理充值过期。
## 创作入口泥点扣费契约
@@ -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 tokenquery order 两个 endpoint,平台层集成测试必须校验真实发起请求的 mock 路径
- `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT` `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT` 默认使用微信官方地址,仅用于测试注入 mock endpoint生产不应覆盖。`WechatConfig` 支持注入 stable tokenquery 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 <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 文件。
脚本每次重新读取本地订单并重新向微信查单;`--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 文件无论处理成功失败都会删除
## 注意事项
@@ -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');
@@ -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 <name> --server-url <url> --order-id <id> --openid-file <path> --env-file <path> [选项]
默认只对一条 wechat_mp_virtual pending / expired 订单执行 dry-run 查单,不修改数据库。
默认只对一条 wechat_mp_virtual pending / expired / paid 订单执行 dry-run 查单,不修改数据库paid 仅用于重试待发货会员单
--database <name> 目标数据库(必填)
--server-url <url> 显式 SpacetimeDB URL(必填)
--order-id <id> 本次只核对的订单 ID(必填)
--openid-file <path> 只包含该订单用户 openid 的 0600 普通文件(必填)
--openid-file <path> 只包含该订单用户 openid 的 0600 普通文件(必填,读取后自动删除
--env-file <path> api-server 生产 env 文件(必填)
--apply 符合入账条件时调用既有 mark_profile_recharge_order_paid_and_return
--confirm <sha256> --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 || '<unknown>'},只允许核对 pending / expired。`,
`目标订单当前状态是 ${status || '<unknown>'},只允许核对 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 || '<unknown>'},未确认为 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 || '<unknown>'},未确认为 paid。`,
);
}
const appliedOutput = { ...output, appliedStatus };
console.log(JSON.stringify(appliedOutput, null, 2));
return appliedOutput;
}
async function main() {
+22
View File
@@ -146,6 +146,7 @@ pub struct AppConfig {
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_virtual_payment_notify_provide_goods_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,
@@ -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");
@@ -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(());
@@ -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<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -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<i64>,
+3
View File
@@ -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(),
})
}
+51 -6
View File
@@ -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<WechatVirtualPaymentNotifyProvideGoodsRequest, WechatError> {
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<i64, WechatError> {
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 {
+3 -2
View File
@@ -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,
};
File diff suppressed because it is too large Load Diff