mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
fix: include actionable error details
This commit is contained in:
@@ -9,6 +9,7 @@ import { actorFromSession, recordAuditLog } from "@/services/audit";
|
|||||||
import { getAppConfig } from "@/services/app-config";
|
import { getAppConfig } from "@/services/app-config";
|
||||||
import { normalizeSiteUrl } from "@/services/site-url";
|
import { normalizeSiteUrl } from "@/services/site-url";
|
||||||
import { encrypt } from "@/lib/crypto";
|
import { encrypt } from "@/lib/crypto";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
import { sendSmtpTestEmail } from "@/services/email";
|
import { sendSmtpTestEmail } from "@/services/email";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -55,15 +56,10 @@ type SmtpTestActionResult =
|
|||||||
|
|
||||||
function formatActionError(error: unknown, fallback: string) {
|
function formatActionError(error: unknown, fallback: string) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return error.issues[0]?.message ?? fallback;
|
const details = error.issues.map((issue) => issue.message).filter(Boolean).join(";");
|
||||||
|
return details || getErrorMessage(error, fallback);
|
||||||
}
|
}
|
||||||
if (error instanceof Error && error.message.trim()) {
|
return getErrorMessage(error, fallback);
|
||||||
return error.message.trim();
|
|
||||||
}
|
|
||||||
if (typeof error === "string" && error.trim()) {
|
|
||||||
return error.trim();
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertSmtpTestRateLimit(userId: string) {
|
async function assertSmtpTestRateLimit(userId: string) {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ async function generateUniqueInviteCode(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("邀请码生成失败,请稍后重试");
|
throw new Error("邀请码生成失败:连续 10 次生成的随机码都已存在,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAccountProfile(formData: FormData) {
|
export async function updateAccountProfile(formData: FormData) {
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ async function getProxyPlanForCart(planId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
|
if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为代理套餐加入购物车`);
|
||||||
if (!plan.isActive) throw new Error("套餐已下架");
|
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +115,8 @@ export async function addProxyPlanToCart(
|
|||||||
export async function addStreamingPlanToCart(planId: string) {
|
export async function addStreamingPlanToCart(planId: string) {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } });
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } });
|
||||||
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
|
if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为流媒体套餐加入购物车`);
|
||||||
if (!plan.isActive) throw new Error("套餐已下架");
|
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
||||||
|
|
||||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||||
if (!availability.available) {
|
if (!availability.available) {
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ export async function purchaseProxy(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
|
if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为代理套餐购买`);
|
||||||
if (!plan.isActive) throw new Error("套餐已下架");
|
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
||||||
|
|
||||||
const price = getPlanPurchasePrice(plan, trafficGb);
|
const price = getPlanPurchasePrice(plan, trafficGb);
|
||||||
|
|
||||||
@@ -216,8 +216,8 @@ export async function purchaseStreaming(planId: string): Promise<string> {
|
|||||||
where: { id: planId },
|
where: { id: planId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
|
if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为流媒体套餐购买`);
|
||||||
if (!plan.isActive) throw new Error("套餐已下架");
|
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
||||||
|
|
||||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||||
if (!availability.available) {
|
if (!availability.available) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
|
||||||
export default function AdminError({
|
export default function AdminError({
|
||||||
error,
|
error,
|
||||||
@@ -10,13 +11,18 @@ export default function AdminError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const message = getErrorMessage(error, "管理后台页面加载失败");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardContent className="py-10 text-center space-y-5">
|
<CardContent className="py-10 text-center space-y-5">
|
||||||
<h1 className="text-xl font-semibold tracking-tight">出了点问题</h1>
|
<h1 className="text-xl font-semibold tracking-tight">出了点问题</h1>
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm leading-6 text-destructive break-words">
|
||||||
{error.message || "页面加载失败,请稍后重试。"}
|
{message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
|
如果重试后仍失败,请复制上面的错误详情给管理员排查。
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={reset} className="h-10">重试</Button>
|
<Button onClick={reset} className="h-10">重试</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
|
|||||||
try {
|
try {
|
||||||
const res = await testNodeConnection(node.id);
|
const res = await testNodeConnection(node.id);
|
||||||
if (res.success) toast.success(res.message);
|
if (res.success) toast.success(res.message);
|
||||||
else toast.error(res.message);
|
else toast.error(getErrorMessage(res.message, "节点测试失败"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(error, "测试失败"));
|
toast.error(getErrorMessage(error, "测试失败"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||||
import { deletePlanPermanently, togglePlan } from "@/actions/admin/plans";
|
import { deletePlanPermanently, togglePlan } from "@/actions/admin/plans";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
import {
|
import {
|
||||||
PlanForm,
|
PlanForm,
|
||||||
type PlanFormValue,
|
type PlanFormValue,
|
||||||
@@ -39,8 +40,7 @@ export function PlanActions({
|
|||||||
toast.success(isActive ? "套餐已下架" : "套餐已上架");
|
toast.success(isActive ? "套餐已下架" : "套餐已上架");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "切换失败";
|
toast.error(getErrorMessage(error, "切换套餐上下架状态失败"));
|
||||||
toast.error(message);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
|||||||
try {
|
try {
|
||||||
const result = await saveAppSettings(new FormData(form));
|
const result = await saveAppSettings(new FormData(form));
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error(result.error);
|
toast.error(getErrorMessage(result.error, "保存设置失败"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearPasswordField(form);
|
clearPasswordField(form);
|
||||||
@@ -86,8 +86,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
|||||||
}
|
}
|
||||||
toast.error(
|
toast.error(
|
||||||
result.settingsSaved
|
result.settingsSaved
|
||||||
? `设置已保存,但测试邮件没有发出:${result.error}`
|
? `设置已保存,但测试邮件没有发出:${getErrorMessage(result.error, "测试邮件发送失败")}`
|
||||||
: result.error,
|
: getErrorMessage(result.error, "测试邮件发送失败"),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { TurnstileWidget } from "@/components/shared/turnstile-widget";
|
import { TurnstileWidget } from "@/components/shared/turnstile-widget";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
|
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
|
||||||
|
|
||||||
export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||||
@@ -44,13 +45,15 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
|||||||
turnstileToken,
|
turnstileToken,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json().catch(() => null) as { error?: unknown; requiresEmailVerification?: unknown } | null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(data.error || "注册失败");
|
setError(getErrorMessage(data, `注册失败 (HTTP ${response.status})`));
|
||||||
} else {
|
} else {
|
||||||
setRequiresEmailVerification(Boolean(data.requiresEmailVerification));
|
setRequiresEmailVerification(Boolean(data?.requiresEmailVerification));
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(getErrorMessage(error, "注册失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
|
||||||
export default function UserError({
|
export default function UserError({
|
||||||
error,
|
error,
|
||||||
@@ -10,13 +11,18 @@ export default function UserError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const message = getErrorMessage(error, "页面加载失败");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardContent className="py-10 text-center space-y-5">
|
<CardContent className="py-10 text-center space-y-5">
|
||||||
<h1 className="text-xl font-semibold tracking-tight">出了点问题</h1>
|
<h1 className="text-xl font-semibold tracking-tight">出了点问题</h1>
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm leading-6 text-destructive break-words">
|
||||||
{error.message || "页面加载失败,请稍后重试。"}
|
{message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
|
如果重试后仍失败,请复制上面的错误详情给管理员排查。
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={reset} className="h-10">重试</Button>
|
<Button onClick={reset} className="h-10">重试</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function usePlanAvailabilityCheck(planId: string) {
|
|||||||
if (result.available) {
|
if (result.available) {
|
||||||
toast.success("这款套餐现在可以购买");
|
toast.success("这款套餐现在可以购买");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message);
|
toast.error(getErrorMessage(result.message, "这款套餐暂时不可购买"));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(error, "暂时无法确认补位时间"));
|
toast.error(getErrorMessage(error, "暂时无法确认补位时间"));
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function RenewalButton({
|
|||||||
try {
|
try {
|
||||||
const result = await purchaseRenewal(subscriptionId, renewalDays);
|
const result = await purchaseRenewal(subscriptionId, renewalDays);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error(result.error);
|
toast.error(getErrorMessage(result.error, "创建续费订单失败"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/pay/${result.orderId}`);
|
router.push(`/pay/${result.orderId}`);
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
return NextResponse.json({ error: "请求体不是有效 JSON,期望格式:{ latencies: [{ carrier, latencyMs }] }" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(body.latencies) || body.latencies.length === 0) {
|
if (!Array.isArray(body.latencies) || body.latencies.length === 0) {
|
||||||
return NextResponse.json({ error: "Missing latencies" }, { status: 400 });
|
return NextResponse.json({ error: "缺少延迟数据:latencies 必须是非空数组" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCarriers = new Set(["telecom", "unicom", "mobile"]);
|
const validCarriers = new Set(["telecom", "unicom", "mobile"]);
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
return NextResponse.json({ error: "请求体不是有效 JSON,期望格式:{ traces: [{ carrier, hops }] }" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(body.traces) || body.traces.length === 0) {
|
if (!Array.isArray(body.traces) || body.traces.length === 0) {
|
||||||
return NextResponse.json({ error: "Missing traces" }, { status: 400 });
|
return NextResponse.json({ error: "缺少路由追踪数据:traces 必须是非空数组" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCarriers = new Set(["telecom", "unicom", "mobile"]);
|
const validCarriers = new Set(["telecom", "unicom", "mobile"]);
|
||||||
|
|||||||
@@ -8,13 +8,22 @@ import { rateLimit } from "@/lib/rate-limit";
|
|||||||
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
|
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email("邮箱格式不正确"),
|
||||||
password: z.string().min(6),
|
password: z.string().min(6, "密码至少需要 6 位"),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
inviteCode: z.string().optional(),
|
inviteCode: z.string().optional(),
|
||||||
turnstileToken: z.string().optional(),
|
turnstileToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function formatValidationErrors(error: z.ZodError) {
|
||||||
|
return error.issues
|
||||||
|
.map((issue) => {
|
||||||
|
const field = issue.path.join(".") || "请求体";
|
||||||
|
return `${field}:${issue.message}`;
|
||||||
|
})
|
||||||
|
.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|| req.headers.get("x-real-ip")?.trim()
|
|| req.headers.get("x-real-ip")?.trim()
|
||||||
@@ -31,12 +40,12 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "参数错误" }, { status: 400 });
|
return NextResponse.json({ error: "注册参数错误:请求体不是有效 JSON" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = schema.safeParse(body);
|
const parsed = schema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: "参数错误" }, { status: 400 });
|
return NextResponse.json({ error: `注册参数错误:${formatValidationErrors(parsed.error)}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { password, name, inviteCode, turnstileToken } = parsed.data;
|
const { password, name, inviteCode, turnstileToken } = parsed.data;
|
||||||
@@ -45,7 +54,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
if (config.turnstileSecretKey) {
|
if (config.turnstileSecretKey) {
|
||||||
if (!turnstileToken || !(await verifyTurnstile(turnstileToken, config.turnstileSecretKey))) {
|
if (!turnstileToken || !(await verifyTurnstile(turnstileToken, config.turnstileSecretKey))) {
|
||||||
return NextResponse.json({ error: "人机验证失败" }, { status: 403 });
|
return NextResponse.json({ error: "人机验证失败:Turnstile token 缺失、已过期或校验未通过" }, { status: 403 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +62,7 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ error: "当前站点暂未开放注册" }, { status: 403 });
|
return NextResponse.json({ error: "当前站点暂未开放注册" }, { status: 403 });
|
||||||
}
|
}
|
||||||
if (config.requireInviteCode && !inviteCode) {
|
if (config.requireInviteCode && !inviteCode) {
|
||||||
return NextResponse.json({ error: "当前注册必须填写邀请码" }, { status: 400 });
|
return NextResponse.json({ error: "当前注册必须填写邀请码:请向管理员或邀请人获取有效邀请码" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { email } });
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
@@ -65,7 +74,7 @@ export async function POST(req: Request) {
|
|||||||
if (inviteCode) {
|
if (inviteCode) {
|
||||||
const inviter = await prisma.user.findUnique({ where: { inviteCode } });
|
const inviter = await prisma.user.findUnique({ where: { inviteCode } });
|
||||||
if (!inviter) {
|
if (!inviter) {
|
||||||
return NextResponse.json({ error: "邀请码无效" }, { status: 400 });
|
return NextResponse.json({ error: "邀请码无效:没有找到对应邀请人,请检查大小写或重新复制" }, { status: 400 });
|
||||||
}
|
}
|
||||||
inviterId = inviter.id;
|
inviterId = inviter.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export async function GET(req: Request) {
|
|||||||
const range = searchParams.get("range") ?? "1d";
|
const range = searchParams.get("range") ?? "1d";
|
||||||
|
|
||||||
if (!nodeId || nodeId.length > 128 || !RANGES[range]) {
|
if (!nodeId || nodeId.length > 128 || !RANGES[range]) {
|
||||||
return NextResponse.json({ error: "Invalid params" }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ error: "参数错误:nodeId 不能为空且长度不超过 128,range 只能是 1d、7d 或 30d" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ms, bucketMs } = RANGES[range];
|
const { ms, bucketMs } = RANGES[range];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function GET(
|
|||||||
const token = url.searchParams.get("token");
|
const token = url.searchParams.get("token");
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: "Missing token" }, { status: 401 });
|
return NextResponse.json({ error: "订阅链接缺少 token 参数,请从订阅页面重新复制完整链接" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = await prisma.userSubscription.findUnique({
|
const sub = await prisma.userSubscription.findUnique({
|
||||||
@@ -25,11 +25,11 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!sub || sub.downloadToken !== token) {
|
if (!sub || sub.downloadToken !== token) {
|
||||||
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
|
return NextResponse.json({ error: "订阅 token 无效或已被重置,请在订阅详情页重新复制链接" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub.status !== "ACTIVE") {
|
if (sub.status !== "ACTIVE") {
|
||||||
return NextResponse.json({ error: "Subscription inactive" }, { status: 403 });
|
return NextResponse.json({ error: `订阅当前状态为 ${sub.status},只有 ACTIVE 状态可以拉取配置` }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
|
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ export async function GET(req: Request) {
|
|||||||
const token = url.searchParams.get("token");
|
const token = url.searchParams.get("token");
|
||||||
|
|
||||||
if (!userId || !token) {
|
if (!userId || !token) {
|
||||||
return NextResponse.json({ error: "Missing subscription token" }, { status: 401 });
|
return NextResponse.json({ error: "总订阅链接缺少 userId 或 token 参数,请从订阅页面重新复制完整链接" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!verifyAggregateSubscriptionToken(userId, token)) {
|
if (!verifyAggregateSubscriptionToken(userId, token)) {
|
||||||
return NextResponse.json({ error: "Invalid subscription token" }, { status: 401 });
|
return NextResponse.json({ error: "总订阅 token 无效,请登录后在订阅页面重新复制链接" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
|
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("附件访问失败:你尚未登录,请登录后重新打开附件", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -24,11 +24,11 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!attachment) {
|
if (!attachment) {
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("附件访问失败:附件不存在,可能已被删除或链接不完整", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "ADMIN" && attachment.ticket.userId !== session.user.id) {
|
if (session.user.role !== "ADMIN" && attachment.ticket.userId !== session.user.id) {
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("附件访问失败:你没有权限查看这个工单附件", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestUrl = new URL(req.url);
|
const requestUrl = new URL(req.url);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function authenticateAgent(
|
|||||||
): Promise<{ nodeId: string } | NextResponse> {
|
): Promise<{ nodeId: string } | NextResponse> {
|
||||||
const authHeader = req.headers.get("authorization") || "";
|
const authHeader = req.headers.get("authorization") || "";
|
||||||
if (!authHeader.startsWith("Bearer ")) {
|
if (!authHeader.startsWith("Bearer ")) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "认证失败:请求头缺少 Bearer Token" }, { status: 401 });
|
||||||
}
|
}
|
||||||
const token = authHeader.slice(7);
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export async function authenticateAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "认证失败:Token 无效、已撤销或节点未配置探测 Token" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Type guard: true when authenticateAgent returned an error response */
|
/** Type guard: true when authenticateAgent returned an error response */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const ALGORITHM = "aes-256-gcm";
|
|||||||
function getKey() {
|
function getKey() {
|
||||||
const raw = process.env.ENCRYPTION_KEY;
|
const raw = process.env.ENCRYPTION_KEY;
|
||||||
if (!raw || Buffer.byteLength(raw, "utf-8") < 32) {
|
if (!raw || Buffer.byteLength(raw, "utf-8") < 32) {
|
||||||
throw new Error("ENCRYPTION_KEY must be at least 32 bytes");
|
throw new Error("加密配置错误:ENCRYPTION_KEY 未配置或长度不足 32 字节");
|
||||||
}
|
}
|
||||||
return Buffer.from(raw, "utf-8").subarray(0, 32);
|
return Buffer.from(raw, "utf-8").subarray(0, 32);
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ export function encrypt(text: string): string {
|
|||||||
export function decrypt(data: string): string {
|
export function decrypt(data: string): string {
|
||||||
const parts = data.split(":");
|
const parts = data.split(":");
|
||||||
if (parts.length !== 3) {
|
if (parts.length !== 3) {
|
||||||
throw new Error("Invalid encrypted data format");
|
throw new Error("解密失败:加密数据格式不正确,期望 iv:authTag:ciphertext 三段内容");
|
||||||
}
|
}
|
||||||
const [ivHex, authTagHex, encryptedHex] = parts;
|
const [ivHex, authTagHex, encryptedHex] = parts;
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), Buffer.from(ivHex, "hex"));
|
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), Buffer.from(ivHex, "hex"));
|
||||||
|
|||||||
@@ -1,14 +1,94 @@
|
|||||||
|
type ErrorLikeRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
const REDACTED_SERVER_COMPONENT_ERROR = /An error occurred in the Server Components render/i;
|
||||||
|
const DETAIL_KEYS = ["error", "message", "detail", "details", "reason", "description"];
|
||||||
|
const CODE_KEYS = ["code", "status", "statusCode", "digest"];
|
||||||
|
|
||||||
|
function normalizeMessage(message: string): string | null {
|
||||||
|
const trimmed = message.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
if (REDACTED_SERVER_COMPONENT_ERROR.test(trimmed)) {
|
||||||
|
return "服务端渲染时发生异常,生产环境已隐藏原始堆栈";
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is ErrorLikeRecord {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUnique(messages: string[], value: unknown) {
|
||||||
|
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return;
|
||||||
|
const normalized = normalizeMessage(String(value));
|
||||||
|
if (normalized && !messages.includes(normalized)) messages.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFromArray(messages: string[], value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return;
|
||||||
|
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
pushUnique(messages, item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
const path = Array.isArray(item.path) ? item.path.join(".") : null;
|
||||||
|
const message = typeof item.message === "string" ? item.message : null;
|
||||||
|
if (message) pushUnique(messages, path ? `${path}:${message}` : message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectErrorMessages(error: unknown, messages: string[], seen = new WeakSet<object>()) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
pushUnique(messages, error.message);
|
||||||
|
const digest = (error as Error & { digest?: unknown }).digest;
|
||||||
|
if (digest) pushUnique(messages, `错误编号:${String(digest)}`);
|
||||||
|
if (error.cause) collectErrorMessages(error.cause, messages, seen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
pushUnique(messages, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(error)) return;
|
||||||
|
if (seen.has(error)) return;
|
||||||
|
seen.add(error);
|
||||||
|
|
||||||
|
for (const key of DETAIL_KEYS) {
|
||||||
|
const value = error[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
pushUnique(messages, value);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
collectFromArray(messages, value);
|
||||||
|
} else if (isRecord(value)) {
|
||||||
|
collectErrorMessages(value, messages, seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of CODE_KEYS) {
|
||||||
|
const value = error[key];
|
||||||
|
if (value == null || value === "") continue;
|
||||||
|
const label = key === "digest" ? "错误编号" : key;
|
||||||
|
pushUnique(messages, `${label}:${String(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getErrorMessage(
|
export function getErrorMessage(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
fallback = "操作失败",
|
fallback = "操作失败",
|
||||||
): string {
|
): string {
|
||||||
if (error instanceof Error && error.message.trim()) {
|
const messages: string[] = [];
|
||||||
return error.message.trim();
|
collectErrorMessages(error, messages);
|
||||||
|
|
||||||
|
if (messages.length > 0) {
|
||||||
|
return messages.join(";");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof error === "string" && error.trim()) {
|
const fallbackMessage = normalizeMessage(fallback) ?? "操作失败";
|
||||||
return error.trim();
|
const errorType = error === null ? "null" : typeof error;
|
||||||
}
|
return `${fallbackMessage}:请求没有返回可读错误内容(错误类型:${errorType}),请查看服务端日志定位原因。`;
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ function extractApiError(payload: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const error = (payload as { error?: unknown }).error;
|
const error = (payload as { error?: unknown }).error;
|
||||||
return typeof error === "string" && error.trim() ? error.trim() : null;
|
if (typeof error === "string" && error.trim()) return error.trim();
|
||||||
|
return getErrorMessage(payload, "请求失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchJson<T>(
|
export async function fetchJson<T>(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { spawn } from "child_process";
|
|||||||
function getDatabaseUrl() {
|
function getDatabaseUrl() {
|
||||||
const url = process.env.DATABASE_URL;
|
const url = process.env.DATABASE_URL;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error("DATABASE_URL 未配置");
|
throw new Error("数据库备份失败:DATABASE_URL 未配置,无法连接数据库");
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ThreeXUIAdapter } from "./three-x-ui";
|
|||||||
export function createPanelAdapter(server: NodeServer): NodePanelAdapter {
|
export function createPanelAdapter(server: NodeServer): NodePanelAdapter {
|
||||||
const panelType = server.panelType ?? "3x-ui";
|
const panelType = server.panelType ?? "3x-ui";
|
||||||
if (panelType !== "3x-ui") {
|
if (panelType !== "3x-ui") {
|
||||||
throw new Error(`Unsupported panel type: ${panelType}`);
|
throw new Error(`节点 ${server.name} 面板类型不支持:${panelType},当前仅支持 3x-ui`);
|
||||||
}
|
}
|
||||||
if (!server.panelUrl || !server.panelUsername || !server.panelPassword) {
|
if (!server.panelUrl || !server.panelUsername || !server.panelPassword) {
|
||||||
throw new Error(`节点 ${server.name} 未配置 3x-ui 面板信息`);
|
throw new Error(`节点 ${server.name} 未配置 3x-ui 面板信息`);
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function errorMessage(error: unknown): string {
|
|||||||
}
|
}
|
||||||
if (error.message) return error.message;
|
if (error.message) return error.message;
|
||||||
}
|
}
|
||||||
return "未知错误";
|
return "未知错误:没有收到面板或网络层返回的具体错误内容";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function repairJBoardClientSubIds(
|
async function repairJBoardClientSubIds(
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.msg || `3x-ui API HTTP ${res.status}`);
|
throw new Error(data.msg || `3x-ui API HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
if (!data.success) throw new Error(data.msg || "3x-ui API error");
|
if (!data.success) throw new Error(data.msg || `3x-ui 接口返回失败但没有错误消息:${path}`);
|
||||||
return data.obj as T;
|
return data.obj as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`登录失败:${lastMessage || "未知错误"}`);
|
throw new Error(`登录失败:${lastMessage || "面板没有返回具体错误内容,请检查地址、账号密码和面板状态"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInbounds(): Promise<PanelInbound[]> {
|
async getInbounds(): Promise<PanelInbound[]> {
|
||||||
@@ -223,7 +223,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
|
|||||||
enable: boolean,
|
enable: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const inbound = await this.getInbound(inboundId);
|
const inbound = await this.getInbound(inboundId);
|
||||||
if (!inbound) throw new Error("Inbound not found");
|
if (!inbound) throw new Error(`3x-ui 入站不存在:面板入站 ID ${inboundId} 未找到,请重新同步节点入站`);
|
||||||
|
|
||||||
const settings = parseInboundSettings(inbound.settings);
|
const settings = parseInboundSettings(inbound.settings);
|
||||||
const client = settings.clients?.find((item) => {
|
const client = settings.clients?.find((item) => {
|
||||||
@@ -232,7 +232,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
|
|||||||
|| item.auth === clientCredential
|
|| item.auth === clientCredential
|
||||||
|| item.email === clientCredential;
|
|| item.email === clientCredential;
|
||||||
});
|
});
|
||||||
if (!client) throw new Error("Client not found");
|
if (!client) throw new Error(`3x-ui 客户端不存在:${clientCredential},请重新同步流量或重置订阅访问`);
|
||||||
|
|
||||||
client.enable = enable;
|
client.enable = enable;
|
||||||
await this.jsonRequest(`/panel/api/inbounds/updateClient/${encodeURIComponent(this.getClientPrimaryKey(inbound.protocol, client))}`, {
|
await this.jsonRequest(`/panel/api/inbounds/updateClient/${encodeURIComponent(this.getClientPrimaryKey(inbound.protocol, client))}`, {
|
||||||
@@ -327,7 +327,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
|
|||||||
case "hysteria2":
|
case "hysteria2":
|
||||||
return JSON.stringify({ clients: [{ ...base, auth: params.uuid }] });
|
return JSON.stringify({ clients: [{ ...base, auth: params.uuid }] });
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported protocol: ${params.protocol}`);
|
throw new Error(`3x-ui 客户端配置失败:不支持的协议 ${params.protocol}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function provisionSubscriptionWithDb(
|
|||||||
return applyTrafficTopup(order, db);
|
return applyTrafficTopup(order, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unsupported order kind: ${String(order.kind)}`);
|
throw new Error(`开通订阅失败:不支持的订单类型 ${String(order.kind)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNewPurchaseItems(order: PaidOrder, db: DbClient): Promise<NewOrderItem[]> {
|
async function getNewPurchaseItems(order: PaidOrder, db: DbClient): Promise<NewOrderItem[]> {
|
||||||
@@ -191,7 +191,7 @@ async function applyRenewal(order: PaidOrder, db: DbClient): Promise<string[]> {
|
|||||||
throw new Error("续费目标订阅与订单不匹配");
|
throw new Error("续费目标订阅与订单不匹配");
|
||||||
}
|
}
|
||||||
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
||||||
throw new Error("续费失败:目标订阅已过期或不可用");
|
throw new Error(`续费失败:目标订阅状态为 ${subscription.status},到期时间为 ${subscription.endDate.toISOString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
Reference in New Issue
Block a user