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:
@@ -55,9 +55,9 @@ public class AppModule extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
|
||||
* 按钮权限型子模块(无路由无编码)为 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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):<br>
|
||||
* 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。<br>
|
||||
*/
|
||||
private String customId;
|
||||
|
||||
/** 模块路径(前端路由/资源路径)。<br> */
|
||||
/** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。<br> */
|
||||
private String path;
|
||||
|
||||
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
|
||||
|
||||
@@ -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。<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) {
|
||||
AppModuleVo vo = new AppModuleVo();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user