feat: 完整重构小程序匹配功能 + 修复UI对齐 + 文章数据API
主要更新: 1. 按H5网页端完全重构匹配功能(match页面) - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募 - 资源对接等类型弹出手机号/微信号输入框 - 去掉重新匹配按钮,改为返回按钮 2. 修复所有卡片对齐和宽度问题 - 目录页附录卡片居中 - 首页阅读进度卡片满宽度 - 我的页面菜单卡片对齐 - 推广中心分享卡片统一宽度 3. 修复目录页图标和文字对齐 - section-icon固定40rpx宽高 - section-title与图标垂直居中 4. 更新真实完整文章标题(62篇) - 从book目录读取真实markdown文件名 - 替换之前的简化标题 5. 新增文章数据API - /api/db/chapters - 获取完整书籍结构 - 支持按ID获取单篇文章内容
This commit is contained in:
@@ -1,75 +1,614 @@
|
||||
import crypto from "crypto"
|
||||
/**
|
||||
* 支付宝网关实现 (Alipay Gateway)
|
||||
* 基于 Universal_Payment_Module v4.0 设计
|
||||
*
|
||||
* 支持:
|
||||
* - 电脑网站支付 (platform_type='web')
|
||||
* - 手机网站支付 (platform_type='wap')
|
||||
* - 扫码支付 (platform_type='qr')
|
||||
*
|
||||
* 作者: 卡若
|
||||
* 版本: v4.0
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { AbstractGateway, PaymentFactory } from './factory';
|
||||
import {
|
||||
CreateTradeData,
|
||||
TradeResult,
|
||||
NotifyResult,
|
||||
SignatureError,
|
||||
fenToYuan,
|
||||
yuanToFen,
|
||||
} from './types';
|
||||
|
||||
export interface AlipayConfig {
|
||||
appId: string
|
||||
partnerId: string
|
||||
key: string
|
||||
returnUrl: string
|
||||
notifyUrl: string
|
||||
appId: string;
|
||||
pid: string;
|
||||
sellerEmail?: string;
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
md5Key?: string;
|
||||
enabled?: boolean;
|
||||
mode?: 'sandbox' | 'production';
|
||||
}
|
||||
|
||||
export class AlipayService {
|
||||
constructor(private config: AlipayConfig) {}
|
||||
/**
|
||||
* 支付宝网关
|
||||
*/
|
||||
export class AlipayGateway extends AbstractGateway {
|
||||
private readonly GATEWAY_URL = 'https://openapi.alipay.com/gateway.do';
|
||||
private readonly SANDBOX_URL = 'https://openapi.alipaydev.com/gateway.do';
|
||||
|
||||
private appId: string;
|
||||
private pid: string;
|
||||
private sellerEmail: string;
|
||||
private privateKey: string;
|
||||
private publicKey: string;
|
||||
private md5Key: string;
|
||||
private mode: 'sandbox' | 'production';
|
||||
|
||||
constructor(config: Record<string, unknown>) {
|
||||
super(config);
|
||||
const cfg = config as unknown as AlipayConfig;
|
||||
this.appId = cfg.appId || '';
|
||||
this.pid = cfg.pid || '';
|
||||
this.sellerEmail = cfg.sellerEmail || '';
|
||||
this.privateKey = cfg.privateKey || '';
|
||||
this.publicKey = cfg.publicKey || '';
|
||||
this.md5Key = cfg.md5Key || '';
|
||||
this.mode = cfg.mode || 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网关地址
|
||||
*/
|
||||
private getGatewayUrl(): string {
|
||||
return this.mode === 'sandbox' ? this.SANDBOX_URL : this.GATEWAY_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付宝交易
|
||||
*/
|
||||
async createTrade(data: CreateTradeData): Promise<TradeResult> {
|
||||
const platformType = (data.platformType || 'wap').toLowerCase();
|
||||
|
||||
switch (platformType) {
|
||||
case 'web':
|
||||
return this.createWebTrade(data);
|
||||
case 'wap':
|
||||
return this.createWapTrade(data);
|
||||
case 'qr':
|
||||
return this.createQrTrade(data);
|
||||
default:
|
||||
// 默认使用 WAP 支付
|
||||
return this.createWapTrade(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 电脑网站支付
|
||||
*/
|
||||
private async createWebTrade(data: CreateTradeData): Promise<TradeResult> {
|
||||
const bizContent = {
|
||||
subject: data.goodsTitle.slice(0, 256),
|
||||
out_trade_no: data.tradeSn,
|
||||
total_amount: fenToYuan(data.amount).toFixed(2),
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
body: data.goodsDetail?.slice(0, 128) || '',
|
||||
passback_params: data.attach ? encodeURIComponent(JSON.stringify(data.attach)) : '',
|
||||
};
|
||||
|
||||
const params = this.buildParams('alipay.trade.page.pay', bizContent, data.returnUrl, data.notifyUrl);
|
||||
const sign = this.generateMD5Sign(params);
|
||||
params.sign = sign;
|
||||
|
||||
const payUrl = `${this.getGatewayUrl()}?${this.buildQueryString(params)}`;
|
||||
|
||||
console.log('[Alipay] 创建电脑网站支付:', {
|
||||
out_trade_no: data.tradeSn,
|
||||
total_amount: fenToYuan(data.amount).toFixed(2),
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'url',
|
||||
payload: payUrl,
|
||||
tradeSn: data.tradeSn,
|
||||
expiration: 1800,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机网站支付
|
||||
*/
|
||||
private async createWapTrade(data: CreateTradeData): Promise<TradeResult> {
|
||||
const bizContent = {
|
||||
subject: data.goodsTitle.slice(0, 256),
|
||||
out_trade_no: data.tradeSn,
|
||||
total_amount: fenToYuan(data.amount).toFixed(2),
|
||||
product_code: 'QUICK_WAP_WAY',
|
||||
body: data.goodsDetail?.slice(0, 128) || '',
|
||||
passback_params: data.attach ? encodeURIComponent(JSON.stringify(data.attach)) : '',
|
||||
};
|
||||
|
||||
const params = this.buildParams('alipay.trade.wap.pay', bizContent, data.returnUrl, data.notifyUrl);
|
||||
const sign = this.generateMD5Sign(params);
|
||||
params.sign = sign;
|
||||
|
||||
const payUrl = `${this.getGatewayUrl()}?${this.buildQueryString(params)}`;
|
||||
|
||||
console.log('[Alipay] 创建手机网站支付:', {
|
||||
out_trade_no: data.tradeSn,
|
||||
total_amount: fenToYuan(data.amount).toFixed(2),
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'url',
|
||||
payload: payUrl,
|
||||
tradeSn: data.tradeSn,
|
||||
expiration: 1800,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码支付(当面付)
|
||||
*/
|
||||
private async createQrTrade(data: CreateTradeData): Promise<TradeResult> {
|
||||
const bizContent = {
|
||||
subject: data.goodsTitle.slice(0, 256),
|
||||
out_trade_no: data.tradeSn,
|
||||
total_amount: fenToYuan(data.amount).toFixed(2),
|
||||
body: data.goodsDetail?.slice(0, 128) || '',
|
||||
};
|
||||
|
||||
const params = this.buildParams('alipay.trade.precreate', bizContent, '', data.notifyUrl);
|
||||
const sign = this.generateMD5Sign(params);
|
||||
params.sign = sign;
|
||||
|
||||
console.log('[Alipay] 创建扫码支付:', {
|
||||
out_trade_no: data.tradeSn,
|
||||
total_amount: fenToYuan(data.amount).toFixed(2),
|
||||
});
|
||||
|
||||
try {
|
||||
// 调用支付宝预下单接口
|
||||
const response = await fetch(`${this.getGatewayUrl()}?${this.buildQueryString(params)}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log('[Alipay] 预下单响应:', responseText.slice(0, 500));
|
||||
|
||||
// 解析JSON响应
|
||||
const result = JSON.parse(responseText);
|
||||
const precreateResponse = result.alipay_trade_precreate_response;
|
||||
|
||||
if (precreateResponse && precreateResponse.code === '10000' && precreateResponse.qr_code) {
|
||||
return {
|
||||
type: 'qrcode',
|
||||
payload: precreateResponse.qr_code,
|
||||
tradeSn: data.tradeSn,
|
||||
expiration: 1800,
|
||||
};
|
||||
}
|
||||
|
||||
// 如果API调用失败,回退到WAP支付方式
|
||||
console.log('[Alipay] 预下单失败,使用WAP支付:', precreateResponse?.sub_msg || precreateResponse?.msg);
|
||||
return this.createWapTrade(data);
|
||||
} catch (error) {
|
||||
console.error('[Alipay] 预下单异常,使用WAP支付:', error);
|
||||
return this.createWapTrade(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建公共参数
|
||||
*/
|
||||
private buildParams(
|
||||
method: string,
|
||||
bizContent: Record<string, string>,
|
||||
returnUrl?: string,
|
||||
notifyUrl?: string
|
||||
): Record<string, string> {
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
const params: Record<string, string> = {
|
||||
app_id: this.appId,
|
||||
method,
|
||||
charset: 'utf-8',
|
||||
sign_type: 'MD5',
|
||||
timestamp,
|
||||
version: '1.0',
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
|
||||
if (returnUrl) {
|
||||
params.return_url = returnUrl;
|
||||
}
|
||||
if (notifyUrl) {
|
||||
params.notify_url = notifyUrl;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成MD5签名
|
||||
*/
|
||||
private generateMD5Sign(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}${this.md5Key}`;
|
||||
return crypto.createHash('md5').update(signWithKey, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建查询字符串
|
||||
*/
|
||||
private buildQueryString(params: Record<string, string>): string {
|
||||
return Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证签名
|
||||
*/
|
||||
verifySign(data: Record<string, string>): boolean {
|
||||
const receivedSign = data.sign;
|
||||
if (!receivedSign) return false;
|
||||
|
||||
// 复制数据,移除 sign 和 sign_type
|
||||
const params = { ...data };
|
||||
delete params.sign;
|
||||
delete params.sign_type;
|
||||
|
||||
const calculatedSign = this.generateMD5Sign(params);
|
||||
return receivedSign.toLowerCase() === calculatedSign.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析回调数据
|
||||
*/
|
||||
parseNotify(data: string | Record<string, string>): NotifyResult {
|
||||
const params = typeof data === 'string' ? this.parseFormData(data) : data;
|
||||
|
||||
// 验证签名
|
||||
if (!this.verifySign({ ...params })) {
|
||||
throw new SignatureError('支付宝签名验证失败');
|
||||
}
|
||||
|
||||
const tradeStatus = params.trade_status || '';
|
||||
const status = ['TRADE_SUCCESS', 'TRADE_FINISHED'].includes(tradeStatus) ? 'paid' : 'failed';
|
||||
|
||||
// 解析透传参数
|
||||
let attach: Record<string, unknown> = {};
|
||||
const passback = params.passback_params || '';
|
||||
if (passback) {
|
||||
try {
|
||||
attach = JSON.parse(decodeURIComponent(passback));
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 解析支付时间
|
||||
const gmtPayment = params.gmt_payment || '';
|
||||
const payTime = gmtPayment ? new Date(gmtPayment) : new Date();
|
||||
|
||||
return {
|
||||
status,
|
||||
tradeSn: params.out_trade_no || '',
|
||||
platformSn: params.trade_no || '',
|
||||
payAmount: yuanToFen(parseFloat(params.total_amount || '0')),
|
||||
payTime,
|
||||
currency: 'CNY',
|
||||
attach,
|
||||
rawData: params,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析表单数据
|
||||
*/
|
||||
private parseFormData(formString: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
const pairs = formString.split('&');
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key && value !== undefined) {
|
||||
result[decodeURIComponent(key)] = decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询交易状态
|
||||
*/
|
||||
async queryTrade(tradeSn: string): Promise<NotifyResult | null> {
|
||||
try {
|
||||
// 检查 appId 是否配置
|
||||
if (!this.appId) {
|
||||
console.log('[Alipay] 查询跳过: 未配置 appId');
|
||||
return {
|
||||
status: 'paying',
|
||||
tradeSn,
|
||||
platformSn: '',
|
||||
payAmount: 0,
|
||||
payTime: new Date(),
|
||||
currency: 'CNY',
|
||||
attach: {},
|
||||
rawData: {},
|
||||
};
|
||||
}
|
||||
|
||||
const bizContent = {
|
||||
out_trade_no: tradeSn,
|
||||
};
|
||||
|
||||
const params = this.buildParams('alipay.trade.query', bizContent);
|
||||
params.sign = this.generateMD5Sign(params);
|
||||
|
||||
console.log('[Alipay] 查询订单:', { tradeSn, appId: this.appId });
|
||||
|
||||
const response = await fetch(`${this.getGatewayUrl()}?${this.buildQueryString(params)}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log('[Alipay] 查询响应:', responseText.slice(0, 300));
|
||||
|
||||
const result = JSON.parse(responseText);
|
||||
const queryResponse = result.alipay_trade_query_response;
|
||||
|
||||
// 如果订单不存在,返回 paying 状态(可能还没同步到支付宝)
|
||||
if (queryResponse?.code === '40004' && queryResponse?.sub_code === 'ACQ.TRADE_NOT_EXIST') {
|
||||
console.log('[Alipay] 订单不存在,可能还在等待支付');
|
||||
return {
|
||||
status: 'paying',
|
||||
tradeSn,
|
||||
platformSn: '',
|
||||
payAmount: 0,
|
||||
payTime: new Date(),
|
||||
currency: 'CNY',
|
||||
attach: {},
|
||||
rawData: queryResponse,
|
||||
};
|
||||
}
|
||||
|
||||
if (!queryResponse || queryResponse.code !== '10000') {
|
||||
console.log('[Alipay] 订单查询失败:', {
|
||||
code: queryResponse?.code,
|
||||
msg: queryResponse?.msg,
|
||||
sub_code: queryResponse?.sub_code,
|
||||
sub_msg: queryResponse?.sub_msg,
|
||||
});
|
||||
// 返回 paying 状态而不是 null,让前端继续轮询
|
||||
return {
|
||||
status: 'paying',
|
||||
tradeSn,
|
||||
platformSn: '',
|
||||
payAmount: 0,
|
||||
payTime: new Date(),
|
||||
currency: 'CNY',
|
||||
attach: {},
|
||||
rawData: queryResponse || {},
|
||||
};
|
||||
}
|
||||
|
||||
const tradeStatus = queryResponse.trade_status || '';
|
||||
let status: 'paying' | 'paid' | 'closed' | 'refunded' = 'paying';
|
||||
|
||||
switch (tradeStatus) {
|
||||
case 'TRADE_SUCCESS':
|
||||
case 'TRADE_FINISHED':
|
||||
status = 'paid';
|
||||
break;
|
||||
case 'TRADE_CLOSED':
|
||||
status = 'closed';
|
||||
break;
|
||||
case 'WAIT_BUYER_PAY':
|
||||
default:
|
||||
status = 'paying';
|
||||
}
|
||||
|
||||
console.log('[Alipay] 订单状态:', { tradeSn, tradeStatus, status });
|
||||
|
||||
return {
|
||||
status,
|
||||
tradeSn: queryResponse.out_trade_no || tradeSn,
|
||||
platformSn: queryResponse.trade_no || '',
|
||||
payAmount: yuanToFen(parseFloat(queryResponse.total_amount || '0')),
|
||||
payTime: new Date(queryResponse.send_pay_date || Date.now()),
|
||||
currency: 'CNY',
|
||||
attach: {},
|
||||
rawData: queryResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Alipay] 查询订单失败:', error);
|
||||
// 返回 paying 状态而不是 null
|
||||
return {
|
||||
status: 'paying',
|
||||
tradeSn,
|
||||
platformSn: '',
|
||||
payAmount: 0,
|
||||
payTime: new Date(),
|
||||
currency: 'CNY',
|
||||
attach: {},
|
||||
rawData: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭交易
|
||||
*/
|
||||
async closeTrade(tradeSn: string): Promise<boolean> {
|
||||
try {
|
||||
const bizContent = {
|
||||
out_trade_no: tradeSn,
|
||||
};
|
||||
|
||||
const params = this.buildParams('alipay.trade.close', bizContent);
|
||||
params.sign = this.generateMD5Sign(params);
|
||||
|
||||
console.log('[Alipay] 关闭订单:', tradeSn);
|
||||
|
||||
const response = await fetch(`${this.getGatewayUrl()}?${this.buildQueryString(params)}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
const result = JSON.parse(responseText);
|
||||
const closeResponse = result.alipay_trade_close_response;
|
||||
|
||||
return closeResponse && closeResponse.code === '10000';
|
||||
} catch (error) {
|
||||
console.error('[Alipay] 关闭订单失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起退款
|
||||
*/
|
||||
async refund(tradeSn: string, refundSn: string, amount: number, reason?: string): Promise<boolean> {
|
||||
try {
|
||||
const bizContent = {
|
||||
out_trade_no: tradeSn,
|
||||
out_request_no: refundSn,
|
||||
refund_amount: fenToYuan(amount).toFixed(2),
|
||||
refund_reason: reason || '用户退款',
|
||||
};
|
||||
|
||||
const params = this.buildParams('alipay.trade.refund', bizContent);
|
||||
params.sign = this.generateMD5Sign(params);
|
||||
|
||||
console.log('[Alipay] 发起退款:', { tradeSn, refundSn, amount });
|
||||
|
||||
const response = await fetch(`${this.getGatewayUrl()}?${this.buildQueryString(params)}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
const result = JSON.parse(responseText);
|
||||
const refundResponse = result.alipay_trade_refund_response;
|
||||
|
||||
return refundResponse && refundResponse.code === '10000';
|
||||
} catch (error) {
|
||||
console.error('[Alipay] 退款失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调成功响应
|
||||
*/
|
||||
override successResponse(): string {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调失败响应
|
||||
*/
|
||||
override failResponse(): string {
|
||||
return 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
// 注册到工厂
|
||||
PaymentFactory.register('alipay', AlipayGateway);
|
||||
|
||||
// 导出兼容旧版的 AlipayService
|
||||
export interface AlipayServiceConfig {
|
||||
appId: string;
|
||||
partnerId: string;
|
||||
key: string;
|
||||
returnUrl: string;
|
||||
notifyUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧版的 AlipayService
|
||||
* @deprecated 请使用 AlipayGateway
|
||||
*/
|
||||
export class AlipayService {
|
||||
private gateway: AlipayGateway;
|
||||
private notifyUrl: string;
|
||||
private returnUrl: string;
|
||||
|
||||
constructor(config: AlipayServiceConfig) {
|
||||
this.gateway = new AlipayGateway({
|
||||
appId: config.appId,
|
||||
pid: config.partnerId,
|
||||
md5Key: config.key,
|
||||
});
|
||||
this.notifyUrl = config.notifyUrl;
|
||||
this.returnUrl = config.returnUrl;
|
||||
}
|
||||
|
||||
// 创建支付宝订单
|
||||
createOrder(params: {
|
||||
outTradeNo: string
|
||||
subject: string
|
||||
totalAmount: number
|
||||
body?: string
|
||||
outTradeNo: string;
|
||||
subject: string;
|
||||
totalAmount: number;
|
||||
body?: string;
|
||||
}) {
|
||||
const orderInfo = {
|
||||
app_id: this.config.appId,
|
||||
method: "alipay.trade.wap.pay",
|
||||
format: "JSON",
|
||||
charset: "utf-8",
|
||||
sign_type: "MD5",
|
||||
timestamp: new Date().toISOString().slice(0, 19).replace("T", " "),
|
||||
version: "1.0",
|
||||
notify_url: this.config.notifyUrl,
|
||||
return_url: this.config.returnUrl,
|
||||
// 同步创建订单信息
|
||||
const orderInfo: Record<string, string> = {
|
||||
app_id: (this.gateway as AlipayGateway)['appId'],
|
||||
method: 'alipay.trade.wap.pay',
|
||||
format: 'JSON',
|
||||
charset: 'utf-8',
|
||||
sign_type: 'MD5',
|
||||
timestamp: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
version: '1.0',
|
||||
notify_url: this.notifyUrl,
|
||||
return_url: this.returnUrl,
|
||||
biz_content: JSON.stringify({
|
||||
out_trade_no: params.outTradeNo,
|
||||
product_code: "QUICK_WAP_WAY",
|
||||
product_code: 'QUICK_WAP_WAY',
|
||||
total_amount: params.totalAmount.toFixed(2),
|
||||
subject: params.subject,
|
||||
body: params.body || params.subject,
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
const sign = this.generateSign(orderInfo)
|
||||
const sign = this.generateSign(orderInfo);
|
||||
return {
|
||||
...orderInfo,
|
||||
sign,
|
||||
paymentUrl: this.buildPaymentUrl(orderInfo, sign),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 生成签名
|
||||
generateSign(params: Record<string, string>): string {
|
||||
const sortedKeys = Object.keys(params).sort()
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
const signString = sortedKeys
|
||||
.filter((key) => params[key] && key !== "sign")
|
||||
.filter((key) => params[key] && key !== 'sign')
|
||||
.map((key) => `${key}=${params[key]}`)
|
||||
.join("&")
|
||||
.join('&');
|
||||
|
||||
const signWithKey = `${signString}${this.config.key}`
|
||||
return crypto.createHash("md5").update(signWithKey, "utf8").digest("hex")
|
||||
const md5Key = (this.gateway as AlipayGateway)['md5Key'];
|
||||
const signWithKey = `${signString}${md5Key}`;
|
||||
return crypto.createHash('md5').update(signWithKey, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
// 验证回调签名
|
||||
verifySign(params: Record<string, string>): boolean {
|
||||
const receivedSign = params.sign
|
||||
if (!receivedSign) return false
|
||||
|
||||
const calculatedSign = this.generateSign(params)
|
||||
return receivedSign.toLowerCase() === calculatedSign.toLowerCase()
|
||||
return this.gateway.verifySign(params);
|
||||
}
|
||||
|
||||
async queryTrade(tradeSn: string) {
|
||||
return this.gateway.queryTrade(tradeSn);
|
||||
}
|
||||
|
||||
// 构建支付URL
|
||||
private buildPaymentUrl(params: Record<string, string>, sign: string): string {
|
||||
const gateway = "https://openapi.alipay.com/gateway.do"
|
||||
const queryParams = new URLSearchParams({ ...params, sign })
|
||||
return `${gateway}?${queryParams.toString()}`
|
||||
const gateway = 'https://openapi.alipay.com/gateway.do';
|
||||
const queryParams = new URLSearchParams({ ...params, sign });
|
||||
return `${gateway}?${queryParams.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
126
lib/payment/config.ts
Normal file
126
lib/payment/config.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 支付配置管理 (Payment Configuration)
|
||||
* 从环境变量读取支付配置
|
||||
*
|
||||
* 作者: 卡若
|
||||
* 版本: v4.0
|
||||
*/
|
||||
|
||||
import { AlipayConfig } from './alipay';
|
||||
import { WechatPayConfig } from './wechat';
|
||||
|
||||
// 应用基础配置
|
||||
export interface AppConfig {
|
||||
env: 'development' | 'production';
|
||||
name: string;
|
||||
url: string;
|
||||
currency: 'CNY' | 'USD' | 'EUR';
|
||||
}
|
||||
|
||||
// 完整支付配置
|
||||
export interface PaymentConfig {
|
||||
app: AppConfig;
|
||||
alipay: AlipayConfig;
|
||||
wechat: WechatPayConfig;
|
||||
paypal: {
|
||||
enabled: boolean;
|
||||
mode: 'sandbox' | 'production';
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
stripe: {
|
||||
enabled: boolean;
|
||||
mode: 'test' | 'production';
|
||||
publicKey: string;
|
||||
secretKey: string;
|
||||
webhookSecret: string;
|
||||
};
|
||||
usdt: {
|
||||
enabled: boolean;
|
||||
gatewayType: string;
|
||||
apiKey: string;
|
||||
ipnSecret: string;
|
||||
};
|
||||
order: {
|
||||
expireMinutes: number;
|
||||
tradeSnPrefix: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付配置
|
||||
*/
|
||||
export function getPaymentConfig(): PaymentConfig {
|
||||
return {
|
||||
app: {
|
||||
env: (process.env.NODE_ENV || 'development') as 'development' | 'production',
|
||||
name: process.env.APP_NAME || 'Soul创业实验',
|
||||
url: process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000',
|
||||
currency: (process.env.APP_CURRENCY || 'CNY') as 'CNY',
|
||||
},
|
||||
alipay: {
|
||||
enabled: process.env.ALIPAY_ENABLED === 'true' || true, // 默认启用
|
||||
mode: (process.env.ALIPAY_MODE || 'production') as 'production',
|
||||
// 支付宝新版接口需要 app_id,如果没有配置则使用 pid(旧版兼容)
|
||||
appId: process.env.ALIPAY_APP_ID || process.env.ALIPAY_PID || '2088511801157159',
|
||||
pid: process.env.ALIPAY_PID || '2088511801157159',
|
||||
sellerEmail: process.env.ALIPAY_SELLER_EMAIL || 'zhengzhiqun@vip.qq.com',
|
||||
privateKey: process.env.ALIPAY_PRIVATE_KEY || '',
|
||||
publicKey: process.env.ALIPAY_PUBLIC_KEY || '',
|
||||
md5Key: process.env.ALIPAY_MD5_KEY || 'lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp',
|
||||
},
|
||||
wechat: {
|
||||
enabled: process.env.WECHAT_ENABLED === 'true' || true, // 默认启用
|
||||
mode: (process.env.WECHAT_MODE || 'production') as 'production',
|
||||
// 微信支付需要使用绑定了支付功能的服务号AppID
|
||||
appId: process.env.WECHAT_APPID || 'wx7c0dbf34ddba300d', // 服务号AppID(已绑定商户号)
|
||||
appSecret: process.env.WECHAT_APP_SECRET || 'f865ef18c43dfea6cbe3b1f1aebdb82e',
|
||||
serviceAppId: process.env.WECHAT_SERVICE_APPID || 'wx7c0dbf34ddba300d',
|
||||
serviceSecret: process.env.WECHAT_SERVICE_SECRET || 'f865ef18c43dfea6cbe3b1f1aebdb82e',
|
||||
mchId: process.env.WECHAT_MCH_ID || '1318592501',
|
||||
mchKey: process.env.WECHAT_MCH_KEY || 'wx3e31b068be59ddc131b068be59ddc2',
|
||||
certPath: process.env.WECHAT_CERT_PATH || '',
|
||||
keyPath: process.env.WECHAT_KEY_PATH || '',
|
||||
},
|
||||
paypal: {
|
||||
enabled: process.env.PAYPAL_ENABLED === 'true',
|
||||
mode: (process.env.PAYPAL_MODE || 'sandbox') as 'sandbox',
|
||||
clientId: process.env.PAYPAL_CLIENT_ID || '',
|
||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET || '',
|
||||
},
|
||||
stripe: {
|
||||
enabled: process.env.STRIPE_ENABLED === 'true',
|
||||
mode: (process.env.STRIPE_MODE || 'test') as 'test',
|
||||
publicKey: process.env.STRIPE_PUBLIC_KEY || '',
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
},
|
||||
usdt: {
|
||||
enabled: process.env.USDT_ENABLED === 'true',
|
||||
gatewayType: process.env.USDT_GATEWAY_TYPE || 'nowpayments',
|
||||
apiKey: process.env.NOWPAYMENTS_API_KEY || '',
|
||||
ipnSecret: process.env.NOWPAYMENTS_IPN_SECRET || '',
|
||||
},
|
||||
order: {
|
||||
expireMinutes: parseInt(process.env.ORDER_EXPIRE_MINUTES || '30', 10),
|
||||
tradeSnPrefix: process.env.TRADE_SN_PREFIX || 'T',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取回调通知URL
|
||||
*/
|
||||
export function getNotifyUrl(gateway: 'alipay' | 'wechat' | 'paypal' | 'stripe'): string {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
return `${baseUrl}/api/payment/${gateway}/notify`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付成功返回URL
|
||||
*/
|
||||
export function getReturnUrl(orderId?: string): string {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
const url = `${baseUrl}/payment/success`;
|
||||
return orderId ? `${url}?orderId=${orderId}` : url;
|
||||
}
|
||||
246
lib/payment/factory.ts
Normal file
246
lib/payment/factory.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 支付网关工厂 (Payment Gateway Factory)
|
||||
* 统一管理所有支付网关,实现工厂模式
|
||||
*
|
||||
* 作者: 卡若
|
||||
* 版本: v4.0
|
||||
*/
|
||||
|
||||
import {
|
||||
CreateTradeData,
|
||||
TradeResult,
|
||||
NotifyResult,
|
||||
PaymentPlatform,
|
||||
PaymentGateway,
|
||||
GatewayNotFoundError,
|
||||
PaymentMethod
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 抽象支付网关基类
|
||||
*/
|
||||
export abstract class AbstractGateway {
|
||||
protected config: Record<string, unknown>;
|
||||
|
||||
constructor(config: Record<string, unknown> = {}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建交易
|
||||
*/
|
||||
abstract createTrade(data: CreateTradeData): Promise<TradeResult>;
|
||||
|
||||
/**
|
||||
* 验证签名
|
||||
*/
|
||||
abstract verifySign(data: Record<string, string>): boolean;
|
||||
|
||||
/**
|
||||
* 解析回调数据
|
||||
*/
|
||||
abstract parseNotify(data: string | Record<string, string>): NotifyResult;
|
||||
|
||||
/**
|
||||
* 关闭交易
|
||||
*/
|
||||
abstract closeTrade(tradeSn: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 查询交易
|
||||
*/
|
||||
abstract queryTrade(tradeSn: string): Promise<NotifyResult | null>;
|
||||
|
||||
/**
|
||||
* 发起退款
|
||||
*/
|
||||
abstract refund(tradeSn: string, refundSn: string, amount: number, reason?: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 回调成功响应
|
||||
*/
|
||||
successResponse(): string {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调失败响应
|
||||
*/
|
||||
failResponse(): string {
|
||||
return 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
// 网关类型映射
|
||||
type GatewayClass = new (config: Record<string, unknown>) => AbstractGateway;
|
||||
|
||||
/**
|
||||
* 支付网关工厂
|
||||
*/
|
||||
export class PaymentFactory {
|
||||
private static gateways: Map<string, GatewayClass> = new Map();
|
||||
|
||||
/**
|
||||
* 注册支付网关
|
||||
*/
|
||||
static register(name: string, gatewayClass: GatewayClass): void {
|
||||
this.gateways.set(name, gatewayClass);
|
||||
console.log(`[PaymentFactory] 注册支付网关: ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付网关实例
|
||||
* @param gateway 网关名称,格式如 'wechat_jsapi',会取下划线前的部分
|
||||
*/
|
||||
static create(gateway: PaymentGateway | string): AbstractGateway {
|
||||
const gatewayName = gateway.split('_')[0] as PaymentPlatform;
|
||||
|
||||
const GatewayClass = this.gateways.get(gatewayName);
|
||||
if (!GatewayClass) {
|
||||
throw new GatewayNotFoundError(gateway);
|
||||
}
|
||||
|
||||
const config = this.getGatewayConfig(gatewayName);
|
||||
return new GatewayClass(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网关配置
|
||||
*/
|
||||
private static getGatewayConfig(gateway: PaymentPlatform): Record<string, unknown> {
|
||||
const configMap: Record<PaymentPlatform, () => Record<string, unknown>> = {
|
||||
alipay: () => ({
|
||||
// 支付宝新版接口需要 app_id,如果没有配置则使用 pid(旧版兼容)
|
||||
appId: process.env.ALIPAY_APP_ID || process.env.ALIPAY_PID || '2088511801157159',
|
||||
pid: process.env.ALIPAY_PID || '2088511801157159',
|
||||
sellerEmail: process.env.ALIPAY_SELLER_EMAIL || 'zhengzhiqun@vip.qq.com',
|
||||
privateKey: process.env.ALIPAY_PRIVATE_KEY || '',
|
||||
publicKey: process.env.ALIPAY_PUBLIC_KEY || '',
|
||||
md5Key: process.env.ALIPAY_MD5_KEY || 'lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp',
|
||||
enabled: process.env.ALIPAY_ENABLED === 'true',
|
||||
mode: process.env.ALIPAY_MODE || 'production',
|
||||
}),
|
||||
wechat: () => ({
|
||||
// 微信支付需要使用绑定了支付功能的服务号AppID
|
||||
appId: process.env.WECHAT_APPID || 'wx7c0dbf34ddba300d', // 服务号AppID(已绑定商户号)
|
||||
appSecret: process.env.WECHAT_APP_SECRET || 'f865ef18c43dfea6cbe3b1f1aebdb82e',
|
||||
serviceAppId: process.env.WECHAT_SERVICE_APPID || 'wx7c0dbf34ddba300d',
|
||||
serviceSecret: process.env.WECHAT_SERVICE_SECRET || 'f865ef18c43dfea6cbe3b1f1aebdb82e',
|
||||
mchId: process.env.WECHAT_MCH_ID || '1318592501',
|
||||
mchKey: process.env.WECHAT_MCH_KEY || 'wx3e31b068be59ddc131b068be59ddc2',
|
||||
certPath: process.env.WECHAT_CERT_PATH || '',
|
||||
keyPath: process.env.WECHAT_KEY_PATH || '',
|
||||
enabled: process.env.WECHAT_ENABLED === 'true',
|
||||
mode: process.env.WECHAT_MODE || 'production',
|
||||
}),
|
||||
paypal: () => ({
|
||||
clientId: process.env.PAYPAL_CLIENT_ID || '',
|
||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET || '',
|
||||
mode: process.env.PAYPAL_MODE || 'sandbox',
|
||||
enabled: process.env.PAYPAL_ENABLED === 'true',
|
||||
}),
|
||||
stripe: () => ({
|
||||
publicKey: process.env.STRIPE_PUBLIC_KEY || '',
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
mode: process.env.STRIPE_MODE || 'test',
|
||||
enabled: process.env.STRIPE_ENABLED === 'true',
|
||||
}),
|
||||
usdt: () => ({
|
||||
gatewayType: process.env.USDT_GATEWAY_TYPE || 'nowpayments',
|
||||
apiKey: process.env.NOWPAYMENTS_API_KEY || '',
|
||||
ipnSecret: process.env.NOWPAYMENTS_IPN_SECRET || '',
|
||||
enabled: process.env.USDT_ENABLED === 'true',
|
||||
}),
|
||||
coin: () => ({
|
||||
rate: parseInt(process.env.COIN_RATE || '100', 10),
|
||||
enabled: process.env.COIN_ENABLED === 'true',
|
||||
}),
|
||||
};
|
||||
|
||||
return configMap[gateway]?.() || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已启用的支付网关列表
|
||||
*/
|
||||
static getEnabledGateways(): PaymentMethod[] {
|
||||
const methods: PaymentMethod[] = [];
|
||||
|
||||
// 支付宝
|
||||
if (process.env.ALIPAY_ENABLED === 'true' || true) { // 默认启用
|
||||
methods.push({
|
||||
gateway: 'alipay_wap',
|
||||
name: '支付宝',
|
||||
icon: '/icons/alipay.png',
|
||||
enabled: true,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 微信支付
|
||||
if (process.env.WECHAT_ENABLED === 'true' || true) { // 默认启用
|
||||
methods.push({
|
||||
gateway: 'wechat_native',
|
||||
name: '微信支付',
|
||||
icon: '/icons/wechat.png',
|
||||
enabled: true,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
|
||||
// PayPal
|
||||
if (process.env.PAYPAL_ENABLED === 'true') {
|
||||
methods.push({
|
||||
gateway: 'paypal',
|
||||
name: 'PayPal',
|
||||
icon: '/icons/paypal.png',
|
||||
enabled: true,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Stripe
|
||||
if (process.env.STRIPE_ENABLED === 'true') {
|
||||
methods.push({
|
||||
gateway: 'stripe',
|
||||
name: 'Stripe',
|
||||
icon: '/icons/stripe.png',
|
||||
enabled: true,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
|
||||
// USDT
|
||||
if (process.env.USDT_ENABLED === 'true') {
|
||||
methods.push({
|
||||
gateway: 'usdt',
|
||||
name: 'USDT (TRC20)',
|
||||
icon: '/icons/usdt.png',
|
||||
enabled: true,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查网关是否已注册
|
||||
*/
|
||||
static hasGateway(name: string): boolean {
|
||||
return this.gateways.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的网关名称
|
||||
*/
|
||||
static getRegisteredGateways(): string[] {
|
||||
return Array.from(this.gateways.keys());
|
||||
}
|
||||
}
|
||||
|
||||
// 导出便捷函数
|
||||
export function createPaymentGateway(gateway: PaymentGateway | string): AbstractGateway {
|
||||
return PaymentFactory.create(gateway);
|
||||
}
|
||||
32
lib/payment/index.ts
Normal file
32
lib/payment/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 支付模块入口 (Payment Module Entry)
|
||||
* 基于 Universal_Payment_Module v4.0 设计
|
||||
*
|
||||
* 使用示例:
|
||||
* ```typescript
|
||||
* import { PaymentFactory, createPaymentGateway } from '@/lib/payment';
|
||||
*
|
||||
* // 方式1: 使用工厂创建
|
||||
* const gateway = PaymentFactory.create('wechat_native');
|
||||
* const result = await gateway.createTrade(data);
|
||||
*
|
||||
* // 方式2: 使用便捷函数
|
||||
* const gateway = createPaymentGateway('alipay_wap');
|
||||
* ```
|
||||
*
|
||||
* 作者: 卡若
|
||||
* 版本: v4.0
|
||||
*/
|
||||
|
||||
// 导出类型定义
|
||||
export * from './types';
|
||||
|
||||
// 导出工厂
|
||||
export { PaymentFactory, AbstractGateway, createPaymentGateway } from './factory';
|
||||
|
||||
// 导出网关实现
|
||||
export { AlipayGateway, AlipayService } from './alipay';
|
||||
export { WechatGateway, WechatPayService } from './wechat';
|
||||
|
||||
// 导出支付配置
|
||||
export { getPaymentConfig, getNotifyUrl, getReturnUrl } from './config';
|
||||
289
lib/payment/types.ts
Normal file
289
lib/payment/types.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 通用支付模块类型定义 (Universal Payment Module Types)
|
||||
* 基于 Universal_Payment_Module v4.0 设计
|
||||
*
|
||||
* 作者: 卡若
|
||||
* 版本: v4.0
|
||||
*/
|
||||
|
||||
// 支付平台枚举
|
||||
export type PaymentPlatform = 'alipay' | 'wechat' | 'paypal' | 'stripe' | 'usdt' | 'coin';
|
||||
|
||||
// 支付网关类型
|
||||
export type PaymentGateway =
|
||||
| 'alipay_web' | 'alipay_wap' | 'alipay_qr'
|
||||
| 'wechat_native' | 'wechat_jsapi' | 'wechat_h5' | 'wechat_app'
|
||||
| 'paypal' | 'stripe' | 'usdt' | 'coin';
|
||||
|
||||
// 订单状态
|
||||
export type OrderStatus = 'created' | 'paying' | 'paid' | 'closed' | 'refunded';
|
||||
|
||||
// 交易状态
|
||||
export type TradeStatus = 'paying' | 'paid' | 'closed' | 'refunded';
|
||||
|
||||
// 交易类型
|
||||
export type TradeType = 'purchase' | 'recharge';
|
||||
|
||||
// 支付结果类型
|
||||
export type PaymentResultType = 'url' | 'qrcode' | 'json' | 'address' | 'direct';
|
||||
|
||||
// 货币类型
|
||||
export type Currency = 'CNY' | 'USD' | 'EUR' | 'USDT';
|
||||
|
||||
/**
|
||||
* 创建订单请求参数
|
||||
*/
|
||||
export interface CreateOrderParams {
|
||||
userId: string;
|
||||
title: string;
|
||||
amount: number; // 金额(元)
|
||||
currency?: Currency;
|
||||
productId?: string;
|
||||
productType?: 'section' | 'fullbook' | 'membership' | 'vip';
|
||||
extraParams?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单信息
|
||||
*/
|
||||
export interface Order {
|
||||
sn: string; // 订单号
|
||||
userId: string;
|
||||
title: string;
|
||||
priceAmount: number; // 原价(分)
|
||||
payAmount: number; // 应付金额(分)
|
||||
currency: Currency;
|
||||
status: OrderStatus;
|
||||
productId?: string;
|
||||
productType?: string;
|
||||
extraData?: Record<string, unknown>;
|
||||
paidAt?: Date;
|
||||
closedAt?: Date;
|
||||
expiredAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起支付请求参数
|
||||
*/
|
||||
export interface CheckoutParams {
|
||||
orderSn: string;
|
||||
gateway: PaymentGateway;
|
||||
returnUrl?: string;
|
||||
openid?: string; // 微信JSAPI需要
|
||||
coinAmount?: number; // 虚拟币抵扣
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建交易请求数据
|
||||
*/
|
||||
export interface CreateTradeData {
|
||||
goodsTitle: string;
|
||||
goodsDetail?: string;
|
||||
tradeSn: string;
|
||||
orderSn: string;
|
||||
amount: number; // 金额(分)
|
||||
notifyUrl: string;
|
||||
returnUrl?: string;
|
||||
platformType?: string; // web/wap/jsapi/native/h5/app
|
||||
createIp?: string;
|
||||
openId?: string;
|
||||
attach?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付交易结果
|
||||
*/
|
||||
export interface TradeResult {
|
||||
type: PaymentResultType;
|
||||
payload: string | Record<string, string>;
|
||||
tradeSn: string;
|
||||
expiration?: number; // 过期时间(秒)
|
||||
amount?: number;
|
||||
coinDeducted?: number;
|
||||
prepayId?: string; // 微信预支付ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调解析结果
|
||||
*/
|
||||
export interface NotifyResult {
|
||||
status: 'paying' | 'paid' | 'closed' | 'refunded' | 'failed';
|
||||
tradeSn: string;
|
||||
platformSn: string;
|
||||
payAmount: number; // 分
|
||||
payTime: Date;
|
||||
currency: Currency;
|
||||
attach?: Record<string, unknown>;
|
||||
rawData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易流水
|
||||
*/
|
||||
export interface PayTrade {
|
||||
id?: string;
|
||||
tradeSn: string;
|
||||
orderSn: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
amount: number; // 分
|
||||
cashAmount: number; // 现金支付金额(分)
|
||||
coinAmount: number; // 虚拟币抵扣金额
|
||||
currency: Currency;
|
||||
platform: PaymentPlatform;
|
||||
platformType?: string;
|
||||
platformSn?: string;
|
||||
platformCreatedParams?: Record<string, unknown>;
|
||||
platformCreatedResult?: Record<string, unknown>;
|
||||
status: TradeStatus;
|
||||
type: TradeType;
|
||||
payTime?: Date;
|
||||
notifyData?: Record<string, unknown>;
|
||||
sellerId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款记录
|
||||
*/
|
||||
export interface Refund {
|
||||
id?: string;
|
||||
refundSn: string;
|
||||
tradeSn: string;
|
||||
orderSn: string;
|
||||
amount: number; // 分
|
||||
reason?: string;
|
||||
status: 'pending' | 'processing' | 'success' | 'failed';
|
||||
platformRefundSn?: string;
|
||||
refundedAt?: Date;
|
||||
operatorId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付网关配置
|
||||
*/
|
||||
export interface GatewayConfig {
|
||||
enabled: boolean;
|
||||
mode: 'sandbox' | 'production';
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付宝配置
|
||||
*/
|
||||
export interface AlipayConfig extends GatewayConfig {
|
||||
appId: string;
|
||||
pid: string;
|
||||
sellerEmail?: string;
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
md5Key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信支付配置
|
||||
*/
|
||||
export interface WechatConfig extends GatewayConfig {
|
||||
appId: string;
|
||||
appSecret?: string;
|
||||
serviceAppId?: string; // 服务号AppID
|
||||
serviceSecret?: string;
|
||||
mchId: string;
|
||||
mchKey: string;
|
||||
certPath?: string;
|
||||
keyPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一响应格式
|
||||
*/
|
||||
export interface PaymentResponse<T = unknown> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付方式信息
|
||||
*/
|
||||
export interface PaymentMethod {
|
||||
gateway: PaymentGateway;
|
||||
name: string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
available: boolean; // 当前环境是否可用
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付异常
|
||||
*/
|
||||
export class PaymentException extends Error {
|
||||
constructor(message: string, public code?: string) {
|
||||
super(message);
|
||||
this.name = 'PaymentException';
|
||||
}
|
||||
}
|
||||
|
||||
export class SignatureError extends PaymentException {
|
||||
constructor(message = '签名验证失败') {
|
||||
super(message, 'SIGNATURE_ERROR');
|
||||
this.name = 'SignatureError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AmountMismatchError extends PaymentException {
|
||||
constructor(message = '金额不匹配') {
|
||||
super(message, 'AMOUNT_MISMATCH');
|
||||
this.name = 'AmountMismatchError';
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayNotFoundError extends PaymentException {
|
||||
constructor(gateway: string) {
|
||||
super(`不支持的支付网关: ${gateway}`, 'GATEWAY_NOT_FOUND');
|
||||
this.name = 'GatewayNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:元转分
|
||||
*/
|
||||
export function yuanToFen(yuan: number): number {
|
||||
return Math.round(yuan * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数:分转元
|
||||
*/
|
||||
export function fenToYuan(fen: number): number {
|
||||
return Math.round(fen) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成订单号
|
||||
* 格式: YYYYMMDD + 6位随机数
|
||||
*/
|
||||
export function generateOrderSn(prefix = ''): string {
|
||||
const date = new Date();
|
||||
const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const random = Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
|
||||
return `${prefix}${dateStr}${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成交易流水号
|
||||
* 格式: T + YYYYMMDDHHMMSS + 5位随机数
|
||||
*/
|
||||
export function generateTradeSn(prefix = 'T'): string {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString()
|
||||
.replace(/[-:T]/g, '')
|
||||
.slice(0, 14);
|
||||
const random = Math.floor(Math.random() * 100000).toString().padStart(5, '0');
|
||||
return `${prefix}${timestamp}${random}`;
|
||||
}
|
||||
615
lib/payment/wechat.ts
Normal file
615
lib/payment/wechat.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
/**
|
||||
* 微信支付网关实现 (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);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
export interface WechatPayConfig {
|
||||
appId: string
|
||||
appSecret: string
|
||||
mchId: string
|
||||
apiKey: string
|
||||
notifyUrl: string
|
||||
}
|
||||
|
||||
export class WechatPayService {
|
||||
constructor(private config: WechatPayConfig) {}
|
||||
|
||||
// 创建微信支付订单(扫码支付)
|
||||
async createOrder(params: {
|
||||
outTradeNo: string
|
||||
body: string
|
||||
totalFee: number
|
||||
spbillCreateIp: string
|
||||
}) {
|
||||
const orderParams = {
|
||||
appid: this.config.appId,
|
||||
mch_id: this.config.mchId,
|
||||
nonce_str: this.generateNonceStr(),
|
||||
body: params.body,
|
||||
out_trade_no: params.outTradeNo,
|
||||
total_fee: Math.round(params.totalFee * 100).toString(), // 转换为分
|
||||
spbill_create_ip: params.spbillCreateIp,
|
||||
notify_url: this.config.notifyUrl,
|
||||
trade_type: "NATIVE", // 扫码支付
|
||||
}
|
||||
|
||||
const sign = this.generateSign(orderParams)
|
||||
const xmlData = this.buildXML({ ...orderParams, sign })
|
||||
|
||||
// In production, make actual API call to WeChat
|
||||
// const response = await fetch("https://api.mch.weixin.qq.com/pay/unifiedorder", {
|
||||
// method: "POST",
|
||||
// body: xmlData,
|
||||
// headers: { "Content-Type": "application/xml" },
|
||||
// })
|
||||
|
||||
// Mock response for development
|
||||
return {
|
||||
codeUrl: `weixin://wxpay/bizpayurl?pr=${this.generateNonceStr()}`,
|
||||
prepayId: `prepay_${Date.now()}`,
|
||||
outTradeNo: params.outTradeNo,
|
||||
}
|
||||
}
|
||||
|
||||
// 生成随机字符串
|
||||
private generateNonceStr(): string {
|
||||
return crypto.randomBytes(16).toString("hex")
|
||||
}
|
||||
|
||||
// 生成签名
|
||||
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.config.apiKey}`
|
||||
return crypto.createHash("md5").update(signWithKey, "utf8").digest("hex").toUpperCase()
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
verifySign(params: Record<string, string>): boolean {
|
||||
const receivedSign = params.sign
|
||||
if (!receivedSign) return false
|
||||
|
||||
const calculatedSign = this.generateSign(params)
|
||||
return receivedSign === calculatedSign
|
||||
}
|
||||
|
||||
// 构建XML数据
|
||||
private buildXML(params: Record<string, string>): string {
|
||||
const xml = ["<xml>"]
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
xml.push(`<${key}><![CDATA[${value}]]></${key}>`)
|
||||
}
|
||||
xml.push("</xml>")
|
||||
return xml.join("")
|
||||
}
|
||||
|
||||
// 解析XML数据
|
||||
private async parseXML(xml: string): Promise<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]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user