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:
98
rdms-system/2026-05-11-file-upload-api-改造需求.md
Normal file
98
rdms-system/2026-05-11-file-upload-api-改造需求.md
Normal 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` 的字符串形式。其它接口不动。
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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