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,98 @@
# 文件上传接口改造需求(给后端)
> 提单时间2026-05-11
> 提单方:前端
> 涉及模块:`rdms-system-boot` 文件模块
> 背景:业务表单(如新建/编辑任务)的附件采用「即传即存」交互,但表单可能被用户取消或关闭。前端需要在合适时机调用删除接口清理孤儿文件。当前 `/system/file/upload` 只返回 url前端拿不到 fileId无法触发删除。
---
## 1. 改造:`POST /system/file/upload`
**路径 / 方法 / 入参全部保持不变**,仅修改返回结构。
### 1.1 现状返回
```json
{
"code": 0,
"msg": "",
"data": "http://192.168.1.107:9009/rdms/20260508/xxx.jpg?X-Amz-..."
}
```
### 1.2 目标返回
```json
{
"code": 0,
"msg": "",
"data": {
"id": "10001",
"url": "http://192.168.1.107:9009/rdms/20260508/xxx.jpg?X-Amz-..."
}
}
```
### 1.3 字段说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | string | 是 | 即 `infra_file.id`**必须以字符串形式返回**,不要返回 number |
| url | string | 是 | 文件访问 URL含义与现状完全一致私有桶带签名公开桶裸 URL |
### 1.4 关键约束
- **`id` 必须是 string不是 number**。原因:`infra_file.id` 是 Long 类型,超过 JS 安全整数2^53时 JSON number 会精度丢失。前端项目 ID 统一按字符串接收。
- 该改动**不兼容老返回结构**,前端会同步切换。
- App 端 `/app-api/system/file/upload` 前端目前未使用,可暂不改;若希望对称建议一起改。
---
## 2. 确认:`DELETE /system/file/delete`
接口已存在(见 `system-file-api.md` §4.3.5),无需新建,但需确认以下两点:
### 2.1 权限
- 业务用户角色需具备 `system:file:delete` 权限码,**或**该接口允许"删除自己上传的文件"无需该权限码。
- 期望:当前业务用户能直接调通 `DELETE /admin-api/system/file/delete?id=xxx`
### 2.2 幂等性(删不存在的文件)
- 当前文档里删不存在的文件返回错误码 `1001003001 / 文件不存在`
- 期望:**幂等返回 `code: 0`**(更佳),或维持现状(前端会把该错误码当作"已删除"吞掉,也能接受)。
### 2.3 入参
保持现状不变:
```
DELETE /admin-api/system/file/delete?id=10001
```
---
## 3. 不在本次范围(避免误解)
以下事项**本次不做**,属于后续「治本方案」范畴,请勿顺手改动:
- 不需要在 `infra_file` 表加 `status`temp / committed字段
- 不需要加业务引用关系表
- 不需要做孤儿文件定时清理 cron
- 不需要改 `/presigned-url``/create`
- 不需要改 App 端 `/upload` 鉴权策略
---
## 4. 联调要点
1. 改造完成后请提供测试环境,前端会同步发版。
2. 接口返回的 `id` 请确保转为字符串再 JSON 序列化(不要直接序列化 Long
3. 若返回里 `id` 是 number 类型,前端视为联调未通过。
---
## 5. 一句话总结
> `/system/file/upload` 返回结构从 `string` 改成 `{ id: string, url: string }``id` 是 `infra_file.id` 的字符串形式。其它接口不动。

View File

@@ -0,0 +1,24 @@
package com.njcn.rdms.module.system.api.file;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = ApiConstants.NAME)
@Tag(name = "RPC 服务 - 文件")
public interface FileApi {
String PREFIX = ApiConstants.PREFIX + "/file";
@GetMapping(PREFIX + "/get-by-url")
@Operation(summary = "通过文件 URL 查询文件")
@Parameter(name = "url", description = "文件 URL", required = true)
CommonResult<FileRespDTO> getFileByUrl(@RequestParam("url") String url);
}

View File

@@ -0,0 +1,38 @@
package com.njcn.rdms.module.system.api.file.dto;
import lombok.Data;
@Data
public class FileRespDTO {
/**
* 文件编号。
*/
private Long id;
/**
* 文件名称。
*/
private String name;
/**
* 文件路径。
*/
private String path;
/**
* 文件 URL。
*/
private String url;
/**
* 文件类型。
*/
private String type;
/**
* 文件大小。
*/
private Long size;
}

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