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
+3
View File
@@ -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
+1 -1
View File
@@ -8,7 +8,7 @@ mvn clean package -DskipTests
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
echo "===== 构建成功 =====" echo "===== 构建成功 ====="
JAR_PATH="target/gmp-mock-service.jar" JAR_PATH="target/gmp-service.jar"
if [ -f "$JAR_PATH" ]; then if [ -f "$JAR_PATH" ]; then
SIZE=$(du -h "$JAR_PATH" | cut -f1) SIZE=$(du -h "$JAR_PATH" | cut -f1)
echo "JAR 文件: $JAR_PATH ($SIZE)" echo "JAR 文件: $JAR_PATH ($SIZE)"
+74
View File
@@ -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
+59
View File
@@ -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')"
+19
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
JAR_PATH="target/gmp-mock-service.jar" JAR_PATH="target/gmp-service.jar"
if [ ! -f "$JAR_PATH" ]; then if [ ! -f "$JAR_PATH" ]; then
echo "JAR 文件不存在,请先执行 build.sh 进行构建" echo "JAR 文件不存在,请先执行 build.sh 进行构建"
@@ -1,19 +1,19 @@
package com.gmp.mock.controller; package com.gmp.mock.controller;
import java.io.IOException;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; 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.dto.DocxExportRequest;
import com.gmp.mock.service.DocxService; import com.gmp.mock.service.DocxService;
import com.gmp.mock.service.DocxService.ExportResult;
@RestController @RestController
@RequestMapping("/api/docx") @RequestMapping("/api/docx")
@@ -26,11 +26,18 @@ public class DocxController {
} }
@PostMapping("/export") @PostMapping("/export")
public ResponseEntity<ApiResponse<DocxExportData>> exportDocx(@Valid @RequestBody DocxExportRequest request) throws IOException { public ResponseEntity<StreamingResponseBody> exportDocx(@Valid @RequestBody DocxExportRequest request) {
DocxExportData data = docxService.export(request.getExportId());
if (data == null) { ExportResult result = docxService.export(request.getExportId());
return ResponseEntity.ok(ApiResponse.error(404, "模板不存在"));
} StreamingResponseBody stream = outputStream ->
return ResponseEntity.ok(ApiResponse.success(data)); 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; package com.gmp.mock.handler;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.http.ResponseEntity; 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.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.gmp.mock.dto.ApiResponse; import com.gmp.mock.service.TemplateNotFoundException;
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) { public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream() String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getDefaultMessage()) .map(e -> e.getDefaultMessage())
.collect(Collectors.joining("; ")); .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; package com.gmp.mock.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException; 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.apache.poi.xwpf.usermodel.XWPFDocument;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.gmp.mock.dto.DocxExportData;
import com.gmp.mock.template.TemplateProvider; import com.gmp.mock.template.TemplateProvider;
import com.gmp.mock.template.TemplateRegistry; import com.gmp.mock.template.TemplateRegistry;
@@ -20,18 +19,34 @@ public class DocxService {
this.templateRegistry = templateRegistry; 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); TemplateProvider provider = templateRegistry.get(exportId);
if (provider == null) { if (provider == null) {
return null; throw new TemplateNotFoundException(exportId);
} }
try (XWPFDocument document = new XWPFDocument(); // Return a writer callback instead of buffering everything in memory.
ByteArrayOutputStream baos = new ByteArrayOutputStream()) { // The document is generated and written directly to the HTTP response stream.
provider.generate(document); ExportFileWriter writer = outputStream -> {
document.write(baos); try (XWPFDocument document = new XWPFDocument()) {
String base64 = Base64.getEncoder().encodeToString(baos.toByteArray()); provider.generate(document);
return new DocxExportData(provider.getFileName(), base64); 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; package com.gmp.mock.template.annualReport;
import java.io.IOException; 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.apache.poi.xwpf.usermodel.XWPFDocument;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.gmp.mock.template.TemplateProvider; import com.gmp.mock.template.TemplateProvider;
@@ -10,6 +20,20 @@ import com.gmp.mock.template.TemplateProvider;
@Component @Component
public class AnnualReportProvider implements TemplateProvider { 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 @Override
public String getTemplateId() { public String getTemplateId() {
return "annualReport"; return "annualReport";
@@ -22,5 +46,55 @@ public class AnnualReportProvider implements TemplateProvider {
@Override @Override
public void generate(XWPFDocument document) throws IOException { 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.