实现Excel转PDF手动转换接口
This commit is contained in:
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package cn.nianxx.thhotel.platform.documentconversion.common.enums;
|
||||
|
||||
/**
|
||||
* 文件转换状态。CP2 手动上传接口只返回成功状态,失败通过受控错误响应表达。
|
||||
*/
|
||||
public enum DocumentConversionStatus {
|
||||
|
||||
/** 转换并上传 PDF 成功。 */
|
||||
SUCCEEDED
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 文件。"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user