diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 6a62a6e..51ef8be 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -9,6 +9,7 @@ import { actorFromSession, recordAuditLog } from "@/services/audit"; import { getAppConfig } from "@/services/app-config"; import { normalizeSiteUrl } from "@/services/site-url"; import { encrypt } from "@/lib/crypto"; +import { getErrorMessage } from "@/lib/errors"; import { sendSmtpTestEmail } from "@/services/email"; const settingsSchema = z.object({ @@ -55,15 +56,10 @@ type SmtpTestActionResult = function formatActionError(error: unknown, fallback: string) { 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 error.message.trim(); - } - if (typeof error === "string" && error.trim()) { - return error.trim(); - } - return fallback; + return getErrorMessage(error, fallback); } async function assertSmtpTestRateLimit(userId: string) { diff --git a/src/actions/user/account.ts b/src/actions/user/account.ts index 690c344..59f7396 100644 --- a/src/actions/user/account.ts +++ b/src/actions/user/account.ts @@ -58,7 +58,7 @@ async function generateUniqueInviteCode(): Promise { } } - throw new Error("邀请码生成失败,请稍后重试"); + throw new Error("邀请码生成失败:连续 10 次生成的随机码都已存在,请稍后重试"); } export async function updateAccountProfile(formData: FormData) { diff --git a/src/actions/user/cart.ts b/src/actions/user/cart.ts index a23f30f..8819df6 100644 --- a/src/actions/user/cart.ts +++ b/src/actions/user/cart.ts @@ -33,8 +33,8 @@ async function getProxyPlanForCart(planId: string) { }, }); - if (plan.type !== "PROXY") throw new Error("套餐类型错误"); - if (!plan.isActive) throw new Error("套餐已下架"); + if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为代理套餐加入购物车`); + if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`); return plan; } @@ -115,8 +115,8 @@ export async function addProxyPlanToCart( export async function addStreamingPlanToCart(planId: string) { const session = await requireAuth(); const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } }); - if (plan.type !== "STREAMING") throw new Error("套餐类型错误"); - if (!plan.isActive) throw new Error("套餐已下架"); + if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为流媒体套餐加入购物车`); + if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`); const availability = await getPlanAvailability(plan, { userId: session.user.id }); if (!availability.available) { diff --git a/src/actions/user/purchase.ts b/src/actions/user/purchase.ts index 2cda40f..d67602d 100644 --- a/src/actions/user/purchase.ts +++ b/src/actions/user/purchase.ts @@ -135,8 +135,8 @@ export async function purchaseProxy( }, }); - if (plan.type !== "PROXY") throw new Error("套餐类型错误"); - if (!plan.isActive) throw new Error("套餐已下架"); + if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为代理套餐购买`); + if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`); const price = getPlanPurchasePrice(plan, trafficGb); @@ -216,8 +216,8 @@ export async function purchaseStreaming(planId: string): Promise { where: { id: planId }, }); - if (plan.type !== "STREAMING") throw new Error("套餐类型错误"); - if (!plan.isActive) throw new Error("套餐已下架"); + if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为流媒体套餐购买`); + if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`); const availability = await getPlanAvailability(plan, { userId: session.user.id }); if (!availability.available) { diff --git a/src/app/(admin)/admin/error.tsx b/src/app/(admin)/admin/error.tsx index 70334d8..87e3bf9 100644 --- a/src/app/(admin)/admin/error.tsx +++ b/src/app/(admin)/admin/error.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { getErrorMessage } from "@/lib/errors"; export default function AdminError({ error, @@ -10,13 +11,18 @@ export default function AdminError({ error: Error & { digest?: string }; reset: () => void; }) { + const message = getErrorMessage(error, "管理后台页面加载失败"); + return (

出了点问题

-

- {error.message || "页面加载失败,请稍后重试。"} +

+ {message} +

+

+ 如果重试后仍失败,请复制上面的错误详情给管理员排查。

diff --git a/src/app/(admin)/admin/nodes/node-actions.tsx b/src/app/(admin)/admin/nodes/node-actions.tsx index 41034bc..202fdbf 100644 --- a/src/app/(admin)/admin/nodes/node-actions.tsx +++ b/src/app/(admin)/admin/nodes/node-actions.tsx @@ -72,7 +72,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl: try { const res = await testNodeConnection(node.id); if (res.success) toast.success(res.message); - else toast.error(res.message); + else toast.error(getErrorMessage(res.message, "节点测试失败")); } catch (error) { toast.error(getErrorMessage(error, "测试失败")); } diff --git a/src/app/(admin)/admin/plans/plan-actions.tsx b/src/app/(admin)/admin/plans/plan-actions.tsx index e1623cd..8dfa1f8 100644 --- a/src/app/(admin)/admin/plans/plan-actions.tsx +++ b/src/app/(admin)/admin/plans/plan-actions.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { deletePlanPermanently, togglePlan } from "@/actions/admin/plans"; import { toast } from "sonner"; +import { getErrorMessage } from "@/lib/errors"; import { PlanForm, type PlanFormValue, @@ -39,8 +40,7 @@ export function PlanActions({ toast.success(isActive ? "套餐已下架" : "套餐已上架"); router.refresh(); } catch (error) { - const message = error instanceof Error ? error.message : "切换失败"; - toast.error(message); + toast.error(getErrorMessage(error, "切换套餐上下架状态失败")); } }} > diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index 2c4a6bc..841e4aa 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -59,7 +59,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: try { const result = await saveAppSettings(new FormData(form)); if (!result.ok) { - toast.error(result.error); + toast.error(getErrorMessage(result.error, "保存设置失败")); return; } clearPasswordField(form); @@ -86,8 +86,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: } toast.error( result.settingsSaved - ? `设置已保存,但测试邮件没有发出:${result.error}` - : result.error, + ? `设置已保存,但测试邮件没有发出:${getErrorMessage(result.error, "测试邮件发送失败")}` + : getErrorMessage(result.error, "测试邮件发送失败"), ); return; } diff --git a/src/app/(auth)/register/register-page-client.tsx b/src/app/(auth)/register/register-page-client.tsx index 6426fe1..83bd9d7 100644 --- a/src/app/(auth)/register/register-page-client.tsx +++ b/src/app/(auth)/register/register-page-client.tsx @@ -7,6 +7,7 @@ import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { TurnstileWidget } from "@/components/shared/turnstile-widget"; +import { getErrorMessage } from "@/lib/errors"; import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell"; export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) { @@ -44,13 +45,15 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) { turnstileToken, }), }); - const data = await response.json(); + const data = await response.json().catch(() => null) as { error?: unknown; requiresEmailVerification?: unknown } | null; if (!response.ok) { - setError(data.error || "注册失败"); + setError(getErrorMessage(data, `注册失败 (HTTP ${response.status})`)); } else { - setRequiresEmailVerification(Boolean(data.requiresEmailVerification)); + setRequiresEmailVerification(Boolean(data?.requiresEmailVerification)); setSuccess(true); } + } catch (error) { + setError(getErrorMessage(error, "注册失败")); } finally { setLoading(false); } diff --git a/src/app/(user)/error.tsx b/src/app/(user)/error.tsx index 3a2813c..04f7548 100644 --- a/src/app/(user)/error.tsx +++ b/src/app/(user)/error.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { getErrorMessage } from "@/lib/errors"; export default function UserError({ error, @@ -10,13 +11,18 @@ export default function UserError({ error: Error & { digest?: string }; reset: () => void; }) { + const message = getErrorMessage(error, "页面加载失败"); + return (

出了点问题

-

- {error.message || "页面加载失败,请稍后重试。"} +

+ {message} +

+

+ 如果重试后仍失败,请复制上面的错误详情给管理员排查。

diff --git a/src/app/(user)/store/use-plan-availability-check.ts b/src/app/(user)/store/use-plan-availability-check.ts index d19c354..2081135 100644 --- a/src/app/(user)/store/use-plan-availability-check.ts +++ b/src/app/(user)/store/use-plan-availability-check.ts @@ -15,7 +15,7 @@ export function usePlanAvailabilityCheck(planId: string) { if (result.available) { toast.success("这款套餐现在可以购买"); } else { - toast.error(result.message); + toast.error(getErrorMessage(result.message, "这款套餐暂时不可购买")); } } catch (error) { toast.error(getErrorMessage(error, "暂时无法确认补位时间")); diff --git a/src/app/(user)/subscriptions/_components/renewal-button.tsx b/src/app/(user)/subscriptions/_components/renewal-button.tsx index 1da0bb8..2bd2261 100644 --- a/src/app/(user)/subscriptions/_components/renewal-button.tsx +++ b/src/app/(user)/subscriptions/_components/renewal-button.tsx @@ -60,7 +60,7 @@ export function RenewalButton({ try { const result = await purchaseRenewal(subscriptionId, renewalDays); if (!result.ok) { - toast.error(result.error); + toast.error(getErrorMessage(result.error, "创建续费订单失败")); return; } router.push(`/pay/${result.orderId}`); diff --git a/src/app/api/agent/latency/route.ts b/src/app/api/agent/latency/route.ts index f0e22f2..3a52d98 100644 --- a/src/app/api/agent/latency/route.ts +++ b/src/app/api/agent/latency/route.ts @@ -19,11 +19,11 @@ export async function POST(req: Request) { try { body = await req.json(); } 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) { - return NextResponse.json({ error: "Missing latencies" }, { status: 400 }); + return NextResponse.json({ error: "缺少延迟数据:latencies 必须是非空数组" }, { status: 400 }); } const validCarriers = new Set(["telecom", "unicom", "mobile"]); diff --git a/src/app/api/agent/trace/route.ts b/src/app/api/agent/trace/route.ts index 7daed5a..698fc10 100644 --- a/src/app/api/agent/trace/route.ts +++ b/src/app/api/agent/trace/route.ts @@ -20,11 +20,11 @@ export async function POST(req: Request) { try { body = await req.json(); } 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) { - return NextResponse.json({ error: "Missing traces" }, { status: 400 }); + return NextResponse.json({ error: "缺少路由追踪数据:traces 必须是非空数组" }, { status: 400 }); } const validCarriers = new Set(["telecom", "unicom", "mobile"]); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 908f858..9a05b33 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -8,13 +8,22 @@ import { rateLimit } from "@/lib/rate-limit"; import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email"; const schema = z.object({ - email: z.string().email(), - password: z.string().min(6), + email: z.string().email("邮箱格式不正确"), + password: z.string().min(6, "密码至少需要 6 位"), name: z.string().optional(), inviteCode: 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) { const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip")?.trim() @@ -31,12 +40,12 @@ export async function POST(req: Request) { try { body = await req.json(); } catch { - return NextResponse.json({ error: "参数错误" }, { status: 400 }); + return NextResponse.json({ error: "注册参数错误:请求体不是有效 JSON" }, { status: 400 }); } const parsed = schema.safeParse(body); 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; @@ -45,7 +54,7 @@ export async function POST(req: Request) { if (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 }); } if (config.requireInviteCode && !inviteCode) { - return NextResponse.json({ error: "当前注册必须填写邀请码" }, { status: 400 }); + return NextResponse.json({ error: "当前注册必须填写邀请码:请向管理员或邀请人获取有效邀请码" }, { status: 400 }); } const existing = await prisma.user.findUnique({ where: { email } }); @@ -65,7 +74,7 @@ export async function POST(req: Request) { if (inviteCode) { const inviter = await prisma.user.findUnique({ where: { inviteCode } }); if (!inviter) { - return NextResponse.json({ error: "邀请码无效" }, { status: 400 }); + return NextResponse.json({ error: "邀请码无效:没有找到对应邀请人,请检查大小写或重新复制" }, { status: 400 }); } inviterId = inviter.id; } diff --git a/src/app/api/latency/history/route.ts b/src/app/api/latency/history/route.ts index 7b0e791..44c7608 100644 --- a/src/app/api/latency/history/route.ts +++ b/src/app/api/latency/history/route.ts @@ -13,7 +13,10 @@ export async function GET(req: Request) { const range = searchParams.get("range") ?? "1d"; 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]; diff --git a/src/app/api/subscription/[id]/route.ts b/src/app/api/subscription/[id]/route.ts index 64ff92b..1461de1 100644 --- a/src/app/api/subscription/[id]/route.ts +++ b/src/app/api/subscription/[id]/route.ts @@ -17,7 +17,7 @@ export async function GET( const token = url.searchParams.get("token"); if (!token) { - return NextResponse.json({ error: "Missing token" }, { status: 401 }); + return NextResponse.json({ error: "订阅链接缺少 token 参数,请从订阅页面重新复制完整链接" }, { status: 401 }); } const sub = await prisma.userSubscription.findUnique({ @@ -25,11 +25,11 @@ export async function GET( }); if (!sub || sub.downloadToken !== token) { - return NextResponse.json({ error: "Invalid token" }, { status: 401 }); + return NextResponse.json({ error: "订阅 token 无效或已被重置,请在订阅详情页重新复制链接" }, { status: 401 }); } 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")); diff --git a/src/app/api/subscription/all/route.ts b/src/app/api/subscription/all/route.ts index bcadefc..f5ca84c 100644 --- a/src/app/api/subscription/all/route.ts +++ b/src/app/api/subscription/all/route.ts @@ -15,11 +15,11 @@ export async function GET(req: Request) { const token = url.searchParams.get("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)) { - 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")); diff --git a/src/app/api/support/attachments/[id]/route.ts b/src/app/api/support/attachments/[id]/route.ts index 6980d2e..2df6740 100644 --- a/src/app/api/support/attachments/[id]/route.ts +++ b/src/app/api/support/attachments/[id]/route.ts @@ -8,7 +8,7 @@ export async function GET( ) { const session = await getServerSession(authOptions); if (!session) { - return new Response("Unauthorized", { status: 401 }); + return new Response("附件访问失败:你尚未登录,请登录后重新打开附件", { status: 401 }); } const { id } = await params; @@ -24,11 +24,11 @@ export async function GET( }); 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) { - return new Response("Forbidden", { status: 403 }); + return new Response("附件访问失败:你没有权限查看这个工单附件", { status: 403 }); } const requestUrl = new URL(req.url); diff --git a/src/lib/agent-auth.ts b/src/lib/agent-auth.ts index 24944df..27ff099 100644 --- a/src/lib/agent-auth.ts +++ b/src/lib/agent-auth.ts @@ -12,7 +12,7 @@ export async function authenticateAgent( ): Promise<{ nodeId: string } | NextResponse> { const authHeader = req.headers.get("authorization") || ""; if (!authHeader.startsWith("Bearer ")) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json({ error: "认证失败:请求头缺少 Bearer Token" }, { status: 401 }); } 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 */ diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 253119d..c18d189 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -5,7 +5,7 @@ const ALGORITHM = "aes-256-gcm"; function getKey() { const raw = process.env.ENCRYPTION_KEY; 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); } @@ -21,7 +21,7 @@ export function encrypt(text: string): string { export function decrypt(data: string): string { const parts = data.split(":"); if (parts.length !== 3) { - throw new Error("Invalid encrypted data format"); + throw new Error("解密失败:加密数据格式不正确,期望 iv:authTag:ciphertext 三段内容"); } const [ivHex, authTagHex, encryptedHex] = parts; const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), Buffer.from(ivHex, "hex")); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 2c5f69e..63c8e78 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,14 +1,94 @@ +type ErrorLikeRecord = Record; + +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()) { + 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( error: unknown, fallback = "操作失败", ): string { - if (error instanceof Error && error.message.trim()) { - return error.message.trim(); + const messages: string[] = []; + collectErrorMessages(error, messages); + + if (messages.length > 0) { + return messages.join(";"); } - if (typeof error === "string" && error.trim()) { - return error.trim(); - } - - return fallback; + const fallbackMessage = normalizeMessage(fallback) ?? "操作失败"; + const errorType = error === null ? "null" : typeof error; + return `${fallbackMessage}:请求没有返回可读错误内容(错误类型:${errorType}),请查看服务端日志定位原因。`; } diff --git a/src/lib/fetch-json.ts b/src/lib/fetch-json.ts index 931478c..b361b09 100644 --- a/src/lib/fetch-json.ts +++ b/src/lib/fetch-json.ts @@ -6,7 +6,8 @@ function extractApiError(payload: unknown): string | null { } 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( diff --git a/src/services/database-backup.ts b/src/services/database-backup.ts index 61b2ccb..4b370e5 100644 --- a/src/services/database-backup.ts +++ b/src/services/database-backup.ts @@ -6,7 +6,7 @@ import { spawn } from "child_process"; function getDatabaseUrl() { const url = process.env.DATABASE_URL; if (!url) { - throw new Error("DATABASE_URL 未配置"); + throw new Error("数据库备份失败:DATABASE_URL 未配置,无法连接数据库"); } return url; } diff --git a/src/services/node-panel/factory.ts b/src/services/node-panel/factory.ts index 8825a4f..324f39f 100644 --- a/src/services/node-panel/factory.ts +++ b/src/services/node-panel/factory.ts @@ -5,7 +5,7 @@ import { ThreeXUIAdapter } from "./three-x-ui"; export function createPanelAdapter(server: NodeServer): NodePanelAdapter { const panelType = server.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) { throw new Error(`节点 ${server.name} 未配置 3x-ui 面板信息`); diff --git a/src/services/node-panel/sync-inbounds.ts b/src/services/node-panel/sync-inbounds.ts index 38e6075..323c300 100644 --- a/src/services/node-panel/sync-inbounds.ts +++ b/src/services/node-panel/sync-inbounds.ts @@ -89,7 +89,7 @@ function errorMessage(error: unknown): string { } if (error.message) return error.message; } - return "未知错误"; + return "未知错误:没有收到面板或网络层返回的具体错误内容"; } async function repairJBoardClientSubIds( diff --git a/src/services/node-panel/three-x-ui.ts b/src/services/node-panel/three-x-ui.ts index 74b11df..9d457ee 100644 --- a/src/services/node-panel/three-x-ui.ts +++ b/src/services/node-panel/three-x-ui.ts @@ -114,7 +114,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter { if (!res.ok) { 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; } @@ -182,7 +182,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter { return false; } - throw new Error(`登录失败:${lastMessage || "未知错误"}`); + throw new Error(`登录失败:${lastMessage || "面板没有返回具体错误内容,请检查地址、账号密码和面板状态"}`); } async getInbounds(): Promise { @@ -223,7 +223,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter { enable: boolean, ): Promise { 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 client = settings.clients?.find((item) => { @@ -232,7 +232,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter { || item.auth === clientCredential || item.email === clientCredential; }); - if (!client) throw new Error("Client not found"); + if (!client) throw new Error(`3x-ui 客户端不存在:${clientCredential},请重新同步流量或重置订阅访问`); client.enable = enable; await this.jsonRequest(`/panel/api/inbounds/updateClient/${encodeURIComponent(this.getClientPrimaryKey(inbound.protocol, client))}`, { @@ -327,7 +327,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter { case "hysteria2": return JSON.stringify({ clients: [{ ...base, auth: params.uuid }] }); default: - throw new Error(`Unsupported protocol: ${params.protocol}`); + throw new Error(`3x-ui 客户端配置失败:不支持的协议 ${params.protocol}`); } } } diff --git a/src/services/provision.ts b/src/services/provision.ts index 5fa94ab..91e5da7 100644 --- a/src/services/provision.ts +++ b/src/services/provision.ts @@ -42,7 +42,7 @@ export async function provisionSubscriptionWithDb( 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 { @@ -191,7 +191,7 @@ async function applyRenewal(order: PaidOrder, db: DbClient): Promise { throw new Error("续费目标订阅与订单不匹配"); } if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) { - throw new Error("续费失败:目标订阅已过期或不可用"); + throw new Error(`续费失败:目标订阅状态为 ${subscription.status},到期时间为 ${subscription.endDate.toISOString()}`); } const now = new Date();