Browse Source

feat(crm-auth): Ticket 04 — 图标上传

- 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 全绿
master
luoweijian 1 month ago
parent
commit
8c4ebb4827
  1. 5
      crm-auth/pom.xml
  2. 8
      crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java
  3. 5
      crm-auth/src/main/java/com/crm/auth/service/IResourceService.java
  4. 51
      crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java
  5. 83
      crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java

5
crm-auth/pom.xml

@ -23,6 +23,11 @@
<artifactId>crm-base</artifactId> <artifactId>crm-base</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-file</artifactId>
</dependency>
<!-- 完整安全栈(crm-base 只依赖了 security-core) --> <!-- 完整安全栈(crm-base 只依赖了 security-core) -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

8
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.domain.dto.ResourceNode;
import com.crm.auth.service.IResourceService; import com.crm.auth.service.IResourceService;
import com.crm.base.domain.result.Result; import com.crm.base.domain.result.Result;
import com.crm.file.domain.dto.FileInfoDTO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List; import java.util.List;
@ -36,4 +38,10 @@ public class ResourceController {
resourceService.delete(id); resourceService.delete(id);
return Result.success(); return Result.success();
} }
/** 图标上传(格式/大小校验) */
@PostMapping("/icon/upload")
public Result<FileInfoDTO> uploadIcon(@RequestParam("file") MultipartFile file) {
return Result.success(resourceService.uploadIcon(file));
}
} }

5
crm-auth/src/main/java/com/crm/auth/service/IResourceService.java

@ -1,6 +1,8 @@
package com.crm.auth.service; package com.crm.auth.service;
import com.crm.auth.domain.dto.ResourceNode; import com.crm.auth.domain.dto.ResourceNode;
import com.crm.file.domain.dto.FileInfoDTO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List; import java.util.List;
@ -17,4 +19,7 @@ public interface IResourceService {
/** 删除节点(含子节点检查 + 级联清理 sys_role_menu 授权引用) */ /** 删除节点(含子节点检查 + 级联清理 sys_role_menu 授权引用) */
void delete(Long id); void delete(Long id);
/** 图标上传(格式/大小校验 + 调 FileApi 存到 MinIO,返回文件信息) */
FileInfoDTO uploadIcon(MultipartFile file);
} }

51
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.IResourceService;
import com.crm.auth.service.ISysMenuService; import com.crm.auth.service.ISysMenuService;
import com.crm.base.domain.exception.BusinessErrorException; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -31,6 +36,7 @@ public class ResourceServiceImpl implements IResourceService {
private final ISysMenuService sysMenuService; private final ISysMenuService sysMenuService;
private final SysRoleMenuMapper sysRoleMenuMapper; private final SysRoleMenuMapper sysRoleMenuMapper;
private final FileApi fileApi;
@Override @Override
public List<ResourceNode> listAll() { public List<ResourceNode> listAll() {
@ -109,6 +115,51 @@ public class ResourceServiceImpl implements IResourceService {
log.info("删除资源节点:id={}, name={}", id, node.getMenuName()); log.info("删除资源节点:id={}, name={}", id, node.getMenuName());
} }
// ==================== 图标上传 ====================
/** 合法图标扩展名 */
private static final Set<String> 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 种非法父子组合 */ /** 层级约束:6 种非法父子组合 */

83
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.mapper.SysRoleMenuMapper;
import com.crm.auth.service.ISysMenuService; import com.crm.auth.service.ISysMenuService;
import com.crm.base.domain.exception.BusinessErrorException; 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.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -16,12 +18,16 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.multipart.MultipartFile;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; 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.*; import static org.mockito.Mockito.*;
/** /**
@ -34,6 +40,8 @@ class ResourceServiceImplTest {
private ISysMenuService sysMenuService; private ISysMenuService sysMenuService;
@Mock @Mock
private SysRoleMenuMapper sysRoleMenuMapper; private SysRoleMenuMapper sysRoleMenuMapper;
@Mock
private FileApi fileApi;
@InjectMocks @InjectMocks
private ResourceServiceImpl resourceService; private ResourceServiceImpl resourceService;
@ -461,6 +469,81 @@ class ResourceServiceImplTest {
verify(sysRoleMenuMapper).delete(captor.capture()); 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) { private SysMenu buildMenu(Long id, Long parentId, String name, int menuType) {

Loading…
Cancel
Save