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 @@
crm-base
+
+ com.crm
+ crm-file
+
+
org.springframework.boot
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/**",
+ // 璧勬簮鏍戠鐞嗘帴鍙o紙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..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;
+
+/**
+ * 缁熶竴鏉冮檺璧勬簮鏍戠鐞嗙鎺ュ彛锛圓DR-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));
+ }
+
+ /** 鍒犻櫎鑺傜偣锛堝惈瀛愯妭鐐规鏌?+ 绾ц仈娓呯悊锛?*/
+ @DeleteMapping("/{id}")
+ public Result delete(@PathVariable Long id) {
+ resourceService.delete(id);
+ return Result.success();
+ }
+
+ /** 鍥炬爣涓婁紶锛堟牸寮?澶у皬鏍¢獙锛?*/
+ @PostMapping("/icon/upload")
+ public Result 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;
+
+/**
+ * 缁熶竴鏉冮檺璧勬簮鏍戞湇鍔★紙ADR-0011锛?+ */
+public interface IResourceService {
+
+ /** 鍏ㄩ噺璧勬簮鏍戞墎骞虫暟缁勶紙鎵€鏈夎妭鐐癸紝涓嶆寜瑙掕壊杩囨护锛?*/
+ List listAll();
+
+ /** 鏂板鎴栫紪杈戣妭鐐癸紙鍚眰绾х害鏉熴€佸瓧娈靛繀濉€佺紪杈戦攣 type锛?*/
+ ResourceNode save(ResourceNode node);
+
+ /** 鍒犻櫎鑺傜偣锛堝惈瀛愯妭鐐规鏌?+ 绾ц仈娓呯悊 sys_role_menu 鎺堟潈寮曠敤锛?*/
+ void delete(Long id);
+
+ /** 鍥炬爣涓婁紶锛堟牸寮?澶у皬鏍¢獙 + 璋?FileApi 瀛樺埌 MinIO锛岃繑鍥炴枃浠朵俊鎭級 */
+ 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()
+
+ // 鈹€鈹€ 鏉冮檺鐮佸苟闆嗭紙ADR-0011锛夛細鏀堕泦鐢ㄦ埛鎵€鏈夎鑹叉巿鏉冪殑 button 鑺傜偣 perms 鈹€鈹€
+ List authorities = new ArrayList<>();
+ if (CollUtil.isNotEmpty(userRoles)) {
+ try {
+ Set roleIds = userRoles.stream().map(SysUserRole::getRoleId).collect(Collectors.toSet());
+ List allRoleMenus = sysRoleMenuMapper.selectList(
+ new LambdaQueryWrapper().in(SysRoleMenu::getRoleId, roleIds));
+ if (CollUtil.isNotEmpty(allRoleMenus)) {
+ Set menuIds = allRoleMenus.stream()
+ .map(SysRoleMenu::getMenuId).collect(Collectors.toSet());
+ List 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("鏉冮檺鐮佸苟闆嗘煡璇㈠け璐ワ紙鍙兘 sys_menu/sys_role_menu 琛ㄥ皻鏈垱寤猴級锛岃烦杩?, e);
+ }
+ }
+ // 瑙掕壊缂栫爜杩藉姞鍦ㄦ潈闄愮爜涔嬪悗锛堜袱鑰呭悓涓?Spring Security authority锛宧asRole/hasAuthority 鍚勮嚜鍖归厤锛?+ roles.stream()
.map(SysRole::getRoleCode)
.filter(Objects::nonNull)
- .toList();
+ .forEach(authorities::add);
+ return authorities;
}
public List 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;
+
+/**
+ * 缁熶竴鏉冮檺璧勬簮鏍戞湇鍔″疄鐜帮紙ADR-0011锛?+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ResourceServiceImpl implements IResourceService {
+
+ /** 璧勬簮鑺傜偣鍙傛暟闈炴硶 */
+ private static final int CODE_RESOURCE_INVALID = 61009;
+
+ private final ISysMenuService sysMenuService;
+ private final SysRoleMenuMapper sysRoleMenuMapper;
+ private final FileApi fileApi;
+
+ @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("淇濆瓨璧勬簮鑺傜偣锛歩d={}, 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, "璧勬簮鑺傜偣涓嶅瓨鍦細id=" + id);
+ }
+
+ // 瀛愯妭鐐规鏌?+ long childCount = sysMenuService.count(
+ new LambdaQueryWrapper().eq(SysMenu::getParentId, id));
+ if (childCount > 0) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID,
+ "鑺傜偣涓嬫湁 " + childCount + " 涓瓙鑺傜偣锛岃鍏堝垹闄ゅ瓙鑺傜偣");
+ }
+
+ // 绾ц仈娓呯悊瑙掕壊鎺堟潈寮曠敤
+ int deletedRefs = sysRoleMenuMapper.delete(
+ new LambdaQueryWrapper().eq(SysRoleMenu::getMenuId, id));
+ if (deletedRefs > 0) {
+ log.info("鍒犻櫎璧勬簮鑺傜偣鏃剁骇鑱旀竻鐞嗚鑹叉巿鏉冨紩鐢細menuId={}, 娓呯悊浜?{} 鏉¤褰?, id, deletedRefs);
+ }
+
+ sysMenuService.removeById(id);
+ log.info("鍒犻櫎璧勬簮鑺傜偣锛歩d={}, name={}", id, node.getMenuName());
+ }
+
+ // ==================== 鍥炬爣涓婁紶 ====================
+
+ /** 鍚堟硶鍥炬爣鎵╁睍鍚?*/
+ private static final Set ALLOWED_ICON_EXTENSIONS = Set.of(
+ "png", "jpg", "jpeg", "gif", "svg", "webp");
+
+ /** 鍥炬爣涓婁紶澶у皬涓婇檺锛?00KB锛?*/
+ private static final long MAX_ICON_SIZE = 500 * 1024;
+
+ @Override
+ public FileInfoDTO uploadIcon(MultipartFile file) {
+ // 绌烘枃浠舵鏌?+ if (file == null || file.isEmpty()) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "鍥炬爣鏂囦欢涓嶈兘涓虹┖");
+ }
+
+ // 澶у皬鏍¢獙
+ if (file.getSize() > MAX_ICON_SIZE) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID,
+ "鍥炬爣澶у皬涓嶈兘瓒呰繃 500KB锛堝綋鍓? " + (file.getSize() / 1024) + "KB锛?);
+ }
+
+ String originalName = file.getOriginalFilename();
+ if (StrUtil.isBlank(originalName)) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "鍥炬爣鏂囦欢鍚嶄笉鑳戒负绌?);
+ }
+
+ // 鏍煎紡鏍¢獙
+ String ext = StrUtil.subAfter(originalName, '.', true);
+ if (StrUtil.isBlank(ext) || !ALLOWED_ICON_EXTENSIONS.contains(ext.toLowerCase())) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID,
+ "涓嶆敮鎸佺殑鍥炬爣鏍煎紡: " + ext + "锛屽厑璁告牸寮? " + ALLOWED_ICON_EXTENSIONS);
+ }
+
+ try {
+ FileInfoDTO result = fileApi.upload(file.getBytes(), originalName,
+ file.getContentType(), "resources/icon");
+ log.info("鍥炬爣涓婁紶鎴愬姛锛歠ileId={}, name={}", result.getFileId(), originalName);
+ return result;
+ } catch (IOException e) {
+ log.error("鍥炬爣涓婁紶璇诲彇鏂囦欢澶辫触锛歿}", originalName, e);
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "鍥炬爣鏂囦欢璇诲彇澶辫触锛岃閲嶈瘯");
+ }
+ }
+
+ // ==================== 鏍¢獙閫昏緫 ====================
+
+ /** 灞傜骇绾︽潫锛? 绉嶉潪娉曠埗瀛愮粍鍚?*/
+ 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, "鐖惰妭鐐逛笉瀛樺湪锛歱arentId=" + 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 绫诲瀷蹇呴』鎻愪緵璺敱锛坮oute锛?);
+ }
+ }
+
+ 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锛坅piUrl锛?);
+ }
+ if (StrUtil.isBlank(node.getStatus())) {
+ throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 绫诲瀷蹇呴』鎻愪緵鐘舵€侊紙status锛?);
+ }
+ }
+ }
+
+ // ==================== DTO 鈫?Entity ====================
+
+ /** 灏?ResourceNode 涓殑涓氬姟瀛楁鍐欏叆 SysMenu锛堟槧灏?name鈫抦enuName, route鈫抪ath, type鈫抦enuType锛?*/
+ 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..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} 鍗曞厓娴嬭瘯锛氳鐩栬祫婧愭爲鏌ヨ銆佹柊澧?缂栬緫鏍¢獙銆佸眰绾х害鏉熴€佸瓧娈靛繀濉?+ */
+@ExtendWith(MockitoExtension.class)
+class ResourceServiceImplTest {
+
+ @Mock
+ private ISysMenuService sysMenuService;
+ @Mock
+ private SysRoleMenuMapper sysRoleMenuMapper;
+ @Mock
+ private FileApi fileApi;
+
+ @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 -> 鎷掔粷锛坈atalog 鍙兘鎸?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 -> 鎷掔粷锛坢enu 鍙兘鎸?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 -> 鎷掔粷锛坢enu 鍙兘鎸?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 涓嬫寕瀛愯妭鐐?-> 鎷掔粷锛坆utton 鏄彾瀛愶級")
+ 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, "鏃爌erms", 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, "绌簆erms", " ", "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, "鏃燿eny", "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, "鏃燼pi", "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, "鏃爏tatus", "crm:x:y", "hide", "/api/x", null);
+
+ assertThatThrownBy(() -> resourceService.save(node))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("status");
+ }
+
+ // ==================== 缂栬緫 type 閿?====================
+
+ @Test
+ @DisplayName("缂栬緫鏃跺皾璇曟敼 type锛坈atalog鈫抦enu锛?> 鎷掔粷")
+ 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 + 姝e父鏇存柊 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");
+ }
+
+ // ==================== 鍒犻櫎鑺傜偣 ====================
+
+ @Test
+ @DisplayName("鍒犻櫎涓嶅瓨鍦ㄧ殑鑺傜偣 -> 鎶涘紓甯?)
+ void delete_nodeNotFound_throwsException() {
+ when(sysMenuService.getById(999L)).thenReturn(null);
+
+ assertThatThrownBy(() -> resourceService.delete(999L))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("涓嶅瓨鍦?);
+
+ verify(sysMenuService, never()).removeById(any());
+ }
+
+ @Test
+ @DisplayName("鏈夊瓙鑺傜偣 -> 鎷掔粷鍒犻櫎")
+ void delete_hasChildren_rejected() {
+ when(sysMenuService.getById(1L)).thenReturn(catalog);
+ // stub count: parentId=1 鏈夊瓙鑺傜偣
+ when(sysMenuService.count(any(LambdaQueryWrapper.class))).thenReturn(3L);
+
+ assertThatThrownBy(() -> resourceService.delete(1L))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("瀛愯妭鐐?);
+
+ verify(sysMenuService, never()).removeById(any());
+ verify(sysRoleMenuMapper, never()).delete(any(LambdaQueryWrapper.class));
+ }
+
+ @Test
+ @DisplayName("鍙跺瓙鑺傜偣鏃犳巿鏉冨紩鐢?-> 鍒犻櫎鎴愬姛")
+ 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("鍙跺瓙鑺傜偣鏈夋巿鏉冨紩鐢?-> 鍒犻櫎鑺傜偣 + 绾ц仈娓呯悊 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);
+ // 鏈夊紩鐢紝绾ц仈娓呯悊
+ ArgumentCaptor> captor =
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+ verify(sysRoleMenuMapper).delete(captor.capture());
+ }
+
+ // ==================== 鍥炬爣涓婁紶 ====================
+
+ @Test
+ @DisplayName("鍚堟硶 PNG 涓婁紶 -> 杩斿洖 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("闈炴硶鎵╁睍鍚嶏紙.exe锛?> 鎷掔粷")
+ 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("鏍煎紡");
+
+ verify(fileApi, never()).upload(any(byte[].class), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ @DisplayName("鏃犳墿灞曞悕 -> 鎷掔粷")
+ 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("鏍煎紡");
+ }
+
+ @Test
+ @DisplayName("瓒呰繃 500KB -> 鎷掔粷")
+ 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("绌烘枃浠?-> 鎷掔粷")
+ void uploadIcon_emptyFile_rejected() {
+ MultipartFile file = mock(MultipartFile.class);
+ when(file.isEmpty()).thenReturn(true);
+
+ assertThatThrownBy(() -> resourceService.uploadIcon(file))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("绌?);
+ }
+
+ // ==================== 杈呭姪鏂规硶 ====================
+
+ 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;
+ }
+}