浏览代码

feat(game-event): 实现参赛证任务的OSS文件管理与智能上传

- 添加OSS文件ID字段到GameBibTask及相关传输对象
- 实现基于OSS的文件删除与下载逻辑- 添加预签名URL生成功能用于直接下载
- 实现智能上传机制(根据文件大小选择直接或分片上传)- 优化ZIP文件生成逻辑,直接在内存中创建不保存单个图片
- 完善任务完成状态更新,支持OSS文件ID存储
- 添加分片上传相关接口与实现(初始化、上传、完成、取消)
- 修复文件下载逻辑优先从OSS下载再回退本地文件
-优化文件删除逻辑,分别处理OSS与本地文件
- 更新MyBatis Mapper注解与依赖注入配置
zhou 1 月之前
父节点
当前提交
114803b379
共有 16 个文件被更改,包括 1106 次插入53 次删除
  1. 4 5
      pom.xml
  2. 187 1
      ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java
  3. 3 1
      ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java
  4. 28 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/NumberController.java
  5. 5 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameBibTask.java
  6. 6 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameBibTaskBo.java
  7. 5 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameBibTaskVO.java
  8. 2 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameBibTaskMapper.java
  9. 9 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameBibTaskService.java
  10. 88 13
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameBibTaskServiceImpl.java
  11. 80 27
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventServiceImpl.java
  12. 16 0
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/config/ScheduleConfig.java
  13. 152 0
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysMultipartUploadController.java
  14. 63 0
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysOssService.java
  15. 292 6
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java
  16. 166 0
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/UploadCleanupService.java

+ 4 - 5
pom.xml

@@ -80,6 +80,10 @@
                 <monitor.username>ruoyi</monitor.username>
                 <monitor.password>123456</monitor.password>
             </properties>
+            <activation>
+                <!-- 默认环境 -->
+                <activeByDefault>true</activeByDefault>
+            </activation>
 
         </profile>
         <profile>
@@ -91,10 +95,6 @@
                 <monitor.username>ruoyi</monitor.username>
                 <monitor.password>123456</monitor.password>
             </properties>
-            <activation>
-                <!-- 默认环境 -->
-                <activeByDefault>true</activeByDefault>
-            </activation>
 
         </profile>
         <profile>
@@ -106,7 +106,6 @@
                 <monitor.password>123456</monitor.password>
             </properties>
 
-
         </profile>
     </profiles>
 

+ 187 - 1
ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java

@@ -20,8 +20,9 @@ import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
 import software.amazon.awssdk.regions.Region;
 import software.amazon.awssdk.services.s3.S3AsyncClient;
 import software.amazon.awssdk.services.s3.S3Configuration;
-import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.*;
 import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
 import software.amazon.awssdk.transfer.s3.S3TransferManager;
 import software.amazon.awssdk.transfer.s3.model.*;
 import software.amazon.awssdk.transfer.s3.progress.LoggingTransferListener;
@@ -32,6 +33,8 @@ import java.net.URL;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
 import java.util.function.Consumer;
 
 /**
@@ -313,6 +316,19 @@ public class OssClient {
         return upload(new ByteArrayInputStream(data), getPath(properties.getPrefix(), suffix), Long.valueOf(data.length), contentType);
     }
 
+    /**
+     * 上传 byte[] 数据到 Amazon S3,使用原始文件名
+     *
+     * @param data           要上传的 byte[] 数据
+     * @param originalFileName 原始文件名
+     * @param contentType    内容类型
+     * @return UploadResult 包含上传后的文件信息
+     * @throws OssException 如果上传失败,抛出自定义异常
+     */
+    public UploadResult uploadWithOriginalName(byte[] data, String originalFileName, String contentType) {
+        return upload(new ByteArrayInputStream(data), getPathWithCustomName(originalFileName), Long.valueOf(data.length), contentType);
+    }
+
     /**
      * 上传 InputStream 到 Amazon S3,使用指定的后缀构造对象键。
      *
@@ -446,6 +462,22 @@ public class OssClient {
         return path + suffix;
     }
 
+    /**
+     * 获取自定义文件路径(使用原始文件名)
+     *
+     * @param originalFileName 原始文件名
+     * @return 文件路径
+     */
+    public String getPathWithCustomName(String originalFileName) {
+        // 生成日期路径
+        String datePath = DateUtils.datePath();
+        // 使用原始文件名,但添加日期路径避免冲突
+        String path = StringUtils.isNotEmpty(properties.getPrefix()) ?
+            properties.getPrefix() + StringUtils.SLASH + datePath + StringUtils.SLASH + originalFileName : 
+            datePath + StringUtils.SLASH + originalFileName;
+        return path;
+    }
+
     /**
      * 移除路径中的基础URL部分,得到相对路径
      *
@@ -488,4 +520,158 @@ public class OssClient {
         return AccessPolicyType.getByType(properties.getAccessPolicy());
     }
 
+    /**
+     * 初始化分片上传
+     *
+     * @param fileName 文件名
+     * @return 上传ID
+     */
+    public String initMultipartUpload(String fileName) {
+        try {
+            CreateMultipartUploadRequest request = CreateMultipartUploadRequest.builder()
+                .bucket(properties.getBucketName())
+                .key(fileName)
+                .build();
+
+            CompletableFuture<CreateMultipartUploadResponse> response = client.createMultipartUpload(request);
+            CreateMultipartUploadResponse result = response.get();
+            return result.uploadId();
+        } catch (Exception e) {
+            throw new OssException("初始化分片上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 上传分片
+     *
+     * @param fileName 文件名
+     * @param uploadId 上传ID
+     * @param partNumber 分片序号
+     * @param partData 分片数据
+     * @return 分片ETag
+     */
+    public String uploadPart(String fileName, String uploadId, int partNumber, byte[] partData) {
+        try {
+            UploadPartRequest request = UploadPartRequest.builder()
+                .bucket(properties.getBucketName())
+                .key(fileName)
+                .uploadId(uploadId)
+                .partNumber(partNumber)
+                .build();
+
+            CompletableFuture<UploadPartResponse> response = client.uploadPart(
+                request,
+                software.amazon.awssdk.core.async.AsyncRequestBody.fromBytes(partData)
+            );
+
+            UploadPartResponse result = response.get();
+            return result.eTag();
+        } catch (Exception e) {
+            throw new OssException("上传分片失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 完成分片上传
+     *
+     * @param fileName 文件名
+     * @param uploadId 上传ID
+     * @param partETags 分片ETag列表
+     * @return 上传结果
+     */
+    public UploadResult completeMultipartUpload(String fileName, String uploadId, List<String> partETags) {
+        try {
+            // 构建分片列表
+            List<CompletedPart> completedParts = new java.util.ArrayList<>();
+            for (int i = 0; i < partETags.size(); i++) {
+                completedParts.add(CompletedPart.builder()
+                    .partNumber(i + 1)
+                    .eTag(partETags.get(i))
+                    .build());
+            }
+
+            CompletedMultipartUpload completedUpload = CompletedMultipartUpload.builder()
+                .parts(completedParts)
+                .build();
+
+            CompleteMultipartUploadRequest request = CompleteMultipartUploadRequest.builder()
+                .bucket(properties.getBucketName())
+                .key(fileName)
+                .uploadId(uploadId)
+                .multipartUpload(completedUpload)
+                .build();
+
+            CompletableFuture<CompleteMultipartUploadResponse> response = client.completeMultipartUpload(request);
+            CompleteMultipartUploadResponse result = response.get();
+
+            return UploadResult.builder()
+                .url(getUrl() + StringUtils.SLASH + fileName)
+                .filename(fileName)
+                .eTag(result.eTag())
+                .build();
+        } catch (Exception e) {
+            throw new OssException("完成分片上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 取消分片上传
+     *
+     * @param fileName 文件名
+     * @param uploadId 上传ID
+     */
+    public void abortMultipartUpload(String fileName, String uploadId) {
+        try {
+            AbortMultipartUploadRequest request = AbortMultipartUploadRequest.builder()
+                .bucket(properties.getBucketName())
+                .key(fileName)
+                .uploadId(uploadId)
+                .build();
+
+            client.abortMultipartUpload(request).get();
+        } catch (Exception e) {
+            throw new OssException("取消分片上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 生成预签名下载URL
+     *
+     * @param fileName 文件名
+     * @param expirationSeconds 过期时间(秒)
+     * @return 预签名URL
+     */
+    public String generatePresignedUrl(String fileName, int expirationSeconds) {
+        try {
+            S3Presigner presigner = S3Presigner.builder()
+                .region(of())
+                .credentialsProvider(StaticCredentialsProvider.create(
+                    AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())
+                ))
+                .endpointOverride(URI.create(getEndpoint()))
+                .build();
+
+            // 从文件名中提取原始文件名(去掉路径)
+            String originalFileName = fileName.substring(fileName.lastIndexOf("/") + 1);
+            
+            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
+                .bucket(properties.getBucketName())
+                .key(fileName)
+                .responseContentDisposition("attachment; filename=\"" + originalFileName + "\"")
+                .build();
+
+            GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
+                .signatureDuration(Duration.ofSeconds(expirationSeconds))
+                .getObjectRequest(getObjectRequest)
+                .build();
+
+            URL presignedUrl = presigner.presignGetObject(presignRequest).url();
+            presigner.close();
+
+            return presignedUrl.toString();
+        } catch (Exception e) {
+            throw new OssException("生成预签名URL失败: " + e.getMessage());
+        }
+    }
+
 }

+ 3 - 1
ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java

@@ -79,7 +79,9 @@ public class SecurityConfig implements WebMvcConfigurer {
             })).addPathPatterns("/**")
             // 排除不需要拦截的路径
             .excludePathPatterns(securityProperties.getExcludes())
-            .excludePathPatterns(ssePath);
+            .excludePathPatterns(ssePath)
+            // 排除公开下载接口
+            .excludePathPatterns("/system/number/public/downloadTask/**");
     }
 
     /**

+ 28 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/NumberController.java

@@ -19,6 +19,7 @@ import org.dromara.system.domain.vo.AthleteNumberTableVO;
 import org.dromara.system.domain.vo.GameBibTaskVO;
 import org.dromara.system.service.IGameBibTaskService;
 import org.dromara.system.service.IGameEventService;
+import org.dromara.system.service.ISysOssService;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
@@ -40,6 +41,7 @@ public class NumberController {
 
     private final IGameEventService gameEventService;
     private final IGameBibTaskService gameBibTaskService;
+    private final ISysOssService sysOssService;
 
     // 预览框尺寸(固定)
     private static final int previewWidth = 600;
@@ -177,6 +179,32 @@ public class NumberController {
         gameBibTaskService.downloadTaskResult(taskId, response);
     }
 
+    /**
+     * 获取任务下载的预签名URL(用于直接OSS下载)
+     */
+    @GetMapping("/getDownloadUrl/{taskId}")
+    public R<String> getDownloadUrl(@PathVariable Long taskId) {
+        try {
+            GameBibTaskVO task = gameBibTaskService.queryById(taskId);
+            if (task == null) {
+                return R.fail("任务不存在");
+            }
+            
+            if (task.getOssId() == null) {
+                return R.fail("任务文件未上传到OSS");
+            }
+            
+            // 生成1小时有效的预签名URL
+            log.info("开始生成预签名URL,taskId: {}, ossId: {}", taskId, task.getOssId());
+            String presignedUrl = sysOssService.generatePresignedUrl(task.getOssId(), 3600);
+            log.info("预签名URL生成成功: {}", presignedUrl);
+            return R.ok(presignedUrl);
+        } catch (Exception e) {
+            log.error("获取下载URL失败,taskId: {}", taskId, e);
+            return R.fail("获取下载链接失败: " + e.getMessage());
+        }
+    }
+
     /**
      * 验证和修正参赛证参数边界
      */

+ 5 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameBibTask.java

@@ -78,6 +78,11 @@ public class GameBibTask implements Serializable {
      */
     private String resultFilePath;
 
+    /**
+     * OSS文件ID
+     */
+    private Long ossId;
+
     /**
      * 错误信息
      */

+ 6 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameBibTaskBo.java

@@ -96,6 +96,12 @@ public class GameBibTaskBo implements Serializable {
     @Schema(description = "结果文件路径")
     private String resultFilePath;
 
+    /**
+     * OSS文件ID
+     */
+    @Schema(description = "OSS文件ID")
+    private Long ossId;
+
     /**
      * 错误信息
      */

+ 5 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameBibTaskVO.java

@@ -86,6 +86,11 @@ public class GameBibTaskVO implements Serializable {
      */
     private String resultFilePath;
 
+    /**
+     * OSS文件ID
+     */
+    private Long ossId;
+
     /**
      * 错误信息
      */

+ 2 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameBibTaskMapper.java

@@ -1,5 +1,6 @@
 package org.dromara.system.mapper;
 
+import org.apache.ibatis.annotations.Mapper;
 import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
 import org.dromara.system.domain.GameBibTask;
 import org.dromara.system.domain.vo.GameBibTaskVO;
@@ -10,6 +11,7 @@ import org.dromara.system.domain.vo.GameBibTaskVO;
  * @author zlt
  * @date 2025-01-27
  */
+@Mapper
 public interface GameBibTaskMapper extends BaseMapperPlus<GameBibTask, GameBibTaskVO> {
 
 }

+ 9 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameBibTaskService.java

@@ -128,6 +128,15 @@ public interface IGameBibTaskService {
      */
     void completeTask(Long taskId, String resultFilePath);
 
+    /**
+     * 完成任务(OSS版本)
+     *
+     * @param taskId         任务ID
+     * @param resultFilePath 结果文件路径
+     * @param ossId          OSS文件ID
+     */
+    void completeTaskWithOss(Long taskId, String resultFilePath, Long ossId);
+
     /**
      * 暂停任务
      *

+ 88 - 13
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameBibTaskServiceImpl.java

@@ -20,7 +20,9 @@ import org.dromara.system.domain.vo.GameBibTaskVO;
 import org.dromara.system.config.FileUploadConfig;
 import org.dromara.system.mapper.GameBibTaskMapper;
 import org.dromara.system.service.IGameBibTaskService;
+import org.dromara.system.service.ISysOssService;
 import org.dromara.system.utils.EventNameUtils;
+import org.dromara.system.domain.vo.SysOssVo;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.multipart.MultipartFile;
@@ -38,8 +40,6 @@ import java.time.Instant;
 import java.time.LocalDateTime;
 import java.util.Collection;
 import java.util.Date;
-import java.util.List;
-import java.util.Map;
 
 /**
  * 参赛证生成任务Service业务层处理
@@ -55,6 +55,7 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
     private final GameBibTaskMapper baseMapper;
     private final FileUploadConfig fileUploadConfig;
     private final EventNameUtils eventNameUtils;
+    private final ISysOssService sysOssService;
 
     private static final ObjectMapper objectMapper = new ObjectMapper();
 
@@ -192,12 +193,23 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
                 // 删除相关文件
                 GameBibTaskVO task = queryById(taskId);
                 if (task != null) {
-                    // 删除单个文件
+                    // 删除OSS文件
+                    if (task.getOssId() != null) {
+                        try {
+                            sysOssService.deleteWithValidByIds(List.of(task.getOssId()), false);
+                            log.info("OSS文件删除成功,taskId: {}, ossId: {}", taskId, task.getOssId());
+                        } catch (Exception e) {
+                            log.error("删除OSS文件失败,taskId: {}, ossId: {}, 错误: {}", taskId, task.getOssId(), e.getMessage(), e);
+                            // OSS删除失败不影响任务删除,继续执行
+                        }
+                    }
+                    
+                    // 删除本地文件
                     deleteFileIfExists(task.getBgImagePath());
                     deleteFileIfExists(task.getLogoImagePath());
                     deleteFileIfExists(task.getResultFilePath());
                     
-                    // 删除整个任务文件夹
+                    // 删除任务文件夹(现在只包含ZIP文件,不包含单个图片文件)
                     deleteTaskFolder(taskId);
                 }
             } catch (Exception e) {
@@ -358,6 +370,25 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
         updateByBo(updateBo);
     }
 
+    /**
+     * 完成任务(OSS版本)
+     *
+     * @param taskId         任务ID
+     * @param resultFilePath 结果文件路径
+     * @param ossId          OSS文件ID
+     */
+    @Override
+    public void completeTaskWithOss(Long taskId, String resultFilePath, Long ossId) {
+        GameBibTaskBo updateBo = new GameBibTaskBo();
+        updateBo.setTaskId(taskId);
+        updateBo.setStatus("2"); // 完成
+        updateBo.setProgress(100);
+        updateBo.setResultFilePath(resultFilePath);
+        updateBo.setOssId(ossId); // 保存OSS文件ID
+        updateBo.setFinishTime(Date.from(Instant.now()));
+        updateByBo(updateBo);
+    }
+
     /**
      * 暂停任务
      *
@@ -379,12 +410,23 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
         // 删除相关文件
         GameBibTaskVO task = queryById(taskId);
         if (task != null) {
-            // 删除单个文件
+            // 删除OSS文件
+            if (task.getOssId() != null) {
+                try {
+                    sysOssService.deleteWithValidByIds(List.of(task.getOssId()), false);
+                    log.info("OSS文件删除成功,taskId: {}, ossId: {}", taskId, task.getOssId());
+                } catch (Exception e) {
+                    log.error("删除OSS文件失败,taskId: {}, ossId: {}, 错误: {}", taskId, task.getOssId(), e.getMessage(), e);
+                    // OSS删除失败不影响任务删除,继续执行
+                }
+            }
+            
+            // 删除本地文件
             deleteFileIfExists(task.getBgImagePath());
             deleteFileIfExists(task.getLogoImagePath());
             deleteFileIfExists(task.getResultFilePath());
             
-            // 删除整个任务文件夹
+            // 删除任务文件夹(现在只包含ZIP文件,不包含单个图片文件)
             deleteTaskFolder(taskId);
         }
 
@@ -401,15 +443,33 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
     @Override
     public void downloadTaskResult(Long taskId, jakarta.servlet.http.HttpServletResponse response) {
         GameBibTaskVO task = queryById(taskId);
-        if (task == null || StringUtils.isBlank(task.getResultFilePath())) {
-            log.error("任务不存在或结果文件不存在,taskId: {}, task: {}", taskId, task);
-            throw new RuntimeException("任务不存在或结果文件不存在");
+        if (task == null) {
+            log.error("任务不存在,taskId: {}", taskId);
+            throw new RuntimeException("任务不存在");
+        }
+
+        // 优先从OSS下载
+        if (task.getOssId() != null) {
+            log.info("从OSS下载文件,taskId: {}, ossId: {}", taskId, task.getOssId());
+            try {
+                sysOssService.download(task.getOssId(), response);
+                return;
+            } catch (Exception e) {
+                log.error("从OSS下载失败,taskId: {}, ossId: {}, 错误: {}", taskId, task.getOssId(), e.getMessage());
+                // OSS下载失败,尝试本地下载
+            }
+        }
+
+        // 回退到本地文件下载
+        if (StringUtils.isBlank(task.getResultFilePath())) {
+            log.error("任务结果文件路径为空,taskId: {}", taskId);
+            throw new RuntimeException("结果文件不存在");
         }
 
         File file = new File(task.getResultFilePath());
-        log.info("尝试下载文件,taskId: {}, 文件路径: {}, 文件存在: {}", taskId, task.getResultFilePath(), file.exists());
+        log.info("尝试从本地下载文件,taskId: {}, 文件路径: {}, 文件存在: {}", taskId, task.getResultFilePath(), file.exists());
         if (!file.exists()) {
-            log.error("结果文件不存在,taskId: {}, 文件路径: {}", taskId, task.getResultFilePath());
+            log.error("本地结果文件不存在,taskId: {}, 文件路径: {}", taskId, task.getResultFilePath());
             throw new RuntimeException("结果文件不存在");
         }
 
@@ -478,8 +538,23 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
             }
             
             if (taskFolder.exists() && taskFolder.isDirectory()) {
-                // 递归删除文件夹及其内容
-                boolean deleted = deleteDirectory(taskFolder);
+                // 删除文件夹中的所有文件(主要是ZIP文件)
+                File[] files = taskFolder.listFiles();
+                if (files != null) {
+                    for (File file : files) {
+                        if (file.isFile()) {
+                            boolean deleted = file.delete();
+                            if (deleted) {
+                                log.info("任务文件删除成功: {}", file.getName());
+                            } else {
+                                log.warn("任务文件删除失败: {}", file.getName());
+                            }
+                        }
+                    }
+                }
+                
+                // 删除空文件夹
+                boolean deleted = taskFolder.delete();
                 if (deleted) {
                     log.info("任务文件夹删除成功: {}", taskFolderPath);
                 } else {

+ 80 - 27
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventServiceImpl.java

@@ -43,6 +43,7 @@ import org.dromara.system.domain.vo.*;
 import org.dromara.system.mapper.GameEventMapper;
 import org.dromara.system.config.FileUploadConfig;
 import org.dromara.system.service.*;
+import org.dromara.system.domain.vo.SysOssVo;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
@@ -93,6 +94,8 @@ public class GameEventServiceImpl implements IGameEventService {
     private IGameBibTaskService gameBibTaskService;
     @Resource
     private FileUploadConfig fileUploadConfig;
+    @Resource
+    private ISysOssService sysOssService;
     private static final ExecutorService PDF_GENERATION_EXECUTOR =
         Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2);
     // 常见图片类型的文件头(前几个字节)
@@ -1606,11 +1609,8 @@ public class GameEventServiceImpl implements IGameEventService {
             // 更新总数量
             gameBibTaskService.updateTaskProgress(taskId, 0, 0);
 
-            // 生成参赛证文件
-            String resultFilePath = generateBibFromTemplateFiles(taskId, eventId, templateImage, bibParam, athleteVoList);
-
-            // 完成任务
-            gameBibTaskService.completeTask(taskId, resultFilePath);
+            // 生成参赛证文件(内部已处理任务完成)
+            generateBibFromTemplateFiles(taskId, eventId, templateImage, bibParam, athleteVoList);
 
         } catch (Exception e) {
             log.error("基于模版生成参赛证失败,taskId: {}", taskId, e);
@@ -1637,31 +1637,39 @@ public class GameEventServiceImpl implements IGameEventService {
             int totalCount = athleteVoList.size();
             int completedCount = 0;
 
-            // 为每个运动员生成参赛证图片
-            for (GameAthleteVo athlete : athleteVoList) {
-                try {
-                    // 基于模版生成单个参赛证图片
-                    byte[] bibImage = generateSingleBibFromTemplate(templateImage, athlete, bibParam);
-
-                    // 保存图片文件
-                    String fileName = athlete.getAthleteCode() + "_" + athlete.getName() + ".png";
-                    String filePath = resultDir + fileName;
-                    Files.write(Paths.get(filePath), bibImage);
-
-                    completedCount++;
-                    int progress = (completedCount * 100) / totalCount;
-                    gameBibTaskService.updateTaskProgress(taskId, progress, completedCount);
+            // 创建ZIP文件(直接在内存中生成,不保存单个图片文件)
+            String zipFilePath = resultDir + "参赛证_" + taskId + ".zip";
+            createZipFileFromMemory(templateImage, athleteVoList, bibParam, zipFilePath, taskId);
+            log.info("ZIP文件创建完成: {}", zipFilePath);
 
-                } catch (Exception e) {
-                    log.error("生成运动员参赛证失败: {}", athlete.getName(), e);
+            // 智能上传ZIP文件到OSS(根据文件大小自动选择上传方式)
+            try {
+                File zipFile = new File(zipFilePath);
+                if (zipFile.exists()) {
+                    long fileSize = zipFile.length();
+                    log.info("ZIP文件大小: {} bytes ({} MB)", fileSize, fileSize / 1024 / 1024);
+                    
+                    // 使用智能上传(自动选择直接上传或分片上传)
+                    SysOssVo ossVo = sysOssService.smartUpload(zipFile);
+                    log.info("ZIP文件已上传到OSS,ossId: {}, url: {}", ossVo.getOssId(), ossVo.getUrl());
+                    
+                    // 使用OSS版本完成任务
+                    gameBibTaskService.completeTaskWithOss(taskId, zipFilePath, ossVo.getOssId());
+                    
+                    // 删除本地ZIP文件以节省空间
+                    if (zipFile.delete()) {
+                        log.info("本地ZIP文件已删除: {}", zipFilePath);
+                    }
+                } else {
+                    log.error("ZIP文件不存在,无法上传到OSS: {}", zipFilePath);
+                    gameBibTaskService.completeTask(taskId, zipFilePath);
                 }
+            } catch (Exception e) {
+                log.error("上传ZIP文件到OSS失败,taskId: {}, 错误: {}", taskId, e.getMessage(), e);
+                // OSS上传失败,使用本地文件完成任务
+                gameBibTaskService.completeTask(taskId, zipFilePath);
             }
 
-            // 创建ZIP文件
-            String zipFilePath = resultDir + "参赛证_" + taskId + ".zip";
-            createZipFile(resultDir, zipFilePath);
-            log.info("ZIP文件创建完成: {}", zipFilePath);
-
             return zipFilePath;
 
         } catch (Exception e) {
@@ -1874,7 +1882,52 @@ public class GameEventServiceImpl implements IGameEventService {
     }
 
     /**
-     * 创建ZIP文件
+     * 从内存中创建ZIP文件(不保存单个图片文件)
+     */
+    private void createZipFileFromMemory(byte[] templateImage, List<GameAthleteVo> athleteVoList, 
+                                       GenerateBibBo bibParam, String zipFilePath, Long taskId) {
+        try (FileOutputStream fos = new FileOutputStream(zipFilePath);
+             ZipOutputStream zos = new ZipOutputStream(fos)) {
+
+            log.info("开始从内存创建ZIP文件 - ZIP文件: {}", zipFilePath);
+            
+            int totalCount = athleteVoList.size();
+            int completedCount = 0;
+
+            // 为每个运动员生成参赛证图片并直接添加到ZIP
+            for (GameAthleteVo athlete : athleteVoList) {
+                try {
+                    // 基于模版生成单个参赛证图片
+                    byte[] bibImage = generateSingleBibFromTemplate(templateImage, athlete, bibParam);
+
+                    // 直接添加到ZIP文件,不保存到本地
+                    String fileName = athlete.getAthleteCode() + "_" + athlete.getName() + ".png";
+                    ZipEntry zipEntry = new ZipEntry(fileName);
+                    zos.putNextEntry(zipEntry);
+                    zos.write(bibImage);
+                    zos.closeEntry();
+
+                    completedCount++;
+                    int progress = (completedCount * 100) / totalCount;
+                    gameBibTaskService.updateTaskProgress(taskId, progress, completedCount);
+                    
+                    log.debug("添加图片到ZIP: {}", fileName);
+
+                } catch (Exception e) {
+                    log.error("生成运动员参赛证失败: {}", athlete.getName(), e);
+                }
+            }
+
+            log.info("从内存创建ZIP文件成功: {}, 包含 {} 个文件", zipFilePath, completedCount);
+
+        } catch (Exception e) {
+            log.error("从内存创建ZIP文件失败", e);
+            throw new RuntimeException("创建ZIP文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 创建ZIP文件(从已存在的文件)
      */
     private void createZipFile(String sourceDir, String zipFilePath) {
         try (FileOutputStream fos = new FileOutputStream(zipFilePath);

+ 16 - 0
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/config/ScheduleConfig.java

@@ -0,0 +1,16 @@
+package org.dromara.system.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+/**
+ * 定时任务配置
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Configuration
+@EnableScheduling
+public class ScheduleConfig {
+    // 启用定时任务功能
+}

+ 152 - 0
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysMultipartUploadController.java

@@ -0,0 +1,152 @@
+package org.dromara.system.controller.system;
+
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.system.service.ISysOssService;
+import org.dromara.system.service.impl.UploadCleanupService;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * 分片上传控制器
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/system/oss/multipart")
+@CrossOrigin(origins = "*", maxAge = 3600)
+public class SysMultipartUploadController extends BaseController {
+
+    private final ISysOssService sysOssService;
+    private final UploadCleanupService uploadCleanupService;
+
+    /**
+     * 初始化分片上传
+     *
+     * @param fileName 文件名
+     * @param fileSize 文件大小
+     * @return 上传ID
+     */
+    @PostMapping("/init")
+    public R<String> initMultipartUpload(@RequestParam String fileName, @RequestParam long fileSize) {
+        try {
+            String uploadId = sysOssService.initMultipartUpload(fileName, fileSize);
+            return R.ok(uploadId);
+        } catch (Exception e) {
+            return R.fail("初始化分片上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 上传分片
+     *
+     * @param uploadId 上传ID
+     * @param partNumber 分片序号
+     * @param file 分片文件
+     * @return 分片ETag
+     */
+    @PostMapping("/uploadPart")
+    public R<String> uploadPart(@RequestParam String uploadId, 
+                               @RequestParam int partNumber, 
+                               @RequestParam("file") MultipartFile file) {
+        try {
+            byte[] partData = file.getBytes();
+            String eTag = sysOssService.uploadPart(uploadId, partNumber, partData);
+            return R.ok(eTag);
+        } catch (Exception e) {
+            return R.fail("上传分片失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 完成分片上传
+     *
+     * @param uploadId 上传ID
+     * @param partETags 分片ETag列表(JSON格式)
+     * @param fileName 文件名
+     * @param originalName 原始文件名
+     * @return 上传结果
+     */
+    @PostMapping("/complete")
+    public R<Object> completeMultipartUpload(@RequestParam String uploadId,
+                                          @RequestParam String partETags,
+                                          @RequestParam String fileName,
+                                          @RequestParam String originalName) {
+        try {
+            // 解析分片ETag列表
+            List<String> eTagList = java.util.Arrays.asList(partETags.split(","));
+            Object result = sysOssService.completeMultipartUpload(uploadId, eTagList, fileName, originalName);
+            return R.ok(result);
+        } catch (Exception e) {
+            return R.fail("完成分片上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 取消分片上传
+     *
+     * @param uploadId 上传ID
+     * @return 操作结果
+     */
+    @PostMapping("/abort")
+    public R<Boolean> abortMultipartUpload(@RequestParam String uploadId) {
+        try {
+            Boolean result = sysOssService.abortMultipartUpload(uploadId);
+            return R.ok(result);
+        } catch (Exception e) {
+            return R.fail("取消分片上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 检查已上传的分片
+     *
+     * @param uploadId 上传ID
+     * @return 已上传的分片序号列表
+     */
+    @GetMapping("/checkParts")
+    public R<List<Integer>> checkUploadedParts(@RequestParam String uploadId) {
+        try {
+            List<Integer> uploadedParts = sysOssService.checkUploadedParts(uploadId);
+            return R.ok(uploadedParts);
+        } catch (Exception e) {
+            return R.fail("检查已上传分片失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 手动清理上传数据
+     *
+     * @param uploadId 上传ID
+     * @return 操作结果
+     */
+    @PostMapping("/cleanup")
+    public R<Boolean> cleanupUpload(@RequestParam String uploadId) {
+        try {
+            uploadCleanupService.cleanupUpload(uploadId);
+            return R.ok(true);
+        } catch (Exception e) {
+            return R.fail("清理上传数据失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取活跃上传数量
+     *
+     * @return 活跃上传数量
+     */
+    @GetMapping("/activeCount")
+    public R<Integer> getActiveUploadCount() {
+        try {
+            int count = uploadCleanupService.getActiveUploadCount();
+            return R.ok(count);
+        } catch (Exception e) {
+            return R.fail("获取活跃上传数量失败: " + e.getMessage());
+        }
+    }
+}

+ 63 - 0
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysOssService.java

@@ -68,6 +68,15 @@ public interface ISysOssService {
      */
     void download(Long ossId, HttpServletResponse response) throws IOException;
 
+    /**
+     * 生成预签名下载URL
+     *
+     * @param ossId OSS对象ID
+     * @param expirationSeconds 过期时间(秒)
+     * @return 预签名URL
+     */
+    String generatePresignedUrl(Long ossId, int expirationSeconds);
+
     /**
      * 删除OSS对象存储
      *
@@ -77,4 +86,58 @@ public interface ISysOssService {
      */
     Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
 
+    /**
+     * 智能上传文件(根据文件大小自动选择直接上传或分片上传)
+     *
+     * @param file 要上传的文件对象
+     * @return 上传成功后的 SysOssVo 对象,包含文件信息
+     */
+    SysOssVo smartUpload(File file);
+
+    /**
+     * 初始化分片上传
+     *
+     * @param fileName 文件名
+     * @param fileSize 文件大小
+     * @return 上传ID
+     */
+    String initMultipartUpload(String fileName, long fileSize);
+
+    /**
+     * 上传分片
+     *
+     * @param uploadId 上传ID
+     * @param partNumber 分片序号
+     * @param partData 分片数据
+     * @return 分片ETag
+     */
+    String uploadPart(String uploadId, int partNumber, byte[] partData);
+
+    /**
+     * 完成分片上传
+     *
+     * @param uploadId 上传ID
+     * @param partETags 分片ETag列表
+     * @param fileName 文件名
+     * @param originalName 原始文件名
+     * @return 上传成功后的 SysOssVo 对象
+     */
+    SysOssVo completeMultipartUpload(String uploadId, List<String> partETags, String fileName, String originalName);
+
+    /**
+     * 取消分片上传
+     *
+     * @param uploadId 上传ID
+     * @return 是否成功
+     */
+    Boolean abortMultipartUpload(String uploadId);
+
+    /**
+     * 检查已上传的分片
+     *
+     * @param uploadId 上传ID
+     * @return 已上传的分片序号列表
+     */
+    List<Integer> checkUploadedParts(String uploadId);
+
 }

+ 292 - 6
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java

@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.dromara.common.core.constant.CacheNames;
 import org.dromara.common.core.domain.dto.OssDTO;
 import org.dromara.common.core.exception.ServiceException;
@@ -23,6 +24,7 @@ import org.dromara.common.oss.core.OssClient;
 import org.dromara.common.oss.entity.UploadResult;
 import org.dromara.common.oss.enums.AccessPolicyType;
 import org.dromara.common.oss.factory.OssFactory;
+import org.dromara.common.redis.utils.RedisUtils;
 import org.dromara.system.domain.SysOss;
 import org.dromara.system.domain.bo.SysOssBo;
 import org.dromara.system.domain.vo.SysOssVo;
@@ -37,22 +39,30 @@ import org.springframework.web.multipart.MultipartFile;
 import java.io.File;
 import java.io.IOException;
 import java.time.Duration;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 
 /**
  * 文件上传 服务层实现
  *
  * @author Lion Li
  */
+@Slf4j
 @RequiredArgsConstructor
 @Service
 public class SysOssServiceImpl implements ISysOssService, OssService {
 
     private final SysOssMapper baseMapper;
 
+    // 分片上传阈值:50MB
+    private static final long MULTIPART_UPLOAD_THRESHOLD = 50 * 1024 * 1024;
+
+    // 分片大小:8MB
+    private static final long PART_SIZE = 8 * 1024 * 1024;
+
+    // Redis键前缀
+    private static final String UPLOAD_PARTS_PREFIX = "oss:upload:parts:";
+    private static final String UPLOAD_INFO_PREFIX = "oss:upload:info:";
+
     /**
      * 查询OSS对象存储列表
      *
@@ -181,6 +191,29 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
         storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong);
     }
 
+    /**
+     * 生成预签名下载URL
+     *
+     * @param ossId OSS对象ID
+     * @param expirationSeconds 过期时间(秒)
+     * @return 预签名URL
+     */
+    @Override
+    public String generatePresignedUrl(Long ossId, int expirationSeconds) {
+        SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId);
+        if (ObjectUtil.isNull(sysOss)) {
+            throw new ServiceException("文件数据不存在!");
+        }
+        
+        try {
+            OssClient storage = OssFactory.instance(sysOss.getService());
+            return storage.generatePresignedUrl(sysOss.getFileName(), expirationSeconds);
+        } catch (Exception e) {
+            log.error("生成预签名URL失败,ossId: {}", ossId, e);
+            throw new ServiceException("生成下载链接失败: " + e.getMessage());
+        }
+    }
+
     /**
      * 上传 MultipartFile 到对象存储服务,并保存文件信息到数据库
      *
@@ -195,7 +228,8 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
         OssClient storage = OssFactory.instance();
         UploadResult uploadResult;
         try {
-            uploadResult = storage.uploadSuffix(file.getBytes(), suffix, file.getContentType());
+            // 使用原始文件名上传,而不是UUID
+            uploadResult = storage.uploadWithOriginalName(file.getBytes(), originalfileName, file.getContentType());
         } catch (IOException e) {
             throw new ServiceException(e.getMessage());
         }
@@ -214,7 +248,11 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
         String originalfileName = file.getName();
         String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
         OssClient storage = OssFactory.instance();
-        UploadResult uploadResult = storage.uploadSuffix(file, suffix);
+        
+        // 使用原始文件名上传,而不是UUID
+        String customPath = storage.getPathWithCustomName(originalfileName);
+        UploadResult uploadResult = storage.upload(file.toPath(), customPath, null, FileUtils.getMimeType(suffix));
+        
         // 保存文件信息
         return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult);
     }
@@ -266,4 +304,252 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
         }
         return oss;
     }
+
+    /**
+     * 智能上传文件(根据文件大小自动选择直接上传或分片上传)
+     *
+     * @param file 要上传的文件对象
+     * @return 上传成功后的 SysOssVo 对象,包含文件信息
+     */
+    @Override
+    public SysOssVo smartUpload(File file) {
+        if (file.length() > MULTIPART_UPLOAD_THRESHOLD) {
+            // 大文件使用分片上传
+            return uploadLargeFile(file);
+        } else {
+            // 小文件使用直接上传
+            return upload(file);
+        }
+    }
+
+    /**
+     * 上传大文件(分片上传)
+     */
+    private SysOssVo uploadLargeFile(File file) {
+        String originalfileName = file.getName();
+        String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
+
+        // 生成唯一的文件名,避免冲突
+        String fileName = generateUniqueFileName(suffix);
+
+        try {
+            // 初始化分片上传
+            String uploadId = initMultipartUpload(fileName, file.length());
+
+            // 计算分片数量
+            long fileSize = file.length();
+            int partCount = (int) Math.ceil((double) fileSize / PART_SIZE);
+
+            List<String> partETags = new ArrayList<>();
+
+            // 上传所有分片
+            try (java.io.FileInputStream fis = new java.io.FileInputStream(file)) {
+                for (int i = 1; i <= partCount; i++) {
+                    byte[] partData = new byte[(int) Math.min(PART_SIZE, fileSize - (i - 1) * PART_SIZE)];
+                    int bytesRead = fis.read(partData);
+
+                    if (bytesRead > 0) {
+                        String eTag = uploadPart(uploadId, i, partData);
+                        partETags.add(eTag);
+                    }
+                }
+            }
+
+            // 完成分片上传
+            return completeMultipartUpload(uploadId, partETags, fileName, originalfileName);
+
+        } catch (Exception e) {
+            throw new ServiceException("分片上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 初始化分片上传
+     *
+     * @param fileName 文件名
+     * @param fileSize 文件大小
+     * @return 上传ID
+     */
+    @Override
+    public String initMultipartUpload(String fileName, long fileSize) {
+        try {
+            OssClient storage = OssFactory.instance();
+
+            // 调用OSS的初始化分片上传API
+            String uploadId = storage.initMultipartUpload(fileName);
+
+            // 在Redis中存储上传信息
+            Map<String, Object> uploadInfo = new HashMap<>();
+            uploadInfo.put("fileName", fileName);
+            uploadInfo.put("fileSize", String.valueOf(fileSize));
+            uploadInfo.put("createTime", String.valueOf(System.currentTimeMillis()));
+            RedisUtils.setCacheObject(UPLOAD_INFO_PREFIX + uploadId, uploadInfo, Duration.ofHours(24));
+
+            return uploadId;
+        } catch (Exception e) {
+            throw new ServiceException("初始化分片上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 上传分片
+     *
+     * @param uploadId 上传ID
+     * @param partNumber 分片序号
+     * @param partData 分片数据
+     * @return 分片ETag
+     */
+    @Override
+    public String uploadPart(String uploadId, int partNumber, byte[] partData) {
+        try {
+            // 获取上传信息
+            Map<String, Object> uploadInfo = RedisUtils.getCacheObject(UPLOAD_INFO_PREFIX + uploadId);
+            if (uploadInfo == null) {
+                throw new ServiceException("上传会话已过期,请重新开始上传");
+            }
+
+            String fileName = (String) uploadInfo.get("fileName");
+            if (fileName == null) {
+                throw new ServiceException("上传信息不完整,请重新开始上传");
+            }
+
+            OssClient storage = OssFactory.instance();
+
+            // 调用OSS的分片上传API
+            String eTag = storage.uploadPart(fileName, uploadId, partNumber, partData);
+
+            // 在Redis中记录已上传的分片
+            String partsKey = UPLOAD_PARTS_PREFIX + uploadId;
+            RedisUtils.setCacheObject(partsKey + ":" + partNumber, eTag, Duration.ofHours(24));
+
+            return eTag;
+        } catch (ServiceException e) {
+            throw e; // 重新抛出业务异常
+        } catch (Exception e) {
+            log.error("上传分片失败,uploadId: {}, partNumber: {}, 错误: {}", uploadId, partNumber, e.getMessage(), e);
+            throw new ServiceException("上传分片失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 完成分片上传
+     *
+     * @param uploadId 上传ID
+     * @param partETags 分片ETag列表
+     * @param fileName 文件名
+     * @param originalName 原始文件名
+     * @return 上传成功后的 SysOssVo 对象
+     */
+    @Override
+    public SysOssVo completeMultipartUpload(String uploadId, List<String> partETags, String fileName, String originalName) {
+        try {
+            // 获取上传信息
+            Map<String, Object> uploadInfo = RedisUtils.getCacheObject(UPLOAD_INFO_PREFIX + uploadId);
+            if (uploadInfo == null) {
+                throw new ServiceException("上传会话已过期,请重新开始上传");
+            }
+
+            String storedFileName = (String) uploadInfo.get("fileName");
+            OssClient storage = OssFactory.instance();
+
+            // 调用OSS的完成分片上传API
+            UploadResult uploadResult = storage.completeMultipartUpload(storedFileName, uploadId, partETags);
+
+            // 保存文件信息到数据库
+            String suffix = StringUtils.substring(originalName, originalName.lastIndexOf("."), originalName.length());
+            SysOssVo result = buildResultEntity(originalName, suffix, storage.getConfigKey(), uploadResult);
+
+            // 清理Redis中的上传信息
+            RedisUtils.deleteObject(UPLOAD_INFO_PREFIX + uploadId);
+            String partsKey = UPLOAD_PARTS_PREFIX + uploadId;
+            // 清理所有分片记录
+            for (int i = 1; i <= partETags.size(); i++) {
+                RedisUtils.deleteObject(partsKey + ":" + i);
+            }
+
+            return result;
+
+        } catch (Exception e) {
+            // 如果完成失败,取消上传
+            abortMultipartUpload(uploadId);
+            throw new ServiceException("完成分片上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 取消分片上传
+     *
+     * @param uploadId 上传ID
+     * @return 是否成功
+     */
+    @Override
+    public Boolean abortMultipartUpload(String uploadId) {
+        try {
+            // 获取上传信息
+            Map<String, Object> uploadInfo = RedisUtils.getCacheObject(UPLOAD_INFO_PREFIX + uploadId);
+            if (uploadInfo != null) {
+                String fileName = (String) uploadInfo.get("fileName");
+                OssClient storage = OssFactory.instance();
+
+                // 调用OSS的取消分片上传API
+                storage.abortMultipartUpload(fileName, uploadId);
+            }
+
+            // 清理Redis中的上传信息
+            RedisUtils.deleteObject(UPLOAD_INFO_PREFIX + uploadId);
+            String partsKey = UPLOAD_PARTS_PREFIX + uploadId;
+
+            // 清理所有分片记录
+            Set<String> keys = (Set<String>) RedisUtils.keys(partsKey + ":*");
+            if (keys != null && !keys.isEmpty()) {
+                RedisUtils.deleteObject(keys);
+            }
+
+            return true;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /**
+     * 检查已上传的分片
+     *
+     * @param uploadId 上传ID
+     * @return 已上传的分片序号列表
+     */
+    @Override
+    public List<Integer> checkUploadedParts(String uploadId) {
+        List<Integer> uploadedParts = new ArrayList<>();
+        String partsKey = UPLOAD_PARTS_PREFIX + uploadId;
+
+        // 检查Redis中已上传的分片
+        Set<String> keys = (Set<String>) RedisUtils.keys(partsKey + ":*");
+        if (keys != null) {
+            for (String key : keys) {
+                String partNumberStr = key.substring(key.lastIndexOf(":") + 1);
+                try {
+                    uploadedParts.add(Integer.parseInt(partNumberStr));
+                } catch (NumberFormatException e) {
+                    // 忽略无效的分片序号
+                }
+            }
+        }
+
+        return uploadedParts;
+    }
+
+    /**
+     * 生成唯一的文件名
+     *
+     * @param suffix 文件后缀
+     * @return 唯一的文件名
+     */
+    private String generateUniqueFileName(String suffix) {
+        // 生成UUID
+        String uuid = UUID.randomUUID().toString();
+        // 生成日期路径
+        String datePath = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+        // 拼接路径
+        return datePath + "/" + uuid + suffix;
+    }
 }

+ 166 - 0
ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/UploadCleanupService.java

@@ -0,0 +1,166 @@
+package org.dromara.system.service.impl;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.redis.utils.RedisUtils;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 上传资源清理服务
+ * 定期清理过期的分片上传数据
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class UploadCleanupService {
+
+    private static final String UPLOAD_INFO_PREFIX = "oss:upload:info:";
+    private static final String UPLOAD_PARTS_PREFIX = "oss:upload:parts:";
+    
+    // 分片上传数据过期时间:24小时
+    private static final long UPLOAD_EXPIRE_HOURS = 24;
+
+    /**
+     * 定期清理过期的分片上传数据
+     * 每小时执行一次
+     */
+    @Scheduled(fixedRate = 3600000) // 1小时 = 3600000毫秒
+    public void cleanupExpiredUploads() {
+        try {
+            log.info("开始清理过期的分片上传数据");
+            
+            // 查找所有上传信息
+            Collection<String> uploadInfoKeys = RedisUtils.keys(UPLOAD_INFO_PREFIX + "*");
+            if (uploadInfoKeys == null || uploadInfoKeys.isEmpty()) {
+                log.info("没有找到需要清理的上传数据");
+                return;
+            }
+            
+            int cleanedCount = 0;
+            long currentTime = System.currentTimeMillis();
+            
+            for (String infoKey : uploadInfoKeys) {
+                try {
+                    // 获取上传信息
+                    Object uploadInfo = RedisUtils.getCacheObject(infoKey);
+                    if (uploadInfo == null) {
+                        // 如果上传信息不存在,清理相关数据
+                        String uploadId = infoKey.substring(UPLOAD_INFO_PREFIX.length());
+                        cleanupUploadData(uploadId);
+                        cleanedCount++;
+                        continue;
+                    }
+                    
+                    // 检查上传时间是否过期
+                    if (isUploadExpired(uploadInfo, currentTime)) {
+                        String uploadId = infoKey.substring(UPLOAD_INFO_PREFIX.length());
+                        cleanupUploadData(uploadId);
+                        cleanedCount++;
+                    }
+                } catch (Exception e) {
+                    log.error("清理上传数据失败,key: {},可能是数据损坏,尝试强制清理", infoKey, e);
+                    try {
+                        // 如果数据损坏,直接删除相关数据
+                        String uploadId = infoKey.substring(UPLOAD_INFO_PREFIX.length());
+                        cleanupUploadData(uploadId);
+                        cleanedCount++;
+                        log.info("已强制清理损坏的上传数据: {}", uploadId);
+                    } catch (Exception cleanupException) {
+                        log.error("强制清理失败,key: {}", infoKey, cleanupException);
+                        // 最后手段:直接删除Redis键
+                        try {
+                            RedisUtils.deleteObject(infoKey);
+                            log.info("已强制删除损坏的Redis键: {}", infoKey);
+                        } catch (Exception deleteException) {
+                            log.error("无法删除损坏的Redis键: {}", infoKey, deleteException);
+                        }
+                    }
+                }
+            }
+            
+            log.info("分片上传数据清理完成,清理了 {} 个过期上传", cleanedCount);
+            
+        } catch (Exception e) {
+            log.error("清理过期分片上传数据失败", e);
+        }
+    }
+
+    /**
+     * 检查上传是否过期
+     */
+    private boolean isUploadExpired(Object uploadInfo, long currentTime) {
+        try {
+            if (uploadInfo instanceof java.util.Map) {
+                @SuppressWarnings("unchecked")
+                java.util.Map<String, Object> infoMap = (java.util.Map<String, Object>) uploadInfo;
+                String createTimeStr = (String) infoMap.get("createTime");
+                if (createTimeStr != null) {
+                    long createTime = Long.parseLong(createTimeStr);
+                    long expireTime = createTime + TimeUnit.HOURS.toMillis(UPLOAD_EXPIRE_HOURS);
+                    return currentTime > expireTime;
+                }
+            }
+        } catch (Exception e) {
+            log.warn("检查上传过期时间失败", e);
+        }
+        return true; // 如果无法解析,认为已过期
+    }
+
+    /**
+     * 清理指定上传ID的所有数据
+     */
+    private void cleanupUploadData(String uploadId) {
+        try {
+            // 清理上传信息
+            RedisUtils.deleteObject(UPLOAD_INFO_PREFIX + uploadId);
+            
+            // 清理所有分片数据
+            String partsKey = UPLOAD_PARTS_PREFIX + uploadId;
+            Collection<String> partKeys = RedisUtils.keys(partsKey + ":*");
+            if (partKeys != null && !partKeys.isEmpty()) {
+                RedisUtils.deleteObject(partKeys);
+            }
+            
+            log.debug("已清理上传数据: {}", uploadId);
+        } catch (Exception e) {
+            log.error("清理上传数据失败,uploadId: {}", uploadId, e);
+        }
+    }
+
+    /**
+     * 手动清理指定上传ID的数据
+     */
+    public void cleanupUpload(String uploadId) {
+        if (uploadId == null || uploadId.trim().isEmpty()) {
+            return;
+        }
+        
+        try {
+            cleanupUploadData(uploadId);
+            log.info("手动清理上传数据成功: {}", uploadId);
+        } catch (Exception e) {
+            log.error("手动清理上传数据失败,uploadId: {}", uploadId, e);
+        }
+    }
+
+    /**
+     * 获取当前活跃的上传数量
+     */
+    public int getActiveUploadCount() {
+        try {
+            Collection<String> uploadInfoKeys = RedisUtils.keys(UPLOAD_INFO_PREFIX + "*");
+            return uploadInfoKeys != null ? uploadInfoKeys.size() : 0;
+        } catch (Exception e) {
+            log.error("获取活跃上传数量失败", e);
+            return 0;
+        }
+    }
+}