From 283ac4ca0a44407462c4fe92f42b8bf2b92f95c0 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 15 Sep 2026 18:11:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(module):=20=E6=A8=A1=E5=9D=97=E8=A1=A8?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20icon=20=E5=AD=97=E6=AE=B5=EF=BC=8CGetUserM?= =?UTF-8?q?od=20=E4=B8=8B=E5=8F=91=E6=A8=A1=E5=9D=97=E8=87=AA=E8=BA=AB?= =?UTF-8?q?=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧实现 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 例全绿 --- .../com/yunhai/userbase/entity/AppModule.java | 7 +++++ .../model/appmodule/AppModuleRequest.java | 3 +++ .../userbase/model/appmodule/AppModuleVo.java | 1 + .../service/impl/AppModuleService.java | 3 ++- .../service/impl/GroupAuthzService.java | 24 ++++++++++------- .../resources/sql/app_module_add_icon.sql | 26 +++++++++++++++++++ .../impl/GroupAuthzServiceModuleTreeTest.java | 22 +++++++++++++++- 7 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 src/main/resources/sql/app_module_add_icon.sql diff --git a/src/main/java/com/yunhai/userbase/entity/AppModule.java b/src/main/java/com/yunhai/userbase/entity/AppModule.java index 6ae9897..8cf1c69 100644 --- a/src/main/java/com/yunhai/userbase/entity/AppModule.java +++ b/src/main/java/com/yunhai/userbase/entity/AppModule.java @@ -66,6 +66,13 @@ public class AppModule extends BaseEntity { @Column(name = "path", length = 255) private String path; + /** + * 模块图标(YschIcons 图标名,如 "notice-list")。非必填;
+ * 为空时开放接口 {@code BoaModule.icon} 即为 null(不回退应用图标,由消费方自行兜底)。
+ */ + @Column(name = "icon", length = 128) + private String icon; + /** * 层级:根=1,子=父+1,上限5(由 Service 校验)。
*/ diff --git a/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java index ff49dfd..bb4b33f 100644 --- a/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java +++ b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java @@ -32,5 +32,8 @@ public class AppModuleRequest { /** 模块路径(前端路由/资源路径)。
*/ private String path; + /** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。
*/ + private String icon; + private Integer sortNo; } diff --git a/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleVo.java b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleVo.java index 85641b0..c39a3bf 100644 --- a/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleVo.java +++ b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleVo.java @@ -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。 */ diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java index 8809fb3..90577e0 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java @@ -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())) { diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 4119f1e..d08c60c 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -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 allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId)); - return assembleModuleTree(allModules, appIcon); + return assembleModuleTree(allModules); } // 3. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用) Set roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId); @@ -598,7 +599,7 @@ public class GroupAuthzService implements IGroupAuthzService { List appModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId)); if (appModules.isEmpty()) { return (roleModuleIdsInApp.isEmpty()) ? Collections.emptyList() - : assembleModuleTreeById(roleModuleIdsInApp, appIcon, buildAppModuleMap(appModules)); + : assembleModuleTreeById(roleModuleIdsInApp, buildAppModuleMap(appModules)); } Set 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 置空列表(非 null),使前端可无判空递归;null 只表示「本接口未展开」。
* 采用自顶向下遍历真根,故 parentId 脏数据成环的节点天然不可达(等同孤儿被丢弃),不存在无限递归。 */ - private static List assembleModuleTree(List modules, String icon) { + private static List assembleModuleTree(List 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 assembleModuleTreeById(Set moduleIds, String icon, + private static List assembleModuleTreeById(Set moduleIds, Map 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; } diff --git a/src/main/resources/sql/app_module_add_icon.sql b/src/main/resources/sql/app_module_add_icon.sql new file mode 100644 index 0000000..87b2056 --- /dev/null +++ b/src/main/resources/sql/app_module_add_icon.sql @@ -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'; diff --git a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java index d87b3c7..f9ca0af 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java @@ -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 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; } }