From 4b013370b9b959f3723b0f8a8173a91878639ffa Mon Sep 17 00:00:00 2001 From: erio Date: Sat, 7 Mar 2026 04:15:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=8E=E7=AB=AF=E8=B5=84=E9=87=91?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E8=A6=86=E7=9B=96=E3=80=81=E8=BF=87=E6=9C=9F=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E3=80=81=E9=80=80=E6=AC=BE=E5=8E=9F=E5=AD=90=E6=80=A7?= =?UTF-8?q?=E7=AD=89=209=20=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- prisma/schema.prisma | 1 + src/lib/alipay/sign.ts | 4 +- src/lib/db.ts | 3 +- src/lib/easy-pay/client.ts | 6 +- src/lib/order/service.ts | 114 ++++++++++++++++++++++++------------- src/lib/sub2api/client.ts | 28 +++++++++ 6 files changed, 114 insertions(+), 42 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d248bff..02ea896 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,7 @@ model Order { @@index([expiresAt]) @@index([createdAt]) @@index([paidAt]) + @@index([paymentType, paidAt]) @@map("orders") } diff --git a/src/lib/alipay/sign.ts b/src/lib/alipay/sign.ts index d1c025c..f726118 100644 --- a/src/lib/alipay/sign.ts +++ b/src/lib/alipay/sign.ts @@ -14,7 +14,7 @@ function formatPublicKey(key: string): string { /** 生成 RSA2 签名 */ export function generateSign(params: Record, 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, privateKey: string) /** 用支付宝公钥验证签名 */ export function verifySign(params: Record, 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('&'); diff --git a/src/lib/db.ts b/src/lib/db.ts index 3ab2f07..3e836e3 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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 }); } diff --git a/src/lib/easy-pay/client.ts b/src/lib/easy-pay/client.ts index d2d0de7..38cfbfa 100644 --- a/src/lib/easy-pay/client.ts +++ b/src/lib/easy-pay/client.ts @@ -76,6 +76,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise { 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) { diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index 73494c3..e617f67 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -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 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 { `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 { } 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 { `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: { diff --git a/src/lib/sub2api/client.ts b/src/lib/sub2api/client.ts index 3f83f5e..d585f04 100644 --- a/src/lib/sub2api/client.ts +++ b/src/lib/sub2api/client.ts @@ -19,6 +19,7 @@ export async function getCurrentUserByToken(token: string): Promise headers: { Authorization: `Bearer ${token}`, }, + signal: AbortSignal.timeout(10_000), }); if (!response.ok) { @@ -33,6 +34,7 @@ export async function getUser(userId: number): Promise { 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 { + 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)}`); + } +}