mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
fix: force delete user data
This commit is contained in:
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||||
|
|
||||||
const createUserSchema = z.object({
|
const createUserSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -21,23 +22,6 @@ const updateUserSchema = z.object({
|
|||||||
role: z.enum(["ADMIN", "USER"]),
|
role: z.enum(["ADMIN", "USER"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const userDeleteBlockerLabels: Record<string, string> = {
|
|
||||||
subscriptions: "订阅",
|
|
||||||
orders: "订单",
|
|
||||||
nodeClients: "节点客户端",
|
|
||||||
streamingSlots: "流媒体分配",
|
|
||||||
supportTickets: "工单",
|
|
||||||
inviteRewardLedgers: "邀请返利记录",
|
|
||||||
inviteeRewardLedgers: "被邀请返利记录",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDeleteBlockers(counts: Record<string, number>) {
|
|
||||||
return Object.entries(counts)
|
|
||||||
.filter(([, count]) => count > 0)
|
|
||||||
.map(([key, count]) => `${userDeleteBlockerLabels[key] ?? key} ${count} 条`)
|
|
||||||
.join("、");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createUser(formData: FormData) {
|
export async function createUser(formData: FormData) {
|
||||||
const session = await requireAdmin();
|
const session = await requireAdmin();
|
||||||
const data = createUserSchema.parse(Object.fromEntries(formData));
|
const data = createUserSchema.parse(Object.fromEntries(formData));
|
||||||
@@ -120,6 +104,30 @@ export async function deleteUser(id: string) {
|
|||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
subscriptions: { select: { id: true } },
|
||||||
|
nodeClients: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
uuid: true,
|
||||||
|
inbound: {
|
||||||
|
select: {
|
||||||
|
panelInboundId: true,
|
||||||
|
server: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
panelType: true,
|
||||||
|
panelUrl: true,
|
||||||
|
panelUsername: true,
|
||||||
|
panelPassword: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
streamingSlots: { select: { serviceId: true } },
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
@@ -127,6 +135,11 @@ export async function deleteUser(id: string) {
|
|||||||
nodeClients: true,
|
nodeClients: true,
|
||||||
streamingSlots: true,
|
streamingSlots: true,
|
||||||
supportTickets: true,
|
supportTickets: true,
|
||||||
|
supportReplies: true,
|
||||||
|
cartItems: true,
|
||||||
|
couponGrants: true,
|
||||||
|
notifications: true,
|
||||||
|
emailTokens: true,
|
||||||
inviteRewardLedgers: true,
|
inviteRewardLedgers: true,
|
||||||
inviteeRewardLedgers: true,
|
inviteeRewardLedgers: true,
|
||||||
},
|
},
|
||||||
@@ -151,24 +164,110 @@ export async function deleteUser(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockers = formatDeleteBlockers(user._count);
|
const panelAdapters = new Map<string, ReturnType<typeof createPanelAdapter>>();
|
||||||
if (blockers) {
|
for (const client of user.nodeClients) {
|
||||||
throw new Error(
|
const panelInboundId = client.inbound.panelInboundId;
|
||||||
`无法直接删除该用户:存在 ${blockers}。为避免订单、订阅和客服记录丢失,请改用“禁用”或“封禁”;如确需彻底清理,请先人工处理关联数据。`,
|
const server = client.inbound.server;
|
||||||
);
|
if (panelInboundId == null) {
|
||||||
|
throw new Error(`节点客户端 ${client.email} 所属入站缺少 3x-ui 入站 ID,无法强制删除。请先同步节点入站后重试。`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id: user.id } });
|
let adapter = panelAdapters.get(server.id);
|
||||||
|
if (!adapter) {
|
||||||
|
adapter = createPanelAdapter(server);
|
||||||
|
const loggedIn = await adapter.login();
|
||||||
|
if (!loggedIn) {
|
||||||
|
throw new Error(`节点 ${server.name} 登录失败,无法删除该用户在节点面板中的客户端。`);
|
||||||
|
}
|
||||||
|
panelAdapters.set(server.id, adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
await adapter.deleteClient(panelInboundId, client.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionIds = user.subscriptions.map((subscription) => subscription.id);
|
||||||
|
const nodeClientIds = user.nodeClients.map((client) => client.id);
|
||||||
|
const streamingServiceIds = [...new Set(user.streamingSlots.map((slot) => slot.serviceId))];
|
||||||
|
const subscriptionLinkedWhere = subscriptionIds.length > 0
|
||||||
|
? { OR: [{ userId: user.id }, { subscriptionId: { in: subscriptionIds } }] }
|
||||||
|
: { userId: user.id };
|
||||||
|
const orderWhere = subscriptionIds.length > 0
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ userId: user.id },
|
||||||
|
{ targetSubscriptionId: { in: subscriptionIds } },
|
||||||
|
{ subscriptionId: { in: subscriptionIds } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { userId: user.id };
|
||||||
|
|
||||||
|
const deleted = await prisma.$transaction(async (tx) => {
|
||||||
|
const trafficLogs = nodeClientIds.length > 0
|
||||||
|
? await tx.trafficLog.deleteMany({ where: { clientId: { in: nodeClientIds } } })
|
||||||
|
: { count: 0 };
|
||||||
|
const accessLogs = await tx.subscriptionAccessLog.deleteMany({ where: subscriptionLinkedWhere });
|
||||||
|
const riskEvents = await tx.subscriptionRiskEvent.deleteMany({ where: subscriptionLinkedWhere });
|
||||||
|
const inviteRewards = await tx.inviteRewardLedger.deleteMany({
|
||||||
|
where: { OR: [{ inviterId: user.id }, { inviteeId: user.id }] },
|
||||||
|
});
|
||||||
|
const couponGrants = await tx.couponGrant.deleteMany({ where: { userId: user.id } });
|
||||||
|
const cartItems = await tx.shoppingCartItem.deleteMany({ where: { userId: user.id } });
|
||||||
|
const notifications = await tx.userNotification.deleteMany({ where: { userId: user.id } });
|
||||||
|
const emailTokens = await tx.emailToken.deleteMany({ where: { userId: user.id } });
|
||||||
|
const supportTickets = await tx.supportTicket.deleteMany({ where: { userId: user.id } });
|
||||||
|
const supportReplies = await tx.supportTicketReply.deleteMany({ where: { authorUserId: user.id } });
|
||||||
|
const orders = await tx.order.deleteMany({ where: orderWhere });
|
||||||
|
const nodeClients = await tx.nodeClient.deleteMany({ where: { userId: user.id } });
|
||||||
|
const streamingSlots = await tx.streamingSlot.deleteMany({ where: { userId: user.id } });
|
||||||
|
|
||||||
|
for (const serviceId of streamingServiceIds) {
|
||||||
|
const usedSlots = await tx.streamingSlot.count({ where: { serviceId } });
|
||||||
|
await tx.streamingService.update({
|
||||||
|
where: { id: serviceId },
|
||||||
|
data: { usedSlots },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = await tx.userSubscription.deleteMany({ where: { userId: user.id } });
|
||||||
|
await tx.user.delete({ where: { id: user.id } });
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessLogs: accessLogs.count,
|
||||||
|
cartItems: cartItems.count,
|
||||||
|
couponGrants: couponGrants.count,
|
||||||
|
emailTokens: emailTokens.count,
|
||||||
|
inviteRewards: inviteRewards.count,
|
||||||
|
nodeClients: nodeClients.count,
|
||||||
|
notifications: notifications.count,
|
||||||
|
orders: orders.count,
|
||||||
|
riskEvents: riskEvents.count,
|
||||||
|
streamingSlots: streamingSlots.count,
|
||||||
|
subscriptions: subscriptions.count,
|
||||||
|
supportReplies: supportReplies.count,
|
||||||
|
supportTickets: supportTickets.count,
|
||||||
|
trafficLogs: trafficLogs.count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
await recordAuditLog({
|
await recordAuditLog({
|
||||||
actor: actorFromSession(session),
|
actor: actorFromSession(session),
|
||||||
action: "user.delete",
|
action: "user.force_delete",
|
||||||
targetType: "User",
|
targetType: "User",
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
targetLabel: user.email,
|
targetLabel: user.email,
|
||||||
message: `删除用户 ${user.email}`,
|
message: `强制删除用户 ${user.email} 及其名下业务数据`,
|
||||||
|
metadata: {
|
||||||
|
beforeDeleteCounts: user._count,
|
||||||
|
deleted,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/users");
|
revalidatePath("/admin/users");
|
||||||
revalidatePath(`/admin/users/${user.id}`);
|
revalidatePath(`/admin/users/${user.id}`);
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
|
revalidatePath("/admin/orders");
|
||||||
|
revalidatePath("/admin/support");
|
||||||
|
revalidatePath("/admin/traffic");
|
||||||
|
revalidatePath("/admin/subscription-risk");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function batchUpdateUserStatus(formData: FormData) {
|
export async function batchUpdateUserStatus(formData: FormData) {
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ export function UserActions({ user }: { user: User }) {
|
|||||||
<ConfirmActionButton
|
<ConfirmActionButton
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
title="删除这个用户?"
|
title="强制删除这个用户?"
|
||||||
description="仅无业务数据的空账号会被物理删除;有订单、订阅或工单的用户请改用禁用/封禁。"
|
description="将同步删除该用户在节点面板中的客户端,并永久清理名下订单、订阅、工单、通知、访问日志等数据。此操作不可恢复。"
|
||||||
confirmLabel="删除用户"
|
confirmLabel="强制删除"
|
||||||
successMessage="用户已删除"
|
successMessage="用户已删除"
|
||||||
errorMessage="删除失败"
|
errorMessage="删除失败"
|
||||||
onConfirm={() => deleteUser(user.id)}
|
onConfirm={() => deleteUser(user.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user