From bc0413f21c471899520d054a94591173f8df52ff Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 16 Jul 2026 18:36:45 +0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DExcel=E8=BD=ACPDF=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E8=BE=B9=E7=95=8C=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...rocessBuilderLibreOfficeProcessRunner.java | 68 ++++++++++++++++--- .../DocumentConversionControllerAdvice.java | 14 ++++ .../src/main/resources/application-prod.yml | 2 +- ...ssBuilderLibreOfficeProcessRunnerTest.java | 38 +++++++++++ ...ocumentConversionControllerAdviceTest.java | 25 +++++++ .../DocumentConversionConfigurationTest.java | 22 ++++++ 6 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 server/src/test/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunnerTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdviceTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionConfigurationTest.java diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunner.java b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunner.java index 6a4433b..03059de 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunner.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunner.java @@ -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 outputFuture = CompletableFuture.supplyAsync(() -> readOutput(process.getInputStream())); + process = processBuilder.start(); + Process startedProcess = process; + CompletableFuture 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 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(); diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdvice.java index 0501ae1..f205677 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdvice.java @@ -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 handleMaxUploadSizeExceeded( + MaxUploadSizeExceededException exception) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(new DocumentConversionErrorResponse( + "DOCUMENT_CONVERSION_FILE_TOO_LARGE", + "上传的 Excel 文件超过大小限制。")); + } } diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 33aede3..82bdf53 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -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}} diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunnerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunnerTest.java new file mode 100644 index 0000000..6962941 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/document/libreoffice/adapter/ProcessBuilderLibreOfficeProcessRunnerTest.java @@ -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(); + } + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdviceTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdviceTest.java new file mode 100644 index 0000000..b8c01aa --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/control/DocumentConversionControllerAdviceTest.java @@ -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 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"); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionConfigurationTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionConfigurationTest.java new file mode 100644 index 0000000..13f5135 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/documentconversion/service/impl/DocumentConversionConfigurationTest.java @@ -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:}}"); + } +}