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:
2026-05-12 21:18:42 +08:00
parent 4f6b209c3d
commit 220dec9b6c
98 changed files with 4138 additions and 1362 deletions

View File

@@ -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));
}
}

View File

@@ -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;

View File

@@ -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

View File

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

View File

@@ -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")

View File

@@ -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,

View File

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

View 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);
/**
* 删除文件
*

View File

@@ -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 {
// 校验存在

View File

@@ -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"));
}
}

View File

@@ -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));
}
}