#!/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 --server-url --order-id --openid-file --env-file [选项] 默认只对一条 wechat_mp_virtual pending / expired / paid 订单执行 dry-run 查单,不修改数据库;paid 仅用于重试待发货会员单。 --database 目标数据库(必填) --server-url 显式 SpacetimeDB URL(必填) --order-id 本次只核对的订单 ID(必填) --openid-file 只包含该订单用户 openid 的 0600 普通文件(必填,读取后自动删除) --env-file api-server 生产 env 文件(必填) --apply 符合入账条件时调用既有 mark_profile_recharge_order_paid_and_return --confirm --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 || ''},只允许核对 pending / expired / paid。`, ); } if (!['points', 'membership'].includes(kind)) { throw new Error(`目标订单商品类型 ${kind || ''} 不在允许范围内。`); } 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 || ''},未确认为 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(); }