mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: enhance subscription risk review workflow
This commit is contained in:
@@ -60,6 +60,11 @@ enum SubscriptionRiskReviewStatus {
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
enum SubscriptionRiskFinalAction {
|
||||
RESTORE_ACCESS
|
||||
KEEP_RESTRICTED
|
||||
}
|
||||
|
||||
enum PlanPricingMode {
|
||||
TRAFFIC_SLIDER
|
||||
FIXED_PACKAGE
|
||||
@@ -350,6 +355,15 @@ model SubscriptionRiskEvent {
|
||||
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])
|
||||
@@ -358,6 +372,8 @@ model SubscriptionRiskEvent {
|
||||
@@index([reason, createdAt])
|
||||
@@index([reviewStatus, createdAt])
|
||||
@@index([reviewedById])
|
||||
@@index([userRestrictionActive, reportSentAt])
|
||||
@@index([finalAction, finalActionAt])
|
||||
}
|
||||
|
||||
model StreamingService {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,18 +152,32 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<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>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
|
||||
@@ -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([]),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
260
src/app/(admin)/admin/users/[id]/page.tsx
Normal file
260
src/app/(admin)/admin/users/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/app/(admin)/admin/users/[id]/user-detail-data.ts
Normal file
82
src/app/(admin)/admin/users/[id]/user-detail-data.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
function handleGenerateReport(openAfterGenerate = true) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateSubscriptionRiskReview(eventId, mode.status, note, {
|
||||
restoreSubscription: mode.status === "RESOLVED" && restoreSubscription,
|
||||
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("风控事件已更新");
|
||||
setMode(null);
|
||||
setDialog(null);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "更新风控事件失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`risk-note-${eventId}`}>处理备注</Label>
|
||||
<Label htmlFor={"risk-note-" + eventId}>处理备注</Label>
|
||||
<Textarea
|
||||
id={`risk-note-${eventId}`}
|
||||
id={"risk-note-" + eventId}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
maxLength={1000}
|
||||
placeholder="例如:已联系用户确认是出差;或确认订阅链接外泄,已重置/暂停处理。"
|
||||
placeholder="例如:已联系用户确认是出差;或确认订阅链接外泄,继续限制。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode.status === "RESOLVED" && canRestoreSubscription && (
|
||||
<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">
|
||||
{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>
|
||||
</>
|
||||
|
||||
106
src/components/user/subscription-risk-restriction-gate.tsx
Normal file
106
src/components/user/subscription-risk-restriction-gate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
385
src/services/subscription-risk-review.ts
Normal file
385
src/services/subscription-risk-review.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user