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 { NextRequest } from 'next/server';
|
||||||
import { ORDER_STATUS } from '@/lib/constants';
|
import { ORDER_STATUS } from '@/lib/constants';
|
||||||
|
|
||||||
@@ -52,10 +52,15 @@ function createPendingOrder(overrides: Record<string, unknown> = {}) {
|
|||||||
|
|
||||||
describe('GET /pay/[orderId]', () => {
|
describe('GET /pay/[orderId]', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ now: new Date('2026-03-14T12:00:00Z') });
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mock=1');
|
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mock=1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 404 error page when order does not exist', async () => {
|
it('returns 404 error page when order does not exist', async () => {
|
||||||
mockFindUnique.mockResolvedValue(null);
|
mockFindUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const { mockGenerateSign } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('@/lib/alipay/sign', () => ({
|
vi.mock('@/lib/alipay/sign', () => ({
|
||||||
generateSign: mockGenerateSign,
|
generateSign: mockGenerateSign,
|
||||||
|
verifyResponseSign: vi.fn(() => true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { execute, pageExecute } from '@/lib/alipay/client';
|
import { execute, pageExecute } from '@/lib/alipay/client';
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ describe('EasyPay client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('queryOrder', () => {
|
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(
|
global.fetch = vi.fn().mockResolvedValue(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -285,12 +285,14 @@ describe('EasyPay client', () => {
|
|||||||
expect(result.status).toBe(1);
|
expect(result.status).toBe(1);
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
const [url] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
expect(url).toContain('https://pay.example.com/api.php');
|
expect(url).toBe('https://pay.example.com/api.php');
|
||||||
expect(url).toContain('act=order');
|
expect(init.method).toBe('POST');
|
||||||
expect(url).toContain('pid=1001');
|
const body = new URLSearchParams(init.body as string);
|
||||||
expect(url).toContain('key=test-merchant-secret-key');
|
expect(body.get('act')).toBe('order');
|
||||||
expect(url).toContain('out_trade_no=order-001');
|
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 () => {
|
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';
|
import { generateRechargeCode } from '@/lib/order/code-gen';
|
||||||
|
|
||||||
describe('generateRechargeCode', () => {
|
describe('generateRechargeCode', () => {
|
||||||
it('should generate code with s2p_ prefix', () => {
|
it('should generate code with s2p_ prefix and random suffix', () => {
|
||||||
const code = generateRechargeCode('cm1234567890');
|
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', () => {
|
it('should truncate long order IDs to fit 32 chars', () => {
|
||||||
@@ -14,8 +17,15 @@ describe('generateRechargeCode', () => {
|
|||||||
expect(code.startsWith('s2p_')).toBe(true);
|
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', () => {
|
it('should handle empty string', () => {
|
||||||
const code = generateRechargeCode('');
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||||
import { prisma } from '@/lib/db';
|
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 }> }) {
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
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 } });
|
const existing = await prisma.channel.findUnique({ where: { id } });
|
||||||
if (!existing) {
|
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> = {};
|
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.name !== undefined) data.name = body.name;
|
||||||
if (body.platform !== undefined) data.platform = body.platform;
|
if (body.platform !== undefined) data.platform = body.platform;
|
||||||
if (body.rate_multiplier !== undefined) data.rateMultiplier = body.rate_multiplier;
|
if (body.rate_multiplier !== undefined) data.rateMultiplier = body.rate_multiplier;
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const result = await listSubscriptions({
|
const result = await listSubscriptions({
|
||||||
group_id: groupId ? Number(groupId) : undefined,
|
group_id: groupId ? Number(groupId) : undefined,
|
||||||
status: status || undefined,
|
status: status || undefined,
|
||||||
page: page ? Number(page) : undefined,
|
page: page ? Math.max(1, Number(page)) : undefined,
|
||||||
page_size: pageSize ? Number(pageSize) : undefined,
|
page_size: pageSize ? Math.min(200, Math.max(1, Number(pageSize))) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -11,7 +11,18 @@ const createOrderSchema = z.object({
|
|||||||
amount: z.number().positive().max(99999999.99),
|
amount: z.number().positive().max(99999999.99),
|
||||||
payment_type: z.string().min(1),
|
payment_type: z.string().min(1),
|
||||||
src_host: z.string().max(253).optional(),
|
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(),
|
is_mobile: z.boolean().optional(),
|
||||||
order_type: z.enum(['balance', 'subscription']).optional(),
|
order_type: z.enum(['balance', 'subscription']).optional(),
|
||||||
plan_id: z.string().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 });
|
return NextResponse.json({ error: 'token is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentUser: { id: number };
|
||||||
try {
|
try {
|
||||||
await getCurrentUserByToken(token);
|
currentUser = await getCurrentUserByToken(token);
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
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 });
|
return NextResponse.json({ error: 'Invalid user id' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只允许查询自身用户信息,防止 IDOR 用户枚举
|
||||||
|
if (userId !== currentUser.id) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getUser(userId);
|
const user = await getUser(userId);
|
||||||
return NextResponse.json({ id: user.id, exists: true });
|
return NextResponse.json({ id: user.id, exists: true });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
import { generateSign } from './sign';
|
import { generateSign, verifyResponseSign } from './sign';
|
||||||
import type { AlipayResponse } from './types';
|
import type { AlipayResponse } from './types';
|
||||||
import { parseAlipayJsonResponse } from './codec';
|
import { parseAlipayJsonResponseWithRaw } from './codec';
|
||||||
|
|
||||||
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
||||||
|
|
||||||
@@ -89,14 +89,20 @@ export async function execute<T extends AlipayResponse>(
|
|||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await parseAlipayJsonResponse<Record<string, unknown>>(response);
|
const { data, rawText } = await parseAlipayJsonResponseWithRaw(response);
|
||||||
|
|
||||||
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
||||||
// TODO: 实现响应验签 — 需要从原始响应文本中提取 responseKey 对应的 JSON 子串,
|
|
||||||
// 使用 verifySign 配合 ALIPAY_PUBLIC_KEY 验证 data.sign。
|
|
||||||
// 当前未验签是因为需要保留原始响应文本(不能 JSON.parse 后再 stringify),
|
|
||||||
// 需要改造 parseAlipayJsonResponse 同时返回原始文本。
|
|
||||||
const responseKey = method.replace(/\./g, '_') + '_response';
|
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;
|
const result = data[responseKey] as T | undefined;
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|||||||
@@ -102,3 +102,16 @@ export async function parseAlipayJsonResponse<T>(response: Response): Promise<T>
|
|||||||
const text = decodeAlipayPayload(rawBody, { 'content-type': contentType });
|
const text = decodeAlipayPayload(rawBody, { 'content-type': contentType });
|
||||||
return JSON.parse(text) as T;
|
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');
|
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) */
|
/** 用支付宝公钥验证签名(回调验签:排除 sign 和 sign_type) */
|
||||||
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)
|
||||||
|
|||||||
@@ -89,8 +89,17 @@ 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}`;
|
// 使用 POST 避免密钥暴露在 URL 中(URL 会被记录到服务器/CDN 日志)
|
||||||
const response = await fetch(url, {
|
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),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
const data = (await response.json()) as EasyPayQueryResponse;
|
const data = (await response.json()) as EasyPayQueryResponse;
|
||||||
|
|||||||
@@ -70,10 +70,21 @@ export class EasyPayProvider implements PaymentProvider {
|
|||||||
throw new Error('EasyPay notification signature verification failed');
|
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 {
|
return {
|
||||||
tradeNo: params.trade_no || '',
|
tradeNo: params.trade_no || '',
|
||||||
orderId: params.out_trade_no || '',
|
orderId: params.out_trade_no || '',
|
||||||
amount: parseFloat(params.money || '0'),
|
amount,
|
||||||
status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed',
|
status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed',
|
||||||
rawData: params,
|
rawData: params,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
export function generateRechargeCode(orderId: string): string {
|
export function generateRechargeCode(orderId: string): string {
|
||||||
const prefix = 's2p_';
|
const prefix = 's2p_';
|
||||||
const maxIdLength = 32 - prefix.length; // 28
|
const random = crypto.randomBytes(4).toString('hex'); // 8 chars
|
||||||
const truncatedId = orderId.slice(0, maxIdLength);
|
const maxIdLength = 32 - prefix.length - random.length; // 16
|
||||||
return `${prefix}${truncatedId}`;
|
const truncatedId = orderId.replace(/-/g, '').slice(0, maxIdLength);
|
||||||
|
return `${prefix}${truncatedId}${random}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,16 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
throw new OrderError('USER_INACTIVE', message(locale, '用户账号已被禁用', 'User account is disabled'), 422);
|
throw new OrderError('USER_INACTIVE', message(locale, '用户账号已被禁用', 'User account is disabled'), 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingCount = await prisma.order.count({
|
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 },
|
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
|
||||||
});
|
});
|
||||||
if (pendingCount >= MAX_PENDING_ORDERS) {
|
if (pendingCount >= MAX_PENDING_ORDERS) {
|
||||||
@@ -144,7 +153,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
|
|
||||||
// 每日累计充值限额校验(0 = 不限制)
|
// 每日累计充值限额校验(0 = 不限制)
|
||||||
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
||||||
const dailyAgg = await prisma.order.aggregate({
|
const dailyAgg = await tx.order.aggregate({
|
||||||
where: {
|
where: {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||||
@@ -170,7 +179,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
// 渠道每日全平台限额校验(0 = 不限)
|
// 渠道每日全平台限额校验(0 = 不限)
|
||||||
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
||||||
if (methodDailyLimit > 0) {
|
if (methodDailyLimit > 0) {
|
||||||
const methodAgg = await prisma.order.aggregate({
|
const methodAgg = await tx.order.aggregate({
|
||||||
where: {
|
where: {
|
||||||
paymentType: input.paymentType,
|
paymentType: input.paymentType,
|
||||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||||
@@ -199,12 +208,6 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
const order = await prisma.$transaction(async (tx) => {
|
|
||||||
const created = await tx.order.create({
|
const created = await tx.order.create({
|
||||||
data: {
|
data: {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
@@ -243,8 +246,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
initPaymentProviders();
|
initPaymentProviders();
|
||||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
const provider = paymentRegistry.getProvider(input.paymentType);
|
||||||
|
|
||||||
const statusAccessToken = createOrderStatusAccessToken(order.id);
|
const statusAccessToken = createOrderStatusAccessToken(order.id, input.userId);
|
||||||
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id);
|
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id, input.userId);
|
||||||
|
|
||||||
// 只有 easypay 从外部传入 notifyUrl,return_url 统一回到带访问令牌的结果页
|
// 只有 easypay 从外部传入 notifyUrl,return_url 统一回到带访问令牌的结果页
|
||||||
let notifyUrl: string | undefined;
|
let notifyUrl: string | undefined;
|
||||||
@@ -1002,7 +1005,10 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
|||||||
`sub2apipay:refund-rollback:${order.id}`,
|
`sub2apipay:refund-rollback:${order.id}`,
|
||||||
);
|
);
|
||||||
} catch (rollbackError) {
|
} 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({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
orderId: input.orderId,
|
orderId: input.orderId,
|
||||||
@@ -1011,6 +1017,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
|||||||
gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError),
|
gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError),
|
||||||
rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
|
rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
|
||||||
rechargeAmount,
|
rechargeAmount,
|
||||||
|
needsBalanceCompensation: true,
|
||||||
}),
|
}),
|
||||||
operator: 'admin',
|
operator: 'admin',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,25 +2,48 @@ import crypto from 'crypto';
|
|||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
|
|
||||||
export const ORDER_STATUS_ACCESS_QUERY_KEY = 'access_token';
|
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
|
return crypto
|
||||||
.createHmac('sha256', getEnv().ADMIN_TOKEN)
|
.createHmac('sha256', deriveKey())
|
||||||
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}`)
|
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}:${userId}:${expiresAt}`)
|
||||||
.digest('base64url');
|
.digest('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOrderStatusAccessToken(orderId: string): string {
|
/** 生成格式: {expiresAt}.{userId}.{signature} */
|
||||||
return buildSignature(orderId);
|
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 {
|
export function verifyOrderStatusAccessToken(orderId: string, token: string | null | undefined): boolean {
|
||||||
if (!token) return false;
|
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 expectedBuffer = Buffer.from(expected, 'utf8');
|
||||||
const receivedBuffer = Buffer.from(token, 'utf8');
|
const receivedBuffer = Buffer.from(sig, 'utf8');
|
||||||
|
|
||||||
if (expectedBuffer.length !== receivedBuffer.length) {
|
if (expectedBuffer.length !== receivedBuffer.length) {
|
||||||
return false;
|
return false;
|
||||||
@@ -29,9 +52,9 @@ export function verifyOrderStatusAccessToken(orderId: string, token: string | nu
|
|||||||
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
|
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);
|
const url = new URL('/pay/result', appUrl);
|
||||||
url.searchParams.set('order_id', orderId);
|
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();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { ORDER_STATUS } from '@/lib/constants';
|
|||||||
import { cancelOrderCore } from './service';
|
import { cancelOrderCore } from './service';
|
||||||
|
|
||||||
const INTERVAL_MS = 30_000; // 30 seconds
|
const INTERVAL_MS = 30_000; // 30 seconds
|
||||||
|
const BATCH_SIZE = 50;
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
export async function expireOrders(): Promise<number> {
|
export async function expireOrders(): Promise<number> {
|
||||||
|
// 查询到期订单(限制批次大小防止内存爆炸)
|
||||||
|
// cancelOrderCore 内部 WHERE status='PENDING' 的 CAS 保证多实例不会重复处理同一订单
|
||||||
const orders = await prisma.order.findMany({
|
const orders = await prisma.order.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: ORDER_STATUS.PENDING,
|
status: ORDER_STATUS.PENDING,
|
||||||
@@ -16,6 +19,8 @@ export async function expireOrders(): Promise<number> {
|
|||||||
paymentTradeNo: true,
|
paymentTradeNo: true,
|
||||||
paymentType: true,
|
paymentType: true,
|
||||||
},
|
},
|
||||||
|
take: BATCH_SIZE,
|
||||||
|
orderBy: { expiresAt: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (orders.length === 0) return 0;
|
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',
|
'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 & {
|
return env as typeof env & {
|
||||||
WXPAY_APP_ID: string;
|
WXPAY_APP_ID: string;
|
||||||
WXPAY_MCH_ID: string;
|
WXPAY_MCH_ID: string;
|
||||||
|
|||||||
@@ -117,8 +117,9 @@ export class WxpayProvider implements PaymentProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (Math.abs(now - Number(timestamp)) > 300) {
|
const tsNum = Number(timestamp);
|
||||||
throw new Error('Wechatpay notification timestamp expired');
|
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 });
|
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });
|
||||||
|
|||||||
Reference in New Issue
Block a user