diff --git a/docs/openapi.yaml b/docs/openapi.yaml
new file mode 100644
index 0000000..d96791b
--- /dev/null
+++ b/docs/openapi.yaml
@@ -0,0 +1,138 @@
+openapi: "3.1.0"
+info:
+ title: GMP Mock Service API
+ description: |
+ Mock 服务,提供基于 Word 模板的文档导出功能。
+ 模板使用 poi-tl 的 `{{variable}}` 语法标记占位符,
+ 业务数据通过 `businessData` 传入。
+ version: "1.0.0"
+ contact:
+ name: closein
+servers:
+ - url: http://localhost:6666
+ description: 本地开发服务器
+
+paths:
+ /api/docx/export:
+ post:
+ summary: 导出 Word 文档
+ description: |
+ 根据模板 ID 选择模板,填充业务数据后流式返回 `.docx` 文件。
+
+ 请求成功时直接流式下载文件,失败时返回 JSON 错误体。
+ operationId: exportDocx
+ tags:
+ - Docx Export
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DocxExportRequest'
+ example:
+ exportId: "annualReport"
+ businessData: {}
+ responses:
+ "200":
+ description: 成功 — 流式下载 `.docx` 文件
+ content:
+ application/vnd.openxmlformats-officedocument.wordprocessingml.document:
+ schema:
+ type: string
+ format: binary
+ headers:
+ Content-Disposition:
+ schema:
+ type: string
+ example: 'attachment; filename="annualReport.docx"'
+ description: 文件名,格式为 `attachment; filename="<模板名>.docx"`
+ "400":
+ description: 参数校验失败
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiError'
+ example:
+ code: 400
+ msg: "exportId 不能为空; businessData 不能为空"
+ "404":
+ description: 模板不存在
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiError'
+ example:
+ code: 404
+ msg: "模板不存在: unknownTemplate"
+ "500":
+ description: 服务器内部错误(文档生成失败)
+
+components:
+ schemas:
+ DocxExportRequest:
+ type: object
+ required:
+ - exportId
+ - businessData
+ properties:
+ exportId:
+ type: string
+ description: 模板标识,例如 `"annualReport"`
+ example: "annualReport"
+ businessData:
+ type: object
+ description: |
+ 业务数据键值对,用于填充模板中的变量。
+ 当前模板自行定义默认值,后续可改为从此处读取。
+ example: {}
+ additionalProperties: false
+
+ ApiError:
+ type: object
+ required:
+ - code
+ - msg
+ properties:
+ code:
+ type: integer
+ description: 错误码
+ example: 400
+ msg:
+ type: string
+ description: 错误描述
+ example: "exportId 不能为空"
+
+ # --- 模板定义(非 OpenAPI 标准,用于文档参考) ---
+
+ Template:
+ type: object
+ description: 可用模板
+ properties:
+ exportId:
+ type: string
+ description: 模板标识
+ example: "annualReport"
+ fileName:
+ type: string
+ description: 生成的文件名
+ example: "annualReport.docx"
+ variables:
+ type: array
+ description: 模板中可用的占位符变量
+ items:
+ type: string
+ example:
+ - "{{title}}"
+ - "{{companyName}}"
+ - "{{reportPeriod}}"
+ - "{{generatedDate}}"
+
+x-templates:
+ - exportId: "annualReport"
+ fileName: "annualReport.docx"
+ description: 年度工作报告模板,包含标题、公司信息、表格(3 行数据)
+ variables:
+ - title
+ - companyName
+ - reportPeriod
+ - generatedDate
diff --git a/pom.xml b/pom.xml
index 879a6ce..ad7ebcc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,6 +21,7 @@
25
5.4.0
+ 1.12.2
@@ -40,6 +41,12 @@
${poi.version}
+
+ com.deepoove
+ poi-tl
+ ${poi-tl.version}
+
+
org.springframework.boot
spring-boot-starter-test
diff --git a/src/main/java/com/gmp/mock/service/DocxService.java b/src/main/java/com/gmp/mock/service/DocxService.java
index 7bbbdbe..8c3ea79 100644
--- a/src/main/java/com/gmp/mock/service/DocxService.java
+++ b/src/main/java/com/gmp/mock/service/DocxService.java
@@ -2,11 +2,11 @@ package com.gmp.mock.service;
import java.io.IOException;
import java.io.OutputStream;
-import java.util.function.Consumer;
-import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.springframework.stereotype.Service;
+import com.deepoove.poi.XWPFTemplate;
+import com.deepoove.poi.xwpf.NiceXWPFDocument;
import com.gmp.mock.template.TemplateProvider;
import com.gmp.mock.template.TemplateRegistry;
@@ -19,23 +19,29 @@ public class DocxService {
this.templateRegistry = templateRegistry;
}
- /**
- * @return ExportResult containing file name and a writer that writes directly to an OutputStream
- * @throws TemplateNotFoundException if templateId is not registered
- */
public ExportResult export(String exportId) {
TemplateProvider provider = templateRegistry.get(exportId);
if (provider == null) {
throw new TemplateNotFoundException(exportId);
}
- // Return a writer callback instead of buffering everything in memory.
- // The document is generated and written directly to the HTTP response stream.
ExportFileWriter writer = outputStream -> {
- try (XWPFDocument document = new XWPFDocument()) {
- provider.generate(document);
- document.write(outputStream);
- outputStream.flush();
+ try {
+ // 1. poi-tl: compile template and render text placeholders
+ XWPFTemplate template = XWPFTemplate.compile(provider.getTemplateResource());
+ template.render(provider.getDataModel());
+
+ // 2. Get the underlying document for post-processing
+ NiceXWPFDocument doc = template.getXWPFDocument();
+
+ // 3. Post-process (e.g. add table rows)
+ provider.postProcess(doc);
+
+ // 4. Write to output stream
+ template.write(outputStream);
+ template.close();
+ } catch (IOException e) {
+ throw new RuntimeException("生成文档失败", e);
}
};
diff --git a/src/main/java/com/gmp/mock/template/TemplateProvider.java b/src/main/java/com/gmp/mock/template/TemplateProvider.java
index 6a034fb..c0e14ab 100644
--- a/src/main/java/com/gmp/mock/template/TemplateProvider.java
+++ b/src/main/java/com/gmp/mock/template/TemplateProvider.java
@@ -1,6 +1,8 @@
package com.gmp.mock.template;
import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
@@ -10,5 +12,30 @@ public interface TemplateProvider {
String getFileName();
- void generate(XWPFDocument document) throws IOException;
+ /**
+ * @return InputStream pointing to the .docx template file
+ */
+ InputStream getTemplateResource();
+
+ /**
+ * @return data model for poi-tl template rendering
+ */
+ Map getDataModel();
+
+ /**
+ * Post-processing hook called after poi-tl rendering.
+ * Override to add dynamic content (e.g. table rows) that can't
+ * be expressed with simple {{variable}} placeholders.
+ */
+ default void postProcess(XWPFDocument document) {
+ // no-op
+ }
+
+ /**
+ * Old generate method — no longer needed when using poi-tl.
+ * Kept for backward compatibility; can be removed once all templates migrate.
+ */
+ default void generate(XWPFDocument document) throws IOException {
+ throw new UnsupportedOperationException("Use poi-tl template instead");
+ }
}
diff --git a/src/main/java/com/gmp/mock/template/annualReport/AnnualReportProvider.java b/src/main/java/com/gmp/mock/template/annualReport/AnnualReportProvider.java
index 614268a..dbb7e16 100644
--- a/src/main/java/com/gmp/mock/template/annualReport/AnnualReportProvider.java
+++ b/src/main/java/com/gmp/mock/template/annualReport/AnnualReportProvider.java
@@ -1,37 +1,37 @@
package com.gmp.mock.template.annualReport;
-import java.io.IOException;
import java.io.InputStream;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
+import java.util.List;
import java.util.Map;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-import java.util.zip.ZipOutputStream;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
+import com.deepoove.poi.XWPFTemplate;
import com.gmp.mock.template.TemplateProvider;
+/**
+ * poi-tl based template provider.
+ *
+ * Simple text placeholders ({{title}}, {{companyName}}, etc.) are handled
+ * by poi-tl directly. Table rows are built programmatically with POI API
+ * after poi-tl rendering, because poi-tl's table loop syntax can be
+ * finicky with raw XML templates.
+ *
+ * To edit the template: open the .docx in Word, write {{placeholders}}.
+ */
@Component
public class AnnualReportProvider implements TemplateProvider {
- private static final Map DEFAULT_VARS = Map.ofEntries(
- Map.entry("title", "2025 年度工作报告"),
- Map.entry("companyName", "示例科技有限公司"),
- Map.entry("reportPeriod", "2025年1月 - 2025年12月"),
- Map.entry("metric1Name", "营业收入"),
- Map.entry("metric1Value", "¥ 1,234,567"),
- Map.entry("metric1Change","+15.3%"),
- Map.entry("metric2Name", "净利润"),
- Map.entry("metric2Value", "¥ 321,098"),
- Map.entry("metric2Change","+8.7%"),
- Map.entry("generatedDate",
- LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")))
+ private static final List