fix: 后端资金安全修复 — 金额覆盖、过期订单、退款原子性等 9 项

- confirmPayment 不再覆盖 amount,实付金额写入 payAmount
- EXPIRED 订单增加 5 分钟宽限窗口
- 退款流程先扣余额再退款,失败可回滚
- 支付宝签名过滤 sign_type
- executeRecharge 使用 CAS 更新
- createOrder rechargeCode 事务保护
- EasyPay/Sub2API client 添加 10s 超时
- db.ts 统一从 getEnv() 获取 DATABASE_URL
- 添加 paymentType+paidAt 复合索引

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erio
2026-03-07 04:15:48 +08:00
parent a5e07edda6
commit 4b013370b9
6 changed files with 114 additions and 42 deletions

View File

@@ -47,6 +47,7 @@ model Order {
@@index([expiresAt])
@@index([createdAt])
@@index([paidAt])
@@index([paymentType, paidAt])
@@map("orders")
}

View File

@@ -14,7 +14,7 @@ function formatPublicKey(key: string): string {
/** 生成 RSA2 签名 */
export function generateSign(params: Record<string, string>, privateKey: string): string {
const filtered = Object.entries(params)
.filter(([key, value]) => key !== 'sign' && value !== '' && value !== undefined && value !== null)
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
.sort(([a], [b]) => a.localeCompare(b));
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
@@ -27,7 +27,7 @@ export function generateSign(params: Record<string, string>, privateKey: string)
/** 用支付宝公钥验证签名 */
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
const filtered = Object.entries(params)
.filter(([key, value]) => key !== 'sign' && value !== '' && value !== undefined && value !== null)
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
.sort(([a], [b]) => a.localeCompare(b));
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');

View File

@@ -1,10 +1,11 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { getEnv } from '@/lib/config';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createPrismaClient() {
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/sub2apipay';
const connectionString = getEnv().DATABASE_URL;
const adapter = new PrismaPg({ connectionString });
return new PrismaClient({ adapter });
}

View File

@@ -76,6 +76,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
method: 'POST',
body: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as EasyPayCreateResponse;
@@ -88,7 +89,9 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
const env = assertEasyPayEnv(getEnv());
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
const response = await fetch(url);
const response = await fetch(url, {
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as EasyPayQueryResponse;
if (data.code !== 1) {
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
@@ -109,6 +112,7 @@ export async function refund(tradeNo: string, outTradeNo: string, money: string)
method: 'POST',
body: params,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as EasyPayRefundResponse;
if (data.code !== 1) {

View File

@@ -6,7 +6,7 @@ import { getMethodDailyLimit } from './limits';
import { getMethodFeeRate, calculatePayAmount } from './fee';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import type { PaymentType, PaymentNotification } from '@/lib/payment';
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
import { getUser, createAndRedeem, subtractBalance, addBalance } from '@/lib/sub2api/client';
import { Prisma } from '@prisma/client';
import { deriveOrderState, isRefundStatus } from './status';
@@ -101,29 +101,33 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
const payAmount = calculatePayAmount(input.amount, feeRate);
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
const order = await prisma.order.create({
data: {
userId: input.userId,
userEmail: user.email,
userName: user.username,
userNotes: user.notes || null,
amount: new Prisma.Decimal(input.amount.toFixed(2)),
payAmount: new Prisma.Decimal(payAmount.toFixed(2)),
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null,
rechargeCode: '',
status: 'PENDING',
paymentType: input.paymentType,
expiresAt,
clientIp: input.clientIp,
srcHost: input.srcHost || null,
srcUrl: input.srcUrl || null,
},
});
const order = await prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
userId: input.userId,
userEmail: user.email,
userName: user.username,
userNotes: user.notes || null,
amount: new Prisma.Decimal(input.amount.toFixed(2)),
payAmount: new Prisma.Decimal(payAmount.toFixed(2)),
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null,
rechargeCode: '',
status: 'PENDING',
paymentType: input.paymentType,
expiresAt,
clientIp: input.clientIp,
srcHost: input.srcHost || null,
srcUrl: input.srcUrl || null,
},
});
const rechargeCode = generateRechargeCode(order.id);
await prisma.order.update({
where: { id: order.id },
data: { rechargeCode },
const rechargeCode = generateRechargeCode(created.id);
await tx.order.update({
where: { id: created.id },
data: { rechargeCode },
});
return { ...created, rechargeCode };
});
try {
@@ -363,14 +367,19 @@ export async function confirmPayment(input: {
);
}
// 只接受 PENDING 状态,或过期不超过 5 分钟的 EXPIRED 订单(支付在过期边缘完成的宽限窗口)
const graceDeadline = new Date(Date.now() - 5 * 60 * 1000);
const result = await prisma.order.updateMany({
where: {
id: order.id,
status: { in: [ORDER_STATUS.PENDING, ORDER_STATUS.EXPIRED] },
OR: [
{ status: ORDER_STATUS.PENDING },
{ status: ORDER_STATUS.EXPIRED, updatedAt: { gte: graceDeadline } },
],
},
data: {
status: ORDER_STATUS.PAID,
amount: paidAmount,
payAmount: paidAmount,
paymentTradeNo: input.tradeNo,
paidAt: new Date(),
failedAt: null,
@@ -486,8 +495,8 @@ export async function executeRecharge(orderId: string): Promise<void> {
`sub2apipay recharge order:${orderId}`,
);
await prisma.order.update({
where: { id: orderId },
await prisma.order.updateMany({
where: { id: orderId, status: ORDER_STATUS.RECHARGING },
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
});
@@ -664,17 +673,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
}
try {
if (order.paymentTradeNo) {
initPaymentProviders();
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
await provider.refund({
tradeNo: order.paymentTradeNo,
orderId: order.id,
amount: refundAmount,
reason: input.reason,
});
}
// 1. 先扣减用户余额(安全方向:先扣后退)
await subtractBalance(
order.userId,
rechargeAmount,
@@ -682,6 +681,45 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
`sub2apipay:refund:${order.id}`,
);
// 2. 调用支付网关退款
if (order.paymentTradeNo) {
try {
initPaymentProviders();
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
await provider.refund({
tradeNo: order.paymentTradeNo,
orderId: order.id,
amount: refundAmount,
reason: input.reason,
});
} catch (gatewayError) {
// 3. 网关退款失败 — 恢复已扣减的余额
try {
await addBalance(
order.userId,
rechargeAmount,
`sub2apipay refund rollback order:${order.id}`,
`sub2apipay:refund-rollback:${order.id}`,
);
} catch (rollbackError) {
// 余额恢复也失败,记录审计日志,需人工介入
await prisma.auditLog.create({
data: {
orderId: input.orderId,
action: 'REFUND_ROLLBACK_FAILED',
detail: JSON.stringify({
gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError),
rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
rechargeAmount,
}),
operator: 'admin',
},
});
}
throw gatewayError;
}
}
await prisma.order.update({
where: { id: input.orderId },
data: {

View File

@@ -19,6 +19,7 @@ export async function getCurrentUserByToken(token: string): Promise<Sub2ApiUser>
headers: {
Authorization: `Bearer ${token}`,
},
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
@@ -33,6 +34,7 @@ export async function getUser(userId: number): Promise<Sub2ApiUser> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}`, {
headers: getHeaders(),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
@@ -61,6 +63,7 @@ export async function createAndRedeem(
user_id: userId,
notes,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
@@ -87,6 +90,7 @@ export async function subtractBalance(
amount,
notes,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
@@ -94,3 +98,27 @@ export async function subtractBalance(
throw new Error(`Subtract balance failed (${response.status}): ${JSON.stringify(errorData)}`);
}
}
export async function addBalance(
userId: number,
amount: number,
notes: string,
idempotencyKey: string,
): Promise<void> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {
method: 'POST',
headers: getHeaders(idempotencyKey),
body: JSON.stringify({
operation: 'add',
amount,
notes,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Add balance failed (${response.status}): ${JSON.stringify(errorData)}`);
}
}