Files
sub2apipay/src/lib/easy-pay/provider.ts
erio 4ce3484179 fix: 全面安全审计修复 — 支付验签、IDOR、竞态、token过期等
- H1: 支付宝响应验签 (verifyResponseSign + bracket-matching 提取签名内容)
- H2/H3: EasyPay queryOrder 从 GET 改 POST,PKEY 不再暴露于 URL
- H5: users/[id] IDOR 修复,校验当前用户只能查询自身信息
- H6: 限额校验移入 prisma.$transaction() 防止 TOCTOU 竞态
- C1: access_token 增加 24h 过期、userId 绑定、派生密钥分离
- M1: EasyPay 回调增加 pid 校验防跨商户注入
- M4: 充值码增加 crypto.randomBytes 随机后缀
- M5: 过期订单批量处理增加 BATCH_SIZE 限制
- M6: 退款失败增加 [CRITICAL] 日志和余额补偿标记
- M7: admin channels PUT 增加 Zod schema 校验
- M8: admin subscriptions 分页参数增加上限
- M9: orders src_url 限制 HTTP/HTTPS 协议
- L1: 微信支付回调时间戳 NaN 检查
- L9: WXPAY_API_V3_KEY 长度校验
2026-03-14 04:36:33 +08:00

105 lines
3.3 KiB
TypeScript

import type {
PaymentProvider,
PaymentType,
CreatePaymentRequest,
CreatePaymentResponse,
QueryOrderResponse,
PaymentNotification,
RefundRequest,
RefundResponse,
} from '@/lib/payment/types';
import { createPayment, queryOrder, refund } from './client';
import { verifySign } from './sign';
import { getEnv } from '@/lib/config';
export class EasyPayProvider implements PaymentProvider {
readonly name = 'easy-pay';
readonly providerKey = 'easypay';
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
readonly defaultLimits = {
alipay: { singleMax: 1000, dailyMax: 10000 },
wxpay: { singleMax: 1000, dailyMax: 10000 },
};
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
const result = await createPayment({
outTradeNo: request.orderId,
amount: request.amount.toFixed(2),
paymentType: request.paymentType as 'alipay' | 'wxpay',
clientIp: request.clientIp || '127.0.0.1',
productName: request.subject,
returnUrl: request.returnUrl,
});
return {
tradeNo: result.trade_no,
payUrl: result.payurl,
qrCode: result.qrcode,
};
}
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
const result = await queryOrder(tradeNo);
return {
tradeNo: result.trade_no,
status: result.status === 1 ? 'paid' : 'pending',
amount: parseFloat(result.money),
paidAt: result.endtime ? new Date(result.endtime) : undefined,
};
}
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
const env = getEnv();
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
const searchParams = new URLSearchParams(body);
const params: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
params[key] = value;
}
const sign = params.sign || '';
const paramsForSign: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (key !== 'sign' && key !== 'sign_type' && value !== undefined && value !== null) {
paramsForSign[key] = value;
}
}
if (!env.EASY_PAY_PKEY || !verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) {
throw new Error('EasyPay notification signature verification failed');
}
// 校验 pid 与配置一致,防止跨商户回调注入
if (params.pid && params.pid !== env.EASY_PAY_PID) {
throw new Error(`EasyPay notification pid mismatch: expected ${env.EASY_PAY_PID}, got ${params.pid}`);
}
// 校验金额为有限正数
const amount = parseFloat(params.money || '0');
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error(`EasyPay notification invalid amount: ${params.money}`);
}
return {
tradeNo: params.trade_no || '',
orderId: params.out_trade_no || '',
amount,
status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed',
rawData: params,
};
}
async refund(request: RefundRequest): Promise<RefundResponse> {
await refund(request.tradeNo, request.orderId, request.amount.toFixed(2));
return {
refundId: `${request.tradeNo}-refund`,
status: 'success',
};
}
async cancelPayment(): Promise<void> {
// EasyPay does not support cancelling payments
}
}