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>
|
* 自定义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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user