/** * 微信支付网关实现 (Wechat Pay Gateway) * 基于 Universal_Payment_Module v4.0 设计 * * 支持: * - Native扫码支付 (platform_type='native') * - JSAPI公众号/小程序支付 (platform_type='jsapi') * - H5支付 (platform_type='h5') * - APP支付 (platform_type='app') * * 作者: 卡若 * 版本: v4.0 */ import crypto from 'crypto'; import { AbstractGateway, PaymentFactory } from './factory'; import { CreateTradeData, TradeResult, NotifyResult, SignatureError, } from './types'; export interface WechatPayConfig { appId: string; appSecret?: string; serviceAppId?: string; serviceSecret?: string; mchId: string; mchKey: string; certPath?: string; keyPath?: string; enabled?: boolean; mode?: 'sandbox' | 'production'; } /** * 微信支付网关 */ export class WechatGateway extends AbstractGateway { private readonly UNIFIED_ORDER_URL = 'https://api.mch.weixin.qq.com/pay/unifiedorder'; private readonly ORDER_QUERY_URL = 'https://api.mch.weixin.qq.com/pay/orderquery'; private readonly CLOSE_ORDER_URL = 'https://api.mch.weixin.qq.com/pay/closeorder'; private readonly REFUND_URL = 'https://api.mch.weixin.qq.com/secapi/pay/refund'; private appId: string; private appSecret: string; private serviceAppId: string; private serviceSecret: string; private mchId: string; private mchKey: string; private certPath: string; private keyPath: string; constructor(config: Record) { super(config); const cfg = config as unknown as WechatPayConfig; this.appId = cfg.appId || ''; this.appSecret = cfg.appSecret || ''; this.serviceAppId = cfg.serviceAppId || ''; this.serviceSecret = cfg.serviceSecret || ''; this.mchId = cfg.mchId || ''; this.mchKey = cfg.mchKey || ''; this.certPath = cfg.certPath || ''; this.keyPath = cfg.keyPath || ''; } /** * 创建微信支付交易 */ async createTrade(data: CreateTradeData): Promise { const platformType = (data.platformType || 'native').toUpperCase(); // 构建统一下单参数 const params: Record = { appid: platformType === 'JSAPI' ? this.serviceAppId || this.appId : this.appId, mch_id: this.mchId, nonce_str: this.generateNonceStr(), body: data.goodsTitle.slice(0, 128), out_trade_no: data.tradeSn, total_fee: data.amount.toString(), // 微信以分为单位 spbill_create_ip: data.createIp || '127.0.0.1', notify_url: data.notifyUrl, trade_type: platformType === 'H5' ? 'MWEB' : platformType, }; // 附加数据 if (data.attach) { params.attach = JSON.stringify(data.attach); } // JSAPI需要openid if (platformType === 'JSAPI') { if (!data.openId) { throw new Error('微信JSAPI支付需要提供 openid'); } params.openid = data.openId; } // H5支付需要scene_info if (platformType === 'MWEB' || platformType === 'H5') { params.scene_info = JSON.stringify({ h5_info: { type: 'Wap', wap_url: data.returnUrl || '', wap_name: data.goodsTitle.slice(0, 32), }, }); } // 生成签名 params.sign = this.generateSign(params); // 调用微信支付统一下单接口 return this.callUnifiedOrder(params, data.tradeSn, platformType); } /** * 调用微信支付统一下单接口 */ private async callUnifiedOrder(params: Record, tradeSn: string, tradeType: string): Promise { try { // 转换为XML const xmlData = this.dictToXml(params); console.log('[Wechat] 调用统一下单接口:', { url: this.UNIFIED_ORDER_URL, trade_type: tradeType, out_trade_no: tradeSn, total_fee: params.total_fee, }); // 发送请求到微信支付 const response = await fetch(this.UNIFIED_ORDER_URL, { method: 'POST', headers: { 'Content-Type': 'application/xml', }, body: xmlData, }); const responseText = await response.text(); console.log('[Wechat] 统一下单响应:', responseText.slice(0, 500)); // 解析响应 const result = this.xmlToDict(responseText); // 检查返回结果 if (result.return_code !== 'SUCCESS') { throw new Error(`微信支付请求失败: ${result.return_msg || '未知错误'}`); } if (result.result_code !== 'SUCCESS') { throw new Error(`微信支付失败: ${result.err_code_des || result.err_code || '未知错误'}`); } // 验证返回签名 if (!this.verifySign(result)) { throw new SignatureError('微信返回数据签名验证失败'); } // 根据支付类型返回不同的数据 return this.buildTradeResult(result, tradeSn, tradeType, params); } catch (error) { console.error('[Wechat] 统一下单失败:', error); throw error; } } /** * 构建支付结果 */ private buildTradeResult(result: Record, tradeSn: string, tradeType: string, params: Record): TradeResult { switch (tradeType) { case 'NATIVE': // 扫码支付返回二维码链接 if (!result.code_url) { throw new Error('微信支付返回数据缺少 code_url'); } return { type: 'qrcode', payload: result.code_url, tradeSn, expiration: 1800, // 30分钟 prepayId: result.prepay_id, }; case 'JSAPI': // 公众号支付返回JS SDK参数 const timestamp = Math.floor(Date.now() / 1000).toString(); const nonceStr = this.generateNonceStr(); const prepayId = result.prepay_id; if (!prepayId) { throw new Error('微信支付返回数据缺少 prepay_id'); } const jsParams: Record = { appId: params.appid, timeStamp: timestamp, nonceStr, package: `prepay_id=${prepayId}`, signType: 'MD5', }; jsParams.paySign = this.generateSign(jsParams); return { type: 'json', payload: jsParams, tradeSn, expiration: 1800, prepayId, }; case 'MWEB': case 'H5': // H5支付返回跳转链接 if (!result.mweb_url) { throw new Error('微信支付返回数据缺少 mweb_url'); } return { type: 'url', payload: result.mweb_url, tradeSn, expiration: 300, // H5支付链接有效期较短 prepayId: result.prepay_id, }; case 'APP': // APP支付返回SDK参数 const appTimestamp = Math.floor(Date.now() / 1000).toString(); const appPrepayId = result.prepay_id; if (!appPrepayId) { throw new Error('微信支付返回数据缺少 prepay_id'); } const appParams: Record = { appid: this.appId, partnerid: this.mchId, prepayid: appPrepayId, package: 'Sign=WXPay', noncestr: this.generateNonceStr(), timestamp: appTimestamp, }; appParams.sign = this.generateSign(appParams); return { type: 'json', payload: appParams, tradeSn, expiration: 1800, prepayId: appPrepayId, }; default: throw new Error(`不支持的微信支付类型: ${tradeType}`); } } /** * 生成随机字符串 */ private generateNonceStr(): string { return crypto.randomBytes(16).toString('hex'); } /** * 生成MD5签名 */ generateSign(params: Record): string { const sortedKeys = Object.keys(params).sort(); const signString = sortedKeys .filter((key) => params[key] && key !== 'sign') .map((key) => `${key}=${params[key]}`) .join('&'); const signWithKey = `${signString}&key=${this.mchKey}`; return crypto.createHash('md5').update(signWithKey, 'utf8').digest('hex').toUpperCase(); } /** * 验证签名 */ verifySign(data: Record): boolean { const receivedSign = data.sign; if (!receivedSign) return false; const params = { ...data }; delete params.sign; const calculatedSign = this.generateSign(params); return receivedSign === calculatedSign; } /** * 解析回调数据 */ parseNotify(data: string | Record): NotifyResult { // 如果是XML字符串,先转换为dict const params = typeof data === 'string' ? this.xmlToDict(data) : data; // 验证签名 if (!this.verifySign({ ...params })) { throw new SignatureError('微信签名验证失败'); } const resultCode = params.result_code || ''; const status = resultCode === 'SUCCESS' ? 'paid' : 'failed'; // 解析透传参数 let attach: Record = {}; const attachStr = params.attach || ''; if (attachStr) { try { attach = JSON.parse(attachStr); } catch { // 忽略解析错误 } } // 解析支付时间 (格式: 20240117100530) const timeEnd = params.time_end || ''; let payTime = new Date(); if (timeEnd && timeEnd.length === 14) { const year = parseInt(timeEnd.slice(0, 4), 10); const month = parseInt(timeEnd.slice(4, 6), 10) - 1; const day = parseInt(timeEnd.slice(6, 8), 10); const hour = parseInt(timeEnd.slice(8, 10), 10); const minute = parseInt(timeEnd.slice(10, 12), 10); const second = parseInt(timeEnd.slice(12, 14), 10); payTime = new Date(year, month, day, hour, minute, second); } return { status, tradeSn: params.out_trade_no || '', platformSn: params.transaction_id || '', payAmount: parseInt(params.cash_fee || params.total_fee || '0', 10), payTime, currency: (params.fee_type || 'CNY') as 'CNY', attach, rawData: params, }; } /** * XML转字典 */ private xmlToDict(xml: string): Record { const result: Record = {}; const regex = /<(\w+)><\/\1>/g; let match; while ((match = regex.exec(xml)) !== null) { result[match[1]] = match[2]; } // 也处理不带CDATA的标签 const simpleRegex = /<(\w+)>([^<]*)<\/\1>/g; while ((match = simpleRegex.exec(xml)) !== null) { if (!result[match[1]]) { result[match[1]] = match[2]; } } return result; } /** * 字典转XML */ private dictToXml(data: Record): string { const xml = ['']; for (const [key, value] of Object.entries(data)) { if (typeof value === 'string') { xml.push(`<${key}>`); } else { xml.push(`<${key}>${value}`); } } xml.push(''); return xml.join(''); } /** * 查询交易状态 */ async queryTrade(tradeSn: string): Promise { try { const params: Record = { appid: this.appId, mch_id: this.mchId, out_trade_no: tradeSn, nonce_str: this.generateNonceStr(), }; params.sign = this.generateSign(params); const xmlData = this.dictToXml(params); console.log('[Wechat] 查询订单:', tradeSn); const response = await fetch(this.ORDER_QUERY_URL, { method: 'POST', headers: { 'Content-Type': 'application/xml', }, body: xmlData, }); const responseText = await response.text(); const result = this.xmlToDict(responseText); console.log('[Wechat] 查询响应:', { return_code: result.return_code, result_code: result.result_code, trade_state: result.trade_state, err_code: result.err_code, }); // 检查通信是否成功 if (result.return_code !== 'SUCCESS') { console.log('[Wechat] 订单查询通信失败:', result.return_msg); return null; } // 如果业务结果失败,但是是订单不存在的情况,返回 paying 状态 if (result.result_code !== 'SUCCESS') { if (result.err_code === 'ORDERNOTEXIST') { console.log('[Wechat] 订单不存在,可能还在创建中'); return { status: 'paying', tradeSn, platformSn: '', payAmount: 0, payTime: new Date(), currency: 'CNY', attach: {}, rawData: result, }; } console.log('[Wechat] 订单查询业务失败:', result.err_code, result.err_code_des); return null; } // 验证签名 if (!this.verifySign(result)) { console.log('[Wechat] 订单查询签名验证失败'); return null; } const tradeState = result.trade_state || ''; let status: 'paying' | 'paid' | 'closed' | 'refunded' = 'paying'; switch (tradeState) { case 'SUCCESS': status = 'paid'; break; case 'CLOSED': case 'REVOKED': case 'PAYERROR': status = 'closed'; break; case 'REFUND': status = 'refunded'; break; case 'NOTPAY': case 'USERPAYING': default: status = 'paying'; } console.log('[Wechat] 订单状态:', { tradeSn, tradeState, status }); return { status, tradeSn: result.out_trade_no || tradeSn, platformSn: result.transaction_id || '', payAmount: parseInt(result.cash_fee || result.total_fee || '0', 10), payTime: new Date(), currency: 'CNY', attach: {}, rawData: result, }; } catch (error) { console.error('[Wechat] 查询订单失败:', error); return null; } } /** * 关闭交易 */ async closeTrade(tradeSn: string): Promise { try { const params: Record = { appid: this.appId, mch_id: this.mchId, out_trade_no: tradeSn, nonce_str: this.generateNonceStr(), }; params.sign = this.generateSign(params); const xmlData = this.dictToXml(params); console.log('[Wechat] 关闭订单:', tradeSn); const response = await fetch(this.CLOSE_ORDER_URL, { method: 'POST', headers: { 'Content-Type': 'application/xml', }, body: xmlData, }); const responseText = await response.text(); const result = this.xmlToDict(responseText); return result.return_code === 'SUCCESS' && result.result_code === 'SUCCESS'; } catch (error) { console.error('[Wechat] 关闭订单失败:', error); return false; } } /** * 发起退款 */ async refund(tradeSn: string, refundSn: string, amount: number, reason?: string): Promise { console.log(`[Wechat] 发起退款: ${tradeSn}, ${refundSn}, ${amount}, ${reason}`); // 退款需要证书,这里只是接口定义 // 实际使用时需要配置证书路径并使用 https 模块发送带证书的请求 return true; } /** * 回调成功响应 */ override successResponse(): string { return ''; } /** * 回调失败响应 */ override failResponse(): string { return ''; } } // 注册到工厂 PaymentFactory.register('wechat', WechatGateway); // 导出兼容旧版的 WechatPayService export interface WechatPayServiceConfig { appId: string; appSecret: string; mchId: string; apiKey: string; notifyUrl: string; } /** * 兼容旧版的 WechatPayService * @deprecated 请使用 WechatGateway */ export class WechatPayService { private gateway: WechatGateway; private notifyUrl: string; constructor(config: WechatPayServiceConfig) { this.gateway = new WechatGateway({ appId: config.appId, appSecret: config.appSecret, mchId: config.mchId, mchKey: config.apiKey, }); this.notifyUrl = config.notifyUrl; } async createOrder(params: { outTradeNo: string; body: string; totalFee: number; spbillCreateIp: string; }) { const result = await this.gateway.createTrade({ goodsTitle: params.body, tradeSn: params.outTradeNo, orderSn: params.outTradeNo, amount: Math.round(params.totalFee * 100), // 转换为分 notifyUrl: this.notifyUrl, createIp: params.spbillCreateIp, platformType: 'native', }); return { codeUrl: typeof result.payload === 'string' ? result.payload : '', prepayId: result.prepayId || `prepay_${Date.now()}`, outTradeNo: params.outTradeNo, }; } generateSign(params: Record): string { return this.gateway.generateSign(params); } verifySign(params: Record): boolean { return this.gateway.verifySign(params); } async queryTrade(tradeSn: string) { return this.gateway.queryTrade(tradeSn); } }