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