GlobalAward上传文件

This commit is contained in:
litianxiang
2026-01-20 15:58:27 +08:00
parent c6aec917c2
commit 46d61cb73f
16 changed files with 1206 additions and 22 deletions

View File

@@ -55,29 +55,29 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
private void validatePdf(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException("file is empty");
throw new BusinessException("File is empty.");
}
String ct = file.getContentType();
if (ct == null || !ct.toLowerCase().contains("pdf")) {
throw new BusinessException("only pdf allowed");
throw new BusinessException("Only PDF files are allowed.");
}
// size limit example 20MB
if (file.getSize() > 20L * 1024 * 1024) {
throw new BusinessException("pdf too large");
throw new BusinessException("PDF file size exceeds the limit.");
}
}
private void validateVideo(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException("file is empty");
throw new BusinessException("File is empty.");
}
String ct = file.getContentType();
if (ct == null || !(ct.toLowerCase().contains("mp4") || ct.toLowerCase().contains("video") )) {
throw new BusinessException("invalid video type");
throw new BusinessException("Invalid video file type.");
}
// size limit example 100MB
if (file.getSize() > 100L * 1024 * 1024) {
throw new BusinessException("video too large");
throw new BusinessException("Video file size exceeds the limit.");
}
}
@@ -108,7 +108,7 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
public Map<String, Object> saveContestant(ContestantDTO request) {
Map<String,Object> resp = new HashMap<>();
if (request.getEmail() == null) {
throw new IllegalArgumentException("email required");
throw new BusinessException("Email is required.");
}
QueryWrapper<Contestant> qw = new QueryWrapper<>();
@@ -161,7 +161,7 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
@Override
public ContestantDTO getContestantByEmail(String email) {
if (email == null) {
throw new BusinessException("email required");
throw new BusinessException("Email is required.");
}
QueryWrapper<Contestant> qw = new QueryWrapper<>();
qw.eq("email", email);

View File

@@ -0,0 +1,92 @@
package com.ai.da.service.upload;
import com.ai.da.model.dto.*;
import org.springframework.web.multipart.MultipartFile;
/**
* 分片上传服务接口
* 提供PDF和视频文件的分片上传、断点续传功能
*/
public interface UploadService {
// ===== PDF上传相关 =====
/**
* 初始化PDF上传任务
* @param request 初始化请求
* @return 上传任务信息
*/
UploadTask initPdfUpload(UploadInitRequest request);
/**
* 上传PDF分片
* @param uploadId 上传任务ID
* @param chunk 分片文件
* @param chunkIndex 分片索引
* @param totalChunks 总分片数
* @return 分片上传结果
*/
UploadChunkResponse uploadPdfChunk(String uploadId, MultipartFile chunk,
int chunkIndex, int totalChunks);
/**
* 完成PDF上传异步合并并上传到MinIO
* @param uploadId 上传任务ID
* @param fileName 文件名
* @param totalSize 文件总大小
* @return 完成上传结果
*/
UploadCompleteResponse completePdfUpload(String uploadId, String fileName, long totalSize);
/**
* 查询PDF上传状态
* @param uploadId 上传任务ID
* @return 上传状态信息
*/
UploadStatusResponse getPdfUploadStatus(String uploadId);
// ===== 视频上传相关 =====
/**
* 初始化视频上传任务
* @param request 初始化请求
* @return 上传任务信息
*/
UploadTask initVideoUpload(UploadInitRequest request);
/**
* 上传视频分片
* @param uploadId 上传任务ID
* @param chunk 分片文件
* @param chunkIndex 分片索引
* @param totalChunks 总分片数
* @return 分片上传结果
*/
UploadChunkResponse uploadVideoChunk(String uploadId, MultipartFile chunk,
int chunkIndex, int totalChunks);
/**
* 完成视频上传异步合并并上传到MinIO
* @param uploadId 上传任务ID
* @param fileName 文件名
* @param totalSize 文件总大小
* @return 完成上传结果
*/
UploadCompleteResponse completeVideoUpload(String uploadId, String fileName, long totalSize);
/**
* 查询视频上传状态
* @param uploadId 上传任务ID
* @return 上传状态信息
*/
UploadStatusResponse getVideoUploadStatus(String uploadId);
// ===== 通用功能 =====
/**
* 清理过期上传任务
*/
void cleanupExpiredUploads();
}

View File

@@ -0,0 +1,106 @@
package com.ai.da.service.upload;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Set;
/**
* 上传任务实体类
* 用于管理分片上传的状态和元数据
*/
@Data
@Builder
public class UploadTask {
/**
* 上传任务唯一标识
*/
private String uploadId;
/**
* 文件名
*/
private String fileName;
/**
* 文件类型 (pdf/video)
*/
private String fileType;
/**
* 文件总大小(字节)
*/
private Long fileSize;
/**
* 用户邮箱
*/
private String email;
/**
* 总分片数
*/
private Integer totalChunks;
/**
* 分片大小(字节)
*/
private Integer chunkSize;
/**
* 已上传分片索引集合
*/
private Set<Integer> uploadedChunks;
/**
* 上传状态
*/
private UploadStatus status;
/**
* 任务创建时间
*/
private LocalDateTime createdAt;
/**
* 任务过期时间
*/
private LocalDateTime expiresAt;
/**
* 最终文件在MinIO中的路径
*/
private String finalPath;
/**
* 上传状态枚举
*/
public enum UploadStatus {
/**
* 已初始化,等待上传分片
*/
INITIATED,
/**
* 正在上传分片
*/
UPLOADING,
/**
* 上传完成
*/
COMPLETED,
/**
* 上传失败
*/
FAILED,
/**
* 任务过期
*/
EXPIRED
}
}

View File

@@ -0,0 +1,542 @@
package com.ai.da.service.upload.impl;
import com.ai.da.common.config.exception.BusinessException;
import com.ai.da.common.utils.MinioUtil;
import com.ai.da.model.dto.*;
import com.ai.da.service.upload.UploadService;
import com.ai.da.service.upload.UploadTask;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.minio.PutObjectArgs;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.Resource;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.HashSet;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 分片上传服务实现类
*/
@Service
@Slf4j
public class UploadServiceImpl implements UploadService {
// ===== 配置参数 =====
@Value("${file.upload.temp.dir:temp/uploads}")
private String tempDir;
// PDF分片大小1MB
@Value("${file.upload.chunk.size.pdf:1048576}")
private int pdfChunkSize;
// 视频分片大小2MB
@Value("${file.upload.chunk.size.video:2097152}")
private int videoChunkSize;
// 文件大小限制
@Value("${file.upload.max.size.pdf:20971520}") // PDF: 20MB
private long maxPdfSize;
@Value("${file.upload.max.size.video:104857600}") // 视频: 100MB
private long maxVideoSize;
@Resource
private MinioUtil minioUtil;
@Value("${minio.bucket:contestants}")
private String minioBucket;
// 内存存储上传任务状态
private final ConcurrentHashMap<String, UploadTask> uploadTasks = new ConcurrentHashMap<>();
// JSON序列化工具
private final ObjectMapper objectMapper = new ObjectMapper();
// ===== PDF上传实现 =====
@Override
public UploadTask initPdfUpload(UploadInitRequest request) {
// 验证PDF文件
validatePdfFile(request);
// 创建上传任务
String uploadId = UUID.randomUUID().toString();
int totalChunks = (int) Math.ceil((double) request.getFileSize() / pdfChunkSize);
UploadTask task = createUploadTask(request, uploadId, totalChunks, pdfChunkSize, "pdf");
// 创建临时目录并保存任务状态
createTempDirectory(uploadId);
uploadTasks.put(uploadId, task);
saveTaskMetadata(task);
log.info("PDF上传任务初始化完成: uploadId={}, totalChunks={}, fileSize={}",
uploadId, totalChunks, request.getFileSize());
return task;
}
@Override
public UploadChunkResponse uploadPdfChunk(String uploadId, MultipartFile chunk,
int chunkIndex, int totalChunks) {
// 验证任务状态
UploadTask task = validateAndGetTask(uploadId, "pdf");
// 保存分片到本地
saveChunkToLocal(chunk, uploadId, chunkIndex);
// 更新任务进度
updateTaskProgress(task, chunkIndex);
log.debug("PDF分片上传完成: uploadId={}, chunkIndex={}, size={}",
uploadId, chunkIndex, chunk.getSize());
return UploadChunkResponse.builder()
.chunkIndex(chunkIndex)
.uploaded(true)
.size(chunk.getSize())
.build();
}
@Override
@Async
public UploadCompleteResponse completePdfUpload(String uploadId, String fileName, long totalSize) {
UploadTask task = validateAndGetTask(uploadId, "pdf");
log.info("开始PDF文件合并: uploadId={}, fileName={}", uploadId, fileName);
try {
// 1. 合并所有分片
Path mergedFile = mergeChunks(task);
// 2. 上传到MinIO
String finalPath = uploadToMinio(task, mergedFile, "pdf");
// 3. 更新任务状态并清理
completeTask(task, finalPath);
cleanupTempFiles(uploadId);
log.info("PDF上传完成: uploadId={}, finalPath={}", uploadId, finalPath);
return buildCompleteResponse(task, finalPath, totalSize);
} catch (Exception e) {
log.error("PDF上传失败: uploadId={}", uploadId, e);
task.setStatus(UploadTask.UploadStatus.FAILED);
saveTaskMetadata(task);
throw new BusinessException("File merge failed. Please try again.");
}
}
@Override
public UploadStatusResponse getPdfUploadStatus(String uploadId) {
UploadTask task = uploadTasks.get(uploadId);
if (task == null) {
throw new BusinessException("Upload task not found.");
}
if (!"pdf".equals(task.getFileType())) {
throw new BusinessException("Task type mismatch.");
}
// 计算上传进度
double progress = task.getTotalChunks() > 0 ?
(double) task.getUploadedChunks().size() / task.getTotalChunks() * 100 : 0;
long uploadedSize = task.getUploadedChunks().size() * task.getChunkSize();
return UploadStatusResponse.builder()
.uploadId(uploadId)
.status(task.getStatus().name().toLowerCase())
.progress(Math.min(progress, 100.0))
.uploadedChunks(new HashSet<>(task.getUploadedChunks()))
.totalChunks(task.getTotalChunks())
.totalSize(task.getFileSize())
.uploadedSize(Math.min(uploadedSize, task.getFileSize()))
.build();
}
// ===== 视频上传实现 =====
@Override
public UploadTask initVideoUpload(UploadInitRequest request) {
// 验证视频文件
validateVideoFile(request);
// 创建上传任务
String uploadId = UUID.randomUUID().toString();
int totalChunks = (int) Math.ceil((double) request.getFileSize() / videoChunkSize);
UploadTask task = createUploadTask(request, uploadId, totalChunks, videoChunkSize, "video");
// 创建临时目录并保存任务状态
createTempDirectory(uploadId);
uploadTasks.put(uploadId, task);
saveTaskMetadata(task);
log.info("视频上传任务初始化完成: uploadId={}, totalChunks={}, fileSize={}",
uploadId, totalChunks, request.getFileSize());
return task;
}
@Override
public UploadChunkResponse uploadVideoChunk(String uploadId, MultipartFile chunk,
int chunkIndex, int totalChunks) {
// 验证任务状态
UploadTask task = validateAndGetTask(uploadId, "video");
// 保存分片到本地
saveChunkToLocal(chunk, uploadId, chunkIndex);
// 更新任务进度
updateTaskProgress(task, chunkIndex);
log.debug("视频分片上传完成: uploadId={}, chunkIndex={}, size={}",
uploadId, chunkIndex, chunk.getSize());
return UploadChunkResponse.builder()
.chunkIndex(chunkIndex)
.uploaded(true)
.size(chunk.getSize())
.build();
}
@Override
@Async
public UploadCompleteResponse completeVideoUpload(String uploadId, String fileName, long totalSize) {
UploadTask task = validateAndGetTask(uploadId, "video");
log.info("开始视频文件合并: uploadId={}, fileName={}", uploadId, fileName);
try {
// 1. 合并所有分片
Path mergedFile = mergeChunks(task);
// 2. 上传到MinIO
String finalPath = uploadToMinio(task, mergedFile, "video");
// 3. 更新任务状态并清理
completeTask(task, finalPath);
cleanupTempFiles(uploadId);
log.info("视频上传完成: uploadId={}, finalPath={}", uploadId, finalPath);
return buildCompleteResponse(task, finalPath, totalSize);
} catch (Exception e) {
log.error("视频上传失败: uploadId={}", uploadId, e);
task.setStatus(UploadTask.UploadStatus.FAILED);
saveTaskMetadata(task);
throw new BusinessException("File merge failed. Please try again.");
}
}
@Override
public UploadStatusResponse getVideoUploadStatus(String uploadId) {
UploadTask task = uploadTasks.get(uploadId);
if (task == null) {
throw new BusinessException("Upload task not found.");
}
if (!"video".equals(task.getFileType())) {
throw new BusinessException("Task type mismatch.");
}
// 计算上传进度
double progress = task.getTotalChunks() > 0 ?
(double) task.getUploadedChunks().size() / task.getTotalChunks() * 100 : 0;
long uploadedSize = task.getUploadedChunks().size() * task.getChunkSize();
return UploadStatusResponse.builder()
.uploadId(uploadId)
.status(task.getStatus().name().toLowerCase())
.progress(Math.min(progress, 100.0))
.uploadedChunks(new HashSet<>(task.getUploadedChunks()))
.totalChunks(task.getTotalChunks())
.totalSize(task.getFileSize())
.uploadedSize(Math.min(uploadedSize, task.getFileSize()))
.build();
}
// ===== 核心辅助方法 =====
/**
* 验证PDF文件
*/
private void validatePdfFile(UploadInitRequest request) {
if (!"application/pdf".equals(request.getFileType())) {
throw new BusinessException("Only PDF files are allowed.");
}
if (request.getFileSize() > maxPdfSize) {
throw new BusinessException("PDF file size cannot exceed " + (maxPdfSize / 1024 / 1024) + "MB.");
}
}
/**
* 验证视频文件
*/
private void validateVideoFile(UploadInitRequest request) {
if (request.getFileType() == null ||
(!request.getFileType().contains("mp4") &&
!request.getFileType().contains("video") &&
!request.getFileType().contains("avi"))) {
throw new BusinessException("Unsupported video format.");
}
if (request.getFileSize() > maxVideoSize) {
throw new BusinessException("Video file size cannot exceed " + (maxVideoSize / 1024 / 1024) + "MB.");
}
}
/**
* 创建上传任务
*/
private UploadTask createUploadTask(UploadInitRequest request, String uploadId,
int totalChunks, int chunkSize, String fileType) {
return UploadTask.builder()
.uploadId(uploadId)
.fileName(request.getFileName())
.fileType(fileType)
.fileSize(request.getFileSize())
.email(request.getEmail())
.totalChunks(totalChunks)
.chunkSize(chunkSize)
.uploadedChunks(new HashSet<>())
.status(UploadTask.UploadStatus.INITIATED)
.createdAt(LocalDateTime.now())
.expiresAt(LocalDateTime.now().plusHours(24))
.build();
}
/**
* 创建临时目录
*/
private void createTempDirectory(String uploadId) {
try {
Path uploadDir = Paths.get(tempDir, uploadId, "chunks");
Files.createDirectories(uploadDir);
} catch (IOException e) {
throw new BusinessException("Failed to create temporary directory.");
}
}
/**
* 验证并获取上传任务
*/
private UploadTask validateAndGetTask(String uploadId, String expectedType) {
UploadTask task = uploadTasks.get(uploadId);
if (task == null) {
throw new BusinessException("Upload task not found.");
}
if (task.getExpiresAt().isBefore(LocalDateTime.now())) {
task.setStatus(UploadTask.UploadStatus.EXPIRED);
throw new BusinessException("Upload task has expired.");
}
if (!expectedType.equals(task.getFileType())) {
throw new BusinessException("Task type mismatch.");
}
if (task.getStatus() == UploadTask.UploadStatus.COMPLETED ||
task.getStatus() == UploadTask.UploadStatus.FAILED) {
throw new BusinessException("Task has already been completed or failed.");
}
return task;
}
/**
* 保存分片到本地
*/
private void saveChunkToLocal(MultipartFile chunk, String uploadId, int chunkIndex) {
try {
Path chunkPath = Paths.get(tempDir, uploadId, "chunks", "chunk_" + chunkIndex);
Files.copy(chunk.getInputStream(), chunkPath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new BusinessException("Failed to save file chunk.");
}
}
/**
* 更新任务进度
*/
private void updateTaskProgress(UploadTask task, int chunkIndex) {
task.getUploadedChunks().add(chunkIndex);
task.setStatus(UploadTask.UploadStatus.UPLOADING);
saveTaskMetadata(task);
}
/**
* 合并分片文件(零拷贝方式)
*/
private Path mergeChunks(UploadTask task) throws IOException {
Path mergedFile = Files.createTempFile("merge_", "_" + task.getFileName());
try (FileChannel outputChannel = FileChannel.open(mergedFile, StandardOpenOption.WRITE)) {
for (int i = 0; i < task.getTotalChunks(); i++) {
Path chunkPath = Paths.get(tempDir, task.getUploadId(), "chunks", "chunk_" + i);
try (FileChannel inputChannel = FileChannel.open(chunkPath, StandardOpenOption.READ)) {
// 零拷贝传输,提升性能
inputChannel.transferTo(0, inputChannel.size(), outputChannel);
}
}
}
return mergedFile;
}
/**
* 上传文件到MinIO
*/
private String uploadToMinio(UploadTask task, Path mergedFile, String fileType) throws Exception {
// 生成MinIO路径: contestants/{email}/{date}/{filename}
String normalizedEmail = normalizeEmail(task.getEmail());
String datePart = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
String ext = getFileExtension(task.getFileName());
String filename = System.currentTimeMillis() + "_" + UUID.randomUUID().toString() + ext;
String relativePath = "contestants/" + normalizedEmail + "/" + datePart + "/" + filename;
// 上传文件
try (FileInputStream fis = new FileInputStream(mergedFile.toFile())) {
minioUtil.getMinioClient().putObject(
PutObjectArgs.builder()
.bucket(minioBucket)
.object(relativePath)
.stream(fis, mergedFile.toFile().length(), -1)
.contentType(getContentType(fileType))
.build()
);
}
return relativePath;
}
/**
* 完成任务
*/
private void completeTask(UploadTask task, String finalPath) {
task.setStatus(UploadTask.UploadStatus.COMPLETED);
task.setFinalPath(finalPath);
saveTaskMetadata(task);
}
/**
* 构建完成响应
*/
private UploadCompleteResponse buildCompleteResponse(UploadTask task, String finalPath, long totalSize) {
// todo:URL是逻辑url
String fileUrl = minioBucket + "/" + finalPath;
return UploadCompleteResponse.builder()
.filePath(finalPath)
.fileUrl(fileUrl)
.fileSize(totalSize)
.build();
}
/**
* 保存任务元数据
*/
private void saveTaskMetadata(UploadTask task) {
try {
Path metadataPath = Paths.get(tempDir, task.getUploadId(), "metadata.json");
String json = objectMapper.writeValueAsString(task);
Files.writeString(metadataPath, json);
} catch (IOException e) {
log.warn("保存任务元数据失败: uploadId={}", task.getUploadId());
}
}
/**
* 清理临时文件
*/
private void cleanupTempFiles(String uploadId) {
try {
Path uploadDir = Paths.get(tempDir, uploadId);
if (Files.exists(uploadDir)) {
Files.walk(uploadDir)
.sorted(Comparator.reverseOrder())
.forEach(path -> {
try {
Files.delete(path);
} catch (IOException e) {
log.warn("删除临时文件失败: {}", path);
}
});
}
} catch (Exception e) {
log.warn("清理临时文件失败: uploadId={}", uploadId);
}
}
/**
* 清理过期上传任务(每小时执行一次)
*/
@Scheduled(fixedDelay = 3600000) // 1小时
public void cleanupExpiredUploads() {
LocalDateTime now = LocalDateTime.now();
uploadTasks.entrySet().removeIf(entry -> {
UploadTask task = entry.getValue();
if (task.getExpiresAt().isBefore(now)) {
cleanupTempFiles(task.getUploadId());
return true;
}
return false;
});
}
// ===== 工具方法 =====
/**
* 标准化邮箱地址
*/
private String normalizeEmail(String email) {
if (email == null) {
return "anonymous";
}
return email.replaceAll("[^a-zA-Z0-9]", "_");
}
/**
* 获取文件扩展名
*/
private String getFileExtension(String fileName) {
if (fileName != null && fileName.contains(".")) {
return fileName.substring(fileName.lastIndexOf('.'));
}
return "";
}
/**
* 获取文件MIME类型
*/
private String getContentType(String fileType) {
switch (fileType.toLowerCase()) {
case "pdf":
return "application/pdf";
case "video":
return "video/mp4";
default:
return "application/octet-stream";
}
}
}