实现Excel转PDF手动转换接口
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
package cn.nianxx.thhotel.integrations.document.libreoffice.adapter;
|
||||
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.service.impl.DocumentConversionProperties;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* LibreOffice Excel 转 PDF 适配器。负责临时目录、独立 profile、soffice 命令和 PDF 输出校验。
|
||||
*/
|
||||
@Component
|
||||
public class LibreOfficeExcelToPdfAdapter implements ExcelToPdfConverter {
|
||||
|
||||
private final DocumentConversionProperties properties;
|
||||
private final LibreOfficeProcessRunner processRunner;
|
||||
|
||||
/**
|
||||
* 注入文件转换配置和 LibreOffice 进程执行端口。
|
||||
*/
|
||||
public LibreOfficeExcelToPdfAdapter(
|
||||
DocumentConversionProperties properties,
|
||||
LibreOfficeProcessRunner processRunner) {
|
||||
this.properties = properties;
|
||||
this.processRunner = processRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Excel 字节写入隔离临时目录后调用 LibreOffice 生成 PDF,并在结束后清理临时文件。
|
||||
*/
|
||||
@Override
|
||||
public ExcelToPdfConvertedDocument convert(ExcelToPdfConversionInput input) {
|
||||
Instant startedAt = Instant.now();
|
||||
Path workDirectory = null;
|
||||
try {
|
||||
Path tempRoot = tempRoot();
|
||||
Files.createDirectories(tempRoot);
|
||||
workDirectory = Files.createTempDirectory(tempRoot, "excel-to-pdf-");
|
||||
Path inputDirectory = Files.createDirectories(workDirectory.resolve("input"));
|
||||
Path outputDirectory = Files.createDirectories(workDirectory.resolve("output"));
|
||||
Path profileDirectory = Files.createDirectories(workDirectory.resolve("lo-profile"));
|
||||
String sourceFileName = safeFileName(input.fileName(), "source.xlsx");
|
||||
Path inputFile = inputDirectory.resolve(sourceFileName);
|
||||
Files.write(inputFile, input.content() == null ? new byte[0] : input.content());
|
||||
LibreOfficeProcessResult processResult = processRunner.run(new LibreOfficeProcessRequest(
|
||||
command(inputFile, outputDirectory, profileDirectory),
|
||||
inputDirectory,
|
||||
inputFile,
|
||||
outputDirectory,
|
||||
profileDirectory,
|
||||
Duration.ofSeconds(Math.max(1, properties.getTimeoutSeconds()))));
|
||||
validateProcessResult(processResult);
|
||||
Path pdfFile = outputDirectory.resolve(pdfFileName(sourceFileName));
|
||||
validatePdfFile(pdfFile);
|
||||
byte[] pdfBytes = Files.readAllBytes(pdfFile);
|
||||
return new ExcelToPdfConvertedDocument(
|
||||
pdfFile.getFileName().toString(),
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
(long) pdfBytes.length,
|
||||
pdfBytes,
|
||||
Duration.between(startedAt, Instant.now()).toMillis());
|
||||
} catch (DocumentConversionException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"DOCUMENT_CONVERSION_FAILED",
|
||||
"Excel 转 PDF 失败。",
|
||||
exception);
|
||||
} finally {
|
||||
cleanup(workDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 LibreOffice headless 命令。
|
||||
*/
|
||||
private List<String> command(Path inputFile, Path outputDirectory, Path profileDirectory) {
|
||||
String sofficePath = properties.getSofficePath() == null || properties.getSofficePath().isBlank()
|
||||
? "soffice"
|
||||
: properties.getSofficePath();
|
||||
return List.of(
|
||||
sofficePath,
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--nofirststartwizard",
|
||||
"--norestore",
|
||||
"-env:UserInstallation=" + profileDirectory.toUri(),
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
outputDirectory.toString(),
|
||||
inputFile.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 LibreOffice 进程结果。
|
||||
*/
|
||||
private void validateProcessResult(LibreOfficeProcessResult result) {
|
||||
if (result == null) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"DOCUMENT_CONVERSION_FAILED",
|
||||
"LibreOffice 未返回转换结果。");
|
||||
}
|
||||
if (result.timedOut()) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.GATEWAY_TIMEOUT,
|
||||
"DOCUMENT_CONVERSION_TIMEOUT",
|
||||
"Excel 转 PDF 超时。");
|
||||
}
|
||||
if (result.exitCode() != 0) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"DOCUMENT_CONVERSION_FAILED",
|
||||
"LibreOffice 转换失败。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 PDF 文件存在且非空。
|
||||
*/
|
||||
private void validatePdfFile(Path pdfFile) throws IOException {
|
||||
if (!Files.exists(pdfFile) || Files.size(pdfFile) <= 0) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"DOCUMENT_CONVERSION_FAILED",
|
||||
"Excel 转 PDF 未生成有效文件。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取转换临时根目录。
|
||||
*/
|
||||
private Path tempRoot() {
|
||||
String configured = properties.getTempDir();
|
||||
String tempDir = configured == null || configured.isBlank()
|
||||
? System.getProperty("java.io.tmpdir") + "/th-hotel-document-conversion"
|
||||
: configured;
|
||||
return Path.of(tempDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 PDF 文件名。
|
||||
*/
|
||||
private String pdfFileName(String sourceFileName) {
|
||||
String baseName = sourceFileName;
|
||||
int dotIndex = baseName.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
baseName = baseName.substring(0, dotIndex);
|
||||
}
|
||||
return baseName + ".pdf";
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件名安全清洗,避免路径穿越和日志污染。
|
||||
*/
|
||||
private String safeFileName(String fileName, String fallback) {
|
||||
String candidate = fileName == null || fileName.isBlank() ? fallback : fileName;
|
||||
int slashIndex = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\'));
|
||||
if (slashIndex >= 0) {
|
||||
candidate = candidate.substring(slashIndex + 1);
|
||||
}
|
||||
String sanitized = candidate.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
if (sanitized.isBlank()) {
|
||||
sanitized = fallback;
|
||||
}
|
||||
return sanitized.length() > 160 ? sanitized.substring(sanitized.length() - 160) : sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理单次转换临时目录。
|
||||
*/
|
||||
private void cleanup(Path workDirectory) {
|
||||
if (workDirectory == null || !Files.exists(workDirectory)) {
|
||||
return;
|
||||
}
|
||||
try (var paths = Files.walk(workDirectory)) {
|
||||
paths.sorted(Comparator.reverseOrder()).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
// 临时文件清理失败不覆盖原始转换结果;运维可通过系统临时目录清理策略兜底。
|
||||
}
|
||||
});
|
||||
} catch (IOException ignored) {
|
||||
// 临时目录遍历失败不覆盖原始转换结果。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.nianxx.thhotel.integrations.document.libreoffice.adapter;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* LibreOffice 外部进程请求。只包含执行命令、输入输出目录和超时信息。
|
||||
*
|
||||
* @param command soffice 命令参数数组
|
||||
* @param workingDirectory soffice 进程工作目录
|
||||
* @param inputFile 输入 Excel 文件
|
||||
* @param outputDirectory PDF 输出目录
|
||||
* @param profileDirectory 本次转换独立 LibreOffice profile 目录
|
||||
* @param timeout 单次转换超时
|
||||
*/
|
||||
public record LibreOfficeProcessRequest(
|
||||
List<String> command,
|
||||
Path workingDirectory,
|
||||
Path inputFile,
|
||||
Path outputDirectory,
|
||||
Path profileDirectory,
|
||||
Duration timeout
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.nianxx.thhotel.integrations.document.libreoffice.adapter;
|
||||
|
||||
/**
|
||||
* LibreOffice 外部进程结果。日志和响应不得直接暴露原始 stdout / stderr 给前端。
|
||||
*
|
||||
* @param exitCode 进程退出码
|
||||
* @param timedOut 是否超时
|
||||
* @param stdout 标准输出摘要
|
||||
* @param stderr 标准错误摘要
|
||||
*/
|
||||
public record LibreOfficeProcessResult(
|
||||
int exitCode,
|
||||
boolean timedOut,
|
||||
String stdout,
|
||||
String stderr
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.nianxx.thhotel.integrations.document.libreoffice.adapter;
|
||||
|
||||
/**
|
||||
* LibreOffice 进程执行端口。便于测试替换,不让单元测试依赖真实 soffice。
|
||||
*/
|
||||
public interface LibreOfficeProcessRunner {
|
||||
|
||||
/**
|
||||
* 执行 LibreOffice 转换命令并返回安全进程结果。
|
||||
*/
|
||||
LibreOfficeProcessResult run(LibreOfficeProcessRequest request);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package cn.nianxx.thhotel.integrations.document.libreoffice.adapter;
|
||||
|
||||
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 基于 ProcessBuilder 的 LibreOffice 进程执行器。只由 LibreOffice Adapter 使用。
|
||||
*/
|
||||
@Component
|
||||
public class ProcessBuilderLibreOfficeProcessRunner implements LibreOfficeProcessRunner {
|
||||
|
||||
/**
|
||||
* 执行 soffice 命令并处理超时;调用方负责解释退出码和输出文件。
|
||||
*/
|
||||
@Override
|
||||
public LibreOfficeProcessResult run(LibreOfficeProcessRequest request) {
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(request.command());
|
||||
if (request.workingDirectory() != null) {
|
||||
processBuilder.directory(request.workingDirectory().toFile());
|
||||
}
|
||||
processBuilder.redirectErrorStream(true);
|
||||
try {
|
||||
Process process = processBuilder.start();
|
||||
CompletableFuture<String> outputFuture = CompletableFuture.supplyAsync(() -> readOutput(process.getInputStream()));
|
||||
boolean completed = process.waitFor(request.timeout().toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!completed) {
|
||||
destroyProcessTree(process);
|
||||
return new LibreOfficeProcessResult(-1, true, safeGetOutput(outputFuture), "");
|
||||
}
|
||||
return new LibreOfficeProcessResult(process.exitValue(), false, safeGetOutput(outputFuture), "");
|
||||
} catch (Exception exception) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"DOCUMENT_CONVERSION_FAILED",
|
||||
"调用 LibreOffice 转换失败。",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 超时时销毁 LibreOffice 进程树,避免 soffice 派生子进程继续占用临时目录或 CPU。
|
||||
*/
|
||||
private void destroyProcessTree(Process process) {
|
||||
ProcessHandle root = process.toHandle();
|
||||
List<ProcessHandle> processTree = new ArrayList<>();
|
||||
root.descendants().forEach(processTree::add);
|
||||
processTree.sort(Comparator.comparingLong(ProcessHandle::pid).reversed());
|
||||
processTree.forEach(ProcessHandle::destroy);
|
||||
root.destroy();
|
||||
waitForProcessTree(root, processTree, Duration.ofMillis(500));
|
||||
processTree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
|
||||
if (root.isAlive()) {
|
||||
root.destroyForcibly();
|
||||
}
|
||||
waitForProcessTree(root, processTree, Duration.ofSeconds(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待进程树退出;等待失败不抛出,调用方已经返回安全超时错误。
|
||||
*/
|
||||
private void waitForProcessTree(ProcessHandle root, List<ProcessHandle> descendants, Duration timeout) {
|
||||
long deadline = System.nanoTime() + timeout.toNanos();
|
||||
for (ProcessHandle descendant : descendants) {
|
||||
waitForHandle(descendant, deadline);
|
||||
}
|
||||
waitForHandle(root, deadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待单个进程退出。
|
||||
*/
|
||||
private void waitForHandle(ProcessHandle handle, long deadlineNanos) {
|
||||
if (!handle.isAlive()) {
|
||||
return;
|
||||
}
|
||||
long remainingNanos = deadlineNanos - System.nanoTime();
|
||||
if (remainingNanos <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handle.onExit().get(remainingNanos, TimeUnit.NANOSECONDS);
|
||||
} catch (Exception ignored) {
|
||||
// 超时或等待失败时继续执行后续 destroyForcibly / 清理流程。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取进程输出,最多保留前 4000 个字符,避免日志或内存被异常输出撑爆。
|
||||
*/
|
||||
private String readOutput(InputStream inputStream) {
|
||||
try (InputStream stream = inputStream) {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[512];
|
||||
int totalKept = 0;
|
||||
int read;
|
||||
while ((read = stream.read(chunk)) >= 0) {
|
||||
if (totalKept < 4000) {
|
||||
int keep = Math.min(read, 4000 - totalKept);
|
||||
buffer.write(chunk, 0, keep);
|
||||
totalKept += keep;
|
||||
}
|
||||
}
|
||||
return buffer.toString(StandardCharsets.UTF_8);
|
||||
} catch (Exception exception) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取异步输出;读取失败时返回空字符串。
|
||||
*/
|
||||
private String safeGetOutput(CompletableFuture<String> outputFuture) {
|
||||
try {
|
||||
return outputFuture.get(1, TimeUnit.SECONDS);
|
||||
} catch (Exception exception) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.common.dto;
|
||||
|
||||
/**
|
||||
* Excel 转 PDF 输入文档。由平台转换服务完成上传校验后传给转换引擎。
|
||||
*
|
||||
* @param fileName 安全文件名
|
||||
* @param contentType 上传文件 MIME 类型
|
||||
* @param sizeBytes 上传文件大小字节数
|
||||
* @param content 上传文件字节内容
|
||||
*/
|
||||
public record ExcelToPdfConversionInput(
|
||||
String fileName,
|
||||
String contentType,
|
||||
Long sizeBytes,
|
||||
byte[] content
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.common.dto;
|
||||
|
||||
/**
|
||||
* Excel 转 PDF 输出文档。转换引擎只返回 PDF 字节和安全元数据,不负责 OSS 上传。
|
||||
*
|
||||
* @param fileName 生成的 PDF 文件名
|
||||
* @param contentType PDF MIME 类型
|
||||
* @param sizeBytes PDF 文件大小字节数
|
||||
* @param content PDF 文件字节内容
|
||||
* @param durationMillis 转换耗时毫秒
|
||||
*/
|
||||
public record ExcelToPdfConvertedDocument(
|
||||
String fileName,
|
||||
String contentType,
|
||||
Long sizeBytes,
|
||||
byte[] content,
|
||||
Long durationMillis
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.common.enums;
|
||||
|
||||
/**
|
||||
* 文件转换状态。CP2 手动上传接口只返回成功状态,失败通过受控错误响应表达。
|
||||
*/
|
||||
public enum DocumentConversionStatus {
|
||||
|
||||
/** 转换并上传 PDF 成功。 */
|
||||
SUCCEEDED
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 文件转换接口错误响应。只返回安全错误码和摘要,不暴露文件内容、临时路径或 Secret。
|
||||
*
|
||||
* @param errorCode 错误码
|
||||
* @param message 安全错误摘要
|
||||
*/
|
||||
public record DocumentConversionErrorResponse(
|
||||
@JsonProperty("error_code")
|
||||
String errorCode,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Excel 转 PDF 手动上传接口结果。返回 PDF 的 OSS 访问信息和安全转换摘要。
|
||||
*
|
||||
* @param conversionStatus 转换状态
|
||||
* @param sourceFileName 上传 Excel 文件名
|
||||
* @param sourceSizeBytes 上传 Excel 文件大小字节数
|
||||
* @param pdfFileName 生成 PDF 文件名
|
||||
* @param pdfUrl PDF OSS 访问 URL
|
||||
* @param objectKey PDF OSS 对象路径
|
||||
* @param contentType PDF MIME 类型
|
||||
* @param pdfSizeBytes PDF 文件大小字节数
|
||||
* @param durationMillis 转换耗时毫秒
|
||||
* @param safeErrorSummary 安全错误摘要,成功时为空
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ExcelToPdfConversionResult(
|
||||
@JsonProperty("conversion_status")
|
||||
String conversionStatus,
|
||||
@JsonProperty("source_file_name")
|
||||
String sourceFileName,
|
||||
@JsonProperty("source_size_bytes")
|
||||
Long sourceSizeBytes,
|
||||
@JsonProperty("pdf_file_name")
|
||||
String pdfFileName,
|
||||
@JsonProperty("pdf_url")
|
||||
String pdfUrl,
|
||||
@JsonProperty("object_key")
|
||||
String objectKey,
|
||||
@JsonProperty("content_type")
|
||||
String contentType,
|
||||
@JsonProperty("pdf_size_bytes")
|
||||
Long pdfSizeBytes,
|
||||
@JsonProperty("duration_millis")
|
||||
Long durationMillis,
|
||||
@JsonProperty("safe_error_summary")
|
||||
String safeErrorSummary
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.control;
|
||||
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.result.ExcelToPdfConversionResult;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 文件转换 Controller。当前只开放受控 Excel 转 PDF 手动上传接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/system/document-conversions")
|
||||
public class DocumentConversionController {
|
||||
|
||||
private final DocumentConversionService documentConversionService;
|
||||
|
||||
/**
|
||||
* 注入文件转换服务,Controller 不直接调用 LibreOffice 或 OSS。
|
||||
*/
|
||||
public DocumentConversionController(DocumentConversionService documentConversionService) {
|
||||
this.documentConversionService = documentConversionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 Excel 并转换为 PDF,成功后返回 PDF OSS URL。
|
||||
*/
|
||||
@PostMapping(path = "/excel-to-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<ExcelToPdfConversionResult> convertExcelToPdf(
|
||||
@RequestHeader(name = "X-TH-Hotel-Document-Conversion-Key", required = false) String accessKey,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(name = "hotel_id", required = false) String hotelId) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(documentConversionService.convertExcelToPdf(accessKey, file, hotelId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.control;
|
||||
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.result.DocumentConversionErrorResponse;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
|
||||
/**
|
||||
* 文件转换接口异常转换。只返回安全错误摘要,不暴露 Excel 内容、PDF 内容、临时路径或 Secret。
|
||||
*/
|
||||
@RestControllerAdvice(assignableTypes = DocumentConversionController.class)
|
||||
public class DocumentConversionControllerAdvice {
|
||||
|
||||
/**
|
||||
* 转换文件转换业务异常。
|
||||
*/
|
||||
@ExceptionHandler(DocumentConversionException.class)
|
||||
public ResponseEntity<DocumentConversionErrorResponse> handleDocumentConversionException(
|
||||
DocumentConversionException exception) {
|
||||
return ResponseEntity.status(exception.getStatus())
|
||||
.body(new DocumentConversionErrorResponse(exception.getErrorCode(), exception.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换缺少 multipart 文件或必填参数的异常。
|
||||
*/
|
||||
@ExceptionHandler({MissingServletRequestPartException.class, MissingServletRequestParameterException.class})
|
||||
public ResponseEntity<DocumentConversionErrorResponse> handleMissingRequestPart(Exception exception) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new DocumentConversionErrorResponse(
|
||||
"DOCUMENT_CONVERSION_FILE_REQUIRED",
|
||||
"请上传 Excel 文件。"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.service;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* 文件转换受控异常。HTTP 层只返回错误码和安全摘要,不暴露文件内容、临时路径或外部命令细节。
|
||||
*/
|
||||
public class DocumentConversionException extends RuntimeException {
|
||||
|
||||
private final HttpStatus status;
|
||||
private final String errorCode;
|
||||
|
||||
/**
|
||||
* 创建文件转换受控异常。
|
||||
*/
|
||||
public DocumentConversionException(HttpStatus status, String errorCode, String message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带内部原因的文件转换受控异常。
|
||||
*/
|
||||
public DocumentConversionException(HttpStatus status, String errorCode, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.status = status;
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.service;
|
||||
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.result.ExcelToPdfConversionResult;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 平台文件转换服务。对 Controller 提供受控上传转换能力,不暴露 LibreOffice 或 OSS SDK 细节。
|
||||
*/
|
||||
public interface DocumentConversionService {
|
||||
|
||||
/**
|
||||
* 上传 Excel 并转换为 PDF 后上传 OSS。调用方必须提供受控访问口令。
|
||||
*/
|
||||
ExcelToPdfConversionResult convertExcelToPdf(String accessKey, MultipartFile file, String hotelId);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.service;
|
||||
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument;
|
||||
|
||||
/**
|
||||
* Excel 转 PDF 转换端口。平台服务依赖该端口,具体实现可由 LibreOffice 或其他引擎提供。
|
||||
*/
|
||||
public interface ExcelToPdfConverter {
|
||||
|
||||
/**
|
||||
* 将 Excel 字节转换为 PDF 字节;实现类负责隔离外部转换引擎和临时文件。
|
||||
*/
|
||||
ExcelToPdfConvertedDocument convert(ExcelToPdfConversionInput input);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.service.impl;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 平台文件转换配置。Secret 只能通过环境变量或部署平台注入,不得写入前端。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "document-conversion")
|
||||
public class DocumentConversionProperties {
|
||||
|
||||
/** 是否启用文件转换接口。 */
|
||||
private boolean enabled = false;
|
||||
/** 手动上传转换访问口令。 */
|
||||
private String accessKey = "";
|
||||
/** LibreOffice soffice 可执行文件路径。 */
|
||||
private String sofficePath = "soffice";
|
||||
/** 文件转换临时目录根路径。 */
|
||||
private String tempDir = System.getProperty("java.io.tmpdir") + "/th-hotel-document-conversion";
|
||||
/** 单个 Excel 最大字节数。 */
|
||||
private long maxFileBytes = 20 * 1024 * 1024L;
|
||||
/** 单次转换超时时间秒。 */
|
||||
private long timeoutSeconds = 60L;
|
||||
/** 最大并发转换数。 */
|
||||
private int maxConcurrent = 2;
|
||||
/** PDF 输出 OSS 前缀。 */
|
||||
private String outputOssPrefix = "document-conversions/excel-to-pdf/";
|
||||
/** 自动转换 worker 开关,CP2 暂不使用,预留给后续邮件附件自动转换。 */
|
||||
private boolean workerEnabled = false;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getAccessKey() {
|
||||
return accessKey;
|
||||
}
|
||||
|
||||
public void setAccessKey(String accessKey) {
|
||||
this.accessKey = accessKey;
|
||||
}
|
||||
|
||||
public String getSofficePath() {
|
||||
return sofficePath;
|
||||
}
|
||||
|
||||
public void setSofficePath(String sofficePath) {
|
||||
this.sofficePath = sofficePath;
|
||||
}
|
||||
|
||||
public String getTempDir() {
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
public void setTempDir(String tempDir) {
|
||||
this.tempDir = tempDir;
|
||||
}
|
||||
|
||||
public long getMaxFileBytes() {
|
||||
return maxFileBytes;
|
||||
}
|
||||
|
||||
public void setMaxFileBytes(long maxFileBytes) {
|
||||
this.maxFileBytes = maxFileBytes;
|
||||
}
|
||||
|
||||
public long getTimeoutSeconds() {
|
||||
return timeoutSeconds;
|
||||
}
|
||||
|
||||
public void setTimeoutSeconds(long timeoutSeconds) {
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
|
||||
public int getMaxConcurrent() {
|
||||
return maxConcurrent;
|
||||
}
|
||||
|
||||
public void setMaxConcurrent(int maxConcurrent) {
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
}
|
||||
|
||||
public String getOutputOssPrefix() {
|
||||
return outputOssPrefix;
|
||||
}
|
||||
|
||||
public void setOutputOssPrefix(String outputOssPrefix) {
|
||||
this.outputOssPrefix = outputOssPrefix;
|
||||
}
|
||||
|
||||
public boolean isWorkerEnabled() {
|
||||
return workerEnabled;
|
||||
}
|
||||
|
||||
public void setWorkerEnabled(boolean workerEnabled) {
|
||||
this.workerEnabled = workerEnabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.service.impl;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest;
|
||||
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult;
|
||||
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.enums.DocumentConversionStatus;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.common.result.ExcelToPdfConversionResult;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionService;
|
||||
import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 平台文件转换服务实现。负责访问口令、文件校验、转换编排、OSS 输出和安全错误转换。
|
||||
*/
|
||||
@Service
|
||||
public class DocumentConversionServiceImpl implements DocumentConversionService {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
|
||||
private final DocumentConversionProperties properties;
|
||||
private final ExcelToPdfConverter excelToPdfConverter;
|
||||
private final ObjectStorageService objectStorageService;
|
||||
private final Semaphore conversionSemaphore;
|
||||
|
||||
/**
|
||||
* 注入转换配置、Excel 转 PDF 端口和 OSS 上传端口。
|
||||
*/
|
||||
public DocumentConversionServiceImpl(
|
||||
DocumentConversionProperties properties,
|
||||
ExcelToPdfConverter excelToPdfConverter,
|
||||
ObjectStorageService objectStorageService) {
|
||||
this.properties = properties;
|
||||
this.excelToPdfConverter = excelToPdfConverter;
|
||||
this.objectStorageService = objectStorageService;
|
||||
this.conversionSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrent()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 Excel 并转换为 PDF 后上传 OSS;当前 CP2 不落库,只返回本次转换结果。
|
||||
*/
|
||||
@Override
|
||||
public ExcelToPdfConversionResult convertExcelToPdf(String accessKey, MultipartFile file, String hotelId) {
|
||||
validateEnabled();
|
||||
validateAccessKey(accessKey);
|
||||
validateFile(file);
|
||||
String sourceFileName = safeFileName(file.getOriginalFilename(), "source.xlsx");
|
||||
acquirePermit();
|
||||
try {
|
||||
byte[] excelBytes = readFileBytes(file);
|
||||
validateFileSignature(sourceFileName, excelBytes);
|
||||
ExcelToPdfConvertedDocument converted = excelToPdfConverter.convert(new ExcelToPdfConversionInput(
|
||||
sourceFileName,
|
||||
file.getContentType(),
|
||||
file.getSize(),
|
||||
excelBytes));
|
||||
validateConvertedDocument(converted);
|
||||
ObjectStoragePutResult putResult = uploadPdf(hotelId, sourceFileName, converted);
|
||||
return new ExcelToPdfConversionResult(
|
||||
DocumentConversionStatus.SUCCEEDED.name(),
|
||||
sourceFileName,
|
||||
file.getSize(),
|
||||
converted.fileName(),
|
||||
putResult.publicUrl(),
|
||||
putResult.objectKey(),
|
||||
putResult.contentType(),
|
||||
putResult.sizeBytes(),
|
||||
converted.durationMillis(),
|
||||
null);
|
||||
} finally {
|
||||
conversionSemaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件转换总开关。
|
||||
*/
|
||||
private void validateEnabled() {
|
||||
if (!properties.isEnabled()) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"DOCUMENT_CONVERSION_DISABLED",
|
||||
"文件转换接口未启用。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验手动上传转换访问口令。
|
||||
*/
|
||||
private void validateAccessKey(String accessKey) {
|
||||
if (properties.getAccessKey() == null || properties.getAccessKey().isBlank()
|
||||
|| accessKey == null || !properties.getAccessKey().equals(accessKey)) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
"DOCUMENT_CONVERSION_KEY_INVALID",
|
||||
"文件转换访问口令缺失或错误。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验上传文件类型、大小和扩展名。
|
||||
*/
|
||||
private void validateFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"DOCUMENT_CONVERSION_FILE_REQUIRED",
|
||||
"请上传 Excel 文件。");
|
||||
}
|
||||
if (file.getSize() > properties.getMaxFileBytes()) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
"DOCUMENT_CONVERSION_FILE_TOO_LARGE",
|
||||
"上传的 Excel 文件超过大小限制。");
|
||||
}
|
||||
String fileName = safeFileName(file.getOriginalFilename(), "");
|
||||
String lowerName = fileName.toLowerCase(Locale.ROOT);
|
||||
if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"DOCUMENT_CONVERSION_FILE_TYPE_UNSUPPORTED",
|
||||
"只支持上传 .xls 或 .xlsx 文件。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取上传文件字节。
|
||||
*/
|
||||
private byte[] readFileBytes(MultipartFile file) {
|
||||
try {
|
||||
return file.getBytes();
|
||||
} catch (Exception exception) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"DOCUMENT_CONVERSION_FILE_READ_FAILED",
|
||||
"读取上传 Excel 文件失败。",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Excel 文件头,避免只改扩展名的任意内容进入 LibreOffice 解析器。
|
||||
*/
|
||||
private void validateFileSignature(String sourceFileName, byte[] content) {
|
||||
String lowerName = sourceFileName.toLowerCase(Locale.ROOT);
|
||||
boolean valid;
|
||||
if (lowerName.endsWith(".xlsx")) {
|
||||
valid = content.length >= 4 && content[0] == 0x50 && content[1] == 0x4B;
|
||||
} else {
|
||||
valid = content.length >= 8
|
||||
&& (content[0] & 0xFF) == 0xD0
|
||||
&& (content[1] & 0xFF) == 0xCF
|
||||
&& (content[2] & 0xFF) == 0x11
|
||||
&& (content[3] & 0xFF) == 0xE0
|
||||
&& (content[4] & 0xFF) == 0xA1
|
||||
&& (content[5] & 0xFF) == 0xB1
|
||||
&& (content[6] & 0xFF) == 0x1A
|
||||
&& (content[7] & 0xFF) == 0xE1;
|
||||
}
|
||||
if (!valid) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"DOCUMENT_CONVERSION_FILE_CONTENT_INVALID",
|
||||
"上传文件内容不是有效的 Excel 文件。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取转换并发许可,避免大量文件同时触发 LibreOffice 进程。
|
||||
*/
|
||||
private void acquirePermit() {
|
||||
if (!conversionSemaphore.tryAcquire()) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
"DOCUMENT_CONVERSION_BUSY",
|
||||
"文件转换任务繁忙,请稍后重试。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验转换器返回的 PDF 内容。
|
||||
*/
|
||||
private void validateConvertedDocument(ExcelToPdfConvertedDocument converted) {
|
||||
if (converted == null || converted.content() == null || converted.content().length == 0) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"DOCUMENT_CONVERSION_FAILED",
|
||||
"Excel 转 PDF 未生成有效文件。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 PDF 到 OSS,并把 OSS 失败转换为安全错误。
|
||||
*/
|
||||
private ObjectStoragePutResult uploadPdf(
|
||||
String hotelId,
|
||||
String sourceFileName,
|
||||
ExcelToPdfConvertedDocument converted) {
|
||||
String pdfFileName = safeFileName(converted.fileName(), pdfFileName(sourceFileName));
|
||||
String objectKey = objectKey(hotelId, pdfFileName);
|
||||
try {
|
||||
return objectStorageService.putObject(new ObjectStoragePutRequest(
|
||||
objectKey,
|
||||
pdfFileName,
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
(long) converted.content().length,
|
||||
converted.content()));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new DocumentConversionException(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"DOCUMENT_CONVERSION_OSS_UPLOAD_FAILED",
|
||||
"PDF 上传 OSS 失败。",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 PDF OSS 对象路径。
|
||||
*/
|
||||
private String objectKey(String hotelId, String pdfFileName) {
|
||||
String prefix = properties.getOutputOssPrefix();
|
||||
String normalizedPrefix = prefix == null || prefix.isBlank()
|
||||
? "document-conversions/excel-to-pdf/"
|
||||
: prefix;
|
||||
if (!normalizedPrefix.endsWith("/")) {
|
||||
normalizedPrefix = normalizedPrefix + "/";
|
||||
}
|
||||
String normalizedHotelId = safePathPart(hotelId == null || hotelId.isBlank() ? "UNKNOWN-HOTEL" : hotelId);
|
||||
String today = DATE_FORMATTER.format(LocalDate.now(ZoneOffset.UTC));
|
||||
return normalizedPrefix + normalizedHotelId + "/" + today + "/" + UUID.randomUUID() + "/" + pdfFileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 Excel 文件名生成 PDF 文件名。
|
||||
*/
|
||||
private String pdfFileName(String sourceFileName) {
|
||||
String baseName = sourceFileName;
|
||||
int dotIndex = baseName.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
baseName = baseName.substring(0, dotIndex);
|
||||
}
|
||||
return baseName + ".pdf";
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件名安全清洗,避免路径穿越和日志污染。
|
||||
*/
|
||||
private String safeFileName(String fileName, String fallback) {
|
||||
String candidate = fileName == null || fileName.isBlank() ? fallback : fileName;
|
||||
int slashIndex = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\'));
|
||||
if (slashIndex >= 0) {
|
||||
candidate = candidate.substring(slashIndex + 1);
|
||||
}
|
||||
String sanitized = candidate.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
if (sanitized.isBlank()) {
|
||||
sanitized = fallback;
|
||||
}
|
||||
return sanitized.length() > 160 ? sanitized.substring(sanitized.length() - 160) : sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS 路径片段安全清洗。
|
||||
*/
|
||||
private String safePathPart(String value) {
|
||||
String sanitized = value.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
return sanitized.isBlank() ? "UNKNOWN" : sanitized;
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,9 @@ spring:
|
||||
enabled: true
|
||||
servlet:
|
||||
multipart:
|
||||
# dev multipart 上限高于业务上限,超限文件由 Debug EML 服务层返回受控 JSON 错误。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_DEV_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}}
|
||||
|
||||
source-message:
|
||||
original-read:
|
||||
# dev 邮件原文读取访问密钥;优先使用 dev 专属变量,兼容旧通用变量。
|
||||
access-key: ${SOURCE_MESSAGE_DEV_ORIGINAL_READ_ACCESS_KEY:${SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY:}}
|
||||
# dev multipart 上限高于 Debug EML / 文件转换业务上限,超限文件由服务层返回受控 JSON 错误。
|
||||
max-file-size: ${DOCUMENT_CONVERSION_DEV_MULTIPART_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:25165824}}}}
|
||||
max-request-size: ${DOCUMENT_CONVERSION_DEV_MULTIPART_MAX_REQUEST_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_DEV_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_DEV_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:29360128}}}}}
|
||||
|
||||
agentbus:
|
||||
probe:
|
||||
@@ -73,6 +68,18 @@ debug:
|
||||
max-file-bytes: ${DEBUG_EML_UPLOAD_DEV_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}}
|
||||
sse-heartbeat-interval: ${DEBUG_EML_UPLOAD_DEV_SSE_HEARTBEAT_INTERVAL:${DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL:15s}}
|
||||
|
||||
document-conversion:
|
||||
# dev 默认关闭;本地安装 LibreOffice 和字体后再显式开启。
|
||||
enabled: ${DOCUMENT_CONVERSION_DEV_ENABLED:${DOCUMENT_CONVERSION_ENABLED:false}}
|
||||
access-key: ${DOCUMENT_CONVERSION_DEV_ACCESS_KEY:${DOCUMENT_CONVERSION_ACCESS_KEY:}}
|
||||
soffice-path: ${DOCUMENT_CONVERSION_DEV_SOFFICE_PATH:${DOCUMENT_CONVERSION_SOFFICE_PATH:soffice}}
|
||||
temp-dir: ${DOCUMENT_CONVERSION_DEV_TEMP_DIR:${DOCUMENT_CONVERSION_TEMP_DIR:${java.io.tmpdir}/th-hotel-document-conversion}}
|
||||
max-file-bytes: ${DOCUMENT_CONVERSION_DEV_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MAX_FILE_BYTES:20971520}}
|
||||
timeout-seconds: ${DOCUMENT_CONVERSION_DEV_TIMEOUT_SECONDS:${DOCUMENT_CONVERSION_TIMEOUT_SECONDS:60}}
|
||||
max-concurrent: ${DOCUMENT_CONVERSION_DEV_MAX_CONCURRENT:${DOCUMENT_CONVERSION_MAX_CONCURRENT:2}}
|
||||
output-oss-prefix: ${DOCUMENT_CONVERSION_DEV_OUTPUT_OSS_PREFIX:${DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX:document-conversions/excel-to-pdf/}}
|
||||
worker-enabled: ${DOCUMENT_CONVERSION_DEV_WORKER_ENABLED:${DOCUMENT_CONVERSION_WORKER_ENABLED:false}}
|
||||
|
||||
auth:
|
||||
bootstrap:
|
||||
admin:
|
||||
|
||||
@@ -9,14 +9,9 @@ spring:
|
||||
enabled: true
|
||||
servlet:
|
||||
multipart:
|
||||
# prod Debug EML 如被显式开启,multipart 上限应高于业务上限,便于服务层返回受控错误。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_PROD_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}}
|
||||
|
||||
source-message:
|
||||
original-read:
|
||||
# prod 邮件原文读取访问密钥;只能通过生产 Secret / 环境变量注入。
|
||||
access-key: ${SOURCE_MESSAGE_PROD_ORIGINAL_READ_ACCESS_KEY:${SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY:}}
|
||||
# prod Debug EML / 文件转换如被显式开启,multipart 上限应高于业务上限,便于服务层返回受控错误。
|
||||
max-file-size: ${DOCUMENT_CONVERSION_PROD_MULTIPART_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:25165824}}}}
|
||||
max-request-size: ${DOCUMENT_CONVERSION_PROD_MULTIPART_MAX_REQUEST_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_PROD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_PROD_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:29360128}}}}}
|
||||
|
||||
agentbus:
|
||||
probe:
|
||||
@@ -71,6 +66,18 @@ debug:
|
||||
max-file-bytes: ${DEBUG_EML_UPLOAD_PROD_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}}
|
||||
sse-heartbeat-interval: ${DEBUG_EML_UPLOAD_PROD_SSE_HEARTBEAT_INTERVAL:${DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL:15s}}
|
||||
|
||||
document-conversion:
|
||||
# prod 默认关闭;启用前必须确认 LibreOffice、字体、OSS、访问口令、监控和临时目录清理策略。
|
||||
enabled: ${DOCUMENT_CONVERSION_PROD_ENABLED:false}
|
||||
access-key: ${DOCUMENT_CONVERSION_PROD_ACCESS_KEY:${DOCUMENT_CONVERSION_ACCESS_KEY}}
|
||||
soffice-path: ${DOCUMENT_CONVERSION_PROD_SOFFICE_PATH:${DOCUMENT_CONVERSION_SOFFICE_PATH:soffice}}
|
||||
temp-dir: ${DOCUMENT_CONVERSION_PROD_TEMP_DIR:${DOCUMENT_CONVERSION_TEMP_DIR:${java.io.tmpdir}/th-hotel-document-conversion}}
|
||||
max-file-bytes: ${DOCUMENT_CONVERSION_PROD_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MAX_FILE_BYTES:20971520}}
|
||||
timeout-seconds: ${DOCUMENT_CONVERSION_PROD_TIMEOUT_SECONDS:${DOCUMENT_CONVERSION_TIMEOUT_SECONDS:60}}
|
||||
max-concurrent: ${DOCUMENT_CONVERSION_PROD_MAX_CONCURRENT:${DOCUMENT_CONVERSION_MAX_CONCURRENT:2}}
|
||||
output-oss-prefix: ${DOCUMENT_CONVERSION_PROD_OUTPUT_OSS_PREFIX:${DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX:document-conversions/excel-to-pdf/}}
|
||||
worker-enabled: ${DOCUMENT_CONVERSION_PROD_WORKER_ENABLED:${DOCUMENT_CONVERSION_WORKER_ENABLED:false}}
|
||||
|
||||
auth:
|
||||
bootstrap:
|
||||
admin:
|
||||
|
||||
@@ -9,14 +9,9 @@ spring:
|
||||
enabled: true
|
||||
servlet:
|
||||
multipart:
|
||||
# test multipart 上限高于业务上限,超限文件由 Debug EML 服务层返回受控 JSON 错误。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_TEST_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}}}
|
||||
|
||||
source-message:
|
||||
original-read:
|
||||
# test 邮件原文读取访问密钥;优先使用 test 专属变量,兼容旧通用变量。
|
||||
access-key: ${SOURCE_MESSAGE_TEST_ORIGINAL_READ_ACCESS_KEY:${SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY:}}
|
||||
# test multipart 上限高于 Debug EML / 文件转换业务上限,超限文件由服务层返回受控 JSON 错误。
|
||||
max-file-size: ${DOCUMENT_CONVERSION_TEST_MULTIPART_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:25165824}}}}
|
||||
max-request-size: ${DOCUMENT_CONVERSION_TEST_MULTIPART_MAX_REQUEST_BYTES:${DOCUMENT_CONVERSION_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_TEST_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_TEST_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:29360128}}}}}
|
||||
|
||||
agentbus:
|
||||
probe:
|
||||
@@ -75,6 +70,18 @@ debug:
|
||||
max-file-bytes: ${DEBUG_EML_UPLOAD_TEST_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}}
|
||||
sse-heartbeat-interval: ${DEBUG_EML_UPLOAD_TEST_SSE_HEARTBEAT_INTERVAL:${DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL:15s}}
|
||||
|
||||
document-conversion:
|
||||
# test 默认关闭;联调时确认 LibreOffice、字体和 OSS 后开启。
|
||||
enabled: ${DOCUMENT_CONVERSION_TEST_ENABLED:${DOCUMENT_CONVERSION_ENABLED:false}}
|
||||
access-key: ${DOCUMENT_CONVERSION_TEST_ACCESS_KEY:${DOCUMENT_CONVERSION_ACCESS_KEY:}}
|
||||
soffice-path: ${DOCUMENT_CONVERSION_TEST_SOFFICE_PATH:${DOCUMENT_CONVERSION_SOFFICE_PATH:soffice}}
|
||||
temp-dir: ${DOCUMENT_CONVERSION_TEST_TEMP_DIR:${DOCUMENT_CONVERSION_TEMP_DIR:${java.io.tmpdir}/th-hotel-document-conversion}}
|
||||
max-file-bytes: ${DOCUMENT_CONVERSION_TEST_MAX_FILE_BYTES:${DOCUMENT_CONVERSION_MAX_FILE_BYTES:20971520}}
|
||||
timeout-seconds: ${DOCUMENT_CONVERSION_TEST_TIMEOUT_SECONDS:${DOCUMENT_CONVERSION_TIMEOUT_SECONDS:60}}
|
||||
max-concurrent: ${DOCUMENT_CONVERSION_TEST_MAX_CONCURRENT:${DOCUMENT_CONVERSION_MAX_CONCURRENT:2}}
|
||||
output-oss-prefix: ${DOCUMENT_CONVERSION_TEST_OUTPUT_OSS_PREFIX:${DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX:document-conversions/excel-to-pdf/}}
|
||||
worker-enabled: ${DOCUMENT_CONVERSION_TEST_WORKER_ENABLED:${DOCUMENT_CONVERSION_WORKER_ENABLED:false}}
|
||||
|
||||
auth:
|
||||
bootstrap:
|
||||
admin:
|
||||
|
||||
@@ -8,9 +8,9 @@ spring:
|
||||
enabled: true
|
||||
servlet:
|
||||
multipart:
|
||||
# multipart 需要高于 Debug EML 业务文件上限,避免超限文件在进入 Controller 前被框架直接 413 拦截。
|
||||
max-file-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:20971520}
|
||||
max-request-size: ${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:25165824}}
|
||||
# multipart 需要高于 Debug EML / 文件转换业务上限,避免超限文件在进入 Controller 前被框架直接 413 拦截。
|
||||
max-file-size: ${DOCUMENT_CONVERSION_MULTIPART_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_FILE_BYTES:25165824}}
|
||||
max-request-size: ${DOCUMENT_CONVERSION_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MULTIPART_MAX_REQUEST_BYTES:${DEBUG_EML_UPLOAD_MAX_REQUEST_BYTES:29360128}}}
|
||||
mvc:
|
||||
async:
|
||||
# Spring MVC async timeout 是应用级全局值;当前主要用于避免 Debug EML SSE 先于 SuperAgent 调试调用关闭。
|
||||
@@ -50,6 +50,18 @@ auth:
|
||||
# 登录 session 默认 12 小时;各环境可通过 AUTH_*_SESSION_TTL_MINUTES 覆盖。
|
||||
ttl-minutes: ${AUTH_SESSION_TTL_MINUTES:720}
|
||||
|
||||
document-conversion:
|
||||
# M008 Excel 转 PDF 文件转换能力,默认关闭;启用前必须确认 LibreOffice、字体、OSS 和访问口令。
|
||||
enabled: ${DOCUMENT_CONVERSION_ENABLED:false}
|
||||
access-key: ${DOCUMENT_CONVERSION_ACCESS_KEY:}
|
||||
soffice-path: ${DOCUMENT_CONVERSION_SOFFICE_PATH:soffice}
|
||||
temp-dir: ${DOCUMENT_CONVERSION_TEMP_DIR:${java.io.tmpdir}/th-hotel-document-conversion}
|
||||
max-file-bytes: ${DOCUMENT_CONVERSION_MAX_FILE_BYTES:20971520}
|
||||
timeout-seconds: ${DOCUMENT_CONVERSION_TIMEOUT_SECONDS:60}
|
||||
max-concurrent: ${DOCUMENT_CONVERSION_MAX_CONCURRENT:2}
|
||||
output-oss-prefix: ${DOCUMENT_CONVERSION_OUTPUT_OSS_PREFIX:document-conversions/excel-to-pdf/}
|
||||
worker-enabled: ${DOCUMENT_CONVERSION_WORKER_ENABLED:false}
|
||||
|
||||
mcp:
|
||||
# SuperAgent MCP 默认关闭;启用时必须通过部署环境配置高熵 Bearer Token。
|
||||
enabled: ${MCP_ENABLED:false}
|
||||
|
||||
Reference in New Issue
Block a user