feat: enhance subscription risk review workflow

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

View File

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

View File

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

View File

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

View File

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