# ADR-0016: PermissionSeeder seam — 消除跨模块权限种子化的影子实体 ## Status Accepted ## Context crm-dict 的 `DictPermissionInitializer` 需要将 10 个 `dict:*` 权限点种子化到权限资源树(`sys_menu` 物理表)并绑定到内置管理员角色。ADR-0015 禁止 crm-dict 编译期依赖 crm-auth("crm-dict 只依赖 crm-base,不 import crm-auth 的任何类")。 为绕过这一约束,crm-dict 创建了三个**影子实体**——`SysMenuSeed`、`SysRoleSeed`、`SysRoleMenuSeed`——用 `@TableName("sys_menu")` 等直接映射 crm-auth 的物理表,通过自己的 Mapper 写入。这导致: 1. **Schema 泄漏**:crm-dict 知道 `sys_menu` 的全部字段名(`menuType`、`parentId`、`perms`、`denyBehavior`、`status`、`apiUrl`、`visible`)、枚举值(`menuType=3` 表示 button)、默认值(`denyBehavior="hide"`、`status="enabled"`)。crm-auth 改 schema 时静默破坏 crm-dict 的种子化。 2. **逻辑重复**:`DictPermissionInitializer` 与 crm-auth 的 `DataInitializer` 有几乎相同的辅助方法(`findMenuByName`、`insertButtonIfAbsent`、`bindIfAbsent`),各 ~100 行。 3. **ADR-0015 意图被违反**:ADR 说"权限码字符串是与 auth 体系的唯一契约",但影子实体把整个 `sys_menu` 物理结构泄漏了过来——契约远不止是"字符串"。 ## Considered Options - **影子实体(现状)**:schema 泄漏 + 逻辑重复,但编译期和运行期都不依赖 crm-auth。否决——schema 泄漏是核心摩擦。 - **SQL 迁移脚本(Flyway/Liquibase)**:项目用 JPA `ddl-auto: update`,无迁移框架。引入 Flyway 仅为此需改变全项目的 schema 管理策略,成本不成比例。否决。 - **`@Autowired(required=false)`**:注入 `PermissionSeeder` 时找不到实现就跳过种子化。生产环境会出现"种子化偶尔不跑"的幽灵问题。否决。 - **PermissionSeeder seam(采纳)**:在 crm-base 提取 interface,crm-auth 提供实现。编译期只依赖 crm-base(interface 所在地),运行期依赖 crm-auth(通过 crm-app 聚合 classpath 提供 `PermissionSeederImpl`)。 ## Decision ### 1. Interface 位置与形状 `PermissionSeeder` interface 放在 **crm-base** 的 `service` 包,`PermissionModuleDescriptor` 和 `ButtonSeed` record 放在 crm-base 的 `domain.dto` 包。 ```java // crm-base public interface PermissionSeeder { void seedModule(PermissionModuleDescriptor descriptor); } public record PermissionModuleDescriptor( String catalogName, // 目录名(如"系统管理") String menuName, // 菜单名(如"数据字典") String menuPath, // 前端路由(如"/system/dict") String menuComponent, // 前端组件路径(如"system/dict/index") int menuSort, // 菜单排序 List buttons, // 按钮权限点(可为空列表) List bindToRoleCodes // 绑定的内置角色编码(如"ROLE_ADMIN") ) {} public record ButtonSeed( String name, // 按钮名称 String perms, // 权限码(如"dict:group:list") String apiUrl, // 接口 URL(ApiPermissionInterceptor 按此匹配) int sort // 排序 ) {} ``` **声明式**:调用方只声明意图("我有这些权限点、挂在这个菜单下、绑给这个角色"),实现方吸收所有 `sys_menu` schema 细节。 ### 2. 实现位置 `PermissionSeederImpl` 放在 **crm-auth** 的 `service.impl` 包,`@Service` 注解,直接使用 `SysMenuMapper`、`SysRoleMapper`、`SysRoleMenuMapper`。它拥有: - 目录节点(type=1)的 find-or-create:按 `name + parentId=0` 查,不存在则建 - 菜单节点(type=2)的 find-or-create:按 `name + parentId=catalogId` 查,不存在则建 - 按钮节点(type=3)的 find-or-create:按 `perms` 查(幂等键),不存在则建 - 角色绑定:按 `roleCode` 查 `sys_role`,找到则 `bindIfAbsent`,找不到 warn 跳过(保留当前行为) 硬编码默认值:`denyBehavior="hide"`、`status="enabled"`、`visible=true`、`menuType` 枚举值——调用方不感知这些字段。 ### 3. 一次一个菜单 描述符 = 一个 catalog + 一个 menu + 一组 buttons。`DataInitializer` 调 3 次 `seedModule`(角色管理 / 菜单管理 / 部门管理),catalog 幂等确保不重复创建。不做多菜单嵌套——当前规模(最多 3 个菜单)不值得引入 `List` 的嵌套结构。 ### 4. 角色创建留在 DataInitializer seam 只管菜单/按钮/绑定,不管角色创建。`DataInitializer` 仍负责创建 `ROLE_ADMIN`(含 `builtin=true`、`dataScope=ALL`),然后调 `seedModule`。`DictPermissionInitializer` 不再创建角色——它只调 `seedModule`,seam 按 `roleCode` 查找角色。 ### 5. 启动顺序 `DataInitializer` 标 `@Order(1)`,`DictPermissionInitializer` 标 `@Order(10)`。确保 `ROLE_ADMIN` 先创建,后续模块的绑定一次到位。 ### 6. 运行期依赖 crm-dict 编译期只依赖 crm-base(`PermissionSeeder` interface)。运行期 `PermissionSeederImpl` 由 crm-auth 提供,通过 crm-app 聚合 classpath 注入。实际系统只通过 crm-app 启动——crm-auth 和 crm-dict 总在同一 classpath 上。 ### 7. 删除的影子实体 - `crm-dict/domain/entity/SysMenuSeed.java` - `crm-dict/domain/entity/SysRoleSeed.java` - `crm-dict/domain/entity/SysRoleMenuSeed.java` - `crm-dict/mapper/SysMenuSeedMapper.java` - `crm-dict/mapper/SysRoleSeedMapper.java` - `crm-dict/mapper/SysRoleMenuSeedMapper.java` `DictPermissionInitializer` 和 `DataInitializer` 中的辅助方法(`findMenuByName`、`insertMenu`、`insertButtonIfAbsent`、`bindIfAbsent`)由 `PermissionSeederImpl` 吸收,两个初始化器变为声明式薄调用方。 ## Consequences - **ADR-0015 编译期约束保持**:crm-dict 仍只 import crm-base 的类,不 import crm-auth。 - **运行期依赖新增**:crm-dict 的 `DictPermissionInitializer` 运行期需要 crm-auth 的 `PermissionSeederImpl`。实际部署通过 crm-app 聚合,不影响生产。crm-dict 独立跑 `DictApplication` 时需 crm-auth 在 classpath 上。 - **权限体系可换性更好**:换权限引擎只需换 `PermissionSeederImpl`,crm-dict 代码一行不改。影子实体方案反而硬编码了 `sys_menu` 物理结构,更不可换。 - **Schema 变更不再跨模块破坏**:crm-auth 改 `sys_menu` 字段只影响 `PermissionSeederImpl`,crm-dict 无感。 - **未来模块复用**:crm-rule、crm-audit 等新模块种子化权限点时,只需调 `seedModule`,不需要再造影子实体。 - **crm-dict 测试瘦身**:`DictPermissionInitializerTest` 从 H2 集成测试改为 mock `PermissionSeeder` 的单元测试,不再需要在 H2 中建 `sys_menu`/`sys_role`/`sys_role_menu` 表。