/** * 微信支付 V3 - 商家转账到零钱 * 文档: 开发文档/提现功能完整技术文档.md */ import crypto from 'crypto' import fs from 'fs' import path from 'path' const BASE_URL = 'https://api.mch.weixin.qq.com' export interface WechatTransferConfig { mchId: string appId: string apiV3Key: string privateKeyPath?: string privateKeyContent?: string /** 私钥文件 URL,可选;配置后会在首次调用时拉取并缓存 */ privateKeyUrl?: string certSerialNo: string } // 与小程序支付、lib/payment/config 保持一致,复用同一套 env const DEFAULT_MCH_ID = '1318592501' const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa' /** 从 apiclient_cert.pem 读取证书序列号(与 lib/payment 的 WECHAT_CERT_PATH 复用) */ function getCertSerialNoFromPath(certPath: string): string { const p = path.isAbsolute(certPath) ? certPath : path.join(process.cwd(), certPath) const pem = fs.readFileSync(p, 'utf8') const cert = new crypto.X509Certificate(pem) const raw = (cert.serialNumber || '').replace(/:/g, '').replace(/^0+/, '') || '' return raw.toUpperCase() } function getConfig(): WechatTransferConfig { const mchId = process.env.WECHAT_MCH_ID || process.env.WECHAT_MCHID || DEFAULT_MCH_ID const appId = process.env.WECHAT_APP_ID || process.env.WECHAT_APPID || DEFAULT_APP_ID const apiV3Key = process.env.WECHAT_API_V3_KEY || process.env.WECHAT_MCH_KEY || '' const keyPath = process.env.WECHAT_KEY_PATH || process.env.WECHAT_MCH_PRIVATE_KEY_PATH || '' const keyContent = process.env.WECHAT_MCH_PRIVATE_KEY || '' const keyUrl = process.env.WECHAT_KEY_URL || '' let certSerialNo = process.env.WECHAT_MCH_CERT_SERIAL_NO || '' const certPath = process.env.WECHAT_CERT_PATH || '' if (!certSerialNo && certPath && !certPath.startsWith('http')) { try { certSerialNo = getCertSerialNoFromPath(certPath) } catch (e) { console.warn('[wechat-transfer] 从证书文件读取序列号失败:', (e as Error).message) } } return { mchId, appId, apiV3Key, privateKeyPath: keyPath, privateKeyContent: keyContent, privateKeyUrl: keyUrl, certSerialNo, } } /** 从 WECHAT_KEY_URL 拉取的私钥缓存(仅内存,不落盘) */ let privateKeyFromUrlCache: string | null = null /** 若配置了 WECHAT_KEY_URL,在发起转账前拉取私钥并缓存 */ export async function loadPrivateKeyFromUrlIfNeeded(): Promise { const cfg = getConfig() if (!cfg.privateKeyUrl) return if (cfg.privateKeyContent || (cfg.privateKeyPath && !cfg.privateKeyPath.startsWith('http'))) return if (privateKeyFromUrlCache) return const res = await fetch(cfg.privateKeyUrl) if (!res.ok) throw new Error(`拉取私钥失败: ${res.status} ${cfg.privateKeyUrl}`) const text = await res.text() if (!text.includes('PRIVATE KEY')) throw new Error('WECHAT_KEY_URL 返回内容不是有效的 PEM 私钥') privateKeyFromUrlCache = text } function getPrivateKey(): string { const cfg = getConfig() if (cfg.privateKeyContent) { const key = cfg.privateKeyContent.replace(/\\n/g, '\n') if (!key.includes('BEGIN')) { return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----` } return key } if (cfg.privateKeyUrl && privateKeyFromUrlCache) return privateKeyFromUrlCache if (cfg.privateKeyPath) { if (cfg.privateKeyPath.startsWith('http://') || cfg.privateKeyPath.startsWith('https://')) { if (privateKeyFromUrlCache) return privateKeyFromUrlCache throw new Error('私钥来自 URL 尚未加载,请先调用 loadPrivateKeyFromUrlIfNeeded()') } const p = path.isAbsolute(cfg.privateKeyPath) ? cfg.privateKeyPath : path.join(process.cwd(), cfg.privateKeyPath) return fs.readFileSync(p, 'utf8') } if (cfg.privateKeyUrl) { throw new Error('已配置 WECHAT_KEY_URL 但私钥尚未拉取,请确保在转账前已调用 loadPrivateKeyFromUrlIfNeeded()') } throw new Error('微信商户私钥未配置: WECHAT_MCH_PRIVATE_KEY、WECHAT_KEY_PATH 或 WECHAT_KEY_URL') } function generateNonce(length = 32): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' let s = '' for (let i = 0; i < length; i++) { s += chars.charAt(Math.floor(Math.random() * chars.length)) } return s } /** 生成请求签名 */ function buildSignature(method: string, urlPath: string, timestamp: string, nonce: string, body: string): string { const message = `${method}\n${urlPath}\n${timestamp}\n${nonce}\n${body}\n` const key = getPrivateKey() const sign = crypto.createSign('RSA-SHA256') sign.update(message) return sign.sign(key, 'base64') } /** 构建 Authorization 头 */ function buildAuthorization(timestamp: string, nonce: string, signature: string): string { const cfg = getConfig() return `WECHATPAY2-SHA256-RSA2048 mchid="${cfg.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${cfg.certSerialNo}"` } export interface CreateTransferParams { openid: string amountFen: number outDetailNo: string outBatchNo?: string transferRemark?: string } export interface CreateTransferResult { success: boolean outBatchNo?: string batchId?: string createTime?: string batchStatus?: string errorCode?: string errorMessage?: string } /** * 发起商家转账到零钱 */ export async function createTransfer(params: CreateTransferParams): Promise { await loadPrivateKeyFromUrlIfNeeded() const cfg = getConfig() if (!cfg.mchId || !cfg.appId || !cfg.apiV3Key || !cfg.certSerialNo) { return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整' } } const urlPath = '/v3/transfer/batches' const outBatchNo = params.outBatchNo || `B${Date.now()}${Math.random().toString(36).slice(2, 8)}` const body = { appid: cfg.appId, out_batch_no: outBatchNo, batch_name: '提现', batch_remark: params.transferRemark || '用户提现', total_amount: params.amountFen, total_num: 1, transfer_detail_list: [ { out_detail_no: params.outDetailNo, transfer_amount: params.amountFen, transfer_remark: params.transferRemark || '提现', openid: params.openid, }, ], transfer_scene_id: '1005', transfer_scene_report_infos: [ { info_type: '岗位类型', info_content: '兼职人员' }, { info_type: '报酬说明', info_content: '当日兼职费' }, ], } const bodyStr = JSON.stringify(body) const timestamp = Math.floor(Date.now() / 1000).toString() const nonce = generateNonce() const signature = buildSignature('POST', urlPath, timestamp, nonce, bodyStr) const authorization = buildAuthorization(timestamp, nonce, signature) const res = await fetch(`${BASE_URL}${urlPath}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: authorization, 'User-Agent': 'Soul-Withdraw/1.0', }, body: bodyStr, }) const data = (await res.json()) as Record if (res.ok && res.status >= 200 && res.status < 300) { return { success: true, outBatchNo: data.out_batch_no as string, batchId: data.batch_id as string, createTime: data.create_time as string, batchStatus: data.batch_status as string, } } return { success: false, errorCode: (data.code as string) || 'UNKNOWN', errorMessage: (data.message as string) || (data.error as string) as string || '请求失败', } } // ========== 用户确认模式:发起转账(需用户在小程序内确认收款)========== // 文档: https://pay.weixin.qq.com/doc/v3/merchant/4012716434 export interface CreateTransferUserConfirmParams { openid: string amountFen: number outBillNo: string transferRemark?: string } export interface CreateTransferUserConfirmResult { success: boolean state?: string packageInfo?: string transferBillNo?: string createTime?: string errorCode?: string errorMessage?: string } /** 获取转账结果通知地址 */ function getTransferNotifyUrl(): string { const base = process.env.NEXT_PUBLIC_BASE_URL || process.env.VERCEL_URL || 'http://localhost:3000' const host = base.startsWith('http') ? base : `https://${base}` return `${host}/api/payment/wechat/transfer/notify` } /** * 用户确认模式 - 发起转账 * 返回 WAIT_USER_CONFIRM 时需将 package_info 下发给小程序,用户调 wx.requestMerchantTransfer 确认收款 */ export async function createTransferUserConfirm( params: CreateTransferUserConfirmParams ): Promise { await loadPrivateKeyFromUrlIfNeeded() const cfg = getConfig() if (!cfg.mchId || !cfg.appId) { return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整:缺少商户号或 AppID' } } if (!cfg.certSerialNo) { const certPath = process.env.WECHAT_CERT_PATH || '' const hint = certPath ? `已配置 WECHAT_CERT_PATH=${certPath} 但读取序列号失败,请检查文件存在且为有效 PEM。或直接在 .env 配置 WECHAT_MCH_CERT_SERIAL_NO(openssl x509 -in apiclient_cert.pem -noout -serial 获取)` : '请在 .env 中配置 WECHAT_CERT_PATH(apiclient_cert.pem 路径),或配置 WECHAT_MCH_CERT_SERIAL_NO(证书序列号)' return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: `微信转账配置不完整:缺少证书序列号。${hint}` } } try { getPrivateKey() } catch (e) { return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: `微信转账配置不完整:商户私钥未配置。请在 .env 中配置 WECHAT_KEY_PATH、WECHAT_MCH_PRIVATE_KEY 或 WECHAT_KEY_URL`, } } const urlPath = '/v3/fund-app/mch-transfer/transfer-bills' const body = { appid: cfg.appId, out_bill_no: params.outBillNo, transfer_scene_id: '1005', openid: params.openid, transfer_amount: params.amountFen, transfer_remark: params.transferRemark || '提现', notify_url: getTransferNotifyUrl(), user_recv_perception: '提现', transfer_scene_report_infos: [ { info_type: '岗位类型', info_content: '兼职人员' }, { info_type: '报酬说明', info_content: '当日兼职费' }, ], } const bodyStr = JSON.stringify(body) const timestamp = Math.floor(Date.now() / 1000).toString() const nonce = generateNonce() const signature = buildSignature('POST', urlPath, timestamp, nonce, bodyStr) const authorization = buildAuthorization(timestamp, nonce, signature) // 发起请求前打印:便于区分是请求微信缺参,还是管理端审核传参问题 console.log('[wechat-transfer] ========== 请求微信支付(用户确认模式)==========') console.log('[wechat-transfer] URL:', `${BASE_URL}${urlPath}`) console.log('[wechat-transfer] 请求体 body:', JSON.stringify(body, null, 2)) console.log('[wechat-transfer] 当前配置: mchId=', cfg.mchId, 'appId=', cfg.appId, 'certSerialNo=', cfg.certSerialNo ? `${cfg.certSerialNo.slice(0, 8)}...` : '(空)') console.log('[wechat-transfer] notify_url:', body.notify_url) console.log('[wechat-transfer] ========================================') const res = await fetch(`${BASE_URL}${urlPath}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: authorization, 'User-Agent': 'Soul-Withdraw/1.0', }, body: bodyStr, }) const data = (await res.json()) as Record console.log('[wechat-transfer] 微信响应 status=', res.status, 'body=', JSON.stringify(data, null, 2)) if (res.ok && res.status >= 200 && res.status < 300) { const state = (data.state as string) || '' return { success: true, state, packageInfo: data.package_info as string | undefined, transferBillNo: data.transfer_bill_no as string | undefined, createTime: data.create_time as string | undefined, } } return { success: false, errorCode: (data.code as string) || 'UNKNOWN', errorMessage: (data.message as string) || (data.error as string) as string || '请求失败', } } /** 供小程序调起确认收款时使用:获取商户号与 AppID */ export function getTransferMchAndAppId(): { mchId: string; appId: string } { const cfg = getConfig() return { mchId: cfg.mchId, appId: cfg.appId } } /** * 解密回调 resource(AEAD_AES_256_GCM) */ export function decryptResource( ciphertext: string, nonce: string, associatedData: string, apiV3Key: string ): Record { if (apiV3Key.length !== 32) { throw new Error('APIv3密钥必须为32字节') } const key = Buffer.from(apiV3Key, 'utf8') const ct = Buffer.from(ciphertext, 'base64') const authTag = ct.subarray(ct.length - 16) const data = ct.subarray(0, ct.length - 16) const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'utf8')) decipher.setAuthTag(authTag) decipher.setAAD(Buffer.from(associatedData, 'utf8')) const dec = decipher.update(data) as Buffer const final = decipher.final() as Buffer const json = Buffer.concat([dec, final]).toString('utf8') return JSON.parse(json) as Record } /** * 验证回调签名(需平台公钥,可选) */ export function verifyCallbackSignature( timestamp: string, nonce: string, body: string, signature: string, publicKeyPem: string ): boolean { const message = `${timestamp}\n${nonce}\n${body}\n` const sigBuf = Buffer.from(signature, 'base64') const verify = crypto.createVerify('RSA-SHA256') verify.update(message) return verify.verify(publicKeyPem, sigBuf) } export interface TransferNotifyDecrypted { mch_id: string out_bill_no: string transfer_bill_no?: string transfer_amount?: number state: string openid?: string create_time?: string update_time?: string }