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

@@ -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 (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="w-full max-w-md">
<CardContent className="py-10 text-center space-y-5">
<h1 className="text-xl font-semibold tracking-tight"></h1>
<p className="text-sm text-destructive">
{error.message || "页面加载失败,请稍后重试。"}
<p className="text-sm leading-6 text-destructive break-words">
{message}
</p>
<p className="text-xs leading-5 text-muted-foreground">
</p>
<Button onClick={reset} className="h-10"></Button>
</CardContent>

View File

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

View File

@@ -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, "切换套餐上下架状态失败"));
}
}}
>

View File

@@ -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:
<option value="false"></option>
<option value="true"></option>
</select>
<p className="text-xs leading-5 text-muted-foreground"></p>
<p className="text-xs leading-5 text-muted-foreground"></p>
</div>
</div>
</section>

View File

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

View File

@@ -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 (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="w-full max-w-md">
<CardContent className="py-10 text-center space-y-5">
<h1 className="text-xl font-semibold tracking-tight"></h1>
<p className="text-sm text-destructive">
{error.message || "页面加载失败,请稍后重试。"}
<p className="text-sm leading-6 text-destructive break-words">
{message}
</p>
<p className="text-xs leading-5 text-muted-foreground">
</p>
<Button onClick={reset} className="h-10"></Button>
</CardContent>

View File

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

View File

@@ -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 (
<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 className="rounded-2xl border border-border/70 bg-background/70 p-3 backdrop-blur">
<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>
<SubscriptionImportActions genericUrl={subscriptionUrl} clashUrl={clashUrl} />
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border/70 bg-background/65 p-3">
@@ -94,7 +85,7 @@ export function AggregateSubscriptionCard({
</div>
<div className="lg:sticky lg:top-24">
<QrPreview label="订阅二维码" value={subscriptionUrl} alt="订阅链接二维码" />
<QrPreview label="Clash 订阅二维码" value={clashUrl} alt="Clash 订阅链接二维码" />
</div>
</div>
</section>

View File

@@ -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
<div className="flex items-center gap-2 text-sm font-semibold">
<Link2 className="size-4 text-primary" />
</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="min-w-0">
<p className="text-xs font-semibold tracking-[0.14em] text-muted-foreground">SUBSCRIPTION URL</p>
<p className="mt-1 truncate font-mono text-xs text-foreground/82">{subUrl}</p>
</div>
<CopyButton text={subUrl} />
<div className="mt-3">
<SubscriptionImportActions
genericUrl={subUrl}
clashUrl={clashUrl}
title="单节点导入"
description="适合只导入当前节点;多节点日常使用建议复制总订阅链接。"
compact
/>
</div>
<div className="mt-3 flex items-center gap-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
<QrCode className="size-3.5" />
</div>
<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 && (
<QrPreview
label="单节点 URI 二维码"

View File

@@ -60,7 +60,7 @@ export function RenewalButton({
try {
const result = await purchaseRenewal(subscriptionId, renewalDays);
if (!result.ok) {
toast.error(result.error);
toast.error(getErrorMessage(result.error, "创建续费订单失败"));
return;
}
router.push(`/pay/${result.orderId}`);

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

View File

@@ -19,11 +19,11 @@ export async function POST(req: Request) {
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
return NextResponse.json({ error: "请求体不是有效 JSON期望格式{ latencies: [{ carrier, latencyMs }] }" }, { status: 400 });
}
if (!Array.isArray(body.latencies) || body.latencies.length === 0) {
return NextResponse.json({ error: "Missing latencies" }, { status: 400 });
return NextResponse.json({ error: "缺少延迟数据:latencies 必须是非空数组" }, { status: 400 });
}
const validCarriers = new Set(["telecom", "unicom", "mobile"]);

View File

@@ -20,11 +20,11 @@ export async function POST(req: Request) {
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
return NextResponse.json({ error: "请求体不是有效 JSON期望格式{ traces: [{ carrier, hops }] }" }, { status: 400 });
}
if (!Array.isArray(body.traces) || body.traces.length === 0) {
return NextResponse.json({ error: "Missing traces" }, { status: 400 });
return NextResponse.json({ error: "缺少路由追踪数据traces 必须是非空数组" }, { status: 400 });
}
const validCarriers = new Set(["telecom", "unicom", "mobile"]);

View File

@@ -6,16 +6,25 @@ import { getAppConfig } from "@/services/app-config";
import { verifyTurnstile } from "@/lib/turnstile";
import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/request-context";
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
import { isSmtpConfigured, normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
const schema = z.object({
email: z.string().email(),
password: z.string().min(6),
email: z.string().email("邮箱格式不正确"),
password: z.string().min(6, "密码至少需要 6 位"),
name: z.string().optional(),
inviteCode: z.string().optional(),
turnstileToken: z.string().optional(),
});
function formatValidationErrors(error: z.ZodError) {
return error.issues
.map((issue) => {
const field = issue.path.join(".") || "请求体";
return `${field}${issue.message}`;
})
.join("");
}
export async function POST(req: Request) {
const ip = 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;
}

View File

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

View File

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

View File

@@ -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<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: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": 'attachment; filename="jboard-all-sub.txt"',
"Cache-Control": "no-store",
},
headers,
});
}

View File

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