From c41933db705aa6e25be7d8de776a0e90cbc7d73e Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 1 Mar 2026 19:25:14 +0800 Subject: [PATCH] =?UTF-8?q?security:=20=E9=9A=90=E7=A7=81=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=85=A8=E9=9D=A2=E5=8A=A0=E5=9B=BA=EF=BC=8C=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=20token=20=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/orders/[id] 只返回 id/status/expiresAt,移除 user_name/pay_url 等隐私字段 - /api/orders/[id]/cancel 改为 token 鉴权,服务端验证用户身份后执行取消 - /api/orders (POST 响应) 过滤 userName/userBalance,不向客户端暴露 - /api/user 移除 username/email/balance,只返回 id/status 和 config - /api/users/[id] 只返回 {id, exists},不暴露任何隐私信息 - pay/page.tsx 恢复从服务端动态获取 config,无 token 时只显示用户 ID - pay/orders/page.tsx 无 token 时不查询隐私接口,统一按钮样式 - PaymentQRCode 新增 token prop,无 token 时隐藏取消按钮 - 创建订单失败改为中文错误提示 --- src/app/api/orders/[id]/cancel/route.ts | 15 +++++++-- src/app/api/orders/[id]/route.ts | 27 ++------------- src/app/api/orders/route.ts | 4 ++- src/app/api/user/route.ts | 3 -- src/app/api/users/[id]/route.ts | 12 ++----- src/app/pay/orders/page.tsx | 18 ++-------- src/app/pay/page.tsx | 45 +++++++++++-------------- src/components/PaymentQRCode.tsx | 10 +++--- 8 files changed, 49 insertions(+), 85 deletions(-) diff --git a/src/app/api/orders/[id]/cancel/route.ts b/src/app/api/orders/[id]/cancel/route.ts index 8367897..cfb8745 100644 --- a/src/app/api/orders/[id]/cancel/route.ts +++ b/src/app/api/orders/[id]/cancel/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { cancelOrder, OrderError } from '@/lib/order/service'; +import { getCurrentUserByToken } from '@/lib/sub2api/client'; const cancelSchema = z.object({ - user_id: z.number().int().positive(), + token: z.string().min(1), }); export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -13,10 +14,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const parsed = cancelSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 }); + return NextResponse.json({ error: '缺少 token 参数' }, { status: 400 }); } - const outcome = await cancelOrder(id, parsed.data.user_id); + let userId: number; + try { + const user = await getCurrentUserByToken(parsed.data.token); + userId = user.id; + } catch { + return NextResponse.json({ error: '登录态已失效,无法取消订单' }, { status: 401 }); + } + + const outcome = await cancelOrder(id, userId); if (outcome === 'already_paid') { return NextResponse.json({ success: true, status: 'PAID', message: '订单已支付完成' }); } diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts index 45cac58..67a7143 100644 --- a/src/app/api/orders/[id]/route.ts +++ b/src/app/api/orders/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; +// 仅返回订单状态相关字段,不暴露任何用户隐私信息 export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -8,19 +9,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ where: { id }, select: { id: true, - userId: true, - userName: true, - amount: true, status: true, - paymentType: true, - payUrl: true, - qrCode: true, - qrCodeImg: true, expiresAt: true, - paidAt: true, - completedAt: true, - failedReason: true, - createdAt: true, }, }); @@ -29,19 +19,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } return NextResponse.json({ - order_id: order.id, - user_id: order.userId, - user_name: order.userName, - amount: Number(order.amount), + id: order.id, status: order.status, - payment_type: order.paymentType, - pay_url: order.payUrl, - qr_code: order.qrCode, - qr_code_img: order.qrCodeImg, - expires_at: order.expiresAt, - paid_at: order.paidAt, - completed_at: order.completedAt, - failed_reason: order.failedReason, - created_at: order.createdAt, + expiresAt: order.expiresAt, }); } diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts index 99a3161..7b40b62 100644 --- a/src/app/api/orders/route.ts +++ b/src/app/api/orders/route.ts @@ -44,7 +44,9 @@ export async function POST(request: NextRequest) { clientIp, }); - return NextResponse.json(result); + // 不向客户端暴露 userName / userBalance 等隐私字段 + const { userName: _u, userBalance: _b, ...safeResult } = result; + return NextResponse.json(safeResult); } catch (error) { if (error instanceof OrderError) { return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode }); diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 9cf3c6f..7dcf02e 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -15,10 +15,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ user: { id: user.id, - username: user.username, - email: user.email, status: user.status, - balance: user.balance, }, config: { enabledPaymentTypes: env.ENABLED_PAYMENT_TYPES, diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 6a76e36..6429e99 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { getUser } from '@/lib/sub2api/client'; +// 仅返回用户是否存在,不暴露私隐信息(用户名/邮箱/余额需 token 验证) export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const userId = Number(id); @@ -11,16 +12,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: try { const user = await getUser(userId); - const displayName = user.username || user.email || `User #${user.id}`; - - return NextResponse.json({ - id: user.id, - username: user.username, - email: user.email, - displayName, - balance: user.balance, - status: user.status, - }); + return NextResponse.json({ id: user.id, exists: true }); } catch (error) { if (error instanceof Error && error.message === 'USER_NOT_FOUND') { return NextResponse.json({ error: 'User not found' }, { status: 404 }); diff --git a/src/app/pay/orders/page.tsx b/src/app/pay/orders/page.tsx index d554f4e..94e72c9 100644 --- a/src/app/pay/orders/page.tsx +++ b/src/app/pay/orders/page.tsx @@ -64,19 +64,7 @@ function OrdersContent() { } if (!hasToken) { - const res = await fetch(`/api/users/${userId}`); - if (res.ok) { - const data = await res.json(); - setUserInfo({ - id: userId, - username: - (typeof data.displayName === 'string' && data.displayName.trim()) || - (typeof data.username === 'string' && data.username.trim()) || - (typeof data.email === 'string' && data.email.trim()) || - `用户 #${userId}`, - balance: typeof data.balance === 'number' ? data.balance : 0, - }); - } + setUserInfo({ id: userId, username: `用户 #${userId}`, balance: 0 }); setOrders([]); setError('当前链接未携带登录 token,无法查询"我的订单"。'); return; @@ -185,7 +173,7 @@ function OrdersContent() { type="button" onClick={loadOrders} className={[ - 'rounded-lg border px-3 py-2 text-xs font-medium', + 'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors', isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100', @@ -196,7 +184,7 @@ function OrdersContent() { ([]); const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay'); - const [config] = useState({ + const [config, setConfig] = useState({ enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'], minAmount: 1, maxAmount: 10000, @@ -80,6 +80,16 @@ function PayContent() { if (!userId || Number.isNaN(userId) || userId <= 0) return; try { + // 始终获取服务端配置(不含隐私信息) + const cfgRes = await fetch(`/api/user?user_id=${userId}`); + if (cfgRes.ok) { + const cfgData = await cfgRes.json(); + if (cfgData.config) { + setConfig(cfgData.config); + } + } + + // 有 token 时才尝试获取用户详情和订单 if (token) { const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`); if (meRes.ok) { @@ -108,19 +118,8 @@ function PayContent() { } } - const res = await fetch(`/api/users/${userId}`); - if (!res.ok) return; - - const data = await res.json(); - setUserInfo({ - id: userId, - username: - (typeof data.displayName === 'string' && data.displayName.trim()) || - (typeof data.username === 'string' && data.username.trim()) || - (typeof data.email === 'string' && data.email.trim()) || - `用户 #${userId}`, - balance: typeof data.balance === 'number' ? data.balance : 0, - }); + // 无 token 或 token 失效:只显示用户 ID,不展示隐私信息 + setUserInfo({ id: userId, username: `用户 #${userId}`, balance: 0 }); setMyOrders([]); } catch { // ignore and keep page usable @@ -175,7 +174,12 @@ function PayContent() { const data = await res.json(); if (!res.ok) { - setError(data.error || '创建订单失败'); + const codeMessages: Record = { + USER_INACTIVE: '账户已被禁用,无法充值,请联系管理员', + TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试', + USER_NOT_FOUND: '用户不存在,请检查链接是否正确', + }; + setError(codeMessages[data.code] || data.error || '创建订单失败'); return; } @@ -190,16 +194,6 @@ function PayContent() { expiresAt: data.expiresAt, }); - if (data.userName || typeof data.userBalance === 'number') { - setUserInfo((prev) => ({ - username: - (typeof data.userName === 'string' && data.userName.trim()) || - prev?.username || - `用户 #${effectiveUserId}`, - balance: typeof data.userBalance === 'number' ? data.userBalance : (prev?.balance ?? 0), - })); - } - setStep('paying'); } catch { setError('网络错误,请稍后重试'); @@ -385,6 +379,7 @@ function PayContent() { {step === 'paying' && orderResult && ( { + if (!token) return; try { + // 先检查当前订单状态 const res = await fetch(`/api/orders/${orderId}`); if (!res.ok) return; const data = await res.json(); - // If the order already reached a terminal status, handle it immediately if (TERMINAL_STATUSES.has(data.status)) { onStatusChange(data.status); return; @@ -149,7 +152,7 @@ export default function PaymentQRCode({ const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_id: data.user_id }), + body: JSON.stringify({ token }), }); if (cancelRes.ok) { const cancelData = await cancelRes.json(); @@ -159,7 +162,6 @@ export default function PaymentQRCode({ } onStatusChange('CANCELLED'); } else { - // Cancel failed (e.g. order was paid between the two requests) — re-check status await pollStatus(); } } catch { @@ -300,7 +302,7 @@ export default function PaymentQRCode({ > {TEXT_BACK} - {!expired && ( + {!expired && token && (