主要更新: 1. 按H5网页端完全重构匹配功能(match页面) - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募 - 资源对接等类型弹出手机号/微信号输入框 - 去掉重新匹配按钮,改为返回按钮 2. 修复所有卡片对齐和宽度问题 - 目录页附录卡片居中 - 首页阅读进度卡片满宽度 - 我的页面菜单卡片对齐 - 推广中心分享卡片统一宽度 3. 修复目录页图标和文字对齐 - section-icon固定40rpx宽高 - section-title与图标垂直居中 4. 更新真实完整文章标题(62篇) - 从book目录读取真实markdown文件名 - 替换之前的简化标题 5. 新增文章数据API - /api/db/chapters - 获取完整书籍结构 - 支持按ID获取单篇文章内容
616 lines
17 KiB
TypeScript
616 lines
17 KiB
TypeScript
/**
|
||
* 微信支付网关实现 (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);
|
||
}
|
||
}
|