feat(module): 模块表新增 icon 字段,GetUserMod 下发模块自身图标
build / build-push (push) Successful in 56s

旧实现 toBoaModule 用 app.getPcIcon() 覆写每个模块的 icon,导致各子应用左侧菜单图标
恒等于应用图标;且 app_module 根本没有图标列,"模块自己的图标"这一概念此前不存在。

- app_module 新增 icon 列(VARCHAR(128),存 YschIcons 图标名);实体/Request/Vo 同步,
  新增走 BeanUtils 自动拷贝,edit 为逐字段赋值故显式补 setIcon(字段缺省即置空,清空可生效)
- GetUserMod:toBoaModule 不再覆写 icon,改由同名属性自动拷贝。**非必填**:模块未配图标时
  BoaModule.icon 即为 null,不再回退应用图标,由消费方自行兜底
- 附幂等加列脚本 sql/app_module_add_icon.sql(5.7 无 ADD COLUMN IF NOT EXISTS,用
  information_schema + PREPARE 兜子;本地 dev 靠 ddl-auto=update,脚本供 dev/prod 手工执行)
- 单测:修正原「icon 按应用级下发」断言为断言 null,并新增用例锁定「配了下发自身、没配为
  null、应用 pcIcon 不得覆盖」,共 10 例全绿
This commit is contained in:
chenxing
2026-09-15 18:11:02 +08:00
parent b0a02e56b0
commit 283ac4ca0a
7 changed files with 74 additions and 12 deletions
@@ -66,6 +66,13 @@ public class AppModule extends BaseEntity {
@Column(name = "path", length = 255)
private String path;
/**
* 模块图标(YschIcons 图标名,如 "notice-list")。非必填;<br>
* 为空时开放接口 {@code BoaModule.icon} 即为 null(不回退应用图标,由消费方自行兜底)。<br>
*/
@Column(name = "icon", length = 128)
private String icon;
/**
* 层级:根=1,子=父+1,上限5(由 Service 校验)。<br>
*/
@@ -32,5 +32,8 @@ public class AppModuleRequest {
/** 模块路径(前端路由/资源路径)。<br> */
private String path;
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
private String icon;
private Integer sortNo;
}
@@ -18,6 +18,7 @@ public class AppModuleVo {
private String name;
private String customId;
private String path;
private String icon;
private Integer level;
private Integer sortNo;
/** 子模块(递归);默认空列表,避免 JSON 序列化出现 null。 */
@@ -80,10 +80,11 @@ public class AppModuleService implements IAppModuleService {
if (appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), request.getCustomId(), id)) {
throw new DException("自定义ID在该应用下已存在");
}
// 可编辑字段:name/customId/path/sortNo;id、appId 不可改
// 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
entity.setName(request.getName());
entity.setCustomId(request.getCustomId());
entity.setPath(request.getPath());
entity.setIcon(request.getIcon());
entity.setSortNo(request.getSortNo());
// parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级
if (!Objects.equals(request.getParentId(), entity.getParentId())) {
@@ -541,7 +541,8 @@ public class GroupAuthzService implements IGroupAuthzService {
if (app == null) {
return Collections.emptyList();
}
String appIcon = app.getPcIcon();
// 注意:icon 取自模块自身(app_module.icon),不再借用应用 PC 图标(旧实现把应用图标
// 盖到每个模块上,导致消费方菜单图标恒等于应用图标);模块未配图标时 BoaModule.icon 即为 null。
SysUser user = sysUserRepository.findById(userId).orElse(null);
if (user == null) {
return Collections.emptyList();
@@ -549,7 +550,7 @@ public class GroupAuthzService implements IGroupAuthzService {
// 2. 超级管理员短路:不再返回全应用模块=仅返回该应用下全部模块(数据库过滤第二层:appId 限定)
if (isSuperAdmin(user.getType(), userId)) {
List<AppModule> allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId));
return assembleModuleTree(allModules, appIcon);
return assembleModuleTree(allModules);
}
// 3. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用)
Set<Integer> roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId);
@@ -598,7 +599,7 @@ public class GroupAuthzService implements IGroupAuthzService {
List<AppModule> appModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId));
if (appModules.isEmpty()) {
return (roleModuleIdsInApp.isEmpty()) ? Collections.emptyList()
: assembleModuleTreeById(roleModuleIdsInApp, appIcon, buildAppModuleMap(appModules));
: assembleModuleTreeById(roleModuleIdsInApp, buildAppModuleMap(appModules));
}
Set<Integer> appModuleIds = new LinkedHashSet<>();
for (AppModule m : appModules) {
@@ -640,7 +641,7 @@ public class GroupAuthzService implements IGroupAuthzService {
if (permittedModuleIds.isEmpty()) {
return Collections.emptyList();
}
return assembleModuleTreeById(permittedModuleIds, appIcon, buildAppModuleMap(appModules));
return assembleModuleTreeById(permittedModuleIds, buildAppModuleMap(appModules));
}
/**
@@ -684,7 +685,7 @@ public class GroupAuthzService implements IGroupAuthzService {
* 3. 叶子节点 children 置<b>空列表</b>(非 null),使前端可无判空递归;null 只表示「本接口未展开」。<br>
* 采用自顶向下遍历真根,故 parentId 脏数据成环的节点天然不可达(等同孤儿被丢弃),不存在无限递归。
*/
private static List<BoaModule> assembleModuleTree(List<AppModule> modules, String icon) {
private static List<BoaModule> assembleModuleTree(List<AppModule> modules) {
if (modules == null || modules.isEmpty()) {
return Collections.emptyList();
}
@@ -696,7 +697,7 @@ public class GroupAuthzService implements IGroupAuthzService {
if (m.getId() == null) {
continue;
}
nodeMap.put(m.getId(), toBoaModule(m, icon));
nodeMap.put(m.getId(), toBoaModule(m));
Integer parentKey = (m.getParentId() == null) ? ROOT_PARENT_ID : m.getParentId();
childrenMap.computeIfAbsent(parentKey, k -> new ArrayList<>()).add(m);
}
@@ -747,7 +748,7 @@ public class GroupAuthzService implements IGroupAuthzService {
}
}
private static List<BoaModule> assembleModuleTreeById(Set<Integer> moduleIds, String icon,
private static List<BoaModule> assembleModuleTreeById(Set<Integer> moduleIds,
Map<Integer, AppModule> moduleMap) {
if (moduleIds == null || moduleIds.isEmpty()) {
return Collections.emptyList();
@@ -759,13 +760,16 @@ public class GroupAuthzService implements IGroupAuthzService {
list.add(m);
}
}
return assembleModuleTree(list, icon);
return assembleModuleTree(list);
}
private static BoaModule toBoaModule(AppModule m, String icon) {
/**
* AppModule → BoaModule。icon 由 {@code BeanUtils} 随同名属性自动拷贝(模块未配图标即为 null)。
* 勿在此处重新用应用 PC 图标覆写——那会让消费方菜单图标恒等于应用图标(历史 bug)。
*/
private static BoaModule toBoaModule(AppModule m) {
BoaModule bm = new BoaModule();
BeanUtils.copyProperties(m, bm);
bm.setIcon(icon);
return bm;
}
@@ -0,0 +1,26 @@
-- 为 app_module 新增 icon 字段:模块自身的图标(存 YschIcons 图标名,如 'notice-list')。
-- 背景:此前 app_module 无图标列,开放接口 /oapi/user/modules 把「应用 PC 图标」盖到每个模块的
-- BoaModule.icon 上,导致各子应用左侧菜单图标恒等于应用图标(已修为下发模块自身 icon)。
-- 语义:非必填。留空 → 开放接口该模块的 icon 返回 null(不回退应用图标),由消费方前端自行兜底。
-- 执行方式:由 DBA 在 dev / prod 各自手工执行,AI 不代跑(本地 dev 靠 ddl-auto=update 自动加列)。
-- 幂等:MySQL 5.7 无 ADD COLUMN IF NOT EXISTS,故先查 information_schema 再动态执行,可重复跑。
SET @sql := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE app_module ADD COLUMN icon VARCHAR(128) NULL COMMENT ''模块图标(YschIcons 图标名);null=未配置,开放接口原样返回 null''',
'SELECT ''app_module.icon 已存在,跳过'' AS msg'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'app_module'
AND COLUMN_NAME = 'icon'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 核对:应看到 icon 列,类型 varchar(128),允许 NULL
-- SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
-- FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'app_module' AND COLUMN_NAME = 'icon';
@@ -38,6 +38,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
@@ -114,7 +115,7 @@ class GroupAuthzServiceModuleTreeTest {
assertEquals(2, roots.size(), "顶层只应有两个真根");
assertEquals("100", roots.get(0).getCustomId(), "customId 数值序:100 在 200 前");
assertEquals(APP_ICON, roots.get(0).getIcon(), "icon 仍按应用级下发到每个节点");
assertNull(roots.get(0).getIcon(), "模块未配图标时 icon 必须为 null,不得回填应用 PC 图标");
BoaModule child = only(roots.get(0).getChildren());
assertEquals(101, child.getId());
@@ -123,6 +124,19 @@ class GroupAuthzServiceModuleTreeTest {
assertEquals("3", String.valueOf(grand.getLevel()));
}
/** icon 取自模块自身(YschIcons 图标名):配了就用配的,没配返回 null——不再借用应用 PC 图标。 */
@Test
void findUserModulesForApp_shouldCarryModuleOwnIconInsteadOfAppIcon() {
AppModule withIcon = module(300, "300", null, 1, 10, "notice-list");
AppModule noIcon = module(310, "310", null, 1, 20);
stubSuperAdmin(withIcon, noIcon);
List<BoaModule> roots = groupAuthzService.findUserModulesForApp(APP_ID, null, superAdminId());
assertEquals("notice-list", roots.get(0).getIcon(), "已配图标应原样下发,应用 pcIcon 不得覆盖");
assertNull(roots.get(1).getIcon(), "未配图标的模块必须为 null,由消费方自行兜底");
}
@Test
void findUserModulesForApp_whenSortNoContradictsCustomId_shouldStillOrderByCustomId() {
// 故意让 sortNo 与 customId 反序:证明开放接口不采用管理端的 sortNo(空=999)+id 口径
@@ -344,6 +358,11 @@ class GroupAuthzServiceModuleTreeTest {
}
private static AppModule module(Integer id, String customId, Integer parentId, int level, Integer sortNo) {
return module(id, customId, parentId, level, sortNo, null);
}
private static AppModule module(Integer id, String customId, Integer parentId, int level, Integer sortNo,
String icon) {
AppModule m = new AppModule();
m.setId(id);
m.setAppId(APP_ID);
@@ -352,6 +371,7 @@ class GroupAuthzServiceModuleTreeTest {
m.setParentId(parentId);
m.setLevel(level);
m.setSortNo(sortNo);
m.setIcon(icon);
return m;
}
}