import crypto from 'node:crypto'; import fs from 'node:fs'; import { fetch } from 'undici'; const API_BASE = 'https://api.mch.weixin.qq.com'; function readPrivateKey(config) { if (config.privateKeyPem) return config.privateKeyPem; if (config.privateKeyPath && fs.existsSync(config.privateKeyPath)) { return fs.readFileSync(config.privateKeyPath, 'utf8'); } return null; } function readPlatformCert(config) { if (config.platformCertPem) return config.platformCertPem; if (config.platformCertPath && fs.existsSync(config.platformCertPath)) { return fs.readFileSync(config.platformCertPath, 'utf8'); } return null; } function resolveNotifyUrl() { const notifyUrl = process.env.H5_WECHAT_NOTIFY_URL?.trim() ?? ''; const publicBaseUrl = process.env.H5_PUBLIC_BASE_URL?.trim() ?? ''; return ( notifyUrl || (publicBaseUrl ? `${publicBaseUrl.replace(/\/$/, '')}/webhooks/wechat-pay/notify` : '') ); } function detectApiVersion({ explicitVersion, apiV2Key, v3Ready }) { const normalized = explicitVersion?.trim().toLowerCase(); if (normalized === 'v2' || normalized === '2') return 'v2'; if (normalized === 'v3' || normalized === '3') return 'v3'; if (v3Ready) return 'v3'; if (apiV2Key) return 'v2'; return null; } export function loadWechatPayConfig() { const enabled = process.env.H5_WECHAT_PAY_ENABLED === '1'; const appId = process.env.H5_WECHAT_APP_ID?.trim() ?? ''; const mchId = process.env.H5_WECHAT_MCH_ID?.trim() ?? ''; const apiV2Key = process.env.H5_WECHAT_API_V2_KEY?.trim() ?? process.env.H5_WECHAT_API_KEY?.trim() ?? ''; const apiV3Key = process.env.H5_WECHAT_API_V3_KEY?.trim() ?? ''; const serialNo = process.env.H5_WECHAT_SERIAL_NO?.trim() ?? ''; const privateKeyPath = process.env.H5_WECHAT_PRIVATE_KEY_PATH?.trim() ?? ''; const privateKeyPem = process.env.H5_WECHAT_PRIVATE_KEY?.replace(/\\n/g, '\n').trim() ?? ''; const platformCertPath = process.env.H5_WECHAT_PLATFORM_CERT_PATH?.trim() ?? ''; const platformCertPem = process.env.H5_WECHAT_PLATFORM_CERT?.replace(/\\n/g, '\n').trim() ?? ''; const resolvedNotifyUrl = resolveNotifyUrl(); const skipNotifyVerify = process.env.H5_WECHAT_SKIP_NOTIFY_VERIFY === '1'; const privateKey = readPrivateKey({ privateKeyPath, privateKeyPem }); const platformCert = readPlatformCert({ platformCertPath, platformCertPem }); const v3Ready = Boolean(appId && mchId && apiV3Key && serialNo && privateKey && resolvedNotifyUrl); const apiVersion = detectApiVersion({ explicitVersion: process.env.H5_WECHAT_API_VERSION, apiV2Key, v3Ready: Boolean(apiV3Key && serialNo && privateKey), }); if (apiVersion === 'v2') { const configured = Boolean(appId && mchId && apiV2Key && resolvedNotifyUrl); return { enabled: enabled && configured, apiVersion: 'v2', appId, mchId, apiKey: apiV2Key, notifyUrl: resolvedNotifyUrl, skipNotifyVerify, }; } const configured = v3Ready; return { enabled: enabled && configured, apiVersion: configured ? 'v3' : apiVersion, appId, mchId, apiV3Key, serialNo, privateKey, platformCert, notifyUrl: resolvedNotifyUrl, skipNotifyVerify, }; } function randomNonce(length = 32) { return crypto.randomBytes(length).toString('hex').slice(0, length); } export function signV2Params(params, apiKey) { const stringA = Object.keys(params) .filter((key) => key !== 'sign' && params[key] !== undefined && params[key] !== '') .sort() .map((key) => `${key}=${params[key]}`) .join('&'); const stringSignTemp = `${stringA}&key=${apiKey}`; return crypto.createHash('md5').update(stringSignTemp, 'utf8').digest('hex').toUpperCase(); } export function buildXml(fields) { const parts = ['']; for (const [key, value] of Object.entries(fields)) { if (value === undefined || value === '') continue; parts.push(`<${key}>`); } parts.push(''); return parts.join(''); } export function parseXmlFields(xml) { const result = {}; const re = /<(\w+)>(?:|([^<]*))<\/\1>/g; let match = re.exec(xml); while (match) { result[match[1]] = match[2] ?? match[3] ?? ''; match = re.exec(xml); } return result; } function normalizeV2Transaction(fields) { return { out_trade_no: fields.out_trade_no, transaction_id: fields.transaction_id, trade_state: fields.result_code === 'SUCCESS' ? 'SUCCESS' : fields.result_code, result_code: fields.result_code, return_code: fields.return_code, total_fee: fields.total_fee ? Number(fields.total_fee) : undefined, amount: fields.total_fee ? { total: Number(fields.total_fee) } : undefined, }; } async function wechatV2Request(config, path, fields) { const payload = { appid: config.appId, mch_id: config.mchId, nonce_str: randomNonce(), ...fields, }; payload.sign = signV2Params(payload, config.apiKey); const body = buildXml(payload); const res = await fetch(`${API_BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'text/xml' }, body, }); const text = await res.text(); const data = parseXmlFields(text); if (!res.ok) { throw new Error(`微信支付请求失败 (${res.status}): ${text}`); } if (data.return_code !== 'SUCCESS') { throw new Error(`微信支付请求失败: ${data.return_msg || data.return_code || text}`); } if (data.result_code !== 'SUCCESS') { throw new Error(`微信支付下单失败: ${data.err_code_des || data.err_code || text}`); } return data; } function signMessage(message, privateKeyPem) { return crypto.createSign('RSA-SHA256').update(message).sign(privateKeyPem, 'base64'); } function buildAuthorization({ mchId, serialNo, privateKey }, method, pathWithQuery, body) { const timestamp = String(Math.floor(Date.now() / 1000)); const nonce = randomNonce(); const payload = body ?? ''; const message = `${method}\n${pathWithQuery}\n${timestamp}\n${nonce}\n${payload}\n`; const signature = signMessage(message, privateKey); const authorization = [ 'WECHATPAY2-SHA256-RSA2048', `mchid="${mchId}"`, `nonce_str="${nonce}"`, `signature="${signature}"`, `timestamp="${timestamp}"`, `serial_no="${serialNo}"`, ].join(','); return { authorization, timestamp, nonce, signature }; } async function wechatV3Request(config, method, path, bodyObj) { const body = bodyObj ? JSON.stringify(bodyObj) : ''; const { authorization } = buildAuthorization(config, method, path, body); const res = await fetch(`${API_BASE}${path}`, { method, headers: { Authorization: authorization, Accept: 'application/json', 'Content-Type': 'application/json', }, body: body || undefined, }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } if (!res.ok) { const detail = data?.message ?? data?.detail ?? text; throw new Error(`微信支付请求失败 (${res.status}): ${detail}`); } return data; } export function buildV2JsapiParams(config, prepayId) { const timeStamp = String(Math.floor(Date.now() / 1000)); const nonceStr = randomNonce(16); const pkg = `prepay_id=${prepayId}`; const signType = 'MD5'; const paySign = signV2Params( { appId: config.appId, timeStamp, nonceStr, package: pkg, signType, }, config.apiKey, ); return { appId: config.appId, timeStamp, nonceStr, package: pkg, signType, paySign }; } export function buildV3JsapiParams(config, prepayId) { const timeStamp = String(Math.floor(Date.now() / 1000)); const nonceStr = randomNonce(16); const pkg = `prepay_id=${prepayId}`; const message = `${config.appId}\n${timeStamp}\n${nonceStr}\n${pkg}\n`; const paySign = signMessage(message, config.privateKey); return { appId: config.appId, timeStamp, nonceStr, package: pkg, signType: 'RSA', paySign }; } function createV2Client(config) { const createNativeOrder = async ({ outTradeNo, description, amountCents, clientIp }) => { const data = await wechatV2Request(config, '/pay/unifiedorder', { body: description, out_trade_no: outTradeNo, total_fee: String(amountCents), spbill_create_ip: clientIp || '127.0.0.1', notify_url: config.notifyUrl, trade_type: 'NATIVE', }); if (!data.code_url) { throw new Error('微信支付未返回二维码链接'); } return { codeUrl: data.code_url }; }; const createH5Order = async ({ outTradeNo, description, amountCents, clientIp }) => { const appUrl = process.env.H5_PUBLIC_BASE_URL ?? 'https://go.tkmind.cn'; const data = await wechatV2Request(config, '/pay/unifiedorder', { body: description, out_trade_no: outTradeNo, total_fee: String(amountCents), spbill_create_ip: clientIp || '127.0.0.1', notify_url: config.notifyUrl, trade_type: 'MWEB', scene_info: JSON.stringify({ h5_info: { type: 'Wap', wap_url: appUrl, wap_name: 'TKMind', }, }), }); if (!data.mweb_url) { throw new Error('微信支付未返回 H5 支付链接'); } return { h5Url: data.mweb_url }; }; const createJsapiOrder = async ({ outTradeNo, description, amountCents, clientIp, openid }) => { if (!openid) { throw new Error('JSAPI 支付缺少 openid'); } const data = await wechatV2Request(config, '/pay/unifiedorder', { body: description, out_trade_no: outTradeNo, total_fee: String(amountCents), spbill_create_ip: clientIp || '127.0.0.1', notify_url: config.notifyUrl, trade_type: 'JSAPI', openid, }); if (!data.prepay_id) { throw new Error('微信支付未返回 prepay_id'); } return { jsapiParams: buildV2JsapiParams(config, data.prepay_id) }; }; const verifyNotify = ({ headers: _headers, body }) => { const bodyText = typeof body === 'string' ? body : String(body ?? ''); const fields = parseXmlFields(bodyText); if (!fields.out_trade_no) { throw new Error('微信支付回调缺少商户订单号'); } if (fields.return_code !== 'SUCCESS') { throw new Error(`微信支付回调失败: ${fields.return_msg || fields.return_code}`); } if (!config.skipNotifyVerify) { const sign = fields.sign; if (!sign) { throw new Error('微信支付回调缺少签名'); } const expected = signV2Params(fields, config.apiKey); if (sign !== expected) { throw new Error('微信支付回调验签失败'); } } return { transaction: normalizeV2Transaction(fields) }; }; return { enabled: true, apiVersion: 'v2', appId: config.appId, createNativeOrder, createH5Order, createJsapiOrder, verifyNotify, }; } function createV3Client(config) { const createNativeOrder = async ({ outTradeNo, description, amountCents, clientIp }) => { const data = await wechatV3Request(config, 'POST', '/v3/pay/transactions/native', { appid: config.appId, mchid: config.mchId, description, out_trade_no: outTradeNo, notify_url: config.notifyUrl, amount: { total: amountCents, currency: 'CNY' }, scene_info: clientIp ? { payer_client_ip: clientIp } : undefined, }); return { codeUrl: data.code_url }; }; const createH5Order = async ({ outTradeNo, description, amountCents, clientIp }) => { const data = await wechatV3Request(config, 'POST', '/v3/pay/transactions/h5', { appid: config.appId, mchid: config.mchId, description, out_trade_no: outTradeNo, notify_url: config.notifyUrl, amount: { total: amountCents, currency: 'CNY' }, scene_info: { payer_client_ip: clientIp || '127.0.0.1', h5_info: { type: 'Wap', app_name: 'TKMind', app_url: process.env.H5_PUBLIC_BASE_URL ?? 'https://go.tkmind.cn', }, }, }); return { h5Url: data.h5_url }; }; const createJsapiOrder = async ({ outTradeNo, description, amountCents, clientIp, openid }) => { if (!openid) { throw new Error('JSAPI 支付缺少 openid'); } const data = await wechatV3Request(config, 'POST', '/v3/pay/transactions/jsapi', { appid: config.appId, mchid: config.mchId, description, out_trade_no: outTradeNo, notify_url: config.notifyUrl, amount: { total: amountCents, currency: 'CNY' }, payer: { openid }, scene_info: clientIp ? { payer_client_ip: clientIp } : undefined, }); if (!data.prepay_id) { throw new Error('微信支付未返回 prepay_id'); } return { jsapiParams: buildV3JsapiParams(config, data.prepay_id) }; }; const verifyNotify = ({ headers, body }) => { const timestamp = headers['wechatpay-timestamp'] ?? headers['Wechatpay-Timestamp']; const nonce = headers['wechatpay-nonce'] ?? headers['Wechatpay-Nonce']; const signature = headers['wechatpay-signature'] ?? headers['Wechatpay-Signature']; const serial = headers['wechatpay-serial'] ?? headers['Wechatpay-Serial']; if (!timestamp || !nonce || !signature) { throw new Error('微信支付回调缺少签名头'); } const bodyText = typeof body === 'string' ? body : JSON.stringify(body); const message = `${timestamp}\n${nonce}\n${bodyText}\n`; if (!config.skipNotifyVerify) { if (!config.platformCert) { throw new Error('未配置微信支付平台证书,无法验签'); } const verified = crypto .createVerify('RSA-SHA256') .update(message) .verify(config.platformCert, signature, 'base64'); if (!verified) { throw new Error('微信支付回调验签失败'); } } const payload = typeof body === 'string' ? JSON.parse(body) : body; const resource = payload?.resource; if (!resource?.ciphertext || !resource?.nonce) { throw new Error('微信支付回调数据不完整'); } const cipherBuf = Buffer.from(resource.ciphertext, 'base64'); const authTag = cipherBuf.subarray(cipherBuf.length - 16); const dataBuf = cipherBuf.subarray(0, cipherBuf.length - 16); const decipher = crypto.createDecipheriv( 'aes-256-gcm', Buffer.from(config.apiV3Key, 'utf8'), Buffer.from(resource.nonce, 'utf8'), ); decipher.setAuthTag(authTag); if (resource.associated_data) { decipher.setAAD(Buffer.from(resource.associated_data, 'utf8')); } const plain = Buffer.concat([decipher.update(dataBuf), decipher.final()]).toString('utf8'); const transaction = JSON.parse(plain); return { serial, transaction }; }; return { enabled: true, apiVersion: 'v3', appId: config.appId, createNativeOrder, createH5Order, createJsapiOrder, verifyNotify, }; } export function createWechatPayClient(config) { if (!config?.enabled) { return { enabled: false, apiVersion: config?.apiVersion ?? null, appId: config?.appId ?? null, async createNativeOrder() { throw new Error('微信支付未配置'); }, async createH5Order() { throw new Error('微信支付未配置'); }, async createJsapiOrder() { throw new Error('微信支付未配置'); }, verifyNotify() { throw new Error('微信支付未配置'); }, }; } if (config.apiVersion === 'v2') { return createV2Client(config); } return createV3Client(config); } export const WECHAT_NOTIFY_SUCCESS_V2 = '';