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;
}
}