From 17163286a6be181098945ce520946ff3db8f1b91 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Wed, 29 Apr 2026 14:26:25 +1000 Subject: [PATCH] feat: add subscription access risk controls --- README.md | 2 + prisma/schema.prisma | 79 +++ src/actions/admin/subscription-risk.ts | 140 +++++ src/actions/auth/email.ts | 8 +- src/app/(admin)/admin/audit-logs/page.tsx | 1 + .../_components/subscription-risk-table.tsx | 173 +++++++ .../(admin)/admin/subscription-risk/page.tsx | 67 +++ .../admin/subscription-risk/risk-data.ts | 153 ++++++ .../subscription-access-risk-section.tsx | 224 ++++++++ .../(admin)/admin/subscriptions/[id]/page.tsx | 18 +- .../[id]/subscription-detail-data.ts | 34 +- src/app/api/auth/register/route.ts | 5 +- src/app/api/subscription/[id]/route.ts | 104 +++- src/app/api/subscription/all/route.ts | 95 +++- src/components/admin/sidebar.tsx | 10 +- .../subscription-risk-review-actions.tsx | 175 +++++++ src/lib/request-context.ts | 142 +++++ src/services/subscription-risk.ts | 483 ++++++++++++++++++ 18 files changed, 1886 insertions(+), 27 deletions(-) create mode 100644 src/actions/admin/subscription-risk.ts create mode 100644 src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx create mode 100644 src/app/(admin)/admin/subscription-risk/page.tsx create mode 100644 src/app/(admin)/admin/subscription-risk/risk-data.ts create mode 100644 src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx create mode 100644 src/components/subscriptions/subscription-risk-review-actions.tsx create mode 100644 src/lib/request-context.ts create mode 100644 src/services/subscription-risk.ts diff --git a/README.md b/README.md index 32a3f7a..a43c5ac 100644 --- a/README.md +++ b/README.md @@ -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 部署 首次启动: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c3cdea0..f1f9f8b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/actions/admin/subscription-risk.ts b/src/actions/admin/subscription-risk.ts new file mode 100644 index 0000000..e84749a --- /dev/null +++ b/src/actions/admin/subscription-risk.ts @@ -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 }; +} diff --git a/src/actions/auth/email.ts b/src/actions/auth/email.ts index b5fb2f2..f0f1edc 100644 --- a/src/actions/auth/email.ts +++ b/src/actions/auth/email.ts @@ -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>; - -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(); diff --git a/src/app/(admin)/admin/audit-logs/page.tsx b/src/app/(admin)/admin/audit-logs/page.tsx index be98978..72f9f9a 100644 --- a/src/app/(admin)/admin/audit-logs/page.tsx +++ b/src/app/(admin)/admin/audit-logs/page.tsx @@ -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." }, ], }, ]} diff --git a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx new file mode 100644 index 0000000..6a72718 --- /dev/null +++ b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx @@ -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 ( +
+
+ {kindLabel(event.kind)} +
+

按用户总订阅统计

+
+ ); + } + + return ( +
+ + {event.subscription.plan.name} + +
+ + +
+

到期:{formatDateShort(event.subscription.endDate)}

+
+ ); +} + +function UserCell({ event }: { event: SubscriptionRiskEventRow }) { + if (!event.user) { + return 未知用户; + } + + return ( +
+

{event.user.email}

+

{event.user.name || "未设置昵称"}

+ +
+ ); +} + +export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEventRow[] }) { + return ( + + + + + 时间 + 用户 + 范围 + 判定 + 地区/IP + 处理状态 + 人工处理 + + + + {events.map((event) => ( + + + {formatDate(event.createdAt)} + + + + + + + + +
+ + {reasonLabel(event.reason)} + +

{event.message}

+
+
+ +
+

{event.ip || "未知 IP"}

+

+ 城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount} +

+
+
+ +
+ + {reviewStatusLabel(event.reviewStatus)} + + {(event.reviewedByEmail || event.reviewNote) && ( +
+ {event.reviewedByEmail &&

{event.reviewedByEmail}

} + {event.reviewedAt &&

{formatDate(event.reviewedAt)}

} + {event.reviewNote &&

{event.reviewNote}

} +
+ )} +
+
+ +
+ +
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/app/(admin)/admin/subscription-risk/page.tsx b/src/app/(admin)/admin/subscription-risk/page.tsx new file mode 100644 index 0000000..842ca91 --- /dev/null +++ b/src/app/(admin)/admin/subscription-risk/page.tsx @@ -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>; +}) { + const { events, total, page, pageSize, filters } = await getSubscriptionRiskEvents(await searchParams); + + return ( + + + + + + + + + ); +} diff --git a/src/app/(admin)/admin/subscription-risk/risk-data.ts b/src/app/(admin)/admin/subscription-risk/risk-data.ts new file mode 100644 index 0000000..68f87d2 --- /dev/null +++ b/src/app/(admin)/admin/subscription-risk/risk-data.ts @@ -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, +) { + 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 }, + }; +} diff --git a/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx b/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx new file mode 100644 index 0000000..c0f6ee1 --- /dev/null +++ b/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx @@ -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) { + 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 ( +
+
+
+ + + +
+

订阅访问风控

+

记录订阅拉取 IP、地区变化和人工处理状态。

+
+
+ + 查看全部风控 + +
+ +
+
+
+ + 关联用户 +
+
+
+ {owner.email} + +
+

{owner.name || "未设置昵称"}

+

{owner.id}

+
+
+ +
+
+ + 当前订阅 +
+
+
+ {subscription.plan.name} + + +
+

到期:{formatDateShort(subscription.endDate)}

+

{subscription.id}

+
+
+
+ + {riskEvents.length > 0 && ( +
+
+ 最近风控事件 +
+
+ {riskEvents.map((event) => ( +
+
+ {reasonLabel(event.reason)} + {reviewStatusLabel(event.reviewStatus)} + {formatDate(event.createdAt)} · {kindLabel(event.kind)} +
+

{event.message}

+ {(event.reviewedByEmail || event.reviewNote) && ( +
+ {event.reviewedByEmail &&

处理人:{event.reviewedByEmail}{event.reviewedAt ? ` · ${formatDate(event.reviewedAt)}` : ""}

} + {event.reviewNote &&

{event.reviewNote}

} +
+ )} +
+ +
+
+ ))} +
+
+ )} + + + + + + 时间 + 类型 + IP + 地区 + 结果 + User-Agent + + + + {accessLogs.map((log) => ( + + {formatDate(log.createdAt)} + {kindLabel(log.kind)} + {log.ip} + {formatLocation(log)} + + + {log.allowed ? "已放行" : log.reason || "已拦截"} + + + + {log.userAgent || "—"} + + + ))} + + + +
+ ); +} diff --git a/src/app/(admin)/admin/subscriptions/[id]/page.tsx b/src/app/(admin)/admin/subscriptions/[id]/page.tsx index e055829..9c25b3f 100644 --- a/src/app/(admin)/admin/subscriptions/[id]/page.tsx +++ b/src/app/(admin)/admin/subscriptions/[id]/page.tsx @@ -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 ( @@ -31,8 +33,22 @@ export default async function AdminSubscriptionDetailPage({ eyebrow="订阅详情" title={subscription.plan.name} description={subscription.user.email} + actions={ + + } /> + diff --git a/src/app/(admin)/admin/subscriptions/[id]/subscription-detail-data.ts b/src/app/(admin)/admin/subscriptions/[id]/subscription-detail-data.ts index 6f53a3a..7cc0644 100644 --- a/src/app/(admin)/admin/subscriptions/[id]/subscription-detail-data.ts +++ b/src/app/(admin)/admin/subscriptions/[id]/subscription-detail-data.ts @@ -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 }; } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 908f858..b2486d6 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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( diff --git a/src/app/api/subscription/[id]/route.ts b/src/app/api/subscription/[id]/route.ts index b17801d..130db15 100644 --- a/src/app/api/subscription/[id]/route.ts +++ b/src/app/api/subscription/[id]/route.ts @@ -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", }, }); } diff --git a/src/app/api/subscription/all/route.ts b/src/app/api/subscription/all/route.ts index a257450..0895fd3 100644 --- a/src/app/api/subscription/all/route.ts +++ b/src/app/api/subscription/all/route.ts @@ -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); diff --git a/src/components/admin/sidebar.tsx b/src/components/admin/sidebar.tsx index 8996a4b..6010c21 100644 --- a/src/components/admin/sidebar.tsx +++ b/src/components/admin/sidebar.tsx @@ -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: }, { href: "/admin/orders", label: "订单管理", icon: }, { href: "/admin/subscriptions", label: "订阅管理", icon: }, + { href: "/admin/subscription-risk", label: "订阅风控", icon: }, { href: "/admin/payments", label: "支付配置", icon: }, { href: "/admin/traffic", label: "流量监控", icon: }, { href: "/admin/tasks", label: "任务中心", icon: }, @@ -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, }, ]; diff --git a/src/components/subscriptions/subscription-risk-review-actions.tsx b/src/components/subscriptions/subscription-risk-review-actions.tsx new file mode 100644 index 0000000..b2bf9ef --- /dev/null +++ b/src/components/subscriptions/subscription-risk-review-actions.tsx @@ -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 = { + 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 ; + if (icon === "resolve") return ; + return ; +} + +export function SubscriptionRiskReviewActions({ + eventId, + reviewStatus, + canRestoreSubscription = false, +}: { + eventId: string; + reviewStatus: SubscriptionRiskReviewStatus; + canRestoreSubscription?: boolean; +}) { + const router = useRouter(); + const [mode, setMode] = useState(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 ( + <> +
+ {availableModes.map((item) => ( + + ))} +
+ + !loading && !open && setMode(null)}> + + {mode && ( + <> + +
+ +
+ {mode.title} + {mode.description} +
+ +
+
+ +