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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { cancelOrder, OrderError } from '@/lib/order/service';
|
import { cancelOrder, OrderError } from '@/lib/order/service';
|
||||||
|
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||||
|
|
||||||
const cancelSchema = z.object({
|
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 }> }) {
|
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);
|
const parsed = cancelSchema.safeParse(body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
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') {
|
if (outcome === 'already_paid') {
|
||||||
return NextResponse.json({ success: true, status: 'PAID', message: '订单已支付完成' });
|
return NextResponse.json({ success: true, status: 'PAID', message: '订单已支付完成' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
// 仅返回订单状态相关字段,不暴露任何用户隐私信息
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
@@ -8,19 +9,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
where: { id },
|
where: { id },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
userId: true,
|
|
||||||
userName: true,
|
|
||||||
amount: true,
|
|
||||||
status: true,
|
status: true,
|
||||||
paymentType: true,
|
|
||||||
payUrl: true,
|
|
||||||
qrCode: true,
|
|
||||||
qrCodeImg: true,
|
|
||||||
expiresAt: 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({
|
return NextResponse.json({
|
||||||
order_id: order.id,
|
id: order.id,
|
||||||
user_id: order.userId,
|
|
||||||
user_name: order.userName,
|
|
||||||
amount: Number(order.amount),
|
|
||||||
status: order.status,
|
status: order.status,
|
||||||
payment_type: order.paymentType,
|
expiresAt: order.expiresAt,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export async function POST(request: NextRequest) {
|
|||||||
clientIp,
|
clientIp,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(result);
|
// 不向客户端暴露 userName / userBalance 等隐私字段
|
||||||
|
const { userName: _u, userBalance: _b, ...safeResult } = result;
|
||||||
|
return NextResponse.json(safeResult);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OrderError) {
|
if (error instanceof OrderError) {
|
||||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
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({
|
return NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
status: user.status,
|
status: user.status,
|
||||||
balance: user.balance,
|
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
enabledPaymentTypes: env.ENABLED_PAYMENT_TYPES,
|
enabledPaymentTypes: env.ENABLED_PAYMENT_TYPES,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getUser } from '@/lib/sub2api/client';
|
import { getUser } from '@/lib/sub2api/client';
|
||||||
|
|
||||||
|
// 仅返回用户是否存在,不暴露私隐信息(用户名/邮箱/余额需 token 验证)
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const userId = Number(id);
|
const userId = Number(id);
|
||||||
@@ -11,16 +12,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getUser(userId);
|
const user = await getUser(userId);
|
||||||
const displayName = user.username || user.email || `User #${user.id}`;
|
return NextResponse.json({ id: user.id, exists: true });
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
displayName,
|
|
||||||
balance: user.balance,
|
|
||||||
status: user.status,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'USER_NOT_FOUND') {
|
if (error instanceof Error && error.message === 'USER_NOT_FOUND') {
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
|||||||
@@ -64,19 +64,7 @@ function OrdersContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasToken) {
|
if (!hasToken) {
|
||||||
const res = await fetch(`/api/users/${userId}`);
|
setUserInfo({ id: userId, username: `用户 #${userId}`, balance: 0 });
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setOrders([]);
|
setOrders([]);
|
||||||
setError('当前链接未携带登录 token,无法查询"我的订单"。');
|
setError('当前链接未携带登录 token,无法查询"我的订单"。');
|
||||||
return;
|
return;
|
||||||
@@ -185,7 +173,7 @@ function OrdersContent() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={loadOrders}
|
onClick={loadOrders}
|
||||||
className={[
|
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
|
isDark
|
||||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||||
@@ -196,7 +184,7 @@ function OrdersContent() {
|
|||||||
<a
|
<a
|
||||||
href={payUrl}
|
href={payUrl}
|
||||||
className={[
|
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
|
isDark
|
||||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function PayContent() {
|
|||||||
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
|
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
|
||||||
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
||||||
|
|
||||||
const [config] = useState<AppConfig>({
|
const [config, setConfig] = useState<AppConfig>({
|
||||||
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
|
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
|
||||||
minAmount: 1,
|
minAmount: 1,
|
||||||
maxAmount: 10000,
|
maxAmount: 10000,
|
||||||
@@ -80,6 +80,16 @@ function PayContent() {
|
|||||||
if (!userId || Number.isNaN(userId) || userId <= 0) return;
|
if (!userId || Number.isNaN(userId) || userId <= 0) return;
|
||||||
|
|
||||||
try {
|
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) {
|
if (token) {
|
||||||
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
||||||
if (meRes.ok) {
|
if (meRes.ok) {
|
||||||
@@ -108,19 +118,8 @@ function PayContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/users/${userId}`);
|
// 无 token 或 token 失效:只显示用户 ID,不展示隐私信息
|
||||||
if (!res.ok) return;
|
setUserInfo({ id: userId, username: `用户 #${userId}`, balance: 0 });
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
setMyOrders([]);
|
setMyOrders([]);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore and keep page usable
|
// ignore and keep page usable
|
||||||
@@ -175,7 +174,12 @@ function PayContent() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,16 +194,6 @@ function PayContent() {
|
|||||||
expiresAt: data.expiresAt,
|
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');
|
setStep('paying');
|
||||||
} catch {
|
} catch {
|
||||||
setError('网络错误,请稍后重试');
|
setError('网络错误,请稍后重试');
|
||||||
@@ -385,6 +379,7 @@ function PayContent() {
|
|||||||
{step === 'paying' && orderResult && (
|
{step === 'paying' && orderResult && (
|
||||||
<PaymentQRCode
|
<PaymentQRCode
|
||||||
orderId={orderResult.orderId}
|
orderId={orderResult.orderId}
|
||||||
|
token={token || undefined}
|
||||||
payUrl={orderResult.payUrl}
|
payUrl={orderResult.payUrl}
|
||||||
qrCode={orderResult.qrCode}
|
qrCode={orderResult.qrCode}
|
||||||
checkoutUrl={orderResult.checkoutUrl}
|
checkoutUrl={orderResult.checkoutUrl}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import QRCode from 'qrcode';
|
|||||||
|
|
||||||
interface PaymentQRCodeProps {
|
interface PaymentQRCodeProps {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
|
token?: string;
|
||||||
payUrl?: string | null;
|
payUrl?: string | null;
|
||||||
qrCode?: string | null;
|
qrCode?: string | null;
|
||||||
checkoutUrl?: string | null;
|
checkoutUrl?: string | null;
|
||||||
@@ -35,6 +36,7 @@ function isSafeCheckoutUrl(url: string): boolean {
|
|||||||
|
|
||||||
export default function PaymentQRCode({
|
export default function PaymentQRCode({
|
||||||
orderId,
|
orderId,
|
||||||
|
token,
|
||||||
payUrl,
|
payUrl,
|
||||||
qrCode,
|
qrCode,
|
||||||
checkoutUrl,
|
checkoutUrl,
|
||||||
@@ -135,12 +137,13 @@ export default function PaymentQRCode({
|
|||||||
}, [pollStatus, expired]);
|
}, [pollStatus, expired]);
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
|
// 先检查当前订单状态
|
||||||
const res = await fetch(`/api/orders/${orderId}`);
|
const res = await fetch(`/api/orders/${orderId}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// If the order already reached a terminal status, handle it immediately
|
|
||||||
if (TERMINAL_STATUSES.has(data.status)) {
|
if (TERMINAL_STATUSES.has(data.status)) {
|
||||||
onStatusChange(data.status);
|
onStatusChange(data.status);
|
||||||
return;
|
return;
|
||||||
@@ -149,7 +152,7 @@ export default function PaymentQRCode({
|
|||||||
const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, {
|
const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ user_id: data.user_id }),
|
body: JSON.stringify({ token }),
|
||||||
});
|
});
|
||||||
if (cancelRes.ok) {
|
if (cancelRes.ok) {
|
||||||
const cancelData = await cancelRes.json();
|
const cancelData = await cancelRes.json();
|
||||||
@@ -159,7 +162,6 @@ export default function PaymentQRCode({
|
|||||||
}
|
}
|
||||||
onStatusChange('CANCELLED');
|
onStatusChange('CANCELLED');
|
||||||
} else {
|
} else {
|
||||||
// Cancel failed (e.g. order was paid between the two requests) — re-check status
|
|
||||||
await pollStatus();
|
await pollStatus();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -300,7 +302,7 @@ export default function PaymentQRCode({
|
|||||||
>
|
>
|
||||||
{TEXT_BACK}
|
{TEXT_BACK}
|
||||||
</button>
|
</button>
|
||||||
{!expired && (
|
{!expired && token && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
className="flex-1 rounded-lg border border-red-300 py-2 text-sm text-red-600 hover:bg-red-50"
|
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