diff --git a/src/__tests__/app/pay/alipay-short-link-route.test.ts b/src/__tests__/app/pay/alipay-short-link-route.test.ts index ede7734..d694473 100644 --- a/src/__tests__/app/pay/alipay-short-link-route.test.ts +++ b/src/__tests__/app/pay/alipay-short-link-route.test.ts @@ -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 = {}) { 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); diff --git a/src/__tests__/lib/alipay/client.test.ts b/src/__tests__/lib/alipay/client.test.ts index e1c1982..5d9c369 100644 --- a/src/__tests__/lib/alipay/client.test.ts +++ b/src/__tests__/lib/alipay/client.test.ts @@ -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'; diff --git a/src/__tests__/lib/easy-pay/client.test.ts b/src/__tests__/lib/easy-pay/client.test.ts index cdc0f1f..ec3e7ba 100644 --- a/src/__tests__/lib/easy-pay/client.test.ts +++ b/src/__tests__/lib/easy-pay/client.test.ts @@ -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).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).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 () => { diff --git a/src/__tests__/lib/order/code-gen.test.ts b/src/__tests__/lib/order/code-gen.test.ts index 1a362eb..33bf00d 100644 --- a/src/__tests__/lib/order/code-gen.test.ts +++ b/src/__tests__/lib/order/code-gen.test.ts @@ -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); }); }); diff --git a/src/app/api/admin/channels/[id]/route.ts b/src/app/api/admin/channels/[id]/route.ts index 861aced..88d597a 100644 --- a/src/app/api/admin/channels/[id]/route.ts +++ b/src/app/api/admin/channels/[id]/route.ts @@ -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 = {}; - 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; diff --git a/src/app/api/admin/subscriptions/route.ts b/src/app/api/admin/subscriptions/route.ts index 6765d22..813e9da 100644 --- a/src/app/api/admin/subscriptions/route.ts +++ b/src/app/api/admin/subscriptions/route.ts @@ -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({ diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts index a093955..0e2593d 100644 --- a/src/app/api/orders/route.ts +++ b/src/app/api/orders/route.ts @@ -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(), diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 5c30b7f..520ae65 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -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 }); diff --git a/src/lib/alipay/client.ts b/src/lib/alipay/client.ts index 89fcc33..0c2b25b 100644 --- a/src/lib/alipay/client.ts +++ b/src/lib/alipay/client.ts @@ -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( signal: AbortSignal.timeout(10_000), }); - const data = await parseAlipayJsonResponse>(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) { diff --git a/src/lib/alipay/codec.ts b/src/lib/alipay/codec.ts index 5aa591a..18054e2 100644 --- a/src/lib/alipay/codec.ts +++ b/src/lib/alipay/codec.ts @@ -102,3 +102,16 @@ export async function parseAlipayJsonResponse(response: Response): Promise 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; 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 }; +} diff --git a/src/lib/alipay/sign.ts b/src/lib/alipay/sign.ts index 45538c4..b2a89a3 100644 --- a/src/lib/alipay/sign.ts +++ b/src/lib/alipay/sign.ts @@ -43,6 +43,73 @@ export function generateSign(params: Record, 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, alipayPublicKey: string, sign: string): boolean { const filtered = Object.entries(params) diff --git a/src/lib/easy-pay/client.ts b/src/lib/easy-pay/client.ts index 27847b2..597a72e 100644 --- a/src/lib/easy-pay/client.ts +++ b/src/lib/easy-pay/client.ts @@ -89,8 +89,17 @@ 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, { + // 使用 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; diff --git a/src/lib/easy-pay/provider.ts b/src/lib/easy-pay/provider.ts index 3bd1382..41707bc 100644 --- a/src/lib/easy-pay/provider.ts +++ b/src/lib/easy-pay/provider.ts @@ -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, }; diff --git a/src/lib/order/code-gen.ts b/src/lib/order/code-gen.ts index 3389866..782ba20 100644 --- a/src/lib/order/code-gen.ts +++ b/src/lib/order/code-gen.ts @@ -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}`; } diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index a189d42..9bfd616 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -127,84 +127,87 @@ export async function createOrder(input: CreateOrderInput): Promise= 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 { `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 { gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError), rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), rechargeAmount, + needsBalanceCompensation: true, }), operator: 'admin', }, diff --git a/src/lib/order/status-access.ts b/src/lib/order/status-access.ts index 170a53b..0c6f394 100644 --- a/src/lib/order/status-access.ts +++ b/src/lib/order/status-access.ts @@ -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(); } diff --git a/src/lib/order/timeout.ts b/src/lib/order/timeout.ts index ad5288d..5d8d010 100644 --- a/src/lib/order/timeout.ts +++ b/src/lib/order/timeout.ts @@ -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 | null = null; export async function expireOrders(): Promise { + // 查询到期订单(限制批次大小防止内存爆炸) + // 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 { paymentTradeNo: true, paymentType: true, }, + take: BATCH_SIZE, + orderBy: { expiresAt: 'asc' }, }); if (orders.length === 0) return 0; diff --git a/src/lib/wxpay/client.ts b/src/lib/wxpay/client.ts index 7382ba6..aaba2fc 100644 --- a/src/lib/wxpay/client.ts +++ b/src/lib/wxpay/client.ts @@ -17,6 +17,9 @@ function assertWxpayEnv(env: ReturnType) { '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; diff --git a/src/lib/wxpay/provider.ts b/src/lib/wxpay/provider.ts index 97d2e60..02942f1 100644 --- a/src/lib/wxpay/provider.ts +++ b/src/lib/wxpay/provider.ts @@ -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 });