feat(file): 改造文件上传接口返回结构
- 将 POST /system/file/upload 接口返回结构从 string 改为 { id: string, url: string }
- id 字段以字符串形式返回 infra_file.id,避免 JavaScript 数值精度丢失问题
- 保持接口路径、方法和入参完全不变,仅修改返回格式
- 添加 GET /system/file/download 接口用于文件下载功能
- 优化 AppFileController 中的文件上传实现逻辑
- 更新 AuthConvert 和 AuthUserInfoRespVO 添加用户昵称和头像字段
- 在 CLAUDE.md 中补充鉴权通道和 HTTP 动词语义说明文档
- 在 ErrorCodeConstants.java 中添加多个项目管理和执行相关的错误码定义
- 删除执行成员相关的数据库表和接口定义(执行协办人替代方案)
- 在 FileMapper 中增加按 URL 查询文件的方法支持
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package com.njcn.rdms.module.system.api.file;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import com.njcn.rdms.module.system.service.file.FileService;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
|
||||
@RestController
|
||||
@Validated
|
||||
@Hidden
|
||||
public class FileApiImpl implements FileApi {
|
||||
|
||||
@Resource
|
||||
private FileService fileService;
|
||||
|
||||
@Override
|
||||
public CommonResult<FileRespDTO> getFileByUrl(String url) {
|
||||
FileDO file = fileService.getFileByUrl(url);
|
||||
return success(BeanUtils.toBean(file, FileRespDTO.class));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,6 +21,12 @@ public class AuthUserInfoRespVO {
|
||||
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
|
||||
private String userName;
|
||||
|
||||
@Schema(description = "用户昵称", example = "灿能")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "用户头像", example = "https://www.iocoder.cn/xx.png")
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "所属公司", example = "灿能")
|
||||
private String company;
|
||||
|
||||
|
||||
@@ -46,11 +46,12 @@ public class FileController {
|
||||
@Operation(summary = "上传文件", description = "模式一:后端上传文件")
|
||||
@Parameter(name = "file", description = "文件附件", required = true,
|
||||
schema = @Schema(type = "string", format = "binary"))
|
||||
public CommonResult<String> uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception {
|
||||
public CommonResult<FileUploadRespVO> uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception {
|
||||
MultipartFile file = uploadReqVO.getFile();
|
||||
byte[] content = IoUtil.readBytes(file.getInputStream());
|
||||
return success(fileService.createFile(content, file.getOriginalFilename(),
|
||||
uploadReqVO.getDirectory(), file.getContentType()));
|
||||
FileDO fileDO = fileService.createFile(content, file.getOriginalFilename(),
|
||||
uploadReqVO.getDirectory(), file.getContentType());
|
||||
return success(new FileUploadRespVO(String.valueOf(fileDO.getId()), fileDO.getUrl()));
|
||||
}
|
||||
|
||||
@GetMapping("/presigned-url")
|
||||
@@ -97,6 +98,21 @@ public class FileController {
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/download")
|
||||
@Operation(summary = "下载文件")
|
||||
@Parameter(name = "id", description = "编号", required = true)
|
||||
@PreAuthorize("@ss.hasPermission('system:file:query')")
|
||||
public void downloadFile(@RequestParam("id") Long id, HttpServletResponse response) throws Exception {
|
||||
FileDO file = fileService.getFile(id);
|
||||
byte[] content = fileService.getFileContent(file.getConfigId(), file.getPath());
|
||||
if (content == null) {
|
||||
log.warn("[downloadFile][id({}) configId({}) path({}) 文件不存在]", id, file.getConfigId(), file.getPath());
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
}
|
||||
writeAttachment(response, StrUtil.blankToDefault(file.getName(), file.getPath()), content);
|
||||
}
|
||||
|
||||
@GetMapping("/{configId}/get/**")
|
||||
@PermitAll
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.njcn.rdms.module.system.controller.admin.file.vo.file;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "管理后台 - 上传文件 Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FileUploadRespVO {
|
||||
|
||||
@Schema(description = "文件编号,Long 以字符串返回,避免前端精度丢失", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "文件 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/rdms.jpg")
|
||||
private String url;
|
||||
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import cn.hutool.core.io.IoUtil;
|
||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FileCreateReqVO;
|
||||
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePresignedUrlRespVO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import com.njcn.rdms.module.system.controller.app.file.vo.AppFileUploadReqVO;
|
||||
import com.njcn.rdms.module.system.service.file.FileService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -39,8 +40,9 @@ public class AppFileController {
|
||||
public CommonResult<String> uploadFile(AppFileUploadReqVO uploadReqVO) throws Exception {
|
||||
MultipartFile file = uploadReqVO.getFile();
|
||||
byte[] content = IoUtil.readBytes(file.getInputStream());
|
||||
return success(fileService.createFile(content, file.getOriginalFilename(),
|
||||
uploadReqVO.getDirectory(), file.getContentType()));
|
||||
FileDO fileDO = fileService.createFile(content, file.getOriginalFilename(),
|
||||
uploadReqVO.getDirectory(), file.getContentType());
|
||||
return success(fileDO.getUrl());
|
||||
}
|
||||
|
||||
@GetMapping("/presigned-url")
|
||||
|
||||
@@ -57,6 +57,8 @@ public interface AuthConvert {
|
||||
return AuthUserInfoRespVO.builder()
|
||||
.userId(String.valueOf(user.getId()))
|
||||
.userName(user.getUsername())
|
||||
.nickname(user.getNickname())
|
||||
.avatar(user.getAvatar())
|
||||
.company(user.getCompany())
|
||||
.roles(sortDistinctStrings(convertList(roleList, RoleDO::getCode)))
|
||||
.buttons(sortDistinctStrings(convertList(menuList, MenuDO::getPermission,
|
||||
|
||||
@@ -7,6 +7,10 @@ import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePageReqVO;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static com.njcn.rdms.framework.common.util.http.HttpUtils.removeUrlQuery;
|
||||
|
||||
/**
|
||||
* 文件操作 Mapper
|
||||
*
|
||||
@@ -23,4 +27,18 @@ public interface FileMapper extends BaseMapperX<FileDO> {
|
||||
.orderByDesc(FileDO::getId));
|
||||
}
|
||||
|
||||
default FileDO selectByUrl(String url) {
|
||||
FileDO file = selectFirstOne(FileDO::getUrl, url);
|
||||
String urlWithoutQuery = removeUrlQuery(url);
|
||||
if (file == null && !Objects.equals(urlWithoutQuery, url)) {
|
||||
file = selectOne(new LambdaQueryWrapperX<FileDO>()
|
||||
.likeRight(FileDO::getUrl, urlWithoutQuery)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
if (file == null && !Objects.equals(urlWithoutQuery, url)) {
|
||||
file = selectFirstOne(FileDO::getUrl, urlWithoutQuery);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -25,15 +25,15 @@ public interface FileService {
|
||||
PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 保存文件,并返回文件的访问路径
|
||||
* 保存文件,并返回文件记录
|
||||
*
|
||||
* @param content 文件内容
|
||||
* @param name 文件名称,允许空
|
||||
* @param directory 目录,允许空
|
||||
* @param type 文件的 MIME 类型,允许空
|
||||
* @return 文件路径
|
||||
* @return 文件记录
|
||||
*/
|
||||
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
|
||||
FileDO createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
|
||||
String name, String directory, String type);
|
||||
|
||||
/**
|
||||
@@ -63,6 +63,14 @@ public interface FileService {
|
||||
Long createFile(FileCreateReqVO createReqVO);
|
||||
FileDO getFile(Long id);
|
||||
|
||||
/**
|
||||
* 通过文件 URL 获得文件。
|
||||
*
|
||||
* @param url 文件 URL
|
||||
* @return 文件记录,不存在返回 null
|
||||
*/
|
||||
FileDO getFileByUrl(String url);
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
|
||||
@@ -61,7 +61,7 @@ public class FileServiceImpl implements FileService {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public String createFile(byte[] content, String name, String directory, String type) {
|
||||
public FileDO createFile(byte[] content, String name, String directory, String type) {
|
||||
// 1.1 处理 type 为空的情况
|
||||
if (StrUtil.isEmpty(type)) {
|
||||
type = FileTypeUtils.getMineType(content, name);
|
||||
@@ -86,10 +86,11 @@ public class FileServiceImpl implements FileService {
|
||||
String url = client.upload(content, path, type);
|
||||
|
||||
// 3. 保存到数据库
|
||||
fileMapper.insert(new FileDO().setConfigId(client.getId())
|
||||
FileDO file = new FileDO().setConfigId(client.getId())
|
||||
.setName(name).setPath(path).setUrl(url)
|
||||
.setType(type).setSize((long) content.length));
|
||||
return url;
|
||||
.setType(type).setSize((long) content.length);
|
||||
fileMapper.insert(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -157,6 +158,14 @@ public class FileServiceImpl implements FileService {
|
||||
return validateFileExists(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileDO getFileByUrl(String url) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
return null;
|
||||
}
|
||||
return fileMapper.selectByUrl(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteFile(Long id) throws Exception {
|
||||
// 校验存在
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.njcn.rdms.module.system.controller.admin.file;
|
||||
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import com.njcn.rdms.module.system.service.file.FileService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class FileControllerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
|
||||
@InjectMocks
|
||||
private FileController fileController;
|
||||
|
||||
@Test
|
||||
void downloadFile_shouldReadContentByFileIdAndWriteAttachment() throws Exception {
|
||||
Long fileId = 10001L;
|
||||
FileDO file = new FileDO()
|
||||
.setId(fileId)
|
||||
.setConfigId(100L)
|
||||
.setPath("task/20260509/report_1778307118261.txt")
|
||||
.setName("report.txt");
|
||||
byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
|
||||
when(fileService.getFile(fileId)).thenReturn(file);
|
||||
when(fileService.getFileContent(file.getConfigId(), file.getPath())).thenReturn(content);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
fileController.downloadFile(fileId, response);
|
||||
|
||||
verify(fileService).getFile(fileId);
|
||||
verify(fileService).getFileContent(100L, "task/20260509/report_1778307118261.txt");
|
||||
assertEquals("hello", response.getContentAsString(StandardCharsets.UTF_8));
|
||||
assertEquals("attachment;filename=report.txt", response.getHeader("Content-Disposition"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.njcn.rdms.module.system.dal.mysql.file;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class FileMapperTest {
|
||||
|
||||
@Test
|
||||
void selectByUrl_whenSignedUrl_shouldTryUnsignedPrefix() {
|
||||
FileMapper mapper = mock(FileMapper.class, invocation -> invocation.callRealMethod());
|
||||
String signedUrl = "http://oss.example.com/rdms/task/test.txt?X-Amz-Signature=abc";
|
||||
doReturn(null).when(mapper).selectFirstOne(any(), eq(signedUrl));
|
||||
doReturn(new FileDO().setId(10001L)).when(mapper).selectOne(any(Wrapper.class));
|
||||
|
||||
FileDO file = mapper.selectByUrl(signedUrl);
|
||||
|
||||
assertEquals(10001L, file.getId());
|
||||
verify(mapper).selectFirstOne(any(), eq(signedUrl));
|
||||
verify(mapper).selectOne(any(Wrapper.class));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user