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

@@ -60,6 +60,11 @@ enum SubscriptionRiskReviewStatus {
RESOLVED
}
enum SubscriptionRiskFinalAction {
RESTORE_ACCESS
KEEP_RESTRICTED
}
enum PlanPricingMode {
TRAFFIC_SLIDER
FIXED_PACKAGE
@@ -329,28 +334,37 @@ model SubscriptionAccessLog {
}
model SubscriptionRiskEvent {
id String @id @default(cuid())
userId String?
subscriptionId String?
kind SubscriptionAccessKind
level SubscriptionRiskLevel
reason SubscriptionRiskReason
ip String?
countryCount Int @default(0)
regionCount Int @default(0)
cityCount Int @default(0)
countryKeys Json?
regionKeys Json?
cityKeys Json?
message String
dedupeKey String @unique
windowStartedAt DateTime
reviewStatus SubscriptionRiskReviewStatus @default(OPEN)
reviewNote String?
reviewedAt DateTime?
reviewedById String?
reviewedByEmail String?
createdAt DateTime @default(now())
id String @id @default(cuid())
userId String?
subscriptionId String?
kind SubscriptionAccessKind
level SubscriptionRiskLevel
reason SubscriptionRiskReason
ip String?
countryCount Int @default(0)
regionCount Int @default(0)
cityCount Int @default(0)
countryKeys Json?
regionKeys Json?
cityKeys Json?
message String
dedupeKey String @unique
windowStartedAt DateTime
reviewStatus SubscriptionRiskReviewStatus @default(OPEN)
reviewNote String?
reviewedAt DateTime?
reviewedById String?
reviewedByEmail String?
riskReport String?
reportGeneratedAt DateTime?
reportSentAt DateTime?
userRestrictionActive Boolean @default(false)
userRestrictionResolvedAt DateTime?
finalAction SubscriptionRiskFinalAction?
finalActionAt DateTime?
finalActionById String?
finalActionByEmail String?
createdAt DateTime @default(now())
@@index([subscriptionId, createdAt])
@@index([userId, kind, createdAt])
@@ -358,6 +372,8 @@ model SubscriptionRiskEvent {
@@index([reason, createdAt])
@@index([reviewStatus, createdAt])
@@index([reviewedById])
@@index([userRestrictionActive, reportSentAt])
@@index([finalAction, finalActionAt])
}
model StreamingService {

View File

@@ -1,13 +1,24 @@
"use server";
import { revalidatePath } from "next/cache";
import type { SubscriptionRiskReviewStatus } from "@prisma/client";
import type {
SubscriptionRiskFinalAction,
SubscriptionRiskReviewStatus,
} from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
import {
buildSubscriptionRiskReport,
getSubscriptionRiskAccessLogsForEvent,
reasonLabel,
riskKindLabel,
} from "@/services/subscription-risk-review";
import { activateSubscription } from "./subscriptions";
const REVIEW_STATUSES = ["OPEN", "ACKNOWLEDGED", "RESOLVED"] as const;
const FINAL_ACTIONS = ["RESTORE_ACCESS", "KEEP_RESTRICTED"] as const;
function assertReviewStatus(status: string): asserts status is SubscriptionRiskReviewStatus {
if (!REVIEW_STATUSES.includes(status as SubscriptionRiskReviewStatus)) {
@@ -15,6 +26,12 @@ function assertReviewStatus(status: string): asserts status is SubscriptionRiskR
}
}
function assertFinalAction(action: string): asserts action is SubscriptionRiskFinalAction {
if (!FINAL_ACTIONS.includes(action as SubscriptionRiskFinalAction)) {
throw new Error("不支持的最终处置");
}
}
function reviewStatusLabel(status: SubscriptionRiskReviewStatus) {
switch (status) {
case "OPEN":
@@ -26,16 +43,30 @@ function reviewStatusLabel(status: SubscriptionRiskReviewStatus) {
}
}
function finalActionLabel(action: SubscriptionRiskFinalAction) {
switch (action) {
case "RESTORE_ACCESS":
return "解除限制";
case "KEEP_RESTRICTED":
return "保持限制";
}
}
function normalizeNote(note: string | null | undefined) {
const value = note?.trim();
return value ? value.slice(0, 1000) : null;
}
function revalidateRiskViews(subscriptionId?: string | null) {
function revalidateRiskViews(subscriptionId?: string | null, userId?: string | null) {
revalidatePath("/admin/subscription-risk");
revalidatePath("/admin/audit-logs");
revalidatePath("/admin/subscriptions");
revalidatePath("/admin/users");
revalidatePath("/dashboard");
revalidatePath("/support");
revalidatePath("/notifications");
if (subscriptionId) revalidatePath(`/admin/subscriptions/${subscriptionId}`);
if (userId) revalidatePath(`/admin/users/${userId}`);
}
async function getRiskTargetLabel(input: {
@@ -65,6 +96,106 @@ async function getRiskTargetLabel(input: {
return null;
}
async function getRiskEventContext(eventId: string) {
const event = await prisma.subscriptionRiskEvent.findUniqueOrThrow({
where: { id: eventId },
});
const [user, subscription, logs] = await Promise.all([
event.userId
? prisma.user.findUnique({
where: { id: event.userId },
select: { id: true, email: true, name: true, status: true, createdAt: true },
})
: Promise.resolve(null),
event.subscriptionId
? prisma.userSubscription.findUnique({
where: { id: event.subscriptionId },
select: {
id: true,
status: true,
endDate: true,
plan: { select: { name: true, type: true } },
},
})
: Promise.resolve(null),
getSubscriptionRiskAccessLogsForEvent(event),
]);
return { event, user, subscription, logs };
}
async function buildAndSaveRiskReport(eventId: string) {
const { event, user, subscription, logs } = await getRiskEventContext(eventId);
const report = buildSubscriptionRiskReport({ event, user, subscription, logs });
const updated = await prisma.subscriptionRiskEvent.update({
where: { id: event.id },
data: {
riskReport: report,
reportGeneratedAt: new Date(),
},
});
return { event: updated, user, subscription, logs, report };
}
async function notifyUserWithRiskReport(input: {
eventId: string;
userId: string;
}) {
await createNotification({
userId: input.userId,
type: "SUBSCRIPTION",
level: "ERROR",
title: "订阅风控处理通知",
body: "你的订阅访问存在异常地区/IP 记录,账户操作已临时限制。请新建工单联系客服核验。",
link: `/support?riskEventId=${input.eventId}`,
dedupeKey: `risk:report-sent:${input.eventId}`,
});
}
async function restoreSubscriptionsForEvent(event: {
userId: string | null;
subscriptionId: string | null;
kind: "SINGLE" | "AGGREGATE";
}) {
const now = new Date();
if (event.kind === "SINGLE" && event.subscriptionId) {
const subscription = await prisma.userSubscription.findUnique({
where: { id: event.subscriptionId },
select: { id: true, status: true, endDate: true },
});
if (subscription?.status === "SUSPENDED" && subscription.endDate > now) {
await activateSubscription(subscription.id);
return [subscription.id];
}
return [];
}
if (event.kind === "AGGREGATE" && event.userId) {
const subscriptions = await prisma.userSubscription.findMany({
where: {
userId: event.userId,
status: "SUSPENDED",
endDate: { gt: now },
plan: { type: "PROXY" },
},
select: { id: true },
orderBy: { createdAt: "desc" },
});
const restoredIds: string[] = [];
for (const subscription of subscriptions) {
await activateSubscription(subscription.id);
restoredIds.push(subscription.id);
}
return restoredIds;
}
return [];
}
export async function updateSubscriptionRiskReview(
eventId: string,
status: SubscriptionRiskReviewStatus,
@@ -108,6 +239,12 @@ export async function updateSubscriptionRiskReview(
reviewedAt,
reviewedById: actor.userId ?? null,
reviewedByEmail: actor.email ?? null,
...(status === "RESOLVED"
? {
userRestrictionActive: false,
userRestrictionResolvedAt: reviewedAt,
}
: {}),
},
});
@@ -135,6 +272,189 @@ export async function updateSubscriptionRiskReview(
},
});
revalidateRiskViews(event.subscriptionId);
revalidateRiskViews(event.subscriptionId, event.userId);
return { ok: true };
}
export async function generateSubscriptionRiskReport(eventId: string) {
const session = await requireAdmin();
const actor = actorFromSession(session);
const { event, report } = await buildAndSaveRiskReport(eventId);
const targetLabel = await getRiskTargetLabel({
userId: event.userId,
subscriptionId: event.subscriptionId,
});
await recordAuditLog({
actor,
action: "risk.subscription.report.generate",
targetType: event.subscriptionId ? "UserSubscription" : "User",
targetId: event.subscriptionId ?? event.userId ?? event.id,
targetLabel,
message: "生成订阅风控风险报告",
metadata: {
eventId: event.id,
kind: event.kind,
level: event.level,
reason: event.reason,
reportLength: report.length,
},
});
revalidateRiskViews(event.subscriptionId, event.userId);
return { ok: true, report };
}
export async function sendSubscriptionRiskReport(eventId: string) {
const session = await requireAdmin();
const actor = actorFromSession(session);
let { event, user, report } = await getRiskEventContext(eventId).then((context) => ({
event: context.event,
user: context.user,
report: context.event.riskReport,
}));
if (!event.userId || !user) {
throw new Error("该风控事件没有关联用户,无法发送用户通知");
}
const reportUserId = event.userId;
if (!report) {
const generated = await buildAndSaveRiskReport(event.id);
event = generated.event;
user = generated.user;
report = generated.report;
}
const now = new Date();
await prisma.subscriptionRiskEvent.update({
where: { id: event.id },
data: {
reportSentAt: now,
userRestrictionActive: true,
reviewStatus: event.reviewStatus === "OPEN" ? "ACKNOWLEDGED" : event.reviewStatus,
reviewedAt: event.reviewStatus === "OPEN" ? now : event.reviewedAt,
reviewedById: event.reviewStatus === "OPEN" ? actor.userId ?? null : event.reviewedById,
reviewedByEmail: event.reviewStatus === "OPEN" ? actor.email ?? null : event.reviewedByEmail,
},
});
await notifyUserWithRiskReport({ eventId: event.id, userId: reportUserId });
const targetLabel = await getRiskTargetLabel({
userId: event.userId,
subscriptionId: event.subscriptionId,
});
await recordAuditLog({
actor,
action: "risk.subscription.report.send",
targetType: event.subscriptionId ? "UserSubscription" : "User",
targetId: event.subscriptionId ?? event.userId ?? event.id,
targetLabel,
message: `向用户发送订阅风控报告并启用强制通知:${user?.email ?? event.userId}`,
metadata: {
eventId: event.id,
reason: event.reason,
riskReasonLabel: reasonLabel(event.reason),
riskKind: riskKindLabel(event.kind),
},
});
revalidateRiskViews(event.subscriptionId, event.userId);
return { ok: true };
}
export async function finalizeSubscriptionRiskDecision(
eventId: string,
action: SubscriptionRiskFinalAction,
note?: string,
options: { notifyUser?: boolean } = {},
) {
assertFinalAction(action);
const session = await requireAdmin();
const actor = actorFromSession(session);
let { event, report } = await getRiskEventContext(eventId).then((context) => ({
event: context.event,
report: context.event.riskReport,
}));
if (options.notifyUser && !event.userId) {
throw new Error("该风控事件没有关联用户,无法发送用户通知");
}
if (options.notifyUser && !report) {
const generated = await buildAndSaveRiskReport(event.id);
event = generated.event;
report = generated.report;
}
const restoredSubscriptionIds = action === "RESTORE_ACCESS"
? await restoreSubscriptionsForEvent(event)
: [];
const normalizedNote = normalizeNote(note);
const now = new Date();
const shouldKeepRestriction = action === "KEEP_RESTRICTED" && (event.userRestrictionActive || options.notifyUser);
await prisma.subscriptionRiskEvent.update({
where: { id: event.id },
data: {
reviewStatus: "RESOLVED",
reviewNote: normalizedNote,
reviewedAt: now,
reviewedById: actor.userId ?? null,
reviewedByEmail: actor.email ?? null,
finalAction: action,
finalActionAt: now,
finalActionById: actor.userId ?? null,
finalActionByEmail: actor.email ?? null,
userRestrictionActive: shouldKeepRestriction,
userRestrictionResolvedAt: action === "RESTORE_ACCESS" ? now : event.userRestrictionResolvedAt,
...(options.notifyUser
? {
reportSentAt: event.reportSentAt ?? now,
}
: {}),
},
});
if (action === "RESTORE_ACCESS" && event.userId) {
await createNotification({
userId: event.userId,
type: "SUBSCRIPTION",
level: "SUCCESS",
title: "订阅风控限制已解除",
body: "管理员已完成订阅风控复核,你的账户操作限制已解除。",
link: "/subscriptions",
dedupeKey: `risk:restriction-restored:${event.id}`,
});
}
if (action === "KEEP_RESTRICTED" && options.notifyUser && event.userId && report) {
await notifyUserWithRiskReport({ eventId: event.id, userId: event.userId });
}
const targetLabel = await getRiskTargetLabel({
userId: event.userId,
subscriptionId: event.subscriptionId,
});
await recordAuditLog({
actor,
action: "risk.subscription.finalize",
targetType: event.subscriptionId ? "UserSubscription" : "User",
targetId: event.subscriptionId ?? event.userId ?? event.id,
targetLabel,
message: `订阅风控最终处置:${finalActionLabel(action)}`,
metadata: {
eventId: event.id,
finalAction: action,
notifyUser: options.notifyUser === true,
note: normalizedNote,
restoredSubscriptionIds,
},
});
revalidateRiskViews(event.subscriptionId, event.userId);
return { ok: true, restoredSubscriptionIds };
}

View File

@@ -37,6 +37,7 @@ export async function createUser(formData: FormData) {
message: `创建用户 ${user.email}`,
});
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${user.id}`);
}
export async function updateUser(id: string, formData: FormData) {
@@ -72,6 +73,7 @@ export async function updateUser(id: string, formData: FormData) {
});
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${user.id}`);
}
export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED" | "BANNED") {
@@ -86,6 +88,7 @@ export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED"
message: `将用户 ${user.email} 状态改为 ${status}`,
});
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${user.id}`);
}
export async function deleteUser(id: string) {
@@ -100,6 +103,7 @@ export async function deleteUser(id: string) {
message: `删除用户 ${user.email}`,
});
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${user.id}`);
}
export async function batchUpdateUserStatus(formData: FormData) {

View File

@@ -1,7 +1,6 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { requireAuth } from "@/lib/require-auth";
import { prisma } from "@/lib/prisma";
import { getErrorMessage } from "@/lib/errors";
import {
@@ -114,8 +113,7 @@ export async function purchaseProxy(
trafficGb: number,
selectedInboundId: string,
): Promise<string> {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
const session = await requireAuth();
await assertNoPendingOrder(session.user.id);
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
@@ -208,8 +206,7 @@ export async function purchaseProxy(
}
export async function purchaseStreaming(planId: string): Promise<string> {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
const session = await requireAuth();
await assertNoPendingOrder(session.user.id);
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
@@ -257,8 +254,7 @@ export async function purchaseRenewal(
renewalDays?: number,
): Promise<PurchaseRenewalResult> {
try {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
const session = await requireAuth();
await assertNoPendingOrder(session.user.id);
const subscription = await prisma.userSubscription.findFirst({
@@ -299,8 +295,7 @@ export async function purchaseTrafficTopup(
subscriptionId: string,
trafficGb: number,
): Promise<string> {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
const session = await requireAuth();
await assertNoPendingOrder(session.user.id);
if (!Number.isFinite(trafficGb) || trafficGb <= 0 || !Number.isInteger(trafficGb)) {
@@ -353,8 +348,7 @@ export async function queryPlanNextAvailability(planId: string): Promise<{
message: string;
nextAvailableAt: string | null;
}> {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
const session = await requireAuth();
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
where: { id: planId },

View File

@@ -17,26 +17,45 @@ const createTicketSchema = z.object({
category: z.string().trim().optional(),
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
body: z.string().trim().min(1, "内容不能为空"),
riskEventId: z.string().trim().optional(),
});
export async function createSupportTicket(formData: FormData) {
const session = await requireAuth();
const session = await requireAuth({ allowDuringRiskRestriction: true });
const data = createTicketSchema.parse(Object.fromEntries(formData));
const attachments = parseSupportAttachments(formData.getAll("attachments"));
const riskEvent = data.riskEventId
? await prisma.subscriptionRiskEvent.findFirst({
where: {
id: data.riskEventId,
userId: session.user.id,
reportSentAt: { not: null },
},
select: { id: true, message: true },
})
: null;
if (data.riskEventId && !riskEvent) {
throw new Error("关联风控事件不存在或不属于当前用户");
}
const body = riskEvent
? data.body + "\n\n关联订阅风控事件" + riskEvent.id + "\n系统判定" + riskEvent.message
: data.body;
const ticket = await prisma.supportTicket.create({
data: {
userId: session.user.id,
subject: data.subject,
category: data.category || null,
priority: data.priority,
category: data.category || (riskEvent ? "订阅风控" : null),
priority: riskEvent ? "HIGH" : data.priority,
status: "OPEN",
lastReplyAt: new Date(),
replies: {
create: {
authorUserId: session.user.id,
isAdmin: false,
body: data.body,
body,
},
},
},
@@ -66,7 +85,7 @@ export async function createSupportTicket(formData: FormData) {
type: "SYSTEM",
level: "INFO",
title: "新工单待处理",
body: `收到新工单:${data.subject}`,
body: riskEvent ? `收到订阅风控复核工单:${data.subject}` : `收到新工单:${data.subject}`,
link: `/admin/support/${ticket.id}`,
dedupeKey: `support-created:${ticket.id}:${admin.id}`,
});
@@ -78,7 +97,7 @@ export async function createSupportTicket(formData: FormData) {
}
export async function replySupportTicket(ticketId: string, formData: FormData) {
const session = await requireAuth();
const session = await requireAuth({ allowDuringRiskRestriction: true });
const body = String(formData.get("body") || "").trim();
const attachments = parseSupportAttachments(formData.getAll("attachments"));
if (!body) {
@@ -154,7 +173,7 @@ export async function replySupportTicket(ticketId: string, formData: FormData) {
}
export async function closeSupportTicket(ticketId: string) {
const session = await requireAuth();
const session = await requireAuth({ allowDuringRiskRestriction: true });
const ticket = await prisma.supportTicket.findFirst({
where: {
id: ticketId,
@@ -199,7 +218,7 @@ export async function closeSupportTicket(ticketId: string) {
}
export async function deleteSupportTicket(ticketId: string) {
const session = await requireAuth();
const session = await requireAuth({ allowDuringRiskRestriction: true });
const ticket = await prisma.supportTicket.findFirst({
where: {
id: ticketId,

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,

View File

@@ -1,11 +1,26 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { SubscriptionRiskReviewStatus } from "@prisma/client";
import { CheckCircle2, RotateCcw, ShieldCheck } from "lucide-react";
import type {
SubscriptionRiskFinalAction,
SubscriptionRiskReviewStatus,
} from "@prisma/client";
import {
FileText,
LockKeyhole,
RotateCcw,
Send,
ShieldCheck,
UnlockKeyhole,
} from "lucide-react";
import { toast } from "sonner";
import { updateSubscriptionRiskReview } from "@/actions/admin/subscription-risk";
import {
finalizeSubscriptionRiskDecision,
generateSubscriptionRiskReport,
sendSubscriptionRiskReport,
updateSubscriptionRiskReview,
} from "@/actions/admin/subscription-risk";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -24,10 +39,10 @@ interface RiskReviewMode {
label: string;
title: string;
description: string;
icon: "ack" | "resolve" | "open";
icon: "ack" | "open";
}
const modes: Record<SubscriptionRiskReviewStatus, RiskReviewMode> = {
const modes: Record<"OPEN" | "ACKNOWLEDGED", RiskReviewMode> = {
OPEN: {
status: "OPEN",
label: "重新打开",
@@ -39,76 +54,193 @@ const modes: Record<SubscriptionRiskReviewStatus, RiskReviewMode> = {
status: "ACKNOWLEDGED",
label: "确认跟进",
title: "确认正在处理",
description: "适合先记录已看到、正在核查,暂不恢复或关闭事件。",
description: "适合先记录已看到、正在核查,暂不解除或关闭事件。",
icon: "ack",
},
RESOLVED: {
status: "RESOLVED",
label: "标记解决",
title: "标记风控事件已解决",
description: "适合已联系用户、确认误判或已经完成必要处置后关闭事件。",
icon: "resolve",
},
};
type DialogState =
| { type: "review"; mode: RiskReviewMode }
| { type: "final"; action: SubscriptionRiskFinalAction }
| { type: "report" }
| null;
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" />;
}
function finalActionCopy(action: SubscriptionRiskFinalAction, restorableSubscriptionCount: number) {
if (action === "RESTORE_ACCESS") {
return {
icon: <UnlockKeyhole className="size-4" />,
label: "解除限制",
title: "确认解除风控限制?",
description: restorableSubscriptionCount > 0
? "会恢复可恢复的暂停订阅,并关闭用户端强制通知。"
: "会关闭用户端强制通知,并把事件记录为已解除;当前没有可自动恢复的暂停订阅。",
confirm: "确认解除",
};
}
return {
icon: <LockKeyhole className="size-4" />,
label: "保持封禁/暂停",
title: "确认保持封禁或暂停?",
description: "订阅和用户限制会维持当前处置,适合确认订阅链接外泄、公共代理滥用或用户无法解释异常访问来源的情况。",
confirm: "保持限制",
};
}
export function SubscriptionRiskReviewActions({
eventId,
reviewStatus,
canRestoreSubscription = false,
restorableSubscriptionCount = 0,
riskReport = null,
reportSentAt = null,
userRestrictionActive = false,
finalAction = null,
}: {
eventId: string;
reviewStatus: SubscriptionRiskReviewStatus;
canRestoreSubscription?: boolean;
restorableSubscriptionCount?: number;
riskReport?: string | null;
reportSentAt?: Date | string | null;
userRestrictionActive?: boolean;
finalAction?: SubscriptionRiskFinalAction | null;
}) {
const router = useRouter();
const [mode, setMode] = useState<RiskReviewMode | null>(null);
const [dialog, setDialog] = useState<DialogState>(null);
const [note, setNote] = useState("");
const [restoreSubscription, setRestoreSubscription] = useState(false);
const [loading, setLoading] = useState(false);
const [notifyUser, setNotifyUser] = useState(Boolean(reportSentAt || userRestrictionActive));
const [reportPreview, setReportPreview] = useState(riskReport ?? "");
const [pending, startTransition] = useTransition();
const availableModes = useMemo(() => {
return [modes.ACKNOWLEDGED, modes.RESOLVED, modes.OPEN].filter((item) => item.status !== reviewStatus);
return [modes.ACKNOWLEDGED, modes.OPEN].filter((item) => item.status !== reviewStatus);
}, [reviewStatus]);
function openDialog(nextMode: RiskReviewMode) {
setMode(nextMode);
const activeFinalCopy = dialog?.type === "final"
? finalActionCopy(dialog.action, restorableSubscriptionCount)
: null;
function openReviewDialog(mode: RiskReviewMode) {
setDialog({ type: "review", mode });
setNote("");
setRestoreSubscription(nextMode.status === "RESOLVED" && canRestoreSubscription);
}
async function submit() {
if (!mode) return;
function openFinalDialog(action: SubscriptionRiskFinalAction) {
setDialog({ type: "final", action });
setNote("");
setNotifyUser(action === "KEEP_RESTRICTED" ? true : Boolean(reportSentAt || userRestrictionActive));
}
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);
}
function handleGenerateReport(openAfterGenerate = true) {
startTransition(async () => {
try {
const result = await generateSubscriptionRiskReport(eventId);
setReportPreview(result.report);
toast.success("风险报告已生成");
if (openAfterGenerate) setDialog({ type: "report" });
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "生成风险报告失败"));
}
});
}
function handleSendReport() {
startTransition(async () => {
try {
await sendSubscriptionRiskReport(eventId);
toast.success("已发送用户端强制通知");
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "发送用户通知失败"));
}
});
}
function submitReview() {
if (dialog?.type !== "review") return;
startTransition(async () => {
try {
await updateSubscriptionRiskReview(eventId, dialog.mode.status, note);
toast.success("风控事件已更新");
setDialog(null);
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "更新风控事件失败"));
}
});
}
function submitFinalDecision() {
if (dialog?.type !== "final") return;
startTransition(async () => {
try {
await finalizeSubscriptionRiskDecision(eventId, dialog.action, note, {
notifyUser: dialog.action === "KEEP_RESTRICTED" && notifyUser,
});
toast.success(dialog.action === "RESTORE_ACCESS" ? "已解除限制" : "已保持限制并记录处置");
setDialog(null);
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "保存最终处置失败"));
}
});
}
return (
<>
<div className="flex flex-wrap gap-2">
<div className="flex max-w-72 flex-wrap justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => handleGenerateReport(true)}
>
<FileText className="size-4" />
{reportPreview ? "重生成报告" : "生成报告"}
</Button>
{reportPreview && (
<Button size="sm" variant="ghost" disabled={pending} onClick={() => setDialog({ type: "report" })}>
</Button>
)}
<Button size="sm" variant="outline" disabled={pending} onClick={handleSendReport}>
<Send className="size-4" />
{reportSentAt ? "重新发送" : "发送用户"}
</Button>
<Button
size="sm"
variant={canRestoreSubscription || userRestrictionActive ? "default" : "outline"}
disabled={pending || (!canRestoreSubscription && !userRestrictionActive && reviewStatus === "RESOLVED")}
onClick={() => openFinalDialog("RESTORE_ACCESS")}
>
<UnlockKeyhole className="size-4" />
</Button>
<Button
size="sm"
variant="destructive"
disabled={pending || finalAction === "KEEP_RESTRICTED"}
onClick={() => openFinalDialog("KEEP_RESTRICTED")}
>
<LockKeyhole className="size-4" />
</Button>
{availableModes.map((item) => (
<Button
key={item.status}
size="sm"
variant={item.status === "RESOLVED" ? "default" : "outline"}
onClick={() => openDialog(item)}
variant="ghost"
disabled={pending}
onClick={() => openReviewDialog(item)}
>
<ModeIcon icon={item.icon} />
{item.label}
@@ -116,54 +248,124 @@ export function SubscriptionRiskReviewActions({
))}
</div>
<Dialog open={mode != null} onOpenChange={(open) => !loading && !open && setMode(null)}>
<DialogContent className="sm:max-w-lg">
{mode && (
<Dialog open={dialog != null} onOpenChange={(open) => !pending && !open && setDialog(null)}>
<DialogContent className={dialog?.type === "report" ? "sm:max-w-3xl" : "sm:max-w-lg"}>
{dialog?.type === "review" && (
<>
<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} />
<ModeIcon icon={dialog.mode.icon} />
</div>
<DialogTitle>{mode.title}</DialogTitle>
<DialogDescription>{mode.description}</DialogDescription>
<DialogTitle>{dialog.mode.title}</DialogTitle>
<DialogDescription>{dialog.mode.description}</DialogDescription>
</DialogHeader>
<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>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialog(null)} disabled={pending}>
</Button>
<Button type="button" onClick={submitReview} disabled={pending}>
{pending ? "保存中..." : dialog.mode.label}
</Button>
</DialogFooter>
</>
)}
{dialog?.type === "final" && activeFinalCopy && (
<>
<DialogHeader>
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
{activeFinalCopy.icon}
</div>
<DialogTitle>{activeFinalCopy.title}</DialogTitle>
<DialogDescription>{activeFinalCopy.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 && (
{dialog.action === "RESTORE_ACCESS" && restorableSubscriptionCount > 0 && (
<div className="rounded-lg border border-primary/20 bg-primary/8 p-3 text-sm leading-6 text-primary">
{restorableSubscriptionCount}
</div>
)}
{dialog.action === "KEEP_RESTRICTED" && (
<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)}
checked={notifyUser}
onChange={(event) => setNotifyUser(event.target.checked)}
/>
<span>
<span className="block text-xs text-muted-foreground">
3x-ui
</span>
</span>
</label>
)}
<div className="space-y-2">
<Label htmlFor={"risk-final-note-" + eventId}></Label>
<Textarea
id={"risk-final-note-" + eventId}
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={1000}
placeholder="记录最终判断依据,例如:用户提交工单证明为本人出差;或确认链接被多人共享,保持限制。"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setMode(null)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => setDialog(null)} disabled={pending}>
</Button>
<Button type="button" onClick={() => void submit()} disabled={loading}>
{loading ? "保存中..." : mode.label}
<Button
type="button"
variant={dialog.action === "KEEP_RESTRICTED" ? "destructive" : "default"}
onClick={submitFinalDecision}
disabled={pending}
>
{pending ? "保存中..." : activeFinalCopy.confirm}
</Button>
</DialogFooter>
</>
)}
{dialog?.type === "report" && (
<>
<DialogHeader>
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<FileText className="size-4" />
</div>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<pre className="max-h-[28rem] overflow-auto whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-4 text-xs leading-6 text-foreground">
{reportPreview || "尚未生成风险报告。"}
</pre>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialog(null)} disabled={pending}>
</Button>
<Button type="button" variant="outline" onClick={() => handleGenerateReport(false)} disabled={pending}>
{pending ? "生成中..." : "重新生成"}
</Button>
<Button type="button" onClick={handleSendReport} disabled={pending}>
<Send className="size-4" />
</Button>
</DialogFooter>
</>

View File

@@ -0,0 +1,106 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AlertTriangle, FileText, LifeBuoy } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
export type SubscriptionRiskRestrictionNotice = {
id: string;
level: "WARNING" | "SUSPENDED";
reasonLabel: string;
message: string;
riskReport: string | null;
reportSentAt: string | null;
};
function supportHref(id: string) {
return "/support?riskEventId=" + encodeURIComponent(id);
}
export function SubscriptionRiskRestrictionGate({
restriction,
}: {
restriction: SubscriptionRiskRestrictionNotice | null;
}) {
const pathname = usePathname();
if (!restriction) return null;
const isSupportPath = pathname === "/support" || pathname.startsWith("/support/");
if (isSupportPath) {
return (
<section className="mb-5 rounded-xl border border-destructive/25 bg-destructive/8 p-4 text-sm leading-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="size-4" />
</span>
<div>
<p className="font-semibold text-destructive"></p>
<p className="mt-1 text-muted-foreground">
访
</p>
</div>
</div>
<span className="rounded-full border border-destructive/20 px-2.5 py-1 text-xs font-medium text-destructive">
{restriction.reasonLabel}
</span>
</div>
</section>
);
}
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center overflow-y-auto bg-background/96 p-4 backdrop-blur-md">
<div className="w-full max-w-3xl overflow-hidden rounded-2xl border border-destructive/25 bg-card shadow-2xl">
<div className="border-b border-border/70 bg-destructive/8 p-5 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</span>
<div>
<p className="text-xs font-medium text-destructive"></p>
<h2 className="mt-1 text-xl font-semibold tracking-[-0.02em]"></h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
IP 访
</p>
</div>
</div>
<span className="w-fit rounded-full border border-destructive/20 px-3 py-1 text-xs font-medium text-destructive">
{restriction.reasonLabel}
</span>
</div>
</div>
<div className="space-y-4 p-5 sm:p-6">
<div className="rounded-xl border border-border/70 bg-muted/25 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
<FileText className="size-4 text-primary" />
</div>
<p className="text-sm leading-6 text-muted-foreground">{restriction.message}</p>
</div>
{restriction.riskReport && (
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-xl border border-border/70 bg-muted/30 p-4 text-xs leading-6 text-foreground">
{restriction.riskReport}
</pre>
)}
<div className="flex flex-col gap-3 rounded-xl border border-primary/20 bg-primary/8 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm leading-6">
<p className="font-semibold text-primary"></p>
<p className="text-muted-foreground">访</p>
</div>
<Link href={supportHref(restriction.id)} className={buttonVariants({ size: "lg" })}>
<LifeBuoy className="size-4" />
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { authOptions } from "./auth";
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
export async function requireAdmin() {
const session = await getServerSession(authOptions);
@@ -9,10 +10,18 @@ export async function requireAdmin() {
return session;
}
export async function requireAuth() {
export async function requireAuth(options: { allowDuringRiskRestriction?: boolean } = {}) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("未登录");
}
if (session.user.role !== "ADMIN" && !options.allowDuringRiskRestriction) {
const restriction = await getActiveSubscriptionRiskRestriction(session.user.id);
if (restriction) {
throw new Error("账户存在未处理的订阅风控限制,请先新建工单联系客服");
}
}
return session;
}

View File

@@ -0,0 +1,385 @@
import type {
Prisma,
SubscriptionAccessKind,
SubscriptionRiskEvent,
SubscriptionRiskReason,
} from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
import { formatDate } from "@/lib/utils";
export const subscriptionRiskAccessLogSelect = {
id: true,
userId: true,
subscriptionId: true,
kind: true,
ip: true,
userAgent: true,
country: true,
region: true,
regionCode: true,
city: true,
latitude: true,
longitude: true,
geoSource: true,
allowed: true,
reason: true,
createdAt: true,
} satisfies Prisma.SubscriptionAccessLogSelect;
export type SubscriptionRiskAccessLog = Prisma.SubscriptionAccessLogGetPayload<{
select: typeof subscriptionRiskAccessLogSelect;
}>;
export type SubscriptionRiskUserBrief = {
id: string;
email: string;
name: string | null;
status?: string;
createdAt?: Date;
};
export type SubscriptionRiskSubscriptionBrief = {
id: string;
status: string;
endDate: Date;
plan: {
name: string;
type: string;
};
};
export type SubscriptionRiskGeoPoint = {
key: string;
ip: string;
country: string;
region: string;
city: string;
latitude: number;
longitude: number;
accessCount: number;
lastSeenAt: string;
allowed: boolean;
};
export type SubscriptionRiskCountrySummary = {
country: string;
accessCount: number;
ipCount: number;
regionCount: number;
cityCount: number;
topRegions: string[];
topCities: string[];
};
export type SubscriptionRiskRecentAccess = {
id: string;
ip: string;
location: string;
allowed: boolean;
reason: string | null;
userAgent: string | null;
createdAt: string;
};
export type SubscriptionRiskGeoSummary = {
totalLogs: number;
allowedLogs: number;
blockedLogs: number;
uniqueIpCount: number;
uniqueCountryCount: number;
uniqueRegionCount: number;
uniqueCityCount: number;
countries: SubscriptionRiskCountrySummary[];
points: SubscriptionRiskGeoPoint[];
recentAccesses: SubscriptionRiskRecentAccess[];
};
type RiskEventScope = Pick<
SubscriptionRiskEvent,
"kind" | "userId" | "subscriptionId" | "windowStartedAt" | "createdAt"
>;
function safeLabel(value: string | null | undefined, fallback: string) {
const trimmed = value?.trim();
return trimmed || fallback;
}
function normalizeKey(value: string | null | undefined, fallback: string) {
return safeLabel(value, fallback).toLowerCase();
}
function locationLabel(log: Pick<SubscriptionRiskAccessLog, "country" | "region" | "regionCode" | "city">) {
const parts = [log.country, log.region || log.regionCode, log.city]
.map((part) => part?.trim())
.filter(Boolean);
return parts.length > 0 ? parts.join(" / ") : "未知地区";
}
function parseCoordinate(value: string | null | undefined, min: number, max: number) {
if (!value) return null;
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) return null;
return parsed;
}
function uniquePreview(values: Iterable<string>, limit = 4) {
const list = Array.from(new Set(Array.from(values).filter(Boolean)));
if (list.length <= limit) return list;
return [...list.slice(0, limit), `${list.length}`];
}
export function reasonLabel(reason: SubscriptionRiskReason) {
switch (reason) {
case "CITY_VARIANCE_WARNING":
return "城市异常警告";
case "CITY_VARIANCE_SUSPEND":
return "城市异常暂停";
case "REGION_VARIANCE_WARNING":
return "省/地区异常警告";
case "REGION_VARIANCE_SUSPEND":
return "省/地区异常暂停";
case "COUNTRY_VARIANCE_WARNING":
return "国家异常警告";
case "COUNTRY_VARIANCE_SUSPEND":
return "国家异常暂停";
}
}
export function riskKindLabel(kind: SubscriptionAccessKind) {
return kind === "AGGREGATE" ? "总订阅" : "单订阅";
}
export function getSubscriptionRiskLogWhere(event: RiskEventScope): Prisma.SubscriptionAccessLogWhereInput {
const base: Prisma.SubscriptionAccessLogWhereInput = {
createdAt: {
gte: event.windowStartedAt,
lte: event.createdAt,
},
};
if (event.kind === "SINGLE" && event.subscriptionId) {
return {
...base,
kind: "SINGLE",
subscriptionId: event.subscriptionId,
};
}
if (event.kind === "AGGREGATE" && event.userId) {
return {
...base,
kind: "AGGREGATE",
userId: event.userId,
};
}
return {
...base,
id: "__missing-risk-scope__",
};
}
export async function getSubscriptionRiskAccessLogsForEvent(
event: RiskEventScope,
db: DbClient = prisma,
take = 120,
) {
return db.subscriptionAccessLog.findMany({
where: getSubscriptionRiskLogWhere(event),
select: subscriptionRiskAccessLogSelect,
orderBy: { createdAt: "desc" },
take,
});
}
export function buildSubscriptionRiskGeoSummary(logs: SubscriptionRiskAccessLog[]): SubscriptionRiskGeoSummary {
const uniqueIps = new Set<string>();
const uniqueCountries = new Set<string>();
const uniqueRegions = new Set<string>();
const uniqueCities = new Set<string>();
const countryMap = new Map<string, {
country: string;
accessCount: number;
ips: Set<string>;
regions: Set<string>;
cities: Set<string>;
}>();
const pointMap = new Map<string, SubscriptionRiskGeoPoint>();
for (const log of logs) {
uniqueIps.add(log.ip);
const country = safeLabel(log.country, "未知国家/地区");
const region = safeLabel(log.region || log.regionCode, "未知省/地区");
const city = safeLabel(log.city, "未知城市");
const countryKey = normalizeKey(log.country, "unknown-country");
const regionKey = [countryKey, normalizeKey(log.regionCode || log.region, "unknown-region")].join(":");
const cityKey = [regionKey, normalizeKey(log.city, "unknown-city")].join(":");
uniqueCountries.add(countryKey);
if (log.region || log.regionCode) uniqueRegions.add(regionKey);
if (log.city) uniqueCities.add(cityKey);
const countryItem = countryMap.get(countryKey) ?? {
country,
accessCount: 0,
ips: new Set<string>(),
regions: new Set<string>(),
cities: new Set<string>(),
};
countryItem.accessCount += 1;
countryItem.ips.add(log.ip);
if (log.region || log.regionCode) countryItem.regions.add(region);
if (log.city) countryItem.cities.add(city);
countryMap.set(countryKey, countryItem);
const latitude = parseCoordinate(log.latitude, -90, 90);
const longitude = parseCoordinate(log.longitude, -180, 180);
if (latitude == null || longitude == null) continue;
const pointKey = [log.ip, latitude.toFixed(3), longitude.toFixed(3)].join(":");
const existing = pointMap.get(pointKey);
if (existing) {
existing.accessCount += 1;
if (new Date(log.createdAt).getTime() > new Date(existing.lastSeenAt).getTime()) {
existing.lastSeenAt = log.createdAt.toISOString();
existing.allowed = log.allowed;
}
} else {
pointMap.set(pointKey, {
key: pointKey,
ip: log.ip,
country,
region,
city,
latitude,
longitude,
accessCount: 1,
lastSeenAt: log.createdAt.toISOString(),
allowed: log.allowed,
});
}
}
const countries = Array.from(countryMap.values())
.map((item) => ({
country: item.country,
accessCount: item.accessCount,
ipCount: item.ips.size,
regionCount: item.regions.size,
cityCount: item.cities.size,
topRegions: uniquePreview(item.regions),
topCities: uniquePreview(item.cities),
}))
.sort((a, b) => b.accessCount - a.accessCount || b.ipCount - a.ipCount || a.country.localeCompare(b.country));
return {
totalLogs: logs.length,
allowedLogs: logs.filter((log) => log.allowed).length,
blockedLogs: logs.filter((log) => !log.allowed).length,
uniqueIpCount: uniqueIps.size,
uniqueCountryCount: uniqueCountries.size,
uniqueRegionCount: uniqueRegions.size,
uniqueCityCount: uniqueCities.size,
countries,
points: Array.from(pointMap.values()).sort((a, b) => b.accessCount - a.accessCount),
recentAccesses: logs.slice(0, 12).map((log) => ({
id: log.id,
ip: log.ip,
location: locationLabel(log),
allowed: log.allowed,
reason: log.reason,
userAgent: log.userAgent,
createdAt: log.createdAt.toISOString(),
})),
};
}
function formatCountrySummary(summary: SubscriptionRiskGeoSummary) {
if (summary.countries.length === 0) return "暂无可识别地区。";
return summary.countries
.slice(0, 8)
.map((country) => {
const regions = country.topRegions.length > 0 ? country.topRegions.join("、") : "未识别";
const cities = country.topCities.length > 0 ? country.topCities.join("、") : "未识别";
return `- ${country.country}${country.ipCount} 个 IP${country.regionCount} 个省/地区,${country.cityCount} 个城市,访问 ${country.accessCount} 次;省/地区:${regions};城市:${cities}`;
})
.join("\n");
}
function formatAccessEvidence(summary: SubscriptionRiskGeoSummary) {
if (summary.recentAccesses.length === 0) return "暂无访问明细。";
return summary.recentAccesses
.slice(0, 10)
.map((access) => {
const result = access.allowed ? "放行" : access.reason || "拦截";
return `- ${formatDate(access.createdAt)} | ${access.ip} | ${access.location} | ${result}`;
})
.join("\n");
}
export function buildSubscriptionRiskReport(input: {
event: SubscriptionRiskEvent;
logs: SubscriptionRiskAccessLog[];
user?: SubscriptionRiskUserBrief | null;
subscription?: SubscriptionRiskSubscriptionBrief | null;
}) {
const { event, logs, user, subscription } = input;
const summary = buildSubscriptionRiskGeoSummary(logs);
const target = subscription
? `${subscription.plan.name}${subscription.plan.type},当前状态:${subscription.status}`
: "用户总订阅";
const userLabel = user ? `${user.email}${user.name ? `${user.name}` : ""}` : event.userId ?? "未知用户";
const windowRange = `${formatDate(event.windowStartedAt)}${formatDate(event.createdAt)}`;
const actionSuggestion = event.level === "SUSPENDED"
? "建议保持暂停,等待用户确认是否本人跨地区使用、订阅链接是否外泄,并在工单中补充说明后再解除限制。"
: "建议先联系用户确认近期访问来源;如果用户无法解释这些地区/IP建议重置订阅链接并临时暂停相关订阅。";
return [
"订阅风控风险报告",
"",
`用户:${userLabel}`,
`风控范围:${riskKindLabel(event.kind)} / ${target}`,
`事件编号:${event.id}`,
`触发时间:${formatDate(event.createdAt)}`,
`检测窗口:${windowRange}`,
`风险判定:${reasonLabel(event.reason)}${event.level === "SUSPENDED" ? "已暂停" : "警告"}`,
"",
"触发原因",
event.message,
"",
"地区与 IP 概览",
`- 访问记录:${summary.totalLogs} 条,其中放行 ${summary.allowedLogs} 条,拦截 ${summary.blockedLogs}`,
`- 不同 IP${summary.uniqueIpCount}`,
`- 不同国家/地区:${summary.uniqueCountryCount} 个,不同省/地区:${summary.uniqueRegionCount} 个,不同城市:${summary.uniqueCityCount}`,
formatCountrySummary(summary),
"",
"关键访问证据",
formatAccessEvidence(summary),
"",
"处理建议",
actionSuggestion,
].join("\n");
}
export async function getActiveSubscriptionRiskRestriction(userId: string, db: DbClient = prisma) {
return db.subscriptionRiskEvent.findFirst({
where: {
userId,
userRestrictionActive: true,
reportSentAt: { not: null },
},
orderBy: { reportSentAt: "desc" },
select: {
id: true,
level: true,
reason: true,
message: true,
riskReport: true,
reportSentAt: true,
createdAt: true,
},
});
}