feat: add subscription access risk controls

This commit is contained in:
JetSprow
2026-04-29 14:26:25 +10:00
parent a0c1a28f5a
commit 17163286a6
18 changed files with 1886 additions and 27 deletions

View File

@@ -210,6 +210,8 @@ server {
订阅域名套 Cloudflare 时,源站应只允许 Cloudflare 回源或通过 Cloudflare Tunnel 暴露服务,并正确传递 `CF-Connecting-IP` / `X-Forwarded-For`。否则后续订阅访问风控中的真实 IP 可能被直连源站请求伪造。
J-Board 会记录订阅 API 的真实 IP、User-Agent 和可用的地理位置头,并在 24 小时窗口内按城市/省份变化触发风控4 个城市警告、5 个城市暂停2 个省/地区警告、3 个省/地区暂停。管理员可在后台“订阅风控”查看关联用户、订阅、IP、地区统计并将事件标记为待处理、已确认或已解决必要时可人工恢复被暂停的订阅。Cloudflare 场景建议在 Rules -> Settings -> Managed Transforms 开启 Add visitor location headers让回源请求带上 `cf-ipcity``cf-region``cf-region-code` 等字段;未提供城市/省份字段时,系统只记录 IP不会触发地区变化规则。
### 手动 Docker 部署
首次启动:

View File

@@ -35,6 +35,29 @@ enum SubscriptionStatus {
SUSPENDED
}
enum SubscriptionAccessKind {
SINGLE
AGGREGATE
}
enum SubscriptionRiskLevel {
WARNING
SUSPENDED
}
enum SubscriptionRiskReason {
CITY_VARIANCE_WARNING
CITY_VARIANCE_SUSPEND
REGION_VARIANCE_WARNING
REGION_VARIANCE_SUSPEND
}
enum SubscriptionRiskReviewStatus {
OPEN
ACKNOWLEDGED
RESOLVED
}
enum PlanPricingMode {
TRAFFIC_SLIDER
FIXED_PACKAGE
@@ -279,6 +302,62 @@ model UserSubscription {
@@index([status])
}
model SubscriptionAccessLog {
id String @id @default(cuid())
userId String?
subscriptionId String?
kind SubscriptionAccessKind
ip String
userAgent String?
country String?
region String?
regionCode String?
city String?
latitude String?
longitude String?
geoSource String?
allowed Boolean @default(true)
reason String?
createdAt DateTime @default(now())
@@index([subscriptionId, createdAt])
@@index([userId, kind, createdAt])
@@index([ip, createdAt])
@@index([kind, createdAt])
}
model SubscriptionRiskEvent {
id String @id @default(cuid())
userId String?
subscriptionId String?
kind SubscriptionAccessKind
level SubscriptionRiskLevel
reason SubscriptionRiskReason
ip String?
countryCount Int @default(0)
regionCount Int @default(0)
cityCount Int @default(0)
countryKeys Json?
regionKeys Json?
cityKeys Json?
message String
dedupeKey String @unique
windowStartedAt DateTime
reviewStatus SubscriptionRiskReviewStatus @default(OPEN)
reviewNote String?
reviewedAt DateTime?
reviewedById String?
reviewedByEmail String?
createdAt DateTime @default(now())
@@index([subscriptionId, createdAt])
@@index([userId, kind, createdAt])
@@index([level, createdAt])
@@index([reason, createdAt])
@@index([reviewStatus, createdAt])
@@index([reviewedById])
}
model StreamingService {
id String @id @default(cuid())
name String

View File

@@ -0,0 +1,140 @@
"use server";
import { revalidatePath } from "next/cache";
import type { SubscriptionRiskReviewStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { activateSubscription } from "./subscriptions";
const REVIEW_STATUSES = ["OPEN", "ACKNOWLEDGED", "RESOLVED"] as const;
function assertReviewStatus(status: string): asserts status is SubscriptionRiskReviewStatus {
if (!REVIEW_STATUSES.includes(status as SubscriptionRiskReviewStatus)) {
throw new Error("不支持的处理状态");
}
}
function reviewStatusLabel(status: SubscriptionRiskReviewStatus) {
switch (status) {
case "OPEN":
return "待处理";
case "ACKNOWLEDGED":
return "已确认";
case "RESOLVED":
return "已解决";
}
}
function normalizeNote(note: string | null | undefined) {
const value = note?.trim();
return value ? value.slice(0, 1000) : null;
}
function revalidateRiskViews(subscriptionId?: string | null) {
revalidatePath("/admin/subscription-risk");
revalidatePath("/admin/audit-logs");
revalidatePath("/admin/subscriptions");
if (subscriptionId) revalidatePath(`/admin/subscriptions/${subscriptionId}`);
}
async function getRiskTargetLabel(input: {
userId?: string | null;
subscriptionId?: string | null;
}) {
if (input.subscriptionId) {
const subscription = await prisma.userSubscription.findUnique({
where: { id: input.subscriptionId },
select: {
plan: { select: { name: true } },
user: { select: { email: true } },
},
});
if (subscription) return `${subscription.user.email} / ${subscription.plan.name}`;
}
if (input.userId) {
const user = await prisma.user.findUnique({
where: { id: input.userId },
select: { email: true },
});
return user?.email ?? input.userId;
}
return null;
}
export async function updateSubscriptionRiskReview(
eventId: string,
status: SubscriptionRiskReviewStatus,
note?: string,
options: { restoreSubscription?: boolean } = {},
) {
assertReviewStatus(status);
const session = await requireAdmin();
const actor = actorFromSession(session);
const event = await prisma.subscriptionRiskEvent.findUniqueOrThrow({
where: { id: eventId },
select: {
id: true,
userId: true,
subscriptionId: true,
kind: true,
level: true,
reason: true,
message: true,
reviewStatus: true,
},
});
if (options.restoreSubscription) {
if (status !== "RESOLVED") {
throw new Error("只有标记已解决时才能恢复订阅");
}
if (!event.subscriptionId) {
throw new Error("该风控事件没有关联单个订阅,请到订阅详情中逐个恢复");
}
await activateSubscription(event.subscriptionId);
}
const normalizedNote = normalizeNote(note);
const reviewedAt = new Date();
await prisma.subscriptionRiskEvent.update({
where: { id: event.id },
data: {
reviewStatus: status,
reviewNote: normalizedNote,
reviewedAt,
reviewedById: actor.userId ?? null,
reviewedByEmail: actor.email ?? null,
},
});
const targetLabel = await getRiskTargetLabel({
userId: event.userId,
subscriptionId: event.subscriptionId,
});
await recordAuditLog({
actor,
action: "risk.subscription.review",
targetType: event.subscriptionId ? "UserSubscription" : "User",
targetId: event.subscriptionId ?? event.userId ?? event.id,
targetLabel,
message: `将订阅风控事件标记为${reviewStatusLabel(status)}`,
metadata: {
eventId: event.id,
oldReviewStatus: event.reviewStatus,
newReviewStatus: status,
restoreSubscription: options.restoreSubscription === true,
note: normalizedNote,
kind: event.kind,
level: event.level,
reason: event.reason,
},
});
revalidateRiskViews(event.subscriptionId);
return { ok: true };
}

View File

@@ -5,6 +5,7 @@ import { headers } from "next/headers";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/request-context";
import { getAppConfig } from "@/services/app-config";
import { isSmtpConfigured, normalizeEmailAddress, sendPasswordResetEmail, sendRegistrationVerificationEmail, consumePasswordResetToken, verifyEmailToken } from "@/services/email";
@@ -22,13 +23,6 @@ const resetPasswordSchema = z.object({
confirmPassword: z.string().min(6, "确认密码至少 6 位"),
});
type HeaderList = Awaited<ReturnType<typeof headers>>;
function getClientIp(headerList: HeaderList) {
return headerList.get("x-forwarded-for")?.split(",")[0]?.trim()
|| headerList.get("x-real-ip")?.trim()
|| "unknown";
}
async function requestEmailContext(action: string, email: string) {
const headerList = await headers();

View File

@@ -51,6 +51,7 @@ export default async function AuditLogsPage({
{ label: "service.", value: "service." },
{ label: "node.", value: "node." },
{ label: "task.", value: "task." },
{ label: "risk.", value: "risk." },
],
},
]}

View File

@@ -0,0 +1,173 @@
import Link from "next/link";
import type { SubscriptionRiskEvent } from "@prisma/client";
import { DataTableShell } from "@/components/admin/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import {
SubscriptionStatusBadge,
SubscriptionTypeBadge,
UserStatusBadge,
} from "@/components/shared/domain-badges";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { formatDate, formatDateShort } from "@/lib/utils";
import type { SubscriptionRiskEventRow } from "../risk-data";
function kindLabel(kind: SubscriptionRiskEvent["kind"]) {
return kind === "AGGREGATE" ? "总订阅" : "单订阅";
}
function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
switch (reason) {
case "CITY_VARIANCE_WARNING":
return "城市异常警告";
case "CITY_VARIANCE_SUSPEND":
return "城市异常暂停";
case "REGION_VARIANCE_WARNING":
return "省/地区异常警告";
case "REGION_VARIANCE_SUSPEND":
return "省/地区异常暂停";
}
}
function reviewStatusLabel(status: SubscriptionRiskEvent["reviewStatus"]) {
switch (status) {
case "OPEN":
return "待处理";
case "ACKNOWLEDGED":
return "已确认";
case "RESOLVED":
return "已解决";
}
}
function reviewStatusTone(status: SubscriptionRiskEvent["reviewStatus"]): StatusTone {
if (status === "RESOLVED") return "success";
if (status === "ACKNOWLEDGED") return "info";
return "warning";
}
function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.subscription) {
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone="info">{kindLabel(event.kind)}</StatusBadge>
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
);
}
return (
<div className="space-y-1">
<Link href={`/admin/subscriptions/${event.subscription.id}`} className="font-medium hover:underline">
{event.subscription.plan.name}
</Link>
<div className="flex flex-wrap items-center gap-2">
<SubscriptionTypeBadge type={event.subscription.plan.type} />
<SubscriptionStatusBadge status={event.subscription.status} />
</div>
<p className="text-xs text-muted-foreground">{formatDateShort(event.subscription.endDate)}</p>
</div>
);
}
function UserCell({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.user) {
return <span className="text-muted-foreground"></span>;
}
return (
<div className="space-y-1">
<p className="max-w-56 break-all font-medium">{event.user.email}</p>
<p className="max-w-52 break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
<UserStatusBadge status={event.user.status} />
</div>
);
}
export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEventRow[] }) {
return (
<DataTableShell
isEmpty={events.length === 0}
emptyTitle="暂无订阅风控事件"
emptyDescription="订阅链接出现跨城市或跨省份访问异常后,会在这里进入人工跟进队列。"
>
<DataTable aria-label="订阅风控事件" className="min-w-[1180px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>/IP</DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{events.map((event) => (
<DataTableRow key={event.id}>
<DataTableCell className="whitespace-nowrap text-muted-foreground">
{formatDate(event.createdAt)}
</DataTableCell>
<DataTableCell>
<UserCell event={event} />
</DataTableCell>
<DataTableCell>
<EventScope event={event} />
</DataTableCell>
<DataTableCell>
<div className="space-y-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>
{reasonLabel(event.reason)}
</StatusBadge>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">{event.message}</p>
</div>
</DataTableCell>
<DataTableCell>
<div className="space-y-1 text-sm">
<p className="font-mono text-xs">{event.ip || "未知 IP"}</p>
<p className="text-xs text-muted-foreground">
{event.cityCount} · / {event.regionCount} · {event.countryCount}
</p>
</div>
</DataTableCell>
<DataTableCell>
<div className="space-y-2">
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>
{reviewStatusLabel(event.reviewStatus)}
</StatusBadge>
{(event.reviewedByEmail || event.reviewNote) && (
<div className="max-w-52 text-xs leading-5 text-muted-foreground">
{event.reviewedByEmail && <p className="break-all">{event.reviewedByEmail}</p>}
{event.reviewedAt && <p>{formatDate(event.reviewedAt)}</p>}
{event.reviewNote && <p className="mt-1 line-clamp-3 whitespace-pre-wrap text-foreground/70">{event.reviewNote}</p>}
</div>
)}
</div>
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<SubscriptionRiskReviewActions
eventId={event.id}
reviewStatus={event.reviewStatus}
canRestoreSubscription={event.canRestoreSubscription}
/>
</div>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
);
}

View File

@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { AdminFilterBar } from "@/components/admin/filter-bar";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { Pagination } from "@/components/shared/pagination";
import { SubscriptionRiskTable } from "./_components/subscription-risk-table";
import { getSubscriptionRiskEvents } from "./risk-data";
export const metadata: Metadata = {
title: "订阅风控",
description: "查看订阅访问异常、关联用户和人工处理状态。",
};
export default async function AdminSubscriptionRiskPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { events, total, page, pageSize, filters } = await getSubscriptionRiskEvents(await searchParams);
return (
<PageShell>
<PageHeader
eyebrow="商品与订单"
title="订阅风控"
description="订阅链接跨城市或跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
/>
<AdminFilterBar
q={filters.q}
searchPlaceholder="搜索用户邮箱、昵称、套餐、IP、事件说明"
selects={[
{
name: "status",
value: filters.status,
options: [
{ label: "全部处理状态", value: "" },
{ label: "待处理", value: "OPEN" },
{ label: "已确认", value: "ACKNOWLEDGED" },
{ label: "已解决", value: "RESOLVED" },
],
},
{
name: "level",
value: filters.level,
options: [
{ label: "全部风险级别", value: "" },
{ label: "警告", value: "WARNING" },
{ label: "已暂停", value: "SUSPENDED" },
],
},
{
name: "kind",
value: filters.kind,
options: [
{ label: "全部订阅范围", value: "" },
{ label: "单订阅", value: "SINGLE" },
{ label: "总订阅", value: "AGGREGATE" },
],
},
]}
/>
<SubscriptionRiskTable events={events} />
<Pagination total={total} pageSize={pageSize} page={page} />
</PageShell>
);
}

View File

@@ -0,0 +1,153 @@
import type { Prisma, SubscriptionRiskEvent } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
type RiskUser = {
id: string;
email: string;
name: string | null;
status: "ACTIVE" | "DISABLED" | "BANNED";
};
type RiskSubscription = {
id: string;
status: "ACTIVE" | "EXPIRED" | "CANCELLED" | "SUSPENDED";
endDate: Date;
plan: {
name: string;
type: "PROXY" | "STREAMING";
};
user: RiskUser;
};
export type SubscriptionRiskEventRow = SubscriptionRiskEvent & {
user: RiskUser | null;
subscription: RiskSubscription | null;
canRestoreSubscription: boolean;
};
async function searchRelatedIds(q: string) {
if (!q) return { userIds: [] as string[], subscriptionIds: [] as string[] };
const [users, subscriptions] = await Promise.all([
prisma.user.findMany({
where: {
OR: [
{ email: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
],
},
select: { id: true },
take: 100,
}),
prisma.userSubscription.findMany({
where: {
OR: [
{ id: q },
{ user: { email: { contains: q, mode: "insensitive" } } },
{ user: { name: { contains: q, mode: "insensitive" } } },
{ plan: { name: { contains: q, mode: "insensitive" } } },
],
},
select: { id: true },
take: 100,
}),
]);
return {
userIds: users.map((user) => user.id),
subscriptionIds: subscriptions.map((subscription) => subscription.id),
};
}
export async function getSubscriptionRiskEvents(
searchParams: Record<string, string | string[] | undefined>,
) {
const { page, skip, pageSize } = parsePage(searchParams);
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
const level = typeof searchParams.level === "string" ? searchParams.level : "";
const status = typeof searchParams.status === "string" ? searchParams.status : "";
const kind = typeof searchParams.kind === "string" ? searchParams.kind : "";
const { userIds, subscriptionIds } = await searchRelatedIds(q);
const where = {
...(level ? { level: level as "WARNING" | "SUSPENDED" } : {}),
...(status ? { reviewStatus: status as "OPEN" | "ACKNOWLEDGED" | "RESOLVED" } : {}),
...(kind ? { kind: kind as "SINGLE" | "AGGREGATE" } : {}),
...(q
? {
OR: [
{ id: q },
{ userId: q },
{ subscriptionId: q },
{ ip: { contains: q, mode: "insensitive" as const } },
{ message: { contains: q, mode: "insensitive" as const } },
...(userIds.length > 0 ? [{ userId: { in: userIds } }] : []),
...(subscriptionIds.length > 0 ? [{ subscriptionId: { in: subscriptionIds } }] : []),
],
}
: {}),
} satisfies Prisma.SubscriptionRiskEventWhereInput;
const [events, total] = await Promise.all([
prisma.subscriptionRiskEvent.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: pageSize,
}),
prisma.subscriptionRiskEvent.count({ where }),
]);
const eventUserIds = Array.from(new Set(events.map((event) => event.userId).filter(Boolean))) as string[];
const eventSubscriptionIds = Array.from(new Set(events.map((event) => event.subscriptionId).filter(Boolean))) as string[];
const [users, subscriptions] = await Promise.all([
eventUserIds.length > 0
? prisma.user.findMany({
where: { id: { in: eventUserIds } },
select: { id: true, email: true, name: true, status: true },
})
: Promise.resolve([]),
eventSubscriptionIds.length > 0
? prisma.userSubscription.findMany({
where: { id: { in: eventSubscriptionIds } },
select: {
id: true,
status: true,
endDate: true,
plan: { select: { name: true, type: true } },
user: { select: { id: true, email: true, name: true, status: true } },
},
})
: Promise.resolve([]),
]);
const userById = new Map(users.map((user) => [user.id, user]));
const subscriptionById = new Map(subscriptions.map((subscription) => [subscription.id, subscription]));
const now = new Date();
const rows: SubscriptionRiskEventRow[] = events.map((event) => {
const subscription = event.subscriptionId ? subscriptionById.get(event.subscriptionId) ?? null : null;
const user = subscription?.user ?? (event.userId ? userById.get(event.userId) ?? null : null);
return {
...event,
user,
subscription,
canRestoreSubscription: Boolean(
subscription
&& subscription.status === "SUSPENDED"
&& subscription.endDate > now
&& event.reviewStatus !== "RESOLVED",
),
};
});
return {
events: rows,
total,
page,
pageSize,
filters: { q, level, status, kind },
};
}

View File

@@ -0,0 +1,224 @@
import Link from "next/link";
import type {
SubscriptionAccessLog,
SubscriptionRiskEvent,
SubscriptionStatus,
SubscriptionType,
UserStatus,
} from "@prisma/client";
import { AlertTriangle, ShieldCheck, UserRound } from "lucide-react";
import { DataTableShell } from "@/components/admin/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import {
SubscriptionStatusBadge,
SubscriptionTypeBadge,
UserStatusBadge,
} from "@/components/shared/domain-badges";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { buttonVariants } from "@/components/ui/button";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { formatDate, formatDateShort } from "@/lib/utils";
interface RiskOwner {
id: string;
email: string;
name: string | null;
status: UserStatus;
}
interface RiskSubscription {
id: string;
status: SubscriptionStatus;
endDate: Date;
plan: {
name: string;
type: SubscriptionType;
};
}
function formatLocation(item: Pick<SubscriptionAccessLog, "country" | "region" | "regionCode" | "city">) {
const parts = [item.country, item.region || item.regionCode, item.city].filter(Boolean);
return parts.length > 0 ? parts.join(" / ") : "未知";
}
function kindLabel(kind: SubscriptionAccessLog["kind"] | SubscriptionRiskEvent["kind"]) {
return kind === "AGGREGATE" ? "总订阅" : "单订阅";
}
function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
switch (reason) {
case "CITY_VARIANCE_WARNING":
return "城市异常警告";
case "CITY_VARIANCE_SUSPEND":
return "城市异常暂停";
case "REGION_VARIANCE_WARNING":
return "省/地区异常警告";
case "REGION_VARIANCE_SUSPEND":
return "省/地区异常暂停";
}
}
function reviewStatusLabel(status: SubscriptionRiskEvent["reviewStatus"]) {
switch (status) {
case "OPEN":
return "待处理";
case "ACKNOWLEDGED":
return "已确认";
case "RESOLVED":
return "已解决";
}
}
function reviewStatusTone(status: SubscriptionRiskEvent["reviewStatus"]): StatusTone {
if (status === "RESOLVED") return "success";
if (status === "ACKNOWLEDGED") return "info";
return "warning";
}
function canRestoreFromEvent(event: SubscriptionRiskEvent, subscription: RiskSubscription) {
return event.subscriptionId === subscription.id
&& subscription.status === "SUSPENDED"
&& subscription.endDate > new Date();
}
export function SubscriptionAccessRiskSection({
accessLogs,
riskEvents,
owner,
subscription,
}: {
accessLogs: SubscriptionAccessLog[];
riskEvents: SubscriptionRiskEvent[];
owner: RiskOwner;
subscription: RiskSubscription;
}) {
return (
<section className="surface-card space-y-5 rounded-xl p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-5" />
</span>
<div>
<h3 className="text-lg font-semibold tracking-[-0.02em]">访</h3>
<p className="mt-0.5 text-sm text-muted-foreground"> IP</p>
</div>
</div>
<Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}>
</Link>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="rounded-lg border border-border/70 bg-muted/25 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
<UserRound className="size-4 text-primary" />
</div>
<div className="space-y-1 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="break-all font-medium">{owner.email}</span>
<UserStatusBadge status={owner.status} />
</div>
<p className="text-muted-foreground">{owner.name || "未设置昵称"}</p>
<p className="break-all font-mono text-xs text-muted-foreground">{owner.id}</p>
</div>
</div>
<div className="rounded-lg border border-border/70 bg-muted/25 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
<ShieldCheck className="size-4 text-primary" />
</div>
<div className="space-y-1 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{subscription.plan.name}</span>
<SubscriptionTypeBadge type={subscription.plan.type} />
<SubscriptionStatusBadge status={subscription.status} />
</div>
<p className="text-muted-foreground">{formatDateShort(subscription.endDate)}</p>
<p className="break-all font-mono text-xs text-muted-foreground">{subscription.id}</p>
</div>
</div>
</div>
{riskEvents.length > 0 && (
<div className="space-y-2 rounded-lg border border-amber-500/25 bg-amber-500/8 p-3 text-sm">
<div className="flex items-center gap-2 font-semibold text-amber-700 dark:text-amber-300">
<AlertTriangle className="size-4" />
</div>
<div className="grid gap-2">
{riskEvents.map((event) => (
<div key={event.id} className="rounded-md border border-border/50 bg-background/55 p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>{reasonLabel(event.reason)}</StatusBadge>
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
<span className="text-xs text-muted-foreground">{formatDate(event.createdAt)} · {kindLabel(event.kind)}</span>
</div>
<p className="text-sm leading-6 text-foreground/85">{event.message}</p>
{(event.reviewedByEmail || event.reviewNote) && (
<div className="mt-2 rounded-md bg-muted/45 p-2 text-xs leading-5 text-muted-foreground">
{event.reviewedByEmail && <p>{event.reviewedByEmail}{event.reviewedAt ? ` · ${formatDate(event.reviewedAt)}` : ""}</p>}
{event.reviewNote && <p className="mt-1 whitespace-pre-wrap text-foreground/75">{event.reviewNote}</p>}
</div>
)}
<div className="mt-3">
<SubscriptionRiskReviewActions
eventId={event.id}
reviewStatus={event.reviewStatus}
canRestoreSubscription={canRestoreFromEvent(event, subscription)}
/>
</div>
</div>
))}
</div>
</div>
)}
<DataTableShell
isEmpty={accessLogs.length === 0}
emptyTitle="暂无订阅访问记录"
emptyDescription="用户客户端拉取订阅后,这里会显示最近访问 IP 与地区。"
>
<DataTable aria-label="订阅访问记录" className="min-w-[980px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>IP</DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>User-Agent</DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{accessLogs.map((log) => (
<DataTableRow key={log.id}>
<DataTableCell className="text-muted-foreground">{formatDate(log.createdAt)}</DataTableCell>
<DataTableCell>{kindLabel(log.kind)}</DataTableCell>
<DataTableCell className="font-mono text-xs">{log.ip}</DataTableCell>
<DataTableCell>{formatLocation(log)}</DataTableCell>
<DataTableCell>
<StatusBadge tone={log.allowed ? "success" : "warning"}>
{log.allowed ? "已放行" : log.reason || "已拦截"}
</StatusBadge>
</DataTableCell>
<DataTableCell className="max-w-sm truncate text-muted-foreground">
{log.userAgent || "—"}
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
</section>
);
}

View File

@@ -5,6 +5,8 @@ import { SubscriptionDetailCards } from "@/components/subscriptions/subscription
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
import { TrafficLogList } from "@/components/subscriptions/traffic-log-list";
import { getAdminSubscriptionDetail } from "./subscription-detail-data";
import { AdminSubscriptionActions } from "../subscription-actions";
import { SubscriptionAccessRiskSection } from "./_components/subscription-access-risk-section";
export const metadata: Metadata = {
title: "订阅详情",
@@ -23,7 +25,7 @@ export default async function AdminSubscriptionDetailPage({
notFound();
}
const { subscription, auditLogs, trafficLogs } = data;
const { subscription, auditLogs, trafficLogs, accessLogs, riskEvents, streamingServices } = data;
return (
<PageShell>
@@ -31,8 +33,22 @@ export default async function AdminSubscriptionDetailPage({
eyebrow="订阅详情"
title={subscription.plan.name}
description={subscription.user.email}
actions={
<AdminSubscriptionActions
subscriptionId={subscription.id}
status={subscription.status}
type={subscription.plan.type}
streamingServices={streamingServices}
/>
}
/>
<SubscriptionDetailCards subscription={subscription} showClientEmail />
<SubscriptionAccessRiskSection
accessLogs={accessLogs}
riskEvents={riskEvents}
owner={subscription.user}
subscription={subscription}
/>
<SubscriptionTimelineSection logs={auditLogs} />
<TrafficLogList logs={trafficLogs} />
</PageShell>

View File

@@ -34,7 +34,7 @@ export async function getAdminSubscriptionDetail(subscriptionId: string) {
return null;
}
const [auditLogs, trafficLogs] = await Promise.all([
const [auditLogs, trafficLogs, accessLogs, riskEvents, streamingServices] = await Promise.all([
prisma.auditLog.findMany({
where: {
targetType: "UserSubscription",
@@ -52,7 +52,37 @@ export async function getAdminSubscriptionDetail(subscriptionId: string) {
take: 30,
})
: Promise.resolve([]),
prisma.subscriptionAccessLog.findMany({
where: {
OR: [
{ subscriptionId: subscription.id },
{ userId: subscription.userId, kind: "AGGREGATE" },
],
},
orderBy: { createdAt: "desc" },
take: 50,
}),
prisma.subscriptionRiskEvent.findMany({
where: {
OR: [
{ subscriptionId: subscription.id },
{ userId: subscription.userId, kind: "AGGREGATE" },
],
},
orderBy: { createdAt: "desc" },
take: 10,
}),
prisma.streamingService.findMany({
where: { isActive: true },
select: {
id: true,
name: true,
usedSlots: true,
maxSlots: true,
},
orderBy: { name: "asc" },
}),
]);
return { subscription, auditLogs, trafficLogs };
return { subscription, auditLogs, trafficLogs, accessLogs, riskEvents, streamingServices };
}

View File

@@ -5,6 +5,7 @@ import { z } from "zod";
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";
const schema = z.object({
@@ -16,9 +17,7 @@ const schema = z.object({
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| req.headers.get("x-real-ip")?.trim()
|| "unknown";
const ip = getClientIp(req.headers);
const { success, remaining } = await rateLimit(`ratelimit:register:${ip}`, 5, 60);
if (!success) {
return NextResponse.json(

View File

@@ -1,6 +1,17 @@
import { NextResponse } from "next/server";
import { generateSubscriptionContent } from "@/services/subscription";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk";
const SUBSCRIPTION_TOKEN_LIMIT = 60;
const SUBSCRIPTION_IP_LIMIT = 180;
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
function jsonError(message: string, status: number) {
return NextResponse.json({ error: message }, { status });
}
export async function GET(
req: Request,
@@ -9,21 +20,101 @@ export async function GET(
const { id } = await params;
const url = new URL(req.url);
const token = url.searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 401 });
}
const context = getClientRequestContext(req.headers);
const sub = await prisma.userSubscription.findUnique({
where: { id },
select: {
id: true,
userId: true,
downloadToken: true,
status: true,
plan: { select: { type: true } },
},
});
const ipLimit = await rateLimit(
`ratelimit:subscription:ip:${context.ip}`,
SUBSCRIPTION_IP_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!ipLimit.success) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub?.userId,
subscriptionId: sub?.id ?? id,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
if (!token) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub?.userId,
subscriptionId: sub?.id ?? id,
allowed: false,
reason: "missing_token",
});
return jsonError("Missing token", 401);
}
if (!sub || sub.downloadToken !== token) {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub?.userId,
subscriptionId: sub?.id ?? id,
allowed: false,
reason: "invalid_token",
});
return jsonError("Invalid token", 401);
}
const tokenLimit = await rateLimit(
`ratelimit:subscription:single:${sub.id}`,
SUBSCRIPTION_TOKEN_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!tokenLimit.success) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub.userId,
subscriptionId: sub.id,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
if (sub.status !== "ACTIVE") {
return NextResponse.json({ error: "Subscription inactive" }, { status: 403 });
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub.userId,
subscriptionId: sub.id,
allowed: false,
reason: "subscription_inactive",
});
return jsonError("Subscription inactive", 403);
}
const risk = await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub.userId,
subscriptionId: sub.id,
evaluateRisk: sub.plan.type === "PROXY",
});
if (risk.suspended) {
return jsonError("Subscription suspended by risk control", 403);
}
const content = await generateSubscriptionContent(id);
@@ -31,6 +122,7 @@ export async function GET(
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="jboard-sub.txt"`,
"Cache-Control": "no-store",
},
});
}

View File

@@ -3,18 +3,105 @@ import {
generateAggregateSubscriptionContent,
verifyAggregateSubscriptionToken,
} from "@/services/subscription";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk";
const AGGREGATE_TOKEN_LIMIT = 60;
const SUBSCRIPTION_IP_LIMIT = 180;
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
function jsonError(message: string, status: number) {
return NextResponse.json({ error: message }, { status });
}
export async function GET(req: Request) {
const url = new URL(req.url);
const userId = url.searchParams.get("userId") ?? url.searchParams.get("user");
const token = url.searchParams.get("token");
const context = getClientRequestContext(req.headers);
if (!userId || !token) {
return NextResponse.json({ error: "Missing subscription token" }, { status: 401 });
const ipLimit = await rateLimit(
`ratelimit:subscription:ip:${context.ip}`,
SUBSCRIPTION_IP_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!ipLimit.success) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
if (!verifyAggregateSubscriptionToken(userId, token)) {
return NextResponse.json({ error: "Invalid subscription token" }, { status: 401 });
if (!userId || !token) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "missing_subscription_token",
});
return jsonError("Missing subscription token", 401);
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, status: true },
});
if (!user || !verifyAggregateSubscriptionToken(userId, token)) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "invalid_subscription_token",
});
return jsonError("Invalid subscription token", 401);
}
const tokenLimit = await rateLimit(
`ratelimit:subscription:aggregate:${userId}`,
AGGREGATE_TOKEN_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!tokenLimit.success) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
if (user.status !== "ACTIVE") {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "user_inactive",
});
return jsonError("User inactive", 403);
}
const risk = await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
});
if (risk.suspended) {
return jsonError("Subscriptions suspended by risk control", 403);
}
const content = await generateAggregateSubscriptionContent(userId);

View File

@@ -11,6 +11,7 @@ import {
CreditCard,
Activity,
Waypoints,
ShieldAlert,
ScrollText,
Settings,
Megaphone,
@@ -29,6 +30,7 @@ export const adminLinks: SidebarLink[] = [
{ href: "/admin/users", label: "用户管理", icon: <Users size={16} /> },
{ href: "/admin/orders", label: "订单管理", icon: <ClipboardList size={16} /> },
{ href: "/admin/subscriptions", label: "订阅管理", icon: <Waypoints size={16} /> },
{ href: "/admin/subscription-risk", label: "订阅风控", icon: <ShieldAlert size={16} /> },
{ href: "/admin/payments", label: "支付配置", icon: <CreditCard size={16} /> },
{ href: "/admin/traffic", label: "流量监控", icon: <Activity size={16} /> },
{ href: "/admin/tasks", label: "任务中心", icon: <ListChecks size={16} /> },
@@ -46,21 +48,21 @@ export const adminNavGroups: SidebarGroup[] = [
},
{
label: "商品与订单",
links: [adminLinks[1], adminLinks[2], adminLinks[3], adminLinks[6], adminLinks[7], adminLinks[8]],
links: [adminLinks[1], adminLinks[2], adminLinks[3], adminLinks[6], adminLinks[7], adminLinks[8], adminLinks[9]],
},
{
label: "基础设施",
links: [adminLinks[4], adminLinks[9], adminLinks[10], adminLinks[11]],
links: [adminLinks[4], adminLinks[10], adminLinks[11], adminLinks[12]],
defaultCollapsed: true,
},
{
label: "用户支持",
links: [adminLinks[5], adminLinks[12], adminLinks[13]],
links: [adminLinks[5], adminLinks[13], adminLinks[14]],
defaultCollapsed: true,
},
{
label: "系统",
links: [adminLinks[14], adminLinks[15]],
links: [adminLinks[15], adminLinks[16]],
defaultCollapsed: true,
},
];

View File

@@ -0,0 +1,175 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { SubscriptionRiskReviewStatus } from "@prisma/client";
import { CheckCircle2, RotateCcw, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { updateSubscriptionRiskReview } from "@/actions/admin/subscription-risk";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/errors";
interface RiskReviewMode {
status: SubscriptionRiskReviewStatus;
label: string;
title: string;
description: string;
icon: "ack" | "resolve" | "open";
}
const modes: Record<SubscriptionRiskReviewStatus, RiskReviewMode> = {
OPEN: {
status: "OPEN",
label: "重新打开",
title: "重新打开风控事件",
description: "事件会回到待处理状态,便于稍后继续跟进。",
icon: "open",
},
ACKNOWLEDGED: {
status: "ACKNOWLEDGED",
label: "确认跟进",
title: "确认正在处理",
description: "适合先记录已看到、正在核查,暂不恢复或关闭事件。",
icon: "ack",
},
RESOLVED: {
status: "RESOLVED",
label: "标记解决",
title: "标记风控事件已解决",
description: "适合已联系用户、确认误判或已经完成必要处置后关闭事件。",
icon: "resolve",
},
};
function ModeIcon({ icon }: { icon: RiskReviewMode["icon"] }) {
if (icon === "open") return <RotateCcw className="size-4" />;
if (icon === "resolve") return <CheckCircle2 className="size-4" />;
return <ShieldCheck className="size-4" />;
}
export function SubscriptionRiskReviewActions({
eventId,
reviewStatus,
canRestoreSubscription = false,
}: {
eventId: string;
reviewStatus: SubscriptionRiskReviewStatus;
canRestoreSubscription?: boolean;
}) {
const router = useRouter();
const [mode, setMode] = useState<RiskReviewMode | null>(null);
const [note, setNote] = useState("");
const [restoreSubscription, setRestoreSubscription] = useState(false);
const [loading, setLoading] = useState(false);
const availableModes = useMemo(() => {
return [modes.ACKNOWLEDGED, modes.RESOLVED, modes.OPEN].filter((item) => item.status !== reviewStatus);
}, [reviewStatus]);
function openDialog(nextMode: RiskReviewMode) {
setMode(nextMode);
setNote("");
setRestoreSubscription(nextMode.status === "RESOLVED" && canRestoreSubscription);
}
async function submit() {
if (!mode) return;
setLoading(true);
try {
await updateSubscriptionRiskReview(eventId, mode.status, note, {
restoreSubscription: mode.status === "RESOLVED" && restoreSubscription,
});
toast.success("风控事件已更新");
setMode(null);
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "更新风控事件失败"));
} finally {
setLoading(false);
}
}
return (
<>
<div className="flex flex-wrap gap-2">
{availableModes.map((item) => (
<Button
key={item.status}
size="sm"
variant={item.status === "RESOLVED" ? "default" : "outline"}
onClick={() => openDialog(item)}
>
<ModeIcon icon={item.icon} />
{item.label}
</Button>
))}
</div>
<Dialog open={mode != null} onOpenChange={(open) => !loading && !open && setMode(null)}>
<DialogContent className="sm:max-w-lg">
{mode && (
<>
<DialogHeader>
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ModeIcon icon={mode.icon} />
</div>
<DialogTitle>{mode.title}</DialogTitle>
<DialogDescription>{mode.description}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor={`risk-note-${eventId}`}></Label>
<Textarea
id={`risk-note-${eventId}`}
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={1000}
placeholder="例如:已联系用户确认是出差;或确认订阅链接外泄,已重置/暂停处理。"
/>
</div>
{mode.status === "RESOLVED" && canRestoreSubscription && (
<label className="flex items-start gap-3 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-6">
<input
type="checkbox"
className="mt-1"
checked={restoreSubscription}
onChange={(event) => setRestoreSubscription(event.target.checked)}
/>
<span>
<span className="block text-xs text-muted-foreground">
3x-ui
</span>
</span>
</label>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setMode(null)} disabled={loading}>
</Button>
<Button type="button" onClick={() => void submit()} disabled={loading}>
{loading ? "保存中..." : mode.label}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</>
);
}

142
src/lib/request-context.ts Normal file
View File

@@ -0,0 +1,142 @@
import { isIP } from "node:net";
type HeaderReader = Pick<Headers, "get">;
export interface RequestGeoContext {
country: string | null;
region: string | null;
regionCode: string | null;
city: string | null;
latitude: string | null;
longitude: string | null;
source: string | null;
}
export interface ClientRequestContext {
ip: string;
userAgent: string | null;
geo: RequestGeoContext;
}
function firstHeader(headers: HeaderReader, names: string[]) {
for (const name of names) {
const value = headers.get(name)?.split(",")[0]?.trim();
if (value) return value;
}
return null;
}
function decodeHeaderValue(value: string | null) {
if (!value) return null;
const normalized = value.trim();
if (!normalized || normalized.toLowerCase() === "unknown") return null;
try {
return decodeURIComponent(normalized.replace(/\+/g, "%20"));
} catch {
return normalized;
}
}
function stripPort(value: string) {
const trimmed = value.trim().replace(/^"|"$/g, "");
if (trimmed.startsWith("[") && trimmed.includes("]")) {
return trimmed.slice(1, trimmed.indexOf("]"));
}
if (isIP(trimmed)) return trimmed;
const ipv4WithPort = trimmed.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/);
if (ipv4WithPort) return ipv4WithPort[1];
return trimmed;
}
function normalizeIp(value: string | null) {
if (!value) return null;
const candidate = stripPort(value);
if (candidate.startsWith("::ffff:")) {
const ipv4 = candidate.slice(7);
return isIP(ipv4) ? ipv4 : null;
}
return isIP(candidate) ? candidate : null;
}
export function getClientIp(headers: HeaderReader) {
const direct = firstHeader(headers, [
"cf-connecting-ip",
"true-client-ip",
"x-real-ip",
"x-client-ip",
]);
const normalizedDirect = normalizeIp(direct);
if (normalizedDirect) return normalizedDirect;
const forwarded = headers.get("x-forwarded-for");
if (forwarded) {
for (const item of forwarded.split(",")) {
const normalized = normalizeIp(item);
if (normalized) return normalized;
}
}
return "unknown";
}
export function getRequestGeo(headers: HeaderReader): RequestGeoContext {
const country = decodeHeaderValue(firstHeader(headers, [
"cf-ipcountry",
"x-vercel-ip-country",
"x-geo-country",
"cloudfront-viewer-country",
]));
const region = decodeHeaderValue(firstHeader(headers, [
"cf-ipregion",
"cf-region",
"x-vercel-ip-country-region",
"x-geo-region",
"x-real-ip-region",
"x-real-ip-province",
]));
const regionCode = decodeHeaderValue(firstHeader(headers, [
"cf-ipregion-code",
"cf-region-code",
"x-vercel-ip-country-region",
"x-geo-region-code",
]));
const city = decodeHeaderValue(firstHeader(headers, [
"cf-ipcity",
"cf-city",
"x-vercel-ip-city",
"x-geo-city",
"x-real-ip-city",
]));
const latitude = decodeHeaderValue(firstHeader(headers, [
"cf-iplatitude",
"x-geo-latitude",
]));
const longitude = decodeHeaderValue(firstHeader(headers, [
"cf-iplongitude",
"x-geo-longitude",
]));
let source: string | null = null;
if (headers.get("cf-connecting-ip") || headers.get("cf-ipcountry")) {
source = "cloudflare";
} else if (headers.get("x-vercel-ip-country")) {
source = "vercel";
} else if (country || region || city) {
source = "proxy";
}
return { country, region, regionCode, city, latitude, longitude, source };
}
export function getClientRequestContext(headers: HeaderReader): ClientRequestContext {
return {
ip: getClientIp(headers),
userAgent: headers.get("user-agent")?.slice(0, 500) ?? null,
geo: getRequestGeo(headers),
};
}

View File

@@ -0,0 +1,483 @@
import { revalidatePath } from "next/cache";
import type {
Prisma,
SubscriptionAccessKind,
SubscriptionRiskLevel,
SubscriptionRiskReason,
} from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
import type { ClientRequestContext } from "@/lib/request-context";
import { recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
import { createPanelAdapter } from "@/services/node-panel/factory";
const RISK_WINDOW_HOURS = 24;
const CITY_WARNING_COUNT = 4;
const CITY_SUSPEND_COUNT = 5;
const REGION_WARNING_COUNT = 2;
const REGION_SUSPEND_COUNT = 3;
interface RecordSubscriptionAccessInput {
kind: SubscriptionAccessKind;
context: ClientRequestContext;
userId?: string | null;
subscriptionId?: string | null;
allowed?: boolean;
reason?: string | null;
evaluateRisk?: boolean;
}
interface RiskDecision {
level: SubscriptionRiskLevel;
reason: SubscriptionRiskReason;
}
interface RiskEvaluationResult {
warned: boolean;
suspended: boolean;
eventId?: string;
}
function normalizeLocationPart(value: string | null | undefined) {
return value?.trim().toLowerCase() || null;
}
function hasLocationPart(value: string | null | undefined) {
return normalizeLocationPart(value) != null;
}
function addLocationKey(
map: Map<string, string>,
parts: Array<string | null | undefined>,
labelParts: Array<string | null | undefined>,
) {
const normalizedParts = parts.map(normalizeLocationPart).filter(Boolean);
if (normalizedParts.length === 0) return;
const key = normalizedParts.join(":");
const label = labelParts.map((part) => part?.trim()).filter(Boolean).join(" / ");
map.set(key, label || key);
}
function formatKeyPreview(values: string[]) {
if (values.length === 0) return "未知";
const preview = values.slice(0, 5).join("、");
return values.length > 5 ? `${preview}${values.length}` : preview;
}
function getScopeLabel(kind: SubscriptionAccessKind) {
return kind === "AGGREGATE" ? "总订阅" : "单订阅";
}
function riskMessage(options: {
decision: RiskDecision;
kind: SubscriptionAccessKind;
ip: string;
cityCount: number;
regionCount: number;
cityLabels: string[];
regionLabels: string[];
}) {
const scope = getScopeLabel(options.kind);
const locationSummary = options.decision.reason.startsWith("REGION")
? `${options.regionCount} 个省/地区:${formatKeyPreview(options.regionLabels)}`
: `${options.cityCount} 个城市:${formatKeyPreview(options.cityLabels)}`;
if (options.decision.level === "SUSPENDED") {
return `${scope}访问地区异常24 小时内出现 ${locationSummary},最近 IP ${options.ip},已自动暂停。`;
}
return `${scope}访问地区异常24 小时内出现 ${locationSummary},最近 IP ${options.ip},已记录警告。`;
}
function decideRisk(cityCount: number, regionCount: number): RiskDecision | null {
if (regionCount >= REGION_SUSPEND_COUNT) {
return { level: "SUSPENDED", reason: "REGION_VARIANCE_SUSPEND" };
}
if (cityCount >= CITY_SUSPEND_COUNT) {
return { level: "SUSPENDED", reason: "CITY_VARIANCE_SUSPEND" };
}
if (regionCount >= REGION_WARNING_COUNT) {
return { level: "WARNING", reason: "REGION_VARIANCE_WARNING" };
}
if (cityCount >= CITY_WARNING_COUNT) {
return { level: "WARNING", reason: "CITY_VARIANCE_WARNING" };
}
return null;
}
function riskDayKey(date = new Date()) {
return date.toISOString().slice(0, 10);
}
function scopeKey(input: { kind: SubscriptionAccessKind; userId?: string | null; subscriptionId?: string | null }) {
if (input.kind === "SINGLE") return `subscription:${input.subscriptionId}`;
return `aggregate:${input.userId}`;
}
async function getTargetLabel(input: { userId?: string | null; subscriptionId?: string | null }, db: DbClient) {
if (input.subscriptionId) {
const subscription = await db.userSubscription.findUnique({
where: { id: input.subscriptionId },
select: {
plan: { select: { name: true } },
user: { select: { email: true } },
},
});
if (subscription) return `${subscription.user.email} / ${subscription.plan.name}`;
}
if (input.userId) {
const user = await db.user.findUnique({
where: { id: input.userId },
select: { email: true },
});
return user?.email ?? input.userId;
}
return null;
}
function revalidateRiskViews(subscriptionIds: string[] = []) {
revalidatePath("/admin/audit-logs");
revalidatePath("/admin/subscriptions");
revalidatePath("/subscriptions");
revalidatePath("/dashboard");
revalidatePath("/notifications");
for (const id of subscriptionIds) {
revalidatePath(`/admin/subscriptions/${id}`);
revalidatePath(`/subscriptions/${id}`);
}
}
async function disableProxyClient(subscriptionId: string) {
const client = await prisma.nodeClient.findUnique({
where: { subscriptionId },
select: {
id: true,
uuid: true,
inbound: {
select: {
panelInboundId: true,
server: true,
},
},
},
});
if (!client) return null;
if (client.inbound.panelInboundId == null) {
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
}
const adapter = createPanelAdapter(client.inbound.server);
await adapter.login();
await adapter.updateClientEnable(client.inbound.panelInboundId, client.uuid, false);
await prisma.nodeClient.update({
where: { id: client.id },
data: { isEnabled: false },
});
return client.id;
}
async function suspendSubscriptionForRisk(subscriptionId: string, message: string) {
const subscription = await prisma.userSubscription.findUnique({
where: { id: subscriptionId },
include: {
plan: true,
user: true,
},
});
if (!subscription || subscription.status !== "ACTIVE") {
return false;
}
let disableError: string | null = null;
if (subscription.plan.type === "PROXY") {
try {
await disableProxyClient(subscription.id);
} catch (error) {
disableError = error instanceof Error ? error.message : String(error);
}
}
await prisma.userSubscription.update({
where: { id: subscription.id },
data: { status: "SUSPENDED" },
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "ERROR",
title: "订阅已自动暂停",
body: `${subscription.plan.name} 因订阅访问地区异常已被系统暂停,请联系管理员确认。`,
link: `/subscriptions/${subscription.id}`,
dedupeKey: `risk:suspended:${subscription.id}:${riskDayKey()}`,
});
await recordAuditLog({
action: "subscription.auto_suspend",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message,
metadata: {
reason: "subscription_access_risk",
disableProxyClientError: disableError,
},
});
return true;
}
async function suspendScopeForRisk(input: {
kind: SubscriptionAccessKind;
userId?: string | null;
subscriptionId?: string | null;
message: string;
}) {
if (input.kind === "SINGLE" && input.subscriptionId) {
const suspended = await suspendSubscriptionForRisk(input.subscriptionId, input.message);
return suspended ? [input.subscriptionId] : [];
}
if (input.kind === "AGGREGATE" && input.userId) {
const subscriptions = await prisma.userSubscription.findMany({
where: {
userId: input.userId,
status: "ACTIVE",
plan: { type: "PROXY" },
},
select: { id: true },
});
const suspendedIds: string[] = [];
for (const subscription of subscriptions) {
const suspended = await suspendSubscriptionForRisk(subscription.id, input.message);
if (suspended) suspendedIds.push(subscription.id);
}
return suspendedIds;
}
return [];
}
async function createRiskEvent(input: {
kind: SubscriptionAccessKind;
userId?: string | null;
subscriptionId?: string | null;
ip: string;
decision: RiskDecision;
message: string;
windowStartedAt: Date;
countryLabels: string[];
regionLabels: string[];
cityLabels: string[];
db: DbClient;
}) {
const dedupeKey = [
"subscription-risk",
scopeKey(input),
input.decision.reason,
riskDayKey(),
].join(":");
const existing = await input.db.subscriptionRiskEvent.findUnique({
where: { dedupeKey },
});
if (existing) return { event: existing, created: false };
const event = await input.db.subscriptionRiskEvent.create({
data: {
userId: input.userId ?? null,
subscriptionId: input.subscriptionId ?? null,
kind: input.kind,
level: input.decision.level,
reason: input.decision.reason,
ip: input.ip === "unknown" ? null : input.ip,
countryCount: input.countryLabels.length,
regionCount: input.regionLabels.length,
cityCount: input.cityLabels.length,
countryKeys: input.countryLabels as Prisma.InputJsonValue,
regionKeys: input.regionLabels as Prisma.InputJsonValue,
cityKeys: input.cityLabels as Prisma.InputJsonValue,
message: input.message,
dedupeKey,
windowStartedAt: input.windowStartedAt,
},
});
return { event, created: true };
}
async function evaluateSubscriptionRisk(input: {
kind: SubscriptionAccessKind;
userId?: string | null;
subscriptionId?: string | null;
ip: string;
db: DbClient;
}): Promise<RiskEvaluationResult> {
if (!input.userId) return { warned: false, suspended: false };
if (input.kind === "SINGLE" && !input.subscriptionId) return { warned: false, suspended: false };
const windowStartedAt = new Date(Date.now() - RISK_WINDOW_HOURS * 60 * 60 * 1000);
const logs = await input.db.subscriptionAccessLog.findMany({
where: {
allowed: true,
createdAt: { gte: windowStartedAt },
...(input.kind === "SINGLE"
? { kind: "SINGLE", subscriptionId: input.subscriptionId }
: { kind: "AGGREGATE", userId: input.userId }),
},
select: {
country: true,
region: true,
regionCode: true,
city: true,
},
});
const countryMap = new Map<string, string>();
const regionMap = new Map<string, string>();
const cityMap = new Map<string, string>();
for (const log of logs) {
addLocationKey(countryMap, [log.country], [log.country]);
if (hasLocationPart(log.regionCode) || hasLocationPart(log.region)) {
addLocationKey(
regionMap,
[log.country, log.regionCode ?? log.region],
[log.country, log.region ?? log.regionCode],
);
}
if (hasLocationPart(log.city)) {
addLocationKey(
cityMap,
[log.country, log.regionCode ?? log.region, log.city],
[log.country, log.region ?? log.regionCode, log.city],
);
}
}
const countryLabels = Array.from(countryMap.values());
const regionLabels = Array.from(regionMap.values());
const cityLabels = Array.from(cityMap.values());
const decision = decideRisk(cityLabels.length, regionLabels.length);
if (!decision) return { warned: false, suspended: false };
const message = riskMessage({
decision,
kind: input.kind,
ip: input.ip,
cityCount: cityLabels.length,
regionCount: regionLabels.length,
cityLabels,
regionLabels,
});
const { event, created } = await createRiskEvent({
...input,
decision,
message,
windowStartedAt,
countryLabels,
regionLabels,
cityLabels,
db: input.db,
});
const targetLabel = created
? await getTargetLabel({ userId: input.userId, subscriptionId: input.subscriptionId }, input.db)
: null;
if (created) {
await recordAuditLog({
action: decision.level === "SUSPENDED" ? "risk.subscription.suspend" : "risk.subscription.warning",
targetType: input.subscriptionId ? "UserSubscription" : "User",
targetId: input.subscriptionId ?? input.userId ?? null,
targetLabel,
message,
metadata: {
eventId: event.id,
reason: decision.reason,
kind: input.kind,
ip: input.ip,
countryCount: countryLabels.length,
regionCount: regionLabels.length,
cityCount: cityLabels.length,
windowStartedAt: windowStartedAt.toISOString(),
},
}, input.db);
if (input.userId && decision.level === "WARNING") {
await createNotification({
userId: input.userId,
type: "SUBSCRIPTION",
level: "WARNING",
title: "订阅访问异常",
body: "检测到订阅链接在多个地区访问。如果不是你本人操作,请重置订阅访问并联系管理员。",
link: input.subscriptionId ? `/subscriptions/${input.subscriptionId}` : "/subscriptions",
dedupeKey: `risk:warning:${event.id}`,
}, input.db);
}
}
if (decision.level === "SUSPENDED") {
const suspendedIds = await suspendScopeForRisk({
kind: input.kind,
userId: input.userId,
subscriptionId: input.subscriptionId,
message,
});
revalidateRiskViews(suspendedIds);
return { warned: false, suspended: true, eventId: event.id };
}
if (created) revalidateRiskViews(input.subscriptionId ? [input.subscriptionId] : []);
return { warned: true, suspended: false, eventId: event.id };
}
export async function recordSubscriptionAccess(
input: RecordSubscriptionAccessInput,
db: DbClient = prisma,
): Promise<RiskEvaluationResult> {
await db.subscriptionAccessLog.create({
data: {
userId: input.userId ?? null,
subscriptionId: input.subscriptionId ?? null,
kind: input.kind,
ip: input.context.ip,
userAgent: input.context.userAgent,
country: input.context.geo.country,
region: input.context.geo.region,
regionCode: input.context.geo.regionCode,
city: input.context.geo.city,
latitude: input.context.geo.latitude,
longitude: input.context.geo.longitude,
geoSource: input.context.geo.source,
allowed: input.allowed ?? true,
reason: input.reason ?? null,
},
});
if (input.allowed === false || input.evaluateRisk === false) {
return { warned: false, suspended: false };
}
return evaluateSubscriptionRisk({
kind: input.kind,
userId: input.userId,
subscriptionId: input.subscriptionId,
ip: input.context.ip,
db,
});
}