实现Excel转PDF手动转换接口

This commit is contained in:
andy
2026-07-16 18:25:51 +07:00
parent ed37d5f955
commit 8d53b72363
30 changed files with 1995 additions and 52 deletions

View File

@@ -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) {
// 临时目录遍历失败不覆盖原始转换结果。
}
}
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,12 @@
package cn.nianxx.thhotel.integrations.document.libreoffice.adapter;
/**
* LibreOffice 进程执行端口。便于测试替换,不让单元测试依赖真实 soffice。
*/
public interface LibreOfficeProcessRunner {
/**
* 执行 LibreOffice 转换命令并返回安全进程结果。
*/
LibreOfficeProcessResult run(LibreOfficeProcessRequest request);
}

View File

@@ -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 "";
}
}
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,10 @@
package cn.nianxx.thhotel.platform.documentconversion.common.enums;
/**
* 文件转换状态。CP2 手动上传接口只返回成功状态,失败通过受控错误响应表达。
*/
public enum DocumentConversionStatus {
/** 转换并上传 PDF 成功。 */
SUCCEEDED
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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));
}
}

View File

@@ -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 文件。"));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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}