Archived
refactor: streaming download, Git LFS templates, deploy workflow
- Replace base64 JSON response with StreamingResponseBody (zero-buffer) - Error responses return JSON via GlobalExceptionHandler (400/404) - Add git-lfs track for *.docx to keep repo lean - Add deploy/deploy.sh: one-click build + restart - Add deploy/clean-lfs.sh: periodic LFS cache cleanup - Add deploy/gmp-service.service: systemd service unit - Remove unused ApiResponse and DocxExportData DTOs - Fix build.sh/run.sh jar name (gmp-mock-service -> gmp-service) - Add TemplateNotFoundException for missing templates
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
package com.gmp.mock.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
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.DocxExportData;
|
||||
import com.gmp.mock.dto.DocxExportRequest;
|
||||
import com.gmp.mock.service.DocxService;
|
||||
import com.gmp.mock.service.DocxService.ExportResult;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/docx")
|
||||
@@ -26,11 +26,18 @@ public class DocxController {
|
||||
}
|
||||
|
||||
@PostMapping("/export")
|
||||
public ResponseEntity<ApiResponse<DocxExportData>> exportDocx(@Valid @RequestBody DocxExportRequest request) throws IOException {
|
||||
DocxExportData data = docxService.export(request.getExportId());
|
||||
if (data == null) {
|
||||
return ResponseEntity.ok(ApiResponse.error(404, "模板不存在"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
public ResponseEntity<StreamingResponseBody> exportDocx(@Valid @RequestBody DocxExportRequest request) {
|
||||
|
||||
ExportResult result = docxService.export(request.getExportId());
|
||||
|
||||
StreamingResponseBody stream = outputStream ->
|
||||
result.writer().write(outputStream);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + result.fileName() + "\"")
|
||||
.body(stream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.gmp.mock.dto;
|
||||
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private int code;
|
||||
private T data;
|
||||
private String msg;
|
||||
|
||||
private ApiResponse(int code, T data, String msg) {
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
this.msg = msg;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(0, data, "success");
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data, String msg) {
|
||||
return new ApiResponse<>(0, data, msg);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(int code, String msg) {
|
||||
return new ApiResponse<>(code, null, msg);
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(T data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public String getMsg() {
|
||||
return msg;
|
||||
}
|
||||
|
||||
public void setMsg(String msg) {
|
||||
this.msg = msg;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.gmp.mock.dto;
|
||||
|
||||
public class DocxExportData {
|
||||
|
||||
private String fileName;
|
||||
private String fileBase64;
|
||||
|
||||
public DocxExportData(String fileName, String fileBase64) {
|
||||
this.fileName = fileName;
|
||||
this.fileBase64 = fileBase64;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public void setFileName(String fileName) {
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public String getFileBase64() {
|
||||
return fileBase64;
|
||||
}
|
||||
|
||||
public void setFileBase64(String fileBase64) {
|
||||
this.fileBase64 = fileBase64;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.gmp.mock.handler;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -7,16 +8,23 @@ 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<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(e -> e.getDefaultMessage())
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.ok(ApiResponse.error(400, message));
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("code", 400, "msg", message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(TemplateNotFoundException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleTemplateNotFound(TemplateNotFoundException ex) {
|
||||
return ResponseEntity.status(404)
|
||||
.body(Map.of("code", 404, "msg", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.gmp.mock.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.io.OutputStream;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.gmp.mock.dto.DocxExportData;
|
||||
import com.gmp.mock.template.TemplateProvider;
|
||||
import com.gmp.mock.template.TemplateRegistry;
|
||||
|
||||
@@ -20,18 +19,34 @@ public class DocxService {
|
||||
this.templateRegistry = templateRegistry;
|
||||
}
|
||||
|
||||
public DocxExportData export(String exportId) throws IOException {
|
||||
/**
|
||||
* @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) {
|
||||
return null;
|
||||
throw new TemplateNotFoundException(exportId);
|
||||
}
|
||||
|
||||
try (XWPFDocument document = new XWPFDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
provider.generate(document);
|
||||
document.write(baos);
|
||||
String base64 = Base64.getEncoder().encodeToString(baos.toByteArray());
|
||||
return new DocxExportData(provider.getFileName(), base64);
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
|
||||
return new ExportResult(provider.getFileName(), writer);
|
||||
}
|
||||
|
||||
public record ExportResult(String fileName, ExportFileWriter writer) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ExportFileWriter {
|
||||
void write(OutputStream outputStream) throws IOException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.gmp.mock.service;
|
||||
|
||||
public class TemplateNotFoundException extends RuntimeException {
|
||||
public TemplateNotFoundException(String templateId) {
|
||||
super("模板不存在: " + templateId);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
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.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.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.gmp.mock.template.TemplateProvider;
|
||||
@@ -10,6 +20,20 @@ import com.gmp.mock.template.TemplateProvider;
|
||||
@Component
|
||||
public class AnnualReportProvider implements TemplateProvider {
|
||||
|
||||
private static final Map<String, String> 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日")))
|
||||
);
|
||||
|
||||
@Override
|
||||
public String getTemplateId() {
|
||||
return "annualReport";
|
||||
@@ -22,5 +46,55 @@ 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] replacePlaceholdersInDocx(byte[] docxBytes) throws IOException {
|
||||
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");
|
||||
for (Map.Entry<String, String> var : DEFAULT_VARS.entrySet()) {
|
||||
xml = xml.replace("${" + var.getKey() + "}", var.getValue());
|
||||
}
|
||||
content = xml.getBytes("UTF-8");
|
||||
}
|
||||
zos.write(content);
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user