diff --git a/src/main/java/com/yunhai/userbase/entity/AppModule.java b/src/main/java/com/yunhai/userbase/entity/AppModule.java index 8cf1c69..5ac2886 100644 --- a/src/main/java/com/yunhai/userbase/entity/AppModule.java +++ b/src/main/java/com/yunhai/userbase/entity/AppModule.java @@ -55,9 +55,9 @@ public class AppModule extends BaseEntity { /** * 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。
+ * 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。 */ - @NotBlank - @Column(name = "custom_id", nullable = false, length = 64) + @Column(name = "custom_id", nullable = true, length = 64) private String customId; /** 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 bb4b33f..ea19c65 100644 --- a/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java +++ b/src/main/java/com/yunhai/userbase/model/appmodule/AppModuleRequest.java @@ -26,10 +26,13 @@ public class AppModuleRequest { @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空") private String name; - @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空") + /** + * 自定义ID(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):
+ * 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。
+ */ private String customId; - /** 模块路径(前端路由/资源路径)。
*/ + /** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。
*/ private String path; /** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 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 90577e0..17c4a82 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java @@ -58,12 +58,16 @@ public class AppModuleService implements IAppModuleService { if (!applicationInfoRepository.existsById(request.getAppId())) { throw new DException("所属应用不存在"); } - if (appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), request.getCustomId())) { - throw new DException("自定义ID在该应用下已存在"); - } AppModule entity = new AppModule(); BeanUtils.copyProperties(request, entity); 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())); // sortNo 默认为空(由前端编辑时带入),不自动递增 appModuleRepository.save(entity); @@ -77,13 +81,15 @@ public class AppModuleService implements IAppModuleService { Objects.requireNonNull(request, "模块编辑入参不能为空"); AppModule entity = appModuleRepository.findById(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在该应用下已存在"); } - // 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改 entity.setName(request.getName()); - entity.setCustomId(request.getCustomId()); - entity.setPath(request.getPath()); + entity.setCustomId(customId); + entity.setPath(normalizeBlankToNull(request.getPath())); entity.setIcon(request.getIcon()); entity.setSortNo(request.getSortNo()); // parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级 @@ -278,6 +284,19 @@ public class AppModuleService implements IAppModuleService { } } + /** + * 按钮权限型子模块可不带编码/路径:空白(null/空串/纯空格)统一归一为 null。
+ * 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) { AppModuleVo vo = new AppModuleVo(); BeanUtils.copyProperties(entity, vo); diff --git a/src/main/resources/sql/app_module_custom_id_nullable.sql b/src/main/resources/sql/app_module_custom_id_nullable.sql new file mode 100644 index 0000000..23cc3a1 --- /dev/null +++ b/src/main/resources/sql/app_module_custom_id_nullable.sql @@ -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'; diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppModuleServiceButtonModuleTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppModuleServiceButtonModuleTest.java new file mode 100644 index 0000000..8c34773 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/AppModuleServiceButtonModuleTest.java @@ -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 可空。
+ * 覆盖:空白 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 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); + } +}