refactor: export API returns JSON with download URL, add FileCacheService

This commit is contained in:
agentuser
2026-05-11 16:30:55 +08:00
parent e107e1eb92
commit 318a5bdf7a
7 changed files with 329 additions and 41 deletions
@@ -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<StreamingResponseBody> exportDocx(@Valid @RequestBody DocxExportRequest request) {
public ResponseEntity<ApiResponse<ExportResultData>> 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;
}
}
@@ -0,0 +1,36 @@
package com.gmp.mock.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* 统一 API 响应格式
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
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 <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "success", data);
}
public static <T> ApiResponse<T> ok(T data, String msg) {
return new ApiResponse<>(0, msg, data);
}
public static <T> ApiResponse<T> 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; }
}
@@ -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; }
}
@@ -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<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
public ResponseEntity<ApiResponse<Void>> 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<Map<String, Object>> handleTemplateNotFound(TemplateNotFoundException ex) {
public ResponseEntity<ApiResponse<Void>> handleTemplateNotFound(TemplateNotFoundException ex) {
return ResponseEntity.status(404)
.body(Map.of("code", 404, "msg", ex.getMessage()));
.body(ApiResponse.error(404, ex.getMessage()));
}
}
@@ -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<String, Object> 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());
// 1. Pre-process: merge split runs
byte[] preprocessed = mergeSplitPlaceholders(provider.getTemplateResource());
// 2. Get the underlying document for post-processing
NiceXWPFDocument doc = template.getXWPFDocument();
// 2. Build data model
Map<String, Object> data = new java.util.HashMap<>(provider.getDataModel());
if (businessData != null) {
data.putAll(businessData);
}
// 3. Post-process (e.g. add table rows)
provider.postProcess(doc);
// 4. Write to output stream
template.write(outputStream);
// 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 RuntimeException("生成文档失败", e);
throw new UncheckedIOException("生成文档失败", e);
}
};
return new ExportResult(provider.getFileName(), writer);
}
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(
"(<w:r>.*?<w:t[^>]*?>)(.*?)(</w:t>.*?</w:r>)",
Pattern.DOTALL).matcher(xml);
var runs = new java.util.ArrayList<Run>();
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<MergedRun>();
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) {}
}
@@ -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\\-_]", "");
}
}
@@ -69,8 +69,12 @@ public class AnnualReportProvider implements TemplateProvider {
@Override
public void postProcess(XWPFDocument document) {
List<XWPFTable> 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<String, String> metric : METRICS) {
XWPFTableRow row = table.createRow();
row.getCell(0).setText(metric.get("name"));