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:
@@ -47,6 +47,7 @@ model Order {
|
||||
@@index([expiresAt])
|
||||
@@index([createdAt])
|
||||
@@index([paidAt])
|
||||
@@index([paymentType, paidAt])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
|
||||
@@ -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('&');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user