diff --git a/.scratch/permission-seeder-seam/issues/01-create-permission-seeder-seam.md b/.scratch/permission-seeder-seam/issues/01-create-permission-seeder-seam.md new file mode 100644 index 0000000..2d62a94 --- /dev/null +++ b/.scratch/permission-seeder-seam/issues/01-create-permission-seeder-seam.md @@ -0,0 +1,19 @@ +# 01 — 创建 PermissionSeeder seam + +**What to build:** crm-base 中定义 `PermissionSeeder` interface、`PermissionModuleDescriptor` 和 `ButtonSeed` record。crm-auth 中实现 `PermissionSeederImpl`,吸收当前 `DataInitializer` 和 `DictPermissionInitializer` 重复的 find-or-create + bind 逻辑——按 `name+parentId` 查目录和菜单、按 `perms` 查按钮(幂等键)、按 `roleCode` 查角色并绑定。硬编码 `sys_menu` 的 schema 默认值(`menuType`、`denyBehavior="hide"`、`status="enabled"`、`visible=true`),调用方不感知这些字段。 + +**Blocked by:** None — 可立即开始 + +**Status:** ready-for-agent + +- [ ] `PermissionSeeder` interface 存在于 crm-base,含单个 `seedModule(PermissionModuleDescriptor)` 方法 +- [ ] `PermissionModuleDescriptor` record 存在于 crm-base domain.dto,字段:catalogName, menuName, menuPath, menuComponent, menuSort, List\ buttons, List\ bindToRoleCodes +- [ ] `ButtonSeed` record 存在于 crm-base domain.dto,字段:name, perms, apiUrl, sort +- [ ] `PermissionSeederImpl` 存在于 crm-auth service.impl,`@Service` 注解,直接使用 SysMenuMapper / SysRoleMapper / SysRoleMenuMapper +- [ ] seedModule 实现 find-or-create:目录(type=1, parentId=0, by name)、菜单(type=2, parentId=catalogId, by name)、按钮(type=3, parentId=menuId, by perms) +- [ ] seedModule 实现 bind:按 roleCode 查 sys_role,找到则 bindIfAbsent,找不到 warn 跳过 +- [ ] seedModule 幂等:同一描述符调用两次不产生重复记录 +- [ ] seedModule 目录共享:两次调用同一 catalogName 只创建一次目录 +- [ ] 硬编码默认值:denyBehavior="hide", status="enabled", visible=true, menuType 枚举值——描述符中不含这些字段 +- [ ] `PermissionSeederImplTest`(H2 集成测试)通过,覆盖以上所有行为 +- [ ] 现有 `DataInitializer` 和 `DictPermissionInitializer` 保持不变,现有测试仍然通过 diff --git a/.scratch/permission-seeder-seam/issues/02-migrate-data-initializer.md b/.scratch/permission-seeder-seam/issues/02-migrate-data-initializer.md new file mode 100644 index 0000000..21a13c4 --- /dev/null +++ b/.scratch/permission-seeder-seam/issues/02-migrate-data-initializer.md @@ -0,0 +1,14 @@ +# 02 — 迁移 DataInitializer 到 seam + +**What to build:** 重写 crm-auth 的 `DataInitializer`——保留 `ROLE_ADMIN` 创建逻辑不变,然后用 3 次 `seedModule` 调用替代内联的 find-or-create 和 bind 逻辑(角色管理 5 个按钮、菜单管理无按钮、部门管理无按钮)。加 `@Order(1)` 确保在其他模块初始化器之前运行。删除 `DataInitializer` 中被 `PermissionSeederImpl` 吸收的辅助方法。 + +**Blocked by:** 01 — 创建 PermissionSeeder seam + +**Status:** ready-for-agent + +- [ ] `DataInitializer` 注入 `PermissionSeeder`,不再注入 SysMenuMapper / SysRoleMapper / SysRoleMenuMapper(角色创建仍用 SysRoleMapper) +- [ ] `DataInitializer` 创建 ROLE_ADMIN 后调用 seedModule 3 次:角色管理(5 个 crm:role:* 按钮)、菜单管理(无按钮)、部门管理(无按钮) +- [ ] `DataInitializer` 标注 `@Order(1)` +- [ ] `DataInitializer` 中不再包含 findMenuByName / insertMenu / insertButtonIfAbsent / bindIfAbsent 等辅助方法 +- [ ] 系统启动后 crm:role:* 权限点正确种子化,ROLE_ADMIN 拥有全部菜单/按钮绑定 +- [ ] crm-auth 全部测试通过 diff --git a/.scratch/permission-seeder-seam/issues/03-migrate-dict-initializer-delete-shadows.md b/.scratch/permission-seeder-seam/issues/03-migrate-dict-initializer-delete-shadows.md new file mode 100644 index 0000000..fdba50f --- /dev/null +++ b/.scratch/permission-seeder-seam/issues/03-migrate-dict-initializer-delete-shadows.md @@ -0,0 +1,18 @@ +# 03 — 迁移 DictPermissionInitializer + 删除影子实体 + +**What to build:** 重写 crm-dict 的 `DictPermissionInitializer`——用 1 次 `seedModule` 调用替代影子实体 + 内联逻辑(数据字典菜单 + 10 个 `dict:*` 按钮、绑给 `ROLE_ADMIN`)。加 `@Order(10)`。删除三个影子实体(`SysMenuSeed`、`SysRoleSeed`、`SysRoleMenuSeed`)和对应的三个 Mapper——它们已变为死代码。重写 `DictPermissionInitializerTest` 为 mock `PermissionSeeder` 的单元测试,验证描述符字段,移除 H2 中 `sys_menu`/`sys_role`/`sys_role_menu` 表的 schema 创建。 + +**Blocked by:** 01 — 创建 PermissionSeeder seam + +**Status:** ready-for-agent + +- [ ] `DictPermissionInitializer` 注入 `PermissionSeeder`,不再注入 SysMenuSeedMapper / SysRoleSeedMapper / SysRoleMenuSeedMapper +- [ ] `DictPermissionInitializer` 调用 seedModule 1 次:数据字典菜单 + 10 个 dict:* 按钮 + bindToRoleCodes=["ROLE_ADMIN"] +- [ ] `DictPermissionInitializer` 标注 `@Order(10)` +- [ ] `SysMenuSeed`、`SysRoleSeed`、`SysRoleMenuSeed` 三个实体类已删除 +- [ ] `SysMenuSeedMapper`、`SysRoleSeedMapper`、`SysRoleMenuSeedMapper` 三个 Mapper 已删除 +- [ ] crm-dict 中无任何代码引用 sys_menu / sys_role / sys_role_menu 物理表 +- [ ] `DictPermissionInitializerTest` 改为 mock `PermissionSeeder`,验证传入的 PermissionModuleDescriptor 字段(catalogName、menuName、menuPath、buttons 的 perms 列表、bindToRoleCodes) +- [ ] `DictPermissionInitializerTest` 不再创建 H2 的 sys_menu / sys_role / sys_role_menu 表 +- [ ] crm-dict 全部测试通过 +- [ ] 系统启动后 dict:* 权限点正确种子化 diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index f8253ec..71c6541 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -9,12 +9,12 @@ This is a multi-module Maven monorepo. Each `crm-{域}` module is a bounded cont | `crm-auth` | 认证与第三方登录 | [`crm-auth/CONTEXT.md`](./crm-auth/CONTEXT.md) | | `crm-dict` | 数据字典(平台级键值字典) | [`crm-dict/CONTEXT.md`](./crm-dict/CONTEXT.md) | | `crm-file` | 文件存储(上传/预览) | [`crm-file/CONTEXT.md`](./crm-file/CONTEXT.md) | -| `crm-base` | 基础包(通用能力,非业务域) | _(暂无独立领域语言)_ | +| `crm-base` | 基础包(通用能力,非业务域) | [`crm-base/CONTEXT.md`](./crm-base/CONTEXT.md) | | `crm-app` | 唯一启动入口 | _(暂无独立领域语言)_ | ## System-wide decisions -ADR 目录 `docs/adr/`(系统级决策)。`README.md` 当前引用了 ADR-0003 / 0004 / 0006,但文件尚未落地——由 `/domain-modeling` 在决策真正被解决时补齐。 +ADR 目录 `docs/adr/`(系统级决策)。`README.md` 当前引用了 ADR-0003 / 0004 / 0006,但文件尚未落地——由 `/domain-modeling` 在决策真正被解决时补齐。ADR-0016 记录了 PermissionSeeder seam 决策(消除跨模块权限种子化的影子实体)。 ## Adding a new context diff --git a/crm-auth/src/main/java/com/crm/auth/config/DataInitializer.java b/crm-auth/src/main/java/com/crm/auth/config/DataInitializer.java index 5411a9e..aa18e1b 100644 --- a/crm-auth/src/main/java/com/crm/auth/config/DataInitializer.java +++ b/crm-auth/src/main/java/com/crm/auth/config/DataInitializer.java @@ -1,33 +1,33 @@ package com.crm.auth.config; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.crm.auth.domain.entity.SysMenu; import com.crm.auth.domain.entity.SysRole; -import com.crm.auth.domain.entity.SysRoleMenu; import com.crm.auth.domain.enums.DataScopeEnum; -import com.crm.auth.mapper.SysMenuMapper; import com.crm.auth.mapper.SysRoleMapper; -import com.crm.auth.mapper.SysRoleMenuMapper; +import com.crm.base.domain.dto.ButtonSeed; +import com.crm.base.domain.dto.PermissionModuleDescriptor; +import com.crm.base.service.PermissionSeeder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.util.List; /** * 系统数据初始化(幂等):每次启动逐项检查,只补缺的。 - *

创建内置管理员角色、系统管理菜单、角色管理 button 权限点, - * 并将全部资源分配给管理员角色(ADR-0012)。

+ *

创建内置管理员角色,然后通过 {@link PermissionSeeder} seam 种子化系统管理菜单、 + * 角色管理/菜单管理/部门管理的 button 权限点,并将全部资源分配给管理员角色(ADR-0012、ADR-0016)。

*/ @Slf4j @Component +@Order(1) @RequiredArgsConstructor public class DataInitializer implements CommandLineRunner { private final SysRoleMapper sysRoleMapper; - private final SysMenuMapper sysMenuMapper; - private final SysRoleMenuMapper sysRoleMenuMapper; + private final PermissionSeeder permissionSeeder; @Override public void run(String... args) { @@ -51,115 +51,45 @@ public class DataInitializer implements CommandLineRunner { log.info("补设内置角色标记:{}", adminRole.getRoleCode()); } - // ---- 一级目录:系统管理 ---- - SysMenu sysManage = findMenu("系统管理", 0L); - if (sysManage == null) { - sysManage = insertMenu(new MenuSeed("系统管理", 0L, 1, null, null, 0)); - } - - // ---- 二级菜单 ---- - SysMenu roleManage = findMenu("角色管理", sysManage.getId()); - if (roleManage == null) { - roleManage = insertMenu(new MenuSeed("角色管理", sysManage.getId(), 2, - "/system/role", "system/role/index", 1)); - } - SysMenu menuManage = findMenu("菜单管理", sysManage.getId()); - if (menuManage == null) { - menuManage = insertMenu(new MenuSeed("菜单管理", sysManage.getId(), 2, - "/system/menu", "system/menu/index", 2)); - } - SysMenu deptManage = findMenu("部门管理", sysManage.getId()); - if (deptManage == null) { - deptManage = insertMenu(new MenuSeed("部门管理", sysManage.getId(), 2, - "/system/dept", "system/dept/index", 3)); - } - - // ---- 角色管理 button 权限点(ADR-0012) ---- - insertButtonIfAbsent("角色列表", roleManage.getId(), "crm:role:list", "/api/roles/page", 1); - insertButtonIfAbsent("角色详情", roleManage.getId(), "crm:role:detail", "/api/roles/detail", 2); - insertButtonIfAbsent("角色保存", roleManage.getId(), "crm:role:save", "/api/roles/saveOrUpdate", 3); - insertButtonIfAbsent("角色删除", roleManage.getId(), "crm:role:delete", "/api/roles/delete", 4); - insertButtonIfAbsent("权限分配", roleManage.getId(), "crm:role:assign", "/api/roles/assign-resources", 5); - - // ---- 角色-菜单绑定(管理员拥有所有菜单和权限点) ---- - bindIfAbsent(adminRole.getId(), sysManage.getId()); - bindIfAbsent(adminRole.getId(), roleManage.getId()); - bindIfAbsent(adminRole.getId(), menuManage.getId()); - bindIfAbsent(adminRole.getId(), deptManage.getId()); - // 绑定角色管理下的全部 button 权限点 - List roleButtons = sysMenuMapper.selectList( - new LambdaQueryWrapper() - .eq(SysMenu::getParentId, roleManage.getId()) - .eq(SysMenu::getMenuType, 3)); - for (SysMenu btn : roleButtons) { - bindIfAbsent(adminRole.getId(), btn.getId()); - } + // ---- 角色管理菜单 + 5 个 button 权限点(ADR-0012) ---- + permissionSeeder.seedModule(new PermissionModuleDescriptor( + "系统管理", + "角色管理", + "/system/role", + "system/role/index", + 1, + List.of( + new ButtonSeed("角色列表", "crm:role:list", "/api/roles/page", 1), + new ButtonSeed("角色详情", "crm:role:detail", "/api/roles/detail", 2), + new ButtonSeed("角色保存", "crm:role:save", "/api/roles/saveOrUpdate", 3), + new ButtonSeed("角色删除", "crm:role:delete", "/api/roles/delete", 4), + new ButtonSeed("权限分配", "crm:role:assign", "/api/roles/assign-resources", 5) + ), + List.of("ROLE_ADMIN") + )); + + // ---- 菜单管理菜单(无 button) ---- + permissionSeeder.seedModule(new PermissionModuleDescriptor( + "系统管理", + "菜单管理", + "/system/menu", + "system/menu/index", + 2, + List.of(), + List.of("ROLE_ADMIN") + )); + + // ---- 部门管理菜单(无 button) ---- + permissionSeeder.seedModule(new PermissionModuleDescriptor( + "系统管理", + "部门管理", + "/system/dept", + "system/dept/index", + 3, + List.of(), + List.of("ROLE_ADMIN") + )); log.info("权限数据初始化检查完成"); } - - // ==================== 幂等辅助方法 ==================== - - private SysMenu findMenu(String name, Long parentId) { - return sysMenuMapper.selectOne( - new LambdaQueryWrapper() - .eq(SysMenu::getMenuName, name) - .eq(SysMenu::getParentId, parentId)); - } - - private SysMenu insertMenu(MenuSeed seed) { - SysMenu m = new SysMenu(); - m.setParentId(seed.parentId()); - m.setMenuName(seed.name()); - m.setMenuType(seed.type()); - m.setPath(seed.path()); - m.setComponent(seed.component()); - m.setSort(seed.sort()); - m.setVisible(true); - sysMenuMapper.insert(m); - log.info("创建菜单节点:{} (type={})", seed.name(), seed.type()); - return m; - } - - /** - * 菜单种子:消除 insertMenu 的位置参数歧义。 - */ - private record MenuSeed(String name, Long parentId, int type, - String path, String component, int sort) { - } - - private void insertButtonIfAbsent(String name, Long parentId, - String perms, String apiUrl, int sort) { - Long count = sysMenuMapper.selectCount( - new LambdaQueryWrapper().eq(SysMenu::getPerms, perms)); - if (count > 0) { - return; - } - SysMenu btn = new SysMenu(); - btn.setParentId(parentId); - btn.setMenuName(name); - btn.setMenuType(3); - btn.setSort(sort); - btn.setVisible(true); - btn.setPerms(perms); - btn.setDenyBehavior("hide"); - btn.setApiUrl(apiUrl); - btn.setStatus("enabled"); - sysMenuMapper.insert(btn); - log.info("创建权限点:{} -> {}", name, perms); - } - - private void bindIfAbsent(Long roleId, Long menuId) { - Long count = sysRoleMenuMapper.selectCount( - new LambdaQueryWrapper() - .eq(SysRoleMenu::getRoleId, roleId) - .eq(SysRoleMenu::getMenuId, menuId)); - if (count > 0) { - return; - } - SysRoleMenu rm = new SysRoleMenu(); - rm.setRoleId(roleId); - rm.setMenuId(menuId); - sysRoleMenuMapper.insert(rm); - } } diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionSeederImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionSeederImpl.java new file mode 100644 index 0000000..3b0691f --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionSeederImpl.java @@ -0,0 +1,152 @@ +package com.crm.auth.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.auth.domain.entity.SysMenu; +import com.crm.auth.domain.entity.SysRole; +import com.crm.auth.domain.entity.SysRoleMenu; +import com.crm.auth.mapper.SysMenuMapper; +import com.crm.auth.mapper.SysRoleMapper; +import com.crm.auth.mapper.SysRoleMenuMapper; +import com.crm.base.domain.dto.ButtonSeed; +import com.crm.base.domain.dto.PermissionModuleDescriptor; +import com.crm.base.service.PermissionSeeder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * 权限种子化 seam 实现(ADR-0016) + *

拥有 sys_menu 的全部字段知识,负责 find-or-create 幂等写入与角色绑定。 + * 硬编码 schema 默认值:denyBehavior="hide"、status="enabled"、visible=true、menuType 枚举值。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PermissionSeederImpl implements PermissionSeeder { + + private final SysMenuMapper sysMenuMapper; + private final SysRoleMapper sysRoleMapper; + private final SysRoleMenuMapper sysRoleMenuMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public void seedModule(PermissionModuleDescriptor descriptor) { + log.debug("种子化权限模块:catalog={}, menu={}", descriptor.catalogName(), descriptor.menuName()); + + // 1. Find-or-create catalog (type=1, parentId=0) + SysMenu catalog = findMenuByName(descriptor.catalogName(), 0L); + if (catalog == null) { + catalog = insertCatalog(descriptor.catalogName()); + } + + // 2. Find-or-create menu (type=2, parentId=catalogId) + SysMenu menu = findMenuByName(descriptor.menuName(), catalog.getId()); + if (menu == null) { + menu = insertMenu(descriptor.menuName(), catalog.getId(), + descriptor.menuPath(), descriptor.menuComponent(), descriptor.menuSort()); + } + + // 3. Find-or-create buttons (type=3, parentId=menuId, by perms) + List buttonIds = new ArrayList<>(); + for (ButtonSeed seed : descriptor.buttons()) { + Long buttonId = insertButtonIfAbsent(seed.name(), menu.getId(), + seed.perms(), seed.apiUrl(), seed.sort()); + buttonIds.add(buttonId); + } + + // 4. Bind to roles (by roleCode, warn-skip if not found) + for (String roleCode : descriptor.bindToRoleCodes()) { + SysRole role = sysRoleMapper.selectOne( + new LambdaQueryWrapper().eq(SysRole::getRoleCode, roleCode)); + if (role == null) { + log.warn("内置角色 {} 不存在,跳过权限绑定(该角色创建后下次启动补绑)", roleCode); + continue; + } + bindIfAbsent(role.getId(), catalog.getId()); + bindIfAbsent(role.getId(), menu.getId()); + for (Long buttonId : buttonIds) { + bindIfAbsent(role.getId(), buttonId); + } + } + + log.debug("权限模块种子化完成:{} / {}", descriptor.catalogName(), descriptor.menuName()); + } + + // ==================== 幂等辅助方法 ==================== + + private SysMenu findMenuByName(String name, Long parentId) { + return sysMenuMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, name) + .eq(SysMenu::getParentId, parentId)); + } + + private SysMenu insertCatalog(String name) { + SysMenu m = new SysMenu(); + m.setParentId(0L); + m.setMenuName(name); + m.setMenuType(1); // 目录 + m.setSort(0); + m.setVisible(true); + sysMenuMapper.insert(m); + log.info("创建目录节点:{} (type=1)", name); + return m; + } + + private SysMenu insertMenu(String name, Long parentId, String path, String component, int sort) { + SysMenu m = new SysMenu(); + m.setParentId(parentId); + m.setMenuName(name); + m.setMenuType(2); // 菜单 + m.setPath(path); + m.setComponent(component); + m.setSort(sort); + m.setVisible(true); + sysMenuMapper.insert(m); + log.info("创建菜单节点:{} (type=2)", name); + return m; + } + + /** + * 按权限码查重插入按钮权限点,返回其 id(已存在则返回现有 id)。 + */ + private Long insertButtonIfAbsent(String name, Long parentId, String perms, + String apiUrl, int sort) { + SysMenu exist = sysMenuMapper.selectOne( + new LambdaQueryWrapper().eq(SysMenu::getPerms, perms)); + if (exist != null) { + return exist.getId(); + } + SysMenu btn = new SysMenu(); + btn.setParentId(parentId); + btn.setMenuName(name); + btn.setMenuType(3); // 按钮 + btn.setSort(sort); + btn.setVisible(true); + btn.setPerms(perms); + btn.setDenyBehavior("hide"); + btn.setApiUrl(apiUrl); + btn.setStatus("enabled"); + sysMenuMapper.insert(btn); + log.info("创建权限点:{} -> {}", name, perms); + return btn.getId(); + } + + private void bindIfAbsent(Long roleId, Long menuId) { + Long count = sysRoleMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysRoleMenu::getRoleId, roleId) + .eq(SysRoleMenu::getMenuId, menuId)); + if (count > 0) { + return; + } + SysRoleMenu rm = new SysRoleMenu(); + rm.setRoleId(roleId); + rm.setMenuId(menuId); + sysRoleMenuMapper.insert(rm); + } +} diff --git a/crm-auth/src/test/java/com/crm/auth/service/impl/PermissionSeederImplTest.java b/crm-auth/src/test/java/com/crm/auth/service/impl/PermissionSeederImplTest.java new file mode 100644 index 0000000..bd66db4 --- /dev/null +++ b/crm-auth/src/test/java/com/crm/auth/service/impl/PermissionSeederImplTest.java @@ -0,0 +1,400 @@ +package com.crm.auth.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; +import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; +import com.crm.auth.domain.entity.SysMenu; +import com.crm.auth.domain.entity.SysRole; +import com.crm.auth.domain.entity.SysRoleMenu; +import com.crm.auth.mapper.SysMenuMapper; +import com.crm.auth.mapper.SysRoleMapper; +import com.crm.auth.mapper.SysRoleMenuMapper; +import com.crm.base.config.MetaObjectFillHandler; +import com.crm.base.domain.dto.ButtonSeed; +import com.crm.base.domain.dto.PermissionModuleDescriptor; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.Statement; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * PermissionSeederImpl 集成测试(ADR-0016) + *

验证权限种子化 seam 的 find-or-create 幂等写入与角色绑定逻辑。

+ */ +@DisplayName("权限种子化 seam 集成测试") +class PermissionSeederImplTest { + + private static SqlSessionFactory sqlSessionFactory; + + private SqlSession session; + private PermissionSeederImpl seeder; + private SysMenuMapper sysMenuMapper; + private SysRoleMapper sysRoleMapper; + private SysRoleMenuMapper sysRoleMenuMapper; + + private static final String[] SCHEMA = { + """ + create table sys_role ( + id bigint primary key, creator_id varchar(50), create_time datetime, + updater_id varchar(50), update_time datetime, deleted tinyint not null default 0, + role_name varchar(50), role_code varchar(50), data_scope tinyint, sort int, remark varchar(200), + builtin boolean default false) + """, + """ + create table sys_menu ( + id bigint primary key, creator_id varchar(50), create_time datetime, + updater_id varchar(50), update_time datetime, deleted tinyint not null default 0, + parent_id bigint not null default 0, menu_name varchar(50) not null, menu_type tinyint not null, + path varchar(200), component varchar(200), icon varchar(50), sort int default 0, + visible tinyint default 1, description varchar(100), perms varchar(100), + deny_behavior varchar(16), status varchar(16) default 'enabled', api_url varchar(200)) + """, + """ + create table sys_role_menu ( + id bigint primary key, role_id bigint not null, menu_id bigint not null, + constraint uk_role_menu unique (role_id, menu_id)) + """, + }; + + @BeforeAll + static void bootstrap() throws Exception { + JdbcDataSource ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:permission-seeder-test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1"); + + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setEnvironment(new Environment("seeder-test", new JdbcTransactionFactory(), ds)); + configuration.setMapUnderscoreToCamelCase(true); + GlobalConfigUtils.getGlobalConfig(configuration).setMetaObjectHandler(new MetaObjectFillHandler()); + GlobalConfigUtils.getGlobalConfig(configuration).setIdentifierGenerator(DefaultIdentifierGenerator.getInstance()); + configuration.addMapper(SysMenuMapper.class); + configuration.addMapper(SysRoleMapper.class); + configuration.addMapper(SysRoleMenuMapper.class); + sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration); + + try (SqlSession s = sqlSessionFactory.openSession(true)) { + Connection conn = s.getConnection(); + try (Statement st = conn.createStatement()) { + for (String sql : SCHEMA) { + st.execute(sql); + } + } + } + } + + @BeforeEach + void openSession() { + session = sqlSessionFactory.openSession(); + sysMenuMapper = session.getMapper(SysMenuMapper.class); + sysRoleMapper = session.getMapper(SysRoleMapper.class); + sysRoleMenuMapper = session.getMapper(SysRoleMenuMapper.class); + seeder = new PermissionSeederImpl(sysMenuMapper, sysRoleMapper, sysRoleMenuMapper); + } + + @AfterEach + void cleanup() { + if (session != null) { + try (Statement st = session.getConnection().createStatement()) { + st.execute("DELETE FROM sys_role_menu"); + st.execute("DELETE FROM sys_menu"); + st.execute("DELETE FROM sys_role"); + } catch (Exception e) { + // ignore + } + session.commit(); + session.close(); + } + } + + @Test + @DisplayName("seedModule 创建目录、菜单、按钮并绑定角色") + void seedModule_createsCatalogMenuButtonsAndBindsRole() { + // 准备:创建一个角色 + SysRole role = new SysRole(); + role.setRoleName("管理员"); + role.setRoleCode("ROLE_ADMIN"); + role.setDataScope(4); + role.setSort(0); + role.setBuiltin(true); + sysRoleMapper.insert(role); + session.commit(); + + // 执行:种子化一个模块 + PermissionModuleDescriptor descriptor = new PermissionModuleDescriptor( + "系统管理", + "数据字典", + "/system/dict", + "system/dict/index", + 4, + List.of( + new ButtonSeed("分组查询", "dict:group:list", "/api/dict/group/page", 1), + new ButtonSeed("分组保存", "dict:group:save", "/api/dict/group/saveOrUpdate", 2) + ), + List.of("ROLE_ADMIN") + ); + seeder.seedModule(descriptor); + session.commit(); + + // 验证:目录创建 + SysMenu catalog = sysMenuMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "系统管理") + .eq(SysMenu::getParentId, 0L) + .eq(SysMenu::getMenuType, 1)); + assertThat(catalog).isNotNull(); + assertThat(catalog.getVisible()).isTrue(); + + // 验证:菜单创建 + SysMenu menu = sysMenuMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "数据字典") + .eq(SysMenu::getParentId, catalog.getId()) + .eq(SysMenu::getMenuType, 2)); + assertThat(menu).isNotNull(); + assertThat(menu.getPath()).isEqualTo("/system/dict"); + assertThat(menu.getComponent()).isEqualTo("system/dict/index"); + assertThat(menu.getSort()).isEqualTo(4); + assertThat(menu.getVisible()).isTrue(); + + // 验证:按钮创建 + List buttons = sysMenuMapper.selectList( + new LambdaQueryWrapper() + .eq(SysMenu::getParentId, menu.getId()) + .eq(SysMenu::getMenuType, 3)); + assertThat(buttons).hasSize(2); + assertThat(buttons).anyMatch(b -> b.getPerms().equals("dict:group:list") + && b.getApiUrl().equals("/api/dict/group/page") + && b.getDenyBehavior().equals("hide") + && b.getStatus().equals("enabled")); + assertThat(buttons).anyMatch(b -> b.getPerms().equals("dict:group:save") + && b.getApiUrl().equals("/api/dict/group/saveOrUpdate")); + + // 验证:角色绑定 + List bindings = sysRoleMenuMapper.selectList( + new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, role.getId())); + assertThat(bindings).hasSize(4); // catalog + menu + 2 buttons + assertThat(bindings).anyMatch(bm -> bm.getMenuId().equals(catalog.getId())); + assertThat(bindings).anyMatch(bm -> bm.getMenuId().equals(menu.getId())); + assertThat(bindings).anyMatch(bm -> bm.getMenuId().equals(buttons.get(0).getId())); + assertThat(bindings).anyMatch(bm -> bm.getMenuId().equals(buttons.get(1).getId())); + } + + @Test + @DisplayName("seedModule 幂等:同一描述符调用两次不产生重复记录") + void seedModule_idempotent_noDuplicates() { + // 准备:创建一个角色 + SysRole role = new SysRole(); + role.setRoleName("管理员"); + role.setRoleCode("ROLE_ADMIN"); + role.setDataScope(4); + role.setSort(0); + role.setBuiltin(true); + sysRoleMapper.insert(role); + session.commit(); + + PermissionModuleDescriptor descriptor = new PermissionModuleDescriptor( + "系统管理", + "数据字典", + "/system/dict", + "system/dict/index", + 4, + List.of(new ButtonSeed("分组查询", "dict:group:list", "/api/dict/group/page", 1)), + List.of("ROLE_ADMIN") + ); + + // 执行:调用两次 + seeder.seedModule(descriptor); + session.commit(); + seeder.seedModule(descriptor); + session.commit(); + + // 验证:目录、菜单、按钮各只有一条 + Long catalogCount = sysMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "系统管理") + .eq(SysMenu::getParentId, 0L) + .eq(SysMenu::getMenuType, 1)); + assertThat(catalogCount).isEqualTo(1); + + Long menuCount = sysMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "数据字典") + .eq(SysMenu::getMenuType, 2)); + assertThat(menuCount).isEqualTo(1); + + Long buttonCount = sysMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysMenu::getPerms, "dict:group:list") + .eq(SysMenu::getMenuType, 3)); + assertThat(buttonCount).isEqualTo(1); + + // 验证:角色绑定也只有一条(catalog + menu + button = 3) + Long bindingCount = sysRoleMenuMapper.selectCount( + new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, role.getId())); + assertThat(bindingCount).isEqualTo(3); + } + + @Test + @DisplayName("seedModule 目录共享:两次调用同一 catalogName 只创建一次目录") + void seedModule_sharedCatalog_createdOnce() { + // 准备:创建一个角色 + SysRole role = new SysRole(); + role.setRoleName("管理员"); + role.setRoleCode("ROLE_ADMIN"); + role.setDataScope(4); + role.setSort(0); + role.setBuiltin(true); + sysRoleMapper.insert(role); + session.commit(); + + // 执行:两个不同模块共享同一目录 + PermissionModuleDescriptor descriptor1 = new PermissionModuleDescriptor( + "系统管理", + "角色管理", + "/system/role", + "system/role/index", + 1, + List.of(), + List.of("ROLE_ADMIN") + ); + PermissionModuleDescriptor descriptor2 = new PermissionModuleDescriptor( + "系统管理", + "数据字典", + "/system/dict", + "system/dict/index", + 4, + List.of(), + List.of("ROLE_ADMIN") + ); + seeder.seedModule(descriptor1); + session.commit(); + seeder.seedModule(descriptor2); + session.commit(); + + // 验证:目录只有一条 + Long catalogCount = sysMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "系统管理") + .eq(SysMenu::getParentId, 0L) + .eq(SysMenu::getMenuType, 1)); + assertThat(catalogCount).isEqualTo(1); + + // 验证:两个菜单都在同一目录下 + SysMenu catalog = sysMenuMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "系统管理") + .eq(SysMenu::getParentId, 0L)); + Long menuCount = sysMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysMenu::getParentId, catalog.getId()) + .eq(SysMenu::getMenuType, 2)); + assertThat(menuCount).isEqualTo(2); + } + + @Test + @DisplayName("seedModule 角色不存在时 warn 跳过绑定") + void seedModule_roleNotFound_warnsAndSkipsBinding() { + // 准备:不创建角色 + + PermissionModuleDescriptor descriptor = new PermissionModuleDescriptor( + "系统管理", + "数据字典", + "/system/dict", + "system/dict/index", + 4, + List.of(new ButtonSeed("分组查询", "dict:group:list", "/api/dict/group/page", 1)), + List.of("ROLE_ADMIN") // 角色不存在 + ); + + // 执行 + seeder.seedModule(descriptor); + session.commit(); + + // 验证:目录、菜单、按钮都创建了 + Long catalogCount = sysMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "系统管理") + .eq(SysMenu::getParentId, 0L)); + assertThat(catalogCount).isEqualTo(1); + + Long menuCount = sysMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "数据字典") + .eq(SysMenu::getMenuType, 2)); + assertThat(menuCount).isEqualTo(1); + + Long buttonCount = sysMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysMenu::getPerms, "dict:group:list")); + assertThat(buttonCount).isEqualTo(1); + + // 验证:没有角色绑定 + Long bindingCount = sysRoleMenuMapper.selectCount(null); + assertThat(bindingCount).isEqualTo(0); + } + + @Test + @DisplayName("seedModule 空按钮列表:只创建目录和菜单") + void seedModule_emptyButtons_onlyCatalogAndMenu() { + // 准备:创建一个角色 + SysRole role = new SysRole(); + role.setRoleName("管理员"); + role.setRoleCode("ROLE_ADMIN"); + role.setDataScope(4); + role.setSort(0); + role.setBuiltin(true); + sysRoleMapper.insert(role); + session.commit(); + + PermissionModuleDescriptor descriptor = new PermissionModuleDescriptor( + "系统管理", + "菜单管理", + "/system/menu", + "system/menu/index", + 2, + List.of(), // 空按钮列表 + List.of("ROLE_ADMIN") + ); + + // 执行 + seeder.seedModule(descriptor); + session.commit(); + + // 验证:目录和菜单创建了,没有按钮 + SysMenu catalog = sysMenuMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "系统管理") + .eq(SysMenu::getParentId, 0L)); + assertThat(catalog).isNotNull(); + + SysMenu menu = sysMenuMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuName, "菜单管理") + .eq(SysMenu::getParentId, catalog.getId())); + assertThat(menu).isNotNull(); + + Long buttonCount = sysMenuMapper.selectCount( + new LambdaQueryWrapper() + .eq(SysMenu::getParentId, menu.getId()) + .eq(SysMenu::getMenuType, 3)); + assertThat(buttonCount).isEqualTo(0); + + // 验证:角色绑定只有 catalog + menu = 2 + Long bindingCount = sysRoleMenuMapper.selectCount( + new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, role.getId())); + assertThat(bindingCount).isEqualTo(2); + } +} diff --git a/crm-base/CONTEXT.md b/crm-base/CONTEXT.md new file mode 100644 index 0000000..d768397 --- /dev/null +++ b/crm-base/CONTEXT.md @@ -0,0 +1,17 @@ +# 基础包(crm-base) + +通用能力与非业务域原语。不装 controller、不建业务表,只提供基础设施:异常体系、响应封装、分页、雪花 ID、MyBatis-Plus 扩展、数据权限词汇、安全上下文工具。跨模块 seam 也放在这里。 + +## Language + +**权限种子器**: +一个 seam interface,让任意业务模块声明式地将自己的权限点(button 节点)种子化到权限资源树(`sys_menu` 物理表),而不需要知道该表的物理 schema。调用方通过 `PermissionModuleDescriptor` 声明目录名、菜单名、按钮列表、目标角色编码;实现方(在 crm-auth 中)拥有 `sys_menu` 的全部字段知识,负责 find-or-create 幂等写入与角色绑定。替代了此前的影子实体方案——crm-dict 曾创建 `SysMenuSeed` 等重复实体类直接映射 `sys_menu` 表,导致 schema 泄漏。 +_Avoid_: 权限初始化器、权限注入器、权限注册器 + +**权限模块描述符**: +权限种子化的声明单元。一个描述符 = 一个目录 + 一个菜单 + 一组按钮 + 目标角色编码列表。目录幂等——同一目录名被多次 `seedModule` 只创建一次。调用方不暴露 `sys_menu` 的任何字段名、枚举值或默认值;`denyBehavior`、`status`、`visible` 等 schema 默认值由种子器实现方硬编码。 +_Avoid_: 权限配置、种子描述文件 + +**按钮种子**: +权限模块描述符中一条按钮权限点的最小声明:名称、权限码、接口 URL、排序。不包含 `denyBehavior`/`status`/`visible`——这些是 `sys_menu` 的 schema 细节,由种子器实现方统一硬编码。需要不同值的管理员在启动后通过资源管理 API 修改。 +_Avoid_: 按钮配置、权限点定义 diff --git a/crm-base/src/main/java/com/crm/base/domain/dto/ButtonSeed.java b/crm-base/src/main/java/com/crm/base/domain/dto/ButtonSeed.java new file mode 100644 index 0000000..e1425bc --- /dev/null +++ b/crm-base/src/main/java/com/crm/base/domain/dto/ButtonSeed.java @@ -0,0 +1,19 @@ +package com.crm.base.domain.dto; + +/** + * 权限模块描述符中一条按钮权限点的最小声明(ADR-0016) + *

不包含 {@code denyBehavior}/{@code status}/{@code visible}——这些是 sys_menu 的 schema 细节, + * 由种子器实现方统一硬编码。需要不同值的管理员在启动后通过资源管理 API 修改。

+ * + * @param name 按钮名称 + * @param perms 权限码(如"dict:group:list") + * @param apiUrl 接口 URL(ApiPermissionInterceptor 按此匹配) + * @param sort 排序 + */ +public record ButtonSeed( + String name, + String perms, + String apiUrl, + int sort +) { +} diff --git a/crm-base/src/main/java/com/crm/base/domain/dto/PermissionModuleDescriptor.java b/crm-base/src/main/java/com/crm/base/domain/dto/PermissionModuleDescriptor.java new file mode 100644 index 0000000..6619a80 --- /dev/null +++ b/crm-base/src/main/java/com/crm/base/domain/dto/PermissionModuleDescriptor.java @@ -0,0 +1,29 @@ +package com.crm.base.domain.dto; + +import java.util.List; + +/** + * 权限种子化的声明单元(ADR-0016) + *

一个描述符 = 一个目录 + 一个菜单 + 一组按钮 + 目标角色编码列表。 + * 目录幂等——同一目录名被多次 {@code seedModule} 只创建一次。 + * 调用方不暴露 sys_menu 的任何字段名、枚举值或默认值; + * {@code denyBehavior}、{@code status}、{@code visible} 等 schema 默认值由种子器实现方硬编码。

+ * + * @param catalogName 目录名(如"系统管理") + * @param menuName 菜单名(如"数据字典") + * @param menuPath 前端路由(如"/system/dict") + * @param menuComponent 前端组件路径(如"system/dict/index") + * @param menuSort 菜单排序 + * @param buttons 按钮权限点(可为空列表) + * @param bindToRoleCodes 绑定的内置角色编码(如"ROLE_ADMIN") + */ +public record PermissionModuleDescriptor( + String catalogName, + String menuName, + String menuPath, + String menuComponent, + int menuSort, + List buttons, + List bindToRoleCodes +) { +} diff --git a/crm-base/src/main/java/com/crm/base/service/PermissionSeeder.java b/crm-base/src/main/java/com/crm/base/service/PermissionSeeder.java new file mode 100644 index 0000000..67b2d05 --- /dev/null +++ b/crm-base/src/main/java/com/crm/base/service/PermissionSeeder.java @@ -0,0 +1,22 @@ +package com.crm.base.service; + +import com.crm.base.domain.dto.PermissionModuleDescriptor; + +/** + * 权限种子化 seam(ADR-0016) + *

让任意业务模块声明式地将自己的权限点(button 节点)种子化到权限资源树(sys_menu 物理表), + * 而不需要知道该表的物理 schema。调用方通过 {@link PermissionModuleDescriptor} 声明目录名、菜单名、 + * 按钮列表、目标角色编码;实现方(在 crm-auth 中)拥有 sys_menu 的全部字段知识,负责 find-or-create + * 幂等写入与角色绑定。

+ *

替代了此前的影子实体方案——crm-dict 曾创建 SysMenuSeed 等重复实体类直接映射 sys_menu 表, + * 导致 schema 泄漏。

+ */ +public interface PermissionSeeder { + + /** + * 种子化一个模块的权限点到权限资源树。 + * + * @param descriptor 权限模块描述符(目录 + 菜单 + 按钮 + 目标角色编码) + */ + void seedModule(PermissionModuleDescriptor descriptor); +} diff --git a/crm-dict/src/main/java/com/crm/dict/config/DictPermissionInitializer.java b/crm-dict/src/main/java/com/crm/dict/config/DictPermissionInitializer.java index b3cc8fe..20a5df5 100644 --- a/crm-dict/src/main/java/com/crm/dict/config/DictPermissionInitializer.java +++ b/crm-dict/src/main/java/com/crm/dict/config/DictPermissionInitializer.java @@ -1,25 +1,22 @@ package com.crm.dict.config; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.base.domain.dto.ButtonSeed; +import com.crm.base.domain.dto.PermissionModuleDescriptor; +import com.crm.base.service.PermissionSeeder; import com.crm.dict.constant.DictConstants; -import com.crm.dict.domain.entity.SysMenuSeed; -import com.crm.dict.domain.entity.SysRoleMenuSeed; -import com.crm.dict.domain.entity.SysRoleSeed; -import com.crm.dict.mapper.SysMenuSeedMapper; -import com.crm.dict.mapper.SysRoleMenuSeedMapper; -import com.crm.dict.mapper.SysRoleSeedMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; -import java.util.ArrayList; import java.util.List; /** - * 数据字典权限种子化(幂等,T07) - *

将 10 个 {@code dict:...} 权限码种子化到权限资源树(sys_menu 物理表)并绑定内置管理员角色。 - * 与 crm-auth DataInitializer 无耦合:系统管理目录缺失则自建;ROLE_ADMIN 缺失则跳过绑定并 warn + * 数据字典权限种子化(幂等,T07,ADR-0016) + *

通过 {@link PermissionSeeder} seam 将 10 个 {@code dict:...} 权限码种子化到权限资源树 + * (sys_menu 物理表)并绑定内置管理员角色。与 crm-auth DataInitializer 无耦合: + * 系统管理目录缺失则 seam 自建;ROLE_ADMIN 缺失则 seam warn 跳过绑定 * (crm-auth 首次启动创建后,字典权限在下次启动补绑)。

*

权限点写入了 api_url:crm-auth 的 ApiPermissionInterceptor 按 api_url 懒加载规则, * 种子化完成后主端点(page/saveOrUpdate/delete/status/set-default 等)未授权访问即被拦截; @@ -27,15 +24,11 @@ import java.util.List; */ @Slf4j @Component +@Order(10) @RequiredArgsConstructor public class DictPermissionInitializer implements CommandLineRunner { - private final SysMenuSeedMapper sysMenuMapper; - private final SysRoleSeedMapper sysRoleMapper; - private final SysRoleMenuSeedMapper sysRoleMenuMapper; - - /** 按钮类型(与 crm-auth 菜单类型枚举一致) */ - private static final int MENU_TYPE_BUTTON = 3; + private final PermissionSeeder permissionSeeder; /** 10 个权限点种子:(名称, 权限码, apiUrl, 排序)。权限码与前端文档 §8 一致 */ private static final List BUTTON_SEEDS = List.of( @@ -54,104 +47,16 @@ public class DictPermissionInitializer implements CommandLineRunner { public void run(String... args) { log.info("执行数据字典权限种子化检查..."); - // ---- 一级目录:系统管理(缺失自建,与 crm-auth DataInitializer 顺序无关) ---- - SysMenuSeed sysManage = findMenuByName("系统管理", 0L); - if (sysManage == null) { - sysManage = insertMenu("系统管理", 0L, 1, null, null, 0); - } - - // ---- 二级菜单:数据字典(挂在系统管理下) ---- - SysMenuSeed dictMenu = findMenuByName("数据字典", sysManage.getId()); - if (dictMenu == null) { - dictMenu = insertMenu("数据字典", sysManage.getId(), 2, - "/system/dict", "system/dict/index", 4); - } - - // ---- 10 个按钮权限点(按 perms 幂等查重,存在即跳过) ---- - List buttonIds = new ArrayList<>(BUTTON_SEEDS.size()); - for (ButtonSeed seed : BUTTON_SEEDS) { - buttonIds.add(insertButtonIfAbsent(seed.name(), dictMenu.getId(), - seed.perms(), seed.apiUrl(), seed.sort())); - } - - // ---- 绑定内置管理员角色(缺失跳过 + warn) ---- - SysRoleSeed admin = sysRoleMapper.selectOne( - new LambdaQueryWrapper().eq(SysRoleSeed::getRoleCode, "ROLE_ADMIN")); - if (admin == null) { - log.warn("内置角色 ROLE_ADMIN 不存在,跳过数据字典权限绑定(crm-auth 创建该角色后下次启动补绑)"); - return; - } - bindIfAbsent(admin.getId(), sysManage.getId()); - bindIfAbsent(admin.getId(), dictMenu.getId()); - for (Long buttonId : buttonIds) { - bindIfAbsent(admin.getId(), buttonId); - } + permissionSeeder.seedModule(new PermissionModuleDescriptor( + "系统管理", + "数据字典", + "/system/dict", + "system/dict/index", + 4, + BUTTON_SEEDS, + List.of("ROLE_ADMIN") + )); log.info("数据字典权限种子化检查完成:{} 个权限点", BUTTON_SEEDS.size()); } - - // ==================== 幂等辅助方法 ==================== - - private SysMenuSeed findMenuByName(String name, Long parentId) { - return sysMenuMapper.selectOne(new LambdaQueryWrapper() - .eq(SysMenuSeed::getMenuName, name) - .eq(SysMenuSeed::getParentId, parentId)); - } - - private SysMenuSeed insertMenu(String name, Long parentId, int type, - String path, String component, int sort) { - SysMenuSeed m = new SysMenuSeed(); - m.setParentId(parentId); - m.setMenuName(name); - m.setMenuType(type); - m.setPath(path); - m.setComponent(component); - m.setSort(sort); - m.setVisible(true); - sysMenuMapper.insert(m); - log.info("创建菜单节点:{} (type={})", name, type); - return m; - } - - /** - * 按权限码查重插入按钮权限点,返回其 id(已存在则返回现有 id)。 - */ - private Long insertButtonIfAbsent(String name, Long parentId, String perms, - String apiUrl, int sort) { - SysMenuSeed exist = sysMenuMapper.selectOne( - new LambdaQueryWrapper().eq(SysMenuSeed::getPerms, perms)); - if (exist != null) { - return exist.getId(); - } - SysMenuSeed btn = new SysMenuSeed(); - btn.setParentId(parentId); - btn.setMenuName(name); - btn.setMenuType(MENU_TYPE_BUTTON); - btn.setSort(sort); - btn.setVisible(true); - btn.setPerms(perms); - btn.setDenyBehavior("hide"); - btn.setApiUrl(apiUrl); - btn.setStatus("enabled"); - sysMenuMapper.insert(btn); - log.info("创建权限点:{} -> {}", name, perms); - return btn.getId(); - } - - private void bindIfAbsent(Long roleId, Long menuId) { - Long count = sysRoleMenuMapper.selectCount(new LambdaQueryWrapper() - .eq(SysRoleMenuSeed::getRoleId, roleId) - .eq(SysRoleMenuSeed::getMenuId, menuId)); - if (count > 0) { - return; - } - SysRoleMenuSeed rm = new SysRoleMenuSeed(); - rm.setRoleId(roleId); - rm.setMenuId(menuId); - sysRoleMenuMapper.insert(rm); - } - - /** 按钮种子:消除 insertButtonIfAbsent 的位置参数歧义 */ - private record ButtonSeed(String name, String perms, String apiUrl, int sort) { - } } diff --git a/crm-dict/src/main/java/com/crm/dict/domain/entity/SysMenuSeed.java b/crm-dict/src/main/java/com/crm/dict/domain/entity/SysMenuSeed.java deleted file mode 100644 index 9982ea6..0000000 --- a/crm-dict/src/main/java/com/crm/dict/domain/entity/SysMenuSeed.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.crm.dict.domain.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.crm.base.domain.entity.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 权限资源树最小化映射(sys_menu 物理表,T07) - *

仅用于 crm-dict 幂等种子化权限点,刻意不标 @Entity:建表归 crm-auth 的 SysMenu, - * 本类只做 MyBatis-Plus 读写,避免与 crm-auth 双实体映射同一张表(ADR-0015 零依赖约束)。

- */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("sys_menu") -public class SysMenuSeed extends BaseEntity { - - /** 上级菜单ID,根节点为 0 */ - private Long parentId; - - /** 菜单名称 */ - private String menuName; - - /** 类型:1=目录 2=菜单 3=按钮 */ - private Integer menuType; - - /** 前端路由路径(目录/菜单用) */ - private String path; - - /** 前端组件路径(菜单用) */ - private String component; - - /** 排序 */ - private Integer sort; - - /** 是否可见 */ - private Boolean visible; - - /** 权限码(按钮级权限标识,如 dict:item:list) */ - private String perms; - - /** 无权限时的前端行为:hide 隐藏,disable 禁用 */ - private String denyBehavior; - - /** 权限点状态:enabled 启用,disabled 停用(全局断路) */ - private String status; - - /** 接口 URL(按钮对应的后端 API 路径,ApiPermissionInterceptor 按此匹配规则) */ - private String apiUrl; -} diff --git a/crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleMenuSeed.java b/crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleMenuSeed.java deleted file mode 100644 index dc1d863..0000000 --- a/crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleMenuSeed.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.crm.dict.domain.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -/** - * 角色-资源绑定最小化映射(sys_role_menu 物理表,T07) - *

仅用于 crm-dict 幂等种子化时将权限点绑定到内置管理员角色, - * 刻意不标 @Entity,建表归 crm-auth 的 SysRoleMenu(ADR-0015 零依赖约束)。

- */ -@Data -@TableName("sys_role_menu") -public class SysRoleMenuSeed { - - @TableId(type = IdType.ASSIGN_ID) - private Long id; - - /** 角色ID */ - private Long roleId; - - /** 菜单/权限点ID */ - private Long menuId; -} diff --git a/crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleSeed.java b/crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleSeed.java deleted file mode 100644 index 1a25d56..0000000 --- a/crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleSeed.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.crm.dict.domain.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.crm.base.domain.entity.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 系统角色最小化映射(sys_role 物理表,T07) - *

仅用于 crm-dict 幂等种子化时定位内置管理员角色 ROLE_ADMIN,刻意不标 @Entity, - * 建表归 crm-auth 的 SysRole(ADR-0015 零依赖约束)。

- */ -@Data -@EqualsAndHashCode(callSuper = true) -@TableName("sys_role") -public class SysRoleSeed extends BaseEntity { - - /** 角色名称 */ - private String roleName; - - /** 角色编码(如 ROLE_ADMIN) */ - private String roleCode; - - /** 数据范围:1=本人 2=本部门 3=本部门及子部门 4=全部 */ - private Integer dataScope; - - /** 排序 */ - private Integer sort; - - /** 内置角色标记:true=系统内置不可删除不可改编码 */ - private Boolean builtin; -} diff --git a/crm-dict/src/main/java/com/crm/dict/mapper/SysMenuSeedMapper.java b/crm-dict/src/main/java/com/crm/dict/mapper/SysMenuSeedMapper.java deleted file mode 100644 index 2251978..0000000 --- a/crm-dict/src/main/java/com/crm/dict/mapper/SysMenuSeedMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.crm.dict.mapper; - -import com.crm.base.mapper.CrmBaseMapper; -import com.crm.dict.domain.entity.SysMenuSeed; -import org.apache.ibatis.annotations.Mapper; - -/** - * sys_menu 最小化映射 Mapper(T07 权限种子化专用) - */ -@Mapper -public interface SysMenuSeedMapper extends CrmBaseMapper { -} diff --git a/crm-dict/src/main/java/com/crm/dict/mapper/SysRoleMenuSeedMapper.java b/crm-dict/src/main/java/com/crm/dict/mapper/SysRoleMenuSeedMapper.java deleted file mode 100644 index c7c98d5..0000000 --- a/crm-dict/src/main/java/com/crm/dict/mapper/SysRoleMenuSeedMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.crm.dict.mapper; - -import com.crm.base.mapper.CrmBaseMapper; -import com.crm.dict.domain.entity.SysRoleMenuSeed; -import org.apache.ibatis.annotations.Mapper; - -/** - * sys_role_menu 最小化映射 Mapper(T07 权限种子化专用) - */ -@Mapper -public interface SysRoleMenuSeedMapper extends CrmBaseMapper { -} diff --git a/crm-dict/src/main/java/com/crm/dict/mapper/SysRoleSeedMapper.java b/crm-dict/src/main/java/com/crm/dict/mapper/SysRoleSeedMapper.java deleted file mode 100644 index 985c73c..0000000 --- a/crm-dict/src/main/java/com/crm/dict/mapper/SysRoleSeedMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.crm.dict.mapper; - -import com.crm.base.mapper.CrmBaseMapper; -import com.crm.dict.domain.entity.SysRoleSeed; -import org.apache.ibatis.annotations.Mapper; - -/** - * sys_role 最小化映射 Mapper(T07 权限种子化专用) - */ -@Mapper -public interface SysRoleSeedMapper extends CrmBaseMapper { -} diff --git a/crm-dict/src/test/java/com/crm/dict/config/DictPermissionInitializerTest.java b/crm-dict/src/test/java/com/crm/dict/config/DictPermissionInitializerTest.java index 2c7b490..40d52d9 100644 --- a/crm-dict/src/test/java/com/crm/dict/config/DictPermissionInitializerTest.java +++ b/crm-dict/src/test/java/com/crm/dict/config/DictPermissionInitializerTest.java @@ -1,63 +1,34 @@ package com.crm.dict.config; -import com.baomidou.mybatisplus.annotation.DbType; -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; -import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; -import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; -import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; -import com.crm.base.config.MetaObjectFillHandler; +import com.crm.base.domain.dto.ButtonSeed; +import com.crm.base.domain.dto.PermissionModuleDescriptor; +import com.crm.base.service.PermissionSeeder; import com.crm.dict.constant.DictConstants; import com.crm.dict.controller.DictGroupController; import com.crm.dict.controller.DictItemController; -import com.crm.dict.domain.entity.SysMenuSeed; -import com.crm.dict.domain.entity.SysRoleMenuSeed; -import com.crm.dict.domain.entity.SysRoleSeed; -import com.crm.dict.mapper.SysMenuSeedMapper; -import com.crm.dict.mapper.SysRoleMenuSeedMapper; -import com.crm.dict.mapper.SysRoleSeedMapper; -import org.apache.ibatis.mapping.Environment; -import org.apache.ibatis.session.LocalCacheScope; -import org.apache.ibatis.session.SqlSession; -import org.apache.ibatis.session.SqlSessionFactory; -import org.apache.ibatis.session.SqlSessionFactoryBuilder; -import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; -import org.h2.jdbcx.JdbcDataSource; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.security.access.prepost.PreAuthorize; import java.lang.reflect.Method; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.Arrays; import java.util.List; import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; /** - * T07 权限种子化集成测试:种子化后 10 个权限点挂树并绑定管理员 / 幂等 / ROLE_ADMIN 缺失跳过 / Controller 注解契约 - *

H2 手工装配(仿 AbstractDictH2Test);"无权限访问被拒"的动态行为由 crm-auth - * ApiPermissionInterceptor(按 api_url 懒加载规则)与 Spring Security 保证, - * 本测试验证种子化产出满足该拦截器契约(api_url + status=enabled + perms)。

+ * T07 权限种子化测试(ADR-0016):验证 DictPermissionInitializer 通过 PermissionSeeder seam + * 声明正确的权限描述符,且 Controller 端点带 @PreAuthorize 注解。 + *

不再使用 H2 建 sys_menu/sys_role/sys_role_menu 表——seam 实现在 crm-auth 的 + * PermissionSeederImplTest 中集成测试。

*/ @DisplayName("T07 权限种子化") class DictPermissionInitializerTest { - private static final AtomicBoolean BOOTSTRAPPED = new AtomicBoolean(false); - - private static SqlSessionFactory sqlSessionFactory; - - private SqlSession session; - private SysMenuSeedMapper sysMenuMapper; - private SysRoleSeedMapper sysRoleMapper; - private SysRoleMenuSeedMapper sysRoleMenuMapper; + private PermissionSeeder permissionSeeder; private DictPermissionInitializer initializer; /** 10 个权限码(与 DictConstants + 前端文档 §8 一致) */ @@ -68,160 +39,39 @@ class DictPermissionInitializerTest { DictConstants.PERM_ITEM_DELETE, DictConstants.PERM_ITEM_FORCE_DELETE, DictConstants.PERM_ITEM_STATUS, DictConstants.PERM_ITEM_DEFAULT); - @BeforeAll - static void bootstrap() throws Exception { - if (!BOOTSTRAPPED.compareAndSet(false, true)) { - return; - } - JdbcDataSource ds = new JdbcDataSource(); - ds.setURL("jdbc:h2:mem:dict-perm;MODE=MySQL;DATABASE_TO_LOWER=TRUE;" + - "CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1;NON_KEYWORDS=VALUE"); - - MybatisConfiguration configuration = new MybatisConfiguration(); - configuration.setEnvironment(new Environment("dict-perm-test", new JdbcTransactionFactory(), ds)); - configuration.setMapUnderscoreToCamelCase(true); - configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); - - MybatisPlusInterceptor mpInterceptor = new MybatisPlusInterceptor(); - mpInterceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); - configuration.addInterceptor(mpInterceptor); - - GlobalConfigUtils.getGlobalConfig(configuration).setMetaObjectHandler(new MetaObjectFillHandler()); - GlobalConfigUtils.getGlobalConfig(configuration).setIdentifierGenerator(DefaultIdentifierGenerator.getInstance()); - - configuration.addMapper(SysMenuSeedMapper.class); - configuration.addMapper(SysRoleSeedMapper.class); - configuration.addMapper(SysRoleMenuSeedMapper.class); - sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration); - - try (SqlSession s = sqlSessionFactory.openSession(true); - Statement st = s.getConnection().createStatement()) { - for (String sql : SCHEMA) { - st.execute(sql); - } - } - } - @BeforeEach - void setUp() throws SQLException { - session = sqlSessionFactory.openSession(true); - try (Statement st = session.getConnection().createStatement()) { - st.execute("TRUNCATE TABLE sys_role_menu"); - st.execute("TRUNCATE TABLE sys_menu"); - st.execute("TRUNCATE TABLE sys_role"); - } - sysMenuMapper = session.getMapper(SysMenuSeedMapper.class); - sysRoleMapper = session.getMapper(SysRoleSeedMapper.class); - sysRoleMenuMapper = session.getMapper(SysRoleMenuSeedMapper.class); - initializer = new DictPermissionInitializer(sysMenuMapper, sysRoleMapper, sysRoleMenuMapper); - } - - @AfterEach - void tearDown() { - if (session != null) { - session.close(); - } + void setUp() { + permissionSeeder = mock(PermissionSeeder.class); + initializer = new DictPermissionInitializer(permissionSeeder); } @Test - @DisplayName("种子化:系统管理/数据字典挂树,10 个权限点带 api_url 且绑定管理员") - void seedAll_createsTreeAndBindsAdmin() { - SysRoleSeed admin = insertAdminRole(); - + @DisplayName("seedModule 声明正确的目录、菜单、10 个按钮权限点和角色绑定") + void seedModule_declaresCorrectDescriptor() { initializer.run(); - // 目录与菜单 - SysMenuSeed sysManage = findMenu("系统管理", 0L); - assertThat(sysManage).isNotNull(); - SysMenuSeed dictMenu = findMenu("数据字典", sysManage.getId()); - assertThat(dictMenu).isNotNull(); - assertThat(dictMenu.getMenuType()).isEqualTo(2); - assertThat(dictMenu.getPath()).isEqualTo("/system/dict"); + ArgumentCaptor captor = ArgumentCaptor.forClass(PermissionModuleDescriptor.class); + verify(permissionSeeder, times(1)).seedModule(captor.capture()); + + PermissionModuleDescriptor descriptor = captor.getValue(); + assertThat(descriptor.catalogName()).isEqualTo("系统管理"); + assertThat(descriptor.menuName()).isEqualTo("数据字典"); + assertThat(descriptor.menuPath()).isEqualTo("/system/dict"); + assertThat(descriptor.menuComponent()).isEqualTo("system/dict/index"); + assertThat(descriptor.menuSort()).isEqualTo(4); + assertThat(descriptor.bindToRoleCodes()).containsExactly("ROLE_ADMIN"); - // 10 个权限点:perms 齐全、按钮类型、enabled、api_url 非空(ApiPermissionInterceptor 加载契约) - List buttons = sysMenuMapper.selectList(null).stream() - .filter(m -> m.getMenuType() == 3) - .toList(); + // 10 个按钮权限点 + List buttons = descriptor.buttons(); assertThat(buttons).hasSize(10); - assertThat(buttons).extracting(SysMenuSeed::getPerms).containsExactlyInAnyOrderElementsOf(PERM_CODES); + assertThat(buttons).extracting(ButtonSeed::perms).containsExactlyInAnyOrderElementsOf(PERM_CODES); + + // 每个按钮都有 apiUrl 和 sort assertThat(buttons).allSatisfy(b -> { - assertThat(b.getStatus()).isEqualTo("enabled"); - assertThat(b.getApiUrl()).isNotBlank(); - assertThat(b.getParentId()).isEqualTo(dictMenu.getId()); + assertThat(b.apiUrl()).isNotBlank(); + assertThat(b.sort()).isGreaterThanOrEqualTo(1); + assertThat(b.name()).isNotBlank(); }); - - // 绑定管理员:目录 + 菜单 + 10 按钮 - List binds = sysRoleMenuMapper.selectList(null); - assertThat(binds).hasSize(12); - assertThat(binds).allMatch(b -> b.getRoleId().equals(admin.getId())); - } - - @Test - @DisplayName("幂等:重复执行不产生重复菜单/权限点/绑定") - void seedAll_idempotent() { - insertAdminRole(); - - initializer.run(); - initializer.run(); - - assertThat(sysMenuMapper.selectCount(null)).isEqualTo(12); - assertThat(sysRoleMenuMapper.selectCount(null)).isEqualTo(12); - } - - @Test - @DisplayName("ROLE_ADMIN 缺失:权限点照常种子化,跳过绑定且不抛异常") - void seedAll_skipsBindingWhenAdminMissing() { - initializer.run(); - - assertThat(sysMenuMapper.selectCount(null)).isEqualTo(12); - assertThat(sysRoleMenuMapper.selectCount(null)).isZero(); - } - - @Test - @DisplayName("复用既有树:系统管理/数据字典已存在时不自建,权限点按 perms 补齐不重复") - void seedAll_reusesExistingMenuTree() { - insertAdminRole(); - // 模拟 crm-auth DataInitializer 已创建目录,且部分权限点已存在(带 apiUrl) - SysMenuSeed sysManage = new SysMenuSeed(); - sysManage.setParentId(0L); - sysManage.setMenuName("系统管理"); - sysManage.setMenuType(1); - sysManage.setSort(0); - sysManage.setVisible(true); - sysMenuMapper.insert(sysManage); - - SysMenuSeed dictMenu = new SysMenuSeed(); - dictMenu.setParentId(sysManage.getId()); - dictMenu.setMenuName("数据字典"); - dictMenu.setMenuType(2); - dictMenu.setPath("/system/dict"); - dictMenu.setSort(4); - dictMenu.setVisible(true); - sysMenuMapper.insert(dictMenu); - - SysMenuSeed existingButton = new SysMenuSeed(); - existingButton.setParentId(dictMenu.getId()); - existingButton.setMenuName("分组查询"); - existingButton.setMenuType(3); - existingButton.setPerms(DictConstants.PERM_GROUP_LIST); - existingButton.setApiUrl("/api/dict/group/page"); - existingButton.setStatus("enabled"); - existingButton.setVisible(true); - sysMenuMapper.insert(existingButton); - - initializer.run(); - - // 树不重复:非按钮节点仍 2 个,按钮恰好 10 个 - assertThat(sysMenuMapper.selectCount(null)).isEqualTo(12); - List buttons = sysMenuMapper.selectList(null).stream() - .filter(m -> m.getMenuType() == 3) - .toList(); - assertThat(buttons).extracting(SysMenuSeed::getPerms).containsExactlyInAnyOrderElementsOf(PERM_CODES); - // 已存在按钮保持原 id 不重建 - assertThat(buttons.stream().filter(b -> b.getPerms().equals(DictConstants.PERM_GROUP_LIST)) - .map(SysMenuSeed::getId)).containsExactly(existingButton.getId()); - // 绑定补齐 - assertThat(sysRoleMenuMapper.selectCount(null)).isEqualTo(12); } @Test @@ -242,49 +92,10 @@ class DictPermissionInitializerTest { } } - /*-------- 辅助 --------*/ - - private SysRoleSeed insertAdminRole() { - SysRoleSeed admin = new SysRoleSeed(); - admin.setRoleName("管理员"); - admin.setRoleCode("ROLE_ADMIN"); - admin.setDataScope(4); - admin.setSort(0); - admin.setBuiltin(true); - sysRoleMapper.insert(admin); - return admin; - } - - private SysMenuSeed findMenu(String name, Long parentId) { - return sysMenuMapper.selectList(null).stream() - .filter(m -> name.equals(m.getMenuName()) && parentId.equals(m.getParentId())) - .findFirst().orElse(null); - } - /** 从 hasAuthority('xxx') 表达式中提取权限码 */ private static String extractPerm(String expression) { int start = expression.indexOf('\''); int end = expression.lastIndexOf('\''); return expression.substring(start + 1, end); } - - /*-------- 建表 DDL(与 crm-auth 实体结构对齐:sys_menu 含 perms/api_url 等扩展列)--------*/ - - private static final String[] SCHEMA = { - "CREATE TABLE IF NOT EXISTS sys_menu (" + - "id BIGINT NOT NULL PRIMARY KEY, creator_id VARCHAR(50), create_time DATETIME, " + - "updater_id VARCHAR(50), update_time DATETIME, deleted TINYINT NOT NULL DEFAULT 0, " + - "parent_id BIGINT NOT NULL DEFAULT 0, menu_name VARCHAR(50) NOT NULL, " + - "menu_type TINYINT NOT NULL, path VARCHAR(200), component VARCHAR(200), icon VARCHAR(50), " + - "sort INT DEFAULT 0, visible TINYINT DEFAULT 1, description VARCHAR(100), " + - "perms VARCHAR(100), deny_behavior VARCHAR(16), status VARCHAR(16) DEFAULT 'enabled', " + - "api_url VARCHAR(200))", - "CREATE TABLE IF NOT EXISTS sys_role (" + - "id BIGINT NOT NULL PRIMARY KEY, creator_id VARCHAR(50), create_time DATETIME, " + - "updater_id VARCHAR(50), update_time DATETIME, deleted TINYINT NOT NULL DEFAULT 0, " + - "role_name VARCHAR(50), role_code VARCHAR(50), data_scope TINYINT, sort INT, " + - "remark VARCHAR(200), builtin BOOLEAN DEFAULT FALSE)", - "CREATE TABLE IF NOT EXISTS sys_role_menu (" + - "id BIGINT NOT NULL PRIMARY KEY, role_id BIGINT NOT NULL, menu_id BIGINT NOT NULL)", - }; } diff --git a/docs/adr/0016-permission-seeder-seam.md b/docs/adr/0016-permission-seeder-seam.md new file mode 100644 index 0000000..d050e14 --- /dev/null +++ b/docs/adr/0016-permission-seeder-seam.md @@ -0,0 +1,101 @@ +# 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` 表。