Browse Source

feat(auth,dict,base): extract PermissionSeeder seam to replace shadow entities (ADR-0016)

- Add PermissionSeeder interface + PermissionModuleDescriptor/ButtonSeed records in crm-base
- Implement PermissionSeederImpl in crm-auth with find-or-create idempotent seeding
- Refactor DataInitializer to use seam (3 seedModule calls) + @Order(1)
- Refactor DictPermissionInitializer to use seam (1 seedModule call) + @Order(10)
- Delete 6 shadow entity/mapper files from crm-dict (SysMenuSeed, SysRoleSeed, SysRoleMenuSeed + mappers)
- Rewrite DictPermissionInitializerTest to mock PermissionSeeder (no more H2 auth tables)
- Add PermissionSeederImplTest (H2 integration test covering idempotency, catalog sharing, role-not-found)
- Add ADR-0016 documenting the decision, rationale, alternatives, and consequences
- Create crm-base/CONTEXT.md with PermissionSeeder domain glossary
- Update CONTEXT-MAP.md to link crm-base/CONTEXT.md

Eliminates schema leakage: crm-dict no longer knows sys_menu's physical schema.
Runtime dependency accepted: crm-dict needs crm-auth's PermissionSeederImpl at runtime (via crm-app classpath aggregation).
master
luoweijian 4 weeks ago
parent
commit
51b7e2a749
  1. 19
      .scratch/permission-seeder-seam/issues/01-create-permission-seeder-seam.md
  2. 14
      .scratch/permission-seeder-seam/issues/02-migrate-data-initializer.md
  3. 18
      .scratch/permission-seeder-seam/issues/03-migrate-dict-initializer-delete-shadows.md
  4. 4
      CONTEXT-MAP.md
  5. 162
      crm-auth/src/main/java/com/crm/auth/config/DataInitializer.java
  6. 152
      crm-auth/src/main/java/com/crm/auth/service/impl/PermissionSeederImpl.java
  7. 400
      crm-auth/src/test/java/com/crm/auth/service/impl/PermissionSeederImplTest.java
  8. 17
      crm-base/CONTEXT.md
  9. 19
      crm-base/src/main/java/com/crm/base/domain/dto/ButtonSeed.java
  10. 29
      crm-base/src/main/java/com/crm/base/domain/dto/PermissionModuleDescriptor.java
  11. 22
      crm-base/src/main/java/com/crm/base/service/PermissionSeeder.java
  12. 133
      crm-dict/src/main/java/com/crm/dict/config/DictPermissionInitializer.java
  13. 50
      crm-dict/src/main/java/com/crm/dict/domain/entity/SysMenuSeed.java
  14. 25
      crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleMenuSeed.java
  15. 32
      crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleSeed.java
  16. 12
      crm-dict/src/main/java/com/crm/dict/mapper/SysMenuSeedMapper.java
  17. 12
      crm-dict/src/main/java/com/crm/dict/mapper/SysRoleMenuSeedMapper.java
  18. 12
      crm-dict/src/main/java/com/crm/dict/mapper/SysRoleSeedMapper.java
  19. 255
      crm-dict/src/test/java/com/crm/dict/config/DictPermissionInitializerTest.java
  20. 101
      docs/adr/0016-permission-seeder-seam.md

19
.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\<ButtonSeed\> buttons, List\<String\> 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` 保持不变,现有测试仍然通过

14
.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 全部测试通过

18
.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:* 权限点正确种子化

4
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

162
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;
/**
* 系统数据初始化幂等每次启动逐项检查只补缺的
* <p>创建内置管理员角色系统管理菜单角色管理 button 权限点
* 并将全部资源分配给管理员角色ADR-0012</p>
* <p>创建内置管理员角色然后通过 {@link PermissionSeeder} seam 种子化系统管理菜单
* 角色管理/菜单管理/部门管理的 button 权限点并将全部资源分配给管理员角色ADR-0012ADR-0016</p>
*/
@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<SysMenu> roleButtons = sysMenuMapper.selectList(
new LambdaQueryWrapper<SysMenu>()
.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<SysMenu>()
.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<SysMenu>().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<SysRoleMenu>()
.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);
}
}

152
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
* <p>拥有 sys_menu 的全部字段知识负责 find-or-create 幂等写入与角色绑定
* 硬编码 schema 默认值denyBehavior="hide"status="enabled"visible=truemenuType 枚举值</p>
*/
@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<Long> 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<SysRole>().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<SysMenu>()
.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<SysMenu>().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<SysRoleMenu>()
.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);
}
}

400
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
* <p>验证权限种子化 seam find-or-create 幂等写入与角色绑定逻辑</p>
*/
@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<SysMenu>()
.eq(SysMenu::getMenuName, "系统管理")
.eq(SysMenu::getParentId, 0L)
.eq(SysMenu::getMenuType, 1));
assertThat(catalog).isNotNull();
assertThat(catalog.getVisible()).isTrue();
// 验证:菜单创建
SysMenu menu = sysMenuMapper.selectOne(
new LambdaQueryWrapper<SysMenu>()
.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<SysMenu> buttons = sysMenuMapper.selectList(
new LambdaQueryWrapper<SysMenu>()
.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<SysRoleMenu> bindings = sysRoleMenuMapper.selectList(
new LambdaQueryWrapper<SysRoleMenu>().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<SysMenu>()
.eq(SysMenu::getMenuName, "系统管理")
.eq(SysMenu::getParentId, 0L)
.eq(SysMenu::getMenuType, 1));
assertThat(catalogCount).isEqualTo(1);
Long menuCount = sysMenuMapper.selectCount(
new LambdaQueryWrapper<SysMenu>()
.eq(SysMenu::getMenuName, "数据字典")
.eq(SysMenu::getMenuType, 2));
assertThat(menuCount).isEqualTo(1);
Long buttonCount = sysMenuMapper.selectCount(
new LambdaQueryWrapper<SysMenu>()
.eq(SysMenu::getPerms, "dict:group:list")
.eq(SysMenu::getMenuType, 3));
assertThat(buttonCount).isEqualTo(1);
// 验证:角色绑定也只有一条(catalog + menu + button = 3)
Long bindingCount = sysRoleMenuMapper.selectCount(
new LambdaQueryWrapper<SysRoleMenu>().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<SysMenu>()
.eq(SysMenu::getMenuName, "系统管理")
.eq(SysMenu::getParentId, 0L)
.eq(SysMenu::getMenuType, 1));
assertThat(catalogCount).isEqualTo(1);
// 验证:两个菜单都在同一目录下
SysMenu catalog = sysMenuMapper.selectOne(
new LambdaQueryWrapper<SysMenu>()
.eq(SysMenu::getMenuName, "系统管理")
.eq(SysMenu::getParentId, 0L));
Long menuCount = sysMenuMapper.selectCount(
new LambdaQueryWrapper<SysMenu>()
.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<SysMenu>()
.eq(SysMenu::getMenuName, "系统管理")
.eq(SysMenu::getParentId, 0L));
assertThat(catalogCount).isEqualTo(1);
Long menuCount = sysMenuMapper.selectCount(
new LambdaQueryWrapper<SysMenu>()
.eq(SysMenu::getMenuName, "数据字典")
.eq(SysMenu::getMenuType, 2));
assertThat(menuCount).isEqualTo(1);
Long buttonCount = sysMenuMapper.selectCount(
new LambdaQueryWrapper<SysMenu>()
.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<SysMenu>()
.eq(SysMenu::getMenuName, "系统管理")
.eq(SysMenu::getParentId, 0L));
assertThat(catalog).isNotNull();
SysMenu menu = sysMenuMapper.selectOne(
new LambdaQueryWrapper<SysMenu>()
.eq(SysMenu::getMenuName, "菜单管理")
.eq(SysMenu::getParentId, catalog.getId()));
assertThat(menu).isNotNull();
Long buttonCount = sysMenuMapper.selectCount(
new LambdaQueryWrapper<SysMenu>()
.eq(SysMenu::getParentId, menu.getId())
.eq(SysMenu::getMenuType, 3));
assertThat(buttonCount).isEqualTo(0);
// 验证:角色绑定只有 catalog + menu = 2
Long bindingCount = sysRoleMenuMapper.selectCount(
new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, role.getId()));
assertThat(bindingCount).isEqualTo(2);
}
}

17
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_: 按钮配置、权限点定义

19
crm-base/src/main/java/com/crm/base/domain/dto/ButtonSeed.java

@ -0,0 +1,19 @@
package com.crm.base.domain.dto;
/**
* 权限模块描述符中一条按钮权限点的最小声明ADR-0016
* <p>不包含 {@code denyBehavior}/{@code status}/{@code visible}这些是 sys_menu schema 细节
* 由种子器实现方统一硬编码需要不同值的管理员在启动后通过资源管理 API 修改</p>
*
* @param name 按钮名称
* @param perms 权限码"dict:group:list"
* @param apiUrl 接口 URLApiPermissionInterceptor 按此匹配
* @param sort 排序
*/
public record ButtonSeed(
String name,
String perms,
String apiUrl,
int sort
) {
}

29
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
* <p>一个描述符 = 一个目录 + 一个菜单 + 一组按钮 + 目标角色编码列表
* 目录幂等同一目录名被多次 {@code seedModule} 只创建一次
* 调用方不暴露 sys_menu 的任何字段名枚举值或默认值
* {@code denyBehavior}{@code status}{@code visible} schema 默认值由种子器实现方硬编码</p>
*
* @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<ButtonSeed> buttons,
List<String> bindToRoleCodes
) {
}

22
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;
/**
* 权限种子化 seamADR-0016
* <p>让任意业务模块声明式地将自己的权限点button 节点种子化到权限资源树sys_menu 物理表
* 而不需要知道该表的物理 schema调用方通过 {@link PermissionModuleDescriptor} 声明目录名菜单名
* 按钮列表目标角色编码实现方 crm-auth 拥有 sys_menu 的全部字段知识负责 find-or-create
* 幂等写入与角色绑定</p>
* <p>替代了此前的影子实体方案crm-dict 曾创建 SysMenuSeed 等重复实体类直接映射 sys_menu
* 导致 schema 泄漏</p>
*/
public interface PermissionSeeder {
/**
* 种子化一个模块的权限点到权限资源树
*
* @param descriptor 权限模块描述符目录 + 菜单 + 按钮 + 目标角色编码
*/
void seedModule(PermissionModuleDescriptor descriptor);
}

133
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
* <p> 10 {@code dict:...} 权限码种子化到权限资源树sys_menu 物理表并绑定内置管理员角色
* crm-auth DataInitializer 无耦合系统管理目录缺失则自建ROLE_ADMIN 缺失则跳过绑定并 warn
* 数据字典权限种子化幂等T07ADR-0016
* <p>通过 {@link PermissionSeeder} seam 10 {@code dict:...} 权限码种子化到权限资源树
* sys_menu 物理表并绑定内置管理员角色 crm-auth DataInitializer 无耦合
* 系统管理目录缺失则 seam 自建ROLE_ADMIN 缺失则 seam warn 跳过绑定
* crm-auth 首次启动创建后字典权限在下次启动补绑</p>
* <p>权限点写入了 api_urlcrm-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<ButtonSeed> 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<Long> 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<SysRoleSeed>().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<SysMenuSeed>()
.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<SysMenuSeed>().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<SysRoleMenuSeed>()
.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) {
}
}

50
crm-dict/src/main/java/com/crm/dict/domain/entity/SysMenuSeed.java

@ -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
* <p>仅用于 crm-dict 幂等种子化权限点刻意不标 @Entity建表归 crm-auth SysMenu
* 本类只做 MyBatis-Plus 读写避免与 crm-auth 双实体映射同一张表ADR-0015 零依赖约束</p>
*/
@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;
}

25
crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleMenuSeed.java

@ -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
* <p>仅用于 crm-dict 幂等种子化时将权限点绑定到内置管理员角色
* 刻意不标 @Entity建表归 crm-auth SysRoleMenuADR-0015 零依赖约束</p>
*/
@Data
@TableName("sys_role_menu")
public class SysRoleMenuSeed {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 角色ID */
private Long roleId;
/** 菜单/权限点ID */
private Long menuId;
}

32
crm-dict/src/main/java/com/crm/dict/domain/entity/SysRoleSeed.java

@ -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
* <p>仅用于 crm-dict 幂等种子化时定位内置管理员角色 ROLE_ADMIN刻意不标 @Entity
* 建表归 crm-auth SysRoleADR-0015 零依赖约束</p>
*/
@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;
}

12
crm-dict/src/main/java/com/crm/dict/mapper/SysMenuSeedMapper.java

@ -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 最小化映射 MapperT07 权限种子化专用
*/
@Mapper
public interface SysMenuSeedMapper extends CrmBaseMapper<SysMenuSeed> {
}

12
crm-dict/src/main/java/com/crm/dict/mapper/SysRoleMenuSeedMapper.java

@ -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 最小化映射 MapperT07 权限种子化专用
*/
@Mapper
public interface SysRoleMenuSeedMapper extends CrmBaseMapper<SysRoleMenuSeed> {
}

12
crm-dict/src/main/java/com/crm/dict/mapper/SysRoleSeedMapper.java

@ -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 最小化映射 MapperT07 权限种子化专用
*/
@Mapper
public interface SysRoleSeedMapper extends CrmBaseMapper<SysRoleSeed> {
}

255
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 注解契约
* <p>H2 手工装配仿 AbstractDictH2Test"无权限访问被拒"的动态行为由 crm-auth
* ApiPermissionInterceptor api_url 懒加载规则 Spring Security 保证
* 本测试验证种子化产出满足该拦截器契约api_url + status=enabled + perms</p>
* T07 权限种子化测试ADR-0016验证 DictPermissionInitializer 通过 PermissionSeeder seam
* 声明正确的权限描述符 Controller 端点带 @PreAuthorize 注解
* <p>不再使用 H2 sys_menu/sys_role/sys_role_menu seam 实现在 crm-auth
* PermissionSeederImplTest 中集成测试</p>
*/
@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<PermissionModuleDescriptor> 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<SysMenuSeed> buttons = sysMenuMapper.selectList(null).stream()
.filter(m -> m.getMenuType() == 3)
.toList();
// 10 个按钮权限点
List<ButtonSeed> 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<SysRoleMenuSeed> 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<SysMenuSeed> 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)",
};
}

101
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<ButtonSeed> buttons, // 按钮权限点(可为空列表)
List<String> 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<MenuSeed>` 的嵌套结构。
### 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` 表。
Loading…
Cancel
Save