renderers) {
+ for (DocumentRenderer renderer : renderers) {
+ rendererMap.put(renderer.getTargetFormat(), renderer);
+ }
+ }
+
+ /**
+ * 生成目标格式文件。
+ *
+ * @param request 原始导出请求
+ * @return 文件导出结果
+ */
+ public FileGenerationResult generate(FileGenerationRequest request) {
+ if (request == null) {
+ throw new BusinessException("文件导出请求不能为空");
+ }
+ if (request.getContent() == null) {
+ throw new BusinessException("文件导出内容不能为空");
+ }
+ TargetFormat targetFormat = TargetFormat.fromValue(request.getTargetFormat());
+ SourceFormat sourceFormat = SourceFormat.fromValue(request.getSourceFormat());
+ FileGenerationRules.requireSupportedCombination(sourceFormat, targetFormat);
+ DocumentRenderer renderer = rendererMap.get(targetFormat);
+ if (renderer == null) {
+ throw new BusinessException("未找到 " + targetFormat.getValue() + " 对应的文件渲染器");
+ }
+ if (!renderer.supports(sourceFormat)) {
+ throw new BusinessException("文件生成节点暂不支持 " + sourceFormat.getValue() + " -> " + targetFormat.getValue() + " 组合");
+ }
+ String normalizedTemplateStyle = FileGenerationRules.normalizeTemplateStyle(request.getTemplateStyle());
+ String normalizedFileName = FileNameSanitizer.sanitize(request.getFileName(), targetFormat);
+ FileGenerationRequest normalizedRequest = new FileGenerationRequest(
+ request.getContent(),
+ sourceFormat.getValue(),
+ targetFormat.getValue(),
+ normalizedFileName,
+ normalizedTemplateStyle
+ );
+ FileGenerationResult result = renderer.render(normalizedRequest);
+ if (result == null || !StringUtils.hasText(result.getFileName()) || result.getBytes() == null) {
+ throw new IllegalStateException("文件导出失败:渲染结果不完整");
+ }
+ return result;
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileNameSanitizer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileNameSanitizer.java
new file mode 100644
index 0000000..525026a
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/FileNameSanitizer.java
@@ -0,0 +1,54 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import cn.hutool.core.util.IdUtil;
+import org.springframework.util.StringUtils;
+
+/**
+ * 文件名清洗工具。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+public final class FileNameSanitizer {
+ private static final String INVALID_CHARS_PATTERN = "[\\\\/:*?\"<>|\\p{Cntrl}]";
+
+ private FileNameSanitizer() {
+ }
+
+ /**
+ * 清洗并补齐目标扩展名。
+ *
+ * @param rawFileName 原始文件名
+ * @param targetFormat 目标格式
+ * @return 清洗后的最终文件名
+ */
+ public static String sanitize(String rawFileName, TargetFormat targetFormat) {
+ String baseName = sanitizeBaseName(rawFileName);
+ if (!StringUtils.hasText(baseName)) {
+ baseName = "generated-file-" + IdUtil.fastSimpleUUID();
+ }
+ return removeTrailingExtension(baseName) + "." + targetFormat.getValue();
+ }
+
+ /**
+ * 清洗不含扩展名的文件名主体。
+ *
+ * @param rawFileName 原始文件名
+ * @return 清洗后的文件名主体
+ */
+ public static String sanitizeBaseName(String rawFileName) {
+ if (!StringUtils.hasText(rawFileName)) {
+ return null;
+ }
+ String sanitized = rawFileName.trim().replaceAll(INVALID_CHARS_PATTERN, "");
+ return sanitized.isBlank() ? null : sanitized;
+ }
+
+ private static String removeTrailingExtension(String fileName) {
+ int lastDot = fileName.lastIndexOf('.');
+ if (lastDot <= 0) {
+ return fileName;
+ }
+ return fileName.substring(0, lastDot);
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlDocumentBuilder.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlDocumentBuilder.java
new file mode 100644
index 0000000..5659fd6
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlDocumentBuilder.java
@@ -0,0 +1,153 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import org.jsoup.nodes.Entities;
+import org.springframework.stereotype.Component;
+
+import java.util.regex.Pattern;
+
+/**
+ * HTML 文档构建器。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+@Component
+public class HtmlDocumentBuilder {
+ private static final Pattern BLANK_LINE_PATTERN = Pattern.compile("\\n\\s*\\n");
+
+ private final MarkdownSupport markdownSupport;
+ private final HtmlSanitizer htmlSanitizer;
+
+ public HtmlDocumentBuilder(MarkdownSupport markdownSupport, HtmlSanitizer htmlSanitizer) {
+ this.markdownSupport = markdownSupport;
+ this.htmlSanitizer = htmlSanitizer;
+ }
+
+ /**
+ * 构建完整 HTML 文档。
+ *
+ * @param request 已归一化请求
+ * @return 完整 HTML 文档字符串
+ */
+ public String buildDocument(FileGenerationRequest request) {
+ String title = Entities.escape(request.getFileName());
+ String body = buildBody(request);
+ return """
+
+
+
+
+
+ __TITLE__
+
+
+
+
+ __BODY__
+
+
+
+ """
+ .replace("__TITLE__", title)
+ .replace("__BODY__", body);
+ }
+
+ private String buildBody(FileGenerationRequest request) {
+ SourceFormat sourceFormat = SourceFormat.fromValue(request.getSourceFormat());
+ return switch (sourceFormat) {
+ case PLAIN_TEXT -> plainTextToHtml(request.getContent());
+ case MARKDOWN -> htmlSanitizer.sanitize(markdownSupport.renderHtml(request.getContent()));
+ case HTML -> htmlSanitizer.sanitize(request.getContent());
+ };
+ }
+
+ private String plainTextToHtml(String content) {
+ String normalized = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n');
+ String[] paragraphs = BLANK_LINE_PATTERN.split(normalized, -1);
+ StringBuilder builder = new StringBuilder();
+ for (String paragraph : paragraphs) {
+ if (builder.length() > 0) {
+ builder.append('\n');
+ }
+ String escaped = Entities.escape(paragraph);
+ builder.append("")
+ .append(escaped.replace("\n", "
"))
+ .append("
");
+ }
+ if (builder.length() == 0) {
+ builder.append("");
+ }
+ return builder.toString();
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlFileRenderer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlFileRenderer.java
new file mode 100644
index 0000000..df39ecf
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlFileRenderer.java
@@ -0,0 +1,43 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * HTML 文件渲染器。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+@Component
+public class HtmlFileRenderer implements DocumentRenderer {
+ private final HtmlDocumentBuilder htmlDocumentBuilder;
+
+ public HtmlFileRenderer(HtmlDocumentBuilder htmlDocumentBuilder) {
+ this.htmlDocumentBuilder = htmlDocumentBuilder;
+ }
+
+ @Override
+ public TargetFormat getTargetFormat() {
+ return TargetFormat.HTML;
+ }
+
+ @Override
+ public boolean supports(SourceFormat sourceFormat) {
+ return sourceFormat == SourceFormat.PLAIN_TEXT
+ || sourceFormat == SourceFormat.MARKDOWN
+ || sourceFormat == SourceFormat.HTML;
+ }
+
+ @Override
+ public FileGenerationResult render(FileGenerationRequest request) {
+ byte[] bytes = htmlDocumentBuilder.buildDocument(request).getBytes(StandardCharsets.UTF_8);
+ return new FileGenerationResult(
+ request.getFileName(),
+ TargetFormat.HTML.getValue(),
+ TargetFormat.HTML.getContentType(),
+ bytes
+ );
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlSanitizer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlSanitizer.java
new file mode 100644
index 0000000..b67dcac
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/HtmlSanitizer.java
@@ -0,0 +1,42 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.safety.Safelist;
+import org.springframework.stereotype.Component;
+
+/**
+ * HTML 清洗组件。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+@Component
+public class HtmlSanitizer {
+ private final Safelist safelist;
+
+ public HtmlSanitizer() {
+ this.safelist = Safelist.relaxed()
+ .addTags("table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col")
+ .addAttributes("table", "border", "cellpadding", "cellspacing")
+ .addAttributes("th", "colspan", "rowspan", "scope", "align")
+ .addAttributes("td", "colspan", "rowspan", "align")
+ .addAttributes("col", "span", "width")
+ .addAttributes("colgroup", "span", "width")
+ .addProtocols("a", "href", "http", "https", "mailto");
+ this.safelist.removeTags("img", "style", "script", "iframe", "object", "embed", "form");
+ this.safelist.preserveRelativeLinks(true);
+ }
+
+ /**
+ * 清洗 HTML 片段。
+ *
+ * @param html 原始 HTML
+ * @return 清洗后的 HTML 片段
+ */
+ public String sanitize(String html) {
+ Document.OutputSettings outputSettings = new Document.OutputSettings();
+ outputSettings.prettyPrint(false);
+ return Jsoup.clean(html == null ? "" : html, "", safelist, outputSettings);
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownFileRenderer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownFileRenderer.java
new file mode 100644
index 0000000..3a445d8
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownFileRenderer.java
@@ -0,0 +1,36 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Markdown 文件渲染器。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+@Component
+public class MarkdownFileRenderer implements DocumentRenderer {
+
+ @Override
+ public TargetFormat getTargetFormat() {
+ return TargetFormat.MD;
+ }
+
+ @Override
+ public boolean supports(SourceFormat sourceFormat) {
+ return sourceFormat == SourceFormat.PLAIN_TEXT || sourceFormat == SourceFormat.MARKDOWN;
+ }
+
+ @Override
+ public FileGenerationResult render(FileGenerationRequest request) {
+ byte[] bytes = (request.getContent() == null ? "" : request.getContent()).getBytes(StandardCharsets.UTF_8);
+ return new FileGenerationResult(
+ request.getFileName(),
+ TargetFormat.MD.getValue(),
+ TargetFormat.MD.getContentType(),
+ bytes
+ );
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownSupport.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownSupport.java
new file mode 100644
index 0000000..abfdb68
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/MarkdownSupport.java
@@ -0,0 +1,54 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import org.commonmark.Extension;
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Markdown 解析与 HTML 渲染支持。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+@Component
+public class MarkdownSupport {
+ private final Parser parser;
+ private final HtmlRenderer htmlRenderer;
+
+ public MarkdownSupport() {
+ List extensions = List.of(TablesExtension.create());
+ this.parser = Parser.builder()
+ .extensions(extensions)
+ .build();
+ this.htmlRenderer = HtmlRenderer.builder()
+ .extensions(extensions)
+ .escapeHtml(true)
+ .sanitizeUrls(true)
+ .build();
+ }
+
+ /**
+ * 解析 Markdown 文本。
+ *
+ * @param markdown Markdown 内容
+ * @return AST 根节点
+ */
+ public Node parse(String markdown) {
+ return parser.parse(markdown == null ? "" : markdown);
+ }
+
+ /**
+ * 将 Markdown 渲染为安全 HTML 片段。
+ *
+ * @param markdown Markdown 内容
+ * @return HTML 片段
+ */
+ public String renderHtml(String markdown) {
+ return htmlRenderer.render(parse(markdown));
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/PdfFileRenderer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/PdfFileRenderer.java
new file mode 100644
index 0000000..0dd03db
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/PdfFileRenderer.java
@@ -0,0 +1,109 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import com.openhtmltopdf.extend.FSUriResolver;
+import com.openhtmltopdf.outputdevice.helper.ExternalResourceControlPriority;
+import com.openhtmltopdf.outputdevice.helper.ExternalResourceType;
+import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+
+/**
+ * PDF 文件渲染器。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+@Component
+public class PdfFileRenderer implements DocumentRenderer {
+ static final String FONT_RESOURCE_PATH = "fonts/SourceHanSansSC-VF.ttf";
+ static final String FONT_FAMILY = "EasyFlowPdfCjk";
+ private static final FSUriResolver BLOCK_ALL_URI_RESOLVER = PdfFileRenderer::resolveBlockedUri;
+
+ private final HtmlDocumentBuilder htmlDocumentBuilder;
+ private final File fontFile;
+
+ @Autowired
+ public PdfFileRenderer(HtmlDocumentBuilder htmlDocumentBuilder) {
+ this(htmlDocumentBuilder, FONT_RESOURCE_PATH);
+ }
+
+ PdfFileRenderer(HtmlDocumentBuilder htmlDocumentBuilder, String fontResourcePath) {
+ this.htmlDocumentBuilder = htmlDocumentBuilder;
+ this.fontFile = loadFont(fontResourcePath);
+ }
+
+ @Override
+ public TargetFormat getTargetFormat() {
+ return TargetFormat.PDF;
+ }
+
+ @Override
+ public boolean supports(SourceFormat sourceFormat) {
+ return sourceFormat == SourceFormat.PLAIN_TEXT
+ || sourceFormat == SourceFormat.MARKDOWN
+ || sourceFormat == SourceFormat.HTML;
+ }
+
+ @Override
+ public FileGenerationResult render(FileGenerationRequest request) {
+ String html = htmlDocumentBuilder.buildDocument(request);
+ try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+ PdfRendererBuilder builder = new PdfRendererBuilder();
+ builder.useFastMode();
+ builder.useFont(fontFile, FONT_FAMILY);
+ // 首版 PDF 只允许文本与内联样式,不允许解析任何外部或内嵌资源。
+ builder.useUriResolver(BLOCK_ALL_URI_RESOLVER);
+ builder.useExternalResourceAccessControl(
+ PdfFileRenderer::denyExternalResource,
+ ExternalResourceControlPriority.RUN_BEFORE_RESOLVING_URI
+ );
+ builder.useExternalResourceAccessControl(
+ PdfFileRenderer::denyExternalResource,
+ ExternalResourceControlPriority.RUN_AFTER_RESOLVING_URI
+ );
+ builder.withHtmlContent(html, fontFile.getParentFile().toURI().toString());
+ builder.toStream(outputStream);
+ builder.run();
+ return new FileGenerationResult(
+ request.getFileName(),
+ TargetFormat.PDF.getValue(),
+ TargetFormat.PDF.getContentType(),
+ outputStream.toByteArray()
+ );
+ } catch (Exception e) {
+ throw new IllegalStateException("PDF 导出失败: " + e.getMessage(), e);
+ }
+ }
+
+ static String resolveBlockedUri(String baseUri, String uri) {
+ return null;
+ }
+
+ static boolean denyExternalResource(String uri, ExternalResourceType type) {
+ return false;
+ }
+
+ private File loadFont(String fontResourcePath) {
+ ClassPathResource resource = new ClassPathResource(fontResourcePath);
+ if (!resource.exists()) {
+ throw new IllegalStateException("PDF 中文字体资源缺失: " + fontResourcePath);
+ }
+ try (InputStream inputStream = resource.getInputStream()) {
+ Path tempFile = Files.createTempFile("easyflow-pdf-font-", ".otf");
+ Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING);
+ tempFile.toFile().deleteOnExit();
+ return tempFile.toFile();
+ } catch (IOException e) {
+ throw new IllegalStateException("PDF 中文字体资源加载失败: " + fontResourcePath, e);
+ }
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/SourceFormat.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/SourceFormat.java
new file mode 100644
index 0000000..29e6bc8
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/SourceFormat.java
@@ -0,0 +1,45 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import org.springframework.util.StringUtils;
+import tech.easyflow.common.web.exceptions.BusinessException;
+
+import java.util.Arrays;
+
+/**
+ * 文件导出的输入内容格式。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+public enum SourceFormat {
+ PLAIN_TEXT("plain_text"),
+ MARKDOWN("markdown"),
+ HTML("html");
+
+ private final String value;
+
+ SourceFormat(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ /**
+ * 解析输入格式。
+ *
+ * @param value 原始值
+ * @return 输入格式枚举
+ */
+ public static SourceFormat fromValue(String value) {
+ if (!StringUtils.hasText(value)) {
+ throw new BusinessException("文件导出输入格式不能为空");
+ }
+ String normalized = value.trim().toLowerCase();
+ return Arrays.stream(values())
+ .filter(item -> item.value.equals(normalized))
+ .findFirst()
+ .orElseThrow(() -> new BusinessException("暂不支持的文件导出输入格式: " + value));
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/TargetFormat.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/TargetFormat.java
new file mode 100644
index 0000000..87c04cf
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/filegeneration/TargetFormat.java
@@ -0,0 +1,52 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import org.springframework.util.StringUtils;
+import tech.easyflow.common.web.exceptions.BusinessException;
+
+import java.util.Arrays;
+
+/**
+ * 文件导出的目标格式。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+public enum TargetFormat {
+ MD("md", "text/markdown"),
+ HTML("html", "text/html"),
+ PDF("pdf", "application/pdf"),
+ DOCX("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+
+ private final String value;
+ private final String contentType;
+
+ TargetFormat(String value, String contentType) {
+ this.value = value;
+ this.contentType = contentType;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public String getContentType() {
+ return contentType;
+ }
+
+ /**
+ * 解析目标格式。
+ *
+ * @param value 原始值
+ * @return 目标格式枚举
+ */
+ public static TargetFormat fromValue(String value) {
+ if (!StringUtils.hasText(value)) {
+ throw new BusinessException("文件导出目标格式不能为空");
+ }
+ String normalized = value.trim().toLowerCase();
+ return Arrays.stream(values())
+ .filter(item -> item.value.equals(normalized))
+ .findFirst()
+ .orElseThrow(() -> new BusinessException("暂不支持的文件导出目标格式: " + value));
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/resources/fonts/SourceHanSansSC-VF.ttf b/easyflow-modules/easyflow-module-ai/src/main/resources/fonts/SourceHanSansSC-VF.ttf
new file mode 100644
index 0000000..c8496a2
Binary files /dev/null and b/easyflow-modules/easyflow-module-ai/src/main/resources/fonts/SourceHanSansSC-VF.ttf differ
diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java
index bfa57bf..898a8e4 100644
--- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java
+++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java
@@ -8,6 +8,7 @@ import org.junit.Test;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.entity.Workflow;
+import tech.easyflow.ai.node.MakeFileNodeParser;
import tech.easyflow.ai.node.SearchDatasetNodeParser;
import tech.easyflow.ai.node.WorkflowNodeParser;
import tech.easyflow.ai.service.WorkflowService;
@@ -328,6 +329,37 @@ public class WorkflowCheckServiceTest {
assertHasCode(result, "START_FORM_OPTIONS_EMPTY");
}
+ @Test
+ public void testSaveShouldBlockInvalidMakeFileCombination() throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject data = data("文件生成");
+ data.put("sourceFormat", "html");
+ data.put("targetFormat", "docx");
+ String content = workflowJson(
+ array(node("mf1", "make-file", null, data)),
+ new JSONArray()
+ );
+
+ WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
+ Assert.assertFalse(result.isPassed());
+ assertHasCode(result, "MAKE_FILE_INVALID");
+ }
+
+ @Test
+ public void testSaveShouldPassForValidMakeFileCombination() throws Exception {
+ WorkflowCheckService service = newService(new HashMap<>());
+ JSONObject data = data("文件生成");
+ data.put("sourceFormat", "markdown");
+ data.put("targetFormat", "pdf");
+ String content = workflowJson(
+ array(node("mf1", "make-file", null, data)),
+ new JSONArray()
+ );
+
+ WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
+ Assert.assertTrue(result.isPassed());
+ }
+
private static WorkflowCheckService newService(Map workflowStore) throws Exception {
WorkflowCheckService service = new WorkflowCheckService();
ChainParser parser = ChainParser.builder()
@@ -335,6 +367,7 @@ public class WorkflowCheckServiceTest {
.build();
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
+ parser.addNodeParser("make-file", new MakeFileNodeParser());
setField(service, "chainParser", parser);
setField(service, "workflowService", mockWorkflowService(workflowStore));
setField(service, "workflowDatacenterContentService", new WorkflowDatacenterContentService());
diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeParserTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeParserTest.java
new file mode 100644
index 0000000..c5b4b08
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeParserTest.java
@@ -0,0 +1,58 @@
+package tech.easyflow.ai.node;
+
+import com.alibaba.fastjson.JSONObject;
+import com.easyagents.flow.core.node.BaseNode;
+import org.junit.Assert;
+import org.junit.Test;
+import tech.easyflow.common.web.exceptions.BusinessException;
+
+/**
+ * {@link MakeFileNodeParser} 单元测试。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+public class MakeFileNodeParserTest {
+
+ @Test
+ public void testShouldUseDefaults() {
+ MakeFileNodeParser parser = new MakeFileNodeParser();
+ MakeFileNode node = (MakeFileNode) parser.doParse(new JSONObject(), new JSONObject(), new JSONObject());
+
+ Assert.assertEquals("docx", node.getTargetFormat());
+ Assert.assertEquals("markdown", node.getSourceFormat());
+ Assert.assertEquals("default", node.getTemplateStyle());
+ Assert.assertNull(node.getFileName());
+ }
+
+ @Test
+ public void testShouldPreferTargetFormatAndTrimFileName() {
+ MakeFileNodeParser parser = new MakeFileNodeParser();
+ JSONObject data = new JSONObject();
+ data.put("targetFormat", "pdf");
+ data.put("sourceFormat", "markdown");
+ data.put("fileName", " 导出报告 ");
+ data.put("templateStyle", "custom");
+
+ BaseNode parsed = parser.doParse(new JSONObject(), data, new JSONObject());
+ MakeFileNode node = (MakeFileNode) parsed;
+ Assert.assertEquals("pdf", node.getTargetFormat());
+ Assert.assertEquals("markdown", node.getSourceFormat());
+ Assert.assertEquals("导出报告", node.getFileName());
+ Assert.assertEquals("default", node.getTemplateStyle());
+ }
+
+ @Test
+ public void testShouldRejectIllegalCombination() {
+ MakeFileNodeParser parser = new MakeFileNodeParser();
+ JSONObject data = new JSONObject();
+ data.put("sourceFormat", "html");
+ data.put("targetFormat", "md");
+
+ BusinessException exception = Assert.assertThrows(
+ BusinessException.class,
+ () -> parser.doParse(new JSONObject(), data, new JSONObject())
+ );
+ Assert.assertTrue(exception.getMessage().contains("html -> md"));
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeTest.java
new file mode 100644
index 0000000..aeba4c5
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/MakeFileNodeTest.java
@@ -0,0 +1,118 @@
+package tech.easyflow.ai.node;
+
+import com.easyagents.flow.core.chain.Chain;
+import com.easyagents.flow.core.chain.ChainDefinition;
+import com.easyagents.flow.core.chain.Parameter;
+import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
+import org.junit.Assert;
+import org.junit.Test;
+import org.springframework.context.ApplicationContext;
+import org.springframework.web.multipart.MultipartFile;
+import tech.easyflow.ai.node.filegeneration.FileGenerationService;
+import tech.easyflow.ai.node.filegeneration.MarkdownFileRenderer;
+import tech.easyflow.common.filestorage.FileStorageManager;
+import tech.easyflow.common.util.SpringContextUtil;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Proxy;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * {@link MakeFileNode} 单元测试。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+public class MakeFileNodeTest {
+
+ @Test
+ public void testExecuteShouldReturnUrl() throws Exception {
+ RecordingFileStorageManager storageManager = new RecordingFileStorageManager();
+ FileGenerationService generationService = new FileGenerationService(List.of(new MarkdownFileRenderer()));
+ ApplicationContext previousContext = getStaticField("applicationContext");
+ Object previousBeanFactory = getStaticField("beanFactory");
+ try {
+ setStaticField("beanFactory", null);
+ setStaticField("applicationContext", mockApplicationContext(generationService, storageManager));
+ MakeFileNode node = new MakeFileNode("md", "plain_text", "测试导出", "default");
+ node.setParameters(Collections.singletonList(new Parameter("content")));
+ Chain chain = createChain(Map.of("content", "hello"));
+
+ Map result = node.execute(chain);
+
+ Assert.assertEquals("https://example.com/generated.md", result.get("url"));
+ Assert.assertNotNull(storageManager.lastFile);
+ Assert.assertEquals("测试导出.md", storageManager.lastFile.getOriginalFilename());
+ } finally {
+ setStaticField("applicationContext", previousContext);
+ setStaticField("beanFactory", previousBeanFactory);
+ }
+ }
+
+ private static Chain createChain(Map memory) {
+ Chain chain = new Chain(new ChainDefinition(), UUID.randomUUID().toString());
+ chain.setChainStateRepository(new InMemoryChainStateRepository());
+ chain.getState().getMemory().putAll(memory);
+ return chain;
+ }
+
+ private static ApplicationContext mockApplicationContext(FileGenerationService service,
+ FileStorageManager storageManager) {
+ return (ApplicationContext) Proxy.newProxyInstance(
+ ApplicationContext.class.getClassLoader(),
+ new Class[]{ApplicationContext.class},
+ (proxy, method, args) -> {
+ if ("getBean".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof Class> clazz) {
+ if (clazz == FileGenerationService.class) {
+ return service;
+ }
+ if (clazz == FileStorageManager.class) {
+ return storageManager;
+ }
+ }
+ if ("equals".equals(method.getName())) {
+ return proxy == args[0];
+ }
+ if ("hashCode".equals(method.getName())) {
+ return System.identityHashCode(proxy);
+ }
+ if (method.getReturnType() == boolean.class) {
+ return false;
+ }
+ if (method.getReturnType() == int.class) {
+ return 0;
+ }
+ if (method.getReturnType() == long.class) {
+ return 0L;
+ }
+ return null;
+ }
+ );
+ }
+
+ @SuppressWarnings("unchecked")
+ private static T getStaticField(String fieldName) throws Exception {
+ Field field = SpringContextUtil.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return (T) field.get(null);
+ }
+
+ private static void setStaticField(String fieldName, Object value) throws Exception {
+ Field field = SpringContextUtil.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(null, value);
+ }
+
+ private static class RecordingFileStorageManager extends FileStorageManager {
+ private MultipartFile lastFile;
+
+ @Override
+ public String save(MultipartFile file) {
+ this.lastFile = file;
+ return "https://example.com/generated.md";
+ }
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileGenerationServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileGenerationServiceTest.java
new file mode 100644
index 0000000..11f73c9
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileGenerationServiceTest.java
@@ -0,0 +1,60 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import org.junit.Assert;
+import org.junit.Test;
+import tech.easyflow.common.web.exceptions.BusinessException;
+
+import java.util.List;
+
+/**
+ * {@link FileGenerationService} 单元测试。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+public class FileGenerationServiceTest {
+
+ @Test
+ public void testShouldSanitizeFileNameAndOverrideExtension() {
+ FileGenerationService service = new FileGenerationService(List.of(new MarkdownFileRenderer()));
+ FileGenerationResult result = service.generate(new FileGenerationRequest(
+ "demo",
+ "plain_text",
+ "md",
+ " a/b.docx ",
+ "custom"
+ ));
+
+ Assert.assertEquals("ab.md", result.getFileName());
+ Assert.assertEquals("md", result.getTargetFormat());
+ Assert.assertEquals("text/markdown", result.getContentType());
+ }
+
+ @Test
+ public void testShouldFallbackToGeneratedFileName() {
+ FileGenerationService service = new FileGenerationService(List.of(new MarkdownFileRenderer()));
+ FileGenerationResult result = service.generate(new FileGenerationRequest(
+ "",
+ "markdown",
+ "md",
+ " / ",
+ "default"
+ ));
+
+ Assert.assertTrue(result.getFileName().startsWith("generated-file-"));
+ Assert.assertTrue(result.getFileName().endsWith(".md"));
+ Assert.assertEquals(0, result.getSize());
+ }
+
+ @Test
+ public void testShouldRejectHtmlToDocx() {
+ FileGenerationService service = new FileGenerationService(List.of(new DocxFileRenderer(new MarkdownSupport())));
+
+ BusinessException exception = Assert.assertThrows(
+ BusinessException.class,
+ () -> service.generate(new FileGenerationRequest("html", "html", "docx", "demo", "default"))
+ );
+
+ Assert.assertTrue(exception.getMessage().contains("html -> docx"));
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileRendererTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileRendererTest.java
new file mode 100644
index 0000000..53c6ce4
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/filegeneration/FileRendererTest.java
@@ -0,0 +1,189 @@
+package tech.easyflow.ai.node.filegeneration;
+
+import com.openhtmltopdf.outputdevice.helper.ExternalResourceType;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+
+/**
+ * 文档渲染器单元测试。
+ *
+ * @author Codex
+ * @since 2026-04-18
+ */
+public class FileRendererTest {
+
+ @Test
+ public void testMarkdownRendererShouldSupportPlainTextAndMarkdown() {
+ MarkdownFileRenderer renderer = new MarkdownFileRenderer();
+ FileGenerationResult plain = renderer.render(new FileGenerationRequest(
+ "hello",
+ "plain_text",
+ "md",
+ "demo.md",
+ "default"
+ ));
+ FileGenerationResult markdown = renderer.render(new FileGenerationRequest(
+ "# title",
+ "markdown",
+ "md",
+ "demo.md",
+ "default"
+ ));
+
+ Assert.assertEquals("hello", new String(plain.getBytes()));
+ Assert.assertEquals("# title", new String(markdown.getBytes()));
+ }
+
+ @Test
+ public void testHtmlRendererShouldSanitizeDangerousHtml() {
+ HtmlFileRenderer renderer = new HtmlFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()));
+ FileGenerationResult result = renderer.render(new FileGenerationRequest(
+ "",
+ "html",
+ "html",
+ "demo.html",
+ "default"
+ ));
+ String html = new String(result.getBytes());
+
+ Assert.assertTrue(html.contains(""));
+ Assert.assertFalse(html.contains("