From 7442f77bd0442e94137fc88bf40717251d7fd78f Mon Sep 17 00:00:00 2001 From: agentuser Date: Fri, 8 May 2026 11:12:47 +0800 Subject: [PATCH] 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 --- .gitattributes | 3 + deploy/build.sh | 2 +- deploy/clean-lfs.sh | 74 +++++++++++++++++++ deploy/deploy.sh | 59 +++++++++++++++ deploy/gmp-service.service | 19 +++++ deploy/run.sh | 2 +- .../gmp/mock/controller/DocxController.java | 27 ++++--- .../java/com/gmp/mock/dto/ApiResponse.java | 50 ------------- .../java/com/gmp/mock/dto/DocxExportData.java | 28 ------- .../mock/handler/GlobalExceptionHandler.java | 14 +++- .../com/gmp/mock/service/DocxService.java | 39 +++++++--- .../service/TemplateNotFoundException.java | 7 ++ .../annualReport/AnnualReportProvider.java | 74 +++++++++++++++++++ .../templates/annualReportTemplate.docx | 3 + 14 files changed, 296 insertions(+), 105 deletions(-) create mode 100644 .gitattributes create mode 100755 deploy/clean-lfs.sh create mode 100755 deploy/deploy.sh create mode 100644 deploy/gmp-service.service delete mode 100644 src/main/java/com/gmp/mock/dto/ApiResponse.java delete mode 100644 src/main/java/com/gmp/mock/dto/DocxExportData.java create mode 100644 src/main/java/com/gmp/mock/service/TemplateNotFoundException.java create mode 100644 src/main/resources/templates/annualReportTemplate.docx diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4f5bd4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.docx filter=lfs diff=lfs merge=lfs -text +*.doc filter=lfs diff=lfs merge=lfs -text +*.dotx filter=lfs diff=lfs merge=lfs -text diff --git a/deploy/build.sh b/deploy/build.sh index 67345e9..0826d09 100644 --- a/deploy/build.sh +++ b/deploy/build.sh @@ -8,7 +8,7 @@ mvn clean package -DskipTests if [ $? -eq 0 ]; then echo "===== 构建成功 =====" - JAR_PATH="target/gmp-mock-service.jar" + JAR_PATH="target/gmp-service.jar" if [ -f "$JAR_PATH" ]; then SIZE=$(du -h "$JAR_PATH" | cut -f1) echo "JAR 文件: $JAR_PATH ($SIZE)" diff --git a/deploy/clean-lfs.sh b/deploy/clean-lfs.sh new file mode 100755 index 0000000..8feacc2 --- /dev/null +++ b/deploy/clean-lfs.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Git LFS 缓存清理脚本 +# +# 用法: +# ./deploy/clean-lfs.sh # 交互式预览 +# ./deploy/clean-lfs.sh --prune # 执行清理 +# ./deploy/clean-lfs.sh --force # 强制清理所有缓存 +# +# Git LFS 会把所有历史版本的二进制文件缓存在 .git/lfs/objects/ 下。 +# 这些缓存不会自动删除,时间久了会占用大量磁盘空间。 +# 此脚本用于安全地清理不再需要的 LFS 缓存对象。 + +set -e + +LFS_DIR="$(git rev-parse --git-dir 2>/dev/null || echo ".git")/lfs/objects" +cd "$(git rev-parse --show-toplevel 2>/dev/null || echo ".")" + +echo "===== Git LFS 缓存分析 =====" +echo "仓库: $(pwd)" + +# 统计当前缓存大小 +if [ -d "$LFS_DIR" ]; then + CACHE_SIZE=$(du -sh "$LFS_DIR" 2>/dev/null | cut -f1) + CACHE_FILES=$(find "$LFS_DIR" -type f 2>/dev/null | wc -l) + echo "LFS 缓存目录: .git/lfs/objects/" + echo "缓存文件数: ${CACHE_FILES:-0}" + echo "缓存总大小: ${CACHE_SIZE:-0}" +else + echo "LFS 缓存目录不存在,尚无缓存文件。" + CACHE_SIZE="0B" +fi + +echo "" + +# 检查 git lfs 是否可用 +if ! git lfs version &>/dev/null; then + echo "错误: git lfs 未安装。" + exit 1 +fi + +# 分析可清理的空间 +echo "===== 可清理空间分析 =====" +git lfs prune --dry-run 2>&1 | head -20 + +echo "" + +case "${1:-}" in + --prune) + echo "===== 执行清理 =====" + git lfs prune + echo "" + # 统计清理后 + if [ -d "$LFS_DIR" ]; then + NEW_SIZE=$(du -sh "$LFS_DIR" 2>/dev/null | cut -f1) + echo "清理完成: ${CACHE_SIZE} -> ${NEW_SIZE}" + fi + ;; + --force) + echo "===== 强制清理所有 LFS 缓存 =====" + echo "注意: 这将删除所有不在当前 checkout 中的 LFS 对象。" + echo "如果你需要切换回旧版本,LFS 对象会被重新下载。" + echo "" + git lfs prune --force 2>&1 | tail -5 + ;; + *) + echo "这是预览模式,未执行任何操作。" + echo "" + echo "执行清理: bash $0 --prune" + echo "强制清理: bash $0 --force" + echo "" + echo "提示: 定期执行 'git lfs prune' 可以删除旧版本 LFS 缓存。" + echo " Git LFS 的默认保留策略是最近 3 天的对象 + 当前 HEAD 可达的对象。" + ;; +esac diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..b309411 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# +# gmp-service 一键部署脚本 +# 用法: ./deploy/deploy.sh +# +# 它会: 构建 -> 停止旧服务 -> 替换jar -> 启动新服务 + +set -e + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SERVICE_NAME="gmp-service" +JAR_SRC="$PROJECT_DIR/target/gmp-service.jar" +JAR_DST="/opt/$SERVICE_NAME/gmp-service.jar" +LOG_DIR="/var/log/$SERVICE_NAME" + +echo "===== 1. Maven 构建 =====" +cd "$PROJECT_DIR" +mvn clean package -DskipTests + +if [ ! -f "$JAR_SRC" ]; then + echo "构建失败,未生成 JAR 文件" + exit 1 +fi + +echo "===== 2. 停止旧服务 =====" +if systemctl is-active --quiet "$SERVICE_NAME"; then + sudo systemctl stop "$SERVICE_NAME" + echo "旧服务已停止" +else + echo "服务未运行,跳过" +fi + +echo "===== 3. 部署新 JAR =====" +sudo mkdir -p /opt/$SERVICE_NAME "$LOG_DIR" +sudo cp "$JAR_SRC" "$JAR_DST" +sudo chmod 644 "$JAR_DST" + +echo "===== 4. 启动服务 =====" +sudo systemctl daemon-reload +sudo systemctl enable "$SERVICE_NAME" +sudo systemctl start "$SERVICE_NAME" + +echo "===== 等待服务启动 =====" +sleep 3 + +if systemctl is-active --quiet "$SERVICE_NAME"; then + echo "服务已成功启动!" + systemctl status "$SERVICE_NAME" --no-pager | head -15 +else + echo "服务启动失败,查看日志: sudo journalctl -u $SERVICE_NAME -n 50 --no-pager" + exit 1 +fi + +echo "" +echo "部署完成!" +echo " 服务: $SERVICE_NAME" +echo " JAR: $JAR_DST" +echo " 日志: journalctl -u $SERVICE_NAME -f" +echo " 端口: $(grep 'server.port' "$PROJECT_DIR/src/main/resources/application.yml" 2>/dev/null || echo '6666')" diff --git a/deploy/gmp-service.service b/deploy/gmp-service.service new file mode 100644 index 0000000..723ffdd --- /dev/null +++ b/deploy/gmp-service.service @@ -0,0 +1,19 @@ +[Unit] +Description=GMP Mock Service - Spring Boot Application +Documentation= +After=network.target + +[Service] +Type=simple +User=agentuser +WorkingDirectory=/opt/gmp-service +ExecStart=/usr/bin/java -Xms256m -Xmx512m -jar /opt/gmp-service/gmp-service.jar +ExecStop=/bin/kill -SIGTERM $MAINPID +SuccessExitStatus=143 +Restart=on-failure +RestartSec=10 +StandardOutput=append:/var/log/gmp-service/stdout.log +StandardError=append:/var/log/gmp-service/stderr.log + +[Install] +WantedBy=multi-user.target diff --git a/deploy/run.sh b/deploy/run.sh index 8741236..bc1d076 100644 --- a/deploy/run.sh +++ b/deploy/run.sh @@ -1,6 +1,6 @@ #!/bin/bash -JAR_PATH="target/gmp-mock-service.jar" +JAR_PATH="target/gmp-service.jar" if [ ! -f "$JAR_PATH" ]; then echo "JAR 文件不存在,请先执行 build.sh 进行构建" diff --git a/src/main/java/com/gmp/mock/controller/DocxController.java b/src/main/java/com/gmp/mock/controller/DocxController.java index c5cf96b..4d13c2e 100644 --- a/src/main/java/com/gmp/mock/controller/DocxController.java +++ b/src/main/java/com/gmp/mock/controller/DocxController.java @@ -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> 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 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); } } diff --git a/src/main/java/com/gmp/mock/dto/ApiResponse.java b/src/main/java/com/gmp/mock/dto/ApiResponse.java deleted file mode 100644 index 80b5183..0000000 --- a/src/main/java/com/gmp/mock/dto/ApiResponse.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.gmp.mock.dto; - -public class ApiResponse { - - 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 ApiResponse success(T data) { - return new ApiResponse<>(0, data, "success"); - } - - public static ApiResponse success(T data, String msg) { - return new ApiResponse<>(0, data, msg); - } - - public static ApiResponse 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; - } -} diff --git a/src/main/java/com/gmp/mock/dto/DocxExportData.java b/src/main/java/com/gmp/mock/dto/DocxExportData.java deleted file mode 100644 index cfa46f3..0000000 --- a/src/main/java/com/gmp/mock/dto/DocxExportData.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/gmp/mock/handler/GlobalExceptionHandler.java b/src/main/java/com/gmp/mock/handler/GlobalExceptionHandler.java index df42315..be9f2b9 100644 --- a/src/main/java/com/gmp/mock/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/gmp/mock/handler/GlobalExceptionHandler.java @@ -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> handleValidation(MethodArgumentNotValidException ex) { + public ResponseEntity> 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> handleTemplateNotFound(TemplateNotFoundException ex) { + return ResponseEntity.status(404) + .body(Map.of("code", 404, "msg", 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 a546ab9..7bbbdbe 100644 --- a/src/main/java/com/gmp/mock/service/DocxService.java +++ b/src/main/java/com/gmp/mock/service/DocxService.java @@ -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; } } diff --git a/src/main/java/com/gmp/mock/service/TemplateNotFoundException.java b/src/main/java/com/gmp/mock/service/TemplateNotFoundException.java new file mode 100644 index 0000000..4636df2 --- /dev/null +++ b/src/main/java/com/gmp/mock/service/TemplateNotFoundException.java @@ -0,0 +1,7 @@ +package com.gmp.mock.service; + +public class TemplateNotFoundException extends RuntimeException { + public TemplateNotFoundException(String templateId) { + super("模板不存在: " + templateId); + } +} 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 a0819e0..614268a 100644 --- a/src/main/java/com/gmp/mock/template/annualReport/AnnualReportProvider.java +++ b/src/main/java/com/gmp/mock/template/annualReport/AnnualReportProvider.java @@ -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 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 var : DEFAULT_VARS.entrySet()) { + xml = xml.replace("${" + var.getKey() + "}", var.getValue()); + } + content = xml.getBytes("UTF-8"); + } + zos.write(content); + zos.closeEntry(); + } + } + return out.toByteArray(); } } diff --git a/src/main/resources/templates/annualReportTemplate.docx b/src/main/resources/templates/annualReportTemplate.docx new file mode 100644 index 0000000..3ae81ec --- /dev/null +++ b/src/main/resources/templates/annualReportTemplate.docx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b23bac9737c9ae3f5c4c0680c0c91f60df7dc59730f3db0e9b2f83493c29328 +size 2134