From 7b79f1f551a03eb1968b905931c2c5aeac3be559 Mon Sep 17 00:00:00 2001 From: agentuser Date: Fri, 8 May 2026 11:53:43 +0800 Subject: [PATCH] switch to poi-tl for template rendering, add OpenAPI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/openapi.yaml | 138 ++++++++++++++++++ pom.xml | 7 + .../com/gmp/mock/service/DocxService.java | 30 ++-- .../gmp/mock/template/TemplateProvider.java | 29 +++- .../annualReport/AnnualReportProvider.java | 107 ++++++-------- .../templates/annualReportTemplate.docx | Bin 11357 -> 2010 bytes 6 files changed, 235 insertions(+), 76 deletions(-) create mode 100644 docs/openapi.yaml 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> METRICS = List.of( + Map.of("name", "营业收入", "value", "¥ 1,234,567", "change", "+15.3%"), + Map.of("name", "净利润", "value", "¥ 321,098", "change", "+8.7%"), + Map.of("name", "毛利率", "value", "45.2%", "change", "+2.1%") ); @Override @@ -45,56 +45,37 @@ public class AnnualReportProvider implements TemplateProvider { } @Override - public void generate(XWPFDocument document) throws IOException { - ClassPathResource resource = new ClassPathResource("templates/annualReportTemplate.docx"); - if (!resource.exists()) { - throw new IOException("模板文件未找到: 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()); + public InputStream getTemplateResource() { + try { + ClassPathResource resource = new ClassPathResource("templates/annualReportTemplate.docx"); + if (!resource.exists()) { + throw new RuntimeException("模板文件未找到: templates/annualReportTemplate.docx"); } + return resource.getInputStream(); + } catch (Exception e) { + throw new RuntimeException(e); } } - private byte[] replacePlaceholdersInDocx(byte[] docxBytes) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(docxBytes)); - ZipOutputStream zos = new ZipOutputStream(out)) { + @Override + public Map getDataModel() { + return Map.of( + "title", "2025 年度工作报告", + "companyName", "示例科技有限公司", + "reportPeriod", "2025年1月 - 2025年12月", + "generatedDate", LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")) + ); + } - ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - zos.putNextEntry(new ZipEntry(entry.getName())); - - byte[] content = zis.readAllBytes(); - if ("word/document.xml".equals(entry.getName())) { - String xml = new String(content, "UTF-8"); - for (Map.Entry var : DEFAULT_VARS.entrySet()) { - xml = xml.replace("${" + var.getKey() + "}", var.getValue()); - } - content = xml.getBytes("UTF-8"); - } - zos.write(content); - zos.closeEntry(); - } + @Override + public void postProcess(XWPFDocument document) { + // Add data rows to the first table in the document + XWPFTable table = document.getTables().get(0); + for (Map metric : METRICS) { + XWPFTableRow row = table.createRow(); + row.getCell(0).setText(metric.get("name")); + row.getCell(1).setText(metric.get("value")); + row.getCell(2).setText(metric.get("change")); } - return out.toByteArray(); } } diff --git a/src/main/resources/templates/annualReportTemplate.docx b/src/main/resources/templates/annualReportTemplate.docx index 53d21be07187fbbabd44a48182de5f1f4a4795d9..1dbee72493ef6c50ab28842b3db86504849173cd 100644 GIT binary patch literal 2010 zcmWIWW@Zs#U|`^2$ctMMW4LU|j(j@7Hk%~?C~ ztlwb+ftL5RT&J&V6+UPX=(@~o@BSb_E+I8vi2MJ^*VCT8EtJv_TGYYpp8agLYX>~;CLmWbL|7a}MqnO@1r;ZNui7cjdb_<#=Xv zZoYlg`no5dVbo#;@z^(e|AmFrPPx)*Qf(vE&Zqhx zbOr8dUDC>z^_(l_|GY1UZ(aEA_ONsQey&QovauJ!L?etf-thEwGxkN@^5C;!G= z=z4s;{CxmQa4AT)z1jo}COt+525ul7UzD0ttgiz(MKN`%`vN1 zFf7#m`9{Ym!ocae*jdT+d7pgeZ0T1~HZz(L^oiYT@r(-}BCPt&{N2QP{CDznRq3sZ zQM?;=wXe;e`Bu{eQSYjg3STy{8R^LVe|Xm-tI=IOk1hAt$EVrKr)@NRc;xgvwC=w( z+U!v5wX5sqJ_b~;HG2lk-vIQIJkV=0K)O7?C`BLQzm)vs(p+Go0Hp?qU;EE;9Reqb zuGIHtYuN=eZ*_C*^{Q={kXLWu>txndTg@D&Q}Mxq=QD5o)jeX&{#-WJ+8fVsvuwKc zb+fGMy9Nb^l!F3)}Q$79!m7~h}< zzt)M1j`*JwW_P_LB)$F@FRD+UrXIX;4d_E_pilW>J}oY(%tKYOmp$xTnI|gKzT*lUJro z?3P^++;xS=GVHo ziahRbj^F>j_FJ|7?C5IGH*bPgDxIqdiTpajS@L1xktn;We{W7U^FGn&jqFH1x1xVn z$ilS=shflOEi0!6ER%IeSat7XV03n1d93R#lY?1PxlbjmKbhKOx?|JRvK>6FPg=BC zSv=o_+2-H%EjV(aE4WH$$sG1c=CcJC-gY$-3%DeaEwJR)!~hM3j1$`r9?naMI3VbF zaQba)qlWp@kG#B6Uie0t$st6^JU*03*h6%ilt{)ymTR^ai~VHH6_2^|A8dBGe&|j^ zU+(M3uC~@Uy&YLwo0fc!@Ljj_n|&&e?3dqGmkjUTKfnF!j5D&EXPW$5QW)l2>g1a$ zXlib?YWs^7_tq-cem1rJHIeUrY<$eWUBB+U7oNv)W|x2ArkKL1{}uGCZT#=cr^Wl# z&#N(iq#_#gN>)o*?%ZlW^G88V4}341JQAP4{pHTAbkkSs7!EdNc;sk(5;T!DsR($r zp{BWe;;cR5$+H%(kP}&8_&|s8yo0b@v4!1vo75kR)^2%rGH5~4tt;M)lLA-&z3%aB zS(@Mah`AaHwxyOT2Kmt*vqa28=k8cpz%Q<9!X?tU(ly$px%thT1J}BAg`(xn>g2Ya zy2X?uuXp)+`2Q|lS-wsA$6sDtUh?6io!{fBRw;MHzxNgdGnV^)xTVyrFl*M$sl7J~ z6RzjUvR^;G{O0Y%vLZg=gFVe+sUb|V{pBKW9dm#;Ba;X-?pg?# zBEg^mm_xDFN$3Wkm&*|C3=9p7`amO)N@;YB=%pS)BNs4x;41^sO+qhK5GH+rnS>}~ h&^4pyM}+3@%ur__=gt6cRyL3lRv>f$dawn|0{|2QI1c~- literal 11357 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