实现Excel转PDF手动转换接口

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

No files matched your search

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