You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1018 lines
78 KiB

��diff --git a/crm-auth/pom.xml b/crm-auth/pom.xml
index 1920720..5d82910 100644
--- a/crm-auth/pom.xml
+++ b/crm-auth/pom.xml
@@ -23,6 +23,11 @@
<artifactId>crm-base</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.crm</groupId>
+ <artifactId>crm-file</artifactId>
+ </dependency>
+
<!-- 9p~\�f9p
Y�S͓,X}crm-base Y�C��}�t&h!| security-core�?-->
<dependency>
<groupId>org.springframework.boot</groupId>
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 = {
// '����}���0[_
"/api/auth/login/**",
+ // �t�R.|͓ bx���U4^Y�O�}ADR-0011�?+ "/api/resources/**",
// ���0[_“�V0
"/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..93849e3
--- /dev/null
+++ b/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java
@@ -0,0 +1,47 @@
+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 com.crm.file.domain.dto.FileInfoDTO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * �q�zɓ�Q�j�t�R.|͓ bx���Ulຓ�0[_�WDR-0011�?+ */
+@RestController
+@RequestMapping("/api/resources")
+@RequiredArgsConstructor
+public class ResourceController {
+
+ private final IResourceService resourceService;
+
+ /** O�)1zV�t�R.|͓/b}��xO����nb�|�}\�X�~��[ parentId �R2r�?*/
+ @GetMapping
+ public Result<List<ResourceNode>> listAll() {
+ return Result.success(resourceService.listAll());
+ }
+
+ /** “g�崓+h*}Hg#b�Y�xv}Z���Nr`i�\} */
+ @PostMapping
+ public Result<ResourceNode> save(@RequestBody ResourceNode node) {
+ return Result.success(resourceService.save(node));
+ }
+
+ /** R��r�jz��PcP�X�`p/a�Y�ĉ��̓?+ �~F�NZoT�`�?*/
+ @DeleteMapping("/{id}")
+ public Result<Void> delete(@PathVariable Long id) {
+ resourceService.delete(id);
+ return Result.success();
+ }
+
+ /** e��p#r�mAZ6}�Xxr�[?�oC�v͓��Ys�?*/
+ @PostMapping("/icon/upload")
+ public Result<FileInfoDTO> uploadIcon(@RequestParam("file") MultipartFile file) {
+ return Result.success(resourceService.uploadIcon(file));
+ }
+}
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..0f44876
--- /dev/null
+++ b/crm-auth/src/main/java/com/crm/auth/service/IResourceService.java
@@ -0,0 +1,25 @@
+package com.crm.auth.service;
+
+import com.crm.auth.domain.dto.ResourceNode;
+import com.crm.file.domain.dto.FileInfoDTO;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * �q�zɓ�Q�j�t�R.|͓bGnT�&}ADR-0011�?+ */
+public interface IResourceService {
+
+ /** O�)1zV�t�R.|͓b�X��k��f�R}��� ȓY�Y�xv}�m�]�[Yt�c�Xig�V�b�?*/
+ List<ResourceNode> listAll();
+
+ /** “g�崓+h*}Hg#b�Y�xv}Z���0w�~E�[ɓ�q� xO�tZ[�@~�o��� zO*}Hg&b#e type�?*/
+ ResourceNode save(ResourceNode node);
+
+ /** R��r�jz��PcP�X�`p/a�Y�ĉ��̓?+ �~F�NZoT�` sys_role_menu ��XHo�[�fde�?*/
+ void delete(Long id);
+
+ /** e��p#r�mAZ6}�Xxr�[?�oC�v͓��Ys + �t?FileApi p:j�W MinIO��\Q~e��p�g`m5g�O��$�} */
+ FileInfoDTO uploadIcon(MultipartFile file);
+}
diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionServiceImpl.java
index 650533d..202d36e 100644
--- a/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionServiceImpl.java
+++ b/crm-auth/src/main/java/com/crm/auth/service/impl/PermissionServiceImpl.java
@@ -1,6 +1,7 @@
package com.crm.auth.service.impl;
import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.crm.auth.domain.entity.*;
import com.crm.auth.mapper.*;
@@ -69,10 +70,36 @@ public class PermissionServiceImpl {
}
DataVisibilityContext.load(
new DataVisibility(userId, primaryDeptId, List.copyOf(deptIds), widest, expandedDeptIds));
- return roles.stream()
+
+ // 9�� 9�� ɓ�Q�j.�xO߂ƕ�U}ADR-0011�Y0}��X�l"�&1�W��� ȓYW�y��S�]ɓ�Q�k button z��PcP perms 9�� 9��
+ List<String> authorities = new ArrayList<>();
+ if (CollUtil.isNotEmpty(userRoles)) {
+ try {
+ Set<Long> roleIds = userRoles.stream().map(SysUserRole::getRoleId).collect(Collectors.toSet());
+ List<SysRoleMenu> allRoleMenus = sysRoleMenuMapper.selectList(
+ new LambdaQueryWrapper<SysRoleMenu>().in(SysRoleMenu::getRoleId, roleIds));
+ if (CollUtil.isNotEmpty(allRoleMenus)) {
+ Set<Long> menuIds = allRoleMenus.stream()
+ .map(SysRoleMenu::getMenuId).collect(Collectors.toSet());
+ List<SysMenu> menus = sysMenuMapper.selectBatchIds(menuIds);
+ menus.stream()
+ .filter(m -> m.getMenuType() != null && m.getMenuType() == 3)
+ .filter(m -> "enabled".equals(m.getStatus()))
+ .map(SysMenu::getPerms)
+ .filter(StrUtil::isNotBlank)
+ .distinct()
+ .forEach(authorities::add);
+ }
+ } catch (Exception e) {
+ log.debug("ɓ�Q�j.�xO߂ƕ�Uaq�t 2Q0�t�0}Y��XQ sys_menu/sys_role_menu t%1�vȓD��W�[4s}��\�pig?, e);
+ }
+ }
+ // Yt�c�X+hrigɅ�Yf�&1Hoĕ.ar�m*[�`�X��p�mT�`�m?Spring Security authority��[asRole/hasAuthority Z��R�VV�R_�S�?+ roles.stream()
.map(SysRole::getRoleCode)
.filter(Objects::nonNull)
- .toList();
+ .forEach(authorities::add);
+ return authorities;
}
public List<SysMenu> getMenuTree(Long userId) {
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..d0705fb
--- /dev/null
+++ b/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java
@@ -0,0 +1,261 @@
+package com.crm.auth.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+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.entity.SysRoleMenu;
+import com.crm.auth.domain.enums.MenuType;
+import com.crm.auth.mapper.SysRoleMenuMapper;
+import com.crm.auth.service.IResourceService;
+import com.crm.auth.service.ISysMenuService;
+import com.crm.base.domain.exception.BusinessErrorException;
+import com.crm.file.api.FileApi;
+import com.crm.file.domain.dto.FileInfoDTO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * �q�zɓ�Q�j�t�R.|͓bGnT�3 �u�.^}ADR-0011�?+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ResourceServiceImpl implements IResourceService {
+
+ /** �t�R.|z��PcPY��P�fȕ�pvx */
+ private static final int CODE_RESOURCE_INVALID = 61009;
+
+ private final ISysMenuService sysMenuService;
+ private final SysRoleMenuMapper sysRoleMenuMapper;
+ private final FileApi fileApi;
+
+ @Override
+ public List<ResourceNode> 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, "�t�R.|z��PcP�m�]XQ�my�%");
+ }
+ if (StrUtil.isBlank(node.getName())) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "z��PcPZ��]�m�]XQ�my�%");
+ }
+ if (node.getType() == null) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "z��PcP�~��7p�m�]XQ�my�%");
+ }
+
+ SysMenu entity;
+ if (node.getId() != null) {
+ // ,h�}�Y3 !}
+ entity = sysMenuService.getById(node.getId());
+ if (entity == null) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "�t�R.|z��PcP�m�]�tf���0}id=" + node.getId());
+ }
+ // ,h�}���O0}type �m�]r_Y�?+ if (entity.getMenuType() != null && !entity.getMenuType().equals(node.getType().getCode())) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "z��PcP�~��7p�m�]r_Y�;j?m�"XG^ȓ Y��h�?"
+ + MenuType.fromCode(entity.getMenuType()) + "�?);
+ }
+ } else {
+ entity = new SysMenu();
+ }
+
+ // pAi��G�mT^��X�[ type��\*}Hgb�[�_r_�o�]de�[�SAnJ�wx}
+ validateRequiredFields(node, entity);
+
+ // ^p�P���~=�ko
+ validateHierarchy(node);
+
+ // DTO +�?Entity ē�r�v
+ applyToEntity(node, entity);
+
+ sysMenuService.saveOrUpdate(entity);
+ log.info("�m�o�t�t�R.|z��PcP�ikd={}, name={}, type={}", entity.getId(), entity.getMenuName(),
+ MenuType.fromCode(entity.getMenuType()));
+
+ return ResourceNode.fromEntity(entity);
+ }
+
+ @Override
+ public void delete(Long id) {
+ SysMenu node = sysMenuService.getById(id);
+ if (node == null) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "�t�R.|z��PcP�m�]�tf���0}id=" + id);
+ }
+
+ // p/a�Y�ĉ��̓?+ long childCount = sysMenuService.count(
+ new LambdaQueryWrapper<SysMenu>().eq(SysMenu::getParentId, id));
+ if (childCount > 0) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID,
+ "z��PcP�m+[An " + childCount + " �mD��tz��PcP��\��O�X�Wĕ�0�tz��PcP");
+ }
+
+ // �~F�NZoT�`Yt�c�X��XHo�[�fde
+ int deletedRefs = sysRoleMenuMapper.delete(
+ new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getMenuId, id));
+ if (deletedRefs > 0) {
+ log.info("R��r�j�t�R.|z��PcPÓAR��q��e�z��UW�y��S�]ɓ�Q)}"���0}menuId={}, ZoT�`\m?{} ɓ���0�?, id, deletedRefs);
+ }
+
+ sysMenuService.removeById(id);
+ log.info("R��r�j�t�R.|z��PcP�ikd={}, name={}", id, node.getMenuName());
+ }
+
+ // ==================== e��p#r�mAZ6} ====================
+
+ /** Z�Xvxe��p#r��A%MwZ�?*/
+ private static final Set<String> ALLOWED_ICON_EXTENSIONS = Set.of(
+ "png", "jpg", "jpeg", "gif", "svg", "webp");
+
+ /** e��p#r�mAZ6}�oC�v�mGZ�j�?00KB�?*/
+ private static final long MAX_ICON_SIZE = 500 * 1024;
+
+ @Override
+ public FileInfoDTO uploadIcon(MultipartFile file) {
+ // �~�p�g`m5���̓?+ if (file == null || file.isEmpty()) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "e��p#r“�V"k�m�]XQ�my�%");
+ }
+
+ // �oC�v͓��Ys
+ if (file.getSize() > MAX_ICON_SIZE) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID,
+ "e��p#r�oC�v�m�]XQ�tpTC~ 500KB�X�}S�? " + (file.getSize() / 1024) + "KB�?);
+ }
+
+ String originalName = file.getOriginalFilename();
+ if (StrUtil.isBlank(originalName)) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "e��p#r“�V"kZ��] {s�b��~?);
+ }
+
+ // ͓Nq!}͓��Ys
+ String ext = StrUtil.subAfter(originalName, '.', true);
+ if (StrUtil.isBlank(ext) || !ALLOWED_ICON_EXTENSIONS.contains(ext.toLowerCase())) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID,
+ "�m�]ne��zO�ke��p#r͓Nq!}: " + ext + "�}\�S�tJTxr�[? " + ALLOWED_ICON_EXTENSIONS);
+ }
+
+ try {
+ FileInfoDTO result = fileApi.upload(file.getBytes(), originalName,
+ file.getContentType(), "resources/icon");
+ log.info("e��p#r�mAZ6}��,a�Y�`kileId={}, name={}", result.getFileId(), originalName);
+ return result;
+ } catch (IOException e) {
+ log.error("e��p#r�mAZ6}�t��G_“�V"k�o�����k}", originalName, e);
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "e��p#r“�V"k�t��G_�o������\�Ვ�]/v");
+ }
+ }
+
+ // ==================== ͓��Ys��f�} ====================
+
+ /** ^p�P���~=�ko�? �~�]jo Z�f�Wp.a�|Z�?*/
+ private void validateHierarchy(ResourceNode node) {
+ MenuType childType = node.getType();
+
+ if (node.getParentId() == null || node.getParentId() == 0) {
+ // ͓Ex�Y�xv0}Y�D��S�t?catalog / menu
+ if (childType == MenuType.BUTTON) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "͓Ex�Y�� {O�}O��#Z���Y button �~��7p");
+ }
+ return;
+ }
+
+ SysMenu parent = sysMenuService.getById(node.getParentId());
+ if (parent == null) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "��`�Y�� {p:jjn�qkarentId=" + node.getParentId());
+ }
+ MenuType parentType = MenuType.fromCode(parent.getMenuType());
+
+ // Y����tz��PcP
+ if (parentType == MenuType.BUTTON) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button ē�~_p/a�Y�xv}�m�]r_#Z���Yp/a�Y�?);
+ }
+ // catalog Y�G�XQ��?menu
+ if (parentType == MenuType.CATALOG && childType != MenuType.MENU) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "catalog z��PcP�m*[g_s���JWT�?menu �~��7p");
+ }
+ // menu Y�G�XQ��?button
+ if (parentType == MenuType.MENU && childType != MenuType.BUTTON) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "menu z��PcP�m*[g_s���JWT�?button �~��7p");
+ }
+ }
+
+ /** pAi��G�mT^�͓��Ys�"X*}Hgb�[�_0}{�?route ȓC�6}cm?DB �[�SAn path R�k�et�\} */
+ 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 �~��7pG�tT0��*a�}�t�qe�nWoute�?);
+ }
+ }
+
+ if (type == MenuType.BUTTON) {
+ if (StrUtil.isBlank(node.getPerms())) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button �~��7pG�tT0��*a�}ɓ�Q�j.��O}perms�?);
+ }
+ if (StrUtil.isBlank(node.getDenyBehavior())) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button �~��7pG�tT0��*a�}Ó�rHoĕ/a��m4s}denyBehavior�?);
+ }
+ if (StrUtil.isBlank(node.getApiUrl())) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button �~��7pG�tT0��*a�}���0[_ URL�EWpiUrl�?);
+ }
+ if (StrUtil.isBlank(node.getStatus())) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button �~��7pG�tT0��*a�}�5�� �O}status�?);
+ }
+ }
+ }
+
+ // ==================== DTO +�?Entity ====================
+
+ /** Op?ResourceNode �m`��k�m,l�YpAi��P�k�S SysMenu�X�iOp?name+��benuName, route+��bath, type+��benuType�?*/
+ 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�3l�ZȓC�6}R�kZ~#�k+^ȓ
Y� ?+ if (node.getRoute() != null) {
+ entity.setPath(node.getRoute());
+ }
+
+ entity.setSort(node.getSort() != null ? node.getSort() : 0);
+ entity.setIcon(node.getIcon());
+ entity.setVisible(true); // �i?j{�Y��F�
+
+ // button �m�ddepAi���X�| button �~��7p��SUO��0}
+ 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 z��PcPZnT^igk:|pAi��
+ 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..15e4990
--- /dev/null
+++ b/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java
@@ -0,0 +1,583 @@
+package com.crm.auth.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.crm.auth.domain.dto.ResourceNode;
+import com.crm.auth.domain.entity.SysMenu;
+import com.crm.auth.domain.entity.SysRoleMenu;
+import com.crm.auth.domain.enums.MenuType;
+import com.crm.auth.mapper.SysRoleMenuMapper;
+import com.crm.auth.service.ISysMenuService;
+import com.crm.base.domain.exception.BusinessErrorException;
+import com.crm.file.api.FileApi;
+import com.crm.file.domain.dto.FileInfoDTO;
+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 org.springframework.web.multipart.MultipartFile;
+
+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.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * {@link ResourceServiceImpl} W��f�S4Z-[/v�3l��)�,hkygZ-a2r̓�0�ᆒyO�g�o?,h�}͓��Ys��xO0w�~E�[ɓ�q� xO�tZ[�@~�o?+ */
+@ExtendWith(MockitoExtension.class)
+class ResourceServiceImplTest {
+
+ @Mock
+ private ISysMenuService sysMenuService;
+ @Mock
+ private SysRoleMenuMapper sysRoleMenuMapper;
+ @Mock
+ private FileApi fileApi;
+
+ @InjectMocks
+ private ResourceServiceImpl resourceService;
+
+ // ========== /u�Tde�YB%�f��?==========
+
+ private SysMenu catalog;
+ private SysMenu menu;
+ private SysMenu button;
+
+ @BeforeEach
+ void setUp() {
+ catalog = buildMenu(1L, 0L, "�~d��|�~��`", 1);
+ menu = buildMenu(10L, 1L, ""�&1�W�~��`", 2);
+ menu.setPath("/system/user");
+
+ button = buildMenu(100L, 10L, "̓�0��"�&1�W", 3);
+ button.setPerms("crm:user:list");
+ button.setDenyBehavior("hide");
+ button.setApiUrl("/api/system/users/page");
+ button.setStatus("enabled");
+ }
+
+ // ==================== ̓�0�� ====================
+
+ @Test
+ @DisplayName("O�)1zV�t�R.|͓baq�t?-> ig�em��xO�� ResourceNode ��nb�|�}\�` catalog/menu/button")
+ void listAll_returnsFlatListWithAllTypes() {
+ when(sysMenuService.list()).thenReturn(List.of(catalog, menu, button));
+
+ List<ResourceNode> result = resourceService.listAll();
+
+ assertThat(result).hasSize(3);
+ assertThat(result.get(0).getType()).isEqualTo(MenuType.CATALOG);
+ assertThat(result.get(0).getName()).isEqualTo("�~d��|�~��`");
+ 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("�~�T1| -> ig�em�~�p�f�R {���m}/u?)
+ void listAll_emptyDb_returnsEmptyList() {
+ when(sysMenuService.list()).thenReturn(List.of());
+
+ List<ResourceNode> result = resourceService.listAll();
+
+ assertThat(result).isEmpty();
+ }
+
+ // ==================== “g�� ====================
+
+ @Test
+ @DisplayName("“g�� catalog z��PcP -> ��,a�Y save")
+ void save_newCatalog_success() {
+ ResourceNode node = newResourceNode(null, null, "“nb0m0�?, MenuType.CATALOG);
+
+ resourceService.save(node);
+
+ ArgumentCaptor<SysMenu> 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("“nb0m0�?);
+ }
+
+ @Test
+ @DisplayName("“g�� menu ���Pjn catalog �m?-> ��,a�Y save")
+ void save_newMenuUnderCatalog_success() {
+ when(sysMenuService.getById(1L)).thenReturn(catalog);
+
+ ResourceNode node = newResourceNode(null, 1L, "“�bM_W�?, MenuType.MENU);
+ node.setRoute("/new/menu");
+
+ resourceService.save(node);
+
+ verify(sysMenuService).saveOrUpdate(any(SysMenu.class));
+ }
+
+ @Test
+ @DisplayName("“g�� button ���Pjn menu �m?-> ��,a�Y save")
+ void save_newButtonUnderMenu_success() {
+ when(sysMenuService.getById(10L)).thenReturn(menu);
+
+ ResourceNode node = newButtonNode(null, 10L, "“g�帓Y3c", "crm:user:add", "hide", "/api/user/add", "enabled");
+
+ resourceService.save(node);
+
+ verify(sysMenuService).saveOrUpdate(any(SysMenu.class));
+ }
+
+ // ==================== ^p�P���~=�ko ====================
+
+ @Test
+ @DisplayName("catalog �m+[�[ button -> ���c�|�HWatalog Y�G�XQ��?menu�?)
+ void save_buttonUnderCatalog_rejected() {
+ when(sysMenuService.getById(1L)).thenReturn(catalog);
+
+ ResourceNode node = newButtonNode(null, 1L, "ȕ�pvx��Y3c", "crm:x:y", "hide", "/api/x", "enabled");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("catalog")
+ .hasMessageContaining("menu");
+ }
+
+ @Test
+ @DisplayName("menu �m+[�[ catalog -> ���c�|�bWenu Y�G�XQ��?button�?)
+ void save_catalogUnderMenu_rejected() {
+ when(sysMenuService.getById(10L)).thenReturn(menu);
+
+ ResourceNode node = newResourceNode(null, 10L, "ȕ�pvx)����}", MenuType.CATALOG);
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("menu")
+ .hasMessageContaining("button");
+ }
+
+ @Test
+ @DisplayName("menu �m+[�[ menu -> ���c�|�bWenu Y�G�XQ��?button�?)
+ void save_menuUnderMenu_rejected() {
+ when(sysMenuService.getById(10L)).thenReturn(menu);
+
+ ResourceNode node = newResourceNode(null, 10L, "ȕ�pvxp/aM_W�?, MenuType.MENU);
+ node.setRoute("/sub/menu");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class);
+ }
+
+ @Test
+ @DisplayName("button �m+[�[p/a�Y�?-> ���c�|�FWutton ē�~_p6a}")
+ void save_childUnderButton_rejected() {
+ when(sysMenuService.getById(100L)).thenReturn(button);
+
+ // p?button t�0�SG�mT^�pAi����\��pAi��͓��Ys��3lC~Z��^@UYt@�B_^p�P���~=�ko
+ ResourceNode node = newButtonNode(null, 100L, "ȕ�pvxpk�t", "crm:x:y", "hide", "/api/x", "enabled");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("Y����t");
+ }
+
+ @Test
+ @DisplayName("͓Ex�Y�ĉ�[ button -> ���c�|�Xg_O�}O�� catalog/menu cm�n�͓xv}")
+ void save_buttonAsRoot_rejected() {
+ ResourceNode node = newButtonNode(null, null, "͓ĉ�[��?, "crm:x:y", "hide", "/api/x", "enabled");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("͓Ex�Y�?);
+ }
+
+ @Test
+ @DisplayName("͓Ex�Y�ĉ�[ catalog -> ��,a�Y")
+ void save_catalogAsRoot_success() {
+ ResourceNode node = newResourceNode(null, null, "͓-W0m0�?, MenuType.CATALOG);
+
+ resourceService.save(node);
+
+ verify(sysMenuService).saveOrUpdate(any(SysMenu.class));
+ }
+
+ @Test
+ @DisplayName("͓Ex�Y�ĉ�[ menu -> ��,a�Y")
+ void save_menuAsRoot_success() {
+ ResourceNode node = newResourceNode(null, null, "͓ExM_W�?, MenuType.MENU);
+ node.setRoute("/root-menu");
+
+ resourceService.save(node);
+
+ verify(sysMenuService).saveOrUpdate(any(SysMenu.class));
+ }
+
+ // ==================== pAi��G�mT^�͓��Ys ====================
+
+ @Test
+ @DisplayName("menu ?route -> ���c�|")
+ void save_menuWithoutRoute_rejected() {
+ ResourceNode node = newResourceNode(null, null, "�r�w"���M_W�?, MenuType.MENU);
+ // route �m?null
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("�t�qe");
+ }
+
+ @Test
+ @DisplayName("menu route �my�%pCi���m?-> ���c�|")
+ void save_menuWithBlankRoute_rejected() {
+ ResourceNode node = newResourceNode(null, null, "�~?��w"���M_W�?, MenuType.MENU);
+ node.setRoute(" ");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("�t�qe");
+ }
+
+ @Test
+ @DisplayName("button ?perms -> ���c�|")
+ void save_buttonWithoutPerms_rejected() {
+ ResourceNode node = newButtonNode(null, null, "Ó rerms", null, "hide", "/api/x", "enabled");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("perms");
+ }
+
+ @Test
+ @DisplayName("button perms �my�%pCi���m?-> ���c�|")
+ void save_buttonWithBlankPerms_rejected() {
+ ResourceNode node = newButtonNode(null, null, "�~|erms", " ", "hide", "/api/x", "enabled");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("perms");
+ }
+
+ @Test
+ @DisplayName("button ?denyBehavior -> ���c�|")
+ void save_buttonWithoutDenyBehavior_rejected() {
+ ResourceNode node = newButtonNode(null, null, "Ó�qeny", "crm:x:y", null, "/api/x", "enabled");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("denyBehavior");
+ }
+
+ @Test
+ @DisplayName("button ?apiUrl -> ���c�|")
+ void save_buttonWithoutApiUrl_rejected() {
+ ResourceNode node = newButtonNode(null, null, "Ó�qpi", "crm:x:y", "hide", null, "enabled");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("apiUrl");
+ }
+
+ @Test
+ @DisplayName("button ?status -> ���c�|")
+ void save_buttonWithoutStatus_rejected() {
+ ResourceNode node = newButtonNode(null, null, "Órtatus", "crm:x:y", "hide", "/api/x", null);
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("status");
+ }
+
+ // ==================== ,h�} type ��?====================
+
+ @Test
+ @DisplayName(",h�}���v�t�f|e type�HWatalog+��benu�?> ���c�|")
+ void save_editChangeType_rejected() {
+ when(sysMenuService.getById(1L)).thenReturn(catalog); // type=1 (catalog)
+
+ ResourceNode node = newResourceNode(1L, null, "��-W��h�?, MenuType.MENU);
+ node.setRoute("/new-route");
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("�~��7p");
+ verify(sysMenuService, never()).saveOrUpdate(any());
+ }
+
+ @Test
+ @DisplayName(",h�}Ó?type �m�]I_ -> ��,a�Y")
+ void save_editSameType_success() {
+ when(sysMenuService.getById(1L)).thenReturn(catalog); // type=1(catalog)
+
+ ResourceNode node = newResourceNode(1L, null, "��pt�`p?, MenuType.CATALOG);
+
+ resourceService.save(node);
+
+ ArgumentCaptor<SysMenu> captor = ArgumentCaptor.forClass(SysMenu.class);
+ verify(sysMenuService).saveOrUpdate(captor.capture());
+ assertThat(captor.getValue().getMenuName()).isEqualTo("��pt�`p?);
+ }
+
+ // ==================== ,h�}Ó?type �m�o�fX��qAnz��PcP field ====================
+
+ @Test
+ @DisplayName(",h�} menu + path/component ȓC�6}O�?-> cm��deX��qAnJ�?)
+ void save_editMenuPreserveOldPath() {
+ when(sysMenuService.getById(10L)).thenReturn(menu); // path="/system/user"
+
+ ResourceNode node = newResourceNode(10L, null, "��pt�`", MenuType.MENU);
+ // �m�]�� route%��e� �ei�ȓ�qZ~#�k+^ȓ?path
+
+ resourceService.save(node);
+
+ ArgumentCaptor<SysMenu> captor = ArgumentCaptor.forClass(SysMenu.class);
+ verify(sysMenuService).saveOrUpdate(captor.capture());
+ SysMenu saved = captor.getValue();
+ assertThat(saved.getMenuName()).isEqualTo("��pt�`");
+ // route ȓC�6}O��00}�m�o�fX��qAn path
+ assertThat(saved.getPath()).isEqualTo("/system/user");
+ }
+
+ // ==================== ,h�}Z�?type �m�o�[�m�]I_ ====================
+
+ @Test
+ @DisplayName(",h�} catalog -> catalog (�?perms/apiUrl �~ Y�h"�%1�tZxQ� null")
+ void save_editCatalog_noButtonFields() {
+ when(sysMenuService.getById(1L)).thenReturn(catalog);
+
+ ResourceNode node = newResourceNode(1L, null, "��ĉj�~?, MenuType.CATALOG);
+ node.setPerms("unused-perm"); // S��]l�Y��XQ�t�6}�|\~} catalog 4d�e��G�Lu�f
+
+ resourceService.save(node);
+
+ ArgumentCaptor<SysMenu> captor = ArgumentCaptor.forClass(SysMenu.class);
+ verify(sysMenuService).saveOrUpdate(captor.capture());
+ SysMenu saved = captor.getValue();
+ assertThat(saved.getMenuType()).isEqualTo(1);
+ assertThat(saved.getPerms()).isNull(); // catalog �m�]�t perms
+ assertThat(saved.getApiUrl()).isNull(); // catalog �m�]�t apiUrl
+ }
+
+ // ==================== ȕ�p%͓��Ys ====================
+
+ @Test
+ @DisplayName("ResourceNode �m?null -> ���c�|")
+ void save_nullNode_rejected() {
+ assertThatThrownBy(() -> resourceService.save(null))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("�m�]XQ�my�%");
+ }
+
+ @Test
+ @DisplayName("type �m?null -> ���c�|")
+ void save_nullType_rejected() {
+ ResourceNode node = new ResourceNode();
+ node.setName("�r��h�?);
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("�~��7p");
+ }
+
+ @Test
+ @DisplayName("name �my�% -> ���c�|")
+ void save_blankName_rejected() {
+ ResourceNode node = newResourceNode(null, null, " ", MenuType.CATALOG);
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("Z��]");
+ }
+
+ // ==================== ,h�}Ó5g6}O�?extra field Ut�U
m ====================
+
+ @Test
+ @DisplayName(",h�} menu + �YE�6rǓX[�g route -> Ut�U
mX��qAn path")
+ void save_editMenuUpdateRoute() {
+ when(sysMenuService.getById(10L)).thenReturn(menu); // path="/system/user"
+
+ ResourceNode node = newResourceNode(10L, null, "��pt�`", MenuType.MENU);
+ node.setRoute("/new/path");
+
+ resourceService.save(node);
+
+ ArgumentCaptor<SysMenu> captor = ArgumentCaptor.forClass(SysMenu.class);
+ verify(sysMenuService).saveOrUpdate(captor.capture());
+ assertThat(captor.getValue().getPath()).isEqualTo("/new/path");
+ }
+
+ // ==================== R��r�jz��PcP ====================
+
+ @Test
+ @DisplayName("R��r�j�m�]�tf�'1�kz��PcP -> ���m}/u?)
+ void delete_nodeNotFound_throwsException() {
+ when(sysMenuService.getById(999L)).thenReturn(null);
+
+ assertThatThrownBy(() -> resourceService.delete(999L))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("�m�]�tf�?);
+
+ verify(sysMenuService, never()).removeById(any());
+ }
+
+ @Test
+ @DisplayName("ȓ
Y�tz��PcP -> ���c�|R��r�j")
+ void delete_hasChildren_rejected() {
+ when(sysMenuService.getById(1L)).thenReturn(catalog);
+ // stub count: parentId=1 ȓ
Y�tz��PcP
+ when(sysMenuService.count(any(LambdaQueryWrapper.class))).thenReturn(3L);
+
+ assertThatThrownBy(() -> resourceService.delete(1L))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("p/a�Y�?);
+
+ verify(sysMenuService, never()).removeById(any());
+ verify(sysRoleMenuMapper, never()).delete(any(LambdaQueryWrapper.class));
+ }
+
+ @Test
+ @DisplayName("Y����tz��PcPÓ�r�]ɓ�Q)}"�?-> R��r�j��,a�Y")
+ void delete_leafNoAuth_success() {
+ when(sysMenuService.getById(100L)).thenReturn(button);
+ when(sysMenuService.count(any(LambdaQueryWrapper.class))).thenReturn(0L);
+ when(sysMenuService.removeById(100L)).thenReturn(true);
+
+ resourceService.delete(100L);
+
+ verify(sysMenuService).removeById(100L);
+ }
+
+ @Test
+ @DisplayName("Y����tz��PcPȓ Y�]ɓ�Q)}"�?-> R��r�jz��PcP + �~F�NZoT�` sys_role_menu")
+ void delete_leafWithAuth_cascadeCleanup() {
+ when(sysMenuService.getById(100L)).thenReturn(button);
+ when(sysMenuService.count(any(LambdaQueryWrapper.class))).thenReturn(0L);
+ when(sysMenuService.removeById(100L)).thenReturn(true);
+ when(sysRoleMenuMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(2);
+
+ resourceService.delete(100L);
+
+ verify(sysMenuService).removeById(100L);
+ // ȓ
Y)}"���}�~F�NZoT�`
+ ArgumentCaptor<LambdaQueryWrapper<SysRoleMenu>> captor =
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+ verify(sysRoleMenuMapper).delete(captor.capture());
+ }
+
+ // ==================== e��p#r�mAZ6} ====================
+
+ @Test
+ @DisplayName("Z�Xvx PNG �mAZ6} -> ig�em FileInfoDTO")
+ void uploadIcon_legalPng_success() throws Exception {
+ MultipartFile file = mock(MultipartFile.class);
+ when(file.getOriginalFilename()).thenReturn("icon.png");
+ when(file.getContentType()).thenReturn("image/png");
+ when(file.getBytes()).thenReturn(new byte[100]);
+ when(file.getSize()).thenReturn(100L);
+
+ FileInfoDTO expected = new FileInfoDTO();
+ expected.setFileId("12345");
+ when(fileApi.upload(any(byte[].class), anyString(), anyString(), eq("resources/icon")))
+ .thenReturn(expected);
+
+ FileInfoDTO result = resourceService.uploadIcon(file);
+
+ assertThat(result.getFileId()).isEqualTo("12345");
+ verify(fileApi).upload(any(byte[].class), eq("icon.png"), eq("image/png"), eq("resources/icon"));
+ }
+
+ @Test
+ @DisplayName("ȕ�pvx��A%MwZ��]}.exe�?> ���c�|")
+ void uploadIcon_illegalExtension_rejected() {
+ MultipartFile file = mock(MultipartFile.class);
+ when(file.isEmpty()).thenReturn(false);
+ when(file.getSize()).thenReturn(100L);
+ when(file.getOriginalFilename()).thenReturn("virus.exe");
+
+ assertThatThrownBy(() -> resourceService.uploadIcon(file))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("͓Nq!}");
+
+ verify(fileApi, never()).upload(any(byte[].class), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ @DisplayName("�r�X^p�f�` -> ���c�|")
+ void uploadIcon_noExtension_rejected() {
+ MultipartFile file = mock(MultipartFile.class);
+ when(file.isEmpty()).thenReturn(false);
+ when(file.getSize()).thenReturn(100L);
+ when(file.getOriginalFilename()).thenReturn("noext");
+
+ assertThatThrownBy(() -> resourceService.uploadIcon(file))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("͓Nq!}");
+ }
+
+ @Test
+ @DisplayName("�tpTC~ 500KB -> ���c�|")
+ void uploadIcon_exceedsMaxSize_rejected() {
+ MultipartFile file = mock(MultipartFile.class);
+ when(file.isEmpty()).thenReturn(false);
+ when(file.getSize()).thenReturn(600 * 1024L); // 600KB
+
+ assertThatThrownBy(() -> resourceService.uploadIcon(file))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("500");
+
+ verify(fileApi, never()).upload(any(byte[].class), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ @DisplayName("�~�p�g`m?-> ���c�|")
+ void uploadIcon_emptyFile_rejected() {
+ MultipartFile file = mock(MultipartFile.class);
+ when(file.isEmpty()).thenReturn(true);
+
+ assertThatThrownBy(() -> resourceService.uploadIcon(file))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("�~?);
+ }
+
+ // ==================== HgmT�Y“ĉvx ====================
+
+ 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;
+ }
+}