实现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,17 @@
package cn.nianxx.thhotel.platform.documentconversion.common.dto;
/**
* Excel 转 PDF 输入文档。由平台转换服务完成上传校验后传给转换引擎。
*
* @param fileName 安全文件名
* @param contentType 上传文件 MIME 类型
* @param sizeBytes 上传文件大小字节数
* @param content 上传文件字节内容
*/
public record ExcelToPdfConversionInput(
String fileName,
String contentType,
Long sizeBytes,
byte[] content
) {
}

View File

@@ -0,0 +1,19 @@
package cn.nianxx.thhotel.platform.documentconversion.common.dto;
/**
* Excel 转 PDF 输出文档。转换引擎只返回 PDF 字节和安全元数据,不负责 OSS 上传。
*
* @param fileName 生成的 PDF 文件名
* @param contentType PDF MIME 类型
* @param sizeBytes PDF 文件大小字节数
* @param content PDF 文件字节内容
* @param durationMillis 转换耗时毫秒
*/
public record ExcelToPdfConvertedDocument(
String fileName,
String contentType,
Long sizeBytes,
byte[] content,
Long durationMillis
) {
}

View File

@@ -0,0 +1,10 @@
package cn.nianxx.thhotel.platform.documentconversion.common.enums;
/**
* 文件转换状态。CP2 手动上传接口只返回成功状态,失败通过受控错误响应表达。
*/
public enum DocumentConversionStatus {
/** 转换并上传 PDF 成功。 */
SUCCEEDED
}

View File

@@ -0,0 +1,16 @@
package cn.nianxx.thhotel.platform.documentconversion.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 文件转换接口错误响应。只返回安全错误码和摘要,不暴露文件内容、临时路径或 Secret。
*
* @param errorCode 错误码
* @param message 安全错误摘要
*/
public record DocumentConversionErrorResponse(
@JsonProperty("error_code")
String errorCode,
String message
) {
}

View File

@@ -0,0 +1,43 @@
package cn.nianxx.thhotel.platform.documentconversion.common.result;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Excel 转 PDF 手动上传接口结果。返回 PDF 的 OSS 访问信息和安全转换摘要。
*
* @param conversionStatus 转换状态
* @param sourceFileName 上传 Excel 文件名
* @param sourceSizeBytes 上传 Excel 文件大小字节数
* @param pdfFileName 生成 PDF 文件名
* @param pdfUrl PDF OSS 访问 URL
* @param objectKey PDF OSS 对象路径
* @param contentType PDF MIME 类型
* @param pdfSizeBytes PDF 文件大小字节数
* @param durationMillis 转换耗时毫秒
* @param safeErrorSummary 安全错误摘要,成功时为空
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ExcelToPdfConversionResult(
@JsonProperty("conversion_status")
String conversionStatus,
@JsonProperty("source_file_name")
String sourceFileName,
@JsonProperty("source_size_bytes")
Long sourceSizeBytes,
@JsonProperty("pdf_file_name")
String pdfFileName,
@JsonProperty("pdf_url")
String pdfUrl,
@JsonProperty("object_key")
String objectKey,
@JsonProperty("content_type")
String contentType,
@JsonProperty("pdf_size_bytes")
Long pdfSizeBytes,
@JsonProperty("duration_millis")
Long durationMillis,
@JsonProperty("safe_error_summary")
String safeErrorSummary
) {
}

View File

@@ -0,0 +1,43 @@
package cn.nianxx.thhotel.platform.documentconversion.control;
import cn.nianxx.thhotel.platform.documentconversion.common.result.ExcelToPdfConversionResult;
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionService;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 文件转换 Controller。当前只开放受控 Excel 转 PDF 手动上传接口。
*/
@RestController
@RequestMapping("/api/system/document-conversions")
public class DocumentConversionController {
private final DocumentConversionService documentConversionService;
/**
* 注入文件转换服务Controller 不直接调用 LibreOffice 或 OSS。
*/
public DocumentConversionController(DocumentConversionService documentConversionService) {
this.documentConversionService = documentConversionService;
}
/**
* 上传 Excel 并转换为 PDF成功后返回 PDF OSS URL。
*/
@PostMapping(path = "/excel-to-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ExcelToPdfConversionResult> convertExcelToPdf(
@RequestHeader(name = "X-TH-Hotel-Document-Conversion-Key", required = false) String accessKey,
@RequestParam("file") MultipartFile file,
@RequestParam(name = "hotel_id", required = false) String hotelId) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(documentConversionService.convertExcelToPdf(accessKey, file, hotelId));
}
}

View File

@@ -0,0 +1,37 @@
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.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.support.MissingServletRequestPartException;
/**
* 文件转换接口异常转换。只返回安全错误摘要,不暴露 Excel 内容、PDF 内容、临时路径或 Secret。
*/
@RestControllerAdvice(assignableTypes = DocumentConversionController.class)
public class DocumentConversionControllerAdvice {
/**
* 转换文件转换业务异常。
*/
@ExceptionHandler(DocumentConversionException.class)
public ResponseEntity<DocumentConversionErrorResponse> handleDocumentConversionException(
DocumentConversionException exception) {
return ResponseEntity.status(exception.getStatus())
.body(new DocumentConversionErrorResponse(exception.getErrorCode(), exception.getMessage()));
}
/**
* 转换缺少 multipart 文件或必填参数的异常。
*/
@ExceptionHandler({MissingServletRequestPartException.class, MissingServletRequestParameterException.class})
public ResponseEntity<DocumentConversionErrorResponse> handleMissingRequestPart(Exception exception) {
return ResponseEntity.badRequest()
.body(new DocumentConversionErrorResponse(
"DOCUMENT_CONVERSION_FILE_REQUIRED",
"请上传 Excel 文件。"));
}
}

View File

@@ -0,0 +1,38 @@
package cn.nianxx.thhotel.platform.documentconversion.service;
import org.springframework.http.HttpStatus;
/**
* 文件转换受控异常。HTTP 层只返回错误码和安全摘要,不暴露文件内容、临时路径或外部命令细节。
*/
public class DocumentConversionException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
/**
* 创建文件转换受控异常。
*/
public DocumentConversionException(HttpStatus status, String errorCode, String message) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
/**
* 创建带内部原因的文件转换受控异常。
*/
public DocumentConversionException(HttpStatus status, String errorCode, String message, Throwable cause) {
super(message, cause);
this.status = status;
this.errorCode = errorCode;
}
public HttpStatus getStatus() {
return status;
}
public String getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,15 @@
package cn.nianxx.thhotel.platform.documentconversion.service;
import cn.nianxx.thhotel.platform.documentconversion.common.result.ExcelToPdfConversionResult;
import org.springframework.web.multipart.MultipartFile;
/**
* 平台文件转换服务。对 Controller 提供受控上传转换能力,不暴露 LibreOffice 或 OSS SDK 细节。
*/
public interface DocumentConversionService {
/**
* 上传 Excel 并转换为 PDF 后上传 OSS。调用方必须提供受控访问口令。
*/
ExcelToPdfConversionResult convertExcelToPdf(String accessKey, MultipartFile file, String hotelId);
}

View File

@@ -0,0 +1,15 @@
package cn.nianxx.thhotel.platform.documentconversion.service;
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput;
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument;
/**
* Excel 转 PDF 转换端口。平台服务依赖该端口,具体实现可由 LibreOffice 或其他引擎提供。
*/
public interface ExcelToPdfConverter {
/**
* 将 Excel 字节转换为 PDF 字节;实现类负责隔离外部转换引擎和临时文件。
*/
ExcelToPdfConvertedDocument convert(ExcelToPdfConversionInput input);
}

View File

@@ -0,0 +1,103 @@
package cn.nianxx.thhotel.platform.documentconversion.service.impl;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 平台文件转换配置。Secret 只能通过环境变量或部署平台注入,不得写入前端。
*/
@Component
@ConfigurationProperties(prefix = "document-conversion")
public class DocumentConversionProperties {
/** 是否启用文件转换接口。 */
private boolean enabled = false;
/** 手动上传转换访问口令。 */
private String accessKey = "";
/** LibreOffice soffice 可执行文件路径。 */
private String sofficePath = "soffice";
/** 文件转换临时目录根路径。 */
private String tempDir = System.getProperty("java.io.tmpdir") + "/th-hotel-document-conversion";
/** 单个 Excel 最大字节数。 */
private long maxFileBytes = 20 * 1024 * 1024L;
/** 单次转换超时时间秒。 */
private long timeoutSeconds = 60L;
/** 最大并发转换数。 */
private int maxConcurrent = 2;
/** PDF 输出 OSS 前缀。 */
private String outputOssPrefix = "document-conversions/excel-to-pdf/";
/** 自动转换 worker 开关CP2 暂不使用,预留给后续邮件附件自动转换。 */
private boolean workerEnabled = false;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getAccessKey() {
return accessKey;
}
public void setAccessKey(String accessKey) {
this.accessKey = accessKey;
}
public String getSofficePath() {
return sofficePath;
}
public void setSofficePath(String sofficePath) {
this.sofficePath = sofficePath;
}
public String getTempDir() {
return tempDir;
}
public void setTempDir(String tempDir) {
this.tempDir = tempDir;
}
public long getMaxFileBytes() {
return maxFileBytes;
}
public void setMaxFileBytes(long maxFileBytes) {
this.maxFileBytes = maxFileBytes;
}
public long getTimeoutSeconds() {
return timeoutSeconds;
}
public void setTimeoutSeconds(long timeoutSeconds) {
this.timeoutSeconds = timeoutSeconds;
}
public int getMaxConcurrent() {
return maxConcurrent;
}
public void setMaxConcurrent(int maxConcurrent) {
this.maxConcurrent = maxConcurrent;
}
public String getOutputOssPrefix() {
return outputOssPrefix;
}
public void setOutputOssPrefix(String outputOssPrefix) {
this.outputOssPrefix = outputOssPrefix;
}
public boolean isWorkerEnabled() {
return workerEnabled;
}
public void setWorkerEnabled(boolean workerEnabled) {
this.workerEnabled = workerEnabled;
}
}

View File

@@ -0,0 +1,279 @@
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.ExcelToPdfConversionInput;
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument;
import cn.nianxx.thhotel.platform.documentconversion.common.enums.DocumentConversionStatus;
import cn.nianxx.thhotel.platform.documentconversion.common.result.ExcelToPdfConversionResult;
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.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.Semaphore;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* 平台文件转换服务实现。负责访问口令、文件校验、转换编排、OSS 输出和安全错误转换。
*/
@Service
public class DocumentConversionServiceImpl implements DocumentConversionService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
private final DocumentConversionProperties properties;
private final ExcelToPdfConverter excelToPdfConverter;
private final ObjectStorageService objectStorageService;
private final Semaphore conversionSemaphore;
/**
* 注入转换配置、Excel 转 PDF 端口和 OSS 上传端口。
*/
public DocumentConversionServiceImpl(
DocumentConversionProperties properties,
ExcelToPdfConverter excelToPdfConverter,
ObjectStorageService objectStorageService) {
this.properties = properties;
this.excelToPdfConverter = excelToPdfConverter;
this.objectStorageService = objectStorageService;
this.conversionSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrent()));
}
/**
* 上传 Excel 并转换为 PDF 后上传 OSS当前 CP2 不落库,只返回本次转换结果。
*/
@Override
public ExcelToPdfConversionResult convertExcelToPdf(String accessKey, MultipartFile file, String hotelId) {
validateEnabled();
validateAccessKey(accessKey);
validateFile(file);
String sourceFileName = safeFileName(file.getOriginalFilename(), "source.xlsx");
acquirePermit();
try {
byte[] excelBytes = readFileBytes(file);
validateFileSignature(sourceFileName, excelBytes);
ExcelToPdfConvertedDocument converted = excelToPdfConverter.convert(new ExcelToPdfConversionInput(
sourceFileName,
file.getContentType(),
file.getSize(),
excelBytes));
validateConvertedDocument(converted);
ObjectStoragePutResult putResult = uploadPdf(hotelId, sourceFileName, converted);
return new ExcelToPdfConversionResult(
DocumentConversionStatus.SUCCEEDED.name(),
sourceFileName,
file.getSize(),
converted.fileName(),
putResult.publicUrl(),
putResult.objectKey(),
putResult.contentType(),
putResult.sizeBytes(),
converted.durationMillis(),
null);
} finally {
conversionSemaphore.release();
}
}
/**
* 校验文件转换总开关。
*/
private void validateEnabled() {
if (!properties.isEnabled()) {
throw new DocumentConversionException(
HttpStatus.NOT_FOUND,
"DOCUMENT_CONVERSION_DISABLED",
"文件转换接口未启用。");
}
}
/**
* 校验手动上传转换访问口令。
*/
private void validateAccessKey(String accessKey) {
if (properties.getAccessKey() == null || properties.getAccessKey().isBlank()
|| accessKey == null || !properties.getAccessKey().equals(accessKey)) {
throw new DocumentConversionException(
HttpStatus.UNAUTHORIZED,
"DOCUMENT_CONVERSION_KEY_INVALID",
"文件转换访问口令缺失或错误。");
}
}
/**
* 校验上传文件类型、大小和扩展名。
*/
private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new DocumentConversionException(
HttpStatus.BAD_REQUEST,
"DOCUMENT_CONVERSION_FILE_REQUIRED",
"请上传 Excel 文件。");
}
if (file.getSize() > properties.getMaxFileBytes()) {
throw new DocumentConversionException(
HttpStatus.PAYLOAD_TOO_LARGE,
"DOCUMENT_CONVERSION_FILE_TOO_LARGE",
"上传的 Excel 文件超过大小限制。");
}
String fileName = safeFileName(file.getOriginalFilename(), "");
String lowerName = fileName.toLowerCase(Locale.ROOT);
if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) {
throw new DocumentConversionException(
HttpStatus.BAD_REQUEST,
"DOCUMENT_CONVERSION_FILE_TYPE_UNSUPPORTED",
"只支持上传 .xls 或 .xlsx 文件。");
}
}
/**
* 读取上传文件字节。
*/
private byte[] readFileBytes(MultipartFile file) {
try {
return file.getBytes();
} catch (Exception exception) {
throw new DocumentConversionException(
HttpStatus.BAD_REQUEST,
"DOCUMENT_CONVERSION_FILE_READ_FAILED",
"读取上传 Excel 文件失败。",
exception);
}
}
/**
* 校验 Excel 文件头,避免只改扩展名的任意内容进入 LibreOffice 解析器。
*/
private void validateFileSignature(String sourceFileName, byte[] content) {
String lowerName = sourceFileName.toLowerCase(Locale.ROOT);
boolean valid;
if (lowerName.endsWith(".xlsx")) {
valid = content.length >= 4 && content[0] == 0x50 && content[1] == 0x4B;
} else {
valid = content.length >= 8
&& (content[0] & 0xFF) == 0xD0
&& (content[1] & 0xFF) == 0xCF
&& (content[2] & 0xFF) == 0x11
&& (content[3] & 0xFF) == 0xE0
&& (content[4] & 0xFF) == 0xA1
&& (content[5] & 0xFF) == 0xB1
&& (content[6] & 0xFF) == 0x1A
&& (content[7] & 0xFF) == 0xE1;
}
if (!valid) {
throw new DocumentConversionException(
HttpStatus.BAD_REQUEST,
"DOCUMENT_CONVERSION_FILE_CONTENT_INVALID",
"上传文件内容不是有效的 Excel 文件。");
}
}
/**
* 获取转换并发许可,避免大量文件同时触发 LibreOffice 进程。
*/
private void acquirePermit() {
if (!conversionSemaphore.tryAcquire()) {
throw new DocumentConversionException(
HttpStatus.TOO_MANY_REQUESTS,
"DOCUMENT_CONVERSION_BUSY",
"文件转换任务繁忙,请稍后重试。");
}
}
/**
* 校验转换器返回的 PDF 内容。
*/
private void validateConvertedDocument(ExcelToPdfConvertedDocument converted) {
if (converted == null || converted.content() == null || converted.content().length == 0) {
throw new DocumentConversionException(
HttpStatus.BAD_GATEWAY,
"DOCUMENT_CONVERSION_FAILED",
"Excel 转 PDF 未生成有效文件。");
}
}
/**
* 上传 PDF 到 OSS并把 OSS 失败转换为安全错误。
*/
private ObjectStoragePutResult uploadPdf(
String hotelId,
String sourceFileName,
ExcelToPdfConvertedDocument converted) {
String pdfFileName = safeFileName(converted.fileName(), pdfFileName(sourceFileName));
String objectKey = objectKey(hotelId, pdfFileName);
try {
return objectStorageService.putObject(new ObjectStoragePutRequest(
objectKey,
pdfFileName,
MediaType.APPLICATION_PDF_VALUE,
(long) converted.content().length,
converted.content()));
} catch (RuntimeException exception) {
throw new DocumentConversionException(
HttpStatus.BAD_GATEWAY,
"DOCUMENT_CONVERSION_OSS_UPLOAD_FAILED",
"PDF 上传 OSS 失败。",
exception);
}
}
/**
* 生成 PDF OSS 对象路径。
*/
private String objectKey(String hotelId, String pdfFileName) {
String prefix = properties.getOutputOssPrefix();
String normalizedPrefix = prefix == null || prefix.isBlank()
? "document-conversions/excel-to-pdf/"
: prefix;
if (!normalizedPrefix.endsWith("/")) {
normalizedPrefix = normalizedPrefix + "/";
}
String normalizedHotelId = safePathPart(hotelId == null || hotelId.isBlank() ? "UNKNOWN-HOTEL" : hotelId);
String today = DATE_FORMATTER.format(LocalDate.now(ZoneOffset.UTC));
return normalizedPrefix + normalizedHotelId + "/" + today + "/" + UUID.randomUUID() + "/" + pdfFileName;
}
/**
* 根据 Excel 文件名生成 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;
}
/**
* OSS 路径片段安全清洗。
*/
private String safePathPart(String value) {
String sanitized = value.replaceAll("[^A-Za-z0-9._-]", "_");
return sanitized.isBlank() ? "UNKNOWN" : sanitized;
}
}