Archived
switch to poi-tl for template rendering, add OpenAPI spec
- Replace manual DOCX ZIP manipulation with poi-tl library
- Templates use {{variable}} syntax, editable in Word
- Add postProcess hook for dynamic content (table rows)
- Clean up unused generate() method from TemplateProvider
- Remove custom ExportResult/ExportFileWriter complexity
- Add docs/openapi.yaml — OpenAPI 3.1 spec for export API
This commit is contained in:
@@ -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
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<java.version>25</java.version>
|
<java.version>25</java.version>
|
||||||
<poi.version>5.4.0</poi.version>
|
<poi.version>5.4.0</poi.version>
|
||||||
|
<poi-tl.version>1.12.2</poi-tl.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -40,6 +41,12 @@
|
|||||||
<version>${poi.version}</version>
|
<version>${poi.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.deepoove</groupId>
|
||||||
|
<artifactId>poi-tl</artifactId>
|
||||||
|
<version>${poi-tl.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ package com.gmp.mock.service;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
|
||||||
import org.springframework.stereotype.Service;
|
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.TemplateProvider;
|
||||||
import com.gmp.mock.template.TemplateRegistry;
|
import com.gmp.mock.template.TemplateRegistry;
|
||||||
|
|
||||||
@@ -19,23 +19,29 @@ public class DocxService {
|
|||||||
this.templateRegistry = templateRegistry;
|
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) {
|
public ExportResult export(String exportId) {
|
||||||
TemplateProvider provider = templateRegistry.get(exportId);
|
TemplateProvider provider = templateRegistry.get(exportId);
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
throw new TemplateNotFoundException(exportId);
|
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 -> {
|
ExportFileWriter writer = outputStream -> {
|
||||||
try (XWPFDocument document = new XWPFDocument()) {
|
try {
|
||||||
provider.generate(document);
|
// 1. poi-tl: compile template and render text placeholders
|
||||||
document.write(outputStream);
|
XWPFTemplate template = XWPFTemplate.compile(provider.getTemplateResource());
|
||||||
outputStream.flush();
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.gmp.mock.template;
|
package com.gmp.mock.template;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||||
|
|
||||||
@@ -10,5 +12,30 @@ public interface TemplateProvider {
|
|||||||
|
|
||||||
String getFileName();
|
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<String, Object> 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
package com.gmp.mock.template.annualReport;
|
package com.gmp.mock.template.annualReport;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
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.XWPFDocument;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFTable;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.deepoove.poi.XWPFTemplate;
|
||||||
import com.gmp.mock.template.TemplateProvider;
|
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
|
@Component
|
||||||
public class AnnualReportProvider implements TemplateProvider {
|
public class AnnualReportProvider implements TemplateProvider {
|
||||||
|
|
||||||
private static final Map<String, String> DEFAULT_VARS = Map.ofEntries(
|
private static final List<Map<String, String>> METRICS = List.of(
|
||||||
Map.entry("title", "2025 年度工作报告"),
|
Map.of("name", "营业收入", "value", "¥ 1,234,567", "change", "+15.3%"),
|
||||||
Map.entry("companyName", "示例科技有限公司"),
|
Map.of("name", "净利润", "value", "¥ 321,098", "change", "+8.7%"),
|
||||||
Map.entry("reportPeriod", "2025年1月 - 2025年12月"),
|
Map.of("name", "毛利率", "value", "45.2%", "change", "+2.1%")
|
||||||
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日")))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -45,56 +45,37 @@ public class AnnualReportProvider implements TemplateProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void generate(XWPFDocument document) throws IOException {
|
public InputStream getTemplateResource() {
|
||||||
|
try {
|
||||||
ClassPathResource resource = new ClassPathResource("templates/annualReportTemplate.docx");
|
ClassPathResource resource = new ClassPathResource("templates/annualReportTemplate.docx");
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
throw new IOException("模板文件未找到: templates/annualReportTemplate.docx");
|
throw new RuntimeException("模板文件未找到: templates/annualReportTemplate.docx");
|
||||||
}
|
|
||||||
|
|
||||||
byte[] templateBytes;
|
|
||||||
try (InputStream is = resource.getInputStream()) {
|
|
||||||
templateBytes = is.readAllBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace ${placeholders} in word/document.xml inside the ZIP
|
|
||||||
byte[] modified = replacePlaceholdersInDocx(templateBytes);
|
|
||||||
|
|
||||||
// Read the modified docx and copy content into the output document
|
|
||||||
try (InputStream modifiedIs = new ByteArrayInputStream(modified);
|
|
||||||
XWPFDocument templateDoc = new XWPFDocument(modifiedIs)) {
|
|
||||||
|
|
||||||
for (var para : templateDoc.getParagraphs()) {
|
|
||||||
var newPara = document.createParagraph();
|
|
||||||
newPara.getCTP().set(para.getCTP().copy());
|
|
||||||
}
|
|
||||||
for (var table : templateDoc.getTables()) {
|
|
||||||
var newTable = document.createTable();
|
|
||||||
newTable.getCTTbl().set(table.getCTTbl().copy());
|
|
||||||
}
|
}
|
||||||
|
return resource.getInputStream();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] replacePlaceholdersInDocx(byte[] docxBytes) throws IOException {
|
@Override
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
public Map<String, Object> getDataModel() {
|
||||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(docxBytes));
|
return Map.of(
|
||||||
ZipOutputStream zos = new ZipOutputStream(out)) {
|
"title", "2025 年度工作报告",
|
||||||
|
"companyName", "示例科技有限公司",
|
||||||
|
"reportPeriod", "2025年1月 - 2025年12月",
|
||||||
|
"generatedDate", LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ZipEntry entry;
|
@Override
|
||||||
while ((entry = zis.getNextEntry()) != null) {
|
public void postProcess(XWPFDocument document) {
|
||||||
zos.putNextEntry(new ZipEntry(entry.getName()));
|
// Add data rows to the first table in the document
|
||||||
|
XWPFTable table = document.getTables().get(0);
|
||||||
byte[] content = zis.readAllBytes();
|
for (Map<String, String> metric : METRICS) {
|
||||||
if ("word/document.xml".equals(entry.getName())) {
|
XWPFTableRow row = table.createRow();
|
||||||
String xml = new String(content, "UTF-8");
|
row.getCell(0).setText(metric.get("name"));
|
||||||
for (Map.Entry<String, String> var : DEFAULT_VARS.entrySet()) {
|
row.getCell(1).setText(metric.get("value"));
|
||||||
xml = xml.replace("${" + var.getKey() + "}", var.getValue());
|
row.getCell(2).setText(metric.get("change"));
|
||||||
}
|
}
|
||||||
content = xml.getBytes("UTF-8");
|
|
||||||
}
|
|
||||||
zos.write(content);
|
|
||||||
zos.closeEntry();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out.toByteArray();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user