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:
parent
9eb280615e
commit
18b7c12417
@ -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) {
|
||||
}
|
||||
@ -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;由 facade(U5a)负责把 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) {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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();
|
||||
|
||||
}
|
||||
@ -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 注入的全部渲染器实现(本单元仅含 TXT,U4 追加 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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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)导出渲染器——纯 JDK,UTF-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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>本单元仅注册 TXT;DOCX/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"), "重复注册异常应指明冲突格式");
|
||||
}
|
||||
|
||||
}
|
||||
@ -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("内容。"));
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user