Files
soul/lib/payment/wechat.ts
卡若 b60edb3d47 feat: 完整重构小程序匹配功能 + 修复UI对齐 + 文章数据API
主要更新:
1. 按H5网页端完全重构匹配功能(match页面)
   - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募
   - 资源对接等类型弹出手机号/微信号输入框
   - 去掉重新匹配按钮,改为返回按钮

2. 修复所有卡片对齐和宽度问题
   - 目录页附录卡片居中
   - 首页阅读进度卡片满宽度
   - 我的页面菜单卡片对齐
   - 推广中心分享卡片统一宽度

3. 修复目录页图标和文字对齐
   - section-icon固定40rpx宽高
   - section-title与图标垂直居中

4. 更新真实完整文章标题(62篇)
   - 从book目录读取真实markdown文件名
   - 替换之前的简化标题

5. 新增文章数据API
   - /api/db/chapters - 获取完整书籍结构
   - 支持按ID获取单篇文章内容
2026-01-21 15:49:12 +08:00

616 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 微信支付网关实现 (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);
}
}