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/controller/ResourceController.java b/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java index 7a7e000..93849e3 100644 --- a/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java +++ b/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java @@ -3,8 +3,10 @@ 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; @@ -36,4 +38,10 @@ public class ResourceController { 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 index 432d80d..0f44876 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/IResourceService.java +++ b/crm-auth/src/main/java/com/crm/auth/service/IResourceService.java @@ -1,6 +1,8 @@ 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; @@ -17,4 +19,7 @@ public interface IResourceService { /** 删除节点(含子节点检查 + 级联清理 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/ResourceServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java index 8a21ec8..d0705fb 100644 --- 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 @@ -11,11 +11,16 @@ 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; /** @@ -31,6 +36,7 @@ public class ResourceServiceImpl implements IResourceService { private final ISysMenuService sysMenuService; private final SysRoleMenuMapper sysRoleMenuMapper; + private final FileApi fileApi; @Override public List listAll() { @@ -109,6 +115,51 @@ public class ResourceServiceImpl implements IResourceService { log.info("删除资源节点:id={}, name={}", id, node.getMenuName()); } + // ==================== 图标上传 ==================== + + /** 合法图标扩展名 */ + private static final Set ALLOWED_ICON_EXTENSIONS = Set.of( + "png", "jpg", "jpeg", "gif", "svg", "webp"); + + /** 图标上传大小上限(500KB) */ + 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("图标上传成功:fileId={}, name={}", result.getFileId(), originalName); + return result; + } catch (IOException e) { + log.error("图标上传读取文件失败:{}", originalName, e); + throw new BusinessErrorException(CODE_RESOURCE_INVALID, "图标文件读取失败,请重试"); + } + } + // ==================== 校验逻辑 ==================== /** 层级约束:6 种非法父子组合 */ 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 index c082693..15e4990 100644 --- 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 @@ -8,6 +8,8 @@ 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; @@ -16,12 +18,16 @@ 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.*; /** @@ -34,6 +40,8 @@ class ResourceServiceImplTest { private ISysMenuService sysMenuService; @Mock private SysRoleMenuMapper sysRoleMenuMapper; + @Mock + private FileApi fileApi; @InjectMocks private ResourceServiceImpl resourceService; @@ -461,6 +469,81 @@ class ResourceServiceImplTest { 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) {