Browse Source
- 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
20 changed files with 891 additions and 597 deletions
@ -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` 保持不变,现有测试仍然通过 |
|||
@ -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 全部测试通过 |
|||
@ -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:* 权限点正确种子化 |
|||
@ -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=true、menuType 枚举值。</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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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_: 按钮配置、权限点定义 |
|||
@ -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 接口 URL(ApiPermissionInterceptor 按此匹配) |
|||
* @param sort 排序 |
|||
*/ |
|||
public record ButtonSeed( |
|||
String name, |
|||
String perms, |
|||
String apiUrl, |
|||
int sort |
|||
) { |
|||
} |
|||
@ -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 |
|||
) { |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
package com.crm.base.service; |
|||
|
|||
import com.crm.base.domain.dto.PermissionModuleDescriptor; |
|||
|
|||
/** |
|||
* 权限种子化 seam(ADR-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); |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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 的 SysRoleMenu(ADR-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; |
|||
} |
|||
@ -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 的 SysRole(ADR-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; |
|||
} |
|||
@ -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<SysMenuSeed> { |
|||
} |
|||
@ -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<SysRoleMenuSeed> { |
|||
} |
|||
@ -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<SysRoleSeed> { |
|||
} |
|||
@ -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…
Reference in new issue