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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user