主要更新: 1. 按H5网页端完全重构匹配功能(match页面) - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募 - 资源对接等类型弹出手机号/微信号输入框 - 去掉重新匹配按钮,改为返回按钮 2. 修复所有卡片对齐和宽度问题 - 目录页附录卡片居中 - 首页阅读进度卡片满宽度 - 我的页面菜单卡片对齐 - 推广中心分享卡片统一宽度 3. 修复目录页图标和文字对齐 - section-icon固定40rpx宽高 - section-title与图标垂直居中 4. 更新真实完整文章标题(62篇) - 从book目录读取真实markdown文件名 - 替换之前的简化标题 5. 新增文章数据API - /api/db/chapters - 获取完整书籍结构 - 支持按ID获取单篇文章内容
615 lines
17 KiB
TypeScript
615 lines
17 KiB
TypeScript
/**
|
||
* 支付宝网关实现 (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;
|
||
pid: string;
|
||
sellerEmail?: string;
|
||
privateKey?: string;
|
||
publicKey?: string;
|
||
md5Key?: string;
|
||
enabled?: boolean;
|
||
mode?: 'sandbox' | 'production';
|
||
}
|
||
|
||
/**
|
||
* 支付宝网关
|
||
*/
|
||
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;
|
||
}) {
|
||
// 同步创建订单信息
|
||
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',
|
||
total_amount: params.totalAmount.toFixed(2),
|
||
subject: params.subject,
|
||
body: params.body || params.subject,
|
||
}),
|
||
};
|
||
|
||
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 signString = sortedKeys
|
||
.filter((key) => params[key] && key !== 'sign')
|
||
.map((key) => `${key}=${params[key]}`)
|
||
.join('&');
|
||
|
||
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 {
|
||
return this.gateway.verifySign(params);
|
||
}
|
||
|
||
async queryTrade(tradeSn: string) {
|
||
return this.gateway.queryTrade(tradeSn);
|
||
}
|
||
|
||
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()}`;
|
||
}
|
||
}
|