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

在虚拟支付商品入账后调用微信发货确认接口
允许已入账商品订单只重试发货而不重复发放权益
遇到失效 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
@@ -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() {