mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add subscription access risk controls
This commit is contained in:
@@ -210,6 +210,8 @@ server {
|
|||||||
|
|
||||||
订阅域名套 Cloudflare 时,源站应只允许 Cloudflare 回源或通过 Cloudflare Tunnel 暴露服务,并正确传递 `CF-Connecting-IP` / `X-Forwarded-For`。否则后续订阅访问风控中的真实 IP 可能被直连源站请求伪造。
|
订阅域名套 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 部署
|
### 手动 Docker 部署
|
||||||
|
|
||||||
首次启动:
|
首次启动:
|
||||||
|
|||||||
@@ -35,6 +35,29 @@ enum SubscriptionStatus {
|
|||||||
SUSPENDED
|
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 {
|
enum PlanPricingMode {
|
||||||
TRAFFIC_SLIDER
|
TRAFFIC_SLIDER
|
||||||
FIXED_PACKAGE
|
FIXED_PACKAGE
|
||||||
@@ -279,6 +302,62 @@ model UserSubscription {
|
|||||||
@@index([status])
|
@@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 {
|
model StreamingService {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
|||||||
140
src/actions/admin/subscription-risk.ts
Normal file
140
src/actions/admin/subscription-risk.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { headers } from "next/headers";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { getClientIp } from "@/lib/request-context";
|
||||||
import { getAppConfig } from "@/services/app-config";
|
import { getAppConfig } from "@/services/app-config";
|
||||||
import { isSmtpConfigured, normalizeEmailAddress, sendPasswordResetEmail, sendRegistrationVerificationEmail, consumePasswordResetToken, verifyEmailToken } from "@/services/email";
|
import { isSmtpConfigured, normalizeEmailAddress, sendPasswordResetEmail, sendRegistrationVerificationEmail, consumePasswordResetToken, verifyEmailToken } from "@/services/email";
|
||||||
|
|
||||||
@@ -22,13 +23,6 @@ const resetPasswordSchema = z.object({
|
|||||||
confirmPassword: z.string().min(6, "确认密码至少 6 位"),
|
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) {
|
async function requestEmailContext(action: string, email: string) {
|
||||||
const headerList = await headers();
|
const headerList = await headers();
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default async function AuditLogsPage({
|
|||||||
{ label: "service.", value: "service." },
|
{ label: "service.", value: "service." },
|
||||||
{ label: "node.", value: "node." },
|
{ label: "node.", value: "node." },
|
||||||
{ label: "task.", value: "task." },
|
{ label: "task.", value: "task." },
|
||||||
|
{ label: "risk.", value: "risk." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/app/(admin)/admin/subscription-risk/page.tsx
Normal file
67
src/app/(admin)/admin/subscription-risk/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/app/(admin)/admin/subscription-risk/risk-data.ts
Normal file
153
src/app/(admin)/admin/subscription-risk/risk-data.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { SubscriptionDetailCards } from "@/components/subscriptions/subscription
|
|||||||
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
|
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
|
||||||
import { TrafficLogList } from "@/components/subscriptions/traffic-log-list";
|
import { TrafficLogList } from "@/components/subscriptions/traffic-log-list";
|
||||||
import { getAdminSubscriptionDetail } from "./subscription-detail-data";
|
import { getAdminSubscriptionDetail } from "./subscription-detail-data";
|
||||||
|
import { AdminSubscriptionActions } from "../subscription-actions";
|
||||||
|
import { SubscriptionAccessRiskSection } from "./_components/subscription-access-risk-section";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "订阅详情",
|
title: "订阅详情",
|
||||||
@@ -23,7 +25,7 @@ export default async function AdminSubscriptionDetailPage({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subscription, auditLogs, trafficLogs } = data;
|
const { subscription, auditLogs, trafficLogs, accessLogs, riskEvents, streamingServices } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -31,8 +33,22 @@ export default async function AdminSubscriptionDetailPage({
|
|||||||
eyebrow="订阅详情"
|
eyebrow="订阅详情"
|
||||||
title={subscription.plan.name}
|
title={subscription.plan.name}
|
||||||
description={subscription.user.email}
|
description={subscription.user.email}
|
||||||
|
actions={
|
||||||
|
<AdminSubscriptionActions
|
||||||
|
subscriptionId={subscription.id}
|
||||||
|
status={subscription.status}
|
||||||
|
type={subscription.plan.type}
|
||||||
|
streamingServices={streamingServices}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<SubscriptionDetailCards subscription={subscription} showClientEmail />
|
<SubscriptionDetailCards subscription={subscription} showClientEmail />
|
||||||
|
<SubscriptionAccessRiskSection
|
||||||
|
accessLogs={accessLogs}
|
||||||
|
riskEvents={riskEvents}
|
||||||
|
owner={subscription.user}
|
||||||
|
subscription={subscription}
|
||||||
|
/>
|
||||||
<SubscriptionTimelineSection logs={auditLogs} />
|
<SubscriptionTimelineSection logs={auditLogs} />
|
||||||
<TrafficLogList logs={trafficLogs} />
|
<TrafficLogList logs={trafficLogs} />
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function getAdminSubscriptionDetail(subscriptionId: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [auditLogs, trafficLogs] = await Promise.all([
|
const [auditLogs, trafficLogs, accessLogs, riskEvents, streamingServices] = await Promise.all([
|
||||||
prisma.auditLog.findMany({
|
prisma.auditLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
targetType: "UserSubscription",
|
targetType: "UserSubscription",
|
||||||
@@ -52,7 +52,37 @@ export async function getAdminSubscriptionDetail(subscriptionId: string) {
|
|||||||
take: 30,
|
take: 30,
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { getAppConfig } from "@/services/app-config";
|
import { getAppConfig } from "@/services/app-config";
|
||||||
import { verifyTurnstile } from "@/lib/turnstile";
|
import { verifyTurnstile } from "@/lib/turnstile";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { getClientIp } from "@/lib/request-context";
|
||||||
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
|
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -16,9 +17,7 @@ const schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
const ip = getClientIp(req.headers);
|
||||||
|| req.headers.get("x-real-ip")?.trim()
|
|
||||||
|| "unknown";
|
|
||||||
const { success, remaining } = await rateLimit(`ratelimit:register:${ip}`, 5, 60);
|
const { success, remaining } = await rateLimit(`ratelimit:register:${ip}`, 5, 60);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { generateSubscriptionContent } from "@/services/subscription";
|
import { generateSubscriptionContent } from "@/services/subscription";
|
||||||
import { prisma } from "@/lib/prisma";
|
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(
|
export async function GET(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -9,21 +20,101 @@ export async function GET(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const token = url.searchParams.get("token");
|
const token = url.searchParams.get("token");
|
||||||
|
const context = getClientRequestContext(req.headers);
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json({ error: "Missing token" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sub = await prisma.userSubscription.findUnique({
|
const sub = await prisma.userSubscription.findUnique({
|
||||||
where: { id },
|
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) {
|
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") {
|
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);
|
const content = await generateSubscriptionContent(id);
|
||||||
@@ -31,6 +122,7 @@ export async function GET(
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
"Content-Disposition": `attachment; filename="jboard-sub.txt"`,
|
"Content-Disposition": `attachment; filename="jboard-sub.txt"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,105 @@ import {
|
|||||||
generateAggregateSubscriptionContent,
|
generateAggregateSubscriptionContent,
|
||||||
verifyAggregateSubscriptionToken,
|
verifyAggregateSubscriptionToken,
|
||||||
} from "@/services/subscription";
|
} 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) {
|
export async function GET(req: Request) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const userId = url.searchParams.get("userId") ?? url.searchParams.get("user");
|
const userId = url.searchParams.get("userId") ?? url.searchParams.get("user");
|
||||||
const token = url.searchParams.get("token");
|
const token = url.searchParams.get("token");
|
||||||
|
const context = getClientRequestContext(req.headers);
|
||||||
|
|
||||||
if (!userId || !token) {
|
const ipLimit = await rateLimit(
|
||||||
return NextResponse.json({ error: "Missing subscription token" }, { status: 401 });
|
`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)) {
|
if (!userId || !token) {
|
||||||
return NextResponse.json({ error: "Invalid subscription token" }, { status: 401 });
|
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);
|
const content = await generateAggregateSubscriptionContent(userId);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Activity,
|
Activity,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
|
ShieldAlert,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
@@ -29,6 +30,7 @@ export const adminLinks: SidebarLink[] = [
|
|||||||
{ href: "/admin/users", label: "用户管理", icon: <Users size={16} /> },
|
{ href: "/admin/users", label: "用户管理", icon: <Users size={16} /> },
|
||||||
{ href: "/admin/orders", label: "订单管理", icon: <ClipboardList size={16} /> },
|
{ href: "/admin/orders", label: "订单管理", icon: <ClipboardList size={16} /> },
|
||||||
{ href: "/admin/subscriptions", label: "订阅管理", icon: <Waypoints 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/payments", label: "支付配置", icon: <CreditCard size={16} /> },
|
||||||
{ href: "/admin/traffic", label: "流量监控", icon: <Activity size={16} /> },
|
{ href: "/admin/traffic", label: "流量监控", icon: <Activity size={16} /> },
|
||||||
{ href: "/admin/tasks", label: "任务中心", icon: <ListChecks size={16} /> },
|
{ href: "/admin/tasks", label: "任务中心", icon: <ListChecks size={16} /> },
|
||||||
@@ -46,21 +48,21 @@ export const adminNavGroups: SidebarGroup[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "商品与订单",
|
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: "基础设施",
|
label: "基础设施",
|
||||||
links: [adminLinks[4], adminLinks[9], adminLinks[10], adminLinks[11]],
|
links: [adminLinks[4], adminLinks[10], adminLinks[11], adminLinks[12]],
|
||||||
defaultCollapsed: true,
|
defaultCollapsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "用户支持",
|
label: "用户支持",
|
||||||
links: [adminLinks[5], adminLinks[12], adminLinks[13]],
|
links: [adminLinks[5], adminLinks[13], adminLinks[14]],
|
||||||
defaultCollapsed: true,
|
defaultCollapsed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "系统",
|
label: "系统",
|
||||||
links: [adminLinks[14], adminLinks[15]],
|
links: [adminLinks[15], adminLinks[16]],
|
||||||
defaultCollapsed: true,
|
defaultCollapsed: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
142
src/lib/request-context.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
483
src/services/subscription-risk.ts
Normal file
483
src/services/subscription-risk.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user