diff --git a/src/main/java/com/ai/da/common/utils/MinioUtil.java b/src/main/java/com/ai/da/common/utils/MinioUtil.java index 1c0709f3..d13c9eb4 100644 --- a/src/main/java/com/ai/da/common/utils/MinioUtil.java +++ b/src/main/java/com/ai/da/common/utils/MinioUtil.java @@ -41,6 +41,13 @@ public class MinioUtil { @Autowired private MinioClient minioClient; + /** + * 获取MinIO客户端实例 + */ + public MinioClient getMinioClient() { + return minioClient; + } + /** * description: 判断bucket是否存在,不存在则创建 * diff --git a/src/main/java/com/ai/da/controller/GlobalAwardController.java b/src/main/java/com/ai/da/controller/GlobalAwardController.java index 9d59d3d5..0c1cfc32 100644 --- a/src/main/java/com/ai/da/controller/GlobalAwardController.java +++ b/src/main/java/com/ai/da/controller/GlobalAwardController.java @@ -1,8 +1,13 @@ package com.ai.da.controller; import com.ai.da.common.response.Response; -import com.ai.da.model.dto.ContestantDTO; +import com.ai.da.model.dto.*; import com.ai.da.service.GlobalAwardService; +import com.ai.da.service.upload.UploadService; +import com.ai.da.service.upload.UploadTask; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -11,30 +16,132 @@ import java.util.Map; @RestController @RequestMapping("/api/global-award") +@Api(tags = "全球奖项API", description = "全球奖项大赛管理和文件上传") public class GlobalAwardController { @Resource private GlobalAwardService globalAwardService; - @PostMapping("/uploads/pdf") - public Response uploadPdf(@RequestParam("file") MultipartFile file, - @RequestParam(value = "email", required = false) String email) throws Exception { - return Response.success(globalAwardService.uploadPdf(file, email)); + @Resource + private UploadService uploadService; + +// @PostMapping("/uploads/pdf") +// public Response uploadPdf(@RequestParam("file") MultipartFile file, +// @RequestParam(value = "email", required = false) String email) throws Exception { +// return Response.success(globalAwardService.uploadPdf(file, email)); +// } +// +// @PostMapping("/uploads/video") +// public Response uploadVideo(@RequestParam("file") MultipartFile file, +// @RequestParam(value = "email", required = false) String email) throws Exception { +// return Response.success(globalAwardService.uploadVideo(file, email)); +// } + + // ===== 新增分片上传接口 ===== + + // ===== PDF分片上传接口 ===== + + /** 初始化PDF上传任务 */ + @PostMapping("/uploads/pdf/init") + @ApiOperation(value = "初始化PDF上传", notes = "创建新的PDF上传任务并返回上传参数") + public Response initPdfUpload(@ApiParam(value = "PDF上传初始化请求", required = true) @RequestBody UploadInitRequest request) { + UploadTask uploadTask = uploadService.initPdfUpload(request); + return Response.success(UploadInitResponse.builder() + .uploadId(uploadTask.getUploadId()) + .chunkSize(uploadTask.getChunkSize()) + .totalChunks(uploadTask.getTotalChunks()) + .expiresAt(uploadTask.getExpiresAt()) + .build()); } - @PostMapping("/uploads/video") - public Response uploadVideo(@RequestParam("file") MultipartFile file, - @RequestParam(value = "email", required = false) String email) throws Exception { - return Response.success(globalAwardService.uploadVideo(file, email)); + /** 上传PDF分片 */ + @PostMapping("/uploads/pdf/chunk") + @ApiOperation(value = "上传PDF分片", notes = "上传PDF文件的单个分片") + public Response uploadPdfChunk( + @ApiParam(value = "PDF文件分片", required = true) @RequestParam("chunk") MultipartFile chunk, + @ApiParam(value = "上传任务ID", required = true) @RequestParam("uploadId") String uploadId, + @ApiParam(value = "分片索引(从0开始)", required = true) @RequestParam("chunkIndex") int chunkIndex, + @ApiParam(value = "分片总数", required = true) @RequestParam("totalChunks") int totalChunks) { + + UploadChunkResponse uploadChunkResponse = uploadService.uploadPdfChunk(uploadId, chunk, chunkIndex, totalChunks); + return Response.success(uploadChunkResponse); + } + + /** 完成PDF上传 */ + @PostMapping("/uploads/pdf/complete") + @ApiOperation(value = "完成PDF上传", notes = "完成PDF上传并合并所有分片") + public Response completePdfUpload(@ApiParam(value = "PDF上传完成请求", required = true) @RequestBody UploadCompleteRequest request) { + UploadCompleteResponse uploadCompleteResponse = uploadService.completePdfUpload( + request.getUploadId(), + request.getFileName(), + request.getTotalSize()); + return Response.success(uploadCompleteResponse); + } + + /** 查询PDF上传状态 */ + @GetMapping("/uploads/pdf/status/{uploadId}") + @ApiOperation(value = "查询PDF上传状态", notes = "获取PDF上传任务的当前状态") + public Response getPdfUploadStatus(@ApiParam(value = "上传任务ID", required = true) @PathVariable String uploadId) { + UploadStatusResponse pdfUploadStatus = uploadService.getPdfUploadStatus(uploadId); + return Response.success(pdfUploadStatus); + } + + // ===== 视频分片上传接口 ===== + + /** 初始化视频上传任务 */ + @PostMapping("/uploads/video/init") + @ApiOperation(value = "初始化视频上传", notes = "创建新的视频上传任务并返回上传参数") + public Response initVideoUpload(@ApiParam(value = "视频上传初始化请求", required = true) @RequestBody UploadInitRequest request) { + UploadTask uploadTask = uploadService.initVideoUpload(request); + return Response.success(UploadInitResponse.builder() + .uploadId(uploadTask.getUploadId()) + .chunkSize(uploadTask.getChunkSize()) + .totalChunks(uploadTask.getTotalChunks()) + .expiresAt(uploadTask.getExpiresAt()) + .build()); + } + + /** 上传视频分片 */ + @PostMapping("/uploads/video/chunk") + @ApiOperation(value = "上传视频分片", notes = "上传视频文件的单个分片") + public Response uploadVideoChunk( + @ApiParam(value = "视频文件分片", required = true) @RequestParam("chunk") MultipartFile chunk, + @ApiParam(value = "上传任务ID", required = true) @RequestParam("uploadId") String uploadId, + @ApiParam(value = "分片索引(从0开始)", required = true) @RequestParam("chunkIndex") int chunkIndex, + @ApiParam(value = "分片总数", required = true) @RequestParam("totalChunks") int totalChunks) { + + UploadChunkResponse uploadChunkResponse = uploadService.uploadVideoChunk(uploadId, chunk, chunkIndex, totalChunks); + return Response.success(uploadChunkResponse); + } + + /** 完成视频上传 */ + @PostMapping("/uploads/video/complete") + @ApiOperation(value = "完成视频上传", notes = "完成视频上传并合并所有分片") + public Response completeVideoUpload(@ApiParam(value = "视频上传完成请求", required = true) @RequestBody UploadCompleteRequest request) { + UploadCompleteResponse uploadCompleteResponse = uploadService.completeVideoUpload( + request.getUploadId(), + request.getFileName(), + request.getTotalSize()); + return Response.success(uploadCompleteResponse); + } + + /** 查询视频上传状态 */ + @GetMapping("/uploads/video/status/{uploadId}") + @ApiOperation(value = "查询视频上传状态", notes = "获取视频上传任务的当前状态") + public Response getVideoUploadStatus(@ApiParam(value = "上传任务ID", required = true) @PathVariable String uploadId) { + UploadStatusResponse videoUploadStatus = uploadService.getVideoUploadStatus(uploadId); + return Response.success(videoUploadStatus); } @PostMapping("/contestants/save") - public Response> submit(@RequestBody ContestantDTO request) { + @ApiOperation(value = "保存参赛者信息", notes = "保存或更新参赛者信息及已上传的文件") + public Response> submit(@ApiParam(value = "参赛者信息", required = true) @RequestBody ContestantDTO request) { return Response.success(globalAwardService.saveContestant(request)); } @GetMapping("/contestants/by-email") - public Response getContestantByEmail(@RequestParam("email") String email) { + @ApiOperation(value = "根据邮箱查询参赛者", notes = "根据邮箱地址获取参赛者信息") + public Response getContestantByEmail(@ApiParam(value = "参赛者邮箱地址", required = true) @RequestParam("email") String email) { ContestantDTO dto = globalAwardService.getContestantByEmail(email); return Response.success(dto); } diff --git a/src/main/java/com/ai/da/model/dto/ContestantDTO.java b/src/main/java/com/ai/da/model/dto/ContestantDTO.java index 342be7a7..47d033f3 100644 --- a/src/main/java/com/ai/da/model/dto/ContestantDTO.java +++ b/src/main/java/com/ai/da/model/dto/ContestantDTO.java @@ -1,28 +1,58 @@ package com.ai.da.model.dto; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; import lombok.Data; /** * Contestant request DTO for Global Award */ @Data +@ApiModel(value = "参赛者信息", description = "全球奖项大赛参赛者信息数据传输对象") public class ContestantDTO { + @ApiModelProperty(value = "邮箱地址", required = true, example = "user@example.com") private String email; + + @ApiModelProperty(value = "名字", required = true, example = "John") private String firstName; + + @ApiModelProperty(value = "姓氏", required = true, example = "Doe") private String lastName; + + @ApiModelProperty(value = "性别", required = true, example = "Male", allowableValues = "Male,Female,Other") private String gender; + + @ApiModelProperty(value = "职业", required = true, example = "Designer") private String occupation; + + @ApiModelProperty(value = "年龄", required = true, example = "25") private Integer age; + + @ApiModelProperty(value = "国家/地区/城市", required = true, example = "China/Shanghai/Shanghai") private String countryRegionCity; + + @ApiModelProperty(value = "电话号码", required = true, example = "+86 138 0000 0000") private String phoneNumber; + + @ApiModelProperty(value = "作品集链接", required = false, example = "https://portfolio.example.com") private String portfolioUrl; + + @ApiModelProperty(value = "设计作品标题", required = true, example = "Modern Office Building Design") private String designTitle; + + @ApiModelProperty(value = "设计作品描述", required = true, example = "A modern office building design featuring sustainable materials...") private String designDescription; + + @ApiModelProperty(value = "PDF文件路径", required = false, example = "contestants/user@example.com/2024/01/design_1234567890.pdf") private String pdfPath; + + @ApiModelProperty(value = "视频文件路径", required = false, example = "contestants/user@example.com/2024/01/video_1234567890.mp4") private String videoPath; + /** * 是否确认覆盖已存在记录(false 表示发现已有记录时仅返回 existingRecord,不覆盖) */ + @ApiModelProperty(value = "是否确认覆盖已存在记录", required = false, example = "false") private Boolean confirm = false; } diff --git a/src/main/java/com/ai/da/model/dto/UploadChunkResponse.java b/src/main/java/com/ai/da/model/dto/UploadChunkResponse.java new file mode 100644 index 00000000..1d52fef8 --- /dev/null +++ b/src/main/java/com/ai/da/model/dto/UploadChunkResponse.java @@ -0,0 +1,33 @@ +package com.ai.da.model.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Builder; +import lombok.Data; + +/** + * 分片上传响应DTO + */ +@Data +@Builder +@ApiModel(value = "分片上传响应", description = "单个文件分片上传成功的响应数据") +public class UploadChunkResponse { + + /** + * 分片索引 + */ + @ApiModelProperty(value = "分片索引(从0开始)", required = true, example = "0") + private Integer chunkIndex; + + /** + * 是否上传成功 + */ + @ApiModelProperty(value = "是否上传成功", required = true, example = "true") + private Boolean uploaded; + + /** + * 分片大小(字节) + */ + @ApiModelProperty(value = "分片大小(字节)", required = true, example = "1048576") + private Long size; +} diff --git a/src/main/java/com/ai/da/model/dto/UploadCompleteRequest.java b/src/main/java/com/ai/da/model/dto/UploadCompleteRequest.java new file mode 100644 index 00000000..9817942f --- /dev/null +++ b/src/main/java/com/ai/da/model/dto/UploadCompleteRequest.java @@ -0,0 +1,39 @@ +package com.ai.da.model.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +/** + * 完成上传请求DTO + */ +@Data +@ApiModel(value = "完成上传请求", description = "文件上传完成时使用的请求参数") +public class UploadCompleteRequest { + + /** + * 上传任务ID + */ + @NotBlank(message = "上传任务ID不能为空") + @ApiModelProperty(value = "上传任务唯一标识", required = true, example = "550e8400-e29b-41d4-a716-446655440000") + private String uploadId; + + /** + * 文件名 + */ + @NotBlank(message = "文件名不能为空") + @ApiModelProperty(value = "原始文件名", required = true, example = "design.pdf") + private String fileName; + + /** + * 文件总大小(字节) + */ + @NotNull(message = "文件大小不能为空") + @Positive(message = "文件大小必须大于0") + @ApiModelProperty(value = "文件总大小(字节)", required = true, example = "10485760") + private Long totalSize; +} diff --git a/src/main/java/com/ai/da/model/dto/UploadCompleteResponse.java b/src/main/java/com/ai/da/model/dto/UploadCompleteResponse.java new file mode 100644 index 00000000..009ffe0e --- /dev/null +++ b/src/main/java/com/ai/da/model/dto/UploadCompleteResponse.java @@ -0,0 +1,33 @@ +package com.ai.da.model.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Builder; +import lombok.Data; + +/** + * 完成上传响应DTO + */ +@Data +@Builder +@ApiModel(value = "完成上传响应", description = "文件上传完成并合并成功的响应数据") +public class UploadCompleteResponse { + + /** + * 文件在MinIO中的路径 + */ + @ApiModelProperty(value = "文件在MinIO中的存储路径", required = true, example = "contestants/user@example.com/2024/01/design_1234567890.pdf") + private String filePath; + + /** + * 文件的完整URL + */ + @ApiModelProperty(value = "文件的完整访问URL", required = true, example = "https://minio.example.com/contestants/user@example.com/2024/01/design_1234567890.pdf") + private String fileUrl; + + /** + * 文件大小(字节) + */ + @ApiModelProperty(value = "文件大小(字节)", required = true, example = "10485760") + private Long fileSize; +} diff --git a/src/main/java/com/ai/da/model/dto/UploadInitRequest.java b/src/main/java/com/ai/da/model/dto/UploadInitRequest.java new file mode 100644 index 00000000..28e7e078 --- /dev/null +++ b/src/main/java/com/ai/da/model/dto/UploadInitRequest.java @@ -0,0 +1,46 @@ +package com.ai.da.model.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +/** + * 初始化上传请求DTO + */ +@Data +@ApiModel(value = "初始化上传请求", description = "文件上传初始化时使用的请求参数") +public class UploadInitRequest { + + /** + * 文件名 + */ + @NotBlank(message = "文件名不能为空") + @ApiModelProperty(value = "文件名", required = true, example = "design.pdf") + private String fileName; + + /** + * 文件大小(字节) + */ + @NotNull(message = "文件大小不能为空") + @Positive(message = "文件大小必须大于0") + @ApiModelProperty(value = "文件大小(字节)", required = true, example = "10485760") + private Long fileSize; + + /** + * 文件类型(MIME类型) + */ + @NotBlank(message = "文件类型不能为空") + @ApiModelProperty(value = "文件类型(MIME类型)", required = true, example = "application/pdf") + private String fileType; + + + /** + * 用户邮箱 + */ + @ApiModelProperty(value = "用户邮箱", required = false, example = "user@example.com") + private String email; +} diff --git a/src/main/java/com/ai/da/model/dto/UploadInitResponse.java b/src/main/java/com/ai/da/model/dto/UploadInitResponse.java new file mode 100644 index 00000000..b896a7eb --- /dev/null +++ b/src/main/java/com/ai/da/model/dto/UploadInitResponse.java @@ -0,0 +1,41 @@ +package com.ai.da.model.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 初始化上传响应DTO + */ +@Data +@Builder +@ApiModel(value = "初始化上传响应", description = "文件上传初始化成功的响应数据") +public class UploadInitResponse { + + /** + * 上传任务ID + */ + @ApiModelProperty(value = "上传任务唯一标识", required = true, example = "550e8400-e29b-41d4-a716-446655440000") + private String uploadId; + + /** + * 分片大小(字节) + */ + @ApiModelProperty(value = "每个分片的大小(字节)", required = true, example = "1048576") + private Integer chunkSize; + + /** + * 总分片数 + */ + @ApiModelProperty(value = "文件被分成多少个分片", required = true, example = "10") + private Integer totalChunks; + + /** + * 任务过期时间 + */ + @ApiModelProperty(value = "上传任务过期时间", required = true, example = "2024-01-20T10:30:00") + private LocalDateTime expiresAt; +} diff --git a/src/main/java/com/ai/da/model/dto/UploadStatusResponse.java b/src/main/java/com/ai/da/model/dto/UploadStatusResponse.java new file mode 100644 index 00000000..125f16a3 --- /dev/null +++ b/src/main/java/com/ai/da/model/dto/UploadStatusResponse.java @@ -0,0 +1,59 @@ +package com.ai.da.model.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Builder; +import lombok.Data; + +import java.util.Set; + +/** + * 上传状态响应DTO + */ +@Data +@Builder +@ApiModel(value = "上传状态响应", description = "查询上传任务当前状态的响应数据") +public class UploadStatusResponse { + + /** + * 上传任务ID + */ + @ApiModelProperty(value = "上传任务唯一标识", required = true, example = "550e8400-e29b-41d4-a716-446655440000") + private String uploadId; + + /** + * 上传状态 + */ + @ApiModelProperty(value = "上传任务状态", required = true, example = "uploading", allowableValues = "initiated,uploading,completed,failed,expired") + private String status; + + /** + * 上传进度百分比 (0-100) + */ + @ApiModelProperty(value = "上传进度百分比(0-100)", required = true, example = "60.0") + private Double progress; + + /** + * 已上传分片索引集合 + */ + @ApiModelProperty(value = "已上传分片的索引集合", required = true, example = "[0,1,2,3,4]") + private Set uploadedChunks; + + /** + * 总分片数 + */ + @ApiModelProperty(value = "文件被分成多少个分片", required = true, example = "10") + private Integer totalChunks; + + /** + * 文件总大小(字节) + */ + @ApiModelProperty(value = "文件总大小(字节)", required = true, example = "10485760") + private Long totalSize; + + /** + * 已上传大小(字节) + */ + @ApiModelProperty(value = "已上传的数据大小(字节)", required = true, example = "6291456") + private Long uploadedSize; +} diff --git a/src/main/java/com/ai/da/service/impl/GlobalAwardServiceImpl.java b/src/main/java/com/ai/da/service/impl/GlobalAwardServiceImpl.java index d1bac9bf..371ebb49 100644 --- a/src/main/java/com/ai/da/service/impl/GlobalAwardServiceImpl.java +++ b/src/main/java/com/ai/da/service/impl/GlobalAwardServiceImpl.java @@ -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 saveContestant(ContestantDTO request) { Map resp = new HashMap<>(); if (request.getEmail() == null) { - throw new IllegalArgumentException("email required"); + throw new BusinessException("Email is required."); } QueryWrapper 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 qw = new QueryWrapper<>(); qw.eq("email", email); diff --git a/src/main/java/com/ai/da/service/upload/UploadService.java b/src/main/java/com/ai/da/service/upload/UploadService.java new file mode 100644 index 00000000..0d051385 --- /dev/null +++ b/src/main/java/com/ai/da/service/upload/UploadService.java @@ -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(); +} diff --git a/src/main/java/com/ai/da/service/upload/UploadTask.java b/src/main/java/com/ai/da/service/upload/UploadTask.java new file mode 100644 index 00000000..825e1e62 --- /dev/null +++ b/src/main/java/com/ai/da/service/upload/UploadTask.java @@ -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 uploadedChunks; + + /** + * 上传状态 + */ + private UploadStatus status; + + /** + * 任务创建时间 + */ + private LocalDateTime createdAt; + + /** + * 任务过期时间 + */ + private LocalDateTime expiresAt; + + /** + * 最终文件在MinIO中的路径 + */ + private String finalPath; + + /** + * 上传状态枚举 + */ + public enum UploadStatus { + /** + * 已初始化,等待上传分片 + */ + INITIATED, + + /** + * 正在上传分片 + */ + UPLOADING, + + /** + * 上传完成 + */ + COMPLETED, + + /** + * 上传失败 + */ + FAILED, + + /** + * 任务过期 + */ + EXPIRED + } +} diff --git a/src/main/java/com/ai/da/service/upload/impl/UploadServiceImpl.java b/src/main/java/com/ai/da/service/upload/impl/UploadServiceImpl.java new file mode 100644 index 00000000..54788c42 --- /dev/null +++ b/src/main/java/com/ai/da/service/upload/impl/UploadServiceImpl.java @@ -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 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"; + } + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 7f5c52e7..52eefa8d 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -158,4 +158,27 @@ google.client.id=157095842121-kdd1fdf8m8nudvj9sprstb2k2prnf9e4.apps.googleuserco #google.client.secret=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 google.client.secret=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 google.redirect.uri=https://develop.api.aida.com.hk/api/third/party/auth/google_callback -design.callback.url=https://develop.api.aida.com.hk/api/third/party/receiveDesignResults \ No newline at end of file +design.callback.url=https://develop.api.aida.com.hk/api/third/party/receiveDesignResults + +# ===== 分片上传配置 ===== + +# 临时文件目录 +file.upload.temp.dir=temp/uploads + +# 分片大小配置 +# PDF分片大小:1MB +file.upload.chunk.size.pdf=1048576 +# 视频分片大小:2MB +file.upload.chunk.size.video=2097152 + +# 文件大小限制 +# PDF最大文件大小:20MB +file.upload.max.size.pdf=20971520 +# 视频最大文件大小:100MB +file.upload.max.size.video=104857600 + +# 上传任务过期时间(小时) +file.upload.task.expiry.hours=24 + +# MinIO配置 +minio.bucket=contestants \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 02d5f809..dfad3b33 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -156,4 +156,27 @@ FREEPIK_API_KEY=FPSX94e5917d376a4facb87dabbaa0319c72 google.client.id=29310152396-nnsd3h533fld665oguu8ovrt1nukmt46.apps.googleusercontent.com google.client.secret=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 google.redirect.uri=https://www.api.aida.com.hk/api/third/party/auth/google_callback -design.callback.url=https://api.aida.com.hk/api/third/party/receiveDesignResults \ No newline at end of file +design.callback.url=https://api.aida.com.hk/api/third/party/receiveDesignResults + +# ===== 分片上传配置 ===== + +# 临时文件目录 +file.upload.temp.dir=temp/uploads + +# 分片大小配置 +# PDF分片大小:1MB +file.upload.chunk.size.pdf=1048576 +# 视频分片大小:2MB +file.upload.chunk.size.video=2097152 + +# 文件大小限制 +# PDF最大文件大小:20MB +file.upload.max.size.pdf=20971520 +# 视频最大文件大小:100MB +file.upload.max.size.video=104857600 + +# 上传任务过期时间(小时) +file.upload.task.expiry.hours=24 + +# MinIO配置 +minio.bucket=contestants \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 974fee23..a1a2dbdf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,4 +5,7 @@ #spring.profiles.active=prod #����application-dev�ļ�(��������) -spring.profiles.active=dev +#spring.profiles.active=dev + +spring.profiles.active=local +