Archived
refactor: export API returns JSON with download URL, add FileCacheService
This commit is contained in:
@@ -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());
|
||||
try {
|
||||
// 1. Pre-process: merge split runs
|
||||
byte[] preprocessed = mergeSplitPlaceholders(provider.getTemplateResource());
|
||||
|
||||
// 2. Get the underlying document for post-processing
|
||||
NiceXWPFDocument doc = template.getXWPFDocument();
|
||||
|
||||
// 3. Post-process (e.g. add table rows)
|
||||
provider.postProcess(doc);
|
||||
|
||||
// 4. Write to output stream
|
||||
template.write(outputStream);
|
||||
template.close();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("生成文档失败", e);
|
||||
// 2. Build data model
|
||||
Map<String, Object> data = new java.util.HashMap<>(provider.getDataModel());
|
||||
if (businessData != null) {
|
||||
data.putAll(businessData);
|
||||
}
|
||||
};
|
||||
|
||||
return new ExportResult(provider.getFileName(), writer);
|
||||
// 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 UncheckedIOException("生成文档失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user