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:
@@ -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: '订单已支付完成' });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user