13 KiB
角色管理模块
Status: ready-for-agent
Problem Statement
现有角色管理散落在 SystemController 的 /api/system/roles/* 端点中,仅实现了基础 CRUD + 菜单分配,缺少祖先补全(ADR-0005)、校验规则、权限控制、角色详情接口和内置角色保护。ADR-0005 要求前端只传用户勾选的叶子节点 ID,后端保存前沿 parentId 向上遍历补全全部祖先 catalog/menu ID,确保菜单可见性链路完整。当前 SysRoleServiceImpl.assignMenus 直接先删后插,不做任何祖先补全,导致如果前端只传按钮节点,权限解析引擎建菜单树时祖先不在授权集合中,用户虽有按钮权限却看不到所在页面。
Solution
新建独立的 RoleController(/api/roles/*),实现完整的角色管理闭环:分页查询、详情(含已补全的授权集合)、保存(带校验)、删除(级联清理)、权限分配(带祖先补全)。采用纯 hasAuthority 权限控制,通过幂等数据初始化器在权限资源树中创建角色管理的 button 权限点并分配给管理员角色。从 SystemController 移除全部角色端点。
User Stories
- 作为管理员,我希望分页查看角色列表,以便浏览和管理系统中的角色
- 作为管理员,我希望通过关键词搜索角色名称,以便快速定位特定角色
- 作为管理员,我希望查看角色详情包含基本信息和已补全的授权集合,以便了解角色当前持有哪些权限资源
- 作为管理员,我希望创建新角色并填写基本信息(名称、编码、数据范围、排序、备注),以便定义新的可分配角色
- 作为管理员,我希望编辑现有角色的基本信息,以便更新角色属性
- 作为管理员,我希望删除不再使用的角色,以便保持角色列表整洁
- 作为管理员,我希望通过勾选权限资源树节点来分配功能权限,以便控制该角色持有哪些权限点
- 作为管理员,我希望后端在保存功能权限时自动补全祖先节点,以便菜单可见性链路不会因遗漏祖先而断裂
- 作为管理员,我希望角色详情返回的授权集合是已补全的完整集合,以便前端直接渲染勾选状态无需二次补全
- 作为管理员,我希望被阻止删除内置角色,以便不会意外锁定系统导致不可恢复
- 作为管理员,我希望被阻止修改内置角色的编码,以便系统关键角色标识保持稳定
- 作为管理员,我希望角色编码唯一性被强制校验,以便两个角色不会共享同一编码导致权限解析异常
- 作为管理员,我希望数据范围值被校验在合法范围内,以便非法值不会导致角色被静默跳过
- 作为管理员,我希望删除角色时同时清理用户-角色关联,以便不留下指向不存在角色的孤儿关联记录
- 作为持有
crm:role:list权限的非管理员用户,我希望能够查看角色列表,以便拥有只读角色访问能力 - 作为持有
crm:role:save权限的非管理员用户,我希望能够保存角色信息,以便在无法删除角色的情况下仍可编辑 - 作为持有
crm:role:assign权限的非管理员用户,我希望能够分配角色权限,以便管理角色授权而无需完整管理员权限 - 作为持有
crm:role:delete权限的非管理员用户,我希望能够删除角色,以便管理角色生命周期 - 作为前端开发者,我希望权限分配端点只接收我勾选的原始节点 ID,以便不需要在前端实现祖先补全逻辑
- 作为前端开发者,我希望角色详情返回的授权集合是完整子树,以便直接用于设置勾选框状态
- 作为前端开发者,我希望权限资源树查询接口仍然可用,以便渲染勾选树的 UI 结构
- 作为前端开发者,我希望用户可见菜单树接口不受影响,以便登录后侧边栏渲染链路不被破坏
- 作为开发者,我希望数据初始化器是幂等的,以便首次启动和已有系统都能正确初始化权限数据
- 作为开发者,我希望内置角色标记由数据初始化器设置而非通过 API,以便防止通过接口将任意角色标记为内置
- 作为开发者,我希望角色管理的权限码被种子化到权限资源树中,以便部署后 hasAuthority 检查即可生效
- 作为开发者,我希望祖先补全逻辑作为通用能力放在菜单服务上,以便角色服务和资源服务都可以复用
- 作为开发者,我希望删除角色时在事务保护下同时清理三张表(角色本身、角色-资源关联、用户-角色关联),以便操作原子性有保障
- 作为开发者,我希望角色列表返回
builtin标记,以便前端可以标识内置角色并隐藏删除按钮 - 作为开发者,我希望创建角色时不能设置
builtin=true,以便只有数据初始化器能创建内置角色 - 作为开发者,我希望现有
/api/system/roles/*端点被移除而非保留兼容,以便不产生 API 冗余
Implementation Decisions
架构决策(ADR-0012 完整记录)
- 独立 RoleController,路径
/api/roles/*:从SystemController移除全部角色端点,新建独立 Controller。与ResourceController的独立模式一致。旧路径/api/system/roles/*不再可用。 - 分步保存:基本信息保存(
POST /api/roles/saveOrUpdate)与权限分配(POST /api/roles/assign-resources)分离,各自独立调用。角色编辑页 Tab 分页各自保存。角色详情(GET /api/roles/detail)返回基本信息 + 授权集合。 - 术语统一 resourceIds:API 层统一使用
resourceIds,与 ADR-0011 权限资源树术语体系一致。底层表名sys_role_menu不变,存储层命名与 API 层术语解耦。 - 祖先补全放在 ISysMenuService:新增
getAncestorIds(Collection<Long> menuIds)方法。沿 parentId 向上遍历是 sys_menu 表的固有操作。实现方式:批量查全表构建 ID→parentId 映射,内存中遍历,避免逐节点查 DB。SysRoleServiceImpl.assignResources调用它拿到补全后的集合再写库。 - 纯 hasAuthority + 数据种子:角色管理端点使用
@PreAuthorize("hasAuthority('crm:role:xxx')")。权限码通过 button 节点的 perms 字段注入(ADR-0011 权限码并集机制)。引导问题通过幂等数据初始化器解决。 - 内置角色保护:SysRole.builtin 字段:
SysRole新增Boolean builtin字段。builtin=true的角色禁止删除、禁止改 roleCode。创建角色接口不允许设builtin=true。 - 校验规则:roleCode 唯一性(保存时查 DB,已存在且 id 非当前记录则拒绝);内置角色保护(builtin=true 禁止删除和改 code);dataScope 范围校验(必须在 1-4 范围内)。
- 删除级联:
deleteRoleCascade同时清理sys_role_menu和sys_user_role。事务保护,三表操作原子性。 - 数据初始化器幂等化:将"整体跳过"改为"逐项检查"。ADMIN 角色已存在就只补
builtin=true,button 权限点不存在就创建,角色-权限绑定不存在就补建。
API 契约
遵循全局接口契约(非严格 RESTful):
| 端点 | 方法 | 权限码 | 说明 |
|---|---|---|---|
/api/roles/page |
POST | crm:role:list |
分页查询,current/size/keyword 表单参数 |
/api/roles/detail |
GET | crm:role:detail |
角色详情,roleId query 参数,返回 RoleDetailVO |
/api/roles/saveOrUpdate |
POST | crm:role:save |
保存基本信息,表单参数(id 可选、roleName、roleCode、dataScope、sort、remark) |
/api/roles/delete |
POST | crm:role:delete |
删除角色,id 表单参数,级联清理 |
/api/roles/assign-resources |
POST | crm:role:assign |
分配权限,roleId + resourceIds(逗号分隔字符串)表单参数,后端补全祖先 |
DTO 设计
- RoleDetailVO:
id(Long,JSON 序列化为 String)、roleName、roleCode、dataScope、sort、remark、builtin(Boolean)、resourceIds(List,已补全的完整授权集合,JSON 序列化为 List) - 分页列表返回
PageResult<SysRole>(实体含 builtin 字段,无需额外 DTO)
权限码种子数据
在权限资源树的"角色管理"menu 节点下创建 5 个 button 权限点:
| 名称 | perms | apiUrl | denyBehavior | status |
|---|---|---|---|---|
| 角色列表 | crm:role:list |
/api/roles/page |
hide | enabled |
| 角色详情 | crm:role:detail |
/api/roles/detail |
hide | enabled |
| 角色保存 | crm:role:save |
/api/roles/saveOrUpdate |
hide | enabled |
| 角色删除 | crm:role:delete |
/api/roles/delete |
hide | enabled |
| 权限分配 | crm:role:assign |
/api/roles/assign-resources |
hide | enabled |
数据模型变更
SysRole新增Boolean builtin字段(@Column(columnDefinition = "boolean default false")),ddl-auto: update 自动加列。sys_role_menu表名不变,sys_user_role表名不变。
涉及模块
crm-auth:RoleController(新建)、ISysRoleService / SysRoleServiceImpl(增强)、ISysMenuService / SysMenuServiceImpl(新增祖先补全)、RoleDetailVO(新建 DTO)、DataInitializer(幂等化重构)、SystemController(移除角色端点)、SysRole(加 builtin 字段)
Testing Decisions
测试理念
只测外部行为,不测实现细节。测试通过服务接口验证业务逻辑的正确性,不测私有方法、不测 mock 的调用次数、不测内部分支路径。测试名用自然语言描述场景,断言用 AssertJ。
测试 Seam:服务层 Mockito 单元测试
单一 seam,复用 ResourceServiceImplTest 的模式(@ExtendWith(MockitoExtension.class) + @Mock mapper 依赖 + @InjectMocks 服务)。
SysMenuServiceImplTest(新建)— 祖先补全逻辑:
- 单链向上遍历:button(100) → menu(10) → catalog(1) → root(0),传入 {100},返回 {100, 10, 1}
- 多链汇合:两条独立链的叶子节点,返回包含两条链全部祖先的并集
- 根节点:传入 parentId=0 的 catalog,返回它自己(无祖先可补)
- 空输入:传入空集合,返回空集合
- 已含祖先:传入 {100, 10, 1},返回 {100, 10, 1}(幂等,不重复)
SysRoleServiceImplTest(新建)— 角色服务增强:
- assignResources 调用祖先补全后写库(验证写入的 menuIds 包含祖先)
- assignResources 先删后插全量替换(验证旧数据被清除)
- deleteRoleCascade 同时清理 sys_role_menu 和 sys_user_role
- 保存时 roleCode 唯一性校验(已存在同 code 不同 id → 拒绝)
- 保存时 roleCode 唯一性校验(同 code 同 id → 通过,编辑场景)
- 保存时 builtin 角色禁止改 roleCode
- 保存时 dataScope 超范围 → 拒绝
- 保存时 dataScope 合法范围(1-4)→ 通过
- 删除时 builtin=true 的角色 → 拒绝
- 删除时 builtin=false 的角色 → 通过
- 创建角色时 builtin=true → 拒绝(不允许通过 API 设置 builtin)
- getRoleDetail 返回 RoleDetailVO 含基本信息 + resourceIds
Prior Art
ResourceServiceImplTest(599 行):Mockito + AssertJ,覆盖资源树查询、新增/编辑校验、层级约束、字段必填。是本功能测试的直接模板。DataVisibilityTest:纯领域逻辑测试,无 mock,用于验证档位折算规则。
Out of Scope
ResourceController的hasRole('ADMIN')迁移到hasAuthority—— 暂不迁移,作为后续渐进迁移的对照。- 前端角色管理页面的实现 —— 本 spec 只覆盖后端。
- 用户-角色分配界面(
/api/system/users/assign-roles)—— 现有端点不变,不在本 spec 范围内。 - 权限资源树的 CRUD 管理 —— 已由
ResourceController(ADR-0011)实现,不重复。 SystemController中菜单树、部门、用户相关端点的迁移或重构 —— 只移除角色端点,其他不动。- 数据库迁移工具(Flyway/Liquibase)的引入 —— 使用 ddl-auto: update + 幂等初始化器,不引入迁移工具。
Further Notes
- ADR-0005(原型侧)决定祖先补全由后端负责。ADR-0012(本仓库)记录了角色管理模块的完整架构决策。
- CONTEXT.md 已新增三个领域术语:角色授权集合、祖先补全、内置角色。
- 现有
DataInitializer中管理员角色的 roleCode 为ROLE_ADMIN,Spring Security 的hasRole('ADMIN')会自动加ROLE_前缀匹配。迁移到hasAuthority后,权限码通过 button 节点的 perms 字段直接注入,不经过ROLE_前缀归一。 PermissionResolverImpl的authorizedMenus方法已批量查sys_role_menu+sys_menu,祖先补全逻辑的批量查询模式与之一致。assign-resources端点的resourceIds参数使用逗号分隔字符串(与现有assign-menus的menuIds参数格式一致),符合全局接口契约中"复杂字段前端 JSON.stringify 后作为普通表单字段传递"的规范。