diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aafefd5..d880b71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -156,7 +156,7 @@ enum SupportTicketPriority { model User { id String @id @default(cuid()) email String @unique - emailVerifiedAt DateTime? @default(now()) + emailVerifiedAt DateTime? password String name String? role Role @default(USER) diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 242bfa8..9970e17 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({ @@ -65,15 +66,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/admin/users.ts b/src/actions/admin/users.ts index b83bfd0..d46df24 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -26,7 +26,7 @@ export async function createUser(formData: FormData) { const data = createUserSchema.parse(Object.fromEntries(formData)); const hashed = await bcrypt.hash(data.password, 12); const user = await prisma.user.create({ - data: { email: data.email, password: hashed, name: data.name || null, role: data.role }, + data: { email: data.email, emailVerifiedAt: new Date(), password: hashed, name: data.name || null, role: data.role }, }); await recordAuditLog({ actor: actorFromSession(session), 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 59b47bd..79a0698 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 137d1bc..8c3456a 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -69,7 +69,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); @@ -96,8 +96,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: } toast.error( result.settingsSaved - ? `设置已保存,但测试邮件没有发出:${result.error}` - : result.error, + ? `设置已保存,但测试邮件没有发出:${getErrorMessage(result.error, "测试邮件发送失败")}` + : getErrorMessage(result.error, "测试邮件发送失败"), ); return; } @@ -360,7 +360,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: -

开启后,新用户注册会先收到验证邮件,完成验证后才能登录。

+

开启后,新用户注册会先收到验证邮件,完成验证后才能登录;关闭后注册成功即可登录。

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/aggregate-subscription-card.tsx b/src/app/(user)/subscriptions/_components/aggregate-subscription-card.tsx index d86ba46..7076b06 100644 --- a/src/app/(user)/subscriptions/_components/aggregate-subscription-card.tsx +++ b/src/app/(user)/subscriptions/_components/aggregate-subscription-card.tsx @@ -1,10 +1,10 @@ import { format } from "date-fns"; import { zhCN } from "date-fns/locale"; -import { CalendarClock, Gauge, Layers3, Link2, Sparkles } from "lucide-react"; -import { CopyButton } from "@/components/shared/copy-button"; +import { CalendarClock, Gauge, Layers3, Sparkles } from "lucide-react"; import { QrPreview } from "@/components/shared/qr-preview"; import { Progress } from "@/components/ui/progress"; import { formatBytes } from "@/lib/utils"; +import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions"; interface AggregateSubscriptionCardProps { subscriptionUrl: string; @@ -24,6 +24,7 @@ export function AggregateSubscriptionCard({ const percent = totalLimit ? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100)) : 0; + const clashUrl = withSubscriptionFormat(subscriptionUrl, "clash"); return (
@@ -44,17 +45,7 @@ export function AggregateSubscriptionCard({
-
-
-
-

- SUBSCRIPTION URL -

-

{subscriptionUrl}

-
- -
-
+
@@ -94,7 +85,7 @@ export function AggregateSubscriptionCard({
- +
diff --git a/src/app/(user)/subscriptions/_components/proxy-subscription-details.tsx b/src/app/(user)/subscriptions/_components/proxy-subscription-details.tsx index e62324c..16e47f0 100644 --- a/src/app/(user)/subscriptions/_components/proxy-subscription-details.tsx +++ b/src/app/(user)/subscriptions/_components/proxy-subscription-details.tsx @@ -1,10 +1,10 @@ import { Gauge, Link2, QrCode } from "lucide-react"; import { Progress } from "@/components/ui/progress"; -import { CopyButton } from "@/components/shared/copy-button"; import { QrPreview } from "@/components/shared/qr-preview"; import { formatBytes } from "@/lib/utils"; import { buildSingleNodeUri } from "@/services/subscription"; import type { SubscriptionRecord } from "../subscriptions-types"; +import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions"; interface ProxySubscriptionDetailsProps { sub: SubscriptionRecord; @@ -18,6 +18,7 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null; const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0; const subUrl = `${baseUrl}/api/subscription/${sub.id}?token=${sub.downloadToken}`; + const clashUrl = withSubscriptionFormat(subUrl, "clash"); const singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : ""; return ( @@ -45,18 +46,20 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
导入信息
-
-
-

SUBSCRIPTION URL

-

{subUrl}

-
- +
+
扫码导入
- + {singleNodeUri && ( +
+
+

+ SUBSCRIPTION URL +

+

{genericUrl}

+

+ {title} · {description} +

+
+
+ + +
+
+
+ ); +} diff --git a/src/app/(user)/subscriptions/page.tsx b/src/app/(user)/subscriptions/page.tsx index 326c76d..9847d4a 100644 --- a/src/app/(user)/subscriptions/page.tsx +++ b/src/app/(user)/subscriptions/page.tsx @@ -18,7 +18,7 @@ import { getAggregateSubscriptionToken } from "@/services/subscription"; export const metadata: Metadata = { title: "我的订阅", - description: "管理活跃订阅并查看历史记录。", + description: "管理活跃订阅,并复制适配 Clash 与通用客户端的订阅 URL。", }; export default async function SubscriptionsPage() { @@ -39,7 +39,7 @@ export default async function SubscriptionsPage() { { + const field = issue.path.join(".") || "请求体"; + return `${field}:${issue.message}`; + }) + .join(";"); +} + export async function POST(req: Request) { const ip = getClientIp(req.headers); const { success, remaining } = await rateLimit(`ratelimit:register:${ip}`, 5, 60); @@ -30,21 +39,28 @@ 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; const email = normalizeEmailAddress(parsed.data.email); const config = await getAppConfig(); + if (config.emailVerificationRequired && !isSmtpConfigured(config)) { + return NextResponse.json( + { error: "注册暂不可用:管理员已开启邮箱验证,但站点尚未配置 SMTP 邮件服务,无法发送验证邮件" }, + { status: 503 }, + ); + } + if (config.turnstileSecretKey) { if (!turnstileToken || !(await verifyTurnstile(turnstileToken, config.turnstileSecretKey))) { - return NextResponse.json({ error: "人机验证失败" }, { status: 403 }); + return NextResponse.json({ error: "人机验证失败:Turnstile token 缺失、已过期或校验未通过" }, { status: 403 }); } } @@ -52,7 +68,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 } }); @@ -64,7 +80,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 e952e23..c9e57e4 100644 --- a/src/app/api/subscription/[id]/route.ts +++ b/src/app/api/subscription/[id]/route.ts @@ -1,5 +1,11 @@ import { NextResponse } from "next/server"; -import { generateSubscriptionContent } from "@/services/subscription"; +import { + buildSubscriptionUserInfo, + generateSubscriptionContent, + getSubscriptionContentType, + getSubscriptionFilename, + resolveSubscriptionFormat, +} from "@/services/subscription"; import { prisma } from "@/lib/prisma"; import { rateLimit } from "@/lib/rate-limit"; import { getClientRequestContext } from "@/lib/request-context"; @@ -29,6 +35,9 @@ export async function GET( userId: true, downloadToken: true, status: true, + trafficUsed: true, + trafficLimit: true, + endDate: true, plan: { select: { type: true } }, }, }), @@ -64,7 +73,7 @@ export async function GET( allowed: false, reason: "missing_token", }); - return jsonError("Missing token", 401); + return jsonError("订阅链接缺少 token 参数,请从订阅页面重新复制完整链接", 401); } if (!sub || sub.downloadToken !== token) { @@ -76,7 +85,7 @@ export async function GET( allowed: false, reason: "invalid_token", }); - return jsonError("Invalid token", 401); + return jsonError("订阅 token 无效或已被重置,请在订阅详情页重新复制链接", 401); } if (config.subscriptionRiskEnabled) { @@ -108,7 +117,7 @@ export async function GET( allowed: false, reason: "subscription_inactive", }); - return jsonError("Subscription inactive", 403); + return jsonError(`订阅当前状态为 ${sub.status},只有 ACTIVE 状态可以拉取配置`, 403); } const risk = await recordSubscriptionAccess({ @@ -124,12 +133,24 @@ export async function GET( return jsonError("Subscription suspended by risk control", 403); } - const content = await generateSubscriptionContent(id); + const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent")); + const content = await generateSubscriptionContent(id, format); + const userInfo = buildSubscriptionUserInfo({ + upload: 0, + download: sub.trafficUsed, + total: sub.trafficLimit, + expire: sub.endDate, + }); + const headers = new Headers({ + "Content-Type": getSubscriptionContentType(format), + "Content-Disposition": `attachment; filename="${getSubscriptionFilename("jboard-sub", format)}"`, + "Cache-Control": "no-store", + "profile-update-interval": "12", + "profile-web-page-url": `${url.origin}/subscriptions/${id}`, + }); + if (userInfo) headers.set("Subscription-Userinfo", userInfo); + return new Response(content, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Content-Disposition": `attachment; filename="jboard-sub.txt"`, - "Cache-Control": "no-store", - }, + headers, }); } diff --git a/src/app/api/subscription/all/route.ts b/src/app/api/subscription/all/route.ts index fe9050d..654ef49 100644 --- a/src/app/api/subscription/all/route.ts +++ b/src/app/api/subscription/all/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; import { + buildSubscriptionUserInfo, generateAggregateSubscriptionContent, + getSubscriptionContentType, + getSubscriptionFilename, + resolveSubscriptionFormat, verifyAggregateSubscriptionToken, } from "@/services/subscription"; import { prisma } from "@/lib/prisma"; @@ -49,7 +53,7 @@ export async function GET(req: Request) { allowed: false, reason: "missing_subscription_token", }); - return jsonError("Missing subscription token", 401); + return jsonError("总订阅链接缺少 userId 或 token 参数,请从订阅页面重新复制完整链接", 401); } const user = await prisma.user.findUnique({ @@ -65,7 +69,7 @@ export async function GET(req: Request) { allowed: false, reason: "invalid_subscription_token", }); - return jsonError("Invalid subscription token", 401); + return jsonError("总订阅 token 无效,请登录后在订阅页面重新复制链接", 401); } if (config.subscriptionRiskEnabled) { @@ -110,12 +114,47 @@ export async function GET(req: Request) { return jsonError("Subscriptions suspended by risk control", 403); } - const content = await generateAggregateSubscriptionContent(userId); + const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent")); + const activeProxyWhere = { + userId, + status: "ACTIVE" as const, + endDate: { gt: new Date() }, + plan: { type: "PROXY" as const }, + nodeClient: { isNot: null }, + }; + const [content, statsRows] = await Promise.all([ + generateAggregateSubscriptionContent(userId, format), + prisma.userSubscription.findMany({ + where: activeProxyWhere, + select: { + trafficUsed: true, + trafficLimit: true, + endDate: true, + }, + }), + ]); + const hasUnlimitedTraffic = statsRows.some((row) => row.trafficLimit == null); + const userInfo = buildSubscriptionUserInfo({ + upload: 0, + download: statsRows.reduce((sum, row) => sum + row.trafficUsed, BigInt(0)), + total: hasUnlimitedTraffic + ? null + : statsRows.reduce((sum, row) => sum + (row.trafficLimit ?? BigInt(0)), BigInt(0)), + expire: statsRows.reduce((earliest, row) => { + if (!earliest || row.endDate < earliest) return row.endDate; + return earliest; + }, null), + }); + const headers = new Headers({ + "Content-Type": getSubscriptionContentType(format), + "Content-Disposition": `attachment; filename="${getSubscriptionFilename("jboard-all-sub", format)}"`, + "Cache-Control": "no-store", + "profile-update-interval": "12", + "profile-web-page-url": `${url.origin}/subscriptions`, + }); + if (userInfo) headers.set("Subscription-Userinfo", userInfo); + return new Response(content, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Content-Disposition": 'attachment; filename="jboard-all-sub.txt"', - "Cache-Control": "no-store", - }, + headers, }); } 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/components/shared/copy-button.tsx b/src/components/shared/copy-button.tsx index 30943ed..da080af 100644 --- a/src/components/shared/copy-button.tsx +++ b/src/components/shared/copy-button.tsx @@ -5,7 +5,7 @@ import { Check, Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; -export function CopyButton({ text }: { text: string }) { +export function CopyButton({ text, label = "复制" }: { text: string; label?: string }) { const [copied, setCopied] = useState(false); return ( @@ -20,7 +20,7 @@ export function CopyButton({ text }: { text: string }) { }} > {copied ? : } - {copied ? "已复制" : "复制"} + {copied ? "已复制" : label} ); } 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(); diff --git a/src/services/subscription.ts b/src/services/subscription.ts index b614a2e..1243e4f 100644 --- a/src/services/subscription.ts +++ b/src/services/subscription.ts @@ -37,6 +37,66 @@ interface LinkTarget { remark?: string; } +export type SubscriptionOutputFormat = "base64" | "uri" | "clash"; + +export interface SubscriptionTrafficStats { + upload?: bigint | number | null; + download?: bigint | number | null; + total?: bigint | number | null; + expire?: Date | null; +} + +const CLASH_FORMAT_ALIASES = new Set(["clash", "clash-meta", "mihomo", "yaml", "yml"]); +const URI_FORMAT_ALIASES = new Set(["uri", "raw", "plain", "text"]); +const BASE64_FORMAT_ALIASES = new Set(["base64", "v2ray", "generic"]); + +function normalizeSubscriptionFormat(raw: string | null): SubscriptionOutputFormat | null { + if (!raw) return null; + const normalized = raw.trim().toLowerCase(); + if (CLASH_FORMAT_ALIASES.has(normalized)) return "clash"; + if (URI_FORMAT_ALIASES.has(normalized)) return "uri"; + if (BASE64_FORMAT_ALIASES.has(normalized)) return "base64"; + return null; +} + +function isClashLikeUserAgent(userAgent: string | null | undefined) { + if (!userAgent) return false; + return /clash|mihomo|verge|stash|flclash|nyanpasu|openclash/i.test(userAgent); +} + +export function resolveSubscriptionFormat( + searchParams: URLSearchParams, + userAgent?: string | null, +): SubscriptionOutputFormat { + return normalizeSubscriptionFormat(searchParams.get("format") ?? searchParams.get("target")) + ?? (isClashLikeUserAgent(userAgent) ? "clash" : "base64"); +} + +export function getSubscriptionContentType(format: SubscriptionOutputFormat) { + return format === "clash" ? "text/yaml; charset=utf-8" : "text/plain; charset=utf-8"; +} + +export function getSubscriptionFilename(baseName: string, format: SubscriptionOutputFormat) { + return `${baseName}.${format === "clash" ? "yaml" : "txt"}`; +} + +function bigintHeaderValue(value: bigint | number | null | undefined) { + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, Math.floor(value)).toString(); + return "0"; +} + +export function buildSubscriptionUserInfo(stats: SubscriptionTrafficStats | null | undefined) { + if (!stats) return null; + const expire = stats.expire ? Math.floor(stats.expire.getTime() / 1000) : 0; + return [ + `upload=${bigintHeaderValue(stats.upload)}`, + `download=${bigintHeaderValue(stats.download)}`, + `total=${bigintHeaderValue(stats.total)}`, + `expire=${expire}`, + ].join("; "); +} + function getAggregateSubscriptionSecret() { const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? process.env.DATABASE_URL; if (!secret) { @@ -439,6 +499,55 @@ function appendQueryAndHash(base: string, params: URLSearchParams, label: string return `${base}${query ? `?${query}` : ""}#${encodeURIComponent(label)}`; } +function stripUndefined(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== "")) as T; +} + +function yamlQuote(value: string) { + return JSON.stringify(value); +} + +function toYaml(value: unknown, indent = 0): string { + const pad = " ".repeat(indent); + if (Array.isArray(value)) { + if (value.length === 0) return `${pad}[]`; + return value.map((item) => { + if (item && typeof item === "object" && !Array.isArray(item)) { + const record = item as JsonRecord; + const entries = Object.entries(record); + if (entries.length === 0) return `${pad}- {}`; + const [firstKey, firstValue] = entries[0]; + const firstLine = `${pad}- ${firstKey}: ${yamlInline(firstValue)}`; + const rest = entries.slice(1).map(([key, child]) => `${pad} ${key}: ${yamlBlock(child, indent + 2)}`); + return [firstLine, ...rest].join("\n"); + } + return `${pad}- ${yamlInline(item)}`; + }).join("\n"); + } + + const record = asRecord(value); + if (!record) return `${pad}${yamlInline(value)}`; + const entries = Object.entries(record); + if (entries.length === 0) return `${pad}{}`; + return entries.map(([key, child]) => `${pad}${key}: ${yamlBlock(child, indent)}`).join("\n"); +} + +function yamlBlock(value: unknown, indent: number) { + if (value && typeof value === "object") { + const nested = toYaml(value, indent + 2); + return `\n${nested}`; + } + return yamlInline(value); +} + +function yamlInline(value: unknown): string { + if (typeof value === "string") return yamlQuote(value); + if (typeof value === "number" && Number.isFinite(value)) return String(value); + if (typeof value === "boolean") return value ? "true" : "false"; + if (value == null) return "null"; + return yamlQuote(String(value)); +} + function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] { const stream = getStream(nodeClient); const proxies: LinkTarget[] = []; @@ -465,6 +574,217 @@ function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] { return [{ address: getServerAddress(nodeClient), port: nodeClient.inbound.port }]; } +function getStreamSecurity(stream: JsonRecord, target?: LinkTarget) { + return target?.securityOverride && target.securityOverride !== "same" + ? target.securityOverride + : (asString(stream.security) ?? "none"); +} + +function getTlsSettings(stream: JsonRecord) { + const tls = asRecord(stream.tlsSettings); + const nested = asRecord(tls?.settings); + return { tls, nested }; +} + +function getRealitySettings(stream: JsonRecord) { + const reality = asRecord(stream.realitySettings); + const nested = asRecord(reality?.settings); + return { reality, nested }; +} + +function applyClashTls(proxy: JsonRecord, stream: JsonRecord, security: string) { + if (security !== "tls" && security !== "reality") return; + + proxy.tls = true; + if (security === "tls") { + const { tls, nested } = getTlsSettings(stream); + const serverName = asString(tls?.serverName) ?? asString(nested?.serverName); + const fingerprint = asString(nested?.fingerprint) ?? asString(tls?.fingerprint); + const alpn = stringList(tls?.alpn); + if (serverName) proxy.servername = serverName; + if (fingerprint) proxy["client-fingerprint"] = fingerprint; + if (alpn.length > 0) proxy.alpn = alpn; + if (asBoolean(nested?.allowInsecure) || asBoolean(tls?.allowInsecure)) { + proxy["skip-cert-verify"] = true; + } + return; + } + + const { reality, nested } = getRealitySettings(stream); + const serverName = firstString(reality?.serverNames) ?? asString(reality?.serverName); + const publicKey = asString(nested?.publicKey) ?? asString(reality?.publicKey); + const shortId = firstString(reality?.shortIds) ?? asString(reality?.shortId); + const fingerprint = asString(nested?.fingerprint) ?? asString(reality?.fingerprint); + const spiderX = asString(nested?.spiderX) ?? asString(reality?.spiderX); + if (serverName) proxy.servername = serverName; + if (fingerprint) proxy["client-fingerprint"] = fingerprint; + proxy["reality-opts"] = stripUndefined({ + "public-key": publicKey, + "short-id": shortId, + "spider-x": spiderX, + }); +} + +function getPathAndHost(settings: unknown) { + const record = asRecord(settings); + if (!record) return { path: null, host: null }; + return { + path: asString(record.path), + host: asString(record.host) ?? getHeaderValue(record.headers, "host"), + }; +} + +function applyClashTransport(proxy: JsonRecord, stream: JsonRecord) { + const network = asString(stream.network) ?? "tcp"; + if (network === "tcp") { + const tcp = asRecord(stream.tcpSettings); + const header = asRecord(tcp?.header); + if (asString(header?.type) !== "http") return; + const request = asRecord(header?.request); + proxy.network = "http"; + proxy["http-opts"] = stripUndefined({ + path: stringList(request?.path), + headers: stripUndefined({ Host: stringList(asRecord(request?.headers)?.Host ?? asRecord(request?.headers)?.host) }), + }); + return; + } + + proxy.network = network; + if (network === "ws") { + const { path, host } = getPathAndHost(stream.wsSettings); + proxy["ws-opts"] = stripUndefined({ + path, + headers: host ? { Host: host } : undefined, + }); + } else if (network === "grpc") { + const grpc = asRecord(stream.grpcSettings); + proxy["grpc-opts"] = stripUndefined({ + "grpc-service-name": asString(grpc?.serviceName), + }); + } else if (network === "httpupgrade") { + const { path, host } = getPathAndHost(stream.httpupgradeSettings); + proxy["httpupgrade-opts"] = stripUndefined({ + path, + headers: host ? { Host: host } : undefined, + }); + } else if (network === "xhttp") { + const { path, host } = getPathAndHost(stream.xhttpSettings); + proxy["xhttp-opts"] = stripUndefined({ + path, + headers: host ? { Host: host } : undefined, + }); + } +} + +function buildClashProxy(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) { + const protocol = nodeClient.inbound.protocol.toLowerCase(); + const stream = getStream(nodeClient); + const settings = getSettings(nodeClient); + const security = getStreamSecurity(stream, target); + const base: JsonRecord = { + name: getDisplayName(nodeClient, target), + server: target.address, + port: target.port, + udp: true, + }; + + let proxy: JsonRecord | null = null; + if (protocol === "vmess") { + proxy = { + ...base, + type: "vmess", + uuid: asString(client?.id) ?? nodeClient.uuid, + alterId: asNumber(settings.alterId) ?? 0, + cipher: getVmessSecurity(settings, client), + }; + } else if (protocol === "vless") { + proxy = stripUndefined({ + ...base, + type: "vless", + uuid: asString(client?.id) ?? nodeClient.uuid, + flow: getVlessFlow(settings, client), + }); + } else if (protocol === "trojan") { + proxy = { + ...base, + type: "trojan", + password: asString(client?.password) ?? nodeClient.uuid, + }; + } else if (protocol === "shadowsocks") { + const method = asString(settings.method) ?? asString(client?.method) ?? "chacha20-ietf-poly1305"; + const inboundPassword = asString(settings.password) ?? asString(settings.serverKey); + const clientPassword = asString(client?.password) ?? nodeClient.uuid; + proxy = { + ...base, + type: "ss", + cipher: method, + password: method.startsWith("2022-") && inboundPassword ? `${inboundPassword}:${clientPassword}` : clientPassword, + }; + } else if (protocol === "hysteria2") { + const obfsPassword = findSalamanderPassword(stream); + proxy = stripUndefined({ + ...base, + type: asNumber(settings.version) === 1 ? "hysteria" : "hysteria2", + password: asString(client?.auth) ?? nodeClient.uuid, + obfs: obfsPassword ? "salamander" : undefined, + "obfs-password": obfsPassword, + }); + } + + if (!proxy) return null; + applyClashTransport(proxy, stream); + applyClashTls(proxy, stream, security); + return stripUndefined(proxy); +} + +function dedupeProxyNames(proxies: JsonRecord[]) { + const seen = new Map(); + return proxies.map((proxy) => { + const name = asString(proxy.name) ?? "J-Board"; + const count = seen.get(name) ?? 0; + seen.set(name, count + 1); + return count === 0 ? proxy : { ...proxy, name: `${name} ${count + 1}` }; + }); +} + +export function buildClashSubscriptionYaml(nodeClients: ProxyNodeContext[]): string { + const proxies = dedupeProxyNames( + nodeClients.flatMap((nodeClient) => { + const client = findClient(nodeClient); + return getTargets(nodeClient) + .map((target) => buildClashProxy(nodeClient, target, client)) + .filter((item): item is JsonRecord => item != null); + }), + ); + const proxyNames = proxies.map((proxy) => asString(proxy.name) ?? "J-Board"); + const selectableNames = proxyNames.length > 0 ? proxyNames : ["DIRECT"]; + const config = { + "mixed-port": 7890, + "allow-lan": false, + mode: "rule", + "log-level": "info", + ipv6: true, + proxies, + "proxy-groups": [ + { + name: "节点选择", + type: "select", + proxies: ["自动选择", ...selectableNames, "DIRECT"], + }, + { + name: "自动选择", + type: "url-test", + proxies: selectableNames, + url: "https://www.gstatic.com/generate_204", + interval: 300, + }, + ], + rules: ["MATCH,节点选择"], + }; + + return `${toYaml(config)}\n`; +} + function getVmessSecurity(settings: JsonRecord, client: PanelClient | null) { return asString(client?.security) ?? firstClientValue(settings, "security") ?? "auto"; } @@ -627,13 +947,35 @@ export async function generateSingleNodeUri(subscriptionId: string): Promise { - const uri = await generateSingleNodeUri(subscriptionId); +function encodeSubscriptionContent(uri: string, format: SubscriptionOutputFormat) { if (!uri) return ""; - return Buffer.from(uri).toString("base64"); + return format === "base64" ? Buffer.from(uri).toString("base64") : uri; } -export async function generateAggregateSubscriptionContent(userId: string): Promise { +export async function generateSubscriptionContent( + subscriptionId: string, + format: SubscriptionOutputFormat = "base64", +): Promise { + const sub = await prisma.userSubscription.findUniqueOrThrow({ + where: { id: subscriptionId }, + include: { + nodeClient: { + include: { + inbound: { include: { server: true } }, + }, + }, + }, + }); + + if (sub.status !== "ACTIVE" || !sub.nodeClient) return ""; + if (format === "clash") return buildClashSubscriptionYaml([sub.nodeClient]); + return encodeSubscriptionContent(buildSingleNodeUri(sub.nodeClient), format); +} + +export async function generateAggregateSubscriptionContent( + userId: string, + format: SubscriptionOutputFormat = "base64", +): Promise { const subscriptions = await prisma.userSubscription.findMany({ where: { userId, @@ -652,10 +994,16 @@ export async function generateAggregateSubscriptionContent(userId: string): Prom orderBy: [{ endDate: "asc" }, { createdAt: "asc" }], }); - const content = subscriptions - .map((subscription) => subscription.nodeClient ? buildSingleNodeUri(subscription.nodeClient) : "") + const nodeClients = subscriptions + .map((subscription) => subscription.nodeClient) + .filter((nodeClient): nodeClient is NonNullable => nodeClient != null); + + if (format === "clash") return buildClashSubscriptionYaml(nodeClients); + + const content = nodeClients + .map((nodeClient) => buildSingleNodeUri(nodeClient)) .filter(Boolean) .join("\n"); - return content ? Buffer.from(content).toString("base64") : ""; + return encodeSubscriptionContent(content, format); }