3 Комити bd1d9e1973 ... 2176feee30

Аутор SHA1 Порука Датум
  zhou 2176feee30 fix(oss):修正云服务商访问样式判断逻辑 пре 1 месец
  zhou 114803b379 feat(game-event): 实现参赛证任务的OSS文件管理与智能上传 пре 1 месец
  zhou 8d610a26ae feat(game-event): 实现参赛证生成与任务管理功能增强 пре 1 месец
26 измењених фајлова са 1640 додато и 172 уклоњено
  1. 4 5
      pom.xml
  2. 5 0
      ruoyi-admin/src/main/resources/application-dev.yml
  3. 4 0
      ruoyi-admin/src/main/resources/application-prod.yml
  4. 2 0
      ruoyi-admin/src/main/resources/application.yml
  5. 190 2
      ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java
  6. 3 1
      ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java
  7. 2 2
      ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml
  8. 4 4
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/config/FileUploadConfig.java
  9. 109 46
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/NumberController.java
  10. 5 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameBibTask.java
  11. 6 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameBibTaskBo.java
  12. 15 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GenerateBibBo.java
  13. 5 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameBibTaskVO.java
  14. 2 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameBibTaskMapper.java
  15. 2 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameEventGroupMapper.java
  16. 11 2
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameBibTaskService.java
  17. 8 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameEventProjectService.java
  18. 70 6
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameAthleteServiceImpl.java
  19. 254 9
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameBibTaskServiceImpl.java
  20. 26 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventProjectServiceImpl.java
  21. 224 89
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventServiceImpl.java
  22. 16 0
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/config/ScheduleConfig.java
  23. 152 0
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysMultipartUploadController.java
  24. 63 0
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysOssService.java
  25. 292 6
      ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java
  26. 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>
 

+ 5 - 0
ruoyi-admin/src/main/resources/application-dev.yml

@@ -270,3 +270,8 @@ justauth:
       client-id: 10**********6
       client-secret: 1f7d08**********5b7**********29e
       redirect-uri: ${justauth.address}/social-callback?source=gitea
+
+
+file:
+  upload:
+    path: D:\A_projects\gameEvent\game_event\files

+ 4 - 0
ruoyi-admin/src/main/resources/application-prod.yml

@@ -272,3 +272,7 @@ justauth:
       client-id: 10**********6
       client-secret: 1f7d08**********5b7**********29e
       redirect-uri: ${justauth.address}/social-callback?source=gitea
+
+file:
+  upload:
+    path: /www/wwwroot/game_event/files

+ 2 - 0
ruoyi-admin/src/main/resources/application.yml

@@ -114,6 +114,7 @@ security:
     - /*/api-docs/**
     - /warm-flow-ui/config
     - /system/**
+    - /system/number/public/downloadTask/**
 
 # 多租户配置
 tenant:
@@ -293,3 +294,4 @@ wechat:
     secret: f97e65576ceb1516e49074a87b608f7b
 #    appid: wx017241c84de43b7a
 #    secret: 91ee2725605ba0ae73829cf4538395ac
+

+ 190 - 2
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;
 
 /**
@@ -82,7 +85,9 @@ public class OssClient {
                 AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()));
 
             // MinIO 使用 HTTPS 限制使用域名访问,站点填域名。需要启用路径样式访问
-            boolean isStyle = !StringUtils.containsAny(properties.getEndpoint(), OssConstant.CLOUD_SERVICE);
+            // 阿里云OSS、腾讯云COS、七牛云等云服务商使用虚拟主机样式访问
+            boolean isStyle = !StringUtils.containsAny(properties.getEndpoint(), OssConstant.CLOUD_SERVICE) 
+                && !StringUtils.containsAny(properties.getEndpoint(), new String[]{"aliyuncs.com", "myqcloud.com", "qiniucs.com"});
 
             // 创建AWS基于 Netty 的 S3 客户端
             this.client = S3AsyncClient.builder()
@@ -313,6 +318,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 +464,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 +522,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/**");
     }
 
     /**

+ 2 - 2
ruoyi-extend/ruoyi-snailjob-server/src/main/resources/application-dev.yml

@@ -2,9 +2,9 @@ spring:
   datasource:
     type: com.zaxxer.hikari.HikariDataSource
     driver-class-name: com.mysql.cj.jdbc.Driver
-    url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
+    url: jdbc:mysql://localhost:3306/game-event?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
     username: root
-    password: root
+    password: 123456
     hikari:
       connection-timeout: 30000
       validation-timeout: 5000

+ 4 - 4
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/config/FileUploadConfig.java

@@ -15,7 +15,7 @@ import java.io.File;
 @Configuration
 public class FileUploadConfig {
 
-    @Value("${file.upload.path:}")
+    @Value("${file.upload.path}")
     private String uploadPath;
 
     private String bibImagePath;
@@ -27,15 +27,15 @@ public class FileUploadConfig {
         if (uploadPath == null || uploadPath.trim().isEmpty()) {
             uploadPath = System.getProperty("user.dir") + File.separator + "upload" + File.separator;
         }
-        
+
         // 确保路径以分隔符结尾
         if (!uploadPath.endsWith(File.separator)) {
             uploadPath += File.separator;
         }
-        
+
         bibImagePath = uploadPath + "bib" + File.separator + "images" + File.separator;
         bibResultPath = uploadPath + "bib" + File.separator + "results" + File.separator;
-        
+
         // 创建必要的目录
         createDirectoryIfNotExists(bibImagePath);
         createDirectoryIfNotExists(bibResultPath);

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

@@ -2,6 +2,7 @@ package org.dromara.system.controller;
 
 
 import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaIgnore;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
@@ -18,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;
@@ -39,6 +41,7 @@ public class NumberController {
 
     private final IGameEventService gameEventService;
     private final IGameBibTaskService gameBibTaskService;
+    private final ISysOssService sysOssService;
 
     // 预览框尺寸(固定)
     private static final int previewWidth = 600;
@@ -93,7 +96,7 @@ public class NumberController {
         Long eventId = Long.valueOf(cacheObject.toString());
 
         // 边界检查和修正
-        validateAndCorrectBibParams(bibParam);
+        // validateAndCorrectBibParams(bibParam);
 
         // 调试日志
         log.info("创建参赛证任务,taskName: {}, bibParam: {}", taskName, bibParam);
@@ -167,6 +170,41 @@ public class NumberController {
         gameBibTaskService.downloadTaskResult(taskId, response);
     }
 
+    /**
+     * 公开下载任务结果(无需认证,用于跨浏览器下载)
+     */
+    @SaIgnore
+    @GetMapping("/public/downloadTask/{taskId}")
+    public void downloadTaskResultPublic(@PathVariable Long taskId, HttpServletResponse response) {
+        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());
+        }
+    }
+
     /**
      * 验证和修正参赛证参数边界
      */
@@ -285,6 +323,40 @@ public class NumberController {
             }
         }
 
+        // 修正画布尺寸(范围100-4000)
+        if (bibParam.getCanvasWidth() != null) {
+            int canvasWidth = Math.max(100, Math.min(4000, bibParam.getCanvasWidth()));
+            if (Integer.compare(canvasWidth, bibParam.getCanvasWidth()) != 0) {
+                log.warn("画布宽度超出边界,从 {} 修正为 {}", bibParam.getCanvasWidth(), canvasWidth);
+                bibParam.setCanvasWidth(canvasWidth);
+            }
+        }
+
+        if (bibParam.getCanvasHeight() != null) {
+            int canvasHeight = Math.max(100, Math.min(4000, bibParam.getCanvasHeight()));
+            if (Integer.compare(canvasHeight, bibParam.getCanvasHeight()) != 0) {
+                log.warn("画布高度超出边界,从 {} 修正为 {}", bibParam.getCanvasHeight(), canvasHeight);
+                bibParam.setCanvasHeight(canvasHeight);
+            }
+        }
+
+        // 修正Logo尺寸(范围10-500)
+        if (bibParam.getLogoWidth() != null) {
+            int logoWidth = Math.max(10, Math.min(500, bibParam.getLogoWidth()));
+            if (Integer.compare(logoWidth, bibParam.getLogoWidth()) != 0) {
+                log.warn("Logo宽度超出边界,从 {} 修正为 {}", bibParam.getLogoWidth(), logoWidth);
+                bibParam.setLogoWidth(logoWidth);
+            }
+        }
+
+        if (bibParam.getLogoHeight() != null) {
+            int logoHeight = Math.max(10, Math.min(500, bibParam.getLogoHeight()));
+            if (Integer.compare(logoHeight, bibParam.getLogoHeight()) != 0) {
+                log.warn("Logo高度超出边界,从 {} 修正为 {}", bibParam.getLogoHeight(), logoHeight);
+                bibParam.setLogoHeight(logoHeight);
+            }
+        }
+
         log.info("参赛证参数边界检查完成");
     }
 
@@ -294,14 +366,25 @@ public class NumberController {
     private byte[] generateCanvasTemplate(String bgImagePath, String logoImagePath, GenerateBibBo bibParam) {
         try {
             // 读取背景图片
-            BufferedImage bgImage = ImageIO.read(new File(bgImagePath));
-            log.info("背景图片尺寸: {}x{}", bgImage.getWidth(), bgImage.getHeight());
-
+            BufferedImage originalBgImage = ImageIO.read(new File(bgImagePath));
+            log.info("原始背景图片尺寸: {}x{}", originalBgImage.getWidth(), originalBgImage.getHeight());
+
+            // 获取目标画布尺寸
+            int targetWidth = bibParam.getCanvasWidth() != null ? bibParam.getCanvasWidth() : originalBgImage.getWidth();
+            int targetHeight = bibParam.getCanvasHeight() != null ? bibParam.getCanvasHeight() : originalBgImage.getHeight();
+            
+            // 创建目标尺寸的画布
+            BufferedImage bgImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
             Graphics2D g2d = bgImage.createGraphics();
 
             // 设置抗锯齿
             g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
             g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+            g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+
+            // 将原始背景图片拉伸/缩放到目标画布尺寸
+            g2d.drawImage(originalBgImage, 0, 0, targetWidth, targetHeight, null);
+            log.info("背景图片已调整到目标尺寸: {}x{}", targetWidth, targetHeight);
 
             // 绘制Logo(如果存在)
             if (logoImagePath != null && new File(logoImagePath).exists()) {
@@ -341,11 +424,7 @@ public class NumberController {
             BufferedImage logoImage = ImageIO.read(new File(logoImagePath));
             log.info("Logo图片尺寸: {}x{}", logoImage.getWidth(), logoImage.getHeight());
 
-            // 预览框尺寸(固定)
-            final int previewWidth = 600;
-            final int previewHeight = 400;
-
-            // 计算Logo位置(从百分比坐标转换为实际图片坐标)
+            // 计算Logo位置(直接使用前端传入的百分比坐标)
             Double logoX = bibParam.getLogoX();
             Double logoY = bibParam.getLogoY();
 
@@ -363,25 +442,20 @@ public class NumberController {
                 log.info("使用默认Logo位置: ({}, {})", x, y);
             }
 
-            // 应用缩放(考虑前端预览的相对比例和用户设置的缩放)
-            Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
-
-            // 前端预览时Logo被限制为最大80px,相对于预览框600x400的比例
-            final int previewMaxSize = 80;
-
-            // 计算预览时的相对比例(相对于预览框的宽度)
-            double previewRelativeSize = (double) previewMaxSize / previewWidth;
-
-            // 计算实际图片中Logo应该占的相对比例
-            double actualRelativeSize = previewRelativeSize * canvasWidth;
-
-            // 计算Logo的实际缩放比例
-            double previewScale = Math.min(1.0, actualRelativeSize / Math.max(logoImage.getWidth(), logoImage.getHeight()));
-
-            // 计算最终缩放:预览缩放 × 用户缩放
-            double finalScale = previewScale * logoScale;
-            int scaledWidth = (int) (logoImage.getWidth() * finalScale);
-            int scaledHeight = (int) (logoImage.getHeight() * finalScale);
+            // 使用传入的Logo尺寸(前端已计算好最终尺寸)
+            int scaledWidth, scaledHeight;
+            if (bibParam.getLogoWidth() != null && bibParam.getLogoHeight() != null) {
+                // 使用前端传入的Logo尺寸(已包含所有缩放计算)
+                scaledWidth = bibParam.getLogoWidth();
+                scaledHeight = bibParam.getLogoHeight();
+                log.info("使用前端计算的Logo尺寸: {}x{}", scaledWidth, scaledHeight);
+            } else {
+                // 回退到原始缩放逻辑(兼容旧版本)
+                Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
+                scaledWidth = (int) (logoImage.getWidth() * logoScale);
+                scaledHeight = (int) (logoImage.getHeight() * logoScale);
+                log.warn("使用回退缩放逻辑,Logo尺寸: {}x{}", scaledWidth, scaledHeight);
+            }
 
             // 边界检查,确保Logo不会超出图片范围
             if (x < 0) x = 0;
@@ -391,8 +465,8 @@ public class NumberController {
 
             // 绘制Logo
             g2d.drawImage(logoImage, x, y, scaledWidth, scaledHeight, null);
-            log.info("Logo绘制完成 - 位置: ({}, {}), 尺寸: {}x{}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 用户缩放: {}, 最终缩放: {}, 画布尺寸: {}x{}",
-                x, y, scaledWidth, scaledHeight, previewRelativeSize, actualRelativeSize, previewScale, logoScale, finalScale, canvasWidth, canvasHeight);
+            log.info("Logo绘制完成 - 位置: ({}, {}), 尺寸: {}x{}, 画布尺寸: {}x{}",
+                x, y, scaledWidth, scaledHeight, canvasWidth, canvasHeight);
 
         } catch (Exception e) {
             log.error("绘制Logo失败", e);
@@ -408,21 +482,10 @@ public class NumberController {
             int x = (int) (canvasWidth * bibParam.getEventX() / 100);
             int y = (int) (canvasHeight * bibParam.getEventY() / 100);
 
-            // 设置字体(考虑前端预览的相对比例和用户设置的缩放)
-            int baseFontSize = bibParam.getFontSize();
-            // 前端预览时赛事名称字体被限制为最大28px,相对于预览框600x400的比例
-            final int previewMaxFontSize = 28;
-
-            // 计算预览时的相对比例(相对于预览框的宽度)
-            double previewRelativeFontSize = (double) previewMaxFontSize / previewWidth;
-
-            // 计算实际图片中字体应该占的相对比例
-            double actualRelativeFontSize = previewRelativeFontSize * canvasWidth;
-
-            // 计算字体的实际缩放比例
-            double fontScale = Math.min(1.0, actualRelativeFontSize / baseFontSize);
+            // 设置字体(直接使用用户设置的字体大小和缩放)
+            int baseFontSize = bibParam.getFontSize() != null ? bibParam.getFontSize() : 28;
             Double eventScale = bibParam.getEventScale() != null ? bibParam.getEventScale() : 1.0;
-            int fontSize = (int) (baseFontSize * fontScale * eventScale);
+            int fontSize = (int) (baseFontSize * eventScale);
 
             Font font = new Font(bibParam.getFontName(), Font.BOLD, fontSize);
             g2d.setFont(font);
@@ -438,8 +501,8 @@ public class NumberController {
 
             // 居中绘制
             g2d.drawString(eventName, x - textWidth / 2, y + textHeight / 4);
-            log.info("赛事名称绘制完成 - 位置: ({}, {}), 基础字体: {}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 用户缩放: {}, 最终字体: {}",
-                x, y, baseFontSize, previewRelativeFontSize, actualRelativeFontSize, fontScale, eventScale, fontSize);
+            log.info("赛事名称绘制完成 - 位置: ({}, {}), 基础字体: {}, 用户缩放: {}, 最终字体: {}, 画布尺寸: {}x{}",
+                x, y, baseFontSize, eventScale, fontSize, canvasWidth, canvasHeight);
 
         } catch (Exception e) {
             log.error("绘制赛事名称失败", e);

+ 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;
+
     /**
      * 错误信息
      */

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

@@ -63,6 +63,21 @@ public class GenerateBibBo implements Serializable {
     @JsonProperty("eventName")
     private String eventName;
 
+    /**
+     * 画布尺寸
+     */
+    @JsonProperty("width")
+    private Integer canvasWidth;
+    @JsonProperty("height")
+    private Integer canvasHeight;
+
+    /**
+     * Logo尺寸
+     */
+    @JsonProperty("logoWidth")
+    private Integer logoWidth;
+    @JsonProperty("logoHeight")
+    private Integer logoHeight;
 
     @Serial
     private static final long serialVersionUID = 1L;

+ 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> {
 
 }

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

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

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

@@ -1,10 +1,10 @@
 package org.dromara.system.service;
 
+import jakarta.servlet.http.HttpServletResponse;
 import org.dromara.common.mybatis.core.page.PageQuery;
 import org.dromara.common.mybatis.core.page.TableDataInfo;
 import org.dromara.system.domain.bo.GameBibTaskBo;
 import org.dromara.system.domain.bo.GenerateBibBo;
-import org.dromara.system.domain.query.GameBibTaskQuery;
 import org.dromara.system.domain.vo.GameBibTaskVO;
 import org.springframework.web.multipart.MultipartFile;
 
@@ -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);
+
     /**
      * 暂停任务
      *
@@ -148,5 +157,5 @@ public interface IGameBibTaskService {
      * @param taskId   任务ID
      * @param response 响应对象
      */
-    void downloadTaskResult(Long taskId, jakarta.servlet.http.HttpServletResponse response);
+    void downloadTaskResult(Long taskId, HttpServletResponse response);
 }

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

@@ -111,4 +111,12 @@ public interface IGameEventProjectService {
      * @return
      */
     Map<String, Long> mapProjectAndProjectId(Long eventId);
+
+    /**
+     * 根据赛事id和项目id查询项目名称
+     * @param eventId
+     * @param projectIds
+     * @return
+     */
+    Map<Long, String> queryNameByEventIdAndProjectIds(Long eventId, List<Long> projectIds);
 }

+ 70 - 6
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameAthleteServiceImpl.java

@@ -152,8 +152,26 @@ public class GameAthleteServiceImpl implements IGameAthleteService {
         Optional.ofNullable(vo.getProjectValue())
             .filter(StringUtils::isNotBlank)
             .ifPresent(projectValue -> {
-                List<Long> projects = JSONUtil.toList(projectValue, Long.class);
-                vo.setProjectList(projects);
+                log.debug("queryById - 解析运动员 {} 的项目数据 - projectValue: {}", vo.getName(), projectValue);
+                try {
+                    List<Long> projects = JSONUtil.toList(projectValue, Long.class);
+                    vo.setProjectList(projects);
+                    log.debug("queryById - 解析成功 - 运动员: {}, 项目列表: {}", vo.getName(), projects);
+                } catch (Exception e) {
+                    log.error("queryById - 解析项目数据失败 - 运动员: {}, projectValue: {}, 错误: {}", vo.getName(), projectValue, e.getMessage());
+                    vo.setProjectList(new ArrayList<>());
+                }
+            });
+        Optional.ofNullable(vo.getTeamId())
+            .filter(ObjectUtil::isNotEmpty)
+            .ifPresent(teamId -> {
+                GameTeamVo gameTeamVo = gameTeamService.queryById(vo.getTeamId());
+                if (gameTeamVo != null) {
+                    vo.setTeamName(gameTeamVo.getTeamName());
+                } else {
+                    log.warn("队伍ID {} 对应的队伍信息不存在", teamId);
+                    vo.setTeamName("未知队伍");
+                }
             });
         return vo;
     }
@@ -197,9 +215,26 @@ public class GameAthleteServiceImpl implements IGameAthleteService {
                     });
                 Optional.ofNullable(vo.getEventId())
                     .filter(ObjectUtil::isNotEmpty)
-                    .ifPresent(projectValue -> {
+                    .ifPresent(eventId -> {
                         GameEventVo gameEventVo = gameEventService.queryById(vo.getEventId());
-                        vo.setEventName(gameEventVo.getEventName());
+                        if (gameEventVo != null) {
+                            vo.setEventName(gameEventVo.getEventName());
+                        } else {
+                            log.warn("赛事ID {} 对应的赛事信息不存在", eventId);
+                            vo.setEventName("未知赛事");
+                        }
+                    });
+
+                Optional.ofNullable(vo.getTeamId())
+                    .filter(ObjectUtil::isNotEmpty)
+                    .ifPresent(teamId -> {
+                        GameTeamVo gameTeamVo = gameTeamService.queryById(vo.getTeamId());
+                        if (gameTeamVo != null) {
+                            vo.setTeamName(gameTeamVo.getTeamName());
+                        } else {
+                            log.warn("队伍ID {} 对应的队伍信息不存在", teamId);
+                            vo.setTeamName("未知队伍");
+                        }
                     });
                 return vo;
             })
@@ -229,8 +264,37 @@ public class GameAthleteServiceImpl implements IGameAthleteService {
             Optional.ofNullable(vo.getProjectValue())
                 .filter(StringUtils::isNotBlank)
                 .ifPresent(projectValue -> {
-                    List<Long> projects = JSONUtil.toList(projectValue, Long.class);
-                    vo.setProjectList(projects);
+                    log.debug("解析运动员 {} 的项目数据 - projectValue: {}", vo.getName(), projectValue);
+                    try {
+                        List<Long> projects = JSONUtil.toList(projectValue, Long.class);
+                        vo.setProjectList(projects);
+                        log.debug("解析成功 - 运动员: {}, 项目列表: {}", vo.getName(), projects);
+                    } catch (Exception e) {
+                        log.error("解析项目数据失败 - 运动员: {}, projectValue: {}, 错误: {}", vo.getName(), projectValue, e.getMessage());
+                        vo.setProjectList(new ArrayList<>());
+                    }
+                });
+            Optional.ofNullable(vo.getEventId())
+                .filter(ObjectUtil::isNotEmpty)
+                .ifPresent(eventId -> {
+                    GameEventVo gameEventVo = gameEventService.queryById(vo.getEventId());
+                    if (gameEventVo != null) {
+                        vo.setEventName(gameEventVo.getEventName());
+                    } else {
+                        log.warn("赛事ID {} 对应的赛事信息不存在", eventId);
+                        vo.setEventName("未知赛事");
+                    }
+                });
+            Optional.ofNullable(vo.getTeamId())
+                .filter(ObjectUtil::isNotEmpty)
+                .ifPresent(teamId -> {
+                    GameTeamVo gameTeamVo = gameTeamService.queryById(vo.getTeamId());
+                    if (gameTeamVo != null) {
+                        vo.setTeamName(gameTeamVo.getTeamName());
+                    } else {
+                        log.warn("队伍ID {} 对应的队伍信息不存在", teamId);
+                        vo.setTeamName("未知队伍");
+                    }
                 });
         });
 

+ 254 - 9
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();
 
@@ -180,10 +181,44 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
      * @return 是否删除成功
      */
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
         if (isValid) {
             // TODO 做一些业务上的校验,判断是否需要校验
         }
+        
+        // 批量删除前,先删除每个任务的相关文件
+        for (Long taskId : ids) {
+            try {
+                // 删除相关文件
+                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) {
+                log.error("删除任务文件失败,taskId: {}, 错误: {}", taskId, e.getMessage(), e);
+                // 继续删除其他任务,不中断整个批量删除操作
+            }
+        }
+        
+        // 删除数据库记录
         return baseMapper.deleteByIds(ids) > 0;
     }
 
@@ -335,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);
+    }
+
     /**
      * 暂停任务
      *
@@ -356,9 +410,24 @@ 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);
         }
 
         // 删除任务记录
@@ -374,21 +443,42 @@ 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("结果文件不存在");
         }
 
         try {
             response.setContentType("application/zip");
-            response.setHeader("Content-Disposition", "attachment; filename=" + file.getName());
+            // 处理中文文件名乱码问题
+            String fileName = file.getName();
+            String encodedFileName = java.net.URLEncoder.encode(fileName, "UTF-8");
+            response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedFileName);
 
             try (FileInputStream fis = new FileInputStream(file);
                  OutputStream os = response.getOutputStream()) {
@@ -413,8 +503,163 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
         if (StringUtils.isNotBlank(filePath)) {
             File file = new File(filePath);
             if (file.exists()) {
-                file.delete();
+                boolean deleted = file.delete();
+                if (deleted) {
+                    log.info("文件删除成功: {}", filePath);
+                } else {
+                    log.warn("文件删除失败: {}", filePath);
+                }
+            }
+        }
+    }
+
+    /**
+     * 删除任务文件夹
+     *
+     * @param taskId 任务ID
+     */
+    private void deleteTaskFolder(Long taskId) {
+        try {
+            // 构建任务文件夹路径,确保路径格式正确
+            String basePath = fileUploadConfig.getBibResultPath();
+            // 确保基础路径以斜杠结尾
+            if (!basePath.endsWith(File.separator)) {
+                basePath += File.separator;
             }
+            String taskFolderPath = basePath + taskId + File.separator;
+            File taskFolder = new File(taskFolderPath);
+            
+            log.debug("准备删除任务文件夹: {}", taskFolderPath);
+            
+            // 安全检查:确保路径在允许的目录内
+            if (!isValidTaskFolderPath(taskFolderPath, taskId)) {
+                log.error("任务文件夹路径不安全,拒绝删除: {}", taskFolderPath);
+                return;
+            }
+            
+            if (taskFolder.exists() && taskFolder.isDirectory()) {
+                // 删除文件夹中的所有文件(主要是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 {
+                    log.warn("任务文件夹删除失败: {}", taskFolderPath);
+                }
+            } else {
+                log.info("任务文件夹不存在,无需删除: {}", taskFolderPath);
+            }
+        } catch (Exception e) {
+            log.error("删除任务文件夹失败,taskId: {}, 错误: {}", taskId, e.getMessage(), e);
         }
     }
+
+    /**
+     * 验证任务文件夹路径是否安全
+     *
+     * @param folderPath 文件夹路径
+     * @param taskId 任务ID
+     * @return 是否安全
+     */
+    private boolean isValidTaskFolderPath(String folderPath, Long taskId) {
+        try {
+            // 获取基础路径
+            String basePath = fileUploadConfig.getBibResultPath();
+            File baseDir = new File(basePath);
+            File targetDir = new File(folderPath);
+            
+            // 检查目标路径是否在基础路径内
+            String baseCanonicalPath = baseDir.getCanonicalPath();
+            String targetCanonicalPath = targetDir.getCanonicalPath();
+            
+            log.info("路径验证 - 基础路径: {}, 目标路径: {}", baseCanonicalPath, targetCanonicalPath);
+            
+            // 确保目标路径以基础路径开头
+            if (!targetCanonicalPath.startsWith(baseCanonicalPath)) {
+                log.error("目标路径不在允许的基础路径内: {} -> {}", baseCanonicalPath, targetCanonicalPath);
+                return false;
+            }
+            
+            // 计算相对路径
+            String relativePath = targetCanonicalPath.substring(baseCanonicalPath.length());
+            log.info("相对路径: {}", relativePath);
+            
+            // 标准化相对路径(去掉开头的斜杠)
+            if (relativePath.startsWith(File.separator)) {
+                relativePath = relativePath.substring(1);
+            }
+            
+            // 检查相对路径是否只包含任务ID(可能带斜杠)
+            String expectedPath1 = taskId.toString();                    // 只有任务ID
+            String expectedPath2 = taskId.toString() + File.separator;   // 任务ID + 斜杠
+            
+            boolean isValidPath = relativePath.equals(expectedPath1) || relativePath.equals(expectedPath2);
+            
+            if (!isValidPath) {
+                log.error("任务文件夹路径格式不正确: {}, 期望: {} 或 {}", relativePath, expectedPath1, expectedPath2);
+                return false;
+            }
+            
+            // 额外检查:确保路径中只包含任务ID,没有其他路径遍历字符
+            if (relativePath.contains("..") || relativePath.contains("~") || relativePath.contains("/")) {
+                log.error("任务文件夹路径包含不安全的字符: {}", relativePath);
+                return false;
+            }
+            
+            log.info("任务文件夹路径验证通过: {}", targetCanonicalPath);
+            return true;
+        } catch (Exception e) {
+            log.error("验证任务文件夹路径失败: {}", e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * 递归删除目录及其内容
+     *
+     * @param directory 要删除的目录
+     * @return 是否删除成功
+     */
+    private boolean deleteDirectory(File directory) {
+        if (directory == null || !directory.exists()) {
+            return true;
+        }
+
+        if (directory.isDirectory()) {
+            File[] files = directory.listFiles();
+            if (files != null) {
+                for (File file : files) {
+                    if (file.isDirectory()) {
+                        // 递归删除子目录
+                        if (!deleteDirectory(file)) {
+                            return false;
+                        }
+                    } else {
+                        // 删除文件
+                        if (!file.delete()) {
+                            log.warn("文件删除失败: {}", file.getAbsolutePath());
+                            return false;
+                        }
+                    }
+                }
+            }
+        }
+
+        // 删除空目录
+        return directory.delete();
+    }
 }

+ 26 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventProjectServiceImpl.java

@@ -638,4 +638,30 @@ public class GameEventProjectServiceImpl implements IGameEventProjectService {
             Map.of() :
             projects.stream().collect(Collectors.toMap(GameEventProject::getProjectName, GameEventProject::getProjectId));
     }
+
+    @Override
+    public Map<Long, String> queryNameByEventIdAndProjectIds(Long eventId, List<Long> projectIds) {
+        if (projectIds == null || projectIds.isEmpty()) {
+            // 如果项目ID列表为空,返回该赛事下的所有项目
+            List<GameEventProject> projectVoList = baseMapper.selectList(
+                Wrappers.lambdaQuery(GameEventProject.class)
+                    .eq(GameEventProject::getEventId, eventId)
+                    .select(GameEventProject::getProjectId, GameEventProject::getProjectName)
+            );
+            return projectVoList.isEmpty() ?
+                Map.of() :
+                projectVoList.stream().collect(Collectors.toMap(GameEventProject::getProjectId, GameEventProject::getProjectName));
+        } else {
+            // 如果项目ID列表不为空,根据指定的项目ID查询
+            List<GameEventProject> projectVoList = baseMapper.selectList(
+                Wrappers.lambdaQuery(GameEventProject.class)
+                    .eq(GameEventProject::getEventId, eventId)
+                    .in(GameEventProject::getProjectId, projectIds)
+                    .select(GameEventProject::getProjectId, GameEventProject::getProjectName)
+            );
+            return projectVoList.isEmpty() ?
+                Map.of() :
+                projectVoList.stream().collect(Collectors.toMap(GameEventProject::getProjectId, GameEventProject::getProjectName));
+        }
+    }
 }

+ 224 - 89
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);
     // 常见图片类型的文件头(前几个字节)
@@ -719,15 +722,15 @@ public class GameEventServiceImpl implements IGameEventService {
             BaseFont baseFont = getChineseFont(fontName);
 
             // 3. 读取背景图和 logo
-            Image bgImage = Image.getInstance(backgroundImageBytes);
-            float pageWidth = bgImage.getWidth();
-            float pageHeight = bgImage.getHeight();
-
-            // 3.1 验证背景图片比例是否为3:2横屏
-            float ratio = pageWidth / pageHeight;
-            if (Math.abs(ratio - 1.5f) > 0.1f) { // 允许0.1的误差
-                throw new IllegalArgumentException("背景图片比例不是3:2横屏比例,当前比例: " + String.format("%.2f", ratio));
-            }
+            Image originalBgImage = Image.getInstance(backgroundImageBytes);
+            float originalWidth = originalBgImage.getWidth();
+            float originalHeight = originalBgImage.getHeight();
+
+            // 3.1 获取目标画布尺寸
+            float pageWidth = bibParam.getCanvasWidth() != null ? bibParam.getCanvasWidth().floatValue() : originalWidth;
+            float pageHeight = bibParam.getCanvasHeight() != null ? bibParam.getCanvasHeight().floatValue() : originalHeight;
+
+            log.info("原始背景图片尺寸: {}x{}, 目标画布尺寸: {}x{}", originalWidth, originalHeight, pageWidth, pageHeight);
 
             Integer finalFontSize = fontSize;
             // 4. 并行生成所有 PDF
@@ -741,8 +744,9 @@ public class GameEventServiceImpl implements IGameEventService {
                         document.open();
                         PdfContentByte cb = writer.getDirectContent();
 
-                        // 添加背景图
+                        // 添加背景图(缩放到目标尺寸)
                         Image bg = Image.getInstance(backgroundImageBytes);
+                        bg.scaleToFit(pageWidth, pageHeight);
                         bg.setAbsolutePosition(0, 0);
                         document.add(bg);
 
@@ -750,9 +754,21 @@ public class GameEventServiceImpl implements IGameEventService {
                         if (finalLogoImageBytes != null && logoX != null && logoY != null) {
                             try {
                                 Image img = Image.getInstance(finalLogoImageBytes);
-                                // 应用缩放参数
-                                Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
-                                img.scaleToFit(80 * logoScale.floatValue(), 80 * logoScale.floatValue());
+                                // 使用传入的Logo尺寸(前端已计算好最终尺寸)
+                                float scaledWidth, scaledHeight;
+                                if (bibParam.getLogoWidth() != null && bibParam.getLogoHeight() != null) {
+                                    // 使用前端传入的Logo尺寸(已包含所有缩放计算)
+                                    scaledWidth = bibParam.getLogoWidth().floatValue();
+                                    scaledHeight = bibParam.getLogoHeight().floatValue();
+                                    log.debug("使用前端计算的Logo尺寸: {}x{}", scaledWidth, scaledHeight);
+                                } else {
+                                    // 回退到原始缩放逻辑(兼容旧版本)
+                                    Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
+                                    scaledWidth = img.getWidth() * logoScale.floatValue();
+                                    scaledHeight = img.getHeight() * logoScale.floatValue();
+                                    log.warn("使用回退缩放逻辑,Logo尺寸: {}x{}", scaledWidth, scaledHeight);
+                                }
+                                img.scaleToFit(scaledWidth, scaledHeight);
                                 // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
                                 float logoPositionX = (float) (logoX * pageWidth / 100.0);
                                 float logoPositionY = (float) (pageHeight - (logoY * pageHeight / 100.0));
@@ -820,7 +836,7 @@ public class GameEventServiceImpl implements IGameEventService {
                             try {
                                 String qrDataStr = getQrDataStr(eventName, groupName, teamNameMap, projectMap, athlete);
                                 Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
-                                int baseQrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15));
+                                int baseQrSize = 100; // 固定基础尺寸
                                 int qrSize = (int) (baseQrSize * barcodeScale.floatValue());
 
                                 // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
@@ -862,7 +878,7 @@ public class GameEventServiceImpl implements IGameEventService {
                             try {
                                 String qrDataStr = getQrDataStr(eventName, groupName, teamNameMap, projectMap, athlete);
                                 Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
-                                int baseQrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15));
+                                int baseQrSize = 100; // 固定基础尺寸
                                 int qrSize = (int) (baseQrSize * barcodeScale.floatValue());
 
                                 // 使用默认位置(右下角)
@@ -1362,9 +1378,15 @@ public class GameEventServiceImpl implements IGameEventService {
             byte[] logoImageBytes = logoImagePath != null ? Files.readAllBytes(Paths.get(logoImagePath)) : null;
 
             // 读取背景图
-            Image bgImageObj = Image.getInstance(backgroundImageBytes);
-            float pageWidth = bgImageObj.getWidth();
-            float pageHeight = bgImageObj.getHeight();
+            Image originalBgImageObj = Image.getInstance(backgroundImageBytes);
+            float originalWidth = originalBgImageObj.getWidth();
+            float originalHeight = originalBgImageObj.getHeight();
+
+            // 获取目标画布尺寸
+            float pageWidth = bibParam.getCanvasWidth() != null ? bibParam.getCanvasWidth().floatValue() : originalWidth;
+            float pageHeight = bibParam.getCanvasHeight() != null ? bibParam.getCanvasHeight().floatValue() : originalHeight;
+
+            log.info("异步生成 - 原始背景图片尺寸: {}x{}, 目标画布尺寸: {}x{}", originalWidth, originalHeight, pageWidth, pageHeight);
 
             // 设置字体和颜色,提供默认值
             String fontName = bibParam.getFontName() != null ? bibParam.getFontName() : "yahei";
@@ -1382,8 +1404,9 @@ public class GameEventServiceImpl implements IGameEventService {
                         document.open();
                         PdfContentByte cb = writer.getDirectContent();
 
-                        // 添加背景图
+                        // 添加背景图(缩放到目标尺寸)
                         Image bg = Image.getInstance(backgroundImageBytes);
+                        bg.scaleToFit(pageWidth, pageHeight);
                         bg.setAbsolutePosition(0, 0);
                         document.add(bg);
 
@@ -1391,9 +1414,21 @@ public class GameEventServiceImpl implements IGameEventService {
                         if (logoImageBytes != null && bibParam.getLogoX() != null && bibParam.getLogoY() != null) {
                             try {
                                 Image img = Image.getInstance(logoImageBytes);
-                                // 应用缩放参数
-                                Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
-                                img.scaleToFit(80 * logoScale.floatValue(), 80 * logoScale.floatValue());
+                                // 使用传入的Logo尺寸(前端已计算好最终尺寸)
+                                float scaledWidth, scaledHeight;
+                                if (bibParam.getLogoWidth() != null && bibParam.getLogoHeight() != null) {
+                                    // 使用前端传入的Logo尺寸(已包含所有缩放计算)
+                                    scaledWidth = bibParam.getLogoWidth().floatValue();
+                                    scaledHeight = bibParam.getLogoHeight().floatValue();
+                                    log.debug("异步生成 - 使用前端计算的Logo尺寸: {}x{}", scaledWidth, scaledHeight);
+                                } else {
+                                    // 回退到原始缩放逻辑(兼容旧版本)
+                                    Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
+                                    scaledWidth = img.getWidth() * logoScale.floatValue();
+                                    scaledHeight = img.getHeight() * logoScale.floatValue();
+                                    log.warn("异步生成 - 使用回退缩放逻辑,Logo尺寸: {}x{}", scaledWidth, scaledHeight);
+                                }
+                                img.scaleToFit(scaledWidth, scaledHeight);
                                 // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
                                 float logoPositionX = (float) (bibParam.getLogoX() * pageWidth / 100.0);
                                 float logoPositionY = (float) (pageHeight - (bibParam.getLogoY() * pageHeight / 100.0));
@@ -1463,7 +1498,7 @@ public class GameEventServiceImpl implements IGameEventService {
                             try {
                                 String qrDataStr = getQrDataStr(bibParam.getEventName(), groupName, teamNameMap, projectMap, athlete);
                                 Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
-                                int baseQrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15));
+                                int baseQrSize = 100; // 固定基础尺寸
                                 int qrSize = (int) (baseQrSize * barcodeScale.floatValue());
 
                                 // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
@@ -1506,7 +1541,7 @@ public class GameEventServiceImpl implements IGameEventService {
                             try {
                                 String qrDataStr = getQrDataStr(bibParam.getEventName(), groupName, teamNameMap, projectMap, athlete);
                                 Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
-                                int baseQrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15));
+                                int baseQrSize = 100; // 固定基础尺寸
                                 int qrSize = (int) (baseQrSize * barcodeScale.floatValue());
 
                                 // 使用默认位置(右下角)
@@ -1574,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);
@@ -1605,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) {
@@ -1644,12 +1684,24 @@ public class GameEventServiceImpl implements IGameEventService {
     private byte[] generateSingleBibFromTemplate(byte[] templateImage, GameAthleteVo athlete, GenerateBibBo bibParam) {
         try {
             // 使用BufferedImage处理模版图片
-            BufferedImage template = ImageIO.read(new ByteArrayInputStream(templateImage));
+            BufferedImage originalTemplate = ImageIO.read(new ByteArrayInputStream(templateImage));
+
+            // 获取目标画布尺寸
+            int targetWidth = bibParam.getCanvasWidth() != null ? bibParam.getCanvasWidth() : originalTemplate.getWidth();
+            int targetHeight = bibParam.getCanvasHeight() != null ? bibParam.getCanvasHeight() : originalTemplate.getHeight();
+
+            // 创建目标尺寸的画布
+            BufferedImage template = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
             Graphics2D g2d = template.createGraphics();
 
             // 设置抗锯齿
             g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
             g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+            g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+
+            // 将原始模版图片拉伸/缩放到目标画布尺寸
+            g2d.drawImage(originalTemplate, 0, 0, targetWidth, targetHeight, null);
+            log.info("模版图片已调整到目标尺寸: {}x{}", targetWidth, targetHeight);
 
             // 绘制号码
             drawNumberOnTemplate(g2d, athlete.getAthleteCode().toString(), bibParam, template.getWidth(), template.getHeight());
@@ -1679,20 +1731,10 @@ public class GameEventServiceImpl implements IGameEventService {
             int x = (int) (canvasWidth * bibParam.getNumberX() / 100);
             int y = (int) (canvasHeight * bibParam.getNumberY() / 100);
 
-            // 设置字体(考虑前端预览的相对比例)
-            int baseFontSize = bibParam.getFontSize();
-            // 前端预览时号码字体被限制为最大56px,相对于预览框600x400的比例
-            final int previewMaxFontSize = 56;
-
-            // 计算预览时的相对比例(相对于预览框的宽度)
-            double previewRelativeFontSize = (double) previewMaxFontSize / previewWidth;
-
-            // 计算实际图片中字体应该占的相对比例
-            double actualRelativeFontSize = previewRelativeFontSize * canvasWidth;
-
-            // 计算字体的实际缩放比例
-            double fontScale = Math.min(1.0, actualRelativeFontSize / baseFontSize);
-            int fontSize = (int) (baseFontSize * fontScale);
+            // 设置字体(直接使用用户设置的字体大小和缩放)
+            int baseFontSize = bibParam.getFontSize() != null ? bibParam.getFontSize() : 36;
+            Double numberScale = bibParam.getNumberScale() != null ? bibParam.getNumberScale() : 1.0;
+            int fontSize = (int) (baseFontSize * numberScale);
             Font font = new Font(bibParam.getFontName(), Font.BOLD, fontSize);
             g2d.setFont(font);
 
@@ -1707,7 +1749,8 @@ public class GameEventServiceImpl implements IGameEventService {
 
             // 居中绘制
             g2d.drawString(number, x - textWidth / 2, y + textHeight / 4);
-            log.info("号码绘制完成 - 位置: ({}, {}), 基础字体: {}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 最终字体: {}", x, y, baseFontSize, previewRelativeFontSize, actualRelativeFontSize, fontScale, fontSize);
+            log.info("号码绘制完成 - 位置: ({}, {}), 基础字体: {}, 用户缩放: {}, 最终字体: {}, 画布尺寸: {}x{}",
+                x, y, baseFontSize, numberScale, fontSize, canvasWidth, canvasHeight);
 
         } catch (Exception e) {
             log.error("绘制号码失败", e);
@@ -1745,25 +1788,10 @@ public class GameEventServiceImpl implements IGameEventService {
                 log.info("使用默认二维码位置: ({}, {})", x, y);
             }
 
-            // 应用缩放(考虑前端预览的相对比例和用户设置的缩放
+            // 应用用户设置的缩放
             Double barcodeScale = bibParam.getBarcodeScale() != null ? bibParam.getBarcodeScale() : 1.0;
-
-            // 前端预览时二维码是32x32的SVG图标,相对于预览框600x400的比例
-            final int previewQRSize = 32; // 前端SVG图标大小
-
-            // 计算预览时的相对比例(相对于预览框的宽度)
-            double previewRelativeSize = (double) previewQRSize / previewWidth;
-
-            // 计算实际图片中二维码应该占的相对比例
-            double actualRelativeSize = previewRelativeSize * canvasWidth;
-
-            // 计算二维码的实际缩放比例
-            double previewScale = Math.min(1.0, actualRelativeSize / Math.max(qrImage.getWidth(), qrImage.getHeight()));
-
-            // 计算最终缩放:预览缩放 × 用户缩放
-            double finalScale = previewScale * barcodeScale;
-            int scaledWidth = (int) (qrImage.getWidth() * finalScale);
-            int scaledHeight = (int) (qrImage.getHeight() * finalScale);
+            int scaledWidth = (int) (qrImage.getWidth() * barcodeScale);
+            int scaledHeight = (int) (qrImage.getHeight() * barcodeScale);
 
             // 边界检查,确保二维码不会超出图片范围
             if (x < 0) x = 0;
@@ -1773,8 +1801,8 @@ public class GameEventServiceImpl implements IGameEventService {
 
             // 绘制二维码
             g2d.drawImage(qrImage, x, y, scaledWidth, scaledHeight, null);
-            log.info("二维码绘制完成 - 位置: ({}, {}), 尺寸: {}x{}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 用户缩放: {}, 最终缩放: {}, 画布尺寸: {}x{}",
-                x, y, scaledWidth, scaledHeight, previewRelativeSize, actualRelativeSize, previewScale, barcodeScale, finalScale, canvasWidth, canvasHeight);
+            log.info("二维码绘制完成 - 位置: ({}, {}), 尺寸: {}x{}, 用户缩放: {}, 画布尺寸: {}x{}",
+                x, y, scaledWidth, scaledHeight, barcodeScale, canvasWidth, canvasHeight);
 
         } catch (Exception e) {
             log.error("绘制二维码失败", e);
@@ -1785,14 +1813,121 @@ public class GameEventServiceImpl implements IGameEventService {
      * 生成二维码数据
      */
     private String generateQRCodeData(GameAthleteVo athlete) {
-        return String.format("运动员编号: %s, 姓名: %s, 队伍: %s",
+        StringBuilder joinProject = new StringBuilder();
+        StringJoiner joiner = new StringJoiner(",");
+        
+        // 添加调试日志
+        log.debug("生成二维码数据 - 运动员: {}, projectValue: {}, projectList: {}", 
+            athlete.getName(), athlete.getProjectValue(), athlete.getProjectList());
+        
+        // 检查projectList是否为null或空
+        if (athlete.getProjectList() == null || athlete.getProjectList().isEmpty()) {
+            log.warn("运动员 {} 的参与项目列表为空,projectValue: {}", athlete.getName(), athlete.getProjectValue());
+            return String.format(
+                """
+                    {
+                        赛事名称:%s,
+                        号码:%s,
+                        姓名:%s,
+                        性别:%s,
+                        年龄:%d
+                        队伍名称:%s,
+                        参与项目:无项目,
+                    }
+                """,
+                athlete.getEventName(),
+                athlete.getAthleteCode(),
+                athlete.getName(),
+                athlete.getGender(),
+                athlete.getAge(),
+                athlete.getTeamName() != null ? athlete.getTeamName() : "未知队伍");
+        }
+        
+        Map<Long, String> projectMap = gameEventProjectService.queryNameByEventIdAndProjectIds(athlete.getEventId(), athlete.getProjectList());
+        log.debug("项目映射结果: {}", projectMap);
+        
+        // 添加额外的null检查
+        if (projectMap == null) {
+            log.warn("项目映射结果为空,返回空字符串,逻辑上不可能为空,如果有空项目,应该在运动员表中删除");
+            projectMap = new HashMap<>();
+        }
+        
+        for (Long projectId : athlete.getProjectList()) {
+            String projectName = projectMap.get(projectId) != null ? projectMap.get(projectId).toString() : "未知项目";
+            joiner.add(projectName);
+        }
+        
+        String projectNames = joiner.toString();
+        log.debug("最终项目名称: {}", projectNames);
+        
+        return String.format(
+            """
+                {
+                    赛事名称:%s,
+                    号码:%s,
+                    姓名:%s,
+                    性别:%s,
+                    年龄:%d
+                    队伍名称:%s,
+                    参与项目:%s,
+                }
+            """,
+            athlete.getEventName(),
             athlete.getAthleteCode(),
             athlete.getName(),
-            athlete.getTeamName() != null ? athlete.getTeamName() : "未知队伍");
+            athlete.getGender(),
+            athlete.getAge(),
+            athlete.getTeamName() != null ? athlete.getTeamName() : "未知队伍",
+            projectNames);
+    }
+
+    /**
+     * 从内存中创建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文件
+     * 创建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;
+        }
+    }
+}