Skip to content

Commit 76bf0f5

Browse files
committed
fix: repair admin user governance
1 parent 9ab83f3 commit 76bf0f5

15 files changed

Lines changed: 610 additions & 27 deletions

File tree

apps/api/src/routes/admin.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import {
5555
parseAdminTopicFilters,
5656
type AdminTopicFilters,
5757
} from "../lib/admin-governance";
58-
import { createReportBodySchema, errorResponseSchema, roleAssignmentSchema, userRoleSchema } from "@cnode/shared";
58+
import { adminUserBulkGovernanceBodySchema, createReportBodySchema, errorResponseSchema, roleAssignmentSchema, userRoleSchema } from "@cnode/shared";
5959

6060
const admin = new OpenAPIHono<{
6161
Variables: AuthVars;
@@ -115,6 +115,8 @@ export function auditEventMeta(action: string) {
115115
unblock_user: "恢复用户内容",
116116
mute_user: "禁言用户",
117117
unmute_user: "解除禁言",
118+
bulk_unblock_user: "批量恢复内容可见",
119+
bulk_unmute_user: "批量解除禁言",
118120
delete_all_user_content: "删除用户所有发言",
119121
grant_role: "授予角色",
120122
revoke_role: "撤销角色",
@@ -137,14 +139,14 @@ export function auditEventMeta(action: string) {
137139
? "security"
138140
: action.startsWith("update_")
139141
? "system"
140-
: ["block_user", "unblock_user", "mute_user", "unmute_user", "delete_all_user_content"].includes(action)
142+
: ["block_user", "unblock_user", "mute_user", "unmute_user", "bulk_unblock_user", "bulk_unmute_user", "delete_all_user_content"].includes(action)
141143
? "user"
142144
: "content";
143145
const risk = ["delete_topic", "delete_reply", "delete_all_user_content", "permanent_delete_topic"].includes(action)
144146
? "critical"
145147
: ["grant_role", "revoke_role", "reset_password", "ban_ip", "unban_ip"].includes(action)
146148
? "high"
147-
: ["lock", "unlock", "block_user", "unblock_user", "mute_user", "unmute_user"].includes(action)
149+
: ["lock", "unlock", "block_user", "unblock_user", "mute_user", "unmute_user", "bulk_unblock_user", "bulk_unmute_user"].includes(action)
148150
? "medium"
149151
: "low";
150152
return { category, risk, label: labels[action] || action.replace(/_/g, " ") };
@@ -266,6 +268,23 @@ function rejectSelfTarget(c: any, targetUser: { id: number }) {
266268
return null;
267269
}
268270

271+
export function bulkUserGovernanceAuditAction(action: "unmute" | "unblock") {
272+
return action === "unmute" ? "bulk_unmute_user" : "bulk_unblock_user";
273+
}
274+
275+
export function buildBulkUserGovernancePlan(action: "unmute" | "unblock", requestedIds: number[], currentUserId: number, existingUsers: { id: number }[]) {
276+
const ids = Array.from(new Set(requestedIds));
277+
const selfSkippedIds = ids.filter((id) => id === currentUserId);
278+
const existingIds = existingUsers.map((user) => user.id);
279+
const missingIds = ids.filter((id) => id !== currentUserId && !existingIds.includes(id));
280+
return {
281+
ids,
282+
processedIds: existingIds,
283+
skippedIds: [...selfSkippedIds, ...missingIds],
284+
update: action === "unmute" ? { isMuted: boolValue(false) } : { isBlock: boolValue(false) },
285+
};
286+
}
287+
269288
async function getReportTargetSummary(targetType: string, targetId: number) {
270289
if (targetType === "topic") {
271290
const topic = await topicQueries.getById(targetId);
@@ -543,6 +562,35 @@ admin.delete("/admin/users/:id/roles/:role", adminRequired(), async (c) => {
543562
return c.json({ success: true, data: { user_id: id, roles } });
544563
});
545564

565+
admin.post("/admin/users/bulk-governance", adminRequired(), async (c) => {
566+
const parsed = adminUserBulkGovernanceBodySchema.safeParse(await c.req.json().catch(() => ({})));
567+
if (!parsed.success) return c.json({ success: false, error_msg: "批量用户治理参数无效" }, 422);
568+
const currentUser = c.get("user")!;
569+
const ids = Array.from(new Set(parsed.data.ids));
570+
const skipped = ids.filter((id) => id === currentUser.id);
571+
const targetIds = ids.filter((id) => id !== currentUser.id);
572+
if (!targetIds.length) {
573+
return c.json({ success: true, processed: 0, processed_ids: [], skipped_ids: skipped });
574+
}
575+
576+
const db = getDb();
577+
const targetRows = await db.select({ id: users.id, loginname: users.loginname }).from(users).where(inArray(users.id, targetIds));
578+
const plan = buildBulkUserGovernancePlan(parsed.data.action, ids, currentUser.id, targetRows);
579+
if (plan.processedIds.length > 0) {
580+
await db.update(users).set(plan.update as any).where(inArray(users.id, plan.processedIds));
581+
}
582+
583+
await auditQueries.log(
584+
currentUser.id,
585+
currentUser.loginname,
586+
bulkUserGovernanceAuditAction(parsed.data.action),
587+
{ type: "user", id: plan.processedIds.join(","), name: targetRows.map((user: any) => user.loginname).join(",") },
588+
"success",
589+
JSON.stringify({ action: parsed.data.action, ids: plan.ids, processed_ids: plan.processedIds, skipped_ids: plan.skippedIds, processed: plan.processedIds.length }),
590+
);
591+
return c.json({ success: true, processed: plan.processedIds.length, processed_ids: plan.processedIds, skipped_ids: plan.skippedIds });
592+
});
593+
546594
admin.post("/user/:name/block", adminRequired(), async (c) => {
547595
const name = c.req.param("name");
548596
const userData = await userQueries.getByLoginName(name);
@@ -649,13 +697,14 @@ admin.post("/user/:name/reset_password", adminRequired(), async (c) => {
649697
// ── Ban management ──
650698
admin.get("/admin/bans/users", adminRequired(), async (c) => {
651699
const pagination = getPagination(c);
700+
const status = c.req.query("status") === "muted" ? "muted" : "blocked";
652701
const db = getDb();
653-
const where = boolEq(users.isBlock, true);
702+
const where = status === "muted" ? boolEq(users.isMuted, true) : boolEq(users.isBlock, true);
654703
const [banned, totalResult] = await Promise.all([
655704
db.select().from(users).where(where).orderBy(desc(users.createAt)).limit(pagination.limit).offset(pagination.offset),
656705
db.select({ c: count() }).from(users).where(where),
657706
]);
658-
return c.json(paginated(banned.map((u: any) => ({ id: u.id, loginname: u.loginname, is_block: true })), Number(totalResult[0]?.c || 0), pagination));
707+
return c.json(paginated(banned.map((u: any) => ({ id: u.id, loginname: u.loginname, is_block: !!u.isBlock, is_muted: !!u.isMuted })), Number(totalResult[0]?.c || 0), pagination));
659708
});
660709

661710
admin.get("/admin/bans/ips", adminRequired(), async (c) => {

apps/api/test/audit-events.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from "vitest";
2-
import { auditEventMeta, sanitizeAuditDetail } from "../src/routes/admin";
2+
import { auditEventMeta, buildBulkUserGovernancePlan, bulkUserGovernanceAuditAction, sanitizeAuditDetail } from "../src/routes/admin";
33

44
test("audit events classify sensitive actions by category and risk", () => {
55
expect(auditEventMeta("delete_all_user_content")).toEqual({
@@ -13,6 +13,35 @@ test("audit events classify sensitive actions by category and risk", () => {
1313
label: "授予角色",
1414
});
1515
expect(auditEventMeta("moderation_scan_create").category).toBe("moderation");
16+
expect(auditEventMeta("bulk_unmute_user")).toEqual({
17+
category: "user",
18+
risk: "medium",
19+
label: "批量解除禁言",
20+
});
21+
expect(auditEventMeta("bulk_unblock_user")).toEqual({
22+
category: "user",
23+
risk: "medium",
24+
label: "批量恢复内容可见",
25+
});
26+
});
27+
28+
test("bulk user governance audit actions stay explicit", () => {
29+
expect(bulkUserGovernanceAuditAction("unmute")).toBe("bulk_unmute_user");
30+
expect(bulkUserGovernanceAuditAction("unblock")).toBe("bulk_unblock_user");
31+
});
32+
33+
test("bulk user governance plan deduplicates and skips unsafe targets", () => {
34+
const plan = buildBulkUserGovernancePlan("unmute", [1, 2, 2, 3, 4], 1, [{ id: 2 }, { id: 4 }]);
35+
expect(plan.ids).toEqual([1, 2, 3, 4]);
36+
expect(plan.processedIds).toEqual([2, 4]);
37+
expect(plan.skippedIds).toEqual([1, 3]);
38+
expect(plan.update).toEqual({ isMuted: false });
39+
});
40+
41+
test("bulk user governance plan maps unblock without changing mute", () => {
42+
const plan = buildBulkUserGovernancePlan("unblock", [2], 1, [{ id: 2 }]);
43+
expect(plan.processedIds).toEqual([2]);
44+
expect(plan.update).toEqual({ isBlock: false });
1645
});
1746

1847
test("audit detail sanitizer redacts sensitive JSON and text values", () => {

apps/web/app/routes/admin/bans.tsx

Lines changed: 110 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { requireAdmin } from "~/lib/auth";
22
import { AdminLayout } from "~/components/AdminLayout";
33
import { apiFetch } from "~/lib/api-client";
4-
import { useRevalidator } from "react-router";
5-
import { useState } from "react";
4+
import { Link, useRevalidator } from "react-router";
5+
import { useEffect, useState } from "react";
66
import { toast } from "sonner";
77
import { useAsyncAction } from "~/hooks/use-async-action";
88
import { Button } from "~/components/ui/button";
99
import { Input } from "~/components/ui/input";
1010
import { Badge } from "~/components/ui/badge";
11+
import { Checkbox } from "~/components/ui/checkbox";
1112
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
1213
import { AdminPage, AdminPageHeader, AdminPanel, AdminToolbar } from "~/components/AdminPage";
1314
import { Pagination } from "~/components/Pagination";
@@ -24,15 +25,32 @@ export function meta() {
2425
return [{ title: "封禁管理 · CNode Admin" }];
2526
}
2627

28+
export type UserGovernanceStatus = "muted" | "blocked";
29+
30+
export const USER_GOVERNANCE_STATUS_LABELS: Record<UserGovernanceStatus, string> = {
31+
muted: "禁言用户",
32+
blocked: "内容已屏蔽用户",
33+
};
34+
35+
export function userGovernanceActionLabel(status: UserGovernanceStatus) {
36+
return status === "muted" ? "解除禁言" : "恢复内容可见";
37+
}
38+
39+
function userGovernanceApiAction(status: UserGovernanceStatus) {
40+
return status === "muted" ? "unmute" : "unblock";
41+
}
42+
2743
export async function loader({ request }: any) {
2844
await requireAdmin(request);
2945
const url = new URL(request.url);
3046
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
3147
const limit = Math.min(100, Number(url.searchParams.get("limit")) || 50);
3248
const tab = url.searchParams.get("tab") || "users";
49+
const userStatus: UserGovernanceStatus = url.searchParams.get("status") === "blocked" ? "blocked" : "muted";
3350
const cookie = request.headers.get("cookie") || "";
51+
const userParams = new URLSearchParams({ page: String(page), limit: String(limit), status: userStatus });
3452
const [usersRes, ipsRes] = await Promise.all([
35-
apiFetch<{ success: boolean; data: any[]; total?: number }>(`/api/v1/admin/bans/users?page=${page}&limit=${limit}`, {
53+
apiFetch<{ success: boolean; data: any[]; total?: number }>(`/api/v1/admin/bans/users?${userParams.toString()}`, {
3654
headers: { cookie },
3755
}),
3856
apiFetch<{ success: boolean; data: any[]; total?: number }>(`/api/v1/admin/bans/ips?page=${page}&limit=${limit}`, { headers: { cookie } }),
@@ -45,27 +63,71 @@ export async function loader({ request }: any) {
4563
page,
4664
limit,
4765
tab,
66+
userStatus,
4867
};
4968
}
5069

5170
export default function AdminBans({ loaderData }: any) {
5271
const { bannedUsers, bannedUsersTotal, bannedIps, bannedIpsTotal, page, limit, tab } = loaderData;
72+
const userStatus = loaderData.userStatus as UserGovernanceStatus;
5373
const { revalidate } = useRevalidator();
5474
const [ip, setIp] = useState("");
5575
const [reason, setReason] = useState("");
76+
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
77+
78+
useEffect(() => {
79+
setSelectedUserIds([]);
80+
}, [page, userStatus]);
5681

57-
const { run: handleUnblock } = useAsyncAction(
82+
const currentUserIds = bannedUsers.map((user: any) => Number(user.id));
83+
const selectedCurrentUserIds = selectedUserIds.filter((id) => currentUserIds.includes(id));
84+
const allCurrentUsersSelected = currentUserIds.length > 0 && selectedCurrentUserIds.length === currentUserIds.length;
85+
const userAction = userGovernanceApiAction(userStatus);
86+
const userActionLabel = userGovernanceActionLabel(userStatus);
87+
const userStatusLabel = USER_GOVERNANCE_STATUS_LABELS[userStatus];
88+
89+
const toggleUserSelection = (id: number, checked: boolean) => {
90+
setSelectedUserIds((ids) => checked ? Array.from(new Set([...ids, id])) : ids.filter((item) => item !== id));
91+
};
92+
93+
const toggleAllCurrentUsers = (checked: boolean) => {
94+
setSelectedUserIds((ids) => checked ? Array.from(new Set([...ids, ...currentUserIds])) : ids.filter((id) => !currentUserIds.includes(id)));
95+
};
96+
97+
const { run: handleSingleUserGovernance } = useAsyncAction(
5898
async (name: string) => {
59-
const res = await apiFetch<{ success: boolean; error_msg?: string }>(`/api/v1/user/${name}/unblock`, { method: "POST" });
99+
const res = await apiFetch<{ success: boolean; error_msg?: string }>(`/api/v1/user/${name}/${userAction}`, { method: "POST" });
60100
return { ...res, name };
61101
},
62102
{
63103
onSuccess: (result) => {
64104
if (result.success) {
65-
toast.success(`已解禁 ${result.name}`);
105+
toast.success(`${userActionLabel} ${result.name}`);
66106
revalidate();
67107
} else {
68-
toast.error(result.error_msg || "解禁失败");
108+
toast.error(result.error_msg || `${userActionLabel}失败`);
109+
}
110+
},
111+
},
112+
);
113+
114+
const { run: handleBulkUserGovernance, pending: bulkUserPending } = useAsyncAction(
115+
async () => {
116+
const res = await apiFetch<{ success: boolean; error_msg?: string; processed?: number; skipped_ids?: number[] }>("/api/v1/admin/users/bulk-governance", {
117+
method: "POST",
118+
body: JSON.stringify({ action: userAction, ids: selectedCurrentUserIds }),
119+
});
120+
return res;
121+
},
122+
{
123+
onSuccess: (result) => {
124+
if (result.success) {
125+
const skipped = result.skipped_ids?.length || 0;
126+
toast.success(`已${userActionLabel} ${result.processed || 0} 个用户`, skipped ? { description: `已跳过 ${skipped} 个目标` } : undefined);
127+
setSelectedUserIds([]);
128+
revalidate();
129+
} else {
130+
toast.error(result.error_msg || `批量${userActionLabel}失败`);
69131
}
70132
},
71133
},
@@ -107,42 +169,73 @@ export default function AdminBans({ loaderData }: any) {
107169
return (
108170
<AdminLayout>
109171
<AdminPage>
110-
<AdminPageHeader title="封禁管理" description="管理用户禁言和 IP 风控规则,保持社区秩序。" />
172+
<AdminPageHeader title="封禁管理" description="管理用户禁言、内容屏蔽和 IP 风控规则,保持社区秩序。" />
111173
<Tabs defaultValue={tab} className="space-y-4">
112174
<TabsList className="bg-card shadow-card">
113-
<TabsTrigger value="users">用户封禁</TabsTrigger>
175+
<TabsTrigger value="users">用户治理</TabsTrigger>
114176
<TabsTrigger value="ips">IP 封禁</TabsTrigger>
115177
</TabsList>
116178
<TabsContent value="users">
117-
<AdminPanel title="用户封禁" description={`当前显示 ${bannedUsers.length} / ${bannedUsersTotal} 个禁言用户`}>
179+
<AdminPanel title={userStatusLabel} description={`当前显示 ${bannedUsers.length} / ${bannedUsersTotal}${userStatusLabel}`}>
180+
<AdminToolbar>
181+
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
182+
<div className="flex gap-2">
183+
<Button asChild size="sm" variant={userStatus === "muted" ? "default" : "outline"}>
184+
<Link to={`/admin/bans?tab=users&status=muted&limit=${limit}`}>禁言用户</Link>
185+
</Button>
186+
<Button asChild size="sm" variant={userStatus === "blocked" ? "default" : "outline"}>
187+
<Link to={`/admin/bans?tab=users&status=blocked&limit=${limit}`}>内容已屏蔽用户</Link>
188+
</Button>
189+
</div>
190+
<Button size="sm" onClick={handleBulkUserGovernance} disabled={bulkUserPending || selectedCurrentUserIds.length === 0}>
191+
{bulkUserPending ? "处理中" : `批量${userActionLabel} (${selectedCurrentUserIds.length})`}
192+
</Button>
193+
</div>
194+
</AdminToolbar>
118195
<div className="overflow-x-auto">
119-
<Table className="min-w-[560px]">
196+
<Table className="min-w-[680px]">
120197
<TableHeader>
121198
<TableRow>
199+
<TableHead className="w-10">
200+
<Checkbox checked={allCurrentUsersSelected} onCheckedChange={(checked) => toggleAllCurrentUsers(checked === true)} aria-label={`选择当前页${userStatusLabel}`} />
201+
</TableHead>
122202
<TableHead>用户</TableHead>
123203
<TableHead>状态</TableHead>
124-
<TableHead>操作</TableHead>
204+
<TableHead className="text-right">操作</TableHead>
125205
</TableRow>
126206
</TableHeader>
127207
<TableBody>
128208
{bannedUsers.map((u: any) => (
129209
<TableRow key={u.id}>
130-
<TableCell>{u.loginname}</TableCell>
131210
<TableCell>
132-
<Badge variant="destructive">禁言</Badge>
211+
<Checkbox checked={selectedUserIds.includes(Number(u.id))} onCheckedChange={(checked) => toggleUserSelection(Number(u.id), checked === true)} aria-label={`选择 ${u.loginname}`} />
133212
</TableCell>
213+
<TableCell>{u.loginname}</TableCell>
134214
<TableCell>
135-
<Button size="sm" variant="ghost" onClick={() => handleUnblock(u.loginname)}>
136-
解禁
215+
<div className="flex flex-wrap gap-1">
216+
{u.is_muted && <Badge variant="destructive">禁言</Badge>}
217+
{u.is_block && <Badge variant="destructive">内容已屏蔽</Badge>}
218+
</div>
219+
</TableCell>
220+
<TableCell className="text-right">
221+
<Button size="sm" variant="ghost" onClick={() => handleSingleUserGovernance(u.loginname)}>
222+
{userActionLabel}
137223
</Button>
138224
</TableCell>
139225
</TableRow>
140226
))}
227+
{bannedUsers.length === 0 && (
228+
<TableRow>
229+
<TableCell colSpan={4} className="py-8 text-center text-sm text-muted-foreground">
230+
暂无{userStatusLabel}
231+
</TableCell>
232+
</TableRow>
233+
)}
141234
</TableBody>
142235
</Table>
143236
</div>
144237
<div className="px-4 pb-4">
145-
<Pagination page={page} total={bannedUsersTotal} limit={limit} basePath="/admin/bans" searchParams={{ tab: "users" }} />
238+
<Pagination page={page} total={bannedUsersTotal} limit={limit} basePath="/admin/bans" searchParams={{ tab: "users", status: userStatus }} />
146239
</div>
147240
</AdminPanel>
148241
</TabsContent>

apps/web/app/routes/admin/users.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,7 @@ export async function loader({ request }: any) {
6767
}
6868

6969
export default function AdminUsers({ loaderData }: any) {
70-
const { users: initialUsers, total, page, limit, q, currentUser } = loaderData;
71-
const [users] = useState<any[]>(initialUsers);
70+
const { users, total, page, limit, q, currentUser } = loaderData;
7271
const [resetTarget, setResetTarget] = useState<{ id: number; loginname: string } | null>(null);
7372
const [deleteAllTarget, setDeleteAllTarget] = useState<string | null>(null);
7473
const { revalidate } = useRevalidator();
@@ -177,7 +176,7 @@ export default function AdminUsers({ loaderData }: any) {
177176
</TableRow>
178177
</TableHeader>
179178
<TableBody>
180-
{users.map((u) => {
179+
{users.map((u: any) => {
181180
const isSelf = currentUser?.loginname === u.loginname || String(currentUser?.id || "") === String(u.id);
182181
return (
183182
<TableRow key={u.id}>

0 commit comments

Comments
 (0)