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; + } +}