feat(module): 子模块支持按钮权限型,custom_id 放开为可空

- AppModule.customId 去掉 @NotBlank / nullable=false;AppModuleRequest 同步放开必填校验
- AppModuleService 空白归一为 null,仅在有值时做应用内唯一校验;path 同样归一 + trim
- 必须存 NULL 而非 '':'' 在 uk_app_module_custom(app_id, custom_id) 下每应用只能有一条
- 新增幂等 DDL sql/app_module_custom_id_nullable.sql(ddl-auto=update 不会改列属性)
- 补 AppModuleServiceButtonModuleTest
This commit is contained in:
chenxing
2026-10-21 08:52:34 +08:00
parent ad434dbc36
commit 195936236d
5 changed files with 155 additions and 11 deletions
@@ -55,9 +55,9 @@ public class AppModule extends BaseEntity {
/** /**
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br> * 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
* 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。
*/ */
@NotBlank @Column(name = "custom_id", nullable = true, length = 64)
@Column(name = "custom_id", nullable = false, length = 64)
private String customId; private String customId;
/** /**
@@ -26,10 +26,13 @@ public class AppModuleRequest {
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空") @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空")
private String name; private String name;
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空") /**
* 自定义ID(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):<br>
* 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。<br>
*/
private String customId; private String customId;
/** 模块路径(前端路由/资源路径)。<br> */ /** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。<br> */
private String path; private String path;
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */ /** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
@@ -58,12 +58,16 @@ public class AppModuleService implements IAppModuleService {
if (!applicationInfoRepository.existsById(request.getAppId())) { if (!applicationInfoRepository.existsById(request.getAppId())) {
throw new DException("所属应用不存在"); throw new DException("所属应用不存在");
} }
if (appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), request.getCustomId())) {
throw new DException("自定义ID在该应用下已存在");
}
AppModule entity = new AppModule(); AppModule entity = new AppModule();
BeanUtils.copyProperties(request, entity); BeanUtils.copyProperties(request, entity);
entity.setId(null); entity.setId(null);
// 按钮权限型子模块可不带编码:空白归一为 null,仅在有值时做应用内唯一校验
entity.setCustomId(normalizeBlankToNull(request.getCustomId()));
if (entity.getCustomId() != null
&& appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), entity.getCustomId())) {
throw new DException("自定义ID在该应用下已存在");
}
entity.setPath(normalizeBlankToNull(request.getPath()));
entity.setLevel(resolveLevel(request.getAppId(), request.getParentId())); entity.setLevel(resolveLevel(request.getAppId(), request.getParentId()));
// sortNo 默认为空(由前端编辑时带入),不自动递增 // sortNo 默认为空(由前端编辑时带入),不自动递增
appModuleRepository.save(entity); appModuleRepository.save(entity);
@@ -77,13 +81,15 @@ public class AppModuleService implements IAppModuleService {
Objects.requireNonNull(request, "模块编辑入参不能为空"); Objects.requireNonNull(request, "模块编辑入参不能为空");
AppModule entity = appModuleRepository.findById(id) AppModule entity = appModuleRepository.findById(id)
.orElseThrow(() -> new RuntimeException("模块不存在: " + id)); .orElseThrow(() -> new RuntimeException("模块不存在: " + id));
if (appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), request.getCustomId(), id)) { // 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
String customId = normalizeBlankToNull(request.getCustomId());
if (customId != null
&& appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), customId, id)) {
throw new DException("自定义ID在该应用下已存在"); throw new DException("自定义ID在该应用下已存在");
} }
// 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
entity.setName(request.getName()); entity.setName(request.getName());
entity.setCustomId(request.getCustomId()); entity.setCustomId(customId);
entity.setPath(request.getPath()); entity.setPath(normalizeBlankToNull(request.getPath()));
entity.setIcon(request.getIcon()); entity.setIcon(request.getIcon());
entity.setSortNo(request.getSortNo()); entity.setSortNo(request.getSortNo());
// parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级 // parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级
@@ -278,6 +284,19 @@ public class AppModuleService implements IAppModuleService {
} }
} }
/**
* 按钮权限型子模块可不带编码/路径:空白(null/空串/纯空格)统一归一为 null。<br>
* customId 归一是硬性要求:'' 在唯一键 uk_app_module_custom(app_id, custom_id) 下每个应用只能有一条,
* 多个按钮型子模块会互相撞键;NULL 在 MySQL 唯一索引中可并存。
*/
private static String normalizeBlankToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private AppModuleVo convertToVo(AppModule entity) { private AppModuleVo convertToVo(AppModule entity) {
AppModuleVo vo = new AppModuleVo(); AppModuleVo vo = new AppModuleVo();
BeanUtils.copyProperties(entity, vo); BeanUtils.copyProperties(entity, vo);
@@ -0,0 +1,14 @@
-- app_module.custom_id 从 NOT NULL 放开为可空:子模块双定位(树节点 / 按钮权限)。
-- 背景:子模块设计上既可作树节点(有路由路径+业务编码),也可作按钮权限(两者皆无)。
-- 路径 path 本就可空;custom_id 原为 NOT NULL + 应用内唯一键,挡死了按钮权限型子模块。
-- 关键:必须存 NULL 不能存 ''——'' 在唯一键 uk_app_module_custom(app_id, custom_id) 下
-- 每个应用只能有一条,第二个按钮型子模块即撞键;NULL 在 MySQL 唯一索引中可多行并存。
-- 执行方式:dev / prod 手工执行(ddl-auto=update 只加列不改既有列属性,不会自动放 nullable)。
-- 幂等:可重复执行,重复 MODIFY 到相同定义是无害 no-op。
ALTER TABLE app_module MODIFY COLUMN custom_id VARCHAR(64) NULL COMMENT '自定义ID(应用内唯一,业务编码);null=按钮权限型子模块无编码';
-- 核对:custom_id 的 IS_NULLABLE 应为 YES
-- 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 = 'custom_id';
@@ -0,0 +1,108 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.entity.AppModule;
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
import com.yunhai.userbase.repository.AppModuleRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 按钮权限型子模块(子模块双定位:树节点/按钮权限)——customId/path 可空。<br>
* 覆盖:空白 customId/path 归一为 null;空白 customId 跳过应用内唯一校验;
* 有值 customId 仍查重;编辑清空编码生效。背景见 sql/app_module_custom_id_nullable.sql
* ('' 在 uk_app_module_custom 下每应用仅一条,按钮型必须存 NULL)。
*/
class AppModuleServiceButtonModuleTest {
@InjectMocks
private AppModuleService appModuleService;
@Mock
private AppModuleRepository appModuleRepository;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private SysGroupModulePermRepository sysGroupModulePermRepository;
@Mock
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
private AppModuleRequest request(String name, String customId, String path) {
AppModuleRequest request = new AppModuleRequest();
request.setAppId(10);
request.setName(name);
request.setCustomId(customId);
request.setPath(path);
return request;
}
@Test
void add_buttonModule_blankCustomIdAndPath_normalizedToNull() {
when(applicationInfoRepository.existsById(10)).thenReturn(true);
appModuleService.add(request("删除按钮", " ", ""));
ArgumentCaptor<AppModule> captor = ArgumentCaptor.forClass(AppModule.class);
verify(appModuleRepository).save(captor.capture());
assertNull(captor.getValue().getCustomId());
assertNull(captor.getValue().getPath());
// 空白编码不进唯一性查询('' 会撞 uk_app_module_custom,必须以 NULL 落库)
verify(appModuleRepository, never()).existsByAppIdAndCustomId(anyInt(), anyString());
}
@Test
void add_withCustomId_stillCheckedForUniqueness() {
when(applicationInfoRepository.existsById(10)).thenReturn(true);
when(appModuleRepository.existsByAppIdAndCustomId(10, "btn-save")).thenReturn(true);
DException ex = assertThrows(DException.class,
() -> appModuleService.add(request("保存按钮", "btn-save", null)));
assertEquals("自定义ID在该应用下已存在", ex.getMessage());
verify(appModuleRepository, never()).save(any(AppModule.class));
}
@Test
void edit_clearCustomIdAndPath_setsNullWithoutUniquenessQuery() {
AppModule entity = new AppModule();
entity.setId(1);
entity.setAppId(10);
entity.setName("编辑按钮");
entity.setCustomId("btn-edit");
entity.setPath("/app/edit");
when(appModuleRepository.findById(1)).thenReturn(Optional.of(entity));
appModuleService.edit(1, request("编辑按钮", "", " "));
assertNull(entity.getCustomId());
assertNull(entity.getPath());
verify(appModuleRepository, never()).existsByAppIdAndCustomIdAndIdNot(anyInt(), anyString(), anyInt());
verify(appModuleRepository).save(entity);
}
}