security: 隐私接口全面加固,统一 token 鉴权

- /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 时隐藏取消按钮
- 创建订单失败改为中文错误提示
This commit is contained in:
erio
2026-03-01 19:25:14 +08:00
parent 47f609a58d
commit c41933db70
8 changed files with 49 additions and 85 deletions

View File

@@ -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: '订单已支付完成' });
}

View File

@@ -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,
});
}

View File

@@ -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 });

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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() {
<a
href={payUrl}
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',

View File

@@ -47,7 +47,7 @@ function PayContent() {
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
const [config] = useState<AppConfig>({
const [config, setConfig] = useState<AppConfig>({
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<string, string> = {
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 && (
<PaymentQRCode
orderId={orderResult.orderId}
token={token || undefined}
payUrl={orderResult.payUrl}
qrCode={orderResult.qrCode}
checkoutUrl={orderResult.checkoutUrl}

View File

@@ -5,6 +5,7 @@ import QRCode from 'qrcode';
interface PaymentQRCodeProps {
orderId: string;
token?: string;
payUrl?: string | null;
qrCode?: string | null;
checkoutUrl?: string | null;
@@ -35,6 +36,7 @@ function isSafeCheckoutUrl(url: string): boolean {
export default function PaymentQRCode({
orderId,
token,
payUrl,
qrCode,
checkoutUrl,
@@ -135,12 +137,13 @@ export default function PaymentQRCode({
}, [pollStatus, expired]);
const handleCancel = async () => {
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}
</button>
{!expired && (
{!expired && token && (
<button
onClick={handleCancel}
className="flex-1 rounded-lg border border-red-300 py-2 text-sm text-red-600 hover:bg-red-50"