36a8719d0f
在虚拟支付商品入账后调用微信发货确认接口 允许已入账商品订单只重试发货而不重复发放权益 遇到失效 access token 时强制刷新并最多重放一次 要求使用微信 paid_time 并完善历史补单文件清理和回归测试
592 lines
20 KiB
JavaScript
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();
|
|
}
|