feat(content): 真 ContentFileFacade 导出/下载接通对象存储 (U5a)
- RealContentFileFacade @Component @Primary:createExportPackage(BC 内组装 canonical→渲染→size guard→createFileForPath,packageRef=稳定 path,凭证 UUID+TTL);downloadExportPackage(getFileBytes 字节代理)。 - KTD2 拓扑:移除 UnavailableContentFileFacade 的 @Service/@ConditionalOnMissingBean(改纯类),MonolithFacadeFallback @Bean 兜底完好。 - 全路径 fail-closed(FileApi 缺/超限/抛错→unavailable,不抛不伪造,@Transactional 回滚);catch 只记 errorType 不记 message(不泄存储细节)。 - RealContentFileFacadeTest 13 绿;muse-server 编译通过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
18b7c12417
commit
a284607762
@ -3,8 +3,6 @@ package cn.iocoder.muse.module.content.application.facade;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.CreateImportTaskReqVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.CreateExportTaskReqVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.ContentSourceSnapshotVO;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@ -99,8 +97,11 @@ public interface ContentFileFacade {
|
||||
|
||||
/**
|
||||
* FileService 尚未接入时的默认导入文件 facade。
|
||||
*
|
||||
* <p>KTD2:去除 {@code @Service @ConditionalOnMissingBean}(在 {@code @Component/@Service} 上不可靠)。
|
||||
* 单体兜底统一由 {@code MonolithFacadeFallbackAutoConfiguration} 的 {@code @Bean @ConditionalOnMissingBean}
|
||||
* 提供;真实现 {@code RealContentFileFacade}({@code @Component @Primary})压过该兜底。本类降为普通包级类,
|
||||
* 仅作 fail-closed 语义的可复用载体(如测试),不再自注册为 bean。</p>
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnMissingBean(ContentFileFacade.class)
|
||||
class UnavailableContentFileFacade implements ContentFileFacade {
|
||||
}
|
||||
|
||||
@ -0,0 +1,260 @@
|
||||
package cn.iocoder.muse.module.content.application.facade;
|
||||
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportChapter;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportDocument;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportFormat;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportRenderer;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportRendererFactory;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.CreateExportTaskReqVO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.ChapterDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.BlockMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.ChapterMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.infra.api.file.FileApi;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Content 文件 facade 真实现 —— 接通 infra 对象存储完成导出包的存取。
|
||||
*
|
||||
* <p>装配拓扑(KTD2):{@code @Component @Primary} 无条件注册,确定性压过
|
||||
* {@code MonolithFacadeFallbackAutoConfiguration} 的 fail-closed 兜底;不用
|
||||
* {@code @ConditionalOnBean}(在 {@code @Component} 上不可靠)。</p>
|
||||
*
|
||||
* <p>失败关闭(R8):{@code FileApi} 不可用、导出包超限、或 FileApi 抛异常时一律返回
|
||||
* {@code unavailable()},绝不抛出、绝不伪造凭证/字节 —— 让调用方 {@code @Transactional} 见
|
||||
* unavailable 后回滚命令预占({@code muse_content_command_log} 0 残留),不落 completed 行。</p>
|
||||
*
|
||||
* <p>导入检视 {@code inspectImportFile} 不在本单元,沿用接口默认(unavailable),由 U5b 实现。</p>
|
||||
*/
|
||||
@Component
|
||||
@Primary
|
||||
public class RealContentFileFacade implements ContentFileFacade {
|
||||
|
||||
private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(RealContentFileFacade.class);
|
||||
|
||||
/**
|
||||
* 导出任务完成态,调用方据此放行下载(与 {@code ContentExportServiceImpl} 的 completed 判定对齐)。
|
||||
*/
|
||||
private static final String STATUS_COMPLETED = "completed";
|
||||
|
||||
/**
|
||||
* 导出包字节上限(R6/DoS 防护):渲染输出全量入堆,超阈值即拒绝、不上传。默认 50MB。
|
||||
*/
|
||||
@Value("${muse.content.export.max-package-bytes:52428800}")
|
||||
private long maxPackageBytes;
|
||||
|
||||
/**
|
||||
* 下载凭证有效期(小时)。
|
||||
*/
|
||||
@Value("${muse.content.export.download-ttl-hours:24}")
|
||||
private long downloadTtlHours;
|
||||
|
||||
/**
|
||||
* infra 对象存储客户端;单体内恒在,缺失时本 facade 全程 fail-closed。
|
||||
*/
|
||||
private final FileApi fileApi;
|
||||
|
||||
@Resource
|
||||
private WorkMapper workMapper;
|
||||
@Resource
|
||||
private ChapterMapper chapterMapper;
|
||||
@Resource
|
||||
private BlockMapper blockMapper;
|
||||
@Resource
|
||||
private ExportRendererFactory rendererFactory;
|
||||
|
||||
public RealContentFileFacade(ObjectProvider<FileApi> fileApiProvider) {
|
||||
// 与 KnowledgeFileFacade 同构:构造期取 FileApi,缺失即 null,后续 fail-closed。
|
||||
this.fileApi = fileApiProvider.getIfAvailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExportPackageCreateResult createExportPackage(Long userId, Long workId, CreateExportTaskReqVO reqVO) {
|
||||
// 1) FileApi 缺失:fail-closed,不伪造任务/凭证。
|
||||
if (fileApi == null) {
|
||||
LOG.warn("Content 导出 fail-closed:FileApi 不可用,workId={}", workId);
|
||||
return ExportPackageCreateResult.unavailable();
|
||||
}
|
||||
// 2) 解析导出格式;未知/空格式 fail-closed(不抛 500,由调用方据 unavailable 回滚)。
|
||||
ExportFormat format = resolveFormat(reqVO == null ? null : reqVO.getFormat());
|
||||
if (format == null) {
|
||||
LOG.warn("Content 导出 fail-closed:不支持的导出格式,workId={}", workId);
|
||||
return ExportPackageCreateResult.unavailable();
|
||||
}
|
||||
// 3) 取渲染器;本期仅 TXT 已注册,docx/epub 未注册时 getRenderer 抛 IllegalArgumentException,
|
||||
// 视作受阻导出(unavailable),不让异常逃逸成假完成。
|
||||
ExportRenderer renderer;
|
||||
try {
|
||||
renderer = rendererFactory.getRenderer(format);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
LOG.warn("Content 导出 fail-closed:导出格式渲染器未注册,workId={}, format={}", workId, format);
|
||||
return ExportPackageCreateResult.unavailable();
|
||||
}
|
||||
|
||||
// 4) 从 content 自有 DAL 装配 canonical 正文(在 BC 内读,不跨 BC)。
|
||||
ExportDocument document = assembleDocument(workId, reqVO == null ? null : reqVO.getIncludeChapters());
|
||||
|
||||
// 5) 渲染包字节。
|
||||
byte[] bytes = renderer.render(document);
|
||||
if (bytes == null) {
|
||||
LOG.warn("Content 导出 fail-closed:渲染器返回 null 字节,workId={}, format={}", workId, format);
|
||||
return ExportPackageCreateResult.unavailable();
|
||||
}
|
||||
// 6) 大小上限守卫(R6):超阈值拒绝,不上传(日志不含密钥/正文)。
|
||||
if (bytes.length > maxPackageBytes) {
|
||||
LOG.warn("Content 导出 fail-closed:导出包超过大小上限,workId={}, size={}, cap={}",
|
||||
workId, bytes.length, maxPackageBytes);
|
||||
return ExportPackageCreateResult.unavailable();
|
||||
}
|
||||
|
||||
// 7) 构造租户/用户命名空间化目录与确定文件名(R5):{tenantId}/content/export/{userId}/{workId}。
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
String directory = tenantId + "/content/export/" + userId + "/" + workId;
|
||||
String name = UUID.randomUUID().toString().replace("-", "") + "." + format.getFileExtension();
|
||||
|
||||
// 8) 上传并取稳定存储 path 作 packageRef(非签名 URL);FileApi 抛异常→fail-closed(R8)。
|
||||
String path;
|
||||
try {
|
||||
path = fileApi.createFileForPath(bytes, name, directory, format.getMimeType());
|
||||
} catch (RuntimeException ex) {
|
||||
// 不记录 path/密钥,仅记 workId 与异常类型,便于排障且不外泄存储细节。
|
||||
LOG.warn("Content 导出 fail-closed:对象存储上传失败,workId={}, errorType={}",
|
||||
workId, ex.getClass().getSimpleName());
|
||||
return ExportPackageCreateResult.unavailable();
|
||||
}
|
||||
if (path == null || path.isBlank()) {
|
||||
LOG.warn("Content 导出 fail-closed:对象存储返回空 path,workId={}", workId);
|
||||
return ExportPackageCreateResult.unavailable();
|
||||
}
|
||||
|
||||
// 9) 铸造下载凭证与过期时间;凭证为去横线 UUID(不可猜、与 path 解耦)。
|
||||
String credentialId = UUID.randomUUID().toString().replace("-", "");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime downloadExpiresAt = now.plusHours(downloadTtlHours);
|
||||
// sourceSnapshot 由调用方/请求承载,本 facade 不构造,返回 null 让调用方回退到 reqVO 的快照。
|
||||
return ExportPackageCreateResult.available(STATUS_COMPLETED, 100, credentialId, path,
|
||||
downloadExpiresAt, now, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadPackageResult downloadExportPackage(Long userId, String credentialId, String packageRef) {
|
||||
// owner/过期/凭证校验已在 ContentExportServiceImpl(service 层、取字节前)完成;
|
||||
// packageRef 来自被拥有的任务行而非用户输入,IDOR 面已在 service 闭合。
|
||||
if (fileApi == null) {
|
||||
LOG.warn("Content 下载 fail-closed:FileApi 不可用");
|
||||
return DownloadPackageResult.unavailable();
|
||||
}
|
||||
if (packageRef == null || packageRef.isBlank()) {
|
||||
return DownloadPackageResult.unavailable();
|
||||
}
|
||||
byte[] content;
|
||||
try {
|
||||
content = fileApi.getFileBytes(packageRef);
|
||||
} catch (RuntimeException ex) {
|
||||
LOG.warn("Content 下载 fail-closed:对象存储取回失败,errorType={}", ex.getClass().getSimpleName());
|
||||
return DownloadPackageResult.unavailable();
|
||||
}
|
||||
if (content == null || content.length == 0) {
|
||||
return DownloadPackageResult.unavailable();
|
||||
}
|
||||
String fileName = resolveFileName(packageRef);
|
||||
String contentType = resolveContentType(fileName);
|
||||
return DownloadPackageResult.available(fileName, contentType, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把请求 format 字符串映射为 {@link ExportFormat};未知/空返回 {@code null}(fail-closed,不抛)。
|
||||
*/
|
||||
private ExportFormat resolveFormat(String format) {
|
||||
if (format == null || format.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return ExportFormat.valueOf(format.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 content 自有 DAL 装配 canonical 正文文档。
|
||||
*
|
||||
* <p>装配规则:标题取自作品;章节按 {@code orderNo} 升序,章下场景/小节 Block 的
|
||||
* {@code contentText} 按 {@code orderNo} 升序拼接为该章 body。{@code includeChapters} 非空时
|
||||
* 仅导出指定章节(章归属已在 service 层 {@code requireIncludedChaptersOwnedByWork} 校验)。</p>
|
||||
*/
|
||||
private ExportDocument assembleDocument(Long workId, List<Long> includeChapters) {
|
||||
WorkDO work = workMapper.selectById(workId);
|
||||
String title = work == null ? null : work.getTitle();
|
||||
|
||||
List<ChapterDO> chapters = chapterMapper.selectListByWorkId(workId);
|
||||
java.util.Set<Long> includeSet = (includeChapters == null || includeChapters.isEmpty())
|
||||
? null : new java.util.HashSet<>(includeChapters);
|
||||
|
||||
List<ExportChapter> exportChapters = new java.util.ArrayList<>();
|
||||
for (ChapterDO chapter : chapters) {
|
||||
if (includeSet != null && !includeSet.contains(chapter.getId())) {
|
||||
continue; // 仅导出指定章节
|
||||
}
|
||||
String body = assembleChapterBody(chapter.getId());
|
||||
exportChapters.add(new ExportChapter(chapter.getTitle(), body));
|
||||
}
|
||||
return new ExportDocument(title, exportChapters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接单章正文:章下 Block 按 {@code orderNo} 升序,{@code contentText} 以换行连接。
|
||||
*/
|
||||
private String assembleChapterBody(Long chapterId) {
|
||||
List<BlockDO> blocks = blockMapper.selectListByChapterId(chapterId);
|
||||
StringBuilder body = new StringBuilder();
|
||||
for (BlockDO block : blocks) {
|
||||
String text = block.getContentText();
|
||||
if (text == null || text.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (body.length() > 0) {
|
||||
body.append('\n');
|
||||
}
|
||||
body.append(text);
|
||||
}
|
||||
return body.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从存储 path 取最后一段作为下载文件名。
|
||||
*/
|
||||
private String resolveFileName(String path) {
|
||||
int idx = path.lastIndexOf('/');
|
||||
String last = idx >= 0 ? path.substring(idx + 1) : path;
|
||||
return last.isBlank() ? "export.bin" : last;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由文件名扩展名回填 Content-Type;无法识别时回退 octet-stream。
|
||||
*/
|
||||
private String resolveContentType(String fileName) {
|
||||
int dot = fileName.lastIndexOf('.');
|
||||
if (dot >= 0 && dot < fileName.length() - 1) {
|
||||
String ext = fileName.substring(dot + 1).toLowerCase();
|
||||
for (ExportFormat fmt : ExportFormat.values()) {
|
||||
if (fmt.getFileExtension().equals(ext)) {
|
||||
return fmt.getMimeType();
|
||||
}
|
||||
}
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,367 @@
|
||||
package cn.iocoder.muse.module.content.application.facade;
|
||||
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportFormat;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportRenderer;
|
||||
import cn.iocoder.muse.module.content.application.export.render.ExportRendererFactory;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.CreateExportTaskReqVO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.ChapterDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.BlockMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.ChapterMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
|
||||
import cn.iocoder.muse.module.infra.api.file.FileApi;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link RealContentFileFacade} 单元测试。
|
||||
*
|
||||
* <p>覆盖 U5a 场景:导出正路径(含命名空间/渲染器调用/packageRef=path 断言)、FileApi 缺失 fail-closed、
|
||||
* 超大小上限不上传、下载正路径、下载 fail-closed、R8 facade 层(FileApi 抛错→unavailable 不抛)、装配契约。</p>
|
||||
*
|
||||
* <p>不用 {@code @InjectMocks}:facade 经构造器取 {@code ObjectProvider<FileApi>},需在构造前 stub provider
|
||||
* 以覆盖 FileApi 缺失分支,故手工构造 + {@link ReflectionTestUtils} 注入 {@code @Resource}/{@code @Value} 字段。</p>
|
||||
*/
|
||||
class RealContentFileFacadeTest extends BaseMockitoUnitTest {
|
||||
|
||||
private static final Long USER_ID = 9001L;
|
||||
private static final Long WORK_ID = 10001L;
|
||||
private static final Long TENANT_ID = 100L;
|
||||
|
||||
@Mock
|
||||
private FileApi fileApi;
|
||||
@Mock
|
||||
private ObjectProvider<FileApi> fileApiProvider;
|
||||
@Mock
|
||||
private WorkMapper workMapper;
|
||||
@Mock
|
||||
private ChapterMapper chapterMapper;
|
||||
@Mock
|
||||
private BlockMapper blockMapper;
|
||||
@Mock
|
||||
private ExportRendererFactory rendererFactory;
|
||||
@Mock
|
||||
private ExportRenderer txtRenderer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 导出包构 key 依赖租户上下文,按既有 content 测试约定显式设置。
|
||||
TenantContextHolder.setTenantId(TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 FileApi 可用的 facade,并注入 mock 协作者与配置。
|
||||
*/
|
||||
private RealContentFileFacade newFacadeWithFileApi() {
|
||||
when(fileApiProvider.getIfAvailable()).thenReturn(fileApi);
|
||||
return buildFacade();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 FileApi 缺失的 facade(provider 返回 null)。
|
||||
*/
|
||||
private RealContentFileFacade newFacadeWithoutFileApi() {
|
||||
when(fileApiProvider.getIfAvailable()).thenReturn(null);
|
||||
return buildFacade();
|
||||
}
|
||||
|
||||
private RealContentFileFacade buildFacade() {
|
||||
RealContentFileFacade facade = new RealContentFileFacade(fileApiProvider);
|
||||
ReflectionTestUtils.setField(facade, "workMapper", workMapper);
|
||||
ReflectionTestUtils.setField(facade, "chapterMapper", chapterMapper);
|
||||
ReflectionTestUtils.setField(facade, "blockMapper", blockMapper);
|
||||
ReflectionTestUtils.setField(facade, "rendererFactory", rendererFactory);
|
||||
ReflectionTestUtils.setField(facade, "maxPackageBytes", 52428800L);
|
||||
ReflectionTestUtils.setField(facade, "downloadTtlHours", 24L);
|
||||
return facade;
|
||||
}
|
||||
|
||||
private CreateExportTaskReqVO txtReqVO() {
|
||||
CreateExportTaskReqVO reqVO = new CreateExportTaskReqVO();
|
||||
reqVO.setCommandId("cmd-1");
|
||||
reqVO.setFormat("txt");
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
private void stubCanonicalWork() {
|
||||
WorkDO work = new WorkDO();
|
||||
work.setId(WORK_ID);
|
||||
work.setTitle("我的作品");
|
||||
when(workMapper.selectById(WORK_ID)).thenReturn(work);
|
||||
|
||||
ChapterDO chapter = new ChapterDO();
|
||||
chapter.setId(2001L);
|
||||
chapter.setWorkId(WORK_ID);
|
||||
chapter.setTitle("第一章");
|
||||
chapter.setOrderNo(1);
|
||||
when(chapterMapper.selectListByWorkId(WORK_ID)).thenReturn(List.of(chapter));
|
||||
|
||||
BlockDO block = new BlockDO();
|
||||
block.setId(3001L);
|
||||
block.setChapterId(2001L);
|
||||
block.setOrderNo(1);
|
||||
block.setContentText("正文内容");
|
||||
when(blockMapper.selectListByChapterId(2001L)).thenReturn(List.of(block));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_createPackage_whenHappyPath() {
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
stubCanonicalWork();
|
||||
byte[] rendered = "我的作品\n\n第一章\n正文内容\n\n".getBytes(StandardCharsets.UTF_8);
|
||||
when(rendererFactory.getRenderer(ExportFormat.TXT)).thenReturn(txtRenderer);
|
||||
when(txtRenderer.render(any())).thenReturn(rendered);
|
||||
String stablePath = TENANT_ID + "/content/export/" + USER_ID + "/" + WORK_ID + "/abc.txt";
|
||||
ArgumentCaptor<String> dirCaptor = ArgumentCaptor.forClass(String.class);
|
||||
when(fileApi.createFileForPath(eq(rendered), anyString(), dirCaptor.capture(), eq(ExportFormat.TXT.getMimeType())))
|
||||
.thenReturn(stablePath);
|
||||
|
||||
ContentFileFacade.ExportPackageCreateResult result =
|
||||
facade.createExportPackage(USER_ID, WORK_ID, txtReqVO());
|
||||
|
||||
assertTrue(result.available());
|
||||
assertEquals("completed", result.status());
|
||||
assertEquals(100, result.progress());
|
||||
// packageRef 必须是返回的稳定 path(非签名 URL)。
|
||||
assertEquals(stablePath, result.packageRef());
|
||||
assertNotNull(result.credentialId());
|
||||
assertFalse(result.credentialId().isBlank());
|
||||
assertNotNull(result.downloadExpiresAt());
|
||||
assertNotNull(result.completedAt());
|
||||
// 目录命名空间化:含 tenantId/userId/workId。
|
||||
String dir = dirCaptor.getValue();
|
||||
assertEquals(TENANT_ID + "/content/export/" + USER_ID + "/" + WORK_ID, dir);
|
||||
// 渲染器确被调用。
|
||||
verify(txtRenderer).render(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosed_whenFileApiProviderEmpty() {
|
||||
RealContentFileFacade facade = newFacadeWithoutFileApi();
|
||||
|
||||
ContentFileFacade.ExportPackageCreateResult result =
|
||||
facade.createExportPackage(USER_ID, WORK_ID, txtReqVO());
|
||||
|
||||
assertFalse(result.available());
|
||||
// FileApi 缺失:不触达渲染/存储。
|
||||
verifyNoInteractions(rendererFactory);
|
||||
verifyNoInteractions(fileApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosed_whenFormatUnknown() {
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
CreateExportTaskReqVO reqVO = txtReqVO();
|
||||
reqVO.setFormat("pdf"); // 未知格式
|
||||
|
||||
ContentFileFacade.ExportPackageCreateResult result =
|
||||
facade.createExportPackage(USER_ID, WORK_ID, reqVO);
|
||||
|
||||
assertFalse(result.available());
|
||||
verifyNoInteractions(rendererFactory);
|
||||
verifyNoInteractions(fileApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosed_whenRendererNotRegistered() {
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
CreateExportTaskReqVO reqVO = txtReqVO();
|
||||
reqVO.setFormat("docx"); // 本期 docx 渲染器未注册(U4 追加)
|
||||
when(rendererFactory.getRenderer(ExportFormat.DOCX))
|
||||
.thenThrow(new IllegalArgumentException("不支持的导出格式"));
|
||||
|
||||
ContentFileFacade.ExportPackageCreateResult result =
|
||||
facade.createExportPackage(USER_ID, WORK_ID, reqVO);
|
||||
|
||||
// 受阻导出(unavailable),不让异常逃逸成 500/假完成。
|
||||
assertFalse(result.available());
|
||||
verifyNoInteractions(fileApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosed_whenPackageOverSizeCap() {
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
ReflectionTestUtils.setField(facade, "maxPackageBytes", 4L); // 极小上限以触发守卫
|
||||
stubCanonicalWork();
|
||||
when(rendererFactory.getRenderer(ExportFormat.TXT)).thenReturn(txtRenderer);
|
||||
when(txtRenderer.render(any())).thenReturn(new byte[]{1, 2, 3, 4, 5}); // 5 字节 > 4
|
||||
|
||||
ContentFileFacade.ExportPackageCreateResult result =
|
||||
facade.createExportPackage(USER_ID, WORK_ID, txtReqVO());
|
||||
|
||||
assertFalse(result.available());
|
||||
// 超限不得上传。
|
||||
verify(fileApi, never()).createFileForPath(any(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosed_whenUploadThrows() {
|
||||
// R8 facade 层:FileApi 抛错→unavailable(不 raise),调用方 @Transactional 据此回滚。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
stubCanonicalWork();
|
||||
when(rendererFactory.getRenderer(ExportFormat.TXT)).thenReturn(txtRenderer);
|
||||
when(txtRenderer.render(any())).thenReturn("data".getBytes(StandardCharsets.UTF_8));
|
||||
when(fileApi.createFileForPath(any(), anyString(), anyString(), anyString()))
|
||||
.thenThrow(new RuntimeException("S3 unavailable"));
|
||||
|
||||
ContentFileFacade.ExportPackageCreateResult result =
|
||||
facade.createExportPackage(USER_ID, WORK_ID, txtReqVO());
|
||||
|
||||
assertFalse(result.available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_downloadPackage_whenHappyPath() {
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
String path = TENANT_ID + "/content/export/" + USER_ID + "/" + WORK_ID + "/abc.txt";
|
||||
byte[] bytes = "正文".getBytes(StandardCharsets.UTF_8);
|
||||
when(fileApi.getFileBytes(path)).thenReturn(bytes);
|
||||
|
||||
ContentFileFacade.DownloadPackageResult result =
|
||||
facade.downloadExportPackage(USER_ID, "cred-1", path);
|
||||
|
||||
assertTrue(result.available());
|
||||
assertEquals("abc.txt", result.fileName());
|
||||
assertEquals(ExportFormat.TXT.getMimeType(), result.contentType());
|
||||
assertEquals(bytes, result.content());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosedDownload_whenFileApiAbsent() {
|
||||
RealContentFileFacade facade = newFacadeWithoutFileApi();
|
||||
|
||||
ContentFileFacade.DownloadPackageResult result =
|
||||
facade.downloadExportPackage(USER_ID, "cred-1", "100/content/export/9001/10001/abc.txt");
|
||||
|
||||
assertFalse(result.available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosedDownload_whenGetBytesThrows() {
|
||||
// R8 facade 层:取回抛错→unavailable,不 raise。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
when(fileApi.getFileBytes(anyString())).thenThrow(new RuntimeException("S3 down"));
|
||||
|
||||
ContentFileFacade.DownloadPackageResult result =
|
||||
facade.downloadExportPackage(USER_ID, "cred-1", "100/content/export/9001/10001/abc.txt");
|
||||
|
||||
assertFalse(result.available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosedDownload_whenContentEmpty() {
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
when(fileApi.getFileBytes(anyString())).thenReturn(new byte[0]);
|
||||
|
||||
ContentFileFacade.DownloadPackageResult result =
|
||||
facade.downloadExportPackage(USER_ID, "cred-1", "100/content/export/9001/10001/abc.txt");
|
||||
|
||||
assertFalse(result.available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_registerAsPrimaryComponentWithoutConditionalOnBean() {
|
||||
// 装配契约(KTD2):@Component + @Primary 无条件注册,压过单体 fail-closed 兜底;
|
||||
// 不用 @ConditionalOnBean(在 @Component 上不可靠)。
|
||||
assertNotNull(RealContentFileFacade.class.getAnnotation(Component.class),
|
||||
"真实现须 @Component 被扫描注册");
|
||||
assertNotNull(RealContentFileFacade.class.getAnnotation(Primary.class),
|
||||
"真实现须 @Primary 压过 Unavailable 兜底");
|
||||
assertNull(RealContentFileFacade.class.getAnnotation(ConditionalOnBean.class),
|
||||
"@ConditionalOnBean 在 @Component 上不可靠,须去除");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_assembleDocumentInChapterAndBlockOrder() {
|
||||
// 装配契约:标题取作品、章按 orderNo、块正文按 orderNo 拼接。
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
stubCanonicalWork();
|
||||
when(rendererFactory.getRenderer(ExportFormat.TXT)).thenReturn(txtRenderer);
|
||||
when(txtRenderer.render(any())).thenReturn("x".getBytes(StandardCharsets.UTF_8));
|
||||
when(fileApi.createFileForPath(any(), anyString(), anyString(), anyString())).thenReturn("100/x/abc.txt");
|
||||
|
||||
facade.createExportPackage(USER_ID, WORK_ID, txtReqVO());
|
||||
|
||||
// 校验传入渲染器的文档由 content DAL 装配而来。
|
||||
ArgumentCaptor<cn.iocoder.muse.module.content.application.export.render.ExportDocument> docCaptor =
|
||||
ArgumentCaptor.forClass(cn.iocoder.muse.module.content.application.export.render.ExportDocument.class);
|
||||
verify(txtRenderer).render(docCaptor.capture());
|
||||
cn.iocoder.muse.module.content.application.export.render.ExportDocument doc = docCaptor.getValue();
|
||||
assertEquals("我的作品", doc.title());
|
||||
assertEquals(1, doc.chapters().size());
|
||||
assertEquals("第一章", doc.chapters().get(0).title());
|
||||
assertEquals("正文内容", doc.chapters().get(0).body());
|
||||
verify(workMapper).selectById(WORK_ID);
|
||||
verify(chapterMapper).selectListByWorkId(WORK_ID);
|
||||
verify(blockMapper).selectListByChapterId(2001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_onlyExportIncludedChapters_whenIncludeChaptersGiven() {
|
||||
RealContentFileFacade facade = newFacadeWithFileApi();
|
||||
WorkDO work = new WorkDO();
|
||||
work.setId(WORK_ID);
|
||||
work.setTitle("作品");
|
||||
when(workMapper.selectById(WORK_ID)).thenReturn(work);
|
||||
ChapterDO c1 = new ChapterDO();
|
||||
c1.setId(2001L);
|
||||
c1.setWorkId(WORK_ID);
|
||||
c1.setOrderNo(1);
|
||||
ChapterDO c2 = new ChapterDO();
|
||||
c2.setId(2002L);
|
||||
c2.setWorkId(WORK_ID);
|
||||
c2.setOrderNo(2);
|
||||
when(chapterMapper.selectListByWorkId(WORK_ID)).thenReturn(List.of(c1, c2));
|
||||
when(blockMapper.selectListByChapterId(anyLong())).thenReturn(List.of());
|
||||
when(rendererFactory.getRenderer(ExportFormat.TXT)).thenReturn(txtRenderer);
|
||||
when(txtRenderer.render(any())).thenReturn("x".getBytes(StandardCharsets.UTF_8));
|
||||
when(fileApi.createFileForPath(any(), anyString(), anyString(), anyString())).thenReturn("100/x/abc.txt");
|
||||
|
||||
CreateExportTaskReqVO reqVO = txtReqVO();
|
||||
reqVO.setIncludeChapters(List.of(2002L)); // 仅导出第二章
|
||||
|
||||
facade.createExportPackage(USER_ID, WORK_ID, reqVO);
|
||||
|
||||
ArgumentCaptor<cn.iocoder.muse.module.content.application.export.render.ExportDocument> docCaptor =
|
||||
ArgumentCaptor.forClass(cn.iocoder.muse.module.content.application.export.render.ExportDocument.class);
|
||||
verify(txtRenderer).render(docCaptor.capture());
|
||||
// 只装配被指定的章节;未指定章不读其 Block。
|
||||
assertEquals(1, docCaptor.getValue().chapters().size());
|
||||
verify(blockMapper).selectListByChapterId(2002L);
|
||||
verify(blockMapper, never()).selectListByChapterId(2001L);
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user