feat(infra,content): 接通对象存储读腿 — FileApi 按 path 字节取回 RPC + content 接入 infra-api (U1,U2)
- U1: content-server pom 加 muse-module-infra-api 依赖(monolith 内 FileApiImpl bean 满足注入,无需 @EnableFeignClients)。 - U2(P0 修复): FileApi 加 createFileReturningPath(返回稳定 path)+ getFileContentByPath(按 path 字节代理); FileServiceImpl 抽 doUploadFile 复用并新增两法; FileMapper.selectByPath; 无记录 fail-closed 不裸取。私有桶下签名 url 不可作稳定句柄,故下载按 path 键。 - FileServiceImplTest 补 3 单测,19 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f014b97c36
commit
9eb280615e
@ -32,6 +32,12 @@
|
||||
<artifactId>muse-module-events-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<!-- R2 对象存储:消费 infra FileApi(上传 / 按 path 字节取回),用于导出包的存取与导入文件检视 -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>muse-module-infra-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>muse-spring-boot-starter-biz-tenant</artifactId>
|
||||
|
||||
@ -58,6 +58,40 @@ public interface FileApi {
|
||||
@Operation(summary = "保存文件,并返回文件的访问路径")
|
||||
CommonResult<String> createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO);
|
||||
|
||||
/**
|
||||
* 保存文件,并返回稳定存储 path(非签名 URL)
|
||||
*
|
||||
* <p>用于导出包等需要稳定句柄的场景:私有桶下访问 URL 带签名会过期,path 才是可重复回读的稳定键。</p>
|
||||
*
|
||||
* @param content 文件内容
|
||||
* @param name 文件名称,允许空
|
||||
* @param directory 目录,允许空
|
||||
* @param type 文件的 MIME 类型,允许空
|
||||
* @return 稳定存储 path
|
||||
*/
|
||||
default String createFileForPath(byte[] content, String name, String directory, String type) {
|
||||
return createFileReturningPath(new FileCreateReqDTO().setName(name).setDirectory(directory)
|
||||
.setType(type).setContent(content)).getCheckedData();
|
||||
}
|
||||
|
||||
@PostMapping(PREFIX + "/create-returning-path")
|
||||
@Operation(summary = "保存文件,并返回稳定存储 path")
|
||||
CommonResult<String> createFileReturningPath(@Valid @RequestBody FileCreateReqDTO createReqDTO);
|
||||
|
||||
/**
|
||||
* 按稳定存储 path 读取文件字节(导出包下载的后端字节代理)
|
||||
*
|
||||
* @param path 存储 path(createFileForPath 的返回值)
|
||||
* @return 文件字节
|
||||
*/
|
||||
default byte[] getFileBytes(String path) {
|
||||
return getFileContentByPath(path).getCheckedData();
|
||||
}
|
||||
|
||||
@GetMapping(PREFIX + "/content-by-path")
|
||||
@Operation(summary = "按存储 path 读取文件字节")
|
||||
CommonResult<byte[]> getFileContentByPath(@NotEmpty(message = "path 不能为空") @RequestParam("path") String path);
|
||||
|
||||
/**
|
||||
* 生成文件预签名地址,用于读取
|
||||
*
|
||||
|
||||
@ -3,6 +3,7 @@ package cn.iocoder.muse.module.infra.api.file;
|
||||
import cn.iocoder.muse.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.muse.module.infra.api.file.dto.FileCreateReqDTO;
|
||||
import cn.iocoder.muse.module.infra.service.file.FileService;
|
||||
import lombok.SneakyThrows;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@ -23,6 +24,19 @@ public class FileApiImpl implements FileApi {
|
||||
createReqDTO.getDirectory(), createReqDTO.getType()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<String> createFileReturningPath(FileCreateReqDTO createReqDTO) {
|
||||
return success(fileService.createFileReturningPath(createReqDTO.getContent(), createReqDTO.getName(),
|
||||
createReqDTO.getDirectory(), createReqDTO.getType()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public CommonResult<byte[]> getFileContentByPath(String path) {
|
||||
// 按稳定 path 读取字节,委派 FileService(其内部按 path 反查 configId 后取字节)
|
||||
return success(fileService.getFileContentByPath(path));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<String> presignGetUrl(String url, Integer expirationSeconds) {
|
||||
return success(fileService.presignGetUrl(url, expirationSeconds));
|
||||
|
||||
@ -23,4 +23,16 @@ public interface FileMapper extends BaseMapperX<FileDO> {
|
||||
.orderByDesc(FileDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按存储 path 反查文件记录。
|
||||
*
|
||||
* <p>用于导出包下载的字节代理:path 是 createFile 落库的稳定路径(非签名 URL),据此取回 configId 后读字节。</p>
|
||||
*
|
||||
* @param path 存储路径
|
||||
* @return 文件记录,不存在返回 {@code null}
|
||||
*/
|
||||
default FileDO selectByPath(String path) {
|
||||
return selectOne(FileDO::getPath, path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -36,6 +36,29 @@ public interface FileService {
|
||||
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
|
||||
String name, String directory, String type);
|
||||
|
||||
/**
|
||||
* 保存文件,并返回稳定存储 path(非访问 URL)。
|
||||
*
|
||||
* <p>用于需要长期稳定句柄的场景(如导出包下载):私有桶下 {@link #createFile} 返回的访问 URL 带签名会过期、
|
||||
* 每次还不同,无法作为可重复回读的键;path 才是稳定键。</p>
|
||||
*
|
||||
* @param content 文件内容
|
||||
* @param name 文件名称,允许空
|
||||
* @param directory 目录,允许空
|
||||
* @param type 文件的 MIME 类型,允许空
|
||||
* @return 稳定存储路径
|
||||
*/
|
||||
String createFileReturningPath(@NotEmpty(message = "文件内容不能为空") byte[] content,
|
||||
String name, String directory, String type);
|
||||
|
||||
/**
|
||||
* 按稳定存储 path 读取文件字节(导出包下载的字节代理)。
|
||||
*
|
||||
* @param path 存储路径({@link #createFileReturningPath} 的返回值)
|
||||
* @return 文件内容
|
||||
*/
|
||||
byte[] getFileContentByPath(String path) throws Exception;
|
||||
|
||||
/**
|
||||
* 生成文件预签名地址信息,用于上传
|
||||
*
|
||||
|
||||
@ -68,8 +68,24 @@ public class FileServiceImpl implements FileService {
|
||||
}
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public String createFile(byte[] content, String name, String directory, String type) {
|
||||
// 返回访问 URL(私有桶下为带签名地址,会过期);需要稳定句柄的导出下载请用 createFileReturningPath。
|
||||
return doUploadFile(content, name, directory, type).getUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createFileReturningPath(byte[] content, String name, String directory, String type) {
|
||||
// 返回稳定存储 path(非签名 URL):导出包下载按 path 字节代理回读,规避签名过期 / 每次不同的问题。
|
||||
return doUploadFile(content, name, directory, type).getPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到存储器并落库,返回完整 {@link FileDO}(含稳定 path、configId、访问 url)。
|
||||
*
|
||||
* <p>抽出公共逻辑供 {@link #createFile} 与 {@link #createFileReturningPath} 复用,避免上传 / 落库逻辑重复。</p>
|
||||
*/
|
||||
@SneakyThrows
|
||||
private FileDO doUploadFile(byte[] content, String name, String directory, String type) {
|
||||
// 1.1 处理 type 为空的情况
|
||||
if (StrUtil.isEmpty(type)) {
|
||||
type = FileTypeUtils.getMineType(content, name);
|
||||
@ -94,10 +110,11 @@ public class FileServiceImpl implements FileService {
|
||||
String url = client.upload(content, path, type);
|
||||
|
||||
// 3. 保存到数据库
|
||||
fileMapper.insert(new FileDO().setConfigId(client.getId())
|
||||
FileDO fileDO = 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(fileDO);
|
||||
return fileDO;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@ -216,4 +233,14 @@ public class FileServiceImpl implements FileService {
|
||||
return client.getContent(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getFileContentByPath(String path) throws Exception {
|
||||
// 按稳定存储 path 反查文件记录,再经其 configId 读取字节;记录不存在直接失败,不回退裸取以免越权读任意对象。
|
||||
FileDO file = fileMapper.selectByPath(path);
|
||||
if (file == null) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
}
|
||||
return getFileContent(file.getConfigId(), path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -189,6 +189,67 @@ public class FileServiceImplTest extends BaseDbUnitTest {
|
||||
assertSame(result, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* createFileReturningPath:上传后返回稳定存储 path(而非带签名的访问 URL),供导出包下载按 path 回读。
|
||||
*/
|
||||
@Test
|
||||
public void testCreateFileReturningPath_success() throws Exception {
|
||||
// 准备参数
|
||||
byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
|
||||
String name = "导出包";
|
||||
String directory = randomString();
|
||||
String type = "image/jpeg";
|
||||
// mock Master 文件客户端
|
||||
FileClient client = mock(FileClient.class);
|
||||
when(fileConfigService.getMasterFileClient()).thenReturn(client);
|
||||
String url = randomString();
|
||||
AtomicReference<String> pathRef = new AtomicReference<>();
|
||||
when(client.upload(same(content), argThat(path -> {
|
||||
pathRef.set(path);
|
||||
return true;
|
||||
}), eq(type))).thenReturn(url);
|
||||
when(client.getId()).thenReturn(10L);
|
||||
// 调用
|
||||
String result = fileService.createFileReturningPath(content, name, directory, type);
|
||||
// 断言:返回的是稳定 path(非签名 URL)
|
||||
assertEquals(pathRef.get(), result);
|
||||
assertNotEquals(url, result);
|
||||
// 校验落库:可按 path 反查到,且 url 仍正常落库
|
||||
FileDO file = fileMapper.selectByPath(result);
|
||||
assertNotNull(file);
|
||||
assertEquals(10L, file.getConfigId());
|
||||
assertEquals(url, file.getUrl());
|
||||
assertEquals(content.length, file.getSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* getFileContentByPath:按稳定 path 反查 configId 后读取字节。
|
||||
*/
|
||||
@Test
|
||||
public void testGetFileContentByPath_success() throws Exception {
|
||||
// mock 数据:落一条带 path/configId 的文件记录
|
||||
FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("t1/content/export/x.txt"));
|
||||
fileMapper.insert(dbFile);
|
||||
// mock 客户端按 path 取字节
|
||||
FileClient client = mock(FileClient.class);
|
||||
when(fileConfigService.getFileClient(eq(10L))).thenReturn(client);
|
||||
byte[] content = new byte[]{1, 2, 3};
|
||||
when(client.getContent(eq("t1/content/export/x.txt"))).thenReturn(content);
|
||||
|
||||
// 调用
|
||||
byte[] result = fileService.getFileContentByPath("t1/content/export/x.txt");
|
||||
// 断言
|
||||
assertSame(content, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* getFileContentByPath:path 无对应记录时失败关闭,不回退裸取以免越权读任意对象。
|
||||
*/
|
||||
@Test
|
||||
public void testGetFileContentByPath_notExists() {
|
||||
assertServiceException(() -> fileService.getFileContentByPath("no/such/path.txt"), FILE_NOT_EXISTS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGenerateUploadPath_AllEnabled() {
|
||||
// 准备参数
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user