From b54ff47350d5ea8b03b56640358f4dbd4d56087a Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 4 Aug 2026 09:34:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(crm-auth):=20Ticket=2002=20=E2=80=94=20?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E8=B5=84=E6=BA=90=E6=A0=91=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=20+=20=E6=96=B0=E5=A2=9E/=E7=BC=96=E8=BE=91=E8=8A=82=E7=82=B9?= =?UTF-8?q?=EF=BC=88=E5=90=AB=E5=B1=82=E7=BA=A7=E7=BA=A6=E6=9D=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/resources 全量扁平数组(含 catalog/menu/button) - POST /api/resources JSON body 新增/编辑 + 6 种层级约束校验 - 字段必填:menu 缺 route 拒绝、button 缺 perms/denyBehavior/apiUrl/status 拒绝 - 编辑锁 type(不可变)+ 编辑时 route 未传保留原值 - SecurityConfig DEFAULT_IGNORE_URLS 放行 /api/resources/** - TDD: 27 个单测覆盖正反例,105 tests 全绿 --- .../com/crm/auth/config/SecurityConfig.java | 2 + .../auth/controller/ResourceController.java | 32 ++ .../crm/auth/service/IResourceService.java | 17 + .../service/impl/ResourceServiceImpl.java | 180 ++++++++ .../service/impl/ResourceServiceImplTest.java | 437 ++++++++++++++++++ 5 files changed, 668 insertions(+) create mode 100644 crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java create mode 100644 crm-auth/src/main/java/com/crm/auth/service/IResourceService.java create mode 100644 crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java create mode 100644 crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java diff --git a/crm-auth/src/main/java/com/crm/auth/config/SecurityConfig.java b/crm-auth/src/main/java/com/crm/auth/config/SecurityConfig.java index fe244fa..e86c009 100644 --- a/crm-auth/src/main/java/com/crm/auth/config/SecurityConfig.java +++ b/crm-auth/src/main/java/com/crm/auth/config/SecurityConfig.java @@ -29,6 +29,8 @@ public class SecurityConfig { private static final String[] DEFAULT_IGNORE_URLS = { // 登录接口 "/api/auth/login/**", + // 资源树管理接口(ADR-0011) + "/api/resources/**", // 接口文档 "/doc.html", "/webjars/**", diff --git a/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java b/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java new file mode 100644 index 0000000..9194984 --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java @@ -0,0 +1,32 @@ +package com.crm.auth.controller; + +import com.crm.auth.domain.dto.ResourceNode; +import com.crm.auth.service.IResourceService; +import com.crm.base.domain.result.Result; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 统一权限资源树管理端接口(ADR-0011) + */ +@RestController +@RequestMapping("/api/resources") +@RequiredArgsConstructor +public class ResourceController { + + private final IResourceService resourceService; + + /** 全量资源树(扁平数组,前端按 parentId 组树) */ + @GetMapping + public Result> listAll() { + return Result.success(resourceService.listAll()); + } + + /** 新增或编辑节点(含校验) */ + @PostMapping + public Result save(@RequestBody ResourceNode node) { + return Result.success(resourceService.save(node)); + } +} diff --git a/crm-auth/src/main/java/com/crm/auth/service/IResourceService.java b/crm-auth/src/main/java/com/crm/auth/service/IResourceService.java new file mode 100644 index 0000000..8b92e3a --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/service/IResourceService.java @@ -0,0 +1,17 @@ +package com.crm.auth.service; + +import com.crm.auth.domain.dto.ResourceNode; + +import java.util.List; + +/** + * 统一权限资源树服务(ADR-0011) + */ +public interface IResourceService { + + /** 全量资源树扁平数组(所有节点,不按角色过滤) */ + List listAll(); + + /** 新增或编辑节点(含层级约束、字段必填、编辑锁 type) */ + ResourceNode save(ResourceNode node); +} diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java new file mode 100644 index 0000000..5eb1fc2 --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java @@ -0,0 +1,180 @@ +package com.crm.auth.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.crm.auth.constant.AuthConstants; +import com.crm.auth.domain.dto.ResourceNode; +import com.crm.auth.domain.entity.SysMenu; +import com.crm.auth.domain.enums.MenuType; +import com.crm.auth.service.IResourceService; +import com.crm.auth.service.ISysMenuService; +import com.crm.base.domain.exception.BusinessErrorException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 统一权限资源树服务实现(ADR-0011) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ResourceServiceImpl implements IResourceService { + + /** 资源节点参数非法 */ + private static final int CODE_RESOURCE_INVALID = 61009; + + private final ISysMenuService sysMenuService; + + @Override + public List listAll() { + return sysMenuService.list().stream() + .map(ResourceNode::fromEntity) + .collect(Collectors.toList()); + } + + @Override + public ResourceNode save(ResourceNode node) { + if (node == null) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "资源节点不能为空"); + } + if (StrUtil.isBlank(node.getName())) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "节点名称不能为空"); + } + if (node.getType() == null) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "节点类型不能为空"); + } + + SysMenu entity; + if (node.getId() != null) { + // 编辑模式 + entity = sysMenuService.getById(node.getId()); + if (entity == null) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "资源节点不存在:id=" + node.getId()); + } + // 编辑锁:type 不可变 + if (entity.getMenuType() != null && !entity.getMenuType().equals(node.getType().getCode())) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "节点类型不可变更(现有类型=" + + MenuType.fromCode(entity.getMenuType()) + ")"); + } + } else { + entity = new SysMenu(); + } + + // 字段必填(按 type,编辑模式可复用已有值) + validateRequiredFields(node, entity); + + // 层级约束 + validateHierarchy(node); + + // DTO → Entity 映射 + applyToEntity(node, entity); + + sysMenuService.saveOrUpdate(entity); + log.info("保存资源节点:id={}, name={}, type={}", entity.getId(), entity.getMenuName(), + MenuType.fromCode(entity.getMenuType())); + + return ResourceNode.fromEntity(entity); + } + + // ==================== 校验逻辑 ==================== + + /** 层级约束:6 种非法父子组合 */ + private void validateHierarchy(ResourceNode node) { + MenuType childType = node.getType(); + + if (node.getParentId() == null || node.getParentId() == 0) { + // 根节点:只允许 catalog / menu + if (childType == MenuType.BUTTON) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "根节点不允许添加 button 类型"); + } + return; + } + + SysMenu parent = sysMenuService.getById(node.getParentId()); + if (parent == null) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "父节点不存在:parentId=" + node.getParentId()); + } + MenuType parentType = MenuType.fromCode(parent.getMenuType()); + + // 叶子节点 + if (parentType == MenuType.BUTTON) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 是叶子节点,不可添加子节点"); + } + // catalog 只能挂 menu + if (parentType == MenuType.CATALOG && childType != MenuType.MENU) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "catalog 节点下只能添加 menu 类型"); + } + // menu 只能挂 button + if (parentType == MenuType.MENU && childType != MenuType.BUTTON) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "menu 节点下只能添加 button 类型"); + } + } + + /** 字段必填校验(编辑模式:若 route 未传但 DB 已有 path 则放行) */ + private void validateRequiredFields(ResourceNode node, SysMenu existing) { + MenuType type = node.getType(); + + if (type == MenuType.MENU) { + boolean hasRoute = StrUtil.isNotBlank(node.getRoute()); + boolean hasExistingPath = existing != null && StrUtil.isNotBlank(existing.getPath()); + if (!hasRoute && !hasExistingPath) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "menu 类型必须提供路由(route)"); + } + } + + if (type == MenuType.BUTTON) { + if (StrUtil.isBlank(node.getPerms())) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 类型必须提供权限码(perms)"); + } + if (StrUtil.isBlank(node.getDenyBehavior())) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 类型必须提供无权限行为(denyBehavior)"); + } + if (StrUtil.isBlank(node.getApiUrl())) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 类型必须提供接口 URL(apiUrl)"); + } + if (StrUtil.isBlank(node.getStatus())) { + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 类型必须提供状态(status)"); + } + } + } + + // ==================== DTO → Entity ==================== + + /** 将 ResourceNode 中的业务字段写入 SysMenu(映射 name→menuName, route→path, type→menuType) */ + private void applyToEntity(ResourceNode node, SysMenu entity) { + entity.setMenuName(node.getName()); + entity.setParentId(node.getParentId() != null ? node.getParentId() : 0L); + + if (node.getType() != null) { + entity.setMenuType(node.getType().getCode()); + } + + // route / path:若未传则保留原有值 + if (node.getRoute() != null) { + entity.setPath(node.getRoute()); + } + + entity.setSort(node.getSort() != null ? node.getSort() : 0); + entity.setIcon(node.getIcon()); + entity.setVisible(true); // 默认可见 + + // button 专用字段(仅 button 类型时写入) + if (node.getType() == MenuType.BUTTON) { + entity.setDescription(node.getDescription()); + entity.setPerms(node.getPerms()); + entity.setDenyBehavior(node.getDenyBehavior()); + entity.setStatus(node.getStatus()); + entity.setApiUrl(node.getApiUrl()); + } else { + // 非 button 节点清掉这些字段 + entity.setDescription(null); + entity.setPerms(null); + entity.setDenyBehavior(null); + entity.setStatus(null); + entity.setApiUrl(null); + } + } +} diff --git a/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java b/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java new file mode 100644 index 0000000..ad34bc2 --- /dev/null +++ b/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java @@ -0,0 +1,437 @@ +package com.crm.auth.service.impl; + +import com.crm.auth.domain.dto.ResourceNode; +import com.crm.auth.domain.entity.SysMenu; +import com.crm.auth.domain.enums.MenuType; +import com.crm.auth.service.ISysMenuService; +import com.crm.base.domain.exception.BusinessErrorException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link ResourceServiceImpl} 单元测试:覆盖资源树查询、新增/编辑校验、层级约束、字段必填 + */ +@ExtendWith(MockitoExtension.class) +class ResourceServiceImplTest { + + @Mock + private ISysMenuService sysMenuService; + + @InjectMocks + private ResourceServiceImpl resourceService; + + // ========== 常用桩数据 ========== + + private SysMenu catalog; + private SysMenu menu; + private SysMenu button; + + @BeforeEach + void setUp() { + catalog = buildMenu(1L, 0L, "系统管理", 1); + menu = buildMenu(10L, 1L, "用户管理", 2); + menu.setPath("/system/user"); + + button = buildMenu(100L, 10L, "查询用户", 3); + button.setPerms("crm:user:list"); + button.setDenyBehavior("hide"); + button.setApiUrl("/api/system/users/page"); + button.setStatus("enabled"); + } + + // ==================== 查询 ==================== + + @Test + @DisplayName("全量资源树查询 -> 返回扁平 ResourceNode 数组,含 catalog/menu/button") + void listAll_returnsFlatListWithAllTypes() { + when(sysMenuService.list()).thenReturn(List.of(catalog, menu, button)); + + List result = resourceService.listAll(); + + assertThat(result).hasSize(3); + assertThat(result.get(0).getType()).isEqualTo(MenuType.CATALOG); + assertThat(result.get(0).getName()).isEqualTo("系统管理"); + assertThat(result.get(1).getType()).isEqualTo(MenuType.MENU); + assertThat(result.get(1).getRoute()).isEqualTo("/system/user"); + assertThat(result.get(2).getType()).isEqualTo(MenuType.BUTTON); + assertThat(result.get(2).getPerms()).isEqualTo("crm:user:list"); + } + + @Test + @DisplayName("空库 -> 返回空数组不抛异常") + void listAll_emptyDb_returnsEmptyList() { + when(sysMenuService.list()).thenReturn(List.of()); + + List result = resourceService.listAll(); + + assertThat(result).isEmpty(); + } + + // ==================== 新增 ==================== + + @Test + @DisplayName("新增 catalog 节点 -> 成功 save") + void save_newCatalog_success() { + ResourceNode node = newResourceNode(null, null, "新目录", MenuType.CATALOG); + + resourceService.save(node); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SysMenu.class); + verify(sysMenuService).saveOrUpdate(captor.capture()); + SysMenu saved = captor.getValue(); + assertThat(saved.getId()).isNull(); + assertThat(saved.getMenuType()).isEqualTo(1); + assertThat(saved.getMenuName()).isEqualTo("新目录"); + } + + @Test + @DisplayName("新增 menu 挂在 catalog 下 -> 成功 save") + void save_newMenuUnderCatalog_success() { + when(sysMenuService.getById(1L)).thenReturn(catalog); + + ResourceNode node = newResourceNode(null, 1L, "新菜单", MenuType.MENU); + node.setRoute("/new/menu"); + + resourceService.save(node); + + verify(sysMenuService).saveOrUpdate(any(SysMenu.class)); + } + + @Test + @DisplayName("新增 button 挂在 menu 下 -> 成功 save") + void save_newButtonUnderMenu_success() { + when(sysMenuService.getById(10L)).thenReturn(menu); + + ResourceNode node = newButtonNode(null, 10L, "新增按钮", "crm:user:add", "hide", "/api/user/add", "enabled"); + + resourceService.save(node); + + verify(sysMenuService).saveOrUpdate(any(SysMenu.class)); + } + + // ==================== 层级约束 ==================== + + @Test + @DisplayName("catalog 下挂 button -> 拒绝(catalog 只能挂 menu)") + void save_buttonUnderCatalog_rejected() { + when(sysMenuService.getById(1L)).thenReturn(catalog); + + ResourceNode node = newButtonNode(null, 1L, "非法按钮", "crm:x:y", "hide", "/api/x", "enabled"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("catalog") + .hasMessageContaining("menu"); + } + + @Test + @DisplayName("menu 下挂 catalog -> 拒绝(menu 只能挂 button)") + void save_catalogUnderMenu_rejected() { + when(sysMenuService.getById(10L)).thenReturn(menu); + + ResourceNode node = newResourceNode(null, 10L, "非法目录", MenuType.CATALOG); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("menu") + .hasMessageContaining("button"); + } + + @Test + @DisplayName("menu 下挂 menu -> 拒绝(menu 只能挂 button)") + void save_menuUnderMenu_rejected() { + when(sysMenuService.getById(10L)).thenReturn(menu); + + ResourceNode node = newResourceNode(null, 10L, "非法子菜单", MenuType.MENU); + node.setRoute("/sub/menu"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class); + } + + @Test + @DisplayName("button 下挂子节点 -> 拒绝(button 是叶子)") + void save_childUnderButton_rejected() { + when(sysMenuService.getById(100L)).thenReturn(button); + + // 子 button 补全必填字段,让字段校验通过后再触发层级约束 + ResourceNode node = newButtonNode(null, 100L, "非法孙子", "crm:x:y", "hide", "/api/x", "enabled"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("叶子"); + } + + @Test + @DisplayName("根节点挂 button -> 拒绝(只允许 catalog/menu 作为根)") + void save_buttonAsRoot_rejected() { + ResourceNode node = newButtonNode(null, null, "根按钮", "crm:x:y", "hide", "/api/x", "enabled"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("根节点"); + } + + @Test + @DisplayName("根节点挂 catalog -> 成功") + void save_catalogAsRoot_success() { + ResourceNode node = newResourceNode(null, null, "根目录", MenuType.CATALOG); + + resourceService.save(node); + + verify(sysMenuService).saveOrUpdate(any(SysMenu.class)); + } + + @Test + @DisplayName("根节点挂 menu -> 成功") + void save_menuAsRoot_success() { + ResourceNode node = newResourceNode(null, null, "根菜单", MenuType.MENU); + node.setRoute("/root-menu"); + + resourceService.save(node); + + verify(sysMenuService).saveOrUpdate(any(SysMenu.class)); + } + + // ==================== 字段必填校验 ==================== + + @Test + @DisplayName("menu 缺 route -> 拒绝") + void save_menuWithoutRoute_rejected() { + ResourceNode node = newResourceNode(null, null, "无路由菜单", MenuType.MENU); + // route 为 null + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("路由"); + } + + @Test + @DisplayName("menu route 为空字符串 -> 拒绝") + void save_menuWithBlankRoute_rejected() { + ResourceNode node = newResourceNode(null, null, "空路由菜单", MenuType.MENU); + node.setRoute(" "); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("路由"); + } + + @Test + @DisplayName("button 缺 perms -> 拒绝") + void save_buttonWithoutPerms_rejected() { + ResourceNode node = newButtonNode(null, null, "无perms", null, "hide", "/api/x", "enabled"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("perms"); + } + + @Test + @DisplayName("button perms 为空字符串 -> 拒绝") + void save_buttonWithBlankPerms_rejected() { + ResourceNode node = newButtonNode(null, null, "空perms", " ", "hide", "/api/x", "enabled"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("perms"); + } + + @Test + @DisplayName("button 缺 denyBehavior -> 拒绝") + void save_buttonWithoutDenyBehavior_rejected() { + ResourceNode node = newButtonNode(null, null, "无deny", "crm:x:y", null, "/api/x", "enabled"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("denyBehavior"); + } + + @Test + @DisplayName("button 缺 apiUrl -> 拒绝") + void save_buttonWithoutApiUrl_rejected() { + ResourceNode node = newButtonNode(null, null, "无api", "crm:x:y", "hide", null, "enabled"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("apiUrl"); + } + + @Test + @DisplayName("button 缺 status -> 拒绝") + void save_buttonWithoutStatus_rejected() { + ResourceNode node = newButtonNode(null, null, "无status", "crm:x:y", "hide", "/api/x", null); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("status"); + } + + // ==================== 编辑 type 锁 ==================== + + @Test + @DisplayName("编辑时尝试改 type(catalog→menu)-> 拒绝") + void save_editChangeType_rejected() { + when(sysMenuService.getById(1L)).thenReturn(catalog); // type=1 (catalog) + + ResourceNode node = newResourceNode(1L, null, "改类型", MenuType.MENU); + node.setRoute("/new-route"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("类型"); + verify(sysMenuService, never()).saveOrUpdate(any()); + } + + @Test + @DisplayName("编辑时 type 不变 -> 成功") + void save_editSameType_success() { + when(sysMenuService.getById(1L)).thenReturn(catalog); // type=1(catalog) + + ResourceNode node = newResourceNode(1L, null, "改名字", MenuType.CATALOG); + + resourceService.save(node); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SysMenu.class); + verify(sysMenuService).saveOrUpdate(captor.capture()); + assertThat(captor.getValue().getMenuName()).isEqualTo("改名字"); + } + + // ==================== 编辑时 type 保留原有节点 field ==================== + + @Test + @DisplayName("编辑 menu + path/component 未传入 -> 使用原有值") + void save_editMenuPreserveOldPath() { + when(sysMenuService.getById(10L)).thenReturn(menu); // path="/system/user" + + ResourceNode node = newResourceNode(10L, null, "改名", MenuType.MENU); + // 不设 route——预期保留原有 path + + resourceService.save(node); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SysMenu.class); + verify(sysMenuService).saveOrUpdate(captor.capture()); + SysMenu saved = captor.getValue(); + assertThat(saved.getMenuName()).isEqualTo("改名"); + // route 未传入:保留原有 path + assertThat(saved.getPath()).isEqualTo("/system/user"); + } + + // ==================== 编辑后 type 保持不变 ==================== + + @Test + @DisplayName("编辑 catalog -> catalog 的 perms/apiUrl 等无用字段为 null") + void save_editCatalog_noButtonFields() { + when(sysMenuService.getById(1L)).thenReturn(catalog); + + ResourceNode node = newResourceNode(1L, null, "改昵称", MenuType.CATALOG); + node.setPerms("unused-perm"); // 前端可能误传,但 catalog 应该忽略 + + resourceService.save(node); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SysMenu.class); + verify(sysMenuService).saveOrUpdate(captor.capture()); + SysMenu saved = captor.getValue(); + assertThat(saved.getMenuType()).isEqualTo(1); + assertThat(saved.getPerms()).isNull(); // catalog 不存 perms + assertThat(saved.getApiUrl()).isNull(); // catalog 不存 apiUrl + } + + // ==================== 非空校验 ==================== + + @Test + @DisplayName("ResourceNode 为 null -> 拒绝") + void save_nullNode_rejected() { + assertThatThrownBy(() -> resourceService.save(null)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("不能为空"); + } + + @Test + @DisplayName("type 为 null -> 拒绝") + void save_nullType_rejected() { + ResourceNode node = new ResourceNode(); + node.setName("无类型"); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("类型"); + } + + @Test + @DisplayName("name 为空 -> 拒绝") + void save_blankName_rejected() { + ResourceNode node = newResourceNode(null, null, " ", MenuType.CATALOG); + + assertThatThrownBy(() -> resourceService.save(node)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("名称"); + } + + // ==================== 编辑时传入 extra field 覆盖 ==================== + + @Test + @DisplayName("编辑 menu + 正常更新 route -> 覆盖原有 path") + void save_editMenuUpdateRoute() { + when(sysMenuService.getById(10L)).thenReturn(menu); // path="/system/user" + + ResourceNode node = newResourceNode(10L, null, "改名", MenuType.MENU); + node.setRoute("/new/path"); + + resourceService.save(node); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SysMenu.class); + verify(sysMenuService).saveOrUpdate(captor.capture()); + assertThat(captor.getValue().getPath()).isEqualTo("/new/path"); + } + + // ==================== 辅助方法 ==================== + + private SysMenu buildMenu(Long id, Long parentId, String name, int menuType) { + SysMenu m = new SysMenu(); + m.setId(id); + m.setParentId(parentId); + m.setMenuName(name); + m.setMenuType(menuType); + return m; + } + + private ResourceNode newResourceNode(Long id, Long parentId, String name, MenuType type) { + ResourceNode node = new ResourceNode(); + node.setId(id); + node.setParentId(parentId); + node.setName(name); + node.setType(type); + node.setSort(0); + return node; + } + + private ResourceNode newButtonNode(Long id, Long parentId, String name, + String perms, String denyBehavior, + String apiUrl, String status) { + ResourceNode node = new ResourceNode(); + node.setId(id); + node.setParentId(parentId); + node.setName(name); + node.setType(MenuType.BUTTON); + node.setSort(0); + node.setPerms(perms); + node.setDenyBehavior(denyBehavior); + node.setApiUrl(apiUrl); + node.setStatus(status); + return node; + } +}