44 KiB
用户权限模块审计报告
审计范围:用户权限(RBAC)模块,包括
shared/types/permissions.ts、shared/lib/permissions.ts、shared/lib/auth-guard.ts、shared/hooks/use-permission.ts、shared/lib/role-utils.ts、shared/lib/session.ts、auth.ts、modules/rbac/*、modules/users/*(权限相关部分)、app/(dashboard)/admin/{roles,permissions,users}/*。 审计时间:2026-06-24 审计依据:项目规则.trae/rules/project_rules.md、架构影响地图004/005。
一、现有实现概要
1.1 文件分布与行数
| 层 | 文件 | 行数 | 职责 |
|---|---|---|---|
| shared/types | permissions.ts |
223 | 67 个权限点常量 + Role/BuiltinRole/Permission/DataScope/AuthContext 类型 |
| shared/lib | permissions.ts |
301 | ROLE_PERMISSIONS_SEED(6 内置角色种子)+ resolvePermissions()(DB 查询 + 种子兜底) |
| shared/lib | auth-guard.ts |
176 | getAuthContext() / requirePermission() / checkPermission() / resolveDataScope() / requireAuth() |
| shared/lib | role-utils.ts |
34 | normalizeRole() / resolvePrimaryRole() 纯函数 |
| shared/lib | session.ts |
35 | getSession()(动态 import @/auth 避免循环依赖) |
| shared/hooks | use-permission.ts |
53 | 客户端 usePermission() Hook(hasPermission/hasAnyPermission/hasAllPermissions/hasRole) |
| app/root | auth.ts |
234 | NextAuth v5 配置(Credentials + JWT + Session 回调 + 登录事件) |
| modules/rbac | data-access.ts |
215 | 角色 CRUD + getRolePermissions/setRolePermissions + ADMIN_ROLE_NAME 常量 |
| modules/rbac | data-access-assignments.ts |
161 | 用户-角色分配(getUserRoleNames/assignRolesToUser/getUserRoleAssignments) |
| modules/rbac | actions.ts |
382 | 8 个 Server Action(含 requirePermission + Zod + 审计日志) |
| modules/rbac | schema.ts |
42 | 4 个 Zod schema |
| modules/rbac | types.ts |
56 | RoleRecord/RoleWithStats/RoleDetail/CreateRoleInput/UpdateRoleInput/UserRoleAssignment/PaginatedResult |
| modules/rbac/lib | permission-catalog.ts |
268 | PERMISSION_CATALOG(24 分组)+ getAllPermissionMetas/getPermissionMeta |
| modules/rbac/components | 7 个文件 | ~600 | RoleList/RoleFormDialog/RolePermissionMatrix/PermissionCatalogView/UserRoleAssignDialog/RoleManagementView/RoleDetailEditButton |
| modules/users | data-access.ts |
424 | 用户查询 + getCurrentStudentUser + getAdminUsers/getAdminUserRoles |
| modules/users | actions.ts |
218 | updateUserProfile/importUsersAction/exportUsersAction/updateUserRoleAction(空实现)/deleteUserAction |
| app/(dashboard)/admin | 4 个 page.tsx | ~200 | 角色列表/角色详情/权限目录/用户管理页面 |
1.2 数据流
登录 (auth.ts authorize)
└─▶ db.query.users → 校验密码 → 查询 usersToRoles → resolvePrimaryRole
└─▶ JWT callback: resolvePermissions(allRoles) → 写入 token.permissions
└─▶ Session callback: 透传到 session.user.permissions
请求 (Server Component / Server Action)
└─▶ getAuthContext() → getSession() → resolveDataScope(userId, roles) → AuthContext
└─▶ requirePermission(p) → 校验 ctx.permissions.includes(p)
客户端 (Client Component)
└─▶ usePermission() → useSession() → hasPermission(p)
1.3 架构图记录情况
架构影响地图 004 和数据 JSON 005 已记录 rbac 模块(JSON 第 16204-16603 行),包含:
- 8 个 actions、11 个 data-access 函数、3 个 lib 常量/函数、8 个组件
- 依赖关系:
shared/db、shared/types/permissions、shared/lib/auth-guard、shared/lib/audit-logger、shared/lib/change-logger - 被使用方:
admin/roles、admin/permissions、admin/users、modules/users - i18n 命名空间
rbac,文件zh-CN/rbac.json+en/rbac.json
架构图遗漏:
shared/lib/permissions.ts(resolvePermissions)未在 rbac 模块依赖中显式列出(实际被auth.ts使用)shared/lib/auth-guard.ts的resolveDataScope内部直接查询classes/classEnrollments/classSubjectTeachers/grades/parentStudentRelations表,属于跨模块数据访问,未在架构图中标注modules/users/data-access.ts的getCurrentStudentUser直接调用auth()而非getAuthContext(),未在架构图中标注此异常依赖app/(dashboard)/admin/permissions/page.tsx直接查询rolePermissions/roles表,未走 data-access,架构图未标注此违规modules/users/actions.ts的deleteUserAction直接db.delete(users),未走 data-access,架构图未标注app/(dashboard)/admin/roles、admin/permissions、admin/users(非 import 子目录)缺少loading.tsx/error.tsx,架构图未标注
二、现存问题与原因分析
2.1 架构分层违规(P0)
问题 2.1.1 — app/ 直接访问数据库
- 位置:admin/permissions/page.tsx 第 28-32 行
- 问题:页面组件直接
db.select(...).from(rolePermissions).innerJoin(roles, ...)查询数据库,绕过 data-access 层。 - 违反规则:
app/ 只能调用 modules/ 的 Server Actions 和 data-access,不直接访问数据库(项目规则「架构分层规则」)。 - 后果:权限统计查询逻辑散落在页面中,无法复用、无法测试、无法统一加缓存;若
rolePermissions表结构变更需修改多处。
问题 2.1.2 — Server Action 直接访问数据库
- 位置:users/actions.ts 第 208 行
await db.delete(users).where(eq(users.id, userId)) - 问题:
deleteUserAction直接在 actions 层执行 DB 删除,未下沉到 data-access。 - 违反规则:
app/ 只能调用 modules/ 的 Server Actions 和 data-access+严格三层架构,依赖方向单向(项目规则「架构分层规则」)。 - 后果:删除用户逻辑(应包含级联清理 sessions、usersToRoles、passwordSecurity 等)散落在 actions 层,易遗漏级联清理,造成孤儿数据。
问题 2.1.3 — 客户端组件直接调用 fetch API
- 位置:admin-users-view.tsx 第 120 行
fetch("/api/admin/users/" + deleteUserId, { method: "DELETE" }) - 问题:客户端组件通过
fetch调用 API 路由删除用户,而非调用 Server Action。 - 违反规则:
app/ 只能调用 modules/ 的 Server Actions 和 data-access(项目规则「架构分层规则」)。 - 后果:绕过 Server Action 的权限校验、Zod 验证、审计日志;存在未授权调用风险;删除逻辑出现两套(Server Action + API 路由)。
问题 2.1.4 — getCurrentStudentUser 使用 auth() 而非 getAuthContext()
- 位置:users/data-access.ts 第 232 行
const session = await auth() - 问题:直接调用
auth()获取 session,而非使用项目统一的getAuthContext()。 - 违反规则:
Authentication must use getAuthContext() and getCurrentStudentUser(); avoid mixing with other auth methods(项目记忆 Hard Constraints)。 - 后果:认证入口不统一,
getAuthContext提供的dataScope等上下文信息丢失;后续若在 session 校验逻辑中增加 IP 限制、租户隔离等,此处不会自动生效。
问题 2.1.5 — resolveDataScope 跨模块直接查询数据库表
- 位置:auth-guard.ts 第 70-163 行
- 问题:
resolveDataScope直接查询classes、classEnrollments、classSubjectTeachers、grades、parentStudentRelations表,这些表属于school、classes、parent等模块。 - 违反规则:
模块间只能通过对方 data-access 通信,禁止跨模块直接查询数据库表(项目规则「架构分层规则」)。 - 后果:
shared/lib反向依赖业务模块的表结构;若classes模块重构表结构,auth-guard会编译报错;shared/不得反向依赖业务模块的硬约束被破坏。
2.2 权限校验缺失或硬编码(P0/P1)
问题 2.2.1 — updateUserRoleAction 是空实现
- 位置:users/actions.ts 第 177-196 行
- 问题:
updateUserRoleAction接收userId和role参数后,仅void userId; void role;直接返回成功,未执行任何实际操作。 - 违反规则:
所有 Server Action 必须调用 requirePermission() 进行权限校验(虽已调用requirePermission,但逻辑为空)+ 功能完整性。 - 后果:管理员在用户管理页点击「编辑」试图修改角色时,系统提示成功但实际未变更,属于功能性 Bug。
问题 2.2.2 — resolveDataScope 角色硬编码
- 位置:auth-guard.ts 第 64、69、81、113、136 行
- 问题:使用
roleNames.includes("admin")、roleNames.includes("grade_head")、roleNames.includes("teacher")、roleNames.includes("student")、roleNames.includes("parent")硬编码角色名。 - 违反规则:
前端权限判断统一使用 usePermission().hasPermission(),严禁出现 role === "xxx" 硬编码(项目规则虽针对前端,但服务端 DataScope 解析也应避免硬编码角色名,以支持动态角色)。 - 后果:新增自定义角色无法正确解析 DataScope;
grade_head/teaching_head被合并处理,无法差异化授权。
问题 2.2.3 — role-utils.ts 将 grade_head/teaching_head 折叠为 teacher
- 位置:role-utils.ts 第 17 行
- 问题:
normalizeRole将grade_head/teaching_head映射为teacher,丢失了角色差异。 - 后果:
auth.tsJWT 回调中token.role = resolvePrimaryRole(allRoles)后,年级主任/教务主任的session.user.role变为teacher,前端若依赖role字段做路由跳转会丢失差异。
2.3 国际化遗漏(P1)
问题 2.3.1 — RBAC 组件大量硬编码英文
- 位置:
- role-list.tsx 第 84、88、98、102、140、151、153、173、179、190、216、227 行("No roles yet"、"Create Role"、"Roles"、"System"、"Custom"、"Enabled"、"Disabled"、"Edit"、"Enable"、"Disable"、"Delete"、"Deleting...")
- role-form-dialog.tsx 第 74、78-79、89、99、109、125 行("Create new role"、"Edit role"、"Role name"、"Description"、"Cancel"、"Saving...")
- user-role-assign-dialog.tsx 第 102、104-107、113、137、151、153、173 行("Assign roles"、"Save roles"、"Cancel"、"Saving...")
- permission-catalog-view.tsx 第 35、44 行("Permission Catalog"、"All permission points...")
- admin/roles/page.tsx 第 28-31 行
- admin/roles/[id]/page.tsx 第 53、56、63、65、78-80 行
- admin/permissions/page.tsx 第 43-44 行
- 违反规则:
所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键(项目规则「安全规范」+ 项目记忆 Hard Constraints)。 - 后果:中文用户看到英文界面,体验不一致;无法切换语言。
问题 2.3.2 — admin-users-view.tsx 大量硬编码中文
- 位置:admin-users-view.tsx 第 142、148、159、169、172、180、190、203、211-216、222、227、247、252、260、273、284、291、305、307、311、317 行
- 问题:与 2.3.1 相反,此处全部硬编码中文("用户管理"、"批量导入"、"搜索姓名或邮箱..."、"所有角色"、"搜索"、"重置"、"暂无用户"、"姓名"、"邮箱"、"角色"、"手机"、"注册时间"、"操作"、"编辑"、"分配角色"、"删除"、"确认删除用户?"等)。
- 违反规则:同 2.3.1。
- 后果:英文用户看到中文界面;与 RBAC 其他组件语言不一致(一半英文一半中文)。
问题 2.3.3 — rbac.json 缺少权限点标签
- 位置:zh-CN/rbac.json + en/rbac.json
- 问题:
PERMISSION_CATALOG中每个权限点都定义了labelKey(如rbac:permissions.exam.create)和descriptionKey,但 i18n 文件中只有permissions.group.*分组标签,缺少permissions.exam.create、permissions.exam.create.desc等具体权限点的翻译。 - 后果:
RolePermissionMatrix和PermissionCatalogView中t.has(labelKey) ? t(labelKey) : perm.key回退到显示原始 key(如EXAM_CREATE),用户无法理解权限含义。
2.4 TypeScript 类型不安全(P1)
问题 2.4.1 — as 断言违规
- 位置:
- permissions.ts 第 279、296 行
row.permission as Permission - auth-guard.ts 第 27-28 行
session.user.roles as Role[]、session.user.permissions as Permission[] - auth.ts 第 146 行
user as { id: string; ... }、第 196 行token.permissions as typeof token.permissions、第 217-223 行message as { ... } - data-access.ts 第 81、186 行
row.permission as Permission - data-access-assignments.ts 第 149 行
params.role as string - admin/permissions/page.tsx 第 36 行
row.permission as Permission - users/actions.ts 第 183-184、207 行
formData.get(...) as string - admin-users-view.tsx 第 128 行
e as Error
- permissions.ts 第 279、296 行
- 违反规则:
禁止 as 断言(除非从 unknown 转换或测试中,需注释原因)(项目规则「TypeScript 规则」)。 - 后果:类型安全被绕过,若 DB schema 或 session 结构变更,编译期不报错,运行时才暴露。
2.5 错误处理与边界缺失(P1)
问题 2.5.1 — 缺少 loading.tsx / error.tsx
- 位置:
app/(dashboard)/admin/roles/— 无loading.tsx、error.tsxapp/(dashboard)/admin/roles/[id]/— 无loading.tsx、error.tsxapp/(dashboard)/admin/permissions/— 无loading.tsx、error.tsxapp/(dashboard)/admin/users/— 无loading.tsx、error.tsx(仅users/import/有)
- 违反规则:
All student routes must include loading.tsx and error.tsx for error boundaries(项目记忆 Hard Constraints,虽针对 student 路由,但 admin 路由也应遵循)。 - 后果:页面加载时无骨架屏,体验差;
requirePermission抛出PermissionDeniedError时无友好错误页,显示 Next.js 默认错误。
问题 2.5.2 — 无 React Error Boundary 包裹独立数据区块
- 位置:所有 RBAC 组件
- 问题:
RoleList、RolePermissionMatrix、PermissionCatalogView等组件未用 Error Boundary 包裹,单个组件抛错会导致整页崩溃。 - 后果:权限矩阵加载失败时,角色列表也无法显示。
问题 2.5.3 — 无空数据/无权限/网络异常边界状态
- 位置:
RolePermissionMatrix无空权限组处理UserRoleAssignDialog无网络异常重试PermissionCatalogView无加载骨架
- 后果:异常状态下用户看到空白或卡顿。
2.6 组件不可复用 / 逻辑未抽取(P2)
问题 2.6.1 — admin-users-view.tsx 与 RBAC 组件重复实现
- 位置:admin-users-view.tsx
- 问题:
AdminUsersView自行实现了删除确认对话框、分页、搜索、角色筛选,未复用 RBAC 模块的EmptyState、分页组件等;同时硬编码了UserRoleAssignDialog的调用。 - 后果:UI 不一致,维护成本高。
问题 2.6.2 — admin-users-view.tsx 包含死代码
- 位置:第 246-248 行「编辑」菜单项
- 问题:
DropdownMenuItem点击无任何行为,未绑定onClick。 - 后果:用户点击「编辑」无反应,体验差。
问题 2.6.3 — 纯逻辑与 UI 未分离
- 位置:
RolePermissionMatrix中的setsEqual函数、选中状态计算逻辑 - 问题:
setsEqual工具函数内联在组件文件底部,未抽取到 hooks 或 utils;选中状态 diff 逻辑未抽取。 - 后果:无法单测,无法复用。
2.7 可访问性缺失(P2)
问题 2.7.1 — 缺少 ARIA 属性与键盘导航
- 位置:所有 RBAC 组件
- 问题:
RoleList表格无<caption>描述RolePermissionMatrix的 checkbox 分组无fieldset/legend语义UserRoleAssignDialog的角色列表无role="list"- 图标按钮(如
MoreHorizontal触发器)虽有sr-only但无aria-label
- 违反规则:
可访问性(a11y):语义化标签、ARIA 属性、键盘导航(项目规则「企业级补充」)。 - 后果:屏幕阅读器用户无法理解页面结构;键盘用户操作困难。
2.8 性能与监控缺失(P2)
问题 2.8.1 — 无 React Server Components 流式渲染
- 位置:所有 admin 页面
- 问题:页面使用
export const dynamic = "force-dynamic"但未使用loading.tsx或 Suspense 流式渲染,整页阻塞等待所有数据。 - 后果:首屏白屏时间长。
问题 2.8.2 — 无关键操作埋点
- 位置:
assignUserRolesAction、setRolePermissionsAction - 问题:虽有
logAudit审计日志,但无前端交互埋点(如权限变更成功率、耗时、失败原因分布)。 - 后果:无法监控权限管理操作的健康度。
2.9 安全性隐患(P1)
问题 2.9.1 — deleteUserAction 未校验目标用户是否为内置角色持有者
- 位置:users/actions.ts 第 201-217 行
- 问题:删除用户时未检查该用户是否为最后一个 admin 角色持有者,可能导致系统无管理员。
- 后果:管理员误删后无法恢复。
问题 2.9.2 — assignRolesToUser 未校验角色是否为内置且未禁用
- 位置:data-access-assignments.ts 第 42-73 行
- 问题:
assignRolesToUser仅校验角色名存在,未校验角色是否isEnabled,可将禁用角色分配给用户(虽resolvePermissions会过滤禁用角色,但数据不一致)。 - 后果:用户被分配禁用角色后,UI 显示已分配但实际无权限,造成困惑。
三、行业差距对比
3.1 权限模型维度
| 能力 | 行业优秀实践(如 PowerSchool、Veracross、Alma) | 当前实现 | 差距影响 |
|---|---|---|---|
| 权限继承 | 支持权限继承(如 admin 继承 teacher 所有权限) | 扁平权限,无继承 | 新增角色需手动勾选所有权限,易遗漏 |
| 权限模板 | 提供角色模板(如「班主任」「学科组长」)一键创建 | 无模板,从零配置 | 管理员配置成本高 |
| 权限预览 | 保存前预览「该角色将获得哪些菜单/操作」 | 仅显示权限点列表 | 管理员无法直观理解权限效果 |
| 权限差异对比 | 对比两个角色的权限差异 | 无 | 无法评估角色调整影响 |
| 权限使用统计 | 统计每个权限点被多少角色/用户使用 | PermissionCatalogView 仅统计角色数,未统计用户数 |
无法识别「僵尸权限」 |
| 数据范围权限 | 支持「只能看自己班级」「只能看本年级」等行级权限 | DataScope 已实现但硬编码角色 |
自定义角色无法配置数据范围 |
| 权限生效时间 | 支持权限定时生效/过期 | 无 | 临时授权需手动撤销 |
3.2 UI/UX 维度
| 能力 | 行业优秀实践 | 当前实现 | 差距影响 |
|---|---|---|---|
| 权限矩阵搜索 | 支持按权限名/模块搜索过滤 | 无搜索,67 个权限点全展示 | 管理员查找特定权限困难 |
| 权限分组折叠 | 支持折叠/展开权限分组 | 分组固定展开 | 页面过长,滚动疲劳 |
| 批量角色分配 | 支持批量给多个用户分配角色 | 仅单个用户 | 批量入职时效率低 |
| 角色克隆 | 复制现有角色创建新角色 | 无 | 相似角色需重新勾选 |
| 权限变更审计可视化 | 时间轴展示角色权限变更历史 | 仅审计日志文本 | 无法直观追溯权限演进 |
| 权限影响范围分析 | 显示「修改此角色将影响 N 个用户」 | RoleList 显示 userCount 但编辑时不提示 |
管理员不知修改影响范围 |
| 实时权限校验 | 前端实时显示当前用户是否有权限操作 | usePermission 已实现但未在 UI 普遍使用 |
用户点击后才报无权限 |
3.3 多角色支持维度
| 能力 | 行业优秀实践 | 当前实现 | 差距影响 |
|---|---|---|---|
| 多角色叠加 | 用户多角色权限取并集 | resolvePermissions 已合并 |
✅ 已实现 |
| 主角色判定 | 支持配置主角色(用于路由/默认视图) | resolvePrimaryRole 硬编码优先级 |
无法自定义主角色 |
| 角色冲突检测 | 检测互斥角色(如 teacher + student) | 无 | 用户可同时拥有冲突角色 |
| 角色有效期 | 角色分配支持起止时间 | 无 | 临时角色无法自动撤销 |
3.4 安全合规维度
| 能力 | 行业优秀实践 | 当前实现 | 差距影响 |
|---|---|---|---|
| 最小权限原则校验 | 提示「该角色权限过大」 | 无 | 易授予过度权限 |
| 敏感权限二次确认 | 修改 admin/删除角色需二次确认 | RoleList 有删除确认,但改权限无 |
误操作风险 |
| 权限变更通知 | 权限变更通知受影响用户 | 无 | 用户不知权限被调整 |
| 权限分离审计 | 审计日志记录操作者 IP/UA | logAudit 未记录 IP/UA |
无法追溯操作来源 |
四、改进优先级建议
P0 — 紧急(安全/架构违规,必须立即修复)
| 编号 | 问题 | 改进方向 |
|---|---|---|
| P0-1 | admin/permissions/page.tsx 直查 DB(问题 2.1.1) |
将权限统计查询下沉到 modules/rbac/data-access.ts,新增 getPermissionRoleCounts() 函数 |
| P0-2 | deleteUserAction 直查 DB(问题 2.1.2) |
将删除逻辑下沉到 modules/users/data-access.ts,新增 deleteUserById() 并处理级联清理 |
| P0-3 | admin-users-view.tsx 用 fetch 删除用户(问题 2.1.3) |
改为调用 deleteUserAction Server Action |
| P0-4 | getCurrentStudentUser 用 auth()(问题 2.1.4) |
改为调用 getAuthContext() 获取 userId |
| P0-5 | updateUserRoleAction 空实现(问题 2.2.1) |
删除此死代码,角色分配统一走 rbac/actions.ts 的 assignUserRolesAction |
| P0-6 | 缺少 loading.tsx/error.tsx(问题 2.5.1) |
为 admin/roles、admin/roles/[id]、admin/permissions、admin/users 新增 |
P1 — 高优先级(i18n/类型安全/错误处理)
| 编号 | 问题 | 改进方向 |
|---|---|---|
| P1-1 | RBAC 组件硬编码英文(问题 2.3.1) | 提取翻译键到 rbac.json,组件改用 useTranslations |
| P1-2 | admin-users-view.tsx 硬编码中文(问题 2.3.2) |
提取翻译键到 users.json |
| P1-3 | rbac.json 缺权限点标签(问题 2.3.3) |
补全 67 个权限点的 label + desc 翻译 |
| P1-4 | as 断言违规(问题 2.4.1) |
用类型守卫替代,如 isPermission(value): value is Permission |
| P1-5 | resolveDataScope 跨模块查表(问题 2.1.5) |
改为调用 classes/data-access、parent/data-access 等模块的查询函数 |
| P1-6 | resolveDataScope 角色硬编码(问题 2.2.2) |
改为基于权限点判断(如 hasPermission(DASHBOARD_ADMIN_READ) → all scope)或配置驱动 |
| P1-7 | 无 Error Boundary(问题 2.5.2) | 为 RoleList、RolePermissionMatrix、PermissionCatalogView 包裹 Error Boundary |
| P1-8 | deleteUserAction 未保护最后 admin(问题 2.9.1) |
删除前校验目标用户是否为最后一个 admin |
| P1-9 | assignRolesToUser 未校验角色启用状态(问题 2.9.2) |
分配前过滤 isEnabled = false 的角色 |
P2 — 中长期(体验/性能/可扩展性)
| 编号 | 问题 | 改进方向 |
|---|---|---|
| P2-1 | 权限矩阵无搜索/折叠 | 新增搜索框 + 分组折叠交互 |
| P2-2 | 无角色模板 | 预置「班主任」「学科组长」等模板 |
| P2-3 | 无权限变更影响提示 | 编辑权限时显示「将影响 N 个用户」 |
| P2-4 | 无权限使用统计 | PermissionCatalogView 增加用户数统计 |
| P2-5 | 无 a11y 属性 | 补充 ARIA、caption、fieldset |
| P2-6 | 无 RSC 流式渲染 | 引入 Suspense + 骨架屏 |
| P2-7 | 无关键操作埋点 | 预留 trackPermissionChange() 接口 |
| P2-8 | role-utils.ts 折叠角色(问题 2.2.3) |
保留 grade_head/teaching_head 差异 |
| P2-9 | 无权限继承 | 支持角色继承父角色权限 |
| P2-10 | 无权限变更通知 | 权限变更后通知受影响用户 |
五、架构图同步说明
本次审计发现架构图存在以下遗漏/不一致,需同步更新:
5.1 004_architecture_impact_map.md 需补充
-
shared/lib/auth-guard.ts的resolveDataScope跨模块依赖:- 当前依赖
classes、classEnrollments、classSubjectTeachers、grades、parentStudentRelations表 - 应标注为「待重构:改为调用各模块 data-access」
- 当前依赖
-
modules/users/data-access.ts的getCurrentStudentUser异常依赖:- 直接调用
auth()而非getAuthContext() - 应标注为「待修复:P0-4」
- 直接调用
-
app/(dashboard)/admin/permissions/page.tsx直查 DB 违规:- 应标注为「待修复:P0-1」
-
modules/users/actions.ts的deleteUserAction直查 DB:- 应标注为「待修复:P0-2」
-
缺失的
loading.tsx/error.tsx:admin/roles、admin/roles/[id]、admin/permissions、admin/users四个路由
5.2 005_architecture_data.json 需补充
modules.rbac.dependencies.dependsOn增加shared/lib/permissions(resolvePermissions被auth.ts使用,但 rbac 模块本身不直接依赖,需在auth节点标注)modules.rbac.exports.dataAccess增加getPermissionRoleCounts(P0-1 新增函数)modules.users.exports.dataAccess增加deleteUserById(P0-2 新增函数)modules.users.exports.actions标注updateUserRoleAction为 deprecated/删除app路由节点增加缺失的loading.tsx/error.tsx文件
六、重构方案设计
6.1 目标架构
app/(dashboard)/admin/
├─ roles/
│ ├─ page.tsx # RSC: 调用 rbac/data-access.getRoles()
│ ├─ loading.tsx # 骨架屏
│ ├─ error.tsx # 错误边界
│ └─ [id]/
│ ├─ page.tsx # RSC: 调用 rbac/data-access.getRoleById()
│ ├─ loading.tsx
│ └─ error.tsx
├─ permissions/
│ ├─ page.tsx # RSC: 调用 rbac/data-access.getPermissionRoleCounts()
│ ├─ loading.tsx
│ └─ error.tsx
└─ users/
├─ page.tsx # RSC: 调用 users/data-access.getAdminUsers()
├─ loading.tsx
└─ error.tsx
modules/rbac/
├─ actions.ts # Server Actions(编排层)
├─ data-access.ts # 角色 CRUD + 权限统计
├─ data-access-assignments.ts # 用户-角色分配
├─ data-access-permissions.ts # 权限查询(新增 getPermissionRoleCounts)
├─ schema.ts # Zod 验证
├─ types.ts # 类型定义
├─ lib/
│ ├─ permission-catalog.ts # 权限目录常量
│ └─ permission-diff.ts # 权限差异计算纯函数(新增)
├─ hooks/
│ ├─ use-role-permissions.ts # 选中状态管理 Hook(新增)
│ └─ use-permission-search.ts # 权限搜索过滤 Hook(新增)
└─ components/
├─ role-list.tsx
├─ role-form-dialog.tsx
├─ role-permission-matrix.tsx
├─ permission-catalog-view.tsx
├─ user-role-assign-dialog.tsx
├─ role-management-view.tsx
├─ role-detail-edit-button.tsx
├─ permission-search-bar.tsx # 新增
├─ permission-impact-badge.tsx # 新增:显示影响用户数
└─ error-boundary.tsx # 新增:RBAC 专用错误边界
modules/users/
├─ actions.ts # 删除 updateUserRoleAction,deleteUserAction 改调 data-access
├─ data-access.ts # 新增 deleteUserById
└─ components/
└─ admin-users-view.tsx # i18n 化,改用 Server Action
shared/lib/
├─ auth-guard.ts # resolveDataScope 改调各模块 data-access
├─ permissions.ts # 用类型守卫替代 as
├─ role-utils.ts # 保留 grade_head/teaching_head 差异
└─ type-guards.ts # 新增 isPermission/isRole 类型守卫
6.2 数据服务接口抽象(解耦)
// modules/rbac/types.ts 新增
/** 角色数据服务接口 — 供依赖注入使用 */
export interface RoleDataService {
getRoles(): Promise<RoleWithStats[]>
getRoleById(id: string): Promise<RoleDetail | null>
createRole(input: CreateRoleInput): Promise<RoleRecord>
updateRole(id: string, input: UpdateRoleInput): Promise<RoleRecord>
deleteRole(id: string): Promise<void>
setRoleEnabled(id: string, enabled: boolean): Promise<RoleRecord>
getRolePermissions(roleId: string): Promise<Permission[]>
setRolePermissions(roleId: string, permissions: Permission[]): Promise<void>
getPermissionRoleCounts(): Promise<Record<string, number>>
}
/** 用户-角色分配数据服务接口 */
export interface UserRoleAssignmentService {
getUserRoleNames(userId: string): Promise<string[]>
assignRolesToUser(userId: string, roleNames: string[]): Promise<void>
getUserRoleAssignments(params?: {
page?: number
pageSize?: number
search?: string
role?: string
}): Promise<PaginatedResult<UserRoleAssignment>>
}
// modules/rbac/data-access.ts 实现 RoleDataService
// modules/rbac/data-access-assignments.ts 实现 UserRoleAssignmentService
6.3 组合优先的 UI 设计
// modules/rbac/components/role-permission-matrix.tsx 重构后
"use client"
import { useRolePermissions } from "../hooks/use-role-permissions"
import { usePermissionSearch } from "../hooks/use-permission-search"
import { PermissionSearchBar } from "./permission-search-bar"
import { PermissionImpactBadge } from "./permission-impact-badge"
import { ErrorBoundary } from "./error-boundary"
interface RolePermissionMatrixProps {
roleId: string
roleName: string
currentPermissions: Permission[]
userCount: number // 新增:用于影响提示
isLocked: boolean
}
export function RolePermissionMatrix({
roleId,
roleName,
currentPermissions,
userCount,
isLocked,
}: RolePermissionMatrixProps) {
const { selected, hasChanges, toggle, toggleGroup, reset } = useRolePermissions(currentPermissions)
const { query, filteredCatalog, setQuery } = usePermissionSearch(PERMISSION_CATALOG)
return (
<ErrorBoundary fallback={<PermissionMatrixError />}>
<Card>
<CardHeader>
<PermissionMatrixHeader
roleName={roleName}
isLocked={isLocked}
selectedCount={selected.size}
hasChanges={hasChanges}
userCount={userCount}
/>
{!isLocked && hasChanges && (
<PermissionImpactBadge count={userCount} />
)}
</CardHeader>
<CardContent>
<PermissionSearchBar value={query} onChange={setQuery} />
<PermissionGroups
catalog={filteredCatalog}
selected={selected}
isLocked={isLocked}
onToggle={toggle}
onToggleGroup={toggleGroup}
/>
<PermissionMatrixActions
isLocked={isLocked}
hasChanges={hasChanges}
onReset={reset}
onSave={() => handleSave(roleId, selected)}
/>
</CardContent>
</Card>
</ErrorBoundary>
)
}
6.4 i18n 翻译文件结构
// shared/i18n/messages/zh-CN/rbac.json(补全后)
{
"roles": { ... },
"permissions": {
"title": "权限目录",
"description": "系统中定义的所有权限点,按模块分组。",
"group": { ... },
"exam": {
"create": { "label": "创建考试", "desc": "允许创建新考试" },
"read": { "label": "查看考试", "desc": "允许查看考试列表和详情" }
// ... 67 个权限点
}
},
"matrix": {
"search": "搜索权限...",
"selected": "已选 {count} 项",
"impact": "修改将影响 {count} 个用户",
"save": "保存更改",
"reset": "重置"
},
"errors": {
"loadFailed": "加载失败,请重试",
"saveFailed": "保存失败:{message}"
}
}
6.5 错误与边界处理
// modules/rbac/components/error-boundary.tsx
"use client"
import { Component, type ReactNode } from "react"
import { Button } from "@/shared/components/ui/button"
import { AlertCircle } from "lucide-react"
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback ?? <DefaultErrorFallback error={this.state.error} />
}
return this.props.children
}
}
function DefaultErrorFallback({ error }: { error?: Error }): ReactNode {
return (
<div role="alert" className="rounded-md border border-destructive/50 p-4">
<AlertCircle className="h-5 w-5 text-destructive" aria-hidden="true" />
<p className="mt-2 text-sm text-destructive">
{error?.message ?? "加载失败"}
</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => window.location.reload()}>
重试
</Button>
</div>
)
}
// app/(dashboard)/admin/roles/loading.tsx
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading(): JSX.Element {
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-96 w-full" />
</div>
)
}
// app/(dashboard)/admin/roles/error.tsx
"use client"
import { useEffect } from "react"
import { Button } from "@/shared/components/ui/button"
import { ShieldAlert } from "lucide-react"
export default function Error({ error, reset }: {
error: Error & { digest?: string }
reset: () => void
}): JSX.Element {
useEffect(() => {
console.error("Roles page error:", error)
}, [error])
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<ShieldAlert className="h-12 w-12 text-destructive" aria-hidden="true" />
<h2 className="text-xl font-semibold">角色管理加载失败</h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}>重试</Button>
</div>
)
}
6.6 可测试性设计
// modules/rbac/lib/permission-diff.ts(纯函数,可单测)
import type { Permission } from "@/shared/types/permissions"
export interface PermissionDiff {
added: Permission[]
removed: Permission[]
unchanged: Permission[]
}
export function diffPermissions(
before: Permission[],
after: Permission[]
): PermissionDiff {
const beforeSet = new Set(before)
const afterSet = new Set(after)
return {
added: after.filter((p) => !beforeSet.has(p)),
removed: before.filter((p) => !afterSet.has(p)),
unchanged: after.filter((p) => beforeSet.has(p)),
}
}
export function arePermissionsEqual(
a: Permission[],
b: Permission[]
): boolean {
if (a.length !== b.length) return false
const set = new Set(a)
return b.every((p) => set.has(p))
}
// modules/rbac/hooks/use-role-permissions.ts(纯逻辑 Hook)
"use client"
import { useCallback, useMemo, useState } from "react"
import type { Permission } from "@/shared/types/permissions"
import { arePermissionsEqual } from "../lib/permission-diff"
export function useRolePermissions(initial: Permission[]) {
const [selected, setSelected] = useState<Set<string>>(new Set(initial))
const hasChanges = useMemo(
() => !setsEqual(selected, new Set(initial)),
[selected, initial]
)
const toggle = useCallback((permission: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(permission)
else next.delete(permission)
return next
})
}, [])
const toggleGroup = useCallback((permissions: string[], checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
for (const p of permissions) {
if (checked) next.add(p)
else next.delete(p)
}
return next
})
}, [])
const reset = useCallback(() => setSelected(new Set(initial)), [initial])
return { selected, hasChanges, toggle, toggleGroup, reset }
}
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) return false
for (const v of a) if (!b.has(v)) return false
return true
}
6.7 配置驱动的可扩展设计
// modules/rbac/lib/role-templates.ts(新增)
import type { Permission } from "@/shared/types/permissions"
import { Permissions } from "@/shared/types/permissions"
export interface RoleTemplate {
id: string
nameKey: string
descriptionKey: string
permissions: Permission[]
}
export const ROLE_TEMPLATES: RoleTemplate[] = [
{
id: "homeroom_teacher",
nameKey: "rbac:templates.homeroom_teacher.name",
descriptionKey: "rbac:templates.homeroom_teacher.desc",
permissions: [
Permissions.EXAM_READ,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.CLASS_READ,
Permissions.ATTENDANCE_MANAGE,
Permissions.MESSAGE_SEND,
Permissions.DASHBOARD_TEACHER_READ,
],
},
{
id: "subject_teacher",
nameKey: "rbac:templates.subject_teacher.name",
descriptionKey: "rbac:templates.subject_teacher.desc",
permissions: [
Permissions.EXAM_CREATE,
Permissions.QUESTION_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.GRADE_RECORD_MANAGE,
],
},
// ... 更多模板
]
6.8 监控埋点接口
// shared/lib/analytics.ts(预留接口)
export interface PermissionChangeMetrics {
action: "role.create" | "role.update" | "role.delete" | "role.set_permissions" | "user.assign_roles"
targetId: string
targetType: "role" | "user"
changes?: {
added?: number
removed?: number
}
affectedUsers?: number
durationMs: number
success: boolean
errorMessage?: string
}
export async function trackPermissionChange(metrics: PermissionChangeMetrics): Promise<void> {
// 预留实现:可接入 Sentry / PostHog / 自建埋点
if (process.env.NODE_ENV === "development") {
console.log("[permission-change]", metrics)
}
}
七、实施计划
7.1 第一阶段(P0 — 立即修复)
-
P0-1:
admin/permissions/page.tsx改用 data-access- 在
modules/rbac/data-access-permissions.ts新增getPermissionRoleCounts() - 页面改为
import { getPermissionRoleCounts } from "@/modules/rbac/data-access-permissions"
- 在
-
P0-2:
deleteUserAction改用 data-access- 在
modules/users/data-access.ts新增deleteUserById(userId: string): Promise<void> - 包含级联清理 sessions、usersToRoles、passwordSecurity
- actions.ts 改调
deleteUserById
- 在
-
P0-3:
admin-users-view.tsx改用 Server Action- 删除
fetch("/api/admin/users/...")逻辑 - 改用
useActionMutation+deleteUserAction
- 删除
-
P0-4:
getCurrentStudentUser改用getAuthContext()const ctx = await getAuthContext()→ctx.userId
-
P0-5:删除
updateUserRoleAction空实现 -
P0-6:新增
loading.tsx/error.tsx- 4 个路由各 2 个文件,共 8 个文件
7.2 第二阶段(P1 — i18n + 类型安全)
-
P1-1/P1-2/P1-3:i18n 化
- 补全
rbac.json的 67 个权限点标签 - RBAC 组件改用
useTranslations admin-users-view.tsx改用useTranslations
- 补全
-
P1-4:类型守卫
shared/lib/type-guards.ts新增isPermission(value: unknown): value is Permission- 替换所有
as Permission断言
-
P1-5:
resolveDataScope改调模块 data-accessclasses模块新增getClassIdsForTeacher(userId)、getClassIdsForStudent(userId)parent模块新增getChildrenIdsForParent(userId)school模块新增getGradeIdsForHead(userId)
-
P1-6:
resolveDataScope改为配置驱动- 新增
shared/lib/data-scope-resolver.ts - 基于权限点而非角色名判断 scope
- 新增
-
P1-7:Error Boundary
- 新增
modules/rbac/components/error-boundary.tsx - 包裹
RoleList、RolePermissionMatrix、PermissionCatalogView
- 新增
-
P1-8/P1-9:安全性增强
deleteUserAction校验最后 adminassignRolesToUser过滤禁用角色
7.3 第三阶段(P2 — 体验/性能/可扩展)
- P2-1:权限矩阵搜索 + 折叠
- P2-2:角色模板
- P2-3:权限变更影响提示
- P2-4:权限使用统计
- P2-5:a11y 增强
- P2-6:Suspense 流式渲染
- P2-7:监控埋点
- P2-8:
role-utils.ts保留角色差异 - P2-9:权限继承
- P2-10:权限变更通知
八、合规性检查
| 约束 | 当前状态 | 重构后 |
|---|---|---|
三层架构 app → modules → shared |
❌ app 直查 DB | ✅ 全部走 data-access |
app/ 不直接访问数据库 |
❌ 3 处违规 | ✅ 修复 |
| 模块间通过 data-access 通信 | ❌ auth-guard 跨模块查表 |
✅ 改调 data-access |
Server Action 调用 requirePermission() |
✅ 已实现 | ✅ 保持 |
前端用 usePermission().hasPermission() |
✅ 已实现 | ✅ 保持 |
| i18n 适配 | ❌ 大量硬编码 | ✅ 全部提取翻译键 |
TypeScript 严格模式(无 any/as) |
❌ 多处 as |
✅ 用类型守卫替代 |
| 单文件行数 ≤ 500/800/1000 | ✅ 当前均未超标 | ✅ 保持 |
loading.tsx/error.tsx |
❌ 缺失 | ✅ 补全 |
| 架构图同步 | ❌ 有遗漏 | ✅ 同步更新 |
九、结论
用户权限模块的核心基础设施(Permissions 常量、resolvePermissions、requirePermission、usePermission)设计合理,RBAC 模块的 actions/data-access 分层清晰。但存在 6 个 P0 级架构违规(app 直查 DB、Server Action 直查 DB、客户端 fetch、认证入口不统一、空实现 Action、缺 loading/error)、9 个 P1 级问题(i18n 遗漏、类型断言、跨模块查表、角色硬编码、错误边界缺失、安全隐患)和 10 个 P2 级改进(搜索/模板/统计/a11y/性能/监控等)。
建议按 P0 → P1 → P2 顺序实施,P0 必须立即修复以消除安全与架构风险,P1 在本迭代内完成,P2 纳入后续迭代规划。