From 8c4ebb4827c15cc4ee23419c9b65b591081b88cf Mon Sep 17 00:00:00 2001
From: luoweijian <1329394916@qq.com>
Date: Tue, 4 Aug 2026 09:44:41 +0800
Subject: [PATCH] =?UTF-8?q?feat(crm-auth):=20Ticket=2004=20=E2=80=94=20?=
=?UTF-8?q?=E5=9B=BE=E6=A0=87=E4=B8=8A=E4=BC=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- POST /api/resources/icon/upload(multipart/file)
- 格式校验:只允许 png/jpg/jpeg/gif/svg/webp
- 大小校验:≤500KB
- 调 crm-file FileApi 上传到 MinIO(bizDomain=resources/icon)
- 返回 FileInfoDTO(含 fileId)
- crm-auth pom.xml 加 crm-file 依赖
- TDD: 36 个单测,114 tests 全绿
---
crm-auth/pom.xml | 5 ++
.../auth/controller/ResourceController.java | 8 ++
.../crm/auth/service/IResourceService.java | 5 ++
.../service/impl/ResourceServiceImpl.java | 51 ++++++++++++
.../service/impl/ResourceServiceImplTest.java | 83 +++++++++++++++++++
5 files changed, 152 insertions(+)
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) {