diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c8a7a7..1bf1968 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/actions/admin/subscription-risk.ts b/src/actions/admin/subscription-risk.ts index e84749a..20e1a45 100644 --- a/src/actions/admin/subscription-risk.ts +++ b/src/actions/admin/subscription-risk.ts @@ -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 }; +} diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts index d46df24..463af8c 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -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) { diff --git a/src/actions/user/purchase.ts b/src/actions/user/purchase.ts index d67602d..4c24e92 100644 --- a/src/actions/user/purchase.ts +++ b/src/actions/user/purchase.ts @@ -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 { - 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 { - 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 { 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 { - 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 }, diff --git a/src/actions/user/support.ts b/src/actions/user/support.ts index 6a806a6..8c93a63 100644 --- a/src/actions/user/support.ts +++ b/src/actions/user/support.ts @@ -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, diff --git a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-geo-details.tsx b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-geo-details.tsx new file mode 100644 index 0000000..458d43a --- /dev/null +++ b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-geo-details.tsx @@ -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 ( +
+ + + + + + + + + {[-120, -60, 0, 60, 120].map((longitude) => { + const x = ((longitude + 180) / 360) * 360; + return ; + })} + {[-45, 0, 45].map((latitude) => { + const y = ((90 - latitude) / 180) * 180; + return ; + })} + + + + + + + + + + + {points.map((point) => { + const { x, y } = projectPoint(point.latitude, point.longitude); + return ( + + {point.ip + " / " + point.country + " / " + point.region + " / " + point.city} + + + + ); + })} + + +
+ {points.length > 0 ? "已标注 " + points.length + " 个坐标点" : "没有可用经纬度坐标"} + 圆点越大表示访问越集中 +
+
+ ); +} + +export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionRiskGeoSummary }) { + return ( +
+ + + + + + + 地区与 IP 证据 + + {summary.uniqueCountryCount} 国 / {summary.uniqueRegionCount} 省区 / {summary.uniqueCityCount} 城市 / {summary.uniqueIpCount} IP + + + + + + +
+
+ + +
+
+
+

访问记录

+

{summary.totalLogs}

+
+
+

不同 IP

+

{summary.uniqueIpCount}

+
+
+ +
+ {summary.countries.length === 0 ? ( +

+ 暂无可识别地区数据,可能是 GeoIP 未命中或访问来源未携带有效 IP。 +

+ ) : ( + summary.countries.map((country) => ( +
+
+

{country.country}

+ {country.accessCount} 次 +
+

+ {country.ipCount} IP · {country.regionCount} 省/地区 · {country.cityCount} 城市 +

+ {(country.topRegions.length > 0 || country.topCities.length > 0) && ( +

+ 省区:{country.topRegions.join("、") || "未识别"};城市:{country.topCities.join("、") || "未识别"} +

+ )} +
+ )) + )} +
+
+
+ +
+
+ 最近访问明细 +
+
+ {summary.recentAccesses.length === 0 ? ( +

暂无访问明细。

+ ) : ( + summary.recentAccesses.slice(0, 6).map((access) => ( +
+
+ {access.ip} + + {access.allowed ? "放行" : access.reason || "拦截"} + +
+

{access.location}

+

{formatDate(access.createdAt)}

+ {access.userAgent &&

{access.userAgent}

} +
+ )) + )} +
+
+
+
+ ); +} diff --git a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx index 9060d64..296fee2 100644 --- a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx +++ b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx @@ -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 (
-

{event.user.email}

+ + {event.user.email} +

{event.user.name || "未设置昵称"}

@@ -138,11 +152,14 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven -
-

{event.ip || "未知 IP"}

-

- 城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount} -

+
+
+

{event.ip || "未知 IP"}

+

+ 城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount} +

+
+
@@ -150,6 +167,17 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven {reviewStatusLabel(event.reviewStatus)} + {(event.reportSentAt || event.userRestrictionActive || event.finalAction) && ( +
+ {event.reportSentAt && 已发送报告} + {event.userRestrictionActive && 用户端限制中} + {finalActionLabel(event.finalAction) && ( + + {finalActionLabel(event.finalAction)} + + )} +
+ )} {(event.reviewedByEmail || event.reviewNote) && (
{event.reviewedByEmail &&

{event.reviewedByEmail}

} @@ -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} />
diff --git a/src/app/(admin)/admin/subscription-risk/risk-data.ts b/src/app/(admin)/admin/subscription-risk/risk-data.ts index 68f87d2..0532ce9 100644 --- a/src/app/(admin)/admin/subscription-risk/risk-data.ts +++ b/src/app/(admin)/admin/subscription-risk/risk-data.ts @@ -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(); + 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([]), }; }); diff --git a/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx b/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx index d417903..cbb8d5a 100644 --- a/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx +++ b/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx @@ -129,7 +129,7 @@ export function SubscriptionAccessRiskSection({
- {owner.email} + {owner.email}

{owner.name || "未设置昵称"}

@@ -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} />
diff --git a/src/app/(admin)/admin/users/[id]/page.tsx b/src/app/(admin)/admin/users/[id]/page.tsx new file mode 100644 index 0000000..c1a87b9 --- /dev/null +++ b/src/app/(admin)/admin/users/[id]/page.tsx @@ -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 ( +
+

{label}

+
{value}
+ {hint &&
{hint}
} +
+ ); +} + +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 ( + + } + /> + +
+ + + + + )} + hint={user.emailVerifiedAt ? "邮箱已验证" : "邮箱未验证"} + /> + + + + +
+ +
+ 查看该用户风控} + /> +
+
+

用户 ID

+

{user.id}

+
+
+

邀请码

+

{user.inviteCode || "—"}

+
+
+

注册时间

+

{formatDate(user.createdAt)}

+
+
+

更新时间

+

{formatDate(user.updatedAt)}

+
+
+
+ +
+ + + + + + 套餐 + 类型 + 状态 + 流量 + 到期 + 创建时间 + + + + {subscriptions.map((subscription) => ( + + + + {subscription.plan.name} + + + + + + {formatBytes(subscription.trafficUsed)} / {subscription.trafficLimit ? formatBytes(subscription.trafficLimit) : "不限"} + + {formatDateShort(subscription.endDate)} + {formatDateShort(subscription.createdAt)} + + ))} + + + +
+ +
+ + + + + + 时间 + 判定 + 地区/IP + 状态 + + + + {riskEvents.map((event) => ( + + {formatDate(event.createdAt)} + +
+ {reasonLabel(event.reason)} +

{event.message}

+
+
+ +

{event.ip || "未知 IP"}

+

城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount}

+
+ + {reviewStatusLabel(event.reviewStatus)} + +
+ ))} +
+
+
+
+ +
+
+ + + + + + 套餐 + 类型 + 金额 + 状态 + 时间 + + + + {orders.map((order) => ( + + {order.plan.name} + {orderKindLabels[order.kind]} + ¥{Number(order.amount).toFixed(2)} + + {formatDateShort(order.createdAt)} + + ))} + + + +
+ +
+ + + + + + 标题 + 状态 + 优先级 + 更新 + + + + {supportTickets.map((ticket) => ( + + + + {ticket.subject} + + + + + {formatDateShort(ticket.updatedAt)} + + ))} + + + +
+
+
+ ); +} diff --git a/src/app/(admin)/admin/users/[id]/user-detail-data.ts b/src/app/(admin)/admin/users/[id]/user-detail-data.ts new file mode 100644 index 0000000..6429898 --- /dev/null +++ b/src/app/(admin)/admin/users/[id]/user-detail-data.ts @@ -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 }; +} diff --git a/src/app/(payment)/layout.tsx b/src/app/(payment)/layout.tsx index dc130f2..5462e8e 100644 --- a/src/app/(payment)/layout.tsx +++ b/src/app/(payment)/layout.tsx @@ -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; } diff --git a/src/app/(user)/layout.tsx b/src/app/(user)/layout.tsx index 0737f3a..915df8c 100644 --- a/src/app/(user)/layout.tsx +++ b/src/app/(user)/layout.tsx @@ -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 (
@@ -41,6 +56,7 @@ export default async function UserLayout({
+ diff --git a/src/app/(user)/support/_components/create-support-ticket-form.tsx b/src/app/(user)/support/_components/create-support-ticket-form.tsx index bfe2faf..7722027 100644 --- a/src/app/(user)/support/_components/create-support-ticket-form.tsx +++ b/src/app/(user)/support/_components/create-support-ticket-form.tsx @@ -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" >
-

新建工单

+

{preset?.riskEventId ? "订阅风控复核工单" : "新建工单"}

+ {!preset?.riskEventId && ( + )}
- +
+
-