You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

150 lines
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
1. 作为管理员,我希望分页查看角色列表,以便浏览和管理系统中的角色
2. 作为管理员,我希望通过关键词搜索角色名称,以便快速定位特定角色
3. 作为管理员,我希望查看角色详情包含基本信息和已补全的授权集合,以便了解角色当前持有哪些权限资源
4. 作为管理员,我希望创建新角色并填写基本信息(名称、编码、数据范围、排序、备注),以便定义新的可分配角色
5. 作为管理员,我希望编辑现有角色的基本信息,以便更新角色属性
6. 作为管理员,我希望删除不再使用的角色,以便保持角色列表整洁
7. 作为管理员,我希望通过勾选权限资源树节点来分配功能权限,以便控制该角色持有哪些权限点
8. 作为管理员,我希望后端在保存功能权限时自动补全祖先节点,以便菜单可见性链路不会因遗漏祖先而断裂
9. 作为管理员,我希望角色详情返回的授权集合是已补全的完整集合,以便前端直接渲染勾选状态无需二次补全
10. 作为管理员,我希望被阻止删除内置角色,以便不会意外锁定系统导致不可恢复
11. 作为管理员,我希望被阻止修改内置角色的编码,以便系统关键角色标识保持稳定
12. 作为管理员,我希望角色编码唯一性被强制校验,以便两个角色不会共享同一编码导致权限解析异常
13. 作为管理员,我希望数据范围值被校验在合法范围内,以便非法值不会导致角色被静默跳过
14. 作为管理员,我希望删除角色时同时清理用户-角色关联,以便不留下指向不存在角色的孤儿关联记录
15. 作为持有 `crm:role:list` 权限的非管理员用户,我希望能够查看角色列表,以便拥有只读角色访问能力
16. 作为持有 `crm:role:save` 权限的非管理员用户,我希望能够保存角色信息,以便在无法删除角色的情况下仍可编辑
17. 作为持有 `crm:role:assign` 权限的非管理员用户,我希望能够分配角色权限,以便管理角色授权而无需完整管理员权限
18. 作为持有 `crm:role:delete` 权限的非管理员用户,我希望能够删除角色,以便管理角色生命周期
19. 作为前端开发者,我希望权限分配端点只接收我勾选的原始节点 ID,以便不需要在前端实现祖先补全逻辑
20. 作为前端开发者,我希望角色详情返回的授权集合是完整子树,以便直接用于设置勾选框状态
21. 作为前端开发者,我希望权限资源树查询接口仍然可用,以便渲染勾选树的 UI 结构
22. 作为前端开发者,我希望用户可见菜单树接口不受影响,以便登录后侧边栏渲染链路不被破坏
23. 作为开发者,我希望数据初始化器是幂等的,以便首次启动和已有系统都能正确初始化权限数据
24. 作为开发者,我希望内置角色标记由数据初始化器设置而非通过 API,以便防止通过接口将任意角色标记为内置
25. 作为开发者,我希望角色管理的权限码被种子化到权限资源树中,以便部署后 hasAuthority 检查即可生效
26. 作为开发者,我希望祖先补全逻辑作为通用能力放在菜单服务上,以便角色服务和资源服务都可以复用
27. 作为开发者,我希望删除角色时在事务保护下同时清理三张表(角色本身、角色-资源关联、用户-角色关联),以便操作原子性有保障
28. 作为开发者,我希望角色列表返回 `builtin` 标记,以便前端可以标识内置角色并隐藏删除按钮
29. 作为开发者,我希望创建角色时不能设置 `builtin=true`,以便只有数据初始化器能创建内置角色
30. 作为开发者,我希望现有 `/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<Long>,已补全的完整授权集合,JSON 序列化为 List<String>
- 分页列表返回 `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 后作为普通表单字段传递"的规范。