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:
agentuser
2026-05-08 11:12:47 +08:00
parent ad4b0e93f0
commit 7442f77bd0
14 changed files with 296 additions and 105 deletions
@@ -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.