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