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 53d21be..1dbee72 100644 Binary files a/src/main/resources/templates/annualReportTemplate.docx and b/src/main/resources/templates/annualReportTemplate.docx differ