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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user