实现Excel转PDF手动转换接口

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

View File

@@ -0,0 +1,128 @@
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.impl.DocumentConversionProperties;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.HttpStatus;
class LibreOfficeExcelToPdfAdapterTest {
@TempDir
private Path tempDir;
@Test
void shouldInvokeLibreOfficeHeadlessAndCleanTemporaryFiles() throws Exception {
DocumentConversionProperties properties = properties();
AtomicReference<LibreOfficeProcessRequest> processRequestRef = new AtomicReference<>();
LibreOfficeProcessRunner processRunner = request -> {
processRequestRef.set(request);
org.assertj.core.api.Assertions.assertThat(request.command())
.contains("soffice-test")
.contains("--headless")
.contains("--convert-to")
.contains("pdf");
org.assertj.core.api.Assertions.assertThat(Files.exists(request.inputFile())).isTrue();
writePdf(request.outputDirectory().resolve("booking-request.pdf"));
return new LibreOfficeProcessResult(0, false, "convert ok", "");
};
LibreOfficeExcelToPdfAdapter adapter = new LibreOfficeExcelToPdfAdapter(properties, processRunner);
ExcelToPdfConvertedDocument result = adapter.convert(new ExcelToPdfConversionInput(
"booking-request.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
10L,
"xlsx-bytes".getBytes(StandardCharsets.UTF_8)));
org.assertj.core.api.Assertions.assertThat(result.fileName()).isEqualTo("booking-request.pdf");
org.assertj.core.api.Assertions.assertThat(result.contentType()).isEqualTo("application/pdf");
org.assertj.core.api.Assertions.assertThat(result.content()).startsWith("%PDF".getBytes(StandardCharsets.UTF_8));
org.assertj.core.api.Assertions.assertThat(processRequestRef.get().profileDirectory().toString())
.contains("lo-profile");
org.assertj.core.api.Assertions.assertThat(processRequestRef.get().workingDirectory())
.isEqualTo(processRequestRef.get().inputFile().getParent());
try (var children = Files.list(tempDir)) {
org.assertj.core.api.Assertions.assertThat(children).isEmpty();
}
}
@Test
void shouldReturnTimeoutErrorAndCleanTemporaryFiles() {
DocumentConversionProperties properties = properties();
LibreOfficeProcessRunner processRunner = request -> new LibreOfficeProcessResult(
-1,
true,
"",
"timeout");
LibreOfficeExcelToPdfAdapter adapter = new LibreOfficeExcelToPdfAdapter(properties, processRunner);
org.assertj.core.api.Assertions.assertThatThrownBy(() -> adapter.convert(new ExcelToPdfConversionInput(
"booking-request.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
10L,
"xlsx-bytes".getBytes(StandardCharsets.UTF_8))))
.isInstanceOf(DocumentConversionException.class)
.satisfies(exception -> {
DocumentConversionException conversionException = (DocumentConversionException) exception;
org.assertj.core.api.Assertions.assertThat(conversionException.getStatus())
.isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
org.assertj.core.api.Assertions.assertThat(conversionException.getErrorCode())
.isEqualTo("DOCUMENT_CONVERSION_TIMEOUT");
});
assertTempDirEmpty();
}
@Test
void shouldReturnFailedErrorWhenLibreOfficeDoesNotProducePdf() {
DocumentConversionProperties properties = properties();
LibreOfficeProcessRunner processRunner = request -> new LibreOfficeProcessResult(0, false, "ok", "");
LibreOfficeExcelToPdfAdapter adapter = new LibreOfficeExcelToPdfAdapter(properties, processRunner);
org.assertj.core.api.Assertions.assertThatThrownBy(() -> adapter.convert(new ExcelToPdfConversionInput(
"booking-request.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
10L,
"xlsx-bytes".getBytes(StandardCharsets.UTF_8))))
.isInstanceOf(DocumentConversionException.class)
.satisfies(exception -> {
DocumentConversionException conversionException = (DocumentConversionException) exception;
org.assertj.core.api.Assertions.assertThat(conversionException.getStatus())
.isEqualTo(HttpStatus.BAD_GATEWAY);
org.assertj.core.api.Assertions.assertThat(conversionException.getErrorCode())
.isEqualTo("DOCUMENT_CONVERSION_FAILED");
});
assertTempDirEmpty();
}
private DocumentConversionProperties properties() {
DocumentConversionProperties properties = new DocumentConversionProperties();
properties.setEnabled(true);
properties.setSofficePath("soffice-test");
properties.setTempDir(tempDir.toString());
properties.setTimeoutSeconds(3);
properties.setOutputOssPrefix("document-conversions/excel-to-pdf/");
return properties;
}
private void writePdf(Path pdfFile) {
try {
Files.write(pdfFile, "%PDF-1.7\nconverted".getBytes(StandardCharsets.UTF_8));
} catch (Exception exception) {
throw new AssertionError("写入模拟 PDF 失败。", exception);
}
}
private void assertTempDirEmpty() {
try (var children = Files.list(tempDir)) {
org.assertj.core.api.Assertions.assertThat(children).isEmpty();
} catch (Exception exception) {
throw new AssertionError("临时目录检查失败。", exception);
}
}
}

View File

@@ -0,0 +1,195 @@
package cn.nianxx.thhotel.platform.documentconversion.control;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
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.ExcelToPdfConverter;
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"document-conversion.enabled=true",
"document-conversion.access-key=test-document-key",
"document-conversion.max-file-bytes=1024",
"document-conversion.output-oss-prefix=document-conversions/excel-to-pdf/"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class DocumentConversionControllerTest {
private static final String ENDPOINT = "/api/system/document-conversions/excel-to-pdf";
@Autowired
private MockMvc mockMvc;
@MockBean
private ExcelToPdfConverter excelToPdfConverter;
@MockBean
private ObjectStorageService objectStorageService;
@Test
void shouldConvertUploadedExcelToPdfAndUploadPdfToOss() throws Exception {
byte[] excelBytes = xlsxBytes("xlsx-bytes");
byte[] pdfBytes = "%PDF-1.7\nconverted".getBytes(StandardCharsets.UTF_8);
when(excelToPdfConverter.convert(any())).thenReturn(new ExcelToPdfConvertedDocument(
"booking-request.pdf",
MediaType.APPLICATION_PDF_VALUE,
(long) pdfBytes.length,
pdfBytes,
123L));
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
ObjectStoragePutRequest request = invocation.getArgument(0);
return new ObjectStoragePutResult(
request.objectKey(),
"https://oss.example.test/" + request.objectKey(),
request.contentType(),
request.sizeBytes());
});
mockMvc.perform(multipart(ENDPOINT)
.file(excelFile("booking-request.xlsx", excelBytes))
.param("hotel_id", "HOTEL-TEST")
.header("X-TH-Hotel-Document-Conversion-Key", "test-document-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.conversion_status").value("SUCCEEDED"))
.andExpect(jsonPath("$.source_file_name").value("booking-request.xlsx"))
.andExpect(jsonPath("$.pdf_file_name").value("booking-request.pdf"))
.andExpect(jsonPath("$.pdf_url", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.object_key", containsString("document-conversions/excel-to-pdf/")))
.andExpect(jsonPath("$.content_type").value(MediaType.APPLICATION_PDF_VALUE))
.andExpect(jsonPath("$.pdf_size_bytes").value(pdfBytes.length))
.andExpect(jsonPath("$.safe_error_summary").doesNotExist())
.andExpect(content().string(not(containsString("test-document-key"))));
ArgumentCaptor<ExcelToPdfConversionInput> converterInputCaptor =
ArgumentCaptor.forClass(ExcelToPdfConversionInput.class);
verify(excelToPdfConverter).convert(converterInputCaptor.capture());
org.assertj.core.api.Assertions.assertThat(converterInputCaptor.getValue().fileName())
.isEqualTo("booking-request.xlsx");
org.assertj.core.api.Assertions.assertThat(converterInputCaptor.getValue().content())
.containsExactly(excelBytes);
ArgumentCaptor<ObjectStoragePutRequest> storageRequestCaptor =
ArgumentCaptor.forClass(ObjectStoragePutRequest.class);
verify(objectStorageService).putObject(storageRequestCaptor.capture());
org.assertj.core.api.Assertions.assertThat(storageRequestCaptor.getValue().objectKey())
.startsWith("document-conversions/excel-to-pdf/HOTEL-TEST/")
.endsWith("/booking-request.pdf");
org.assertj.core.api.Assertions.assertThat(storageRequestCaptor.getValue().contentType())
.isEqualTo(MediaType.APPLICATION_PDF_VALUE);
org.assertj.core.api.Assertions.assertThat(storageRequestCaptor.getValue().content())
.containsExactly(pdfBytes);
}
@Test
void shouldRejectUploadWhenDocumentConversionKeyMissing() throws Exception {
mockMvc.perform(multipart(ENDPOINT)
.file(excelFile("booking-request.xlsx", xlsxBytes("xlsx-bytes"))))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_KEY_INVALID"))
.andExpect(content().string(not(containsString("test-document-key"))));
verifyNoInteractions(excelToPdfConverter, objectStorageService);
}
@Test
void shouldRejectUnsupportedExcelFileType() throws Exception {
mockMvc.perform(multipart(ENDPOINT)
.file(excelFile("booking-request.txt", "not-excel".getBytes(StandardCharsets.UTF_8)))
.header("X-TH-Hotel-Document-Conversion-Key", "test-document-key"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_FILE_TYPE_UNSUPPORTED"));
verifyNoInteractions(excelToPdfConverter, objectStorageService);
}
@Test
void shouldRejectExcelExtensionWhenContentHeaderInvalid() throws Exception {
mockMvc.perform(multipart(ENDPOINT)
.file(excelFile("booking-request.xlsx", "not-excel".getBytes(StandardCharsets.UTF_8)))
.header("X-TH-Hotel-Document-Conversion-Key", "test-document-key"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_FILE_CONTENT_INVALID"));
verifyNoInteractions(excelToPdfConverter, objectStorageService);
}
@Test
void shouldRejectFileLargerThanConfiguredLimit() throws Exception {
mockMvc.perform(multipart(ENDPOINT)
.file(excelFile("booking-request.xlsx", "x".repeat(2048).getBytes(StandardCharsets.UTF_8)))
.header("X-TH-Hotel-Document-Conversion-Key", "test-document-key"))
.andExpect(status().isPayloadTooLarge())
.andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_FILE_TOO_LARGE"));
verifyNoInteractions(excelToPdfConverter, objectStorageService);
}
@Test
void shouldReturnSafeErrorWhenLibreOfficeConversionFails() throws Exception {
when(excelToPdfConverter.convert(any())).thenThrow(new DocumentConversionException(
HttpStatus.GATEWAY_TIMEOUT,
"DOCUMENT_CONVERSION_TIMEOUT",
"Excel 转 PDF 超时。"));
mockMvc.perform(multipart(ENDPOINT)
.file(excelFile("booking-request.xlsx", xlsxBytes("xlsx-bytes")))
.header("X-TH-Hotel-Document-Conversion-Key", "test-document-key"))
.andExpect(status().isGatewayTimeout())
.andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_TIMEOUT"))
.andExpect(jsonPath("$.message").value("Excel 转 PDF 超时。"))
.andExpect(content().string(not(containsString("xlsx-bytes"))));
verifyNoInteractions(objectStorageService);
}
private MockMultipartFile excelFile(String fileName, byte[] content) {
return new MockMultipartFile(
"file",
fileName,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
content);
}
private byte[] xlsxBytes(String content) {
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
outputStream.write(new byte[]{0x50, 0x4B, 0x03, 0x04});
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
return outputStream.toByteArray();
} catch (IOException exception) {
throw new AssertionError("构造测试 xlsx 字节失败。", exception);
}
}
}

View File

@@ -0,0 +1,56 @@
package cn.nianxx.thhotel.platform.documentconversion.control;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"document-conversion.enabled=false",
"document-conversion.access-key=test-document-key"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class DocumentConversionDisabledControllerTest {
private static final String ENDPOINT = "/api/system/document-conversions/excel-to-pdf";
@Autowired
private MockMvc mockMvc;
@MockBean
private ExcelToPdfConverter excelToPdfConverter;
@MockBean
private ObjectStorageService objectStorageService;
@Test
void shouldRejectUploadWhenDocumentConversionDisabled() throws Exception {
mockMvc.perform(multipart(ENDPOINT)
.file(new MockMultipartFile(
"file",
"booking-request.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"xlsx-bytes".getBytes(StandardCharsets.UTF_8)))
.header("X-TH-Hotel-Document-Conversion-Key", "test-document-key"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_DISABLED"));
verifyNoInteractions(excelToPdfConverter, objectStorageService);
}
}

View File

@@ -0,0 +1,105 @@
package cn.nianxx.thhotel.platform.documentconversion.service.impl;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument;
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException;
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionService;
import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
class DocumentConversionServiceImplTest {
@Test
void shouldRejectSecondConversionWhenConcurrencyLimitReachedBeforeReadingBytes() throws Exception {
byte[] pdfBytes = "%PDF-1.7".getBytes(StandardCharsets.UTF_8);
CountDownLatch converterEntered = new CountDownLatch(1);
CountDownLatch releaseConverter = new CountDownLatch(1);
ExcelToPdfConverter converter = input -> {
converterEntered.countDown();
await(releaseConverter);
return new ExcelToPdfConvertedDocument(
"booking-request.pdf",
MediaType.APPLICATION_PDF_VALUE,
(long) pdfBytes.length,
pdfBytes,
10L);
};
ObjectStorageService storageService = request -> putResult(request);
DocumentConversionService service = new DocumentConversionServiceImpl(properties(), converter, storageService);
Thread firstThread = new Thread(() -> service.convertExcelToPdf(
"test-document-key",
excelFile("booking-request.xlsx"),
"HOTEL-TEST"));
firstThread.start();
org.assertj.core.api.Assertions.assertThat(converterEntered.await(2, TimeUnit.SECONDS)).isTrue();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.convertExcelToPdf(
"test-document-key",
excelFile("second-request.xlsx"),
"HOTEL-TEST"))
.isInstanceOf(DocumentConversionException.class)
.satisfies(exception -> {
DocumentConversionException conversionException = (DocumentConversionException) exception;
org.assertj.core.api.Assertions.assertThat(conversionException.getErrorCode())
.isEqualTo("DOCUMENT_CONVERSION_BUSY");
});
releaseConverter.countDown();
firstThread.join(2000);
}
private DocumentConversionProperties properties() {
DocumentConversionProperties properties = new DocumentConversionProperties();
properties.setEnabled(true);
properties.setAccessKey("test-document-key");
properties.setMaxConcurrent(1);
properties.setMaxFileBytes(1024);
properties.setOutputOssPrefix("document-conversions/excel-to-pdf/");
return properties;
}
private MockMultipartFile excelFile(String fileName) {
return new MockMultipartFile(
"file",
fileName,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
xlsxBytes(fileName));
}
private byte[] xlsxBytes(String content) {
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
outputStream.write(new byte[]{0x50, 0x4B, 0x03, 0x04});
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
return outputStream.toByteArray();
} catch (Exception exception) {
throw new AssertionError("构造测试 xlsx 字节失败。", exception);
}
}
private ObjectStoragePutResult putResult(ObjectStoragePutRequest request) {
return new ObjectStoragePutResult(
request.objectKey(),
"https://oss.example.test/" + request.objectKey(),
request.contentType(),
request.sizeBytes());
}
private void await(CountDownLatch latch) {
try {
org.assertj.core.api.Assertions.assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError("等待转换器释放失败。", exception);
}
}
}