Răsfoiți Sursa

feat(game): 实现参赛证生成任务管理功能- 新增参赛证生成任务的完整CRUD操作
- 实现任务状态管理和进度跟踪
- 添加图片文件上传和存储配置
- 支持任务结果文件下载和清理
- 集成赛事名称查询工具类
- 完善参赛证参数配置和位置控制
-优化二维码生成和定位逻辑
-修复运动员编号重复校验问题
- 更新开发环境数据库连接配置
- 添加参赛证任务表到忽略列表

zhou 2 zile în urmă
părinte
comite
bd1d9e1973
17 a modificat fișierele cu 2271 adăugiri și 34 ștergeri
  1. 3 5
      ruoyi-admin/src/main/resources/application-dev.yml
  2. 1 0
      ruoyi-admin/src/main/resources/application.yml
  3. 65 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/config/FileUploadConfig.java
  4. 383 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/NumberController.java
  5. 100 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/GameBibTask.java
  6. 122 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GameBibTaskBo.java
  7. 34 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/bo/GenerateBibBo.java
  8. 42 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/query/GameBibTaskQuery.java
  9. 108 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/GameBibTaskVO.java
  10. 15 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/mapper/GameBibTaskMapper.java
  11. 152 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameBibTaskService.java
  12. 23 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameEventService.java
  13. 2 2
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameAthleteServiceImpl.java
  14. 420 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameBibTaskServiceImpl.java
  15. 748 25
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameEventServiceImpl.java
  16. 1 2
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameTeamServiceImpl.java
  17. 52 0
      ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/utils/EventNameUtils.java

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

@@ -49,11 +49,9 @@ spring:
           driverClassName: com.mysql.cj.jdbc.Driver
           # jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562
           # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题)
-#          url: jdbc:mysql://192.168.1.146:3306/game_event?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
-          url: jdbc:mysql://localhost:3306/game-event?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
-          username: root
-#          password: P@ssw0rd
-          password: 123456
+          url: jdbc:mysql://182.92.79.54:3306/game_event?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
+          username: game_event
+          password: TxtYJXEtnC6ecmBw
 #        # 从库数据源
 #        slave:
 #          lazy: true

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

@@ -130,6 +130,7 @@ tenant:
     - sys_user_role
     - sys_client
     - sys_oss_config
+    - game_bib_task
 
 # MyBatisPlus配置
 # https://baomidou.com/config/

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

@@ -0,0 +1,65 @@
+package org.dromara.system.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+import jakarta.annotation.PostConstruct;
+import java.io.File;
+
+/**
+ * 文件上传配置
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Configuration
+public class FileUploadConfig {
+
+    @Value("${file.upload.path:}")
+    private String uploadPath;
+
+    private String bibImagePath;
+    private String bibResultPath;
+
+    @PostConstruct
+    public void init() {
+        // 如果没有配置上传路径,使用项目根目录下的upload文件夹
+        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);
+    }
+
+    private void createDirectoryIfNotExists(String path) {
+        File dir = new File(path);
+        if (!dir.exists()) {
+            boolean created = dir.mkdirs();
+            if (!created) {
+                throw new RuntimeException("无法创建目录: " + dir.getAbsolutePath());
+            }
+        }
+    }
+
+    public String getBibImagePath() {
+        return bibImagePath;
+    }
+
+    public String getBibResultPath() {
+        return bibResultPath;
+    }
+
+    public String getUploadPath() {
+        return uploadPath;
+    }
+}

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

@@ -6,16 +6,29 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
 import org.dromara.common.redis.utils.RedisUtils;
+import org.dromara.system.domain.bo.GameBibTaskBo;
 import org.dromara.system.domain.bo.GenerateBibBo;
 import org.dromara.system.domain.constant.GameEventConstant;
 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.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
 
 @Slf4j
 @Validated
@@ -25,6 +38,10 @@ import java.util.List;
 public class NumberController {
 
     private final IGameEventService gameEventService;
+    private final IGameBibTaskService gameBibTaskService;
+
+    // 预览框尺寸(固定)
+    private static final int previewWidth = 600;
 
     /**
      *
@@ -63,4 +80,370 @@ public class NumberController {
         gameEventService.generateNumberBib(response, bgImage, logo, bibParam);
     }
 
+    /**
+     * 创建参赛证生成任务(异步)
+     */
+    @SaCheckPermission("system:gameEvent:numberBib")
+    @PostMapping("/createBibTask")
+    public R<String> createBibTask(@RequestPart("bgImage") MultipartFile bgImage,
+                                @RequestPart(name = "logo", required = false) MultipartFile logo,
+                                @RequestParam("taskName") String taskName,
+                                @RequestPart("bibParam") GenerateBibBo bibParam) {
+        Object cacheObject = RedisUtils.getCacheObject(GameEventConstant.DEFAULT_EVENT_ID);
+        Long eventId = Long.valueOf(cacheObject.toString());
+
+        // 边界检查和修正
+        validateAndCorrectBibParams(bibParam);
+
+        // 调试日志
+        log.info("创建参赛证任务,taskName: {}, bibParam: {}", taskName, bibParam);
+        log.info("bibParam详情 - fontName: {}, fontSize: {}, fontColor: {}, logoX: {}, logoY: {}",
+                bibParam.getFontName(), bibParam.getFontSize(), bibParam.getFontColor(),
+                bibParam.getLogoX(), bibParam.getLogoY());
+        log.info("二维码坐标详情 - qRCodeX: {}, qRCodeY: {}",
+                bibParam.getQRCodeX(), bibParam.getQRCodeY());
+        log.info("Logo缩放: {}, 二维码缩放: {}", bibParam.getLogoScale(), bibParam.getBarcodeScale());
+
+        // 先保存图片文件,再创建任务
+        final String bgImagePath = gameBibTaskService.saveImageFile(bgImage, "bg_" + System.currentTimeMillis());
+        final String logoImagePath;
+        if (logo != null && !logo.isEmpty()) {
+            logoImagePath = gameBibTaskService.saveImageFile(logo, "logo_" + System.currentTimeMillis());
+        } else {
+            logoImagePath = null;
+        }
+
+        // 创建任务(不包含图片文件)
+        final Long taskId = gameBibTaskService.createTaskWithImagePaths(taskName, eventId, bgImagePath, logoImagePath, bibParam);
+
+        // 异步执行任务 - 使用画布模版方案
+        CompletableFuture.runAsync(() -> {
+            try {
+                // 生成画布模版
+                byte[] templateImage = generateCanvasTemplate(bgImagePath, logoImagePath, bibParam);
+
+                // 使用模版生成参赛证
+                gameEventService.generateBibFromTemplateAsync(taskId, eventId, templateImage, bibParam);
+            } catch (Exception e) {
+                log.error("参赛证生成任务执行失败,taskId: {}", taskId, e);
+                gameBibTaskService.updateTaskStatus(taskId, "3", e.getMessage());
+            }
+        });
+
+        return R.ok(taskId.toString(), "任务创建成功,请到任务列表查看进度");
+    }
+
+    /**
+     * 查询任务列表
+     */
+    @GetMapping("/taskList")
+    public TableDataInfo<GameBibTaskVO> queryTaskList(GameBibTaskBo bo, PageQuery query) {
+        return gameBibTaskService.queryPageList(bo, query);
+    }
+
+    /**
+     * 停止任务
+     */
+    @PostMapping("/pauseTask/{taskId}")
+    public R<Void> pauseTask(@PathVariable Long taskId) {
+        gameBibTaskService.pauseTask(taskId);
+        return R.ok("任务已停止");
+    }
+
+    /**
+     * 删除任务
+     */
+    @DeleteMapping("/task/{taskId}")
+    public R<Void> deleteTask(@PathVariable Long taskId) {
+        gameBibTaskService.deleteTask(taskId);
+        return R.ok("任务已删除");
+    }
+
+    /**
+     * 下载任务结果
+     */
+    @GetMapping("/downloadTask/{taskId}")
+    public void downloadTaskResult(@PathVariable Long taskId, HttpServletResponse response) {
+        gameBibTaskService.downloadTaskResult(taskId, response);
+    }
+
+    /**
+     * 验证和修正参赛证参数边界
+     */
+    private void validateAndCorrectBibParams(GenerateBibBo bibParam) {
+        // 统一使用百分比坐标系统 (0-100)
+        final double maxPercent = 100.0;
+        final double minPercent = 0.0;
+
+        // 修正Logo位置(百分比)
+        if (bibParam.getLogoX() != null) {
+            double logoX = Math.max(minPercent, Math.min(maxPercent, bibParam.getLogoX()));
+            if (Double.compare(logoX, bibParam.getLogoX()) != 0) {
+                log.warn("Logo X坐标百分比超出边界,从 {} 修正为 {}", bibParam.getLogoX(), logoX);
+                bibParam.setLogoX(logoX);
+            }
+        }
+
+        if (bibParam.getLogoY() != null) {
+            double logoY = Math.max(minPercent, Math.min(maxPercent, bibParam.getLogoY()));
+            if (Double.compare(logoY, bibParam.getLogoY()) != 0) {
+                log.warn("Logo Y坐标百分比超出边界,从 {} 修正为 {}", bibParam.getLogoY(), logoY);
+                bibParam.setLogoY(logoY);
+            }
+        }
+
+        // 修正二维码位置(百分比)
+        if (bibParam.getQRCodeX() != null) {
+            double qrX = Math.max(minPercent, Math.min(maxPercent, bibParam.getQRCodeX()));
+            if (Double.compare(qrX, bibParam.getQRCodeX()) != 0) {
+                log.warn("二维码 X坐标百分比超出边界,从 {} 修正为 {}", bibParam.getQRCodeX(), qrX);
+                bibParam.setQRCodeX(qrX);
+            }
+        }
+
+        if (bibParam.getQRCodeY() != null) {
+            double qrY = Math.max(minPercent, Math.min(maxPercent, bibParam.getQRCodeY()));
+            if (Double.compare(qrY, bibParam.getQRCodeY()) != 0) {
+                log.warn("二维码 Y坐标百分比超出边界,从 {} 修正为 {}", bibParam.getQRCodeY(), qrY);
+                bibParam.setQRCodeY(qrY);
+            }
+        }
+
+        // 修正号码位置(百分比值,范围0-100)
+        if (bibParam.getNumberX() != null) {
+            double numberX = Math.max(0.0, Math.min(100.0, bibParam.getNumberX()));
+            if (Double.compare(numberX, bibParam.getNumberX()) != 0) {
+                log.warn("号码 X坐标百分比超出边界,从 {} 修正为 {}", bibParam.getNumberX(), numberX);
+                bibParam.setNumberX(numberX);
+            }
+        }
+
+        if (bibParam.getNumberY() != null) {
+            double numberY = Math.max(0.0, Math.min(100.0, bibParam.getNumberY()));
+            if (Double.compare(numberY, bibParam.getNumberY()) != 0) {
+                log.warn("号码 Y坐标百分比超出边界,从 {} 修正为 {}", bibParam.getNumberY(), numberY);
+                bibParam.setNumberY(numberY);
+            }
+        }
+
+        // 修正赛事名称位置(百分比值,范围0-100)
+        if (bibParam.getEventX() != null) {
+            double eventX = Math.max(0.0, Math.min(100.0, bibParam.getEventX()));
+            if (Double.compare(eventX, bibParam.getEventX()) != 0) {
+                log.warn("赛事名称 X坐标百分比超出边界,从 {} 修正为 {}", bibParam.getEventX(), eventX);
+                bibParam.setEventX(eventX);
+            }
+        }
+
+        if (bibParam.getEventY() != null) {
+            double eventY = Math.max(0.0, Math.min(100.0, bibParam.getEventY()));
+            if (Double.compare(eventY, bibParam.getEventY()) != 0) {
+                log.warn("赛事名称 Y坐标百分比超出边界,从 {} 修正为 {}", bibParam.getEventY(), eventY);
+                bibParam.setEventY(eventY);
+            }
+        }
+
+        // 修正缩放参数(范围0.1-5.0)
+        if (bibParam.getLogoScale() != null) {
+            double logoScale = Math.max(0.1, Math.min(5.0, bibParam.getLogoScale()));
+            if (Double.compare(logoScale, bibParam.getLogoScale()) != 0) {
+                log.warn("Logo缩放参数超出边界,从 {} 修正为 {}", bibParam.getLogoScale(), logoScale);
+                bibParam.setLogoScale(logoScale);
+            }
+        }
+
+        if (bibParam.getBarcodeScale() != null) {
+            double barcodeScale = Math.max(0.1, Math.min(5.0, bibParam.getBarcodeScale()));
+            if (Double.compare(barcodeScale, bibParam.getBarcodeScale()) != 0) {
+                log.warn("二维码缩放参数超出边界,从 {} 修正为 {}", bibParam.getBarcodeScale(), barcodeScale);
+                bibParam.setBarcodeScale(barcodeScale);
+            }
+        }
+
+        if (bibParam.getNumberScale() != null) {
+            double numberScale = Math.max(0.1, Math.min(5.0, bibParam.getNumberScale()));
+            if (Double.compare(numberScale, bibParam.getNumberScale()) != 0) {
+                log.warn("号码缩放参数超出边界,从 {} 修正为 {}", bibParam.getNumberScale(), numberScale);
+                bibParam.setNumberScale(numberScale);
+            }
+        }
+
+        if (bibParam.getEventScale() != null) {
+            double eventScale = Math.max(0.1, Math.min(5.0, bibParam.getEventScale()));
+            if (Double.compare(eventScale, bibParam.getEventScale()) != 0) {
+                log.warn("赛事名称缩放参数超出边界,从 {} 修正为 {}", bibParam.getEventScale(), eventScale);
+                bibParam.setEventScale(eventScale);
+            }
+        }
+
+        // 修正字体大小(范围8-200)
+        if (bibParam.getFontSize() != null) {
+            int fontSize = Math.max(8, Math.min(200, bibParam.getFontSize()));
+            if (Integer.compare(fontSize, bibParam.getFontSize()) != 0) {
+                log.warn("字体大小超出边界,从 {} 修正为 {}", bibParam.getFontSize(), fontSize);
+                bibParam.setFontSize(fontSize);
+            }
+        }
+
+        log.info("参赛证参数边界检查完成");
+    }
+
+    /**
+     * 生成画布模版
+     */
+    private byte[] generateCanvasTemplate(String bgImagePath, String logoImagePath, GenerateBibBo bibParam) {
+        try {
+            // 读取背景图片
+            BufferedImage bgImage = ImageIO.read(new File(bgImagePath));
+            log.info("背景图片尺寸: {}x{}", bgImage.getWidth(), bgImage.getHeight());
+
+            Graphics2D g2d = bgImage.createGraphics();
+
+            // 设置抗锯齿
+            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+            // 绘制Logo(如果存在)
+            if (logoImagePath != null && new File(logoImagePath).exists()) {
+                log.info("开始绘制Logo - 路径: {}", logoImagePath);
+                drawLogoOnTemplate(g2d, logoImagePath, bibParam, bgImage.getWidth(), bgImage.getHeight());
+            } else {
+                log.warn("Logo图片不存在或路径为空: {}", logoImagePath);
+            }
+
+            // 绘制赛事名称(如果存在)
+            if (StringUtils.isNotBlank(bibParam.getEventName())) {
+                log.info("开始绘制赛事名称: {}", bibParam.getEventName());
+                drawEventNameOnTemplate(g2d, bibParam.getEventName(), bibParam, bgImage.getWidth(), bgImage.getHeight());
+            } else {
+                log.warn("赛事名称为空");
+            }
+
+            g2d.dispose();
+
+            // 转换为字节数组
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            ImageIO.write(bgImage, "PNG", baos);
+            log.info("画布模版生成完成,大小: {} bytes", baos.size());
+            return baos.toByteArray();
+
+        } catch (Exception e) {
+            log.error("生成画布模版失败", e);
+            throw new RuntimeException("生成画布模版失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 在模版上绘制Logo
+     */
+    private void drawLogoOnTemplate(Graphics2D g2d, String logoImagePath, GenerateBibBo bibParam, int canvasWidth, int canvasHeight) {
+        try {
+            BufferedImage logoImage = ImageIO.read(new File(logoImagePath));
+            log.info("Logo图片尺寸: {}x{}", logoImage.getWidth(), logoImage.getHeight());
+
+            // 预览框尺寸(固定)
+            final int previewWidth = 600;
+            final int previewHeight = 400;
+
+            // 计算Logo位置(从百分比坐标转换为实际图片坐标)
+            Double logoX = bibParam.getLogoX();
+            Double logoY = bibParam.getLogoY();
+
+            int x, y;
+            if (logoX != null && logoY != null) {
+                // 将百分比坐标转换为实际图片像素坐标
+                x = (int) (logoX * canvasWidth / 100.0);
+                y = (int) (logoY * canvasHeight / 100.0);
+                log.info("Logo坐标转换 - 百分比坐标: ({}, {}), 实际图片坐标: ({}, {})",
+                    logoX, logoY, x, y);
+            } else {
+                // 使用默认位置(左上角)
+                x = (int) (4.17 * canvasWidth / 100.0);  // 4.17%
+                y = (int) (6.25 * canvasHeight / 100.0); // 6.25%
+                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不会超出图片范围
+            if (x < 0) x = 0;
+            if (y < 0) y = 0;
+            if (x + scaledWidth > canvasWidth) x = canvasWidth - scaledWidth;
+            if (y + scaledHeight > canvasHeight) y = canvasHeight - scaledHeight;
+
+            // 绘制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);
+
+        } catch (Exception e) {
+            log.error("绘制Logo失败", e);
+        }
+    }
+
+    /**
+     * 在模版上绘制赛事名称
+     */
+    private void drawEventNameOnTemplate(Graphics2D g2d, String eventName, GenerateBibBo bibParam, int canvasWidth, int canvasHeight) {
+        try {
+            // 计算赛事名称位置(基于百分比)
+            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);
+            Double eventScale = bibParam.getEventScale() != null ? bibParam.getEventScale() : 1.0;
+            int fontSize = (int) (baseFontSize * fontScale * eventScale);
+
+            Font font = new Font(bibParam.getFontName(), Font.BOLD, fontSize);
+            g2d.setFont(font);
+
+            // 设置颜色
+            Color color = new Color(bibParam.getFontColor());
+            g2d.setColor(color);
+
+            // 绘制赛事名称
+            FontMetrics fm = g2d.getFontMetrics();
+            int textWidth = fm.stringWidth(eventName);
+            int textHeight = fm.getHeight();
+
+            // 居中绘制
+            g2d.drawString(eventName, x - textWidth / 2, y + textHeight / 4);
+            log.info("赛事名称绘制完成 - 位置: ({}, {}), 基础字体: {}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 用户缩放: {}, 最终字体: {}",
+                x, y, baseFontSize, previewRelativeFontSize, actualRelativeFontSize, fontScale, eventScale, fontSize);
+
+        } catch (Exception e) {
+            log.error("绘制赛事名称失败", e);
+        }
+    }
+
 }

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

@@ -0,0 +1,100 @@
+package org.dromara.system.domain;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 参赛证生成任务对象 game_bib_task
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("game_bib_task")
+public class GameBibTask implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 任务ID
+     */
+    @TableId(value = "task_id", type = IdType.AUTO)
+    private Long taskId;
+
+    /**
+     * 任务名称
+     */
+    private String taskName;
+
+    /**
+     * 赛事ID
+     */
+    private Long eventId;
+
+    /**
+     * 任务状态(0运行中 1暂停 2完成 3失败)
+     */
+    private String status;
+
+    /**
+     * 进度百分比
+     */
+    private Integer progress;
+
+    /**
+     * 总数量
+     */
+    private Integer totalCount;
+
+    /**
+     * 已完成数量
+     */
+    private Integer completedCount;
+
+    /**
+     * 背景图片路径
+     */
+    private String bgImagePath;
+
+    /**
+     * Logo图片路径
+     */
+    private String logoImagePath;
+
+    /**
+     * 参赛证参数JSON
+     */
+    private String bibParams;
+
+    /**
+     * 结果文件路径
+     */
+    private String resultFilePath;
+
+    /**
+     * 错误信息
+     */
+    private String errorMessage;
+
+    /**
+     * 创建时间
+     */
+    private Date createTime;
+
+    /**
+     * 更新时间
+     */
+    private Date updateTime;
+
+    /**
+     * 完成时间
+     */
+    private Date finishTime;
+}

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

@@ -0,0 +1,122 @@
+package org.dromara.system.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import org.dromara.system.domain.GameBibTask;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 参赛证生成任务业务对象 game_bib_task
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Schema(description = "参赛证生成任务")
+@AutoMapper(target = GameBibTask.class, reverseConvertGenerate = false)
+public class GameBibTaskBo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 任务ID
+     */
+    @Schema(description = "任务ID", example = "1")
+    private Long taskId;
+
+    /**
+     * 任务名称
+     */
+    @Schema(description = "任务名称", example = "参赛证生成_2025-01-27")
+    @NotBlank(message = "任务名称不能为空", groups = { AddGroup.class, EditGroup.class })
+    private String taskName;
+
+    /**
+     * 赛事ID
+     */
+    @Schema(description = "赛事ID", example = "1")
+    @NotNull(message = "赛事ID不能为空", groups = { AddGroup.class, EditGroup.class })
+    private Long eventId;
+
+    /**
+     * 任务状态(0运行中 1暂停 2完成 3失败)
+     */
+    @Schema(description = "任务状态", example = "0")
+    private String status;
+
+    /**
+     * 进度百分比
+     */
+    @Schema(description = "进度百分比", example = "50")
+    private Integer progress;
+
+    /**
+     * 总数量
+     */
+    @Schema(description = "总数量", example = "100")
+    private Integer totalCount;
+
+    /**
+     * 已完成数量
+     */
+    @Schema(description = "已完成数量", example = "50")
+    private Integer completedCount;
+
+    /**
+     * 背景图片路径
+     */
+    @Schema(description = "背景图片路径")
+    private String bgImagePath;
+
+    /**
+     * Logo图片路径
+     */
+    @Schema(description = "Logo图片路径")
+    private String logoImagePath;
+
+    /**
+     * 参赛证参数JSON
+     */
+    @Schema(description = "参赛证参数JSON")
+    private String bibParams;
+
+    /**
+     * 结果文件路径
+     */
+    @Schema(description = "结果文件路径")
+    private String resultFilePath;
+
+    /**
+     * 错误信息
+     */
+    @Schema(description = "错误信息")
+    private String errorMessage;
+
+    /**
+     * 创建时间
+     */
+    @Schema(description = "创建时间")
+    private Date createTime;
+
+    /**
+     * 更新时间
+     */
+    @Schema(description = "更新时间")
+    private Date updateTime;
+
+    /**
+     * 完成时间
+     */
+    @Schema(description = "完成时间")
+    private Date finishTime;
+}

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

@@ -1,5 +1,6 @@
 package org.dromara.system.domain.bo;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
 import lombok.Data;
 
 import java.io.Serial;
@@ -11,13 +12,17 @@ public class GenerateBibBo implements Serializable {
     /**
      * logo位置
      */
+    @JsonProperty("logoX")
     private Double logoX;
+    @JsonProperty("logoY")
     private Double logoY;
 
     /**
      * 二维码位置
      */
+    @JsonProperty("qRCodeX")
     private Double qRCodeX;
+    @JsonProperty("qRCodeY")
     private Double qRCodeY;
 
     /**
@@ -27,6 +32,35 @@ public class GenerateBibBo implements Serializable {
     private Integer fontSize;
     private Integer fontColor;
 
+    /**
+     * 号码位置(百分比)
+     */
+    @JsonProperty("numberX")
+    private Double numberX;
+    @JsonProperty("numberY")
+    private Double numberY;
+
+    /**
+     * 赛事名称位置(百分比)
+     */
+    @JsonProperty("eventX")
+    private Double eventX;
+    @JsonProperty("eventY")
+    private Double eventY;
+
+    /**
+     * 元素缩放参数
+     */
+    @JsonProperty("logoScale")
+    private Double logoScale;
+    @JsonProperty("barcodeScale")
+    private Double barcodeScale;
+    @JsonProperty("numberScale")
+    private Double numberScale;
+    @JsonProperty("eventScale")
+    private Double eventScale;
+
+    @JsonProperty("eventName")
     private String eventName;
 
 

+ 42 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/query/GameBibTaskQuery.java

@@ -0,0 +1,42 @@
+package org.dromara.system.domain.query;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * 参赛证生成任务查询对象 game_bib_task
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "参赛证生成任务查询")
+public class GameBibTaskQuery extends BaseEntity {
+
+    /**
+     * 任务ID
+     */
+    @Schema(description = "任务ID", example = "1")
+    private Long taskId;
+
+    /**
+     * 任务名称
+     */
+    @Schema(description = "任务名称", example = "参赛证生成")
+    private String taskName;
+
+    /**
+     * 赛事ID
+     */
+    @Schema(description = "赛事ID", example = "1")
+    private Long eventId;
+
+    /**
+     * 任务状态(0运行中 1暂停 2完成 3失败)
+     */
+    @Schema(description = "任务状态", example = "0")
+    private String status;
+}

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

@@ -0,0 +1,108 @@
+package org.dromara.system.domain.vo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.system.domain.GameBibTask;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 参赛证生成任务视图对象 game_bib_task
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Data
+@AutoMapper(target = GameBibTask.class)
+public class GameBibTaskVO implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 任务ID
+     */
+    private Long taskId;
+
+    /**
+     * 任务名称
+     */
+    private String taskName;
+
+    /**
+     * 赛事ID
+     */
+    private Long eventId;
+
+    /**
+     * 赛事名称
+     */
+    private String eventName;
+
+    /**
+     * 任务状态(0运行中 1暂停 2完成 3失败)
+     */
+    private String status;
+
+    /**
+     * 状态文本
+     */
+    private String statusText;
+
+    /**
+     * 进度百分比
+     */
+    private Integer progress;
+
+    /**
+     * 总数量
+     */
+    private Integer totalCount;
+
+    /**
+     * 已完成数量
+     */
+    private Integer completedCount;
+
+    /**
+     * 背景图片路径
+     */
+    private String bgImagePath;
+
+    /**
+     * Logo图片路径
+     */
+    private String logoImagePath;
+
+    /**
+     * 参赛证参数JSON
+     */
+    private String bibParams;
+
+    /**
+     * 结果文件路径
+     */
+    private String resultFilePath;
+
+    /**
+     * 错误信息
+     */
+    private String errorMessage;
+
+    /**
+     * 创建时间
+     */
+    private Date createTime;
+
+    /**
+     * 更新时间
+     */
+    private Date updateTime;
+
+    /**
+     * 完成时间
+     */
+    private Date finishTime;
+}

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

@@ -0,0 +1,15 @@
+package org.dromara.system.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.system.domain.GameBibTask;
+import org.dromara.system.domain.vo.GameBibTaskVO;
+
+/**
+ * 参赛证生成任务Mapper接口
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+public interface GameBibTaskMapper extends BaseMapperPlus<GameBibTask, GameBibTaskVO> {
+
+}

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

@@ -0,0 +1,152 @@
+package org.dromara.system.service;
+
+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;
+
+import java.util.Collection;
+
+/**
+ * 参赛证生成任务Service接口
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+public interface IGameBibTaskService {
+
+    /**
+     * 查询参赛证生成任务
+     *
+     * @param taskId 主键
+     * @return 参赛证生成任务
+     */
+    GameBibTaskVO queryById(Long taskId);
+
+    /**
+     * 分页查询参赛证生成任务列表
+     *
+     * @param bo 查询条件
+     * @return 参赛证生成任务分页列表
+     */
+    TableDataInfo<GameBibTaskVO> queryPageList(GameBibTaskBo bo, PageQuery query);
+
+    /**
+     * 查询符合条件的参赛证生成任务列表
+     *
+     * @param bo 查询条件
+     * @return 参赛证生成任务列表
+     */
+    java.util.List<GameBibTaskVO> queryList(GameBibTaskBo bo);
+
+    /**
+     * 新增参赛证生成任务
+     *
+     * @param bo 参赛证生成任务
+     * @return 是否新增成功
+     */
+    Boolean insertByBo(GameBibTaskBo bo);
+
+    /**
+     * 修改参赛证生成任务
+     *
+     * @param bo 参赛证生成任务
+     * @return 是否修改成功
+     */
+    Boolean updateByBo(GameBibTaskBo bo);
+
+    /**
+     * 校验并批量删除参赛证生成任务信息
+     *
+     * @param ids     待删除的主键集合
+     * @param isValid 是否进行有效性校验
+     * @return 是否删除成功
+     */
+    Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
+
+    /**
+     * 创建参赛证生成任务
+     *
+     * @param taskName 任务名称
+     * @param eventId  赛事ID
+     * @param bgImage  背景图片
+     * @param logo     Logo图片
+     * @param bibParam 参赛证参数
+     * @return 任务ID
+     */
+    Long createTask(String taskName, Long eventId, MultipartFile bgImage,
+                    MultipartFile logo, GenerateBibBo bibParam);
+
+    /**
+     * 创建参赛证生成任务(使用已保存的图片路径)
+     *
+     * @param taskName      任务名称
+     * @param eventId       赛事ID
+     * @param bgImagePath   背景图片路径
+     * @param logoImagePath Logo图片路径
+     * @param bibParam      参赛证参数
+     * @return 任务ID
+     */
+    Long createTaskWithImagePaths(String taskName, Long eventId, String bgImagePath,
+                                  String logoImagePath, GenerateBibBo bibParam);
+
+    /**
+     * 保存图片文件
+     *
+     * @param file     图片文件
+     * @param fileName 文件名
+     * @return 保存后的文件路径
+     */
+    String saveImageFile(MultipartFile file, String fileName);
+
+    /**
+     * 更新任务状态
+     *
+     * @param taskId      任务ID
+     * @param status      状态
+     * @param errorMessage 错误信息
+     */
+    void updateTaskStatus(Long taskId, String status, String errorMessage);
+
+    /**
+     * 更新任务进度
+     *
+     * @param taskId         任务ID
+     * @param progress       进度
+     * @param completedCount 已完成数量
+     */
+    void updateTaskProgress(Long taskId, Integer progress, Integer completedCount);
+
+    /**
+     * 完成任务
+     *
+     * @param taskId         任务ID
+     * @param resultFilePath 结果文件路径
+     */
+    void completeTask(Long taskId, String resultFilePath);
+
+    /**
+     * 暂停任务
+     *
+     * @param taskId 任务ID
+     */
+    void pauseTask(Long taskId);
+
+    /**
+     * 删除任务
+     *
+     * @param taskId 任务ID
+     */
+    void deleteTask(Long taskId);
+
+    /**
+     * 下载任务结果
+     *
+     * @param taskId   任务ID
+     * @param response 响应对象
+     */
+    void downloadTaskResult(Long taskId, jakarta.servlet.http.HttpServletResponse response);
+}

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

@@ -14,6 +14,7 @@ import org.springframework.web.multipart.MultipartFile;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 赛事基本信息Service接口
@@ -122,6 +123,17 @@ public interface IGameEventService {
 
     void generateNumberBib(HttpServletResponse response, MultipartFile bgImage, MultipartFile logo, GenerateBibBo bibParam);
 
+    /**
+     * 异步生成参赛证
+     *
+     * @param taskId        任务ID
+     * @param eventId       赛事ID
+     * @param bgImagePath   背景图片路径
+     * @param logoImagePath Logo图片路径
+     * @param bibParam      参赛证参数
+     */
+    void generateNumberBibAsync(Long taskId, Long eventId, String bgImagePath, String logoImagePath, GenerateBibBo bibParam);
+
     /**
      * 获取赛事项目进度信息
      *
@@ -129,4 +141,15 @@ public interface IGameEventService {
      * @return 项目进度列表
      */
     List<ProjectProgressVo> getProjectProgress(Long eventId);
+
+    /**
+     * 基于画布模版生成参赛证(异步)
+     *
+     * @param taskId         任务ID
+     * @param eventId        赛事ID
+     * @param templateImage  模版图片
+     * @param bibParam       参赛证参数
+     */
+    void generateBibFromTemplateAsync(Long taskId, Long eventId, byte[] templateImage, GenerateBibBo bibParam);
+
 }

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

@@ -433,10 +433,10 @@ public class GameAthleteServiceImpl implements IGameAthleteService {
         if (entity.getAthleteCode() != null){
             List<GameAthlete> list = baseMapper.selectList(new LambdaQueryWrapper<GameAthlete>()
                 .eq(GameAthlete::getAthleteCode, entity.getAthleteCode())
-                .ne(GameAthlete::getAthleteId, entity.getAthleteId())
+                .ne( entity.getAthleteId() != null, GameAthlete::getAthleteId, entity.getAthleteId())
             );
             if (!list.isEmpty()){
-                throw new ServiceException(entity.getName()+"的编号已存在!");
+                throw new ServiceException(entity.getAthleteCode()+"编号已存在!");
             }
         }
     }

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

@@ -0,0 +1,420 @@
+package org.dromara.system.service.impl;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.utils.MapstructUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.system.domain.GameBibTask;
+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.dromara.system.config.FileUploadConfig;
+import org.dromara.system.mapper.GameBibTaskMapper;
+import org.dromara.system.service.IGameBibTaskService;
+import org.dromara.system.utils.EventNameUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+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业务层处理
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class GameBibTaskServiceImpl implements IGameBibTaskService {
+
+    private final GameBibTaskMapper baseMapper;
+    private final FileUploadConfig fileUploadConfig;
+    private final EventNameUtils eventNameUtils;
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 查询参赛证生成任务
+     *
+     * @param taskId 主键
+     * @return 参赛证生成任务
+     */
+    @Override
+    public GameBibTaskVO queryById(Long taskId) {
+        return baseMapper.selectVoById(taskId);
+    }
+
+    /**
+     * 分页查询参赛证生成任务列表
+     *
+     * @param query 查询条件
+     * @return 参赛证生成任务分页列表
+     */
+    @Override
+    public TableDataInfo<GameBibTaskVO> queryPageList(GameBibTaskBo bo, PageQuery query) {
+        LambdaQueryWrapper<GameBibTask> lqw = buildQueryWrapper(bo);
+        Page<GameBibTaskVO> result = baseMapper.selectVoPage(query.build(), lqw);
+        
+        // 填充赛事名称
+        if (result.getRecords() != null && !result.getRecords().isEmpty()) {
+            fillEventNames(result.getRecords());
+        }
+        
+        return TableDataInfo.build(result);
+    }
+    
+    /**
+     * 填充赛事名称
+     */
+    private void fillEventNames(List<GameBibTaskVO> records) {
+        // 获取所有赛事ID
+        Set<Long> eventIds = records.stream()
+            .map(GameBibTaskVO::getEventId)
+            .filter(Objects::nonNull)
+            .collect(Collectors.toSet());
+        
+        if (eventIds.isEmpty()) {
+            return;
+        }
+        
+        // 查询赛事信息
+        Map<Long, String> eventNameMap = eventNameUtils.getEventNameMap(eventIds);
+        
+        // 填充赛事名称
+        records.forEach(record -> {
+            if (record.getEventId() != null) {
+                String eventName = eventNameMap.get(record.getEventId());
+                record.setEventName(eventName != null ? eventName : "未知赛事");
+            }
+        });
+    }
+
+    /**
+     * 查询符合条件的参赛证生成任务列表
+     *
+     * @param bo 查询条件
+     * @return 参赛证生成任务列表
+     */
+    @Override
+    public List<GameBibTaskVO> queryList(GameBibTaskBo bo) {
+        LambdaQueryWrapper<GameBibTask> lqw = buildQueryWrapper(bo);
+        return baseMapper.selectVoList(lqw);
+    }
+
+    private LambdaQueryWrapper<GameBibTask> buildQueryWrapper(GameBibTaskBo bo) {
+        LambdaQueryWrapper<GameBibTask> lqw = Wrappers.lambdaQuery();
+        lqw.like(StringUtils.isNotBlank(bo.getTaskName()), GameBibTask::getTaskName, bo.getTaskName());
+        lqw.eq(ObjectUtil.isNotNull(bo.getEventId()), GameBibTask::getEventId, bo.getEventId());
+        lqw.eq(StringUtils.isNotBlank(bo.getStatus()), GameBibTask::getStatus, bo.getStatus());
+        lqw.orderByDesc(GameBibTask::getCreateTime);
+        return lqw;
+    }
+
+    /**
+     * 新增参赛证生成任务
+     *
+     * @param bo 参赛证生成任务
+     * @return 是否新增成功
+     */
+    @Override
+    public Boolean insertByBo(GameBibTaskBo bo) {
+        GameBibTask add = MapstructUtils.convert(bo, GameBibTask.class);
+        validEntityBeforeSave(add);
+        boolean flag = baseMapper.insert(add) > 0;
+        if (flag) {
+            bo.setTaskId(add.getTaskId());
+        }
+        return flag;
+    }
+
+    /**
+     * 修改参赛证生成任务
+     *
+     * @param bo 参赛证生成任务
+     * @return 是否修改成功
+     */
+    @Override
+    public Boolean updateByBo(GameBibTaskBo bo) {
+        GameBibTask update = MapstructUtils.convert(bo, GameBibTask.class);
+        validEntityBeforeSave(update);
+        return baseMapper.updateById(update) > 0;
+    }
+
+    /**
+     * 保存前的数据校验
+     */
+    private void validEntityBeforeSave(GameBibTask entity) {
+        // TODO 做一些数据校验,如唯一约束
+    }
+
+    /**
+     * 校验并批量删除参赛证生成任务信息
+     *
+     * @param ids     待删除的主键集合
+     * @param isValid 是否进行有效性校验
+     * @return 是否删除成功
+     */
+    @Override
+    public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
+        if (isValid) {
+            // TODO 做一些业务上的校验,判断是否需要校验
+        }
+        return baseMapper.deleteByIds(ids) > 0;
+    }
+
+    /**
+     * 创建参赛证生成任务
+     *
+     * @param taskName 任务名称
+     * @param eventId  赛事ID
+     * @param bgImage  背景图片
+     * @param logo     Logo图片
+     * @param bibParam 参赛证参数
+     * @return 任务ID
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long createTask(String taskName, Long eventId, MultipartFile bgImage,
+                          MultipartFile logo, GenerateBibBo bibParam) {
+        try {
+            // 保存图片文件
+            String bgImagePath = saveImageFile(bgImage, "bg_" + System.currentTimeMillis());
+            String logoImagePath = null;
+            if (logo != null && !logo.isEmpty()) {
+                logoImagePath = saveImageFile(logo, "logo_" + System.currentTimeMillis());
+            }
+
+            return createTaskWithImagePaths(taskName, eventId, bgImagePath, logoImagePath, bibParam);
+        } catch (Exception e) {
+            log.error("创建参赛证生成任务失败", e);
+            throw new RuntimeException("创建任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 创建参赛证生成任务(使用已保存的图片路径)
+     *
+     * @param taskName      任务名称
+     * @param eventId       赛事ID
+     * @param bgImagePath   背景图片路径
+     * @param logoImagePath Logo图片路径
+     * @param bibParam      参赛证参数
+     * @return 任务ID
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long createTaskWithImagePaths(String taskName, Long eventId, String bgImagePath,
+                                        String logoImagePath, GenerateBibBo bibParam) {
+        try {
+            // 创建任务记录
+            GameBibTaskBo taskBo = new GameBibTaskBo();
+            taskBo.setTaskName(taskName);
+            taskBo.setEventId(eventId);
+            taskBo.setStatus("0"); // 运行中
+            taskBo.setProgress(0);
+            taskBo.setTotalCount(0);
+            taskBo.setCompletedCount(0);
+            taskBo.setBgImagePath(bgImagePath);
+            taskBo.setLogoImagePath(logoImagePath);
+            taskBo.setBibParams(objectMapper.writeValueAsString(bibParam));
+
+            boolean success = insertByBo(taskBo);
+            if (success) {
+                return taskBo.getTaskId();
+            }
+            return null;
+        } catch (Exception e) {
+            log.error("创建参赛证生成任务失败", e);
+            throw new RuntimeException("创建任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 保存图片文件
+     *
+     * @param file     图片文件
+     * @param fileName 文件名
+     * @return 保存后的文件路径
+     */
+    @Override
+    public String saveImageFile(MultipartFile file, String fileName) {
+        try {
+            String imagePath = fileUploadConfig.getBibImagePath();
+            String originalFilename = file.getOriginalFilename();
+            if (originalFilename == null || !originalFilename.contains(".")) {
+                throw new RuntimeException("文件名无效: " + originalFilename);
+            }
+            
+            String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
+            String filePath = imagePath + fileName + extension;
+
+            File destFile = new File(filePath);
+            file.transferTo(destFile);
+
+            return filePath;
+        } catch (Exception e) {
+            log.error("保存图片文件失败", e);
+            throw new RuntimeException("保存图片文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 更新任务状态
+     *
+     * @param taskId       任务ID
+     * @param status       状态
+     * @param errorMessage 错误信息
+     */
+    @Override
+    public void updateTaskStatus(Long taskId, String status, String errorMessage) {
+        GameBibTaskBo updateBo = new GameBibTaskBo();
+        updateBo.setTaskId(taskId);
+        updateBo.setStatus(status);
+        updateBo.setErrorMessage(errorMessage);
+        if ("2".equals(status) || "3".equals(status)) {
+            updateBo.setFinishTime(Date.from(Instant.now()));
+        }
+        updateByBo(updateBo);
+    }
+
+    /**
+     * 更新任务进度
+     *
+     * @param taskId         任务ID
+     * @param progress       进度
+     * @param completedCount 已完成数量
+     */
+    @Override
+    public void updateTaskProgress(Long taskId, Integer progress, Integer completedCount) {
+        GameBibTaskBo updateBo = new GameBibTaskBo();
+        updateBo.setTaskId(taskId);
+        updateBo.setProgress(progress);
+        updateBo.setCompletedCount(completedCount);
+        updateByBo(updateBo);
+    }
+
+    /**
+     * 完成任务
+     *
+     * @param taskId         任务ID
+     * @param resultFilePath 结果文件路径
+     */
+    @Override
+    public void completeTask(Long taskId, String resultFilePath) {
+        GameBibTaskBo updateBo = new GameBibTaskBo();
+        updateBo.setTaskId(taskId);
+        updateBo.setStatus("2"); // 完成
+        updateBo.setProgress(100);
+        updateBo.setResultFilePath(resultFilePath);
+        updateBo.setFinishTime(Date.from(Instant.now()));
+        updateByBo(updateBo);
+    }
+
+    /**
+     * 暂停任务
+     *
+     * @param taskId 任务ID
+     */
+    @Override
+    public void pauseTask(Long taskId) {
+        updateTaskStatus(taskId, "1", null); // 暂停
+    }
+
+    /**
+     * 删除任务
+     *
+     * @param taskId 任务ID
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteTask(Long taskId) {
+        // 删除相关文件
+        GameBibTaskVO task = queryById(taskId);
+        if (task != null) {
+            deleteFileIfExists(task.getBgImagePath());
+            deleteFileIfExists(task.getLogoImagePath());
+            deleteFileIfExists(task.getResultFilePath());
+        }
+
+        // 删除任务记录
+        baseMapper.deleteById(taskId);
+    }
+
+    /**
+     * 下载任务结果
+     *
+     * @param taskId   任务ID
+     * @param response 响应对象
+     */
+    @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("任务不存在或结果文件不存在");
+        }
+
+        File file = new File(task.getResultFilePath());
+        log.info("尝试下载文件,taskId: {}, 文件路径: {}, 文件存在: {}", taskId, task.getResultFilePath(), file.exists());
+        if (!file.exists()) {
+            log.error("结果文件不存在,taskId: {}, 文件路径: {}", taskId, task.getResultFilePath());
+            throw new RuntimeException("结果文件不存在");
+        }
+
+        try {
+            response.setContentType("application/zip");
+            response.setHeader("Content-Disposition", "attachment; filename=" + file.getName());
+
+            try (FileInputStream fis = new FileInputStream(file);
+                 OutputStream os = response.getOutputStream()) {
+                byte[] buffer = new byte[1024];
+                int length;
+                while ((length = fis.read(buffer)) != -1) {
+                    os.write(buffer, 0, length);
+                }
+                os.flush();
+            }
+        } catch (IOException e) {
+            log.error("下载任务结果失败", e);
+            throw new RuntimeException("下载失败: " + e.getMessage());
+        }
+    }
+
+
+    /**
+     * 删除文件(如果存在)
+     */
+    private void deleteFileIfExists(String filePath) {
+        if (StringUtils.isNotBlank(filePath)) {
+            File file = new File(filePath);
+            if (file.exists()) {
+                file.delete();
+            }
+        }
+    }
+}

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

@@ -41,17 +41,25 @@ import org.dromara.system.domain.bo.*;
 import org.dromara.system.domain.constant.GameEventConstant;
 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.springframework.context.annotation.Lazy;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.Color;
+import java.awt.Font;
 import java.awt.image.BufferedImage;
 import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Paths;
 import java.util.*;
+import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -81,6 +89,10 @@ public class GameEventServiceImpl implements IGameEventService {
     private IGameEventProjectService gameEventProjectService;
     @Resource
     private IGameEventGroupService gameEventGroupService;
+    @Resource
+    private IGameBibTaskService gameBibTaskService;
+    @Resource
+    private FileUploadConfig fileUploadConfig;
     private static final ExecutorService PDF_GENERATION_EXECUTOR =
         Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2);
     // 常见图片类型的文件头(前几个字节)
@@ -92,6 +104,11 @@ public class GameEventServiceImpl implements IGameEventService {
         "4D4D002A", // TIFF (big endian)
         "424D"      // BMP
     };
+
+    // 预览框尺寸(固定)
+    private static final int previewWidth = 600;
+    private static final int previewHeight = 400;
+
     /**
      * 查询赛事基本信息
      *
@@ -733,9 +750,12 @@ public class GameEventServiceImpl implements IGameEventService {
                         if (finalLogoImageBytes != null && logoX != null && logoY != null) {
                             try {
                                 Image img = Image.getInstance(finalLogoImageBytes);
-                                img.scaleToFit(80, 80);
-                                float logoPositionX = logoX.floatValue();
-                                float logoPositionY = logoY.floatValue();
+                                // 应用缩放参数
+                                Double logoScale = bibParam.getLogoScale() != null ? bibParam.getLogoScale() : 1.0;
+                                img.scaleToFit(80 * logoScale.floatValue(), 80 * logoScale.floatValue());
+                                // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
+                                float logoPositionX = (float) (logoX * pageWidth / 100.0);
+                                float logoPositionY = (float) (pageHeight - (logoY * pageHeight / 100.0));
                                 img.setAbsolutePosition(logoPositionX, logoPositionY);
                                 cb.addImage(img);
 //                                log.debug("成功添加Logo到PDF");
@@ -744,37 +764,121 @@ public class GameEventServiceImpl implements IGameEventService {
                             }
                         }
 
-                        // 添加号码(居中)
-                        float textWidth = baseFont.getWidthPoint(athlete.getAthleteCode(), finalFontSize);
-                        float textHeight = finalFontSize;
-                        float textPositionX = (pageWidth - textWidth) / 2;
-                        float textPositionY = (pageHeight / 2) + (textHeight / 2);
-                        addText(cb, baseFont, finalFontSize, textColor, textPositionX, textPositionY, athlete.getAthleteCode());
+                        // 添加号码(使用前端传递的位置参数)
+                        Double numberScale = bibParam.getNumberScale() != null ? bibParam.getNumberScale() : 1.0;
+                        int scaledFontSize = (int) (finalFontSize * numberScale.floatValue());
+                        float textWidth = baseFont.getWidthPoint(athlete.getAthleteCode(), scaledFontSize);
+                        float textHeight = scaledFontSize;
+
+                        // 使用前端传递的位置参数(百分比转换为像素)
+                        float textPositionX, textPositionY;
+                        if (bibParam.getNumberX() != null && bibParam.getNumberY() != null) {
+                            textPositionX = (pageWidth * bibParam.getNumberX().floatValue() / 100) - (textWidth / 2);
+                            // PDF Y轴从下到上递增,需要翻转Y坐标
+                            textPositionY = pageHeight - (pageHeight * bibParam.getNumberY().floatValue() / 100) + (textHeight / 2);
+                        } else {
+                            // 默认居中
+                            textPositionX = (pageWidth - textWidth) / 2;
+                            textPositionY = (pageHeight / 2) + (textHeight / 2);
+                        }
+                        addText(cb, baseFont, scaledFontSize, textColor, textPositionX, textPositionY, athlete.getAthleteCode());
+
+                        // 添加赛事名称(使用前端传递的位置参数)
+                        String eventNameToShow = eventName;
+                        if (eventNameToShow == null || eventNameToShow.trim().isEmpty()) {
+                            eventNameToShow = "参赛证"; // 默认赛事名称
+                        }
+                        log.debug("同步生成 - 赛事名称检查 - eventName: '{}', 最终显示: '{}'", eventName, eventNameToShow);
 
-                        // 添加赛事名称(在3:2横屏比例下调整位置)
-                        if (eventName != null && !eventName.trim().isEmpty()) {
-                            int eventNameFontSize = Math.min(64, (int) (pageHeight * 0.08)); // 根据页面高度调整字体大小
+                        if (eventNameToShow != null && !eventNameToShow.trim().isEmpty()) {
+                            Double eventScale = bibParam.getEventScale() != null ? bibParam.getEventScale() : 1.0;
+                            int baseEventNameFontSize = Math.min(64, (int) (pageHeight * 0.08));
+                            int eventNameFontSize = (int) (baseEventNameFontSize * eventScale.floatValue());
                             cb.beginText();
                             cb.setFontAndSize(baseFont, eventNameFontSize);
                             cb.setColorFill(BaseColor.BLACK);
-                            float textWidth2 = baseFont.getWidthPoint(eventName, eventNameFontSize);
-                            float textX = (pageWidth - textWidth2) / 2;
-                            float textY = pageHeight - eventNameFontSize - 20; // 增加边距
+                            float textWidth2 = baseFont.getWidthPoint(eventNameToShow, eventNameFontSize);
+
+                            // 使用前端传递的位置参数(百分比转换为像素)
+                            float textX, textY;
+                            if (bibParam.getEventX() != null && bibParam.getEventY() != null) {
+                                textX = (pageWidth * bibParam.getEventX().floatValue() / 100) - (textWidth2 / 2);
+                                // PDF Y轴从下到上递增,需要翻转Y坐标
+                                textY = pageHeight - (pageHeight * bibParam.getEventY().floatValue() / 100) + (eventNameFontSize / 2);
+                            } else {
+                                // 默认位置(顶部居中)
+                                textX = (pageWidth - textWidth2) / 2;
+                                textY = pageHeight - eventNameFontSize - 20;
+                            }
                             cb.setTextMatrix(textX, textY);
-                            cb.showText(eventName);
+                            cb.showText(eventNameToShow);
                             cb.endText();
                         }
 
-                        // 生成二维码(在3:2横屏比例下调整尺寸)
+                        // 生成二维码(使用前端传递的位置和缩放参数
                         if (qRCodeX != null && qRCodeY != null) {
-                            String qrDataStr = getQrDataStr(eventName, groupName, teamNameMap, projectMap, athlete);
-                            int qrSize = Math.min(150, (int) (Math.min(pageWidth, pageHeight) * 0.15)); // 根据页面尺寸调整二维码大小
-                            byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
-                            Image qrImage = Image.getInstance(qrBytes);
-                            float qrX = qRCodeX.floatValue();
-                            float qrY = qRCodeY.floatValue();
-                            qrImage.setAbsolutePosition(qrX, qrY);
-                            cb.addImage(qrImage);
+                            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 qrSize = (int) (baseQrSize * barcodeScale.floatValue());
+
+                                // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
+                                float qrX = (float) (qRCodeX * pageWidth / 100.0);
+                                float qrY = (float) (pageHeight - (qRCodeY * pageHeight / 100.0));
+
+                                // 检查坐标是否在页面范围内
+                                if (qrX >= 0 && qrY >= 0 && qrX + qrSize <= pageWidth && qrY + qrSize <= pageHeight) {
+                                    log.debug("同步生成二维码 - 位置: ({}, {}), 大小: {}, 数据: {}",
+                                        qrX, qrY, qrSize, qrDataStr);
+
+                                    byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                    Image qrImage = Image.getInstance(qrBytes);
+                                    qrImage.setAbsolutePosition(qrX, qrY);
+                                    cb.addImage(qrImage);
+                                } else {
+                                    log.warn("同步生成 - 二维码位置超出页面范围 - 位置: ({}, {}), 大小: {}, 页面尺寸: {}x{}",
+                                        qrX, qrY, qrSize, pageWidth, pageHeight);
+                                    // 使用默认位置(右下角)
+                                    float defaultX = pageWidth - qrSize - 20;
+                                    float defaultY = 20;
+                                    log.debug("同步生成 - 使用默认二维码位置: ({}, {})", defaultX, defaultY);
+
+                                    byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                    Image qrImage = Image.getInstance(qrBytes);
+                                    qrImage.setAbsolutePosition(defaultX, defaultY);
+                                    cb.addImage(qrImage);
+                                }
+
+                                log.debug("二维码添加成功 - 运动员: {}", athlete.getName());
+                            } catch (Exception e) {
+                                log.error("生成二维码失败 - 运动员: {}, 错误: {}", athlete.getName(), e.getMessage(), e);
+                            }
+                        } else {
+                            log.warn("同步生成 - 二维码位置参数为空 - 运动员: {}, qRCodeX: {}, qRCodeY: {}",
+                                athlete.getName(), qRCodeX, qRCodeY);
+
+                            // 即使位置参数为空,也生成二维码在默认位置
+                            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 qrSize = (int) (baseQrSize * barcodeScale.floatValue());
+
+                                // 使用默认位置(右下角)
+                                float defaultX = pageWidth - qrSize - 20;
+                                float defaultY = 20;
+                                log.debug("同步生成 - 使用默认二维码位置: ({}, {})", defaultX, defaultY);
+
+                                byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                Image qrImage = Image.getInstance(qrBytes);
+                                qrImage.setAbsolutePosition(defaultX, defaultY);
+                                cb.addImage(qrImage);
+
+                                log.debug("同步生成 - 默认位置二维码添加成功 - 运动员: {}", athlete.getName());
+                            } catch (Exception e) {
+                                log.error("同步生成 - 生成默认位置二维码失败 - 运动员: {}, 错误: {}", athlete.getName(), e.getMessage(), e);
+                            }
                         }
 
                         document.close();
@@ -908,6 +1012,10 @@ public class GameEventServiceImpl implements IGameEventService {
         // 方式1:使用系统字体(Windows)
         // return BaseFont.createFont("C:/Windows/Fonts/simhei.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
         String filePath;
+        // 处理null值,使用默认字体
+        if (fontName == null) {
+            fontName = "yahei";
+        }
         switch (fontName) {
             case "simhei" -> filePath = "fonts/simhei.ttf";
             case "simsun" -> filePath = "fonts/simsun.ttf";
@@ -1144,4 +1252,619 @@ public class GameEventServiceImpl implements IGameEventService {
         // 如果组别时间不存在,返回项目时间
         return earliestGroupTime != null ? earliestGroupTime : project.getStartTime();
     }
+
+    /**
+     * 异步生成参赛证
+     *
+     * @param taskId        任务ID
+     * @param eventId       赛事ID
+     * @param bgImagePath   背景图片路径
+     * @param logoImagePath Logo图片路径
+     * @param bibParam      参赛证参数
+     */
+    @Override
+    @Async
+    public void generateNumberBibAsync(Long taskId, Long eventId, String bgImagePath,
+                                     String logoImagePath, GenerateBibBo bibParam) {
+        try {
+            // 更新任务状态为运行中
+            gameBibTaskService.updateTaskStatus(taskId, "0", null);
+
+            // 查询运动员数据
+            GameAthleteBo gameAthleteBo = new GameAthleteBo();
+            gameAthleteBo.setEventId(eventId);
+            List<GameAthleteVo> athleteVoList = gameAthleteService.queryList(gameAthleteBo);
+
+            if (CollectionUtil.isEmpty(athleteVoList)) {
+                gameBibTaskService.updateTaskStatus(taskId, "3", "当前赛事没有队员数据");
+                return;
+            }
+
+            // 更新总数量
+            gameBibTaskService.updateTaskProgress(taskId, 0, 0);
+
+            // 生成参赛证文件
+            String resultFilePath = generateBibFilesAsync(taskId, eventId, bgImagePath, logoImagePath, bibParam, athleteVoList);
+
+            // 完成任务
+            gameBibTaskService.completeTask(taskId, resultFilePath);
+
+        } catch (Exception e) {
+            log.error("参赛证生成失败,taskId: {}", taskId, e);
+            gameBibTaskService.updateTaskStatus(taskId, "3", e.getMessage());
+        }
+    }
+
+    /**
+     * 异步生成参赛证文件
+     */
+    private String generateBibFilesAsync(Long taskId, Long eventId, String bgImagePath, String logoImagePath,
+                                       GenerateBibBo bibParam, List<GameAthleteVo> athleteVoList) {
+        try {
+            // 创建结果目录
+            String resultDir = fileUploadConfig.getBibResultPath() + taskId + File.separator;
+            File resultDirFile = new File(resultDir);
+            if (!resultDirFile.exists()) {
+                boolean created = resultDirFile.mkdirs();
+                if (!created) {
+                    throw new RuntimeException("无法创建结果目录: " + resultDirFile.getAbsolutePath());
+                }
+            }
+
+            // 图片路径已经传入,直接使用
+
+            // 查询队伍名称缓存
+            Set<Long> teamIds = athleteVoList.stream()
+                .map(GameAthleteVo::getTeamId)
+                .collect(Collectors.toSet());
+            Map<Long, String> teamNameMap = gameTeamService.queryTeamIdAndName(teamIds);
+
+            // 查询赛事所有项目缓存
+            Map<Long, String> projectMap = gameEventProjectService.queryListByEventId(eventId)
+                .stream().collect(Collectors.toMap(GameEventProjectVo::getProjectId, GameEventProjectVo::getProjectName));
+
+            // 查询赛事组别
+            GameEventGroup gameEventGroup = gameEventGroupService.queryByEventId(eventId);
+
+            // 生成PDF文件
+            List<PdfEntry> pdfEntries = generatePdfEntries(bgImagePath, logoImagePath, bibParam, athleteVoList, teamNameMap, projectMap, gameEventGroup.getGroupName());
+
+            // 创建ZIP文件
+            String zipFilePath = resultDir + "athlete_bibs.zip";
+            try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFilePath))) {
+                for (PdfEntry entry : pdfEntries) {
+                    zos.putNextEntry(new ZipEntry(entry.getFileName()));
+                    zos.write(entry.getPdfBytes());
+                    zos.closeEntry();
+                }
+                zos.flush();
+            }
+
+            return zipFilePath;
+
+        } catch (Exception e) {
+            log.error("生成参赛证文件失败,taskId: {}", taskId, e);
+            throw new RuntimeException("生成参赛证文件失败: " + e.getMessage());
+        }
+    }
+
+
+    /**
+     * 生成PDF条目列表
+     */
+    private List<PdfEntry> generatePdfEntries(String bgImagePath, String logoImagePath,
+                                            GenerateBibBo bibParam, List<GameAthleteVo> athleteVoList,
+                                            Map<Long, String> teamNameMap, Map<Long, String> projectMap,
+                                            String groupName) {
+        try {
+            // 从文件路径读取图片
+            byte[] backgroundImageBytes = Files.readAllBytes(Paths.get(bgImagePath));
+            byte[] logoImageBytes = logoImagePath != null ? Files.readAllBytes(Paths.get(logoImagePath)) : null;
+
+            // 读取背景图
+            Image bgImageObj = Image.getInstance(backgroundImageBytes);
+            float pageWidth = bgImageObj.getWidth();
+            float pageHeight = bgImageObj.getHeight();
+
+            // 设置字体和颜色,提供默认值
+            String fontName = bibParam.getFontName() != null ? bibParam.getFontName() : "yahei";
+            BaseFont baseFont = getChineseFont(fontName);
+            BaseColor textColor = parseColor(bibParam.getFontColor());
+            Integer fontSize = bibParam.getFontSize() != null ? bibParam.getFontSize() : 14;
+
+            // 并行生成所有PDF
+            List<CompletableFuture<PdfEntry>> pdfFutures = athleteVoList.parallelStream()
+                .map(athlete -> CompletableFuture.supplyAsync(() -> {
+                    try {
+                        ByteArrayOutputStream pdfStream = new ByteArrayOutputStream();
+                        Document document = new Document(new Rectangle(pageWidth, pageHeight));
+                        PdfWriter writer = PdfWriter.getInstance(document, pdfStream);
+                        document.open();
+                        PdfContentByte cb = writer.getDirectContent();
+
+                        // 添加背景图
+                        Image bg = Image.getInstance(backgroundImageBytes);
+                        bg.setAbsolutePosition(0, 0);
+                        document.add(bg);
+
+                        // 添加Logo(如果存在)
+                        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());
+                                // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
+                                float logoPositionX = (float) (bibParam.getLogoX() * pageWidth / 100.0);
+                                float logoPositionY = (float) (pageHeight - (bibParam.getLogoY() * pageHeight / 100.0));
+                                img.setAbsolutePosition(logoPositionX, logoPositionY);
+                                cb.addImage(img);
+                            } catch (Exception e) {
+                                log.warn("添加Logo到PDF失败,跳过Logo处理: {}", e.getMessage());
+                            }
+                        }
+
+                        // 添加号码(使用前端传递的位置参数)
+                        Double numberScale = bibParam.getNumberScale() != null ? bibParam.getNumberScale() : 1.0;
+                        int scaledFontSize = (int) (fontSize * numberScale.floatValue());
+                        float textWidth = baseFont.getWidthPoint(athlete.getAthleteCode(), scaledFontSize);
+                        float textHeight = scaledFontSize;
+
+                        // 使用前端传递的位置参数(百分比转换为像素)
+                        float textPositionX, textPositionY;
+                        if (bibParam.getNumberX() != null && bibParam.getNumberY() != null) {
+                            textPositionX = (pageWidth * bibParam.getNumberX().floatValue() / 100) - (textWidth / 2);
+                            // PDF Y轴从下到上递增,需要翻转Y坐标
+                            textPositionY = pageHeight - (pageHeight * bibParam.getNumberY().floatValue() / 100) + (textHeight / 2);
+                        } else {
+                            // 默认居中
+                            textPositionX = (pageWidth - textWidth) / 2;
+                            textPositionY = (pageHeight / 2) + (textHeight / 2);
+                        }
+                        addText(cb, baseFont, scaledFontSize, textColor, textPositionX, textPositionY, athlete.getAthleteCode());
+
+                        // 添加赛事名称(使用前端传递的位置参数)
+                        String eventNameToShow = bibParam.getEventName();
+                        if (eventNameToShow == null || eventNameToShow.trim().isEmpty()) {
+                            eventNameToShow = "参赛证"; // 默认赛事名称
+                        }
+                        log.debug("赛事名称检查 - eventName: '{}', 最终显示: '{}'", bibParam.getEventName(), eventNameToShow);
+
+                        if (eventNameToShow != null && !eventNameToShow.trim().isEmpty()) {
+                            Double eventScale = bibParam.getEventScale() != null ? bibParam.getEventScale() : 1.0;
+                            int baseEventNameFontSize = Math.min(64, (int) (pageHeight * 0.08));
+                            int eventNameFontSize = (int) (baseEventNameFontSize * eventScale.floatValue());
+                            cb.beginText();
+                            cb.setFontAndSize(baseFont, eventNameFontSize);
+                            cb.setColorFill(BaseColor.BLACK);
+                            float textWidth2 = baseFont.getWidthPoint(eventNameToShow, eventNameFontSize);
+
+                            // 使用前端传递的位置参数(百分比转换为像素)
+                            float textX, textY;
+                            if (bibParam.getEventX() != null && bibParam.getEventY() != null) {
+                                textX = (pageWidth * bibParam.getEventX().floatValue() / 100) - (textWidth2 / 2);
+                                // PDF Y轴从下到上递增,需要翻转Y坐标
+                                textY = pageHeight - (pageHeight * bibParam.getEventY().floatValue() / 100) + (eventNameFontSize / 2);
+                            } else {
+                                // 默认位置(顶部居中)
+                                textX = (pageWidth - textWidth2) / 2;
+                                textY = pageHeight - eventNameFontSize - 20;
+                            }
+                            cb.setTextMatrix(textX, textY);
+                            cb.showText(eventNameToShow);
+                            cb.endText();
+                        }
+
+                        // 生成二维码(使用前端传递的位置和缩放参数)
+                        log.debug("二维码参数检查 - qRCodeX: {}, qRCodeY: {}, 是否为空: {}",
+                            bibParam.getQRCodeX(), bibParam.getQRCodeY(),
+                            bibParam.getQRCodeX() == null || bibParam.getQRCodeY() == null);
+                        if (bibParam.getQRCodeX() != null && bibParam.getQRCodeY() != null) {
+                            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 qrSize = (int) (baseQrSize * barcodeScale.floatValue());
+
+                                // 将百分比坐标转换为PDF像素坐标,并翻转Y轴
+                                float qrX = (float) (bibParam.getQRCodeX() * pageWidth / 100.0);
+                                float qrY = (float) (pageHeight - (bibParam.getQRCodeY() * pageHeight / 100.0));
+
+                                // 检查坐标是否在页面范围内
+                                if (qrX >= 0 && qrY >= 0 && qrX + qrSize <= pageWidth && qrY + qrSize <= pageHeight) {
+                                    log.debug("生成二维码 - 位置: ({}, {}), 大小: {}, 数据: {}",
+                                        qrX, qrY, qrSize, qrDataStr);
+
+                                    byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                    Image qrImage = Image.getInstance(qrBytes);
+                                    // 二维码位置不需要翻转Y轴,因为它是图片元素
+                                    qrImage.setAbsolutePosition(qrX, qrY);
+                                    cb.addImage(qrImage);
+                                } else {
+                                    log.warn("二维码位置超出页面范围 - 位置: ({}, {}), 大小: {}, 页面尺寸: {}x{}",
+                                        qrX, qrY, qrSize, pageWidth, pageHeight);
+                                    // 使用默认位置(右下角)
+                                    float defaultX = pageWidth - qrSize - 20;
+                                    float defaultY = 20;
+                                    log.debug("使用默认二维码位置: ({}, {})", defaultX, defaultY);
+
+                                    byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                    Image qrImage = Image.getInstance(qrBytes);
+                                    qrImage.setAbsolutePosition(defaultX, defaultY);
+                                    cb.addImage(qrImage);
+                                }
+
+                                log.debug("二维码添加成功 - 运动员: {}", athlete.getName());
+                            } catch (Exception e) {
+                                log.error("生成二维码失败 - 运动员: {}, 错误: {}", athlete.getName(), e.getMessage(), e);
+                            }
+                        } else {
+                            log.warn("二维码位置参数为空 - 运动员: {}, qRCodeX: {}, qRCodeY: {}",
+                                athlete.getName(), bibParam.getQRCodeX(), bibParam.getQRCodeY());
+
+                            // 即使位置参数为空,也生成二维码在默认位置
+                            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 qrSize = (int) (baseQrSize * barcodeScale.floatValue());
+
+                                // 使用默认位置(右下角)
+                                float defaultX = pageWidth - qrSize - 20;
+                                float defaultY = 20;
+                                log.debug("使用默认二维码位置: ({}, {})", defaultX, defaultY);
+
+                                byte[] qrBytes = generateQRCode(qrDataStr, qrSize, qrSize);
+                                Image qrImage = Image.getInstance(qrBytes);
+                                qrImage.setAbsolutePosition(defaultX, defaultY);
+                                cb.addImage(qrImage);
+
+                                log.debug("默认位置二维码添加成功 - 运动员: {}", athlete.getName());
+                            } catch (Exception e) {
+                                log.error("生成默认位置二维码失败 - 运动员: {}, 错误: {}", athlete.getName(), e.getMessage(), e);
+                            }
+                        }
+
+                        document.close();
+                        byte[] pdfBytes = pdfStream.toByteArray();
+
+                        // 构造安全的文件名
+                        String safeName = athlete.getName().replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5]", "_");
+                        String fileName = String.format("%s_%s.pdf", athlete.getAthleteCode(), safeName);
+                        return new PdfEntry(fileName, pdfBytes);
+
+                    } catch (Exception e) {
+                        log.error("生成PDF失败: {} - {}", athlete.getName(), e.getMessage());
+                        throw new RuntimeException("生成PDF失败: " + athlete.getName(), e);
+                    }
+                }, PDF_GENERATION_EXECUTOR))
+                .toList();
+
+            // 等待所有任务完成
+            return pdfFutures.stream()
+                .map(CompletableFuture::join)
+                .toList();
+
+        } catch (Exception e) {
+            log.error("生成PDF条目失败", e);
+            throw new RuntimeException("生成PDF条目失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 基于画布模版生成参赛证(异步)
+     */
+    @Override
+    @Async
+    public void generateBibFromTemplateAsync(Long taskId, Long eventId, byte[] templateImage, GenerateBibBo bibParam) {
+        try {
+            // 更新任务状态为运行中
+            gameBibTaskService.updateTaskStatus(taskId, "0", null);
+
+            // 查询运动员数据
+            GameAthleteBo gameAthleteBo = new GameAthleteBo();
+            gameAthleteBo.setEventId(eventId);
+            List<GameAthleteVo> athleteVoList = gameAthleteService.queryList(gameAthleteBo);
+
+            if (CollectionUtil.isEmpty(athleteVoList)) {
+                gameBibTaskService.updateTaskStatus(taskId, "3", "当前赛事没有队员数据");
+                return;
+            }
+
+            // 更新总数量
+            gameBibTaskService.updateTaskProgress(taskId, 0, 0);
+
+            // 生成参赛证文件
+            String resultFilePath = generateBibFromTemplateFiles(taskId, eventId, templateImage, bibParam, athleteVoList);
+
+            // 完成任务
+            gameBibTaskService.completeTask(taskId, resultFilePath);
+
+        } catch (Exception e) {
+            log.error("基于模版生成参赛证失败,taskId: {}", taskId, e);
+            gameBibTaskService.updateTaskStatus(taskId, "3", e.getMessage());
+        }
+    }
+
+    /**
+     * 基于画布模版生成参赛证文件
+     */
+    private String generateBibFromTemplateFiles(Long taskId, Long eventId, byte[] templateImage,
+                                              GenerateBibBo bibParam, List<GameAthleteVo> athleteVoList) {
+        try {
+            // 创建结果目录
+            String resultDir = fileUploadConfig.getBibResultPath() + taskId + File.separator;
+            File resultDirFile = new File(resultDir);
+            if (!resultDirFile.exists()) {
+                boolean created = resultDirFile.mkdirs();
+                if (!created) {
+                    throw new RuntimeException("无法创建结果目录: " + resultDirFile.getAbsolutePath());
+                }
+            }
+
+            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);
+
+                } catch (Exception e) {
+                    log.error("生成运动员参赛证失败: {}", athlete.getName(), e);
+                }
+            }
+
+            // 创建ZIP文件
+            String zipFilePath = resultDir + "参赛证_" + taskId + ".zip";
+            createZipFile(resultDir, zipFilePath);
+            log.info("ZIP文件创建完成: {}", zipFilePath);
+
+            return zipFilePath;
+
+        } catch (Exception e) {
+            log.error("基于模版生成参赛证文件失败", e);
+            throw new RuntimeException("生成参赛证文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 基于模版生成单个参赛证图片
+     */
+    private byte[] generateSingleBibFromTemplate(byte[] templateImage, GameAthleteVo athlete, GenerateBibBo bibParam) {
+        try {
+            // 使用BufferedImage处理模版图片
+            BufferedImage template = ImageIO.read(new ByteArrayInputStream(templateImage));
+            Graphics2D g2d = template.createGraphics();
+
+            // 设置抗锯齿
+            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+            // 绘制号码
+            drawNumberOnTemplate(g2d, athlete.getAthleteCode().toString(), bibParam, template.getWidth(), template.getHeight());
+
+            // 绘制二维码
+            drawQRCodeOnTemplate(g2d, athlete, bibParam, template.getWidth(), template.getHeight());
+
+            g2d.dispose();
+
+            // 转换为字节数组
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            ImageIO.write(template, "PNG", baos);
+            return baos.toByteArray();
+
+        } catch (Exception e) {
+            log.error("基于模版生成单个参赛证失败", e);
+            throw new RuntimeException("生成参赛证失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 在模版上绘制号码
+     */
+    private void drawNumberOnTemplate(Graphics2D g2d, String number, GenerateBibBo bibParam, int canvasWidth, int canvasHeight) {
+        try {
+            // 计算号码位置(基于百分比)
+            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);
+            Font font = new Font(bibParam.getFontName(), Font.BOLD, fontSize);
+            g2d.setFont(font);
+
+            // 设置颜色
+            Color color = new Color(bibParam.getFontColor());
+            g2d.setColor(color);
+
+            // 绘制号码
+            FontMetrics fm = g2d.getFontMetrics();
+            int textWidth = fm.stringWidth(number);
+            int textHeight = fm.getHeight();
+
+            // 居中绘制
+            g2d.drawString(number, x - textWidth / 2, y + textHeight / 4);
+            log.info("号码绘制完成 - 位置: ({}, {}), 基础字体: {}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 最终字体: {}", x, y, baseFontSize, previewRelativeFontSize, actualRelativeFontSize, fontScale, fontSize);
+
+        } catch (Exception e) {
+            log.error("绘制号码失败", e);
+        }
+    }
+
+    /**
+     * 在模版上绘制二维码
+     */
+    private void drawQRCodeOnTemplate(Graphics2D g2d, GameAthleteVo athlete, GenerateBibBo bibParam, int canvasWidth, int canvasHeight) {
+        try {
+            // 生成二维码数据
+            String qrData = generateQRCodeData(athlete);
+            log.info("生成二维码数据: {}", qrData);
+
+            // 生成二维码图片
+            byte[] qrCodeBytes = generateQRCode(qrData, 100, 100);
+            BufferedImage qrImage = ImageIO.read(new ByteArrayInputStream(qrCodeBytes));
+
+            // 计算二维码位置(从百分比坐标转换为实际图片坐标)
+            Double qrX = bibParam.getQRCodeX();
+            Double qrY = bibParam.getQRCodeY();
+
+            int x, y;
+            if (qrX != null && qrY != null) {
+                // 将百分比坐标转换为实际图片像素坐标
+                x = (int) (qrX * canvasWidth / 100.0);
+                y = (int) (qrY * canvasHeight / 100.0);
+                log.info("二维码坐标转换 - 百分比坐标: ({}, {}), 实际图片坐标: ({}, {})",
+                    qrX, qrY, x, y);
+            } else {
+                // 使用默认位置(右下角)
+                x = (int) (88.33 * canvasWidth / 100.0);  // 88.33%
+                y = (int) (67.5 * canvasHeight / 100.0);  // 67.5%
+                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);
+
+            // 边界检查,确保二维码不会超出图片范围
+            if (x < 0) x = 0;
+            if (y < 0) y = 0;
+            if (x + scaledWidth > canvasWidth) x = canvasWidth - scaledWidth;
+            if (y + scaledHeight > canvasHeight) y = canvasHeight - scaledHeight;
+
+            // 绘制二维码
+            g2d.drawImage(qrImage, x, y, scaledWidth, scaledHeight, null);
+            log.info("二维码绘制完成 - 位置: ({}, {}), 尺寸: {}x{}, 预览相对比例: {}, 实际相对大小: {}, 预览缩放: {}, 用户缩放: {}, 最终缩放: {}, 画布尺寸: {}x{}",
+                x, y, scaledWidth, scaledHeight, previewRelativeSize, actualRelativeSize, previewScale, barcodeScale, finalScale, canvasWidth, canvasHeight);
+
+        } catch (Exception e) {
+            log.error("绘制二维码失败", e);
+        }
+    }
+
+    /**
+     * 生成二维码数据
+     */
+    private String generateQRCodeData(GameAthleteVo athlete) {
+        return String.format("运动员编号: %s, 姓名: %s, 队伍: %s",
+            athlete.getAthleteCode(),
+            athlete.getName(),
+            athlete.getTeamName() != null ? athlete.getTeamName() : "未知队伍");
+    }
+
+    /**
+     * 创建ZIP文件
+     */
+    private void createZipFile(String sourceDir, String zipFilePath) {
+        try (FileOutputStream fos = new FileOutputStream(zipFilePath);
+             ZipOutputStream zos = new ZipOutputStream(fos)) {
+
+            File sourceFile = new File(sourceDir);
+            log.info("开始创建ZIP文件 - 源目录: {}, ZIP文件: {}", sourceDir, zipFilePath);
+
+            if (!sourceFile.exists()) {
+                throw new RuntimeException("源目录不存在: " + sourceDir);
+            }
+
+            File[] files = sourceFile.listFiles();
+            if (files == null || files.length == 0) {
+                log.warn("源目录为空: {}", sourceDir);
+                return;
+            }
+
+            log.info("找到 {} 个文件需要打包", files.length);
+
+            // 只添加PNG文件,不包含ZIP文件本身
+            for (File file : files) {
+                if (file.isFile() && file.getName().toLowerCase().endsWith(".png")) {
+                    try (FileInputStream fis = new FileInputStream(file)) {
+                        ZipEntry zipEntry = new ZipEntry(file.getName());
+                        zos.putNextEntry(zipEntry);
+
+                        byte[] buffer = new byte[1024];
+                        int length;
+                        while ((length = fis.read(buffer)) > 0) {
+                            zos.write(buffer, 0, length);
+                        }
+
+                        zos.closeEntry();
+                        log.debug("添加文件到ZIP: {}", file.getName());
+                    }
+                }
+            }
+
+            log.info("ZIP文件创建成功: {}", zipFilePath);
+
+        } catch (Exception e) {
+            log.error("创建ZIP文件失败", e);
+            throw new RuntimeException("创建ZIP文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 递归添加文件到ZIP
+     */
+    private void addFileToZip(File file, String fileName, ZipOutputStream zos) throws IOException {
+        if (file.isDirectory()) {
+            File[] files = file.listFiles();
+            if (files != null) {
+                for (File subFile : files) {
+                    addFileToZip(subFile, fileName + File.separator + subFile.getName(), zos);
+                }
+            }
+        } else {
+            try (FileInputStream fis = new FileInputStream(file)) {
+                ZipEntry zipEntry = new ZipEntry(fileName);
+                zos.putNextEntry(zipEntry);
+
+                byte[] buffer = new byte[1024];
+                int length;
+                while ((length = fis.read(buffer)) > 0) {
+                    zos.write(buffer, 0, length);
+                }
+
+                zos.closeEntry();
+            }
+        }
+    }
+
 }

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

@@ -271,9 +271,8 @@ public class GameTeamServiceImpl implements IGameTeamService {
             Long count = baseMapper.selectCount(
                 Wrappers.lambdaQuery(GameTeam.class)
                     .eq(GameTeam::getTeamCode, entity.getTeamCode())
-                    .ne(GameTeam::getTeamId, entity.getTeamId())
+                    .ne(entity.getTeamId() != null,GameTeam::getTeamId, entity.getTeamId())
             );
-            //排除自己
             if (count > 0) {
                 throw new ServiceException(entity.getTeamName()+"的编号已存在!");
             }

+ 52 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/utils/EventNameUtils.java

@@ -0,0 +1,52 @@
+package org.dromara.system.utils;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.dromara.system.domain.GameEvent;
+import org.dromara.system.domain.vo.GameEventVo;
+import org.dromara.system.mapper.GameEventMapper;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 赛事名称工具类
+ *
+ * @author zlt
+ * @date 2025-01-27
+ */
+@Component
+@RequiredArgsConstructor
+public class EventNameUtils {
+
+    private final GameEventMapper gameEventMapper;
+
+    /**
+     * 根据赛事ID集合获取赛事名称映射
+     *
+     * @param eventIds 赛事ID集合
+     * @return 赛事ID到赛事名称的映射
+     */
+    public Map<Long, String> getEventNameMap(Set<Long> eventIds) {
+        if (eventIds == null || eventIds.isEmpty()) {
+            return Map.of();
+        }
+        
+        List<GameEventVo> events = gameEventMapper.selectVoList(
+            Wrappers.lambdaQuery(GameEvent.class)
+                .in(GameEvent::getEventId, eventIds)
+                .select(GameEvent::getEventId, GameEvent::getEventName)
+        );
+        
+        return events.stream()
+            .collect(Collectors.toMap(
+                GameEventVo::getEventId,
+                GameEventVo::getEventName,
+                (existing, replacement) -> existing
+            ));
+    }
+}