修复Excel转PDF转换边界问题
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 文件超过大小限制。"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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:}}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user