Commit 779f5d7b by zhangxingmin

Merge remote-tracking branch 'origin/test' into dev

parents ecd8f9fb 93c23a0c
# 构建阶段 - 创建包含LibreOffice的基础镜像 # ============================================================
FROM openjdk:8-jre-slim as base-builder # 构建阶段 - 创建包含 LibreOffice 的基础镜像
# ============================================================
FROM eclipse-temurin:8-jre-jammy as base-builder
ENV LANG=zh_CN.UTF-8 \ ENV LANG=zh_CN.UTF-8 \
LANGUAGE=zh_CN:zh \ LANGUAGE=zh_CN:zh \
LC_ALL=zh_CN.UTF-8 \ LC_ALL=zh_CN.UTF-8 \
DEBIAN_FRONTEND=noninteractive DEBIAN_FRONTEND=noninteractive
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \ # 替换为阿里云 Ubuntu 源(jammy = 22.04)
sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list RUN sed -i 's|archive.ubuntu.com|mirrors.aliyun.com|g' /etc/apt/sources.list && \
sed -i 's|security.ubuntu.com|mirrors.aliyun.com|g' /etc/apt/sources.list
# 安装字体、LibreOffice 及中文语言包
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
fonts-liberation \ fonts-liberation \
...@@ -29,12 +33,15 @@ RUN apt-get update && \ ...@@ -29,12 +33,15 @@ RUN apt-get update && \
RUN mkdir -p /home/app RUN mkdir -p /home/app
WORKDIR /home/app WORKDIR /home/app
# ============================================================
# 最终阶段 # 最终阶段
# ============================================================
FROM base-builder FROM base-builder
# 只拷贝jar包 # 拷贝 Spring Boot 可执行 JAR
COPY target/yd-oss-api-1.0-SNAPSHOT-exec.jar /home/app/yd-oss-api.jar COPY target/yd-oss-api-1.0-SNAPSHOT-exec.jar /home/app/yd-oss-api.jar
# 执行命令启动jar,并设置JVM内存参数 # 启动命令,设置 JVM 内存参数
ENTRYPOINT ["java", "-Xmx1024m", "-Xms512m", "-jar", "/home/app/yd-oss-api.jar"] ENTRYPOINT ["java", "-Xmx1024m", "-Xms512m", "-jar", "/home/app/yd-oss-api.jar"]
EXPOSE 9106 EXPOSE 9106
\ No newline at end of file
package com.yd.oss.api.config;
import org.jodconverter.core.office.OfficeManager;
import org.jodconverter.local.office.LocalOfficeManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
public class OfficeManagerHolder {
private static final Logger logger = LoggerFactory.getLogger(OfficeManagerHolder.class);
private static OfficeManager instance = null;
private static final Object lock = new Object();
public static OfficeManager getInstance(String libreOfficeHome) throws Exception {
if (instance == null) {
synchronized (lock) {
if (instance == null) {
logger.info("正在启动 LibreOffice (home: {}) ...", libreOfficeHome);
instance = LocalOfficeManager.builder()
.officeHome(new File(libreOfficeHome))
.build();
instance.start();
logger.info("LibreOffice 启动成功");
}
}
}
return instance;
}
public static void stop() {
if (instance != null) {
try {
instance.stop();
logger.info("LibreOffice 已停止");
} catch (Exception e) {
logger.error("停止 LibreOffice 失败", e);
}
instance = null;
}
}
}
\ No newline at end of file
package com.yd.oss.api.controller;
import com.yd.common.result.Result;
import com.yd.oss.api.service.ApiChunkedUploadContextService;
import com.yd.oss.feign.client.ApiChunkedUploadContextFeignClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.util.Map;
/**
* 分片上传上下文信息
* (一个任务上传文件信息拆分成多个ETag上传存储,任务执行完毕,阿里云合并ETag列表为完整的文件信息)
* @author zxm
* @since 2026-08-05
*/
@Slf4j
@RestController
@RequestMapping("/chunkedUploadContext")
@Validated
public class ApiChunkedUploadContextController implements ApiChunkedUploadContextFeignClient {
@Resource
private ApiChunkedUploadContextService apiChunkedUploadContextService;
/**
* 上传单个分片
* @param chunk
* @param taskId
* @param chunkIndex
* @param projectBizId
* @param source
* @return
*/
@Override
public Result<Void> uploadChunk(MultipartFile chunk, String taskId, Integer chunkIndex, String projectBizId, String source) {
apiChunkedUploadContextService.uploadChunk(taskId, chunkIndex, chunk,projectBizId,source);
return Result.success();
}
/**
* 完成分片上传(合并)
*/
public Result<Map<String, Object>> finishChunks(String taskId,
String projectBizId) {
return apiChunkedUploadContextService.finishChunks(taskId,projectBizId);
}
}
package com.yd.oss.api.controller; package com.yd.oss.api.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yd.common.result.Result; import com.yd.common.result.Result;
import com.yd.oss.api.service.ApiOssService; import com.yd.oss.api.service.ApiOssService;
import com.yd.oss.feign.client.ApiOssFeignClient; import com.yd.oss.feign.client.ApiOssFeignClient;
...@@ -7,9 +8,13 @@ import com.yd.oss.feign.request.ApiUploadFileRequest; ...@@ -7,9 +8,13 @@ import com.yd.oss.feign.request.ApiUploadFileRequest;
import com.yd.oss.feign.response.ApiBatchUploadResponse; import com.yd.oss.feign.response.ApiBatchUploadResponse;
import com.yd.oss.feign.response.ApiFileMetadataResponse; import com.yd.oss.feign.response.ApiFileMetadataResponse;
import com.yd.oss.feign.response.ApiUploadResponse; import com.yd.oss.feign.response.ApiUploadResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
...@@ -21,6 +26,7 @@ import java.util.List; ...@@ -21,6 +26,7 @@ import java.util.List;
* @author zxm * @author zxm
* @since 2025-07-31 * @since 2025-07-31
*/ */
@Slf4j
@RestController @RestController
@RequestMapping("/oss") @RequestMapping("/oss")
@Validated @Validated
...@@ -29,6 +35,9 @@ public class ApiOssController implements ApiOssFeignClient { ...@@ -29,6 +35,9 @@ public class ApiOssController implements ApiOssFeignClient {
@Autowired @Autowired
private ApiOssService apiOssService; private ApiOssService apiOssService;
@Autowired
private ObjectMapper objectMapper;
/** /**
* 上传文件 * 上传文件
* @param file 上传的文件 * @param file 上传的文件
...@@ -50,6 +59,17 @@ public class ApiOssController implements ApiOssFeignClient { ...@@ -50,6 +59,17 @@ public class ApiOssController implements ApiOssFeignClient {
return apiOssService.uploadFileBody(file,request); return apiOssService.uploadFileBody(file,request);
} }
public Result<ApiUploadResponse> uploadFileBodyWithJson(MultipartFile file,String requestJson) {
try {
// 反序列化 JSON 为 ApiUploadFileRequest
ApiUploadFileRequest request = objectMapper.readValue(requestJson, ApiUploadFileRequest.class);
return apiOssService.uploadFileBody(file, request);
} catch (Exception e) {
log.error("解析请求参数失败", e);
return Result.fail("请求参数格式错误");
}
}
/** /**
* 下载文件 * 下载文件
* @param fileKey 文件唯一标识 * @param fileKey 文件唯一标识
......
package com.yd.oss.api.controller;
import com.yd.oss.api.utils.LocalDocumentConversionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* 文档转换接口
* 提供将 Office 文档(Word/Excel/PPT)转换为 PDF/HTML/图片等格式的 HTTP 接口。
* 供其他微服务通过内网调用。
*/
@RestController
@RequestMapping("/convert")
public class DocumentConvertController {
private static final Logger logger = LoggerFactory.getLogger(DocumentConvertController.class);
@Autowired
private LocalDocumentConversionService conversionService;
/**
* 将指定 URL 的文档转换为目标格式,并返回字节流。
*
* @param fileUrl 源文件 URL(支持 HTTP/HTTPS/OSS)
* @param targetFormat 目标格式,如 pdf、html、png、jpg 等,默认 pdf
* @return 转换后的文件字节流,Content-Type 根据格式自动设置
*/
@PostMapping("/file")
public ResponseEntity<byte[]> convert(
@RequestParam("fileUrl") String fileUrl,
@RequestParam(value = "targetFormat", defaultValue = "pdf") String targetFormat) {
logger.info("收到转换请求: fileUrl={}, targetFormat={}", fileUrl, targetFormat);
try {
byte[] result = conversionService.convertToBytes(fileUrl, targetFormat);
HttpHeaders headers = new HttpHeaders();
// 根据目标格式设置 Content-Type
MediaType mediaType = getMediaType(targetFormat);
headers.setContentType(mediaType);
// 可选:设置下载文件名
String filename = extractFileName(fileUrl) + "." + targetFormat;
try {
headers.setContentDispositionFormData("attachment",
URLEncoder.encode(filename, StandardCharsets.UTF_8.name()));
} catch (UnsupportedEncodingException e) {
// 忽略
}
logger.info("转换成功,返回字节数: {}", result.length);
return new ResponseEntity<>(result, headers, HttpStatus.OK);
} catch (Exception e) {
logger.error("文档转换失败: fileUrl={}, targetFormat={}", fileUrl, targetFormat, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(("转换失败: " + e.getMessage()).getBytes(StandardCharsets.UTF_8));
}
}
private MediaType getMediaType(String format) {
if (format == null) return MediaType.APPLICATION_OCTET_STREAM;
switch (format.toLowerCase()) {
case "pdf":
return MediaType.APPLICATION_PDF;
case "html":
return MediaType.TEXT_HTML;
case "png":
return MediaType.IMAGE_PNG;
case "jpg":
case "jpeg":
return MediaType.IMAGE_JPEG;
default:
return MediaType.APPLICATION_OCTET_STREAM;
}
}
private String extractFileName(String url) {
if (url == null) return "converted";
int lastSlash = url.lastIndexOf('/');
if (lastSlash >= 0 && lastSlash < url.length() - 1) {
String name = url.substring(lastSlash + 1);
int dot = name.lastIndexOf('.');
return dot > 0 ? name.substring(0, dot) : name;
}
return "converted";
}
}
\ No newline at end of file
package com.yd.oss.api.service;
import com.yd.common.result.Result;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
public interface ApiChunkedUploadContextService {
void uploadChunk(String taskId, Integer chunkIndex, MultipartFile chunk,String projectBizId,String source);
Result<Map<String, Object>> finishChunks(String taskId,String projectBizId);
}
package com.yd.oss.api.service.impl;
import com.aliyun.oss.OSS;
import com.aliyun.oss.model.*;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yd.common.exception.BusinessException;
import com.yd.common.result.Result;
import com.yd.oss.api.service.ApiChunkedUploadContextService;
import com.yd.oss.service.config.OssClientFactory;
import com.yd.oss.service.dao.ChunkedUploadContextMapper;
import com.yd.oss.service.model.ChunkedUploadContext;
import com.yd.oss.service.model.OssProvider;
import com.yd.oss.service.service.IOssProviderService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class ApiChunkedUploadContextServiceImpl implements ApiChunkedUploadContextService {
@Resource
private ChunkedUploadContextMapper contextMapper;
@Resource
private ObjectMapper objectMapper;
@Resource
private OssClientFactory ossClientFactory;
@Resource
private IOssProviderService ossProviderService;
@Resource
private RedissonClient redissonClient;
private static final String LOCK_KEY_PREFIX = "chunked:upload:lock:";
/**
* 上传单个分片(使用分布式锁保护上下文初始化 + Map存储ETag)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void uploadChunk(String taskId, Integer chunkIndex, MultipartFile chunk,
String projectBizId, String source) {
log.info("【uploadChunk】开始处理, taskId={}, chunkIndex={}, projectBizId={}, source={}, fileSize={}",
taskId, chunkIndex, projectBizId, source, chunk != null ? chunk.getSize() : 0);
// 获取OSS服务商
OssProvider provider = ossProviderService.getProviderByProjectId(projectBizId);
if (provider == null) {
log.error("【uploadChunk】未找到OSS服务商, projectBizId={}", projectBizId);
throw new BusinessException("未找到对应的OSS服务商配置");
}
OSS ossClient = ossClientFactory.createOssClient(provider);
// 获取或创建上下文(仅初始化时加锁)
ChunkedUploadContext context = getOrCreateContext(taskId, provider, source, ossClient);
// 上传分片并更新ETag(使用Map存储,避免PartETag反序列化问题)
try {
int partNumber = chunkIndex + 1;
log.info("【uploadChunk】开始上传分片, taskId={}, partNumber={}, chunkSize={}",
taskId, partNumber, chunk.getSize());
UploadPartRequest uploadPartRequest = new UploadPartRequest();
uploadPartRequest.setBucketName(provider.getBucketName());
uploadPartRequest.setKey(context.getObjectKey());
uploadPartRequest.setUploadId(context.getUploadId());
uploadPartRequest.setPartNumber(partNumber);
uploadPartRequest.setInputStream(chunk.getInputStream());
uploadPartRequest.setPartSize(chunk.getSize());
UploadPartResult uploadResult = ossClient.uploadPart(uploadPartRequest);
// 读取现有ETag列表(存储为List<Map>)
String json = context.getPartEtagsJson();
List<Map<String, Object>> partInfoList;
if (StringUtils.isBlank(json) || "[]".equals(json)) {
partInfoList = new ArrayList<>();
} else {
partInfoList = objectMapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {});
}
// 添加新分片信息
Map<String, Object> newPart = new HashMap<>();
newPart.put("partNumber", partNumber);
newPart.put("etag", uploadResult.getETag());
partInfoList.add(newPart);
// 按partNumber排序
partInfoList.sort(Comparator.comparingInt(m -> (Integer) m.get("partNumber")));
// 序列化并更新数据库
context.setPartEtagsJson(objectMapper.writeValueAsString(partInfoList));
context.setUpdateTime(LocalDateTime.now());
contextMapper.updateById(context);
log.info("【uploadChunk】分片上传成功, taskId={}, partNumber={}, ETag={}, 当前总分片数={}",
taskId, partNumber, uploadResult.getETag(), partInfoList.size());
} catch (Exception e) {
log.error("【uploadChunk】分片上传或更新ETag失败, taskId={}, chunkIndex={}", taskId, chunkIndex, e);
throw new BusinessException("分片上传失败: " + e.getMessage());
}
}
/**
* 获取或创建上传上下文(使用分布式锁保证唯一性)
*/
private ChunkedUploadContext getOrCreateContext(String taskId, OssProvider provider,
String source, OSS ossClient) {
String lockKey = LOCK_KEY_PREFIX + taskId;
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
locked = lock.tryLock(5, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后重试");
}
ChunkedUploadContext context = contextMapper.selectOne(
new LambdaQueryWrapper<ChunkedUploadContext>()
.eq(ChunkedUploadContext::getTaskId, taskId)
);
if (context == null) {
log.info("【getOrCreateContext】上下文不存在,初始化新上传, taskId={}", taskId);
context = new ChunkedUploadContext();
context.setTaskId(taskId);
String objectKey = String.format("sharding/" + source + "/%tY/%tm/%s_%d.webm",
new Date(), new Date(), taskId, System.currentTimeMillis());
context.setObjectKey(objectKey);
InitiateMultipartUploadRequest initRequest =
new InitiateMultipartUploadRequest(provider.getBucketName(), objectKey);
InitiateMultipartUploadResult initResult = ossClient.initiateMultipartUpload(initRequest);
context.setUploadId(initResult.getUploadId());
context.setPartEtagsJson("[]"); // 初始为空数组
context.setStatus(1); // 上传中
int insertResult = contextMapper.insert(context);
log.info("【getOrCreateContext】初始化分片上传并入库, taskId={}, uploadId={}, objectKey={}, insertResult={}",
taskId, context.getUploadId(), objectKey, insertResult);
} else {
log.info("【getOrCreateContext】找到已存在的上下文, taskId={}, uploadId={}, status={}",
taskId, context.getUploadId(), context.getStatus());
}
return context;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("【getOrCreateContext】获取锁被中断, taskId={}", taskId, e);
throw new BusinessException("系统中断,请重试");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
log.debug("【getOrCreateContext】释放锁, taskId={}", taskId);
}
}
}
/**
* 完成分片上传(合并)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Map<String, Object>> finishChunks(String taskId, String projectBizId) {
log.info("【finishChunks】开始完成分片上传, taskId={}, projectBizId={}", taskId, projectBizId);
// 1. 查询上下文
ChunkedUploadContext context = contextMapper.selectOne(
new LambdaQueryWrapper<ChunkedUploadContext>()
.eq(ChunkedUploadContext::getTaskId, taskId)
);
if (context == null) {
log.error("【finishChunks】未找到分片上传上下文, taskId={}", taskId);
throw new BusinessException("未找到分片上传上下文,请检查 taskId 是否正确");
}
log.info("【finishChunks】找到上下文, taskId={}, uploadId={}, status={}, objectKey={}",
taskId, context.getUploadId(), context.getStatus(), context.getObjectKey());
// 2. 解析分片列表(从Map列表转为PartETag列表)
String json = context.getPartEtagsJson();
List<Map<String, Object>> partInfoList;
try {
partInfoList = objectMapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {});
} catch (Exception e) {
log.error("【finishChunks】解析分片数据失败, taskId={}, json={}", taskId, json, e);
throw new BusinessException("数据异常,无法合并");
}
if (partInfoList.isEmpty()) {
log.error("【finishChunks】没有分片数据, taskId={}", taskId);
throw new BusinessException("没有分片数据,无法合并");
}
List<PartETag> partETags = new ArrayList<>();
for (Map<String, Object> map : partInfoList) {
Integer partNumber = (Integer) map.get("partNumber");
String etag = (String) map.get("etag");
if (partNumber == null || etag == null) {
log.warn("【finishChunks】分片数据不完整,跳过: {}", map);
continue;
}
partETags.add(new PartETag(partNumber, etag));
}
if (partETags.isEmpty()) {
throw new BusinessException("没有有效的分片数据,无法合并");
}
log.info("【finishChunks】解析到 {} 个分片, taskId={}", partETags.size(), taskId);
// 3. 获取OSS服务商
OssProvider provider = ossProviderService.getProviderByProjectId(projectBizId);
if (provider == null) {
log.error("【finishChunks】未找到OSS服务商, projectBizId={}", projectBizId);
throw new BusinessException("未找到对应的OSS服务商配置");
}
OSS ossClient = ossClientFactory.createOssClient(provider);
// 4. 执行合并
CompleteMultipartUploadRequest completeRequest = new CompleteMultipartUploadRequest(
provider.getBucketName(),
context.getObjectKey(),
context.getUploadId(),
partETags
);
CompleteMultipartUploadResult completeResult = ossClient.completeMultipartUpload(completeRequest);
log.info("【finishChunks】合并成功, taskId={}, location={}", taskId, completeResult.getLocation());
// 5. 构建文件URL和大小
String fileUrl = String.format("https://%s.%s/%s",
provider.getBucketName(),
provider.getEndpoint().replace("https://", ""),
context.getObjectKey()
);
ObjectMetadata metadata = ossClient.getObjectMetadata(provider.getBucketName(), context.getObjectKey());
long fileSize = metadata.getContentLength();
// 6. 更新上下文状态为已完成
context.setStatus(2);
contextMapper.updateById(context);
log.info("【finishChunks】上下文状态更新为已完成, taskId={}", taskId);
// 7. 返回结果
Map<String, Object> result = new HashMap<>();
result.put("taskId", taskId);
result.put("fileUrl", fileUrl);
result.put("fileKey", context.getObjectKey());
result.put("fileSize", fileSize);
log.info("【finishChunks】完成, taskId={}, fileUrl={}", taskId, fileUrl);
return Result.success(result);
}
}
\ No newline at end of file
package com.yd.oss.api.utils;
import com.yd.oss.api.config.OfficeManagerHolder;
import org.jodconverter.core.DocumentConverter;
import org.jodconverter.core.office.OfficeException;
import org.jodconverter.local.LocalConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
@Service
public class LocalDocumentConversionService {
private static final Logger logger = LoggerFactory.getLogger(LocalDocumentConversionService.class);
@Value("${jodconverter.libreoffice.home:}")
private String libreOfficeHome;
private DocumentConverter converter;
@PostConstruct
public void init() {
if (libreOfficeHome == null || libreOfficeHome.trim().isEmpty()) {
libreOfficeHome = System.getenv("LIBREOFFICE_HOME");
}
if (libreOfficeHome == null || libreOfficeHome.trim().isEmpty()) {
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
libreOfficeHome = "D:/LibreOffice";
} else if (os.contains("linux")) {
libreOfficeHome = "/opt/libreoffice_standalone/libreoffice7.4";
} else if (os.contains("mac")) {
libreOfficeHome = "/Applications/LibreOffice.app/Contents";
} else {
throw new RuntimeException("无法确定 LibreOffice 安装路径,请通过配置或环境变量设置");
}
logger.warn("未配置 libreOfficeHome,使用默认路径: {}", libreOfficeHome);
}
try {
OfficeManagerHolder.getInstance(libreOfficeHome);
converter = LocalConverter.builder()
.officeManager(OfficeManagerHolder.getInstance(libreOfficeHome))
.build();
logger.info("JodConverter 初始化成功,LibreOffice 路径: {}", libreOfficeHome);
} catch (Exception e) {
logger.error("JodConverter 初始化失败,请检查 LibreOffice 安装", e);
throw new RuntimeException("JodConverter 初始化失败", e);
}
}
public byte[] convertToPdfBytes(String fileUrl) throws Exception {
return convertToBytes(fileUrl, "pdf");
}
public byte[] convertToBytes(String fileUrl, String targetExtension) throws Exception {
if (targetExtension.startsWith(".")) {
targetExtension = targetExtension.substring(1);
}
if (targetExtension == null || targetExtension.trim().isEmpty()) {
throw new IllegalArgumentException("目标扩展名不能为空");
}
logger.info("开始转换文档: {} -> 格式: {}", fileUrl, targetExtension);
Path tempInput = Files.createTempFile("convert_", "_" + UUID.randomUUID());
try (InputStream in = new URL(fileUrl).openStream()) {
Files.copy(in, tempInput, StandardCopyOption.REPLACE_EXISTING);
}
logger.info("源文件下载到临时路径: {}", tempInput.toAbsolutePath());
Path tempOutput = Files.createTempFile("converted_", "." + targetExtension);
try {
converter.convert(tempInput.toFile())
.to(tempOutput.toFile())
.execute();
logger.info("转换完成,临时输出: {}", tempOutput.toAbsolutePath());
byte[] resultBytes = Files.readAllBytes(tempOutput);
logger.info("转换结果大小: {} bytes", resultBytes.length);
return resultBytes;
} catch (OfficeException e) {
logger.error("转换失败 (格式: {})", targetExtension, e);
throw new Exception("文档转换失败 (格式: " + targetExtension + "): " + e.getMessage(), e);
} finally {
try {
Files.deleteIfExists(tempInput);
Files.deleteIfExists(tempOutput);
} catch (Exception ignored) {
logger.warn("清理临时文件失败");
}
}
}
@PreDestroy
public void destroy() {
OfficeManagerHolder.stop();
}
}
\ No newline at end of file
...@@ -291,5 +291,13 @@ ...@@ -291,5 +291,13 @@
<orderEntry type="library" name="Maven: org.apache.commons:commons-compress:1.19" level="project" /> <orderEntry type="library" name="Maven: org.apache.commons:commons-compress:1.19" level="project" />
<orderEntry type="library" name="Maven: com.github.virtuald:curvesapi:1.06" level="project" /> <orderEntry type="library" name="Maven: com.github.virtuald:curvesapi:1.06" level="project" />
<orderEntry type="library" name="Maven: org.apache.xmlbeans:xmlbeans:3.1.0" level="project" /> <orderEntry type="library" name="Maven: org.apache.xmlbeans:xmlbeans:3.1.0" level="project" />
<orderEntry type="library" name="Maven: org.jodconverter:jodconverter-core:4.4.7" level="project" />
<orderEntry type="library" name="Maven: org.jodconverter:jodconverter-local:4.4.7" level="project" />
<orderEntry type="library" name="Maven: org.openoffice:juh:4.1.2" level="project" />
<orderEntry type="library" name="Maven: org.openoffice:jurt:4.1.2" level="project" />
<orderEntry type="library" name="Maven: org.openoffice:ridl:4.1.2" level="project" />
<orderEntry type="library" name="Maven: org.openoffice:unoil:4.1.2" level="project" />
<orderEntry type="library" name="Maven: com.itextpdf:itextpdf:5.5.13" level="project" />
<orderEntry type="library" name="Maven: com.itextpdf:itext-asian:5.2.0" level="project" />
</component> </component>
</module> </module>
\ No newline at end of file
package com.yd.oss.feign.client;
import com.yd.common.result.Result;
import com.yd.oss.feign.fallback.ApiChunkedUploadContextFeignFallbackFactory;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
/**
* 分片上传上下文信息Feign客户端
*/
@FeignClient(name = "yd-oss-api", path = "/oss/api/chunkedUploadContext", fallbackFactory = ApiChunkedUploadContextFeignFallbackFactory.class)
public interface ApiChunkedUploadContextFeignClient {
/**
* 上传单个分片
* @param chunk
* @param taskId
* @param chunkIndex
* @param projectBizId
* @param source
* @return
*/
@PostMapping(value = "/uploadChunk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
Result<Void> uploadChunk(
@RequestPart("chunk") MultipartFile chunk,
@RequestParam("taskId") String taskId,
@RequestParam("chunkIndex") Integer chunkIndex,
@RequestParam("projectBizId") String projectBizId,
@RequestParam("source") String source
);
/**
* 完成分片上传(合并)
*/
@PostMapping("/finishChunks")
Result<Map<String, Object>> finishChunks(
@RequestParam("taskId") String taskId,
@RequestParam("projectBizId") String projectBizId
);
}
...@@ -10,14 +10,14 @@ import org.springframework.cloud.openfeign.FeignClient; ...@@ -10,14 +10,14 @@ import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import javax.validation.constraints.NotBlank;
import java.util.List; import java.util.List;
/** /**
* OSS服务信息Feign客户端 * OSS服务信息Feign客户端
*/ */
@FeignClient(name = "yd-oss-api", fallbackFactory = ApiOssFeignFallbackFactory.class) @FeignClient(name = "yd-oss-api",
path = "/oss/api/oss",
fallbackFactory = ApiOssFeignFallbackFactory.class)
public interface ApiOssFeignClient { public interface ApiOssFeignClient {
/** /**
...@@ -42,7 +42,10 @@ public interface ApiOssFeignClient { ...@@ -42,7 +42,10 @@ public interface ApiOssFeignClient {
Result<ApiUploadResponse> uploadFileBody(@RequestPart("file") MultipartFile file, Result<ApiUploadResponse> uploadFileBody(@RequestPart("file") MultipartFile file,
@RequestPart("request") ApiUploadFileRequest request); @RequestPart("request") ApiUploadFileRequest request);
@PostMapping(value = "/upload/body/json", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
Result<ApiUploadResponse> uploadFileBodyWithJson(
@RequestPart("file") MultipartFile file,
@RequestPart("request") String requestJson);
/** /**
* 下载文件 * 下载文件
......
package com.yd.oss.feign.fallback;
import com.yd.common.result.Result;
import com.yd.oss.feign.client.ApiChunkedUploadContextFeignClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
/**
* 分片上传上下文信息Feign降级处理
*/
@Slf4j
@Component
public class ApiChunkedUploadContextFeignFallbackFactory implements FallbackFactory<ApiChunkedUploadContextFeignClient> {
@Override
public ApiChunkedUploadContextFeignClient create(Throwable cause) {
return new ApiChunkedUploadContextFeignClient() {
@Override
public Result<Void> uploadChunk(MultipartFile chunk, String taskId, Integer chunkIndex, String projectBizId, String source) {
return null;
}
@Override
public Result<Map<String, Object>> finishChunks(String taskId, String projectBizId) {
return null;
}
};
}
}
...@@ -4,7 +4,6 @@ import com.yd.common.result.Result; ...@@ -4,7 +4,6 @@ import com.yd.common.result.Result;
import com.yd.oss.feign.client.ApiExcelFeignClient; import com.yd.oss.feign.client.ApiExcelFeignClient;
import com.yd.oss.feign.dto.ExportResult; import com.yd.oss.feign.dto.ExportResult;
import com.yd.oss.feign.request.ApiExportRequest; import com.yd.oss.feign.request.ApiExportRequest;
import com.yd.oss.feign.request.ApiOssExcelParseRequest;
import com.yd.oss.feign.request.ApiOssExportAppointmentExcelRequest; import com.yd.oss.feign.request.ApiOssExportAppointmentExcelRequest;
import com.yd.oss.feign.request.MultiSheetExportRequest; import com.yd.oss.feign.request.MultiSheetExportRequest;
import com.yd.oss.feign.response.ApiOssExcelParseResponse; import com.yd.oss.feign.response.ApiOssExcelParseResponse;
......
...@@ -33,6 +33,11 @@ public class ApiOssFeignFallbackFactory implements FallbackFactory<ApiOssFeignCl ...@@ -33,6 +33,11 @@ public class ApiOssFeignFallbackFactory implements FallbackFactory<ApiOssFeignCl
} }
@Override @Override
public Result<ApiUploadResponse> uploadFileBodyWithJson(MultipartFile file, String requestJson) {
return null;
}
@Override
public Result downloadFile(String fileKey) { public Result downloadFile(String fileKey) {
return null; return null;
} }
......
...@@ -8,6 +8,11 @@ import javax.validation.constraints.NotBlank; ...@@ -8,6 +8,11 @@ import javax.validation.constraints.NotBlank;
public class ApiUploadFileRequest { public class ApiUploadFileRequest {
/** /**
* 租户唯一标识(业务ID)
*/
private String tenantBizId;
/**
* 项目ID * 项目ID
*/ */
private String projectBizId; private String projectBizId;
......
...@@ -61,6 +61,6 @@ public class ApiUploadResponse { ...@@ -61,6 +61,6 @@ public class ApiUploadResponse {
/** /**
* 创建时间 * 创建时间
*/ */
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime; private LocalDateTime createTime;
} }
...@@ -120,5 +120,30 @@ ...@@ -120,5 +120,30 @@
<artifactId>xmlbeans</artifactId> <artifactId>xmlbeans</artifactId>
</dependency> </dependency>
<!-- JodConverter 文档转换 -->
<dependency>
<groupId>org.jodconverter</groupId>
<artifactId>jodconverter-core</artifactId>
<version>4.4.7</version>
</dependency>
<dependency>
<groupId>org.jodconverter</groupId>
<artifactId>jodconverter-local</artifactId>
<version>4.4.7</version>
</dependency>
<!-- iText PDF 处理 -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itextpdf</artifactId>
<version>5.5.13</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-asian</artifactId>
<version>5.2.0</version>
</dependency>
</dependencies> </dependencies>
</project> </project>
package com.yd.oss.service.dao;
import com.yd.oss.service.model.ChunkedUploadContext;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* <p>
* 分片上传上下文表(一个任务上传文件信息拆分成多个ETag上传存储,任务执行完毕,阿里云合并ETag列表为完整的文件信息) Mapper 接口
* </p>
*
* @author zxm
* @since 2026-08-05
*/
public interface ChunkedUploadContextMapper extends BaseMapper<ChunkedUploadContext> {
}
...@@ -15,6 +15,11 @@ import lombok.NoArgsConstructor; ...@@ -15,6 +15,11 @@ import lombok.NoArgsConstructor;
public class OssUploadFileReqDto { public class OssUploadFileReqDto {
/** /**
* 租户唯一标识(业务ID)
*/
private String tenantBizId;
/**
* 项目ID * 项目ID
*/ */
private String projectBizId; private String projectBizId;
......
package com.yd.oss.service.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
/**
* <p>
* 分片上传上下文表(一个任务上传文件信息拆分成多个ETag上传存储,任务执行完毕,阿里云合并ETag列表为完整的文件信息)
* </p>
*
* @author zxm
* @since 2026-08-05
*/
@Getter
@Setter
@TableName("chunked_upload_context")
public class ChunkedUploadContext implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 任务ID
*/
@TableField("task_id")
private String taskId;
/**
* OSS 分片上传 ID(由 OSS 初始化时返回)
*/
@TableField("upload_id")
private String uploadId;
/**
* 文件在 OSS 中的完整路径(不含 Bucket 名称)
*/
@TableField("object_key")
private String objectKey;
/**
* 已上传分片的 ETag 列表(JSON 数组格式,如 [{"partNumber":1,"eTag":"xxx"}])
*/
@TableField("part_etags_json")
private String partEtagsJson;
/**
* 状态:0-初始化,1-上传中,2-已完成,3-已取消
*/
@TableField("status")
private Integer status;
/**
* 通用备注
*/
@TableField("remark")
private String remark;
/**
* 删除标识: 0-正常, 1-删除
*/
@TableField("is_deleted")
private Integer isDeleted;
/**
* 创建人ID
*/
@TableField("creator_id")
private String creatorId;
/**
* 更新人ID
*/
@TableField("updater_id")
private String updaterId;
/**
* 创建时间
*/
@TableField("create_time")
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField("update_time")
private LocalDateTime updateTime;
}
package com.yd.oss.service.service;
import com.yd.oss.service.model.ChunkedUploadContext;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p>
* 分片上传上下文表(一个任务上传文件信息拆分成多个ETag上传存储,任务执行完毕,阿里云合并ETag列表为完整的文件信息) 服务类
* </p>
*
* @author zxm
* @since 2026-08-05
*/
public interface IChunkedUploadContextService extends IService<ChunkedUploadContext> {
}
package com.yd.oss.service.service.impl; package com.yd.oss.service.service.impl;
import com.alibaba.fastjson2.JSON;
import com.aliyun.oss.OSS; import com.aliyun.oss.OSS;
import com.aliyun.oss.model.CannedAccessControlList; import com.aliyun.oss.model.CannedAccessControlList;
import com.aliyun.oss.model.OSSObject; import com.aliyun.oss.model.OSSObject;
...@@ -83,7 +84,7 @@ public class AliYunOssServiceImpl implements OssService { ...@@ -83,7 +84,7 @@ public class AliYunOssServiceImpl implements OssService {
OSS ossClient = null; OSS ossClient = null;
try { try {
ossClient = ossClientFactory.createOssClient(provider); ossClient = ossClientFactory.createOssClient(provider);
return doUploadFile(ossClient, provider, inputStream, fileName, bucketName, uploadUser, type, null, null, null, null, null, null); return doUploadFile(ossClient, provider, inputStream, fileName, bucketName, uploadUser, type, null, null, null, null, null, null,null,null);
} finally { } finally {
if (ossClient != null) { if (ossClient != null) {
ossClient.shutdown(); ossClient.shutdown();
...@@ -115,7 +116,8 @@ public class AliYunOssServiceImpl implements OssService { ...@@ -115,7 +116,8 @@ public class AliYunOssServiceImpl implements OssService {
FileUtil.getFileType(file.getOriginalFilename()), FileUtil.getFileType(file.getOriginalFilename()),
reqDto.getObjectBizId(), reqDto.getObjectName(), reqDto.getObjectBizId(), reqDto.getObjectName(),
reqDto.getObjectTableName(), reqDto.getObjectType(), reqDto.getObjectTableName(), reqDto.getObjectType(),
reqDto.getObjectBizId(), reqDto.getObjectName()); // 这里需要根据实际情况调整,原代码中存储了object信息 reqDto.getObjectBizId(), reqDto.getObjectName(),
reqDto.getTenantBizId(),reqDto.getProjectBizId()); // 这里需要根据实际情况调整,原代码中存储了object信息
} catch (IOException e) { } catch (IOException e) {
log.error("文件输入流解析异常", e); log.error("文件输入流解析异常", e);
throw new BusinessException("文件输入流解析异常"); throw new BusinessException("文件输入流解析异常");
...@@ -142,7 +144,7 @@ public class AliYunOssServiceImpl implements OssService { ...@@ -142,7 +144,7 @@ public class AliYunOssServiceImpl implements OssService {
try { try {
ossClient = ossClientFactory.createOssClient(defaultProvider); ossClient = ossClientFactory.createOssClient(defaultProvider);
OssUploadFileResDto result = doUploadFile(ossClient, defaultProvider, inputStream, fileName, OssUploadFileResDto result = doUploadFile(ossClient, defaultProvider, inputStream, fileName,
defaultProvider.getBucketName(), uploadUser, null, null, null, null, null, null, null); defaultProvider.getBucketName(), uploadUser, null, null, null, null, null, null, null,null,null);
return result.getUrl(); return result.getUrl();
} finally { } finally {
if (ossClient != null) { if (ossClient != null) {
...@@ -159,7 +161,8 @@ public class AliYunOssServiceImpl implements OssService { ...@@ -159,7 +161,8 @@ public class AliYunOssServiceImpl implements OssService {
String bucketName, String uploadUser, String type, String bucketName, String uploadUser, String type,
String objectBizId, String objectName, String objectBizId, String objectName,
String objectTableName, String objectType, String objectTableName, String objectType,
String creatorId, String creatorName) { String creatorId, String creatorName,
String tenantBizId,String projectBizId) {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
String operationResult = "success"; String operationResult = "success";
...@@ -203,8 +206,15 @@ public class AliYunOssServiceImpl implements OssService { ...@@ -203,8 +206,15 @@ public class AliYunOssServiceImpl implements OssService {
ossFile.setObjectName(objectName); ossFile.setObjectName(objectName);
ossFile.setObjectTableName(objectTableName); ossFile.setObjectTableName(objectTableName);
ossFile.setObjectType(objectType); ossFile.setObjectType(objectType);
ossFile.setTenantBizId(tenantBizId);
ossFile.setProjectBizId(projectBizId);
// 获取当前登录用户信息(如果存在) // 获取当前登录用户信息(如果存在)
AuthUserDto authUserDto = SecurityUtil.getCurrentLoginUser(); AuthUserDto authUserDto = null;
try {
authUserDto = SecurityUtil.getCurrentLoginUser();
}catch (Exception e){
log.info("获取当前登录用户信息失败:{}", e.getMessage());
}
if (authUserDto != null) { if (authUserDto != null) {
ossFile.setCreatorId(authUserDto.getUserBizId()); ossFile.setCreatorId(authUserDto.getUserBizId());
ossFile.setCreatorName(authUserDto.getRealName()); ossFile.setCreatorName(authUserDto.getRealName());
......
package com.yd.oss.service.service.impl;
import com.yd.oss.service.model.ChunkedUploadContext;
import com.yd.oss.service.dao.ChunkedUploadContextMapper;
import com.yd.oss.service.service.IChunkedUploadContextService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
/**
* <p>
* 分片上传上下文表(一个任务上传文件信息拆分成多个ETag上传存储,任务执行完毕,阿里云合并ETag列表为完整的文件信息) 服务实现类
* </p>
*
* @author zxm
* @since 2026-08-05
*/
@Service
public class ChunkedUploadContextServiceImpl extends ServiceImpl<ChunkedUploadContextMapper, ChunkedUploadContext> implements IChunkedUploadContextService {
}
...@@ -21,7 +21,7 @@ public class MyBatisPlusCodeGenerator { ...@@ -21,7 +21,7 @@ public class MyBatisPlusCodeGenerator {
}) })
.strategyConfig(builder -> { .strategyConfig(builder -> {
builder.addInclude( builder.addInclude(
"material","rel_object_material" "chunked_upload_context"
) )
.entityBuilder() .entityBuilder()
.enableLombok() .enableLombok()
......
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yd.oss.service.dao.ChunkedUploadContextMapper">
</mapper>
...@@ -287,5 +287,13 @@ ...@@ -287,5 +287,13 @@
<orderEntry type="library" name="Maven: org.apache.commons:commons-compress:1.19" level="project" /> <orderEntry type="library" name="Maven: org.apache.commons:commons-compress:1.19" level="project" />
<orderEntry type="library" name="Maven: com.github.virtuald:curvesapi:1.06" level="project" /> <orderEntry type="library" name="Maven: com.github.virtuald:curvesapi:1.06" level="project" />
<orderEntry type="library" name="Maven: org.apache.xmlbeans:xmlbeans:3.1.0" level="project" /> <orderEntry type="library" name="Maven: org.apache.xmlbeans:xmlbeans:3.1.0" level="project" />
<orderEntry type="library" name="Maven: org.jodconverter:jodconverter-core:4.4.7" level="project" />
<orderEntry type="library" name="Maven: org.jodconverter:jodconverter-local:4.4.7" level="project" />
<orderEntry type="library" name="Maven: org.openoffice:juh:4.1.2" level="project" />
<orderEntry type="library" name="Maven: org.openoffice:jurt:4.1.2" level="project" />
<orderEntry type="library" name="Maven: org.openoffice:ridl:4.1.2" level="project" />
<orderEntry type="library" name="Maven: org.openoffice:unoil:4.1.2" level="project" />
<orderEntry type="library" name="Maven: com.itextpdf:itextpdf:5.5.13" level="project" />
<orderEntry type="library" name="Maven: com.itextpdf:itext-asian:5.2.0" level="project" />
</component> </component>
</module> </module>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment