feat: add subscription access risk controls

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

View File

@@ -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>
);
}