diff --git a/src/main/java/com/gmp/mock/controller/DocxController.java b/src/main/java/com/gmp/mock/controller/DocxController.java index 4d13c2e..0925ea3 100644 --- a/src/main/java/com/gmp/mock/controller/DocxController.java +++ b/src/main/java/com/gmp/mock/controller/DocxController.java @@ -5,39 +5,92 @@ import jakarta.validation.Valid; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import com.gmp.mock.dto.ApiResponse; import com.gmp.mock.dto.DocxExportRequest; +import com.gmp.mock.dto.ExportResultData; import com.gmp.mock.service.DocxService; -import com.gmp.mock.service.DocxService.ExportResult; +import com.gmp.mock.service.DocxService.CachedResult; +import com.gmp.mock.service.FileCacheService; + +import jakarta.servlet.http.HttpServletRequest; @RestController @RequestMapping("/api/docx") public class DocxController { private final DocxService docxService; + private final FileCacheService fileCache; - public DocxController(DocxService docxService) { + public DocxController(DocxService docxService, FileCacheService fileCache) { this.docxService = docxService; + this.fileCache = fileCache; } + /** + * POST /api/docx/export + * + * 导出文档,成功时返回 JSON: + * { + * "code": 0, + * "msg": "success", + * "data": { + * "url": "http://localhost:6666/api/docx/download?id=xxxx", + * "fileName": "annualReport.docx" + * } + * } + */ @PostMapping("/export") - public ResponseEntity exportDocx(@Valid @RequestBody DocxExportRequest request) { + public ResponseEntity> exportDocx( + @Valid @RequestBody DocxExportRequest request, + HttpServletRequest httpRequest + ) { + CachedResult result = docxService.export(request.getExportId(), request.getBusinessData()); - ExportResult result = docxService.export(request.getExportId()); + // 构造下载 URL + String baseUrl = getBaseUrl(httpRequest); + String downloadUrl = baseUrl + "/api/docx/download?id=" + result.key(); - StreamingResponseBody stream = outputStream -> - result.writer().write(outputStream); + ExportResultData data = new ExportResultData(downloadUrl, result.fileName()); + return ResponseEntity.ok(ApiResponse.ok(data)); + } + /** + * GET /api/docx/download?id={key} + * + * 根据 key 返回缓存的文档文件。 + */ + @GetMapping("/download") + public ResponseEntity downloadDocx(@RequestParam("id") String key) { + byte[] fileBytes = fileCache.retrieve(key); + if (fileBytes == null) { + return ResponseEntity.status(404) + .body(ApiResponse.error(404, "文件不存在或已过期: " + key)); + } + + // 从缓存文件名可以推断,但我们不需要 fileName,因为下载时由客户端决定。 + // 这里固定一个通用的 Content-Disposition return ResponseEntity.ok() .contentType(MediaType.parseMediaType( "application/vnd.openxmlformats-officedocument.wordprocessingml.document")) .header(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + result.fileName() + "\"") - .body(stream); + "attachment; filename=\"document.docx\"") + .body(fileBytes); + } + + private String getBaseUrl(HttpServletRequest request) { + String scheme = request.getScheme(); + String host = request.getServerName(); + int port = request.getServerPort(); + if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) { + return scheme + "://" + host; + } + return scheme + "://" + host + ":" + port; } } diff --git a/src/main/java/com/gmp/mock/dto/ApiResponse.java b/src/main/java/com/gmp/mock/dto/ApiResponse.java new file mode 100644 index 0000000..7214be8 --- /dev/null +++ b/src/main/java/com/gmp/mock/dto/ApiResponse.java @@ -0,0 +1,36 @@ +package com.gmp.mock.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * 统一 API 响应格式 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + + private int code; + private String msg; + private T data; + + private ApiResponse(int code, String msg, T data) { + this.code = code; + this.msg = msg; + this.data = data; + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(0, "success", data); + } + + public static ApiResponse ok(T data, String msg) { + return new ApiResponse<>(0, msg, data); + } + + public static ApiResponse error(int code, String msg) { + return new ApiResponse<>(code, msg, null); + } + + public int getCode() { return code; } + public String getMsg() { return msg; } + public T getData() { return data; } +} diff --git a/src/main/java/com/gmp/mock/dto/ExportResultData.java b/src/main/java/com/gmp/mock/dto/ExportResultData.java new file mode 100644 index 0000000..39e38c4 --- /dev/null +++ b/src/main/java/com/gmp/mock/dto/ExportResultData.java @@ -0,0 +1,17 @@ +package com.gmp.mock.dto; + +/** + * Export 成功后返回的数据 + */ +public class ExportResultData { + private String url; + private String fileName; + + public ExportResultData(String url, String fileName) { + this.url = url; + this.fileName = fileName; + } + + public String getUrl() { return url; } + public String getFileName() { return fileName; } +} diff --git a/src/main/java/com/gmp/mock/handler/GlobalExceptionHandler.java b/src/main/java/com/gmp/mock/handler/GlobalExceptionHandler.java index be9f2b9..fecefe6 100644 --- a/src/main/java/com/gmp/mock/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/gmp/mock/handler/GlobalExceptionHandler.java @@ -1,6 +1,5 @@ package com.gmp.mock.handler; -import java.util.Map; import java.util.stream.Collectors; import org.springframework.http.ResponseEntity; @@ -8,23 +7,24 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import com.gmp.mock.dto.ApiResponse; import com.gmp.mock.service.TemplateNotFoundException; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldErrors().stream() .map(e -> e.getDefaultMessage()) .collect(Collectors.joining("; ")); return ResponseEntity.badRequest() - .body(Map.of("code", 400, "msg", message)); + .body(ApiResponse.error(400, message)); } @ExceptionHandler(TemplateNotFoundException.class) - public ResponseEntity> handleTemplateNotFound(TemplateNotFoundException ex) { + public ResponseEntity> handleTemplateNotFound(TemplateNotFoundException ex) { return ResponseEntity.status(404) - .body(Map.of("code", 404, "msg", ex.getMessage())); + .body(ApiResponse.error(404, ex.getMessage())); } } diff --git a/src/main/java/com/gmp/mock/service/DocxService.java b/src/main/java/com/gmp/mock/service/DocxService.java index 8c3ea79..7201c34 100644 --- a/src/main/java/com/gmp/mock/service/DocxService.java +++ b/src/main/java/com/gmp/mock/service/DocxService.java @@ -1,12 +1,19 @@ package com.gmp.mock.service; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.OutputStream; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; 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; @@ -14,45 +21,125 @@ import com.gmp.mock.template.TemplateRegistry; public class DocxService { private final TemplateRegistry templateRegistry; + private final FileCacheService fileCache; - public DocxService(TemplateRegistry templateRegistry) { + public DocxService(TemplateRegistry templateRegistry, FileCacheService fileCache) { this.templateRegistry = templateRegistry; + this.fileCache = fileCache; } - public ExportResult export(String exportId) { + /** + * 生成文档,缓存到磁盘,返回 key 和文件名。 + */ + public CachedResult export(String exportId, Map businessData) { TemplateProvider provider = templateRegistry.get(exportId); if (provider == null) { throw new TemplateNotFoundException(exportId); } - ExportFileWriter writer = outputStream -> { - try { - // 1. poi-tl: compile template and render text placeholders - XWPFTemplate template = XWPFTemplate.compile(provider.getTemplateResource()); - template.render(provider.getDataModel()); + try { + // 1. Pre-process: merge split runs + byte[] preprocessed = mergeSplitPlaceholders(provider.getTemplateResource()); - // 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); + // 2. Build data model + Map data = new java.util.HashMap<>(provider.getDataModel()); + if (businessData != null) { + data.putAll(businessData); } - }; - return new ExportResult(provider.getFileName(), writer); + // 3. Render with poi-tl + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + XWPFTemplate template = XWPFTemplate.compile(new ByteArrayInputStream(preprocessed)); + template.render(data); + provider.postProcess(template.getXWPFDocument()); + template.write(baos); + template.close(); + + // 4. Cache to disk + String key = fileCache.store(baos.toByteArray()); + + return new CachedResult(key, provider.getFileName()); + + } catch (IOException e) { + throw new UncheckedIOException("生成文档失败", e); + } } - public record ExportResult(String fileName, ExportFileWriter writer) { + public record CachedResult(String key, String fileName) {} + + // ====== Run merging (unchanged) ====== + + private byte[] mergeSplitPlaceholders(InputStream templateStream) throws IOException { + byte[] docxBytes; + try (InputStream is = templateStream) { + docxBytes = is.readAllBytes(); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(docxBytes)); + ZipOutputStream zos = new ZipOutputStream(out)) { + 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"); + xml = mergeRunsInXml(xml); + content = xml.getBytes("UTF-8"); + } + zos.write(content); + zos.closeEntry(); + } + } + return out.toByteArray(); } - @FunctionalInterface - public interface ExportFileWriter { - void write(OutputStream outputStream) throws IOException; + private String mergeRunsInXml(String xml) { + StringBuilder result = new StringBuilder(); + java.util.regex.Matcher m = Pattern.compile( + "(.*?]*?>)(.*?)(.*?)", + Pattern.DOTALL).matcher(xml); + var runs = new java.util.ArrayList(); + while (m.find()) { + runs.add(new Run(m.start(), m.end(), m.group(1), m.group(2), m.group(3))); + } + if (runs.isEmpty()) return xml; + var merged = new java.util.ArrayList(); + int i = 0; + while (i < runs.size()) { + StringBuilder combined = new StringBuilder(); + int start = i; + while (i < runs.size() && isPlaceholderPart(combined, runs.get(i).text)) { + combined.append(runs.get(i).text); + i++; + } + if (combined.toString().matches("\\{\\{.*?\\}\\}")) { + Run first = runs.get(start); + Run last = runs.get(i - 1); + merged.add(new MergedRun(first.start, last.end, first.openTag + combined + first.closeTag)); + } else { + for (int j = start; j < i; j++) { + Run r = runs.get(j); + merged.add(new MergedRun(r.start, r.end, r.fullMatch())); + } + } + } + int pos = 0; + for (MergedRun mr : merged) { + result.append(xml, pos, mr.start); + result.append(mr.xml); + pos = mr.end; + } + result.append(xml.substring(pos)); + return result.toString(); } + + private boolean isPlaceholderPart(StringBuilder current, String text) { + if (current.isEmpty()) return text.contains("{{") || text.startsWith("{{"); + return !text.contains("}}"); + } + + private record Run(int start, int end, String openTag, String text, String closeTag) { + String fullMatch() { return openTag + text + closeTag; } + } + private record MergedRun(int start, int end, String xml) {} } diff --git a/src/main/java/com/gmp/mock/service/FileCacheService.java b/src/main/java/com/gmp/mock/service/FileCacheService.java new file mode 100644 index 0000000..be4b07d --- /dev/null +++ b/src/main/java/com/gmp/mock/service/FileCacheService.java @@ -0,0 +1,91 @@ +package com.gmp.mock.service; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * 文件缓存服务。 + * 生成的文件保存到本地磁盘,通过随机 key 索引,供下载接口使用。 + */ +@Service +public class FileCacheService { + + private static final Logger log = LoggerFactory.getLogger(FileCacheService.class); + + private final Path cacheDir; + + public FileCacheService(@Value("${gmp.cache-dir:/tmp/gmp-cache}") String cacheDirPath) { + this.cacheDir = Path.of(cacheDirPath); + try { + Files.createDirectories(this.cacheDir); + } catch (IOException e) { + throw new RuntimeException("无法创建缓存目录: " + cacheDirPath, e); + } + log.info("File cache directory: {}", this.cacheDir.toAbsolutePath()); + } + + /** + * 生成一个随机 key,并将数据写入缓存文件。 + * + * @param data 文件字节数据 + * @return 随机 key + */ + public String store(byte[] data) { + String key = UUID.randomUUID().toString().replace("-", ""); + Path file = cacheDir.resolve(key); + try { + Files.write(file, data); + log.debug("Cached file: {} ({} bytes)", key, data.length); + } catch (IOException e) { + throw new RuntimeException("缓存文件写入失败: " + key, e); + } + return key; + } + + /** + * 根据 key 读取缓存文件。 + * + * @param key 随机 key + * @return 文件字节数据,或 null(key 不存在) + */ + public byte[] retrieve(String key) { + Path file = cacheDir.resolve(sanitize(key)); + if (!Files.exists(file)) { + return null; + } + try { + return Files.readAllBytes(file); + } catch (IOException e) { + log.error("读取缓存文件失败: {}", key, e); + return null; + } + } + + /** + * 把缓存文件内容写入输出流。 + * + * @param key 随机 key + * @param out 输出流 + * @return 是否写入成功 + */ + public boolean writeToStream(String key, OutputStream out) throws IOException { + Path file = cacheDir.resolve(sanitize(key)); + if (!Files.exists(file)) { + return false; + } + Files.copy(file, out); + return true; + } + + private String sanitize(String key) { + return key.replaceAll("[^a-zA-Z0-9\\-_]", ""); + } +} 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 dbb7e16..c34e020 100644 --- a/src/main/java/com/gmp/mock/template/annualReport/AnnualReportProvider.java +++ b/src/main/java/com/gmp/mock/template/annualReport/AnnualReportProvider.java @@ -69,8 +69,12 @@ public class AnnualReportProvider implements TemplateProvider { @Override public void postProcess(XWPFDocument document) { + List tables = document.getTables(); + if (tables.isEmpty()) { + return; // 模板中无表格,跳过 + } // Add data rows to the first table in the document - XWPFTable table = document.getTables().get(0); + XWPFTable table = tables.get(0); for (Map metric : METRICS) { XWPFTableRow row = table.createRow(); row.getCell(0).setText(metric.get("name"));