2026-01-21 15:49:12 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 支付宝网关实现 (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';
|
2026-01-09 11:58:08 +08:00
|
|
|
|
|
|
|
|
|
|
export interface AlipayConfig {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
2026-01-09 11:58:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 注册到工厂
|
|
|
|
|
|
PaymentFactory.register('alipay', AlipayGateway);
|
|
|
|
|
|
|
|
|
|
|
|
// 导出兼容旧版的 AlipayService
|
|
|
|
|
|
export interface AlipayServiceConfig {
|
|
|
|
|
|
appId: string;
|
|
|
|
|
|
partnerId: string;
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
returnUrl: string;
|
|
|
|
|
|
notifyUrl: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 兼容旧版的 AlipayService
|
|
|
|
|
|
* @deprecated 请使用 AlipayGateway
|
|
|
|
|
|
*/
|
2026-01-09 11:58:08 +08:00
|
|
|
|
export class AlipayService {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-09 11:58:08 +08:00
|
|
|
|
|
|
|
|
|
|
createOrder(params: {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
outTradeNo: string;
|
|
|
|
|
|
subject: string;
|
|
|
|
|
|
totalAmount: number;
|
|
|
|
|
|
body?: string;
|
2026-01-09 11:58:08 +08:00
|
|
|
|
}) {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
// 同步创建订单信息
|
|
|
|
|
|
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,
|
2026-01-09 11:58:08 +08:00
|
|
|
|
biz_content: JSON.stringify({
|
|
|
|
|
|
out_trade_no: params.outTradeNo,
|
2026-01-21 15:49:12 +08:00
|
|
|
|
product_code: 'QUICK_WAP_WAY',
|
2026-01-09 11:58:08 +08:00
|
|
|
|
total_amount: params.totalAmount.toFixed(2),
|
|
|
|
|
|
subject: params.subject,
|
|
|
|
|
|
body: params.body || params.subject,
|
|
|
|
|
|
}),
|
2026-01-21 15:49:12 +08:00
|
|
|
|
};
|
2026-01-09 11:58:08 +08:00
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
const sign = this.generateSign(orderInfo);
|
2026-01-09 11:58:08 +08:00
|
|
|
|
return {
|
|
|
|
|
|
...orderInfo,
|
|
|
|
|
|
sign,
|
|
|
|
|
|
paymentUrl: this.buildPaymentUrl(orderInfo, sign),
|
2026-01-21 15:49:12 +08:00
|
|
|
|
};
|
2026-01-09 11:58:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
generateSign(params: Record<string, string>): string {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
const sortedKeys = Object.keys(params).sort();
|
2026-01-09 11:58:08 +08:00
|
|
|
|
const signString = sortedKeys
|
2026-01-21 15:49:12 +08:00
|
|
|
|
.filter((key) => params[key] && key !== 'sign')
|
2026-01-09 11:58:08 +08:00
|
|
|
|
.map((key) => `${key}=${params[key]}`)
|
2026-01-21 15:49:12 +08:00
|
|
|
|
.join('&');
|
2026-01-09 11:58:08 +08:00
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
const md5Key = (this.gateway as AlipayGateway)['md5Key'];
|
|
|
|
|
|
const signWithKey = `${signString}${md5Key}`;
|
|
|
|
|
|
return crypto.createHash('md5').update(signWithKey, 'utf8').digest('hex');
|
2026-01-09 11:58:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
verifySign(params: Record<string, string>): boolean {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
return this.gateway.verifySign(params);
|
|
|
|
|
|
}
|
2026-01-09 11:58:08 +08:00
|
|
|
|
|
2026-01-21 15:49:12 +08:00
|
|
|
|
async queryTrade(tradeSn: string) {
|
|
|
|
|
|
return this.gateway.queryTrade(tradeSn);
|
2026-01-09 11:58:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private buildPaymentUrl(params: Record<string, string>, sign: string): string {
|
2026-01-21 15:49:12 +08:00
|
|
|
|
const gateway = 'https://openapi.alipay.com/gateway.do';
|
|
|
|
|
|
const queryParams = new URLSearchParams({ ...params, sign });
|
|
|
|
|
|
return `${gateway}?${queryParams.toString()}`;
|
2026-01-09 11:58:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|