补齐微信虚拟支付发货确认
在虚拟支付商品入账后调用微信发货确认接口 允许已入账商品订单只重试发货而不重复发放权益 遇到失效 access token 时强制刷新并最多重放一次 要求使用微信 paid_time 并完善历史补单文件清理和回归测试
This commit is contained in:
@@ -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 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 <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() {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user