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:
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 { prisma } from "@/lib/prisma";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { getClientIp } from "@/lib/request-context";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
import { isSmtpConfigured, normalizeEmailAddress, sendPasswordResetEmail, sendRegistrationVerificationEmail, consumePasswordResetToken, verifyEmailToken } from "@/services/email";
|
||||
|
||||
@@ -22,13 +23,6 @@ const resetPasswordSchema = z.object({
|
||||
confirmPassword: z.string().min(6, "确认密码至少 6 位"),
|
||||
});
|
||||
|
||||
type HeaderList = Awaited<ReturnType<typeof headers>>;
|
||||
|
||||
function getClientIp(headerList: HeaderList) {
|
||||
return headerList.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
|| headerList.get("x-real-ip")?.trim()
|
||||
|| "unknown";
|
||||
}
|
||||
|
||||
async function requestEmailContext(action: string, email: string) {
|
||||
const headerList = await headers();
|
||||
|
||||
@@ -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." },
|
||||
],
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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 { TrafficLogList } from "@/components/subscriptions/traffic-log-list";
|
||||
import { getAdminSubscriptionDetail } from "./subscription-detail-data";
|
||||
import { AdminSubscriptionActions } from "../subscription-actions";
|
||||
import { SubscriptionAccessRiskSection } from "./_components/subscription-access-risk-section";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "订阅详情",
|
||||
@@ -23,7 +25,7 @@ export default async function AdminSubscriptionDetailPage({
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { subscription, auditLogs, trafficLogs } = data;
|
||||
const { subscription, auditLogs, trafficLogs, accessLogs, riskEvents, streamingServices } = data;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -31,8 +33,22 @@ export default async function AdminSubscriptionDetailPage({
|
||||
eyebrow="订阅详情"
|
||||
title={subscription.plan.name}
|
||||
description={subscription.user.email}
|
||||
actions={
|
||||
<AdminSubscriptionActions
|
||||
subscriptionId={subscription.id}
|
||||
status={subscription.status}
|
||||
type={subscription.plan.type}
|
||||
streamingServices={streamingServices}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionDetailCards subscription={subscription} showClientEmail />
|
||||
<SubscriptionAccessRiskSection
|
||||
accessLogs={accessLogs}
|
||||
riskEvents={riskEvents}
|
||||
owner={subscription.user}
|
||||
subscription={subscription}
|
||||
/>
|
||||
<SubscriptionTimelineSection logs={auditLogs} />
|
||||
<TrafficLogList logs={trafficLogs} />
|
||||
</PageShell>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CreditCard,
|
||||
Activity,
|
||||
Waypoints,
|
||||
ShieldAlert,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Megaphone,
|
||||
@@ -29,6 +30,7 @@ export const adminLinks: SidebarLink[] = [
|
||||
{ href: "/admin/users", label: "用户管理", icon: <Users size={16} /> },
|
||||
{ href: "/admin/orders", label: "订单管理", icon: <ClipboardList size={16} /> },
|
||||
{ href: "/admin/subscriptions", label: "订阅管理", icon: <Waypoints size={16} /> },
|
||||
{ href: "/admin/subscription-risk", label: "订阅风控", icon: <ShieldAlert size={16} /> },
|
||||
{ href: "/admin/payments", label: "支付配置", icon: <CreditCard size={16} /> },
|
||||
{ href: "/admin/traffic", label: "流量监控", icon: <Activity size={16} /> },
|
||||
{ href: "/admin/tasks", label: "任务中心", icon: <ListChecks size={16} /> },
|
||||
@@ -46,21 +48,21 @@ export const adminNavGroups: SidebarGroup[] = [
|
||||
},
|
||||
{
|
||||
label: "商品与订单",
|
||||
links: [adminLinks[1], adminLinks[2], adminLinks[3], adminLinks[6], adminLinks[7], adminLinks[8]],
|
||||
links: [adminLinks[1], adminLinks[2], adminLinks[3], adminLinks[6], adminLinks[7], adminLinks[8], adminLinks[9]],
|
||||
},
|
||||
{
|
||||
label: "基础设施",
|
||||
links: [adminLinks[4], adminLinks[9], adminLinks[10], adminLinks[11]],
|
||||
links: [adminLinks[4], adminLinks[10], adminLinks[11], adminLinks[12]],
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
{
|
||||
label: "用户支持",
|
||||
links: [adminLinks[5], adminLinks[12], adminLinks[13]],
|
||||
links: [adminLinks[5], adminLinks[13], adminLinks[14]],
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
{
|
||||
label: "系统",
|
||||
links: [adminLinks[14], adminLinks[15]],
|
||||
links: [adminLinks[15], adminLinks[16]],
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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