Files
soul/lib/payment/wechat.ts

616 lines
17 KiB
TypeScript
Raw Normal View History

/**
* (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<string, unknown>) {
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<TradeResult> {
const platformType = (data.platformType || 'native').toUpperCase();
// 构建统一下单参数
const params: Record<string, string> = {
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<string, string>, tradeSn: string, tradeType: string): Promise<TradeResult> {
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<string, string>, tradeSn: string, tradeType: string, params: Record<string, string>): 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<string, string> = {
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<string, string> = {
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, string>): 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<string, string>): 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<string, string>): 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<string, unknown> = {};
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<string, string> {
const result: Record<string, string> = {};
const regex = /<(\w+)><!\[CDATA\[(.*?)\]\]><\/\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, string>): string {
const xml = ['<xml>'];
for (const [key, value] of Object.entries(data)) {
if (typeof value === 'string') {
xml.push(`<${key}><![CDATA[${value}]]></${key}>`);
} else {
xml.push(`<${key}>${value}</${key}>`);
}
}
xml.push('</xml>');
return xml.join('');
}
/**
*
*/
async queryTrade(tradeSn: string): Promise<NotifyResult | null> {
try {
const params: Record<string, string> = {
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<boolean> {
try {
const params: Record<string, string> = {
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<boolean> {
console.log(`[Wechat] 发起退款: ${tradeSn}, ${refundSn}, ${amount}, ${reason}`);
// 退款需要证书,这里只是接口定义
// 实际使用时需要配置证书路径并使用 https 模块发送带证书的请求
return true;
}
/**
*
*/
override successResponse(): string {
return '<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>';
}
/**
*
*/
override failResponse(): string {
return '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[ERROR]]></return_msg></xml>';
}
}
// 注册到工厂
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, string>): string {
return this.gateway.generateSign(params);
}
verifySign(params: Record<string, string>): boolean {
return this.gateway.verifySign(params);
}
async queryTrade(tradeSn: string) {
return this.gateway.queryTrade(tradeSn);
}
}