Browse Source

feat(game-event): 实现参赛证生成与任务管理功能增强

- 添加画布和Logo尺寸参数支持,优化参赛证模板生成逻辑
- 增强二维码数据内容,包含赛事、号码、姓名、性别、年龄及参与项目信息- 实现任务文件夹安全删除机制,确保路径合法性并递归清理相关资源
- 支持公开下载任务结果接口,无需认证即可跨浏览器访问- 修正参数边界检查逻辑,限制画布和Logo尺寸在合理范围内
- 更新数据库配置和文件上传路径,适配开发与生产环境- 修复运动员项目解析异常处理,增强健壮性和日志记录- 添加赛事项目名称查询接口,支持根据ID列表获取映射关系
-优化PDF生成过程中的背景图和元素缩放逻辑,提升视觉效果
- 完善MyBatis Mapper注解配置,确保接口扫描正确性
zhou 1 month ago
parent
commit
8d610a26ae

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

+ 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);

+ 81 - 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;
@@ -93,7 +94,7 @@ public class NumberController {
         Long eventId = Long.valueOf(cacheObject.toString());
 
         // 边界检查和修正
-        validateAndCorrectBibParams(bibParam);
+        // validateAndCorrectBibParams(bibParam);
 
         // 调试日志
         log.info("创建参赛证任务,taskName: {}, bibParam: {}", taskName, bibParam);
@@ -167,6 +168,15 @@ public class NumberController {
         gameBibTaskService.downloadTaskResult(taskId, response);
     }
 
+    /**
+     * 公开下载任务结果(无需认证,用于跨浏览器下载)
+     */
+    @SaIgnore
+    @GetMapping("/public/downloadTask/{taskId}")
+    public void downloadTaskResultPublic(@PathVariable Long taskId, HttpServletResponse response) {
+        gameBibTaskService.downloadTaskResult(taskId, response);
+    }
+
     /**
      * 验证和修正参赛证参数边界
      */
@@ -285,6 +295,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 +338,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 +396,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 +414,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 +437,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 +454,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 +473,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);

+ 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;

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

+ 2 - 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;
 
@@ -148,5 +148,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("未知队伍");
+                    }
                 });
         });
 

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

@@ -180,10 +180,33 @@ 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) {
+                    // 删除单个文件
+                    deleteFileIfExists(task.getBgImagePath());
+                    deleteFileIfExists(task.getLogoImagePath());
+                    deleteFileIfExists(task.getResultFilePath());
+                    
+                    // 删除整个任务文件夹
+                    deleteTaskFolder(taskId);
+                }
+            } catch (Exception e) {
+                log.error("删除任务文件失败,taskId: {}, 错误: {}", taskId, e.getMessage(), e);
+                // 继续删除其他任务,不中断整个批量删除操作
+            }
+        }
+        
+        // 删除数据库记录
         return baseMapper.deleteByIds(ids) > 0;
     }
 
@@ -356,9 +379,13 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
         // 删除相关文件
         GameBibTaskVO task = queryById(taskId);
         if (task != null) {
+            // 删除单个文件
             deleteFileIfExists(task.getBgImagePath());
             deleteFileIfExists(task.getLogoImagePath());
             deleteFileIfExists(task.getResultFilePath());
+            
+            // 删除整个任务文件夹
+            deleteTaskFolder(taskId);
         }
 
         // 删除任务记录
@@ -388,7 +415,10 @@ public class GameBibTaskServiceImpl implements IGameBibTaskService {
 
         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 +443,148 @@ 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()) {
+                // 递归删除文件夹及其内容
+                boolean deleted = deleteDirectory(taskFolder);
+                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));
+        }
+    }
 }

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

@@ -719,15 +719,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 +741,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 +751,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 +833,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 +875,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 +1375,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 +1401,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 +1411,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 +1495,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 +1538,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());
 
                                 // 使用默认位置(右下角)
@@ -1644,12 +1676,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 +1723,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 +1741,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 +1780,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 +1793,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,10 +1805,72 @@ 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);
     }
 
     /**