修复Excel转PDF转换边界问题

This commit is contained in:
andy
2026-07-16 18:36:45 +07:00
parent 8d53b72363
commit bc0413f21c
6 changed files with 159 additions and 10 deletions

View File

@@ -29,15 +29,28 @@ public class ProcessBuilderLibreOfficeProcessRunner implements LibreOfficeProces
processBuilder.directory(request.workingDirectory().toFile());
}
processBuilder.redirectErrorStream(true);
Process process = null;
try {
Process process = processBuilder.start();
CompletableFuture<String> outputFuture = CompletableFuture.supplyAsync(() -> readOutput(process.getInputStream()));
process = processBuilder.start();
Process startedProcess = process;
CompletableFuture<String> outputFuture = CompletableFuture.supplyAsync(
() -> readOutput(startedProcess.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 (InterruptedException exception) {
if (process != null) {
destroyProcessTree(process);
}
Thread.currentThread().interrupt();
throw new DocumentConversionException(
HttpStatus.BAD_GATEWAY,
"DOCUMENT_CONVERSION_FAILED",
"调用 LibreOffice 转换被中断。",
exception);
} catch (Exception exception) {
throw new DocumentConversionException(
HttpStatus.BAD_GATEWAY,
@@ -53,18 +66,55 @@ public class ProcessBuilderLibreOfficeProcessRunner implements LibreOfficeProces
private void destroyProcessTree(Process process) {
ProcessHandle root = process.toHandle();
List<ProcessHandle> processTree = new ArrayList<>();
root.descendants().forEach(processTree::add);
try {
root.descendants().forEach(processTree::add);
} catch (RuntimeException ignored) {
// 某些系统权限下枚举子进程可能失败;后续仍会尽力销毁根进程。
}
processTree.sort(Comparator.comparingLong(ProcessHandle::pid).reversed());
processTree.forEach(ProcessHandle::destroy);
root.destroy();
processTree.forEach(this::destroyQuietly);
destroyQuietly(root);
waitForProcessTree(root, processTree, Duration.ofMillis(500));
processTree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
if (root.isAlive()) {
root.destroyForcibly();
processTree.stream().filter(this::isAliveQuietly).forEach(this::destroyForciblyQuietly);
if (isAliveQuietly(root)) {
destroyForciblyQuietly(root);
}
waitForProcessTree(root, processTree, Duration.ofSeconds(1));
}
/**
* 尝试发送普通销毁信号,权限或进程状态异常时静默降级。
*/
private void destroyQuietly(ProcessHandle handle) {
try {
handle.destroy();
} catch (RuntimeException ignored) {
// 进程清理是 best-effort异常不应覆盖原始超时或中断错误。
}
}
/**
* 尝试强制销毁进程,权限或进程状态异常时静默降级。
*/
private void destroyForciblyQuietly(ProcessHandle handle) {
try {
handle.destroyForcibly();
} catch (RuntimeException ignored) {
// 进程清理是 best-effort异常不应覆盖原始超时或中断错误。
}
}
/**
* 安全判断进程是否仍存活。
*/
private boolean isAliveQuietly(ProcessHandle handle) {
try {
return handle.isAlive();
} catch (RuntimeException exception) {
return false;
}
}
/**
* 等待进程树退出;等待失败不抛出,调用方已经返回安全超时错误。
*/
@@ -80,7 +130,7 @@ public class ProcessBuilderLibreOfficeProcessRunner implements LibreOfficeProces
* 等待单个进程退出。
*/
private void waitForHandle(ProcessHandle handle, long deadlineNanos) {
if (!handle.isAlive()) {
if (!isAliveQuietly(handle)) {
return;
}
long remainingNanos = deadlineNanos - System.nanoTime();

View File

@@ -2,10 +2,12 @@ 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.HttpStatus;
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.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
/**
@@ -34,4 +36,16 @@ public class DocumentConversionControllerAdvice {
"DOCUMENT_CONVERSION_FILE_REQUIRED",
"请上传 Excel 文件。"));
}
/**
* 转换 Spring multipart 框架层上传大小超限异常。
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<DocumentConversionErrorResponse> handleMaxUploadSizeExceeded(
MaxUploadSizeExceededException exception) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(new DocumentConversionErrorResponse(
"DOCUMENT_CONVERSION_FILE_TOO_LARGE",
"上传的 Excel 文件超过大小限制。"));
}
}

View File

@@ -69,7 +69,7 @@ debug:
document-conversion:
# prod 默认关闭;启用前必须确认 LibreOffice、字体、OSS、访问口令、监控和临时目录清理策略。
enabled: ${DOCUMENT_CONVERSION_PROD_ENABLED:false}
access-key: ${DOCUMENT_CONVERSION_PROD_ACCESS_KEY:${DOCUMENT_CONVERSION_ACCESS_KEY}}
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}}

View File

@@ -0,0 +1,38 @@
package cn.nianxx.thhotel.integrations.document.libreoffice.adapter;
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ProcessBuilderLibreOfficeProcessRunnerTest {
@TempDir
private Path tempDir;
@Test
void shouldRestoreInterruptFlagWhenInterruptedDuringLibreOfficeWait() {
ProcessBuilderLibreOfficeProcessRunner runner = new ProcessBuilderLibreOfficeProcessRunner();
LibreOfficeProcessRequest request = new LibreOfficeProcessRequest(
List.of("/bin/sh", "-c", "sleep 5"),
tempDir,
tempDir.resolve("source.xlsx"),
tempDir,
tempDir.resolve("lo-profile"),
Duration.ofSeconds(30));
Thread.currentThread().interrupt();
try {
org.assertj.core.api.Assertions.assertThatThrownBy(() -> runner.run(request))
.isInstanceOf(DocumentConversionException.class)
.satisfies(exception -> org.assertj.core.api.Assertions.assertThat(
((DocumentConversionException) exception).getErrorCode())
.isEqualTo("DOCUMENT_CONVERSION_FAILED"));
org.assertj.core.api.Assertions.assertThat(Thread.currentThread().isInterrupted()).isTrue();
} finally {
Thread.interrupted();
}
}
}

View File

@@ -0,0 +1,25 @@
package cn.nianxx.thhotel.platform.documentconversion.control;
import cn.nianxx.thhotel.platform.documentconversion.common.result.DocumentConversionErrorResponse;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
class DocumentConversionControllerAdviceTest {
@Test
void shouldMapFrameworkUploadLimitToSafeJsonError() {
DocumentConversionControllerAdvice advice = new DocumentConversionControllerAdvice();
ResponseEntity<DocumentConversionErrorResponse> response =
advice.handleMaxUploadSizeExceeded(new MaxUploadSizeExceededException(1024));
org.assertj.core.api.Assertions.assertThat(response.getStatusCode())
.isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE);
org.assertj.core.api.Assertions.assertThat(response.getBody())
.isNotNull()
.extracting(DocumentConversionErrorResponse::errorCode)
.isEqualTo("DOCUMENT_CONVERSION_FILE_TOO_LARGE");
}
}

View File

@@ -0,0 +1,22 @@
package cn.nianxx.thhotel.platform.documentconversion.service.impl;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
class DocumentConversionConfigurationTest {
@Test
void shouldAllowProdProfileToStartWithoutDocumentConversionAccessKeyWhenDisabled() throws IOException {
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
PropertySource<?> propertySource = loader.load(
"application-prod",
new ClassPathResource("application-prod.yml"))
.get(0);
org.assertj.core.api.Assertions.assertThat(propertySource.getProperty("document-conversion.access-key"))
.isEqualTo("${DOCUMENT_CONVERSION_PROD_ACCESS_KEY:${DOCUMENT_CONVERSION_ACCESS_KEY:}}");
}
}