merge main into dev

This commit is contained in:
JetSprow
2026-04-29 15:32:12 +10:00
36 changed files with 681 additions and 124 deletions

View File

@@ -156,7 +156,7 @@ enum SupportTicketPriority {
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
emailVerifiedAt DateTime? @default(now()) emailVerifiedAt DateTime?
password String password String
name String? name String?
role Role @default(USER) role Role @default(USER)

View File

@@ -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({
@@ -65,15 +66,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) {

View File

@@ -26,7 +26,7 @@ export async function createUser(formData: FormData) {
const data = createUserSchema.parse(Object.fromEntries(formData)); const data = createUserSchema.parse(Object.fromEntries(formData));
const hashed = await bcrypt.hash(data.password, 12); const hashed = await bcrypt.hash(data.password, 12);
const user = await prisma.user.create({ 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({ await recordAuditLog({
actor: actorFromSession(session), actor: actorFromSession(session),

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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, "测试失败"));
} }

View File

@@ -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);
} }
}} }}
> >

View File

@@ -69,7 +69,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);
@@ -96,8 +96,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;
} }
@@ -360,7 +360,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<option value="false"></option> <option value="false"></option>
<option value="true"></option> <option value="true"></option>
</select> </select>
<p className="text-xs leading-5 text-muted-foreground"></p> <p className="text-xs leading-5 text-muted-foreground"></p>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -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);
} }

View File

@@ -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>

View File

@@ -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, "暂时无法确认补位时间"));

View File

@@ -1,10 +1,10 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { zhCN } from "date-fns/locale"; import { zhCN } from "date-fns/locale";
import { CalendarClock, Gauge, Layers3, Link2, Sparkles } from "lucide-react"; import { CalendarClock, Gauge, Layers3, Sparkles } from "lucide-react";
import { CopyButton } from "@/components/shared/copy-button";
import { QrPreview } from "@/components/shared/qr-preview"; import { QrPreview } from "@/components/shared/qr-preview";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { formatBytes } from "@/lib/utils"; import { formatBytes } from "@/lib/utils";
import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions";
interface AggregateSubscriptionCardProps { interface AggregateSubscriptionCardProps {
subscriptionUrl: string; subscriptionUrl: string;
@@ -24,6 +24,7 @@ export function AggregateSubscriptionCard({
const percent = totalLimit const percent = totalLimit
? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100)) ? Math.min(100, Math.round((Number(totalUsed) / Number(totalLimit)) * 100))
: 0; : 0;
const clashUrl = withSubscriptionFormat(subscriptionUrl, "clash");
return ( return (
<section className="relative overflow-hidden rounded-2xl border border-primary/15 bg-[radial-gradient(circle_at_top_left,hsl(var(--primary)/0.16),transparent_34%),linear-gradient(135deg,hsl(var(--card)),hsl(var(--muted)/0.35))] p-5 shadow-sm sm:p-6"> <section className="relative overflow-hidden rounded-2xl border border-primary/15 bg-[radial-gradient(circle_at_top_left,hsl(var(--primary)/0.16),transparent_34%),linear-gradient(135deg,hsl(var(--card)),hsl(var(--muted)/0.35))] p-5 shadow-sm sm:p-6">
@@ -44,17 +45,7 @@ export function AggregateSubscriptionCard({
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur"> <SubscriptionImportActions genericUrl={subscriptionUrl} clashUrl={clashUrl} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<Link2 className="size-3.5" /> SUBSCRIPTION URL
</p>
<p className="mt-1 truncate font-mono text-xs text-foreground/80">{subscriptionUrl}</p>
</div>
<CopyButton text={subscriptionUrl} />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border/70 bg-background/65 p-3"> <div className="rounded-xl border border-border/70 bg-background/65 p-3">
@@ -94,7 +85,7 @@ export function AggregateSubscriptionCard({
</div> </div>
<div className="lg:sticky lg:top-24"> <div className="lg:sticky lg:top-24">
<QrPreview label="订阅二维码" value={subscriptionUrl} alt="订阅链接二维码" /> <QrPreview label="Clash 订阅二维码" value={clashUrl} alt="Clash 订阅链接二维码" />
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,10 +1,10 @@
import { Gauge, Link2, QrCode } from "lucide-react"; import { Gauge, Link2, QrCode } from "lucide-react";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { CopyButton } from "@/components/shared/copy-button";
import { QrPreview } from "@/components/shared/qr-preview"; import { QrPreview } from "@/components/shared/qr-preview";
import { formatBytes } from "@/lib/utils"; import { formatBytes } from "@/lib/utils";
import { buildSingleNodeUri } from "@/services/subscription"; import { buildSingleNodeUri } from "@/services/subscription";
import type { SubscriptionRecord } from "../subscriptions-types"; import type { SubscriptionRecord } from "../subscriptions-types";
import { SubscriptionImportActions, withSubscriptionFormat } from "./subscription-import-actions";
interface ProxySubscriptionDetailsProps { interface ProxySubscriptionDetailsProps {
sub: SubscriptionRecord; sub: SubscriptionRecord;
@@ -18,6 +18,7 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null; const limit = sub.trafficLimit ? Number(sub.trafficLimit) : null;
const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0; const percent = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0;
const subUrl = `${baseUrl}/api/subscription/${sub.id}?token=${sub.downloadToken}`; const subUrl = `${baseUrl}/api/subscription/${sub.id}?token=${sub.downloadToken}`;
const clashUrl = withSubscriptionFormat(subUrl, "clash");
const singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : ""; const singleNodeUri = sub.nodeClient ? buildSingleNodeUri(sub.nodeClient) : "";
return ( return (
@@ -45,18 +46,20 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
<div className="flex items-center gap-2 text-sm font-semibold"> <div className="flex items-center gap-2 text-sm font-semibold">
<Link2 className="size-4 text-primary" /> <Link2 className="size-4 text-primary" />
</div> </div>
<div className="mt-3 flex flex-col gap-3 rounded-2xl border border-border/40 bg-background/45 p-3 sm:flex-row sm:items-center sm:justify-between"> <div className="mt-3">
<div className="min-w-0"> <SubscriptionImportActions
<p className="text-xs font-semibold tracking-[0.14em] text-muted-foreground">SUBSCRIPTION URL</p> genericUrl={subUrl}
<p className="mt-1 truncate font-mono text-xs text-foreground/82">{subUrl}</p> clashUrl={clashUrl}
</div> title="单节点导入"
<CopyButton text={subUrl} /> description="适合只导入当前节点;多节点日常使用建议复制总订阅链接。"
compact
/>
</div> </div>
<div className="mt-3 flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground"> <div className="mt-3 flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<QrCode className="size-3.5" /> <QrCode className="size-3.5" />
</div> </div>
<div className="mt-2 grid gap-3 sm:grid-cols-2"> <div className="mt-2 grid gap-3 sm:grid-cols-2">
<QrPreview label="订阅 URL 二维码" value={subUrl} alt="订阅 URL 二维码" /> <QrPreview label="Clash 订阅二维码" value={clashUrl} alt="Clash 订阅二维码" />
{singleNodeUri && ( {singleNodeUri && (
<QrPreview <QrPreview
label="单节点 URI 二维码" label="单节点 URI 二维码"

View File

@@ -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}`);

View File

@@ -0,0 +1,44 @@
import { Link2 } from "lucide-react";
import { CopyButton } from "@/components/shared/copy-button";
import { cn } from "@/lib/utils";
interface SubscriptionImportActionsProps {
genericUrl: string;
clashUrl: string;
title?: string;
description?: string;
compact?: boolean;
}
export function withSubscriptionFormat(url: string, format: "base64" | "uri" | "clash") {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}format=${format}`;
}
export function SubscriptionImportActions({
genericUrl,
clashUrl,
title = "客户端导入",
description = "复制适配 Clash 的订阅 URL其他客户端可复制通用订阅 URL。",
compact = false,
}: SubscriptionImportActionsProps) {
return (
<div className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur">
<div className={cn("flex flex-col gap-3", compact ? "" : "lg:flex-row lg:items-center lg:justify-between")}>
<div className="min-w-0 space-y-1">
<p className="inline-flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<Link2 className="size-3.5" /> SUBSCRIPTION URL
</p>
<p className="truncate font-mono text-xs text-foreground/80">{genericUrl}</p>
<p className="text-xs leading-5 text-muted-foreground">
<span className="font-semibold text-foreground">{title}</span> · {description}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:flex lg:shrink-0 lg:flex-wrap lg:justify-end">
<CopyButton text={clashUrl} label="复制 Clash" />
<CopyButton text={genericUrl} label="复制通用" />
</div>
</div>
</div>
);
}

View File

@@ -18,7 +18,7 @@ import { getAggregateSubscriptionToken } from "@/services/subscription";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "我的订阅", title: "我的订阅",
description: "管理活跃订阅并查看历史记录。", description: "管理活跃订阅,并复制适配 Clash 与通用客户端的订阅 URL。",
}; };
export default async function SubscriptionsPage() { export default async function SubscriptionsPage() {
@@ -39,7 +39,7 @@ export default async function SubscriptionsPage() {
<PageHeader <PageHeader
eyebrow="订阅管理" eyebrow="订阅管理"
title="我的订阅" title="我的订阅"
description="总订阅链接负责导入全部代理节点;单个节点卡片只保留状态和快捷操作。" description="总订阅链接负责导入全部代理节点;复制 Clash 或通用订阅 URL 后粘贴到客户端。"
/> />
<SubscriptionMetrics <SubscriptionMetrics

View File

@@ -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"]);

View File

@@ -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"]);

View File

@@ -6,16 +6,25 @@ import { getAppConfig } from "@/services/app-config";
import { verifyTurnstile } from "@/lib/turnstile"; import { verifyTurnstile } from "@/lib/turnstile";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/request-context"; import { getClientIp } from "@/lib/request-context";
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email"; import { isSmtpConfigured, 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 = getClientIp(req.headers); const ip = getClientIp(req.headers);
const { success, remaining } = await rateLimit(`ratelimit:register:${ip}`, 5, 60); const { success, remaining } = await rateLimit(`ratelimit:register:${ip}`, 5, 60);
@@ -30,21 +39,28 @@ 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;
const email = normalizeEmailAddress(parsed.data.email); const email = normalizeEmailAddress(parsed.data.email);
const config = await getAppConfig(); const config = await getAppConfig();
if (config.emailVerificationRequired && !isSmtpConfigured(config)) {
return NextResponse.json(
{ error: "注册暂不可用:管理员已开启邮箱验证,但站点尚未配置 SMTP 邮件服务,无法发送验证邮件" },
{ status: 503 },
);
}
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 });
} }
} }
@@ -52,7 +68,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 } });
@@ -64,7 +80,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;
} }

View File

@@ -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 不能为空且长度不超过 128range 只能是 1d、7d 或 30d" },
{ status: 400 },
);
} }
const { ms, bucketMs } = RANGES[range]; const { ms, bucketMs } = RANGES[range];

View File

@@ -1,5 +1,11 @@
import { NextResponse } from "next/server"; 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 { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context"; import { getClientRequestContext } from "@/lib/request-context";
@@ -29,6 +35,9 @@ export async function GET(
userId: true, userId: true,
downloadToken: true, downloadToken: true,
status: true, status: true,
trafficUsed: true,
trafficLimit: true,
endDate: true,
plan: { select: { type: true } }, plan: { select: { type: true } },
}, },
}), }),
@@ -64,7 +73,7 @@ export async function GET(
allowed: false, allowed: false,
reason: "missing_token", reason: "missing_token",
}); });
return jsonError("Missing token", 401); return jsonError("订阅链接缺少 token 参数,请从订阅页面重新复制完整链接", 401);
} }
if (!sub || sub.downloadToken !== token) { if (!sub || sub.downloadToken !== token) {
@@ -76,7 +85,7 @@ export async function GET(
allowed: false, allowed: false,
reason: "invalid_token", reason: "invalid_token",
}); });
return jsonError("Invalid token", 401); return jsonError("订阅 token 无效或已被重置,请在订阅详情页重新复制链接", 401);
} }
if (config.subscriptionRiskEnabled) { if (config.subscriptionRiskEnabled) {
@@ -108,7 +117,7 @@ export async function GET(
allowed: false, allowed: false,
reason: "subscription_inactive", reason: "subscription_inactive",
}); });
return jsonError("Subscription inactive", 403); return jsonError(`订阅当前状态为 ${sub.status},只有 ACTIVE 状态可以拉取配置`, 403);
} }
const risk = await recordSubscriptionAccess({ const risk = await recordSubscriptionAccess({
@@ -124,12 +133,24 @@ export async function GET(
return jsonError("Subscription suspended by risk control", 403); return jsonError("Subscription suspended by risk control", 403);
} }
const content = await generateSubscriptionContent(id); const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
return new Response(content, { const content = await generateSubscriptionContent(id, format);
headers: { const userInfo = buildSubscriptionUserInfo({
"Content-Type": "text/plain; charset=utf-8", upload: 0,
"Content-Disposition": `attachment; filename="jboard-sub.txt"`, 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", "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,
}); });
} }

View File

@@ -1,6 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { import {
buildSubscriptionUserInfo,
generateAggregateSubscriptionContent, generateAggregateSubscriptionContent,
getSubscriptionContentType,
getSubscriptionFilename,
resolveSubscriptionFormat,
verifyAggregateSubscriptionToken, verifyAggregateSubscriptionToken,
} from "@/services/subscription"; } from "@/services/subscription";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -49,7 +53,7 @@ export async function GET(req: Request) {
allowed: false, allowed: false,
reason: "missing_subscription_token", reason: "missing_subscription_token",
}); });
return jsonError("Missing subscription token", 401); return jsonError("总订阅链接缺少 userId 或 token 参数,请从订阅页面重新复制完整链接", 401);
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@@ -65,7 +69,7 @@ export async function GET(req: Request) {
allowed: false, allowed: false,
reason: "invalid_subscription_token", reason: "invalid_subscription_token",
}); });
return jsonError("Invalid subscription token", 401); return jsonError("总订阅 token 无效,请登录后在订阅页面重新复制链接", 401);
} }
if (config.subscriptionRiskEnabled) { if (config.subscriptionRiskEnabled) {
@@ -110,12 +114,47 @@ export async function GET(req: Request) {
return jsonError("Subscriptions suspended by risk control", 403); return jsonError("Subscriptions suspended by risk control", 403);
} }
const content = await generateAggregateSubscriptionContent(userId); const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
return new Response(content, { const activeProxyWhere = {
headers: { userId,
"Content-Type": "text/plain; charset=utf-8", status: "ACTIVE" as const,
"Content-Disposition": 'attachment; filename="jboard-all-sub.txt"', endDate: { gt: new Date() },
"Cache-Control": "no-store", 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<Date | null>((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,
}); });
} }

View File

@@ -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);

View File

@@ -5,7 +5,7 @@ import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
export function CopyButton({ text }: { text: string }) { export function CopyButton({ text, label = "复制" }: { text: string; label?: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
return ( return (
@@ -20,7 +20,7 @@ export function CopyButton({ text }: { text: string }) {
}} }}
> >
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />} {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? "已复制" : "复制"} {copied ? "已复制" : label}
</Button> </Button>
); );
} }

View File

@@ -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 */

View File

@@ -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"));

View File

@@ -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;
} }

View File

@@ -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>(

View File

@@ -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;
} }

View File

@@ -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 面板信息`);

View File

@@ -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(

View File

@@ -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}`);
} }
} }
} }

View File

@@ -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();

View File

@@ -37,6 +37,66 @@ interface LinkTarget {
remark?: string; 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() { function getAggregateSubscriptionSecret() {
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? process.env.DATABASE_URL; const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? process.env.DATABASE_URL;
if (!secret) { if (!secret) {
@@ -439,6 +499,55 @@ function appendQueryAndHash(base: string, params: URLSearchParams, label: string
return `${base}${query ? `?${query}` : ""}#${encodeURIComponent(label)}`; return `${base}${query ? `?${query}` : ""}#${encodeURIComponent(label)}`;
} }
function stripUndefined<T extends JsonRecord>(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[] { function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] {
const stream = getStream(nodeClient); const stream = getStream(nodeClient);
const proxies: LinkTarget[] = []; const proxies: LinkTarget[] = [];
@@ -465,6 +574,217 @@ function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] {
return [{ address: getServerAddress(nodeClient), port: nodeClient.inbound.port }]; 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<string, number>();
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) { function getVmessSecurity(settings: JsonRecord, client: PanelClient | null) {
return asString(client?.security) ?? firstClientValue(settings, "security") ?? "auto"; return asString(client?.security) ?? firstClientValue(settings, "security") ?? "auto";
} }
@@ -627,13 +947,35 @@ export async function generateSingleNodeUri(subscriptionId: string): Promise<str
return buildSingleNodeUri(sub.nodeClient); return buildSingleNodeUri(sub.nodeClient);
} }
export async function generateSubscriptionContent(subscriptionId: string): Promise<string> { function encodeSubscriptionContent(uri: string, format: SubscriptionOutputFormat) {
const uri = await generateSingleNodeUri(subscriptionId);
if (!uri) return ""; 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<string> { export async function generateSubscriptionContent(
subscriptionId: string,
format: SubscriptionOutputFormat = "base64",
): Promise<string> {
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<string> {
const subscriptions = await prisma.userSubscription.findMany({ const subscriptions = await prisma.userSubscription.findMany({
where: { where: {
userId, userId,
@@ -652,10 +994,16 @@ export async function generateAggregateSubscriptionContent(userId: string): Prom
orderBy: [{ endDate: "asc" }, { createdAt: "asc" }], orderBy: [{ endDate: "asc" }, { createdAt: "asc" }],
}); });
const content = subscriptions const nodeClients = subscriptions
.map((subscription) => subscription.nodeClient ? buildSingleNodeUri(subscription.nodeClient) : "") .map((subscription) => subscription.nodeClient)
.filter((nodeClient): nodeClient is NonNullable<typeof nodeClient> => nodeClient != null);
if (format === "clash") return buildClashSubscriptionYaml(nodeClients);
const content = nodeClients
.map((nodeClient) => buildSingleNodeUri(nodeClient))
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
return content ? Buffer.from(content).toString("base64") : ""; return encodeSubscriptionContent(content, format);
} }