实现Excel转PDF手动转换接口
This commit is contained in:
1 parent
ed37d5f955
commit
8d53b72363
30 files changed
+1995
-52
No files matched your search
+200
@@ -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) {
|
||||
// 临时目录遍历失败不覆盖原始转换结果。
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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
|
||||
) {
|
||||
}
|
||||
+17
@@ -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
|
||||
) {
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package cn.nianxx.thhotel.integrations.document.libreoffice.adapter;
|
||||
|
||||
/**
|
||||
* LibreOffice 进程执行端口。便于测试替换,不让单元测试依赖真实 soffice。
|
||||
*/
|
||||
public interface LibreOfficeProcessRunner {
|
||||
|
||||
/**
|
||||
* 执行 LibreOffice 转换命令并返回安全进程结果。
|
||||
*/
|
||||
LibreOfficeProcessResult run(LibreOfficeProcessRequest request);
|
||||
}
|
||||
+129
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user