Files
Genarrative/scripts/reconcile-wechat-virtual-payment-order.mjs
kdletters 36a8719d0f 补齐微信虚拟支付发货确认
在虚拟支付商品入账后调用微信发货确认接口
允许已入账商品订单只重试发货而不重复发放权益
遇到失效 access token 时强制刷新并最多重放一次
要求使用微信 paid_time 并完善历史补单文件清理和回归测试
2026-07-13 13:39:59 +08:00

592 lines
20 KiB
JavaScript

#!/usr/bin/env node
import { createHash, createHmac } from 'node:crypto';
import { lstat, readFile, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
encodeSpacetimeCliOption,
ensureProcedureOk,
} from './spacetime-migration-common.mjs';
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;
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 / paid 订单执行 dry-run 查单,不修改数据库;paid 仅用于重试待发货会员单。
--database <name> 目标数据库(必填)
--server-url <url> 显式 SpacetimeDB URL(必填)
--order-id <id> 本次只核对的订单 ID(必填)
--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
--allow-non-official-endpoint
允许 apply 使用非官方 stable_token / query_order endpoint,仅限受控测试
--help 显示帮助
脚本使用 env 文件内的 GENARRATIVE_SPACETIME_TOKEN 调用既有 procedure,不使用 migration operator 身份;不输出 openid、AppSecret、AppKey、access_token 或 SpacetimeDB token。`;
}
function parseOptions(argv) {
const options = {
allowNonOfficialEndpoint: false,
apply: false,
confirm: '',
database: process.env.GENARRATIVE_SPACETIME_DATABASE || '',
envFile: '',
openidFile: '',
orderId: '',
serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '',
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = () => {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${arg} 缺少参数值。`);
}
index += 1;
return value.trim();
};
if (arg === '--database') options.database = readValue();
else if (arg === '--server-url') options.serverUrl = readValue();
else if (arg === '--order-id') options.orderId = readValue();
else if (arg === '--openid-file') options.openidFile = readValue();
else if (arg === '--env-file') options.envFile = readValue();
else if (arg === '--confirm') options.confirm = readValue().toLowerCase();
else if (arg === '--apply') options.apply = true;
else if (arg === '--allow-non-official-endpoint') {
options.allowNonOfficialEndpoint = true;
} else if (arg === '--help' || arg === '-h') options.help = true;
else throw new Error(`未知参数: ${arg}`);
}
if (options.help) return options;
for (const [name, value] of [
['--database', options.database],
['--server-url', options.serverUrl],
['--order-id', options.orderId],
['--openid-file', options.openidFile],
['--env-file', options.envFile],
]) {
if (!value) throw new Error(`${name} 必填。`);
}
if (!/^https?:\/\/[^\s]+$/u.test(options.serverUrl)) {
throw new Error('--server-url 必须是显式 HTTP(S) URL。');
}
if (options.apply && !/^[0-9a-f]{64}$/u.test(options.confirm)) {
throw new Error('--apply 必须同时传入 dry-run 生成的 64 位 --confirm。');
}
return options;
}
export function parseEnvText(text) {
const values = {};
for (const rawLine of text.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) continue;
const [, key, rawValue] = match;
let value = rawValue.trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
} else {
value = value.replace(/\s+#.*$/u, '').trim();
}
values[key] = value;
}
return values;
}
export function calcPaySig(appKey, uri, body) {
return createHmac('sha256', appKey).update(`${uri}&${body}`).digest('hex');
}
export function validateQueryResult(localOrder, wechatOrder) {
if (wechatOrder.order_id !== localOrder.orderId) {
throw new Error('微信查单返回的订单号与本地订单不一致。');
}
if (![0, 7].includes(Number(wechatOrder.order_type))) {
throw new Error('微信查单返回的不是可入账虚拟支付单。');
}
if (Number(wechatOrder.order_fee) !== localOrder.amountCents) {
throw new Error('微信查单返回的金额与本地订单不一致。');
}
const status = Number(wechatOrder.status);
if (!Number.isInteger(status) || status < 0 || status > 10) {
throw new Error('微信查单返回了未知订单状态。');
}
return {
eligibleForCredit: status >= 2 && status <= 4,
status,
};
}
export function buildReconcileFingerprint(facts) {
return createHash('sha256').update(JSON.stringify(facts)).digest('hex');
}
async function readRegularFile(
filePath,
label,
{ requirePrivate = false } = {},
) {
const resolved = resolve(filePath);
const metadata = await lstat(resolved);
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error(`${label} 必须是普通文件且不能是符号链接。`);
}
if (requirePrivate && (metadata.mode & 0o077) !== 0) {
throw new Error(`${label} 权限必须为 0600(或更严格)。`);
}
return readFile(resolved, 'utf8');
}
function requiredEnv(env, keys, label) {
for (const key of keys) {
const value = String(env[key] ?? '').trim();
if (value) return value;
}
throw new Error(`${label} 未配置。`);
}
function normalizeVariant(value, variants = []) {
if (typeof value === 'string') return value.toLowerCase();
if (Array.isArray(value) && Number.isInteger(value[0])) {
return String(variants[value[0]] ?? '').toLowerCase();
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
if (typeof value.tag === 'string') return value.tag.toLowerCase();
const key = Object.keys(value)[0];
if (key) return key.toLowerCase();
}
return String(value ?? '').toLowerCase();
}
export function normalizeLocalOrderSnapshot(value) {
if (!Array.isArray(value)) return value;
if (value.length < 17) return value;
return {
order_id: value[0],
user_id: value[1],
kind: value[4],
amount_cents: value[5],
status: value[6],
payment_channel: value[7],
};
}
function unwrapOption(value) {
if (Array.isArray(value)) {
if (value.length === 2 && value[0] === 0) return value[1];
if (value.length === 0 || value[0] === 1) return null;
}
return value ?? null;
}
async function callProfileRechargeProcedure(options, procedureName, input) {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(options.database)) {
throw new Error('--database 不是合法 SpacetimeDB 数据库名。');
}
const serverUrl = options.serverUrl.replace(/\/+$/u, '');
const url = `${serverUrl}/v1/database/${encodeURIComponent(options.database)}/call/${encodeURIComponent(procedureName)}`;
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${options.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([input]),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error) {
throw new Error(
`SpacetimeDB ${procedureName} 请求失败:${error instanceof Error ? error.message : String(error)}`,
);
}
const text = await response.text();
if (!response.ok) {
throw new Error(
`SpacetimeDB ${procedureName} 返回 HTTP ${response.status}。`,
);
}
let payload;
try {
payload = JSON.parse(text);
} catch {
throw new Error(`SpacetimeDB ${procedureName} 响应不是合法 JSON。`);
}
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload;
}
if (!Array.isArray(payload) || payload.length !== 4) {
throw new Error(`SpacetimeDB ${procedureName} 响应结构不符合契约。`);
}
return {
ok: payload[0],
record: unwrapOption(payload[1]),
order: unwrapOption(payload[2]),
error_message: unwrapOption(payload[3]),
};
}
function parseLocalOrder(result, expectedOrderId) {
ensureProcedureOk(result);
const order = normalizeLocalOrderSnapshot(unwrapOption(result.order));
if (!order || typeof order !== 'object') {
throw new Error('读取本地充值订单失败:procedure 响应缺少 order。');
}
const orderId = String(order.order_id ?? '').trim();
const paymentChannel = String(order.payment_channel ?? '').trim();
const status = normalizeVariant(order.status, [
'pending',
'paid',
'failed',
'closed',
'refunded',
'expired',
]);
const kind = normalizeVariant(order.kind, ['points', 'membership']);
const amountCents = Number(order.amount_cents);
if (orderId !== expectedOrderId)
throw new Error('本地 procedure 返回了其它订单。');
if (paymentChannel !== 'wechat_mp_virtual') {
throw new Error('目标订单不是 wechat_mp_virtual 渠道。');
}
if (!['pending', 'expired', 'paid'].includes(status)) {
throw new Error(
`目标订单当前状态是 ${status || '<unknown>'},只允许核对 pending / expired / paid。`,
);
}
if (!['points', 'membership'].includes(kind)) {
throw new Error(`目标订单商品类型 ${kind || '<unknown>'} 不在允许范围内。`);
}
if (!Number.isSafeInteger(amountCents) || amountCents < 0) {
throw new Error('本地订单 amount_cents 无效。');
}
return {
amountCents,
kind,
orderId,
status,
userId: String(order.user_id ?? ''),
};
}
async function postJson(url, payload, label, { allowEmpty = false } = {}) {
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: typeof payload === 'string' ? payload : JSON.stringify(payload),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error) {
throw new Error(
`${label}请求失败:${error instanceof Error ? error.message : String(error)}`,
);
}
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 {
throw new Error(`${label}响应不是合法 JSON。`);
}
}
async function queryWechatOrder(env, openid, orderId) {
const appId = requiredEnv(
env,
['WECHAT_MINI_PROGRAM_APP_ID', 'WECHAT_APP_ID'],
'小程序 AppID',
);
const appSecret = requiredEnv(
env,
['WECHAT_MINI_PROGRAM_APP_SECRET', 'WECHAT_APP_SECRET'],
'小程序 AppSecret',
);
const paymentEnvText = String(
env.WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV ?? '0',
).trim();
if (!/^[01]$/u.test(paymentEnvText)) {
throw new Error('WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV 只允许 0 或 1。');
}
const paymentEnv = Number(paymentEnvText);
const appKey = requiredEnv(
env,
paymentEnv === 1
? ['WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY']
: ['WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY'],
paymentEnv === 1 ? '虚拟支付沙箱 AppKey' : '虚拟支付 AppKey',
);
const stableTokenEndpoint =
env.WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT || OFFICIAL_STABLE_TOKEN_ENDPOINT;
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,
{
grant_type: 'client_credential',
appid: appId,
secret: appSecret,
force_refresh: false,
},
'微信 stable_token ',
);
if (Number(tokenResponse.errcode ?? 0) !== 0) {
throw new Error(`微信 stable_token 返回错误:${tokenResponse.errcode}。`);
}
const accessToken = String(tokenResponse.access_token ?? '').trim();
if (!accessToken)
throw new Error('微信 stable_token 响应缺少 access_token。');
const body = JSON.stringify({ openid, env: paymentEnv, order_id: orderId });
const url = new URL(queryEndpoint);
url.searchParams.set('access_token', accessToken);
url.searchParams.set('pay_sig', calcPaySig(appKey, QUERY_ORDER_URI, body));
const queryResponse = await postJson(url, body, '微信虚拟支付查单');
if (Number(queryResponse.errcode ?? 0) !== 0) {
throw new Error(`微信虚拟支付查单返回错误:${queryResponse.errcode}。`);
}
if (!queryResponse.order || typeof queryResponse.order !== 'object') {
throw new Error('微信虚拟支付查单响应缺少 order。');
}
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) {
const seconds = Number(order.paid_time);
if (!Number.isSafeInteger(seconds) || seconds <= 0) {
throw new Error('微信已支付订单缺少合法 paid_time。');
}
const micros = seconds * 1_000_000;
if (!Number.isSafeInteger(micros)) {
throw new Error('微信已支付订单 paid_time 超出安全整数范围。');
}
return micros;
}
function assertApplyEndpoints(options, endpoints) {
if (!options.apply || options.allowNonOfficialEndpoint) return;
if (
endpoints.queryEndpoint !== OFFICIAL_QUERY_ORDER_ENDPOINT ||
endpoints.stableTokenEndpoint !== OFFICIAL_STABLE_TOKEN_ENDPOINT ||
endpoints.notifyProvideGoodsEndpoint !==
OFFICIAL_NOTIFY_PROVIDE_GOODS_ENDPOINT
) {
throw new Error(
'--apply 默认只允许微信官方 endpoint;受控测试才可追加 --allow-non-official-endpoint。',
);
}
}
export async function run(options) {
const env = parseEnvText(
await readRegularFile(options.envFile, '--env-file'),
);
const spacetimeToken = requiredEnv(
env,
['GENARRATIVE_SPACETIME_TOKEN'],
'GENARRATIVE_SPACETIME_TOKEN',
);
const spacetimeOptions = { ...options, token: spacetimeToken };
const openid = (
await readRegularFile(options.openidFile, '--openid-file', {
requirePrivate: true,
})
).trim();
if (!openid || openid.length > 256 || /\s/u.test(openid)) {
throw new Error('--openid-file 内容不是合法单行 openid。');
}
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.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;
});
}
}
async function main() {
try {
const options = parseOptions(process.argv.slice(2));
if (options.help) {
console.log(usage());
return;
}
await run(options);
} catch (error) {
console.error(
`[wechat-virtual-payment-reconcile] ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
await main();
}