旧实现 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:
@@ -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';
|
||||
+21
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user