feat(content): 导出渲染器端口 + txt 实现 (U3)

ExportDocument/ExportChapter DTO + ExportFormat(txt/docx/epub MIME/ext) + ExportRenderer 端口 + TxtExportRenderer(纯 JDK,UTF-8) + ExportRendererFactory(EnumMap,未注册格式 fail-fast)。端口可扩展:U4 加 docx/epub 只需 @Component。11 单测绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-19 00:16:44 -07:00
parent 9eb280615e
commit 18b7c12417
8 changed files with 441 additions and 0 deletions

View File

@ -0,0 +1,13 @@
package cn.iocoder.muse.module.content.application.export.render;
/**
* 导出渲染输入模型单章节的纯 DTO
*
* <p>{@code body} 为该章 canonical 正文 facade 把章下场景/小节级 Block 的正文按 orderNo
* 拼装而成架构为场景/小节级 Block渲染器不感知 Block 粒度只渲染章标题 + 整章正文</p>
*
* @param title 章节标题可为空
* @param body 章节正文可为空空正文章只渲染标题
*/
public record ExportChapter(String title, String body) {
}

View File

@ -0,0 +1,17 @@
package cn.iocoder.muse.module.content.application.export.render;
import java.util.List;
/**
* 导出渲染输入模型作品级 canonical 正文的纯 DTO
*
* <p>刻意与 content DAL/domain{@code WorkDO}/{@code ChapterDO}/{@code BlockDO}解耦
* 渲染器是纯函数只认这个 DTO facadeU5a负责把 canonical 正文映射进来
* {@code title} 取自作品标题{@code chapters} 由章节按 orderNo 排序章下场景/小节 Block
* {@code contentText} 拼装为各章 body</p>
*
* @param title 作品标题可为空空文档场景
* @param chapters 章节列表按导出顺序排列不应为 {@code null}空作品传空列表
*/
public record ExportDocument(String title, List<ExportChapter> chapters) {
}

View File

@ -0,0 +1,52 @@
package cn.iocoder.muse.module.content.application.export.render;
/**
* Content 导出格式枚举
*
* <p>每个值携带 MIME 类型与文件扩展名 facade 构造存储 name/Content-Type下载时回填
* Content-Disposition扩展名小写与调用方 {@code SUPPORTED_EXPORT_FORMATS}"txt"/"docx"/"epub"一致
* 便于由请求 format 字符串映射到本枚举</p>
*
* <p>本单元U3仅实现 TXT 渲染DOCX/EPUB 枚举值先就位渲染实现由 U4 挂载</p>
*/
public enum ExportFormat {
/**
* 纯文本UTF-8
*/
TXT("text/plain; charset=UTF-8", "txt"),
/**
* Word 文档OOXML
*/
DOCX("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"),
/**
* EPUB 电子书
*/
EPUB("application/epub+zip", "epub");
/**
* MIME 类型用于存储元数据与下载响应 Content-Type
*/
private final String mimeType;
/**
* 文件扩展名不含点号小写
*/
private final String fileExtension;
ExportFormat(String mimeType, String fileExtension) {
this.mimeType = mimeType;
this.fileExtension = fileExtension;
}
public String getMimeType() {
return mimeType;
}
public String getFileExtension() {
return fileExtension;
}
}

View File

@ -0,0 +1,27 @@
package cn.iocoder.muse.module.content.application.export.render;
/**
* 导出渲染器端口按格式把作品 canonical 正文渲染成包字节
*
* <p>纯函数契约实现必须无副作用<b>不得</b>触达存储数据库或 Spring 上下文不注入 {@code @Resource}
* 输入只认 {@link ExportDocument} DTO输出为整包字节这样渲染逻辑可被纯单测覆盖
* 也便于 U4 在不改 facade/工厂选择逻辑的前提下挂载 DOCX/EPUB 实现</p>
*/
public interface ExportRenderer {
/**
* 把作品文档渲染为导出包字节
*
* @param document 作品 canonical 正文 DTO {@code null}
* @return 包字节 {@code null}空文档可返回空字节但不得为 {@code null}
*/
byte[] render(ExportDocument document);
/**
* 本渲染器支持的格式供工厂按格式索引
*
* @return {@code null} 的导出格式
*/
ExportFormat format();
}

View File

@ -0,0 +1,57 @@
package cn.iocoder.muse.module.content.application.export.render;
import org.springframework.stereotype.Component;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* 导出渲染器工厂 {@link ExportFormat} 选择渲染器
*
* <p>选择逻辑与具体格式解耦构造期注入所有 {@link ExportRenderer} bean按各自 {@code format()}
* 建立索引U4 新增 DOCX/EPUB 渲染器只需声明为 {@code @Component}无需改动本类这是端口可扩展的关键</p>
*
* <p>未注册格式 fail-fast {@link IllegalArgumentException}绝不静默返回空包避免下游拿到空字节假成功
* 选择逻辑为纯函数不触达存储/DB单测可脱离 Spring 直接 new 本类验证</p>
*/
@Component
public class ExportRendererFactory {
/**
* 格式 -> 渲染器索引构造期一次性建立之后只读
*/
private final Map<ExportFormat, ExportRenderer> renderers = new EnumMap<>(ExportFormat.class);
/**
* @param renderers Spring 注入的全部渲染器实现本单元仅含 TXTU4 追加 DOCX/EPUB
*/
public ExportRendererFactory(List<ExportRenderer> renderers) {
for (ExportRenderer renderer : renderers) {
ExportFormat format = renderer.format();
// 同格式重复注册视为装配错误启动期即暴露防止 U4 误装两个同格式渲染器
ExportRenderer existing = this.renderers.putIfAbsent(format, renderer);
if (existing != null) {
throw new IllegalStateException("导出格式 " + format + " 存在重复渲染器注册:"
+ existing.getClass().getName() + "" + renderer.getClass().getName());
}
}
}
/**
* 按格式取渲染器
*
* @param format 目标导出格式 {@code null}
* @return 对应渲染器
* @throws IllegalArgumentException 该格式未注册渲染器
*/
public ExportRenderer getRenderer(ExportFormat format) {
ExportRenderer renderer = renderers.get(format);
if (renderer == null) {
// 明确报错而非返回空包便于排障并阻断假成功
throw new IllegalArgumentException("不支持的导出格式:" + format + "(未注册对应渲染器)");
}
return renderer;
}
}

View File

@ -0,0 +1,71 @@
package cn.iocoder.muse.module.content.application.export.render;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* 纯文本txt导出渲染器 JDKUTF-8
*
* <p>装配结构作品标题置于文首其后按入参顺序逐章拼装章标题 + 章正文
* 以空行分隔各章保证结构清晰章节顺序与正文归属可读无副作用不触达存储/DB/Spring 上下文</p>
*
* <p>边界约定标题为空且无章节时返回空字节无可渲染内容空正文章只渲染章标题</p>
*/
@Component
public class TxtExportRenderer implements ExportRenderer {
/**
* 章节之间的分隔空行使正文层次清晰
*/
private static final String CHAPTER_SEPARATOR = "\n\n";
@Override
public byte[] render(ExportDocument document) {
StringBuilder sb = new StringBuilder();
// 1. 作品标题置于文首标题为空则跳过不留占位
String title = document.title();
if (title != null && !title.isBlank()) {
sb.append(title).append(CHAPTER_SEPARATOR);
}
// 2. 逐章拼装章标题换行后接整章正文保持入参顺序
List<ExportChapter> chapters = document.chapters();
if (chapters != null) {
for (ExportChapter chapter : chapters) {
appendChapter(sb, chapter);
}
}
// 3. UTF-8 编码输出中文不乱码
return sb.toString().getBytes(StandardCharsets.UTF_8);
}
/**
* 渲染单章章标题后换行再接正文章末以空行收尾
*/
private void appendChapter(StringBuilder sb, ExportChapter chapter) {
if (chapter == null) {
return;
}
String chapterTitle = chapter.title();
if (chapterTitle != null && !chapterTitle.isBlank()) {
// 章标题独占一行
sb.append(chapterTitle).append('\n');
}
String body = chapter.body();
if (body != null && !body.isEmpty()) {
sb.append(body);
}
// 章末空行分隔下一章
sb.append(CHAPTER_SEPARATOR);
}
@Override
public ExportFormat format() {
return ExportFormat.TXT;
}
}

View File

@ -0,0 +1,60 @@
package cn.iocoder.muse.module.content.application.export.render;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@link ExportRendererFactory} 单元测试按格式选择渲染器未注册格式须明确报错
*
* <p>本单元仅注册 TXTDOCX/EPUB 未注册时应抛清晰异常不静默返回空包验证 U4 加格式无需改选择逻辑</p>
*/
class ExportRendererFactoryTest {
@Test
@DisplayName("已注册格式:返回对应渲染器实例")
void getRenderer_registeredFormat_returnsRenderer() {
TxtExportRenderer txt = new TxtExportRenderer();
ExportRendererFactory factory = new ExportRendererFactory(List.of(txt));
ExportRenderer renderer = factory.getRenderer(ExportFormat.TXT);
assertSame(txt, renderer, "应返回注册的同一实例");
assertEquals(ExportFormat.TXT, renderer.format());
}
@Test
@DisplayName("未注册格式DOCX抛 IllegalArgumentException、消息含格式名不静默返回")
void getRenderer_unregisteredFormat_throws() {
ExportRendererFactory factory = new ExportRendererFactory(List.of(new TxtExportRenderer()));
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> factory.getRenderer(ExportFormat.DOCX));
// 异常消息须可定位到具体格式便于排障
assertTrue(ex.getMessage().contains("DOCX"), "异常消息应包含未支持的格式名");
}
@Test
@DisplayName("未注册格式EPUB同样抛清晰异常")
void getRenderer_unregisteredEpub_throws() {
ExportRendererFactory factory = new ExportRendererFactory(List.of(new TxtExportRenderer()));
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> factory.getRenderer(ExportFormat.EPUB));
assertTrue(ex.getMessage().contains("EPUB"));
}
@Test
@DisplayName("重复注册同一格式:构造期即抛异常(防止 U4 误装配两个同格式渲染器)")
void construct_duplicateFormat_throws() {
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> new ExportRendererFactory(List.of(new TxtExportRenderer(), new TxtExportRenderer())));
assertTrue(ex.getMessage().contains("TXT"), "重复注册异常应指明冲突格式");
}
}

View File

@ -0,0 +1,144 @@
package cn.iocoder.muse.module.content.application.export.render;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@link TxtExportRenderer} 单元测试 JDK 渲染不依赖 Spring/DB/存储
*
* <p>覆盖计划 U3 场景多章节顺序拼装换行/标题结构UTF-8含中文边界/单章/超长格式声明</p>
*/
class TxtExportRendererTest {
private final TxtExportRenderer renderer = new TxtExportRenderer();
@Test
@DisplayName("format() 声明为 TXT")
void format_returnsTxt() {
assertEquals(ExportFormat.TXT, renderer.format());
}
@Test
@DisplayName("多章节按入参顺序拼装标题与正文结构正确UTF-8 含中文")
void render_multiChapter_orderedAssemblyWithCjk() {
// 构造含中文的多章节文档验证顺序与编码
ExportDocument document = new ExportDocument("我的长篇作品", List.of(
new ExportChapter("第一章 启程", "夜色降临,旅人踏上征途。"),
new ExportChapter("第二章 抉择", "他在岔路口停下脚步。")
));
byte[] bytes = renderer.render(document);
assertNotNull(bytes);
assertTrue(bytes.length > 0);
// 必须以 UTF-8 解码校验中文不乱码
String text = new String(bytes, StandardCharsets.UTF_8);
// 作品标题在最前
assertTrue(text.startsWith("我的长篇作品"), "作品标题应位于文首");
// 两章标题与正文都存在
assertTrue(text.contains("第一章 启程"));
assertTrue(text.contains("夜色降临,旅人踏上征途。"));
assertTrue(text.contains("第二章 抉择"));
assertTrue(text.contains("他在岔路口停下脚步。"));
// 章节顺序第一章必须出现在第二章之前
assertTrue(text.indexOf("第一章 启程") < text.indexOf("第二章 抉择"), "章节顺序须保持");
// 正文紧随对应标题之后
assertTrue(text.indexOf("第一章 启程") < text.indexOf("夜色降临,旅人踏上征途。"));
assertTrue(text.indexOf("夜色降临,旅人踏上征途。") < text.indexOf("第二章 抉择"));
// 标题与正文之间章节之间须有换行分隔结构清晰
assertTrue(text.contains("\n"), "应包含换行分隔");
assertTrue(text.contains("第一章 启程\n"), "章标题后须换行");
}
@Test
@DisplayName("单章节:正常渲染、非空字节")
void render_singleChapter_nonEmpty() {
ExportDocument document = new ExportDocument("短篇", List.of(
new ExportChapter("唯一章", "正文内容。")
));
byte[] bytes = renderer.render(document);
assertNotNull(bytes);
assertTrue(bytes.length > 0);
String text = new String(bytes, StandardCharsets.UTF_8);
assertTrue(text.contains("短篇"));
assertTrue(text.contains("唯一章"));
assertTrue(text.contains("正文内容。"));
}
@Test
@DisplayName("空文档:无章节、标题为空 -> 不抛异常(允许空或仅头部,本实现返回空字节)")
void render_emptyDocument_noException() {
// 约定标题为空且无章节时返回空字节无内容可渲染但绝不抛异常
ExportDocument document = new ExportDocument(null, List.of());
byte[] bytes = renderer.render(document);
assertNotNull(bytes, "空文档也不应返回 null");
assertEquals(0, bytes.length, "无标题无章节时本实现约定返回空字节");
}
@Test
@DisplayName("仅有标题、无章节:渲染标题、非空字节")
void render_titleOnly_headerOnly() {
ExportDocument document = new ExportDocument("仅标题作品", List.of());
byte[] bytes = renderer.render(document);
assertNotNull(bytes);
assertTrue(bytes.length > 0, "有标题时应至少渲染标题");
String text = new String(bytes, StandardCharsets.UTF_8);
assertTrue(text.contains("仅标题作品"));
}
@Test
@DisplayName("超长正文:不抛异常、字节非空且包含全部内容")
void render_veryLongBody_noException() {
// 构造约 200K 字符的正文验证大文本无异常
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100_000; i++) {
sb.append("");
}
String longBody = sb.toString();
ExportDocument document = new ExportDocument("巨著", List.of(
new ExportChapter("长章", longBody)
));
byte[] bytes = renderer.render(document);
assertNotNull(bytes);
// 中文 UTF-8 每字符 3 字节正文部分应远大于 0
assertTrue(bytes.length > longBody.length(), "超长正文须完整渲染");
String text = new String(bytes, StandardCharsets.UTF_8);
assertTrue(text.contains(longBody), "超长正文内容须完整保留");
}
@Test
@DisplayName("章节正文为空:仅渲染章标题、不抛异常")
void render_chapterWithBlankBody_noException() {
ExportDocument document = new ExportDocument("作品", List.of(
new ExportChapter("空正文章", null),
new ExportChapter("有正文章", "内容。")
));
byte[] bytes = renderer.render(document);
assertNotNull(bytes);
assertTrue(bytes.length > 0);
String text = new String(bytes, StandardCharsets.UTF_8);
assertTrue(text.contains("空正文章"));
assertTrue(text.contains("有正文章"));
assertTrue(text.contains("内容。"));
}
}