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 长度校验
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
|
||||
@@ -52,10 +52,15 @@ function createPendingOrder(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
describe('GET /pay/[orderId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: new Date('2026-03-14T12:00:00Z') });
|
||||
vi.clearAllMocks();
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mock=1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns 404 error page when order does not exist', async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const { mockGenerateSign } = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock('@/lib/alipay/sign', () => ({
|
||||
generateSign: mockGenerateSign,
|
||||
verifyResponseSign: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { execute, pageExecute } from '@/lib/alipay/client';
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('EasyPay client', () => {
|
||||
});
|
||||
|
||||
describe('queryOrder', () => {
|
||||
it('should call GET api.php with correct query parameters', async () => {
|
||||
it('should call POST api.php with correct body parameters', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
@@ -285,12 +285,14 @@ describe('EasyPay client', () => {
|
||||
expect(result.status).toBe(1);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toContain('https://pay.example.com/api.php');
|
||||
expect(url).toContain('act=order');
|
||||
expect(url).toContain('pid=1001');
|
||||
expect(url).toContain('key=test-merchant-secret-key');
|
||||
expect(url).toContain('out_trade_no=order-001');
|
||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('https://pay.example.com/api.php');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('act')).toBe('order');
|
||||
expect(body.get('pid')).toBe('1001');
|
||||
expect(body.get('key')).toBe('test-merchant-secret-key');
|
||||
expect(body.get('out_trade_no')).toBe('order-001');
|
||||
});
|
||||
|
||||
it('should throw when API returns code !== 1', async () => {
|
||||
|
||||
@@ -2,9 +2,12 @@ import { describe, it, expect } from 'vitest';
|
||||
import { generateRechargeCode } from '@/lib/order/code-gen';
|
||||
|
||||
describe('generateRechargeCode', () => {
|
||||
it('should generate code with s2p_ prefix', () => {
|
||||
it('should generate code with s2p_ prefix and random suffix', () => {
|
||||
const code = generateRechargeCode('cm1234567890');
|
||||
expect(code).toBe('s2p_cm1234567890');
|
||||
expect(code.startsWith('s2p_')).toBe(true);
|
||||
expect(code.length).toBeLessThanOrEqual(32);
|
||||
// 包含 orderId 部分和 8 字符随机后缀
|
||||
expect(code.length).toBeGreaterThan(12);
|
||||
});
|
||||
|
||||
it('should truncate long order IDs to fit 32 chars', () => {
|
||||
@@ -14,8 +17,15 @@ describe('generateRechargeCode', () => {
|
||||
expect(code.startsWith('s2p_')).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate different codes for same orderId (randomness)', () => {
|
||||
const code1 = generateRechargeCode('order-001');
|
||||
const code2 = generateRechargeCode('order-001');
|
||||
expect(code1).not.toBe(code2);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const code = generateRechargeCode('');
|
||||
expect(code).toBe('s2p_');
|
||||
expect(code.startsWith('s2p_')).toBe(true);
|
||||
expect(code.length).toBeLessThanOrEqual(32);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
const updateChannelSchema = z
|
||||
.object({
|
||||
group_id: z.number().int().positive().optional(),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
platform: z.string().min(1).max(50).optional(),
|
||||
rate_multiplier: z.number().positive().optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
models: z.array(z.string()).nullable().optional(),
|
||||
features: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
sort_order: z.number().int().min(0).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const rawBody = await request.json();
|
||||
const parsed = updateChannelSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: '参数校验失败' }, { status: 400 });
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
const existing = await prisma.channel.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
@@ -27,15 +47,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
if (body.rate_multiplier !== undefined && (typeof body.rate_multiplier !== 'number' || body.rate_multiplier <= 0)) {
|
||||
return NextResponse.json({ error: 'rate_multiplier 必须是正数' }, { status: 400 });
|
||||
}
|
||||
if (body.sort_order !== undefined && (!Number.isInteger(body.sort_order) || body.sort_order < 0)) {
|
||||
return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.group_id !== undefined) data.groupId = Number(body.group_id);
|
||||
if (body.group_id !== undefined) data.groupId = body.group_id;
|
||||
if (body.name !== undefined) data.name = body.name;
|
||||
if (body.platform !== undefined) data.platform = body.platform;
|
||||
if (body.rate_multiplier !== undefined) data.rateMultiplier = body.rate_multiplier;
|
||||
|
||||
@@ -37,8 +37,8 @@ export async function GET(request: NextRequest) {
|
||||
const result = await listSubscriptions({
|
||||
group_id: groupId ? Number(groupId) : undefined,
|
||||
status: status || undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
page_size: pageSize ? Number(pageSize) : undefined,
|
||||
page: page ? Math.max(1, Number(page)) : undefined,
|
||||
page_size: pageSize ? Math.min(200, Math.max(1, Number(pageSize))) : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -11,7 +11,18 @@ const createOrderSchema = z.object({
|
||||
amount: z.number().positive().max(99999999.99),
|
||||
payment_type: z.string().min(1),
|
||||
src_host: z.string().max(253).optional(),
|
||||
src_url: z.string().max(2048).optional(),
|
||||
src_url: z
|
||||
.string()
|
||||
.max(2048)
|
||||
.refine((url) => {
|
||||
try {
|
||||
const protocol = new URL(url).protocol;
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'src_url must be a valid HTTP/HTTPS URL')
|
||||
.optional(),
|
||||
is_mobile: z.boolean().optional(),
|
||||
order_type: z.enum(['balance', 'subscription']).optional(),
|
||||
plan_id: z.string().optional(),
|
||||
|
||||
@@ -9,8 +9,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'token is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let currentUser: { id: number };
|
||||
try {
|
||||
await getCurrentUserByToken(token);
|
||||
currentUser = await getCurrentUserByToken(token);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
@@ -22,6 +23,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Invalid user id' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 只允许查询自身用户信息,防止 IDOR 用户枚举
|
||||
if (userId !== currentUser.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getUser(userId);
|
||||
return NextResponse.json({ id: user.id, exists: true });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { generateSign } from './sign';
|
||||
import { generateSign, verifyResponseSign } from './sign';
|
||||
import type { AlipayResponse } from './types';
|
||||
import { parseAlipayJsonResponse } from './codec';
|
||||
import { parseAlipayJsonResponseWithRaw } from './codec';
|
||||
|
||||
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
||||
|
||||
@@ -89,14 +89,20 @@ export async function execute<T extends AlipayResponse>(
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const data = await parseAlipayJsonResponse<Record<string, unknown>>(response);
|
||||
const { data, rawText } = await parseAlipayJsonResponseWithRaw(response);
|
||||
|
||||
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
||||
// TODO: 实现响应验签 — 需要从原始响应文本中提取 responseKey 对应的 JSON 子串,
|
||||
// 使用 verifySign 配合 ALIPAY_PUBLIC_KEY 验证 data.sign。
|
||||
// 当前未验签是因为需要保留原始响应文本(不能 JSON.parse 后再 stringify),
|
||||
// 需要改造 parseAlipayJsonResponse 同时返回原始文本。
|
||||
const responseKey = method.replace(/\./g, '_') + '_response';
|
||||
|
||||
// 响应验签:从原始文本中提取 responseKey 对应的 JSON 子串进行 RSA2 验签
|
||||
const responseSign = data.sign as string | undefined;
|
||||
if (responseSign) {
|
||||
const valid = verifyResponseSign(rawText, responseKey, env.ALIPAY_PUBLIC_KEY, responseSign);
|
||||
if (!valid) {
|
||||
throw new Error(`Alipay API response signature verification failed for ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = data[responseKey] as T | undefined;
|
||||
|
||||
if (!result) {
|
||||
|
||||
@@ -102,3 +102,16 @@ export async function parseAlipayJsonResponse<T>(response: Response): Promise<T>
|
||||
const text = decodeAlipayPayload(rawBody, { 'content-type': contentType });
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析支付宝 JSON 响应并返回原始文本,用于响应验签。
|
||||
* 验签要求使用原始 JSON 子串(不能 parse 后再 stringify)。
|
||||
*/
|
||||
export async function parseAlipayJsonResponseWithRaw(
|
||||
response: Response,
|
||||
): Promise<{ data: Record<string, unknown>; rawText: string }> {
|
||||
const rawBody = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const rawText = decodeAlipayPayload(rawBody, { 'content-type': contentType });
|
||||
return { data: JSON.parse(rawText), rawText };
|
||||
}
|
||||
|
||||
@@ -43,6 +43,73 @@ export function generateSign(params: Record<string, string>, privateKey: string)
|
||||
return signer.sign(formatPrivateKey(privateKey), 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付宝服务端 API 响应签名。
|
||||
* 从原始 JSON 文本中提取 responseKey 对应的子串作为验签内容。
|
||||
*/
|
||||
export function verifyResponseSign(
|
||||
rawText: string,
|
||||
responseKey: string,
|
||||
alipayPublicKey: string,
|
||||
sign: string,
|
||||
): boolean {
|
||||
// 从原始文本中精确提取 responseKey 对应的 JSON 子串
|
||||
// 格式: {"responseKey":{ ... },"sign":"..."}
|
||||
const keyPattern = `"${responseKey}"`;
|
||||
const keyIdx = rawText.indexOf(keyPattern);
|
||||
if (keyIdx < 0) return false;
|
||||
|
||||
const colonIdx = rawText.indexOf(':', keyIdx + keyPattern.length);
|
||||
if (colonIdx < 0) return false;
|
||||
|
||||
// 找到 value 的起始位置(跳过冒号后的空白)
|
||||
let start = colonIdx + 1;
|
||||
while (start < rawText.length && rawText[start] === ' ') start++;
|
||||
|
||||
// 使用括号匹配找到完整的 JSON 值
|
||||
let depth = 0;
|
||||
let end = start;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = start; i < rawText.length; i++) {
|
||||
const ch = rawText[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\' && inString) {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const signContent = rawText.substring(start, end);
|
||||
const pem = formatPublicKey(alipayPublicKey);
|
||||
try {
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(signContent);
|
||||
return verifier.verify(pem, sign, 'base64');
|
||||
} catch (err) {
|
||||
if (shouldLogVerifyDebug()) {
|
||||
console.error('[Alipay verifyResponseSign] crypto error:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 用支付宝公钥验证签名(回调验签:排除 sign 和 sign_type) */
|
||||
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
||||
const filtered = Object.entries(params)
|
||||
|
||||
@@ -89,8 +89,17 @@ 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, {
|
||||
// 使用 POST 避免密钥暴露在 URL 中(URL 会被记录到服务器/CDN 日志)
|
||||
const params = new URLSearchParams({
|
||||
act: 'order',
|
||||
pid: env.EASY_PAY_PID,
|
||||
key: env.EASY_PAY_PKEY,
|
||||
out_trade_no: outTradeNo,
|
||||
});
|
||||
const response = await fetch(`${env.EASY_PAY_API_BASE}/api.php`, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const data = (await response.json()) as EasyPayQueryResponse;
|
||||
|
||||
@@ -70,10 +70,21 @@ export class EasyPayProvider implements PaymentProvider {
|
||||
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: parseFloat(params.money || '0'),
|
||||
amount,
|
||||
status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function generateRechargeCode(orderId: string): string {
|
||||
const prefix = 's2p_';
|
||||
const maxIdLength = 32 - prefix.length; // 28
|
||||
const truncatedId = orderId.slice(0, maxIdLength);
|
||||
return `${prefix}${truncatedId}`;
|
||||
const random = crypto.randomBytes(4).toString('hex'); // 8 chars
|
||||
const maxIdLength = 32 - prefix.length - random.length; // 16
|
||||
const truncatedId = orderId.replace(/-/g, '').slice(0, maxIdLength);
|
||||
return `${prefix}${truncatedId}${random}`;
|
||||
}
|
||||
|
||||
@@ -127,84 +127,87 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
throw new OrderError('USER_INACTIVE', message(locale, '用户账号已被禁用', 'User account is disabled'), 422);
|
||||
}
|
||||
|
||||
const pendingCount = await prisma.order.count({
|
||||
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
|
||||
});
|
||||
if (pendingCount >= MAX_PENDING_ORDERS) {
|
||||
throw new OrderError(
|
||||
'TOO_MANY_PENDING',
|
||||
message(
|
||||
locale,
|
||||
`待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`,
|
||||
`Too many pending orders (${MAX_PENDING_ORDERS})`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
// 每日累计充值限额校验(0 = 不限制)
|
||||
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
||||
const dailyAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
|
||||
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
|
||||
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
|
||||
throw new OrderError(
|
||||
'DAILY_LIMIT_EXCEEDED',
|
||||
message(
|
||||
locale,
|
||||
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`,
|
||||
`Daily recharge limit reached. Remaining amount: ${remaining.toFixed(2)} CNY`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 渠道每日全平台限额校验(0 = 不限)
|
||||
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
||||
if (methodDailyLimit > 0) {
|
||||
const methodAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
paymentType: input.paymentType,
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const methodUsed = Number(methodAgg._sum.amount ?? 0);
|
||||
if (methodUsed + input.amount > methodDailyLimit) {
|
||||
const remaining = Math.max(0, methodDailyLimit - methodUsed);
|
||||
throw new OrderError(
|
||||
'METHOD_DAILY_LIMIT_EXCEEDED',
|
||||
remaining > 0
|
||||
? message(
|
||||
locale,
|
||||
`${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`,
|
||||
`${input.paymentType} remaining daily quota: ${remaining.toFixed(2)} CNY. Reduce the amount or use another payment method`,
|
||||
)
|
||||
: message(
|
||||
locale,
|
||||
`${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
|
||||
`${input.paymentType} daily quota is full. Please use another payment method`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const feeRate = getMethodFeeRate(input.paymentType);
|
||||
const payAmountStr = calculatePayAmount(input.amount, feeRate);
|
||||
const payAmountNum = Number(payAmountStr);
|
||||
|
||||
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
||||
|
||||
// 将限额校验与订单创建放在同一个 serializable 事务中,防止并发突破限额
|
||||
const order = await prisma.$transaction(async (tx) => {
|
||||
// 待支付订单数限制
|
||||
const pendingCount = await tx.order.count({
|
||||
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
|
||||
});
|
||||
if (pendingCount >= MAX_PENDING_ORDERS) {
|
||||
throw new OrderError(
|
||||
'TOO_MANY_PENDING',
|
||||
message(
|
||||
locale,
|
||||
`待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`,
|
||||
`Too many pending orders (${MAX_PENDING_ORDERS})`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
// 每日累计充值限额校验(0 = 不限制)
|
||||
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
||||
const dailyAgg = await tx.order.aggregate({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
|
||||
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
|
||||
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
|
||||
throw new OrderError(
|
||||
'DAILY_LIMIT_EXCEEDED',
|
||||
message(
|
||||
locale,
|
||||
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`,
|
||||
`Daily recharge limit reached. Remaining amount: ${remaining.toFixed(2)} CNY`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 渠道每日全平台限额校验(0 = 不限)
|
||||
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
||||
if (methodDailyLimit > 0) {
|
||||
const methodAgg = await tx.order.aggregate({
|
||||
where: {
|
||||
paymentType: input.paymentType,
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const methodUsed = Number(methodAgg._sum.amount ?? 0);
|
||||
if (methodUsed + input.amount > methodDailyLimit) {
|
||||
const remaining = Math.max(0, methodDailyLimit - methodUsed);
|
||||
throw new OrderError(
|
||||
'METHOD_DAILY_LIMIT_EXCEEDED',
|
||||
remaining > 0
|
||||
? message(
|
||||
locale,
|
||||
`${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`,
|
||||
`${input.paymentType} remaining daily quota: ${remaining.toFixed(2)} CNY. Reduce the amount or use another payment method`,
|
||||
)
|
||||
: message(
|
||||
locale,
|
||||
`${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
|
||||
`${input.paymentType} daily quota is full. Please use another payment method`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
@@ -243,8 +246,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
||||
|
||||
const statusAccessToken = createOrderStatusAccessToken(order.id);
|
||||
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id);
|
||||
const statusAccessToken = createOrderStatusAccessToken(order.id, input.userId);
|
||||
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id, input.userId);
|
||||
|
||||
// 只有 easypay 从外部传入 notifyUrl,return_url 统一回到带访问令牌的结果页
|
||||
let notifyUrl: string | undefined;
|
||||
@@ -1002,7 +1005,10 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
`sub2apipay:refund-rollback:${order.id}`,
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
// 余额恢复也失败,记录审计日志,需人工介入
|
||||
// 余额恢复也失败,记录审计日志并标记需要补偿,便于定时任务或管理员重试
|
||||
console.error(
|
||||
`[CRITICAL] Refund rollback failed for order ${input.orderId}: balance deducted ${rechargeAmount} but gateway refund and balance restoration both failed. Manual intervention required.`,
|
||||
);
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orderId: input.orderId,
|
||||
@@ -1011,6 +1017,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError),
|
||||
rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
|
||||
rechargeAmount,
|
||||
needsBalanceCompensation: true,
|
||||
}),
|
||||
operator: 'admin',
|
||||
},
|
||||
|
||||
@@ -2,25 +2,48 @@ import crypto from 'crypto';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
export const ORDER_STATUS_ACCESS_QUERY_KEY = 'access_token';
|
||||
const ORDER_STATUS_ACCESS_PURPOSE = 'order-status-access:v1';
|
||||
const ORDER_STATUS_ACCESS_PURPOSE = 'order-status-access:v2';
|
||||
/** access_token 有效期(24 小时) */
|
||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function buildSignature(orderId: string): string {
|
||||
/** 使用独立派生密钥,不直接使用 ADMIN_TOKEN */
|
||||
function deriveKey(): string {
|
||||
return crypto.createHmac('sha256', getEnv().ADMIN_TOKEN).update('order-status-access-key').digest('hex');
|
||||
}
|
||||
|
||||
function buildSignature(orderId: string, userId: number, expiresAt: number): string {
|
||||
return crypto
|
||||
.createHmac('sha256', getEnv().ADMIN_TOKEN)
|
||||
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}`)
|
||||
.createHmac('sha256', deriveKey())
|
||||
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}:${userId}:${expiresAt}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
export function createOrderStatusAccessToken(orderId: string): string {
|
||||
return buildSignature(orderId);
|
||||
/** 生成格式: {expiresAt}.{userId}.{signature} */
|
||||
export function createOrderStatusAccessToken(orderId: string, userId?: number): string {
|
||||
const expiresAt = Date.now() + TOKEN_TTL_MS;
|
||||
const uid = userId ?? 0;
|
||||
const sig = buildSignature(orderId, uid, expiresAt);
|
||||
return `${expiresAt}.${uid}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifyOrderStatusAccessToken(orderId: string, token: string | null | undefined): boolean {
|
||||
if (!token) return false;
|
||||
|
||||
const expected = buildSignature(orderId);
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return false;
|
||||
|
||||
const [expiresAtStr, userIdStr, sig] = parts;
|
||||
const expiresAt = Number(expiresAtStr);
|
||||
const userId = Number(userIdStr);
|
||||
|
||||
if (!Number.isFinite(expiresAt) || !Number.isFinite(userId)) return false;
|
||||
|
||||
// 检查过期
|
||||
if (Date.now() > expiresAt) return false;
|
||||
|
||||
const expected = buildSignature(orderId, userId, expiresAt);
|
||||
const expectedBuffer = Buffer.from(expected, 'utf8');
|
||||
const receivedBuffer = Buffer.from(token, 'utf8');
|
||||
const receivedBuffer = Buffer.from(sig, 'utf8');
|
||||
|
||||
if (expectedBuffer.length !== receivedBuffer.length) {
|
||||
return false;
|
||||
@@ -29,9 +52,9 @@ export function verifyOrderStatusAccessToken(orderId: string, token: string | nu
|
||||
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
|
||||
}
|
||||
|
||||
export function buildOrderResultUrl(appUrl: string, orderId: string): string {
|
||||
export function buildOrderResultUrl(appUrl: string, orderId: string, userId?: number): string {
|
||||
const url = new URL('/pay/result', appUrl);
|
||||
url.searchParams.set('order_id', orderId);
|
||||
url.searchParams.set(ORDER_STATUS_ACCESS_QUERY_KEY, createOrderStatusAccessToken(orderId));
|
||||
url.searchParams.set(ORDER_STATUS_ACCESS_QUERY_KEY, createOrderStatusAccessToken(orderId, userId));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { cancelOrderCore } from './service';
|
||||
|
||||
const INTERVAL_MS = 30_000; // 30 seconds
|
||||
const BATCH_SIZE = 50;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export async function expireOrders(): Promise<number> {
|
||||
// 查询到期订单(限制批次大小防止内存爆炸)
|
||||
// cancelOrderCore 内部 WHERE status='PENDING' 的 CAS 保证多实例不会重复处理同一订单
|
||||
const orders = await prisma.order.findMany({
|
||||
where: {
|
||||
status: ORDER_STATUS.PENDING,
|
||||
@@ -16,6 +19,8 @@ export async function expireOrders(): Promise<number> {
|
||||
paymentTradeNo: true,
|
||||
paymentType: true,
|
||||
},
|
||||
take: BATCH_SIZE,
|
||||
orderBy: { expiresAt: 'asc' },
|
||||
});
|
||||
|
||||
if (orders.length === 0) return 0;
|
||||
|
||||
@@ -17,6 +17,9 @@ function assertWxpayEnv(env: ReturnType<typeof getEnv>) {
|
||||
'Wxpay environment variables (WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY) are required',
|
||||
);
|
||||
}
|
||||
if (env.WXPAY_API_V3_KEY.length !== 32) {
|
||||
throw new Error(`WXPAY_API_V3_KEY must be exactly 32 bytes for AES-256-GCM, got ${env.WXPAY_API_V3_KEY.length}`);
|
||||
}
|
||||
return env as typeof env & {
|
||||
WXPAY_APP_ID: string;
|
||||
WXPAY_MCH_ID: string;
|
||||
|
||||
@@ -117,8 +117,9 @@ export class WxpayProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(now - Number(timestamp)) > 300) {
|
||||
throw new Error('Wechatpay notification timestamp expired');
|
||||
const tsNum = Number(timestamp);
|
||||
if (!Number.isFinite(tsNum) || Math.abs(now - tsNum) > 300) {
|
||||
throw new Error('Wechatpay notification timestamp invalid or expired');
|
||||
}
|
||||
|
||||
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });
|
||||
|
||||
Reference in New Issue
Block a user