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([expiresAt])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([paidAt])
|
@@index([paidAt])
|
||||||
|
@@index([paymentType, paidAt])
|
||||||
@@map("orders")
|
@@map("orders")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function formatPublicKey(key: string): string {
|
|||||||
/** 生成 RSA2 签名 */
|
/** 生成 RSA2 签名 */
|
||||||
export function generateSign(params: Record<string, string>, privateKey: string): string {
|
export function generateSign(params: Record<string, string>, privateKey: string): string {
|
||||||
const filtered = Object.entries(params)
|
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));
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
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 {
|
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
||||||
const filtered = Object.entries(params)
|
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));
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { getEnv } from '@/lib/config';
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
function createPrismaClient() {
|
function createPrismaClient() {
|
||||||
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/sub2apipay';
|
const connectionString = getEnv().DATABASE_URL;
|
||||||
const adapter = new PrismaPg({ connectionString });
|
const adapter = new PrismaPg({ connectionString });
|
||||||
return new PrismaClient({ adapter });
|
return new PrismaClient({ adapter });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = (await response.json()) as EasyPayCreateResponse;
|
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> {
|
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
|
||||||
const env = assertEasyPayEnv(getEnv());
|
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 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;
|
const data = (await response.json()) as EasyPayQueryResponse;
|
||||||
if (data.code !== 1) {
|
if (data.code !== 1) {
|
||||||
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
|
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',
|
method: 'POST',
|
||||||
body: params,
|
body: params,
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
const data = (await response.json()) as EasyPayRefundResponse;
|
const data = (await response.json()) as EasyPayRefundResponse;
|
||||||
if (data.code !== 1) {
|
if (data.code !== 1) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getMethodDailyLimit } from './limits';
|
|||||||
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
||||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
import type { PaymentType, PaymentNotification } 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 { Prisma } from '@prisma/client';
|
||||||
import { deriveOrderState, isRefundStatus } from './status';
|
import { deriveOrderState, isRefundStatus } from './status';
|
||||||
|
|
||||||
@@ -101,29 +101,33 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
const payAmount = calculatePayAmount(input.amount, feeRate);
|
const payAmount = calculatePayAmount(input.amount, feeRate);
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
||||||
const order = await prisma.order.create({
|
const order = await prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const created = await tx.order.create({
|
||||||
userId: input.userId,
|
data: {
|
||||||
userEmail: user.email,
|
userId: input.userId,
|
||||||
userName: user.username,
|
userEmail: user.email,
|
||||||
userNotes: user.notes || null,
|
userName: user.username,
|
||||||
amount: new Prisma.Decimal(input.amount.toFixed(2)),
|
userNotes: user.notes || null,
|
||||||
payAmount: new Prisma.Decimal(payAmount.toFixed(2)),
|
amount: new Prisma.Decimal(input.amount.toFixed(2)),
|
||||||
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null,
|
payAmount: new Prisma.Decimal(payAmount.toFixed(2)),
|
||||||
rechargeCode: '',
|
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null,
|
||||||
status: 'PENDING',
|
rechargeCode: '',
|
||||||
paymentType: input.paymentType,
|
status: 'PENDING',
|
||||||
expiresAt,
|
paymentType: input.paymentType,
|
||||||
clientIp: input.clientIp,
|
expiresAt,
|
||||||
srcHost: input.srcHost || null,
|
clientIp: input.clientIp,
|
||||||
srcUrl: input.srcUrl || null,
|
srcHost: input.srcHost || null,
|
||||||
},
|
srcUrl: input.srcUrl || null,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const rechargeCode = generateRechargeCode(order.id);
|
const rechargeCode = generateRechargeCode(created.id);
|
||||||
await prisma.order.update({
|
await tx.order.update({
|
||||||
where: { id: order.id },
|
where: { id: created.id },
|
||||||
data: { rechargeCode },
|
data: { rechargeCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...created, rechargeCode };
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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({
|
const result = await prisma.order.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: order.id,
|
id: order.id,
|
||||||
status: { in: [ORDER_STATUS.PENDING, ORDER_STATUS.EXPIRED] },
|
OR: [
|
||||||
|
{ status: ORDER_STATUS.PENDING },
|
||||||
|
{ status: ORDER_STATUS.EXPIRED, updatedAt: { gte: graceDeadline } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: ORDER_STATUS.PAID,
|
status: ORDER_STATUS.PAID,
|
||||||
amount: paidAmount,
|
payAmount: paidAmount,
|
||||||
paymentTradeNo: input.tradeNo,
|
paymentTradeNo: input.tradeNo,
|
||||||
paidAt: new Date(),
|
paidAt: new Date(),
|
||||||
failedAt: null,
|
failedAt: null,
|
||||||
@@ -486,8 +495,8 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
|||||||
`sub2apipay recharge order:${orderId}`,
|
`sub2apipay recharge order:${orderId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await prisma.order.update({
|
await prisma.order.updateMany({
|
||||||
where: { id: orderId },
|
where: { id: orderId, status: ORDER_STATUS.RECHARGING },
|
||||||
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
|
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -664,17 +673,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (order.paymentTradeNo) {
|
// 1. 先扣减用户余额(安全方向:先扣后退)
|
||||||
initPaymentProviders();
|
|
||||||
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
|
|
||||||
await provider.refund({
|
|
||||||
tradeNo: order.paymentTradeNo,
|
|
||||||
orderId: order.id,
|
|
||||||
amount: refundAmount,
|
|
||||||
reason: input.reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await subtractBalance(
|
await subtractBalance(
|
||||||
order.userId,
|
order.userId,
|
||||||
rechargeAmount,
|
rechargeAmount,
|
||||||
@@ -682,6 +681,45 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
|||||||
`sub2apipay:refund:${order.id}`,
|
`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({
|
await prisma.order.update({
|
||||||
where: { id: input.orderId },
|
where: { id: input.orderId },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function getCurrentUserByToken(token: string): Promise<Sub2ApiUser>
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -33,6 +34,7 @@ export async function getUser(userId: number): Promise<Sub2ApiUser> {
|
|||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}`, {
|
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}`, {
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -61,6 +63,7 @@ export async function createAndRedeem(
|
|||||||
user_id: userId,
|
user_id: userId,
|
||||||
notes,
|
notes,
|
||||||
}),
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -87,6 +90,7 @@ export async function subtractBalance(
|
|||||||
amount,
|
amount,
|
||||||
notes,
|
notes,
|
||||||
}),
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -94,3 +98,27 @@ export async function subtractBalance(
|
|||||||
throw new Error(`Subtract balance failed (${response.status}): ${JSON.stringify(errorData)}`);
|
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