feat: enhance subscription risk review workflow

This commit is contained in:
JetSprow
2026-04-29 16:12:51 +10:00
parent 086934198a
commit 823b31363a
20 changed files with 1866 additions and 138 deletions

View File

@@ -0,0 +1,174 @@
import { ChevronDown, Globe2, MapPin } from "lucide-react";
import { StatusBadge } from "@/components/shared/status-badge";
import { formatDate } from "@/lib/utils";
import type { SubscriptionRiskGeoSummary } from "@/services/subscription-risk-review";
function projectPoint(latitude: number, longitude: number) {
return {
x: ((longitude + 180) / 360) * 360,
y: ((90 - latitude) / 180) * 180,
};
}
function radiusForAccess(count: number) {
return Math.min(7, Math.max(3, 2 + Math.sqrt(count)));
}
function WorldRiskMap({ summary }: { summary: SubscriptionRiskGeoSummary }) {
const points = summary.points.slice(0, 60);
return (
<div className="overflow-hidden rounded-lg border border-border/70 bg-muted/20">
<svg
viewBox="0 0 360 180"
className="h-48 w-full"
role="img"
aria-label="订阅访问 IP 世界地图分布"
>
<defs>
<linearGradient id="risk-map-water" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="var(--muted)" stopOpacity="0.68" />
<stop offset="100%" stopColor="var(--card)" stopOpacity="0.94" />
</linearGradient>
</defs>
<rect width="360" height="180" rx="12" fill="url(#risk-map-water)" />
{[-120, -60, 0, 60, 120].map((longitude) => {
const x = ((longitude + 180) / 360) * 360;
return <line key={longitude} x1={x} x2={x} y1="12" y2="168" stroke="var(--border)" strokeDasharray="2 5" strokeOpacity="0.7" />;
})}
{[-45, 0, 45].map((latitude) => {
const y = ((90 - latitude) / 180) * 180;
return <line key={latitude} x1="12" x2="348" y1={y} y2={y} stroke="var(--border)" strokeDasharray="2 5" strokeOpacity="0.7" />;
})}
<g fill="var(--card)" stroke="var(--border)" strokeWidth="1" opacity="0.92">
<path d="M39 50 61 35 91 39 116 57 106 78 82 80 72 106 54 97 36 69Z" />
<path d="M86 93 105 103 113 129 100 158 82 144 75 118Z" />
<path d="M132 47 160 36 191 41 215 35 251 48 286 58 303 79 276 95 246 89 220 102 190 91 169 98 144 82Z" />
<path d="M174 89 198 98 208 126 195 158 172 139 158 107Z" />
<path d="M281 116 306 110 326 126 316 147 289 145 270 130Z" />
<path d="M295 72 330 69 342 83 325 94 300 89Z" />
<path d="M126 33 146 26 168 32 151 42Z" />
</g>
<g>
{points.map((point) => {
const { x, y } = projectPoint(point.latitude, point.longitude);
return (
<g key={point.key}>
<title>{point.ip + " / " + point.country + " / " + point.region + " / " + point.city}</title>
<circle
cx={x}
cy={y}
r={radiusForAccess(point.accessCount) + 2}
fill={point.allowed ? "var(--primary)" : "var(--destructive)"}
opacity="0.16"
/>
<circle
cx={x}
cy={y}
r={radiusForAccess(point.accessCount)}
fill={point.allowed ? "var(--primary)" : "var(--destructive)"}
stroke="var(--card)"
strokeWidth="1.5"
/>
</g>
);
})}
</g>
</svg>
<div className="flex items-center justify-between border-t border-border/60 px-3 py-2 text-xs text-muted-foreground">
<span>{points.length > 0 ? "已标注 " + points.length + " 个坐标点" : "没有可用经纬度坐标"}</span>
<span>访</span>
</div>
</div>
);
}
export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionRiskGeoSummary }) {
return (
<details className="group min-w-[24rem] rounded-lg border border-border/70 bg-muted/20 text-sm">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 [&::-webkit-details-marker]:hidden">
<span className="flex min-w-0 items-center gap-2">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background text-primary">
<Globe2 className="size-4" />
</span>
<span className="min-w-0">
<span className="block font-medium"> IP </span>
<span className="block truncate text-xs text-muted-foreground">
{summary.uniqueCountryCount} / {summary.uniqueRegionCount} / {summary.uniqueCityCount} / {summary.uniqueIpCount} IP
</span>
</span>
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div className="border-t border-border/60 p-3">
<div className="grid gap-3 xl:grid-cols-[minmax(20rem,1.1fr)_minmax(16rem,0.9fr)]">
<WorldRiskMap summary={summary} />
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className="rounded-md border border-border/60 bg-background/70 p-2">
<p className="text-xs text-muted-foreground">访</p>
<p className="mt-1 font-mono text-base font-semibold">{summary.totalLogs}</p>
</div>
<div className="rounded-md border border-border/60 bg-background/70 p-2">
<p className="text-xs text-muted-foreground"> IP</p>
<p className="mt-1 font-mono text-base font-semibold">{summary.uniqueIpCount}</p>
</div>
</div>
<div className="max-h-44 space-y-2 overflow-auto pr-1">
{summary.countries.length === 0 ? (
<p className="rounded-md border border-dashed border-border/70 p-3 text-xs text-muted-foreground">
GeoIP 访 IP
</p>
) : (
summary.countries.map((country) => (
<div key={country.country} className="rounded-md border border-border/60 bg-background/70 p-2">
<div className="flex items-start justify-between gap-2">
<p className="break-words font-medium">{country.country}</p>
<span className="shrink-0 font-mono text-xs text-muted-foreground">{country.accessCount} </span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{country.ipCount} IP · {country.regionCount} / · {country.cityCount}
</p>
{(country.topRegions.length > 0 || country.topCities.length > 0) && (
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{country.topRegions.join("、") || "未识别"}{country.topCities.join("、") || "未识别"}
</p>
)}
</div>
))
)}
</div>
</div>
</div>
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<MapPin className="size-3.5" /> 访
</div>
<div className="grid gap-2 lg:grid-cols-2">
{summary.recentAccesses.length === 0 ? (
<p className="rounded-md border border-dashed border-border/70 p-3 text-xs text-muted-foreground">访</p>
) : (
summary.recentAccesses.slice(0, 6).map((access) => (
<div key={access.id} className="min-w-0 rounded-md border border-border/60 bg-background/70 p-2 text-xs leading-5">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono">{access.ip}</span>
<StatusBadge tone={access.allowed ? "success" : "warning"}>
{access.allowed ? "放行" : access.reason || "拦截"}
</StatusBadge>
</div>
<p className="mt-1 break-words text-muted-foreground">{access.location}</p>
<p className="text-muted-foreground">{formatDate(access.createdAt)}</p>
{access.userAgent && <p className="mt-1 truncate text-muted-foreground">{access.userAgent}</p>}
</div>
))
)}
</div>
</div>
</div>
</details>
);
}

View File

@@ -17,6 +17,7 @@ import {
} from "@/components/shared/domain-badges";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details";
import { formatDate, formatDateShort } from "@/lib/utils";
import type { SubscriptionRiskEventRow } from "../risk-data";
@@ -58,6 +59,17 @@ function reviewStatusTone(status: SubscriptionRiskEvent["reviewStatus"]): Status
return "warning";
}
function finalActionLabel(action: SubscriptionRiskEvent["finalAction"]) {
switch (action) {
case "RESTORE_ACCESS":
return "已解除限制";
case "KEEP_RESTRICTED":
return "保持封禁/暂停";
default:
return null;
}
}
function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.subscription) {
return (
@@ -91,7 +103,9 @@ function UserCell({ event }: { event: SubscriptionRiskEventRow }) {
return (
<div className="space-y-1">
<p className="max-w-56 break-all font-medium">{event.user.email}</p>
<Link href={"/admin/users/" + event.user.id} className="block max-w-56 break-all font-medium text-foreground hover:underline">
{event.user.email}
</Link>
<p className="max-w-52 break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
<UserStatusBadge status={event.user.status} />
</div>
@@ -138,11 +152,14 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
</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 className="space-y-2">
<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>
<SubscriptionRiskGeoDetails summary={event.geoSummary} />
</div>
</DataTableCell>
<DataTableCell>
@@ -150,6 +167,17 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>
{reviewStatusLabel(event.reviewStatus)}
</StatusBadge>
{(event.reportSentAt || event.userRestrictionActive || event.finalAction) && (
<div className="flex flex-wrap gap-1.5">
{event.reportSentAt && <StatusBadge tone="info"></StatusBadge>}
{event.userRestrictionActive && <StatusBadge tone="danger"></StatusBadge>}
{finalActionLabel(event.finalAction) && (
<StatusBadge tone={event.finalAction === "RESTORE_ACCESS" ? "success" : "warning"}>
{finalActionLabel(event.finalAction)}
</StatusBadge>
)}
</div>
)}
{(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>}
@@ -165,6 +193,11 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
eventId={event.id}
reviewStatus={event.reviewStatus}
canRestoreSubscription={event.canRestoreSubscription}
restorableSubscriptionCount={event.restorableSubscriptionCount}
riskReport={event.riskReport}
reportSentAt={event.reportSentAt}
userRestrictionActive={event.userRestrictionActive}
finalAction={event.finalAction}
/>
</div>
</DataTableCell>

View File

@@ -1,6 +1,11 @@
import type { Prisma, SubscriptionRiskEvent } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
import {
buildSubscriptionRiskGeoSummary,
getSubscriptionRiskAccessLogsForEvent,
type SubscriptionRiskGeoSummary,
} from "@/services/subscription-risk-review";
type RiskUser = {
id: string;
@@ -24,6 +29,8 @@ export type SubscriptionRiskEventRow = SubscriptionRiskEvent & {
user: RiskUser | null;
subscription: RiskSubscription | null;
canRestoreSubscription: boolean;
restorableSubscriptionCount: number;
geoSummary: SubscriptionRiskGeoSummary;
};
async function searchRelatedIds(q: string) {
@@ -102,7 +109,8 @@ export async function getSubscriptionRiskEvents(
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([
const now = new Date();
const [users, subscriptions, restorableSubscriptions, geoSummaries] = await Promise.all([
eventUserIds.length > 0
? prisma.user.findMany({
where: { id: { in: eventUserIds } },
@@ -121,25 +129,57 @@ export async function getSubscriptionRiskEvents(
},
})
: Promise.resolve([]),
eventUserIds.length > 0
? prisma.userSubscription.findMany({
where: {
userId: { in: eventUserIds },
status: "SUSPENDED",
endDate: { gt: now },
plan: { type: "PROXY" },
},
select: { id: true, userId: true },
})
: Promise.resolve([]),
Promise.all(
events.map(async (event) => {
const logs = await getSubscriptionRiskAccessLogsForEvent(event);
return [event.id, buildSubscriptionRiskGeoSummary(logs)] as const;
}),
),
]);
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 geoSummaryByEventId = new Map(geoSummaries);
const restorableCountByUserId = new Map<string, number>();
for (const subscription of restorableSubscriptions) {
restorableCountByUserId.set(
subscription.userId,
(restorableCountByUserId.get(subscription.userId) ?? 0) + 1,
);
}
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);
const singleRestorable = Boolean(
subscription
&& subscription.status === "SUSPENDED"
&& subscription.endDate > now
&& event.reviewStatus !== "RESOLVED",
);
const aggregateRestorableCount = event.kind === "AGGREGATE" && event.userId
? restorableCountByUserId.get(event.userId) ?? 0
: 0;
const restorableSubscriptionCount = singleRestorable ? 1 : aggregateRestorableCount;
return {
...event,
user,
subscription,
canRestoreSubscription: Boolean(
subscription
&& subscription.status === "SUSPENDED"
&& subscription.endDate > now
&& event.reviewStatus !== "RESOLVED",
),
canRestoreSubscription: restorableSubscriptionCount > 0 && event.reviewStatus !== "RESOLVED",
restorableSubscriptionCount,
geoSummary: geoSummaryByEventId.get(event.id) ?? buildSubscriptionRiskGeoSummary([]),
};
});

View File

@@ -129,7 +129,7 @@ export function SubscriptionAccessRiskSection({
</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>
<Link href={"/admin/users/" + owner.id} className="break-all font-medium hover:underline">{owner.email}</Link>
<UserStatusBadge status={owner.status} />
</div>
<p className="text-muted-foreground">{owner.name || "未设置昵称"}</p>
@@ -179,6 +179,11 @@ export function SubscriptionAccessRiskSection({
eventId={event.id}
reviewStatus={event.reviewStatus}
canRestoreSubscription={canRestoreFromEvent(event, subscription)}
restorableSubscriptionCount={canRestoreFromEvent(event, subscription) ? 1 : 0}
riskReport={event.riskReport}
reportSentAt={event.reportSentAt}
userRestrictionActive={event.userRestrictionActive}
finalAction={event.finalAction}
/>
</div>
</div>

View File

@@ -0,0 +1,260 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
import { DataTableShell } from "@/components/admin/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import {
OrderStatusBadge,
SubscriptionStatusBadge,
SubscriptionTypeBadge,
UserRoleBadge,
UserStatusBadge,
orderKindLabels,
} from "@/components/shared/domain-badges";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import {
SupportTicketPriorityBadge,
SupportTicketStatusBadge,
} from "@/components/support/ticket-badges";
import { formatBytes, formatDate, formatDateShort } from "@/lib/utils";
import { reasonLabel } from "@/services/subscription-risk-review";
import { UserActions } from "../user-actions";
import { getAdminUserDetail } from "./user-detail-data";
export const metadata: Metadata = {
title: "用户详情",
description: "查看用户资料、订阅、订单、工单和风控记录。",
};
function MetricCard({ label, value, hint }: { label: string; value: ReactNode; hint?: ReactNode }) {
return (
<div className="rounded-lg border border-border/70 bg-card p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<div className="mt-2 min-h-7 break-words text-xl font-semibold tracking-[-0.02em]">{value}</div>
{hint && <div className="mt-1 text-xs leading-5 text-muted-foreground">{hint}</div>}
</div>
);
}
function reviewStatusLabel(status: "OPEN" | "ACKNOWLEDGED" | "RESOLVED") {
if (status === "RESOLVED") return "已解决";
if (status === "ACKNOWLEDGED") return "已确认";
return "待处理";
}
function reviewStatusTone(status: "OPEN" | "ACKNOWLEDGED" | "RESOLVED"): StatusTone {
if (status === "RESOLVED") return "success";
if (status === "ACKNOWLEDGED") return "info";
return "warning";
}
export default async function AdminUserDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const data = await getAdminUserDetail(id);
if (!data) {
notFound();
}
const { user, subscriptions, orders, riskEvents, supportTickets } = data;
return (
<PageShell>
<PageHeader
eyebrow="用户详情"
title={user.email}
description={user.name || "未设置昵称"}
actions={<UserActions user={user} />}
/>
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<MetricCard
label="账号状态"
value={(
<span className="flex flex-wrap gap-2">
<UserStatusBadge status={user.status} />
<UserRoleBadge role={user.role} />
</span>
)}
hint={user.emailVerifiedAt ? "邮箱已验证" : "邮箱未验证"}
/>
<MetricCard label="订阅" value={user._count.subscriptions} hint="用户持有的全部订阅" />
<MetricCard label="订单" value={user._count.orders} hint="历史订单数量" />
<MetricCard label="工单" value={user._count.supportTickets} hint="售后沟通记录" />
<MetricCard label="注册时间" value={formatDateShort(user.createdAt)} hint={user.invitedBy ? "邀请人:" + user.invitedBy.email : "非邀请注册或邀请人已删除"} />
</section>
<section className="rounded-lg border border-border/70 bg-card p-4 text-sm leading-6">
<SectionHeader
title="账号资料"
description="用于风控判断时快速确认用户基础信息。"
actions={<Link href={"/admin/subscription-risk?q=" + encodeURIComponent(user.email)} className="text-sm font-medium text-primary hover:underline"></Link>}
/>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground"> ID</p>
<p className="mt-1 break-all font-mono text-xs">{user.id}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 break-all font-mono text-xs">{user.inviteCode || "—"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1">{formatDate(user.createdAt)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1">{formatDate(user.updatedAt)}</p>
</div>
</div>
</section>
<section className="space-y-3">
<SectionHeader title="订阅" description="最近创建的订阅与当前状态。" />
<DataTableShell isEmpty={subscriptions.length === 0} emptyTitle="暂无订阅" emptyDescription="这个用户还没有订阅记录。">
<DataTable aria-label="用户订阅" className="min-w-[820px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{subscriptions.map((subscription) => (
<DataTableRow key={subscription.id}>
<DataTableCell>
<Link href={"/admin/subscriptions/" + subscription.id} className="font-medium hover:underline">
{subscription.plan.name}
</Link>
</DataTableCell>
<DataTableCell><SubscriptionTypeBadge type={subscription.plan.type} /></DataTableCell>
<DataTableCell><SubscriptionStatusBadge status={subscription.status} /></DataTableCell>
<DataTableCell className="text-xs text-muted-foreground">
{formatBytes(subscription.trafficUsed)} / {subscription.trafficLimit ? formatBytes(subscription.trafficLimit) : "不限"}
</DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.endDate)}</DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.createdAt)}</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
</section>
<section className="space-y-3">
<SectionHeader title="近期风控" description="这个用户最近触发的订阅访问风控事件。" />
<DataTableShell isEmpty={riskEvents.length === 0} emptyTitle="暂无风控事件" emptyDescription="目前没有该用户的订阅风控记录。">
<DataTable aria-label="用户风控事件" className="min-w-[760px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>/IP</DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{riskEvents.map((event) => (
<DataTableRow key={event.id}>
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDate(event.createdAt)}</DataTableCell>
<DataTableCell>
<div className="space-y-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>{reasonLabel(event.reason)}</StatusBadge>
<p className="max-w-lg text-xs leading-5 text-muted-foreground">{event.message}</p>
</div>
</DataTableCell>
<DataTableCell className="text-xs text-muted-foreground">
<p className="font-mono text-foreground">{event.ip || "未知 IP"}</p>
<p> {event.cityCount} · / {event.regionCount} · {event.countryCount}</p>
</DataTableCell>
<DataTableCell>
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
</section>
<section className="grid gap-6 xl:grid-cols-2">
<div className="space-y-3">
<SectionHeader title="近期订单" description="最近的购买、续费和增流量订单。" />
<DataTableShell isEmpty={orders.length === 0} emptyTitle="暂无订单" emptyDescription="这个用户还没有订单记录。">
<DataTable aria-label="用户订单" className="min-w-[680px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{orders.map((order) => (
<DataTableRow key={order.id}>
<DataTableCell className="max-w-52 truncate font-medium">{order.plan.name}</DataTableCell>
<DataTableCell>{orderKindLabels[order.kind]}</DataTableCell>
<DataTableCell className="font-mono">¥{Number(order.amount).toFixed(2)}</DataTableCell>
<DataTableCell><OrderStatusBadge status={order.status} /></DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(order.createdAt)}</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
</div>
<div className="space-y-3">
<SectionHeader title="近期工单" description="用户与客服之间的最近沟通。" />
<DataTableShell isEmpty={supportTickets.length === 0} emptyTitle="暂无工单" emptyDescription="这个用户还没有提交工单。">
<DataTable aria-label="用户工单" className="min-w-[680px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{supportTickets.map((ticket) => (
<DataTableRow key={ticket.id}>
<DataTableCell>
<Link href={"/admin/support/" + ticket.id} className="max-w-72 truncate font-medium hover:underline">
{ticket.subject}
</Link>
</DataTableCell>
<DataTableCell><SupportTicketStatusBadge status={ticket.status} /></DataTableCell>
<DataTableCell><SupportTicketPriorityBadge priority={ticket.priority} /></DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(ticket.updatedAt)}</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
</div>
</section>
</PageShell>
);
}

View File

@@ -0,0 +1,82 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
const adminUserDetailInclude = {
invitedBy: {
select: {
id: true,
email: true,
},
},
_count: {
select: {
subscriptions: true,
orders: true,
invitedUsers: true,
supportTickets: true,
notifications: true,
},
},
} satisfies Prisma.UserInclude;
export type AdminUserDetail = Prisma.UserGetPayload<{
include: typeof adminUserDetailInclude;
}>;
export async function getAdminUserDetail(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: adminUserDetailInclude,
});
if (!user) return null;
const [subscriptions, orders, riskEvents, supportTickets] = await Promise.all([
prisma.userSubscription.findMany({
where: { userId: user.id },
select: {
id: true,
status: true,
endDate: true,
trafficUsed: true,
trafficLimit: true,
createdAt: true,
plan: { select: { name: true, type: true } },
},
orderBy: { createdAt: "desc" },
take: 12,
}),
prisma.order.findMany({
where: { userId: user.id },
select: {
id: true,
amount: true,
status: true,
kind: true,
createdAt: true,
plan: { select: { name: true } },
},
orderBy: { createdAt: "desc" },
take: 8,
}),
prisma.subscriptionRiskEvent.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 8,
}),
prisma.supportTicket.findMany({
where: { userId: user.id },
select: {
id: true,
subject: true,
status: true,
priority: true,
updatedAt: true,
},
orderBy: { updatedAt: "desc" },
take: 8,
}),
]);
return { user, subscriptions, orders, riskEvents, supportTickets };
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/lib/auth";
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
export const metadata: Metadata = {
title: {
@@ -26,5 +27,10 @@ export default async function PaymentLayout({
redirect("/admin/dashboard");
}
const restriction = await getActiveSubscriptionRiskRestriction(session.user.id);
if (restriction) {
redirect("/support?riskEventId=" + restriction.id);
}
return children;
}

View File

@@ -8,6 +8,8 @@ import { UserMobileNav } from "@/components/user/mobile-nav";
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
import { getUnreadNotificationCount } from "./notifications/notifications-data";
import { PageTransition } from "@/components/shared/page-transition";
import { SubscriptionRiskRestrictionGate } from "@/components/user/subscription-risk-restriction-gate";
import { getActiveSubscriptionRiskRestriction, reasonLabel } from "@/services/subscription-risk-review";
export const metadata: Metadata = {
title: {
@@ -31,7 +33,20 @@ export default async function UserLayout({
}
const userName = session.user.name || session.user.email || "";
const unreadCount = await getUnreadNotificationCount(session.user.id);
const [unreadCount, activeRestriction] = await Promise.all([
getUnreadNotificationCount(session.user.id),
getActiveSubscriptionRiskRestriction(session.user.id),
]);
const restrictionNotice = activeRestriction
? {
id: activeRestriction.id,
level: activeRestriction.level,
reasonLabel: reasonLabel(activeRestriction.reason),
message: activeRestriction.message,
riskReport: activeRestriction.riskReport,
reportSentAt: activeRestriction.reportSentAt?.toISOString() ?? null,
}
: null;
return (
<div className="flex h-[100dvh] overflow-hidden p-0 md:p-3">
@@ -41,6 +56,7 @@ export default async function UserLayout({
<div className="flex min-w-0 flex-1 flex-col overflow-hidden md:pl-3">
<UserMobileNav userName={userName} unreadCount={unreadCount} />
<main className="flex-1 overflow-auto px-3 py-4 sm:px-5 sm:py-6 md:pt-0 lg:px-7 lg:pb-7">
<SubscriptionRiskRestrictionGate restriction={restrictionNotice} />
<Suspense fallback={null}>
<AnnouncementLoader userId={session.user.id} role="USER" />
</Suspense>

View File

@@ -10,8 +10,22 @@ import { Textarea } from "@/components/ui/textarea";
const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif";
export function CreateSupportTicketForm() {
const [open, setOpen] = useState(false);
type SupportTicketPreset = {
riskEventId?: string;
subject?: string;
category?: string;
priority?: "LOW" | "NORMAL" | "HIGH" | "URGENT";
body?: string;
};
export function CreateSupportTicketForm({
defaultOpen = false,
preset,
}: {
defaultOpen?: boolean;
preset?: SupportTicketPreset;
}) {
const [open, setOpen] = useState(defaultOpen);
if (!open) {
return (
@@ -29,7 +43,8 @@ export function CreateSupportTicketForm() {
className="surface-card space-y-5 rounded-[2rem] p-5 sm:p-6"
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"></h3>
<h3 className="text-lg font-semibold">{preset?.riskEventId ? "订阅风控复核工单" : "新建工单"}</h3>
{!preset?.riskEventId && (
<button
type="button"
onClick={() => setOpen(false)}
@@ -37,18 +52,19 @@ export function CreateSupportTicketForm() {
>
<X className="size-4" />
</button>
)}
</div>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="subject"></Label>
<Input id="subject" name="subject" placeholder="一句话描述遇到的问题" required />
<Input id="subject" name="subject" placeholder="一句话描述遇到的问题" defaultValue={preset?.subject} required />
</div>
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<select
id="priority"
name="priority"
defaultValue="NORMAL"
defaultValue={preset?.priority ?? "NORMAL"}
className="h-11 w-full px-3 text-sm outline-none"
>
<option value="LOW"></option>
@@ -60,11 +76,11 @@ export function CreateSupportTicketForm() {
</div>
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" />
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" defaultValue={preset?.category} />
</div>
<div className="space-y-2">
<Label htmlFor="body"></Label>
<Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" required />
<Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" defaultValue={preset?.body} required />
</div>
<div className="space-y-2">
<Label htmlFor="attachments"> 3 3MB</Label>
@@ -76,6 +92,7 @@ export function CreateSupportTicketForm() {
accept={ATTACHMENT_ACCEPT}
/>
</div>
{preset?.riskEventId && <input type="hidden" name="riskEventId" value={preset.riskEventId} />}
<Button type="submit" size="lg"></Button>
</form>
);

View File

@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { prisma } from "@/lib/prisma";
import { reasonLabel } from "@/services/subscription-risk-review";
import { CreateSupportTicketForm } from "./_components/create-support-ticket-form";
import { UserSupportTicketTable } from "./_components/user-support-ticket-table";
import { getUserSupportTickets } from "./support-data";
@@ -11,9 +13,41 @@ export const metadata: Metadata = {
description: "提交问题并跟踪工单处理进度。",
};
export default async function SupportPage() {
export default async function SupportPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const session = await getServerSession(authOptions);
const tickets = await getUserSupportTickets(session!.user.id);
const resolvedSearchParams = await searchParams;
const riskEventId = typeof resolvedSearchParams.riskEventId === "string" ? resolvedSearchParams.riskEventId : "";
const [tickets, riskEvent] = await Promise.all([
getUserSupportTickets(session!.user.id),
riskEventId
? prisma.subscriptionRiskEvent.findFirst({
where: {
id: riskEventId,
userId: session!.user.id,
reportSentAt: { not: null },
},
select: {
id: true,
reason: true,
message: true,
createdAt: true,
},
})
: Promise.resolve(null),
]);
const preset = riskEvent
? {
riskEventId: riskEvent.id,
subject: "订阅风控复核申请",
category: "订阅风控",
priority: "HIGH" as const,
body: "我需要复核订阅风控限制。\n\n请在这里补充近期访问订阅的设备、所在城市/国家、是否出差或旅行、是否曾分享订阅链接。\n\n系统判定" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message,
}
: undefined;
return (
<PageShell>
@@ -22,7 +56,7 @@ export default async function SupportPage() {
title="需要帮助?"
/>
<CreateSupportTicketForm />
<CreateSupportTicketForm defaultOpen={Boolean(preset)} preset={preset} />
<UserSupportTicketTable tickets={tickets} />
</PageShell>
);

View File

@@ -6,6 +6,7 @@ import { jsonError, jsonOk } from "@/lib/api-response";
import { getPaymentAdapter } from "@/services/payment/factory";
import { rateLimit } from "@/lib/rate-limit";
import { getSiteBaseUrl } from "@/services/site-url";
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
import { v4 as uuidv4 } from "uuid";
const createPaymentSchema = z.object({
@@ -32,6 +33,11 @@ export async function POST(req: Request) {
return jsonError("未登录", { status: 401 });
}
const restriction = await getActiveSubscriptionRiskRestriction(session.user.id);
if (restriction) {
return jsonError("账户存在未处理的订阅风控限制,请先新建工单联系客服", { status: 403 });
}
const { success, remaining } = await rateLimit(
`ratelimit:payment:${session.user.id}`,
5,