Browse Source

feat(enroll): 实现报名表导入校验功能- 新增导入报名表并验证数据接口,支持Excel文件校验- 添加EnrollImportResult、EnrollValidationResult等校验结果实体类
- 完善Excel解析逻辑,增加号码列支持并调整列索引- 集成性别字典转换工具,支持多种性别格式输入
- 实现运动员号码校验及重复性检查
- 添加每人限报项目数和项目限报人数的校验逻辑
- 优化队伍号码段生成逻辑,支持自定义字符号码段
- 增加详细的错误信息记录和返回机制

zhou 2 weeks ago
parent
commit
347ec55934

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

@@ -13,6 +13,7 @@ import org.apache.ibatis.javassist.bytecode.AnnotationsAttribute;
 import org.dromara.common.core.domain.R;
 import org.dromara.common.excel.utils.ExcelUtil;
 import org.dromara.common.idempotent.annotation.RepeatSubmit;
+import org.dromara.system.domain.EnrollImportResult;
 import org.dromara.system.domain.vo.EnrollProjectVo;
 import org.dromara.system.domain.vo.GameAthleteVo;
 import org.dromara.system.service.IEnrollService;
@@ -61,4 +62,33 @@ public class EnrollController {
         Boolean result = enrollService.importDataForPoi(file, eventId);
         return R.ok(result ? "导入成功" : "导入失败");
     }
+
+    /**
+     * 导入报名表并验证数据
+     */
+    @PostMapping("/importDataWithValidation/{eventId}")
+    public R<EnrollImportResult> importDataWithValidation(
+        @RequestParam("file") MultipartFile file,
+        @PathVariable Long eventId) {
+        try {
+            // 添加文件校验
+            if (file == null || file.isEmpty()) {
+                return R.fail("请选择要导入的文件");
+            }
+            
+            String fileName = file.getOriginalFilename();
+            if (fileName == null || !fileName.toLowerCase().endsWith(".xlsx")) {
+                return R.fail("请选择Excel文件(.xlsx格式)");
+            }
+            
+            EnrollImportResult result = enrollService.importDataWithValidation(file, eventId);
+            return R.ok(result);
+        } catch (IllegalArgumentException e) {
+            log.warn("参数错误:{}", e.getMessage());
+            return R.fail("参数错误:" + e.getMessage());
+        } catch (Exception e) {
+            log.error("导入报名表失败,赛事ID: {}", eventId, e);
+            return R.fail("导入失败:" + e.getMessage());
+        }
+    }
 }

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

@@ -0,0 +1,13 @@
+package org.dromara.system.domain;
+
+import lombok.Data;
+
+@Data
+public class EnrollImportResult {
+    private boolean success;                    // 是否成功
+    private EnrollValidationResult validationResult;  // 校验结果
+    private int totalCount;                     // 总数据条数
+    private int validCount;                     // 有效数据条数
+    private int errorCount;                     // 错误数据条数
+    private String message;                     // 结果消息
+}

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

@@ -0,0 +1,14 @@
+package org.dromara.system.domain;
+
+import lombok.Data;
+
+// 校验错误信息
+@Data
+public class EnrollValidationError {
+    private int rowIndex;           // 行号
+    private String athleteName;     // 运动员姓名
+    private String teamName;        // 队伍名称
+    private String errorType;       // 错误类型:PERSON_LIMIT_EXCEEDED, PROJECT_LIMIT_EXCEEDED
+    private String errorMessage;    // 错误信息
+    private String projectName;     // 相关项目名称(如果是项目限制错误)
+}

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

@@ -0,0 +1,14 @@
+package org.dromara.system.domain;
+
+import lombok.Data;
+import org.dromara.system.domain.vo.EnrollProjectVo;
+
+import java.util.List;
+
+// 校验结果VO
+@Data
+public class EnrollValidationResult {
+    private boolean valid;
+    private List<EnrollValidationError> errors;
+    private List<EnrollProjectVo> validData;
+}

+ 7 - 1
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/domain/vo/EnrollProjectVo.java

@@ -17,6 +17,12 @@ import java.util.Map;
 @AutoMapper(target = GameAthlete.class)
 public class EnrollProjectVo {
 
+    /**
+     * 号码
+     */
+    @ExcelProperty(value = "号码")
+    private String athleteCode;
+
     /**
      * 姓名
      */
@@ -50,7 +56,7 @@ public class EnrollProjectVo {
     /**
      * 联系方式
      */
-    @ExcelProperty(value = "联系方式")
+    @ExcelProperty(value = "电话")
     private String phone;
 
     private Map<String, Boolean> ProjectSelections;

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

@@ -2,9 +2,14 @@ package org.dromara.system.service;
 
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+import org.dromara.system.domain.EnrollImportResult;
+import org.dromara.system.domain.EnrollValidationResult;
 import org.dromara.system.domain.bo.EnrollBo;
+import org.dromara.system.domain.vo.EnrollProjectVo;
 import org.springframework.web.multipart.MultipartFile;
 
+import java.util.List;
+
 public interface IEnrollService {
 
     /**
@@ -23,4 +28,20 @@ public interface IEnrollService {
     Boolean importDataForPoi(MultipartFile file, Long eventId);
 
     Boolean enroll(EnrollBo enrollBo);
+
+    /**
+     * 校验导入数据
+     * @param enrollList 导入的报名数据
+     * @param eventId 赛事ID
+     * @return 校验结果
+     */
+    EnrollValidationResult validateEnrollData(List<EnrollProjectVo> enrollList, Long eventId);
+
+    /**
+     * 导入报名表(带校验)
+     * @param file 文件
+     * @param eventId 赛事ID
+     * @return 导入结果
+     */
+    EnrollImportResult importDataWithValidation(MultipartFile file, Long eventId);
 }

+ 647 - 128
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/IEnrollServiceImpl.java

@@ -14,16 +14,14 @@ import org.apache.poi.ss.util.CellRangeAddress;
 import org.apache.poi.xssf.usermodel.XSSFSheet;
 import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.dromara.common.core.utils.ObjectUtils;
-import org.dromara.system.domain.GameTeam;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.system.domain.*;
 import org.dromara.system.domain.bo.EnrollBo;
 import org.dromara.system.domain.bo.GameAthleteBo;
 import org.dromara.system.domain.bo.GameTeamBo;
-import org.dromara.system.domain.vo.EnrollProjectVo;
-import org.dromara.system.domain.vo.GameTeamVo;
-import org.dromara.system.service.IEnrollService;
-import org.dromara.system.service.IGameAthleteService;
-import org.dromara.system.service.IGameEventProjectService;
-import org.dromara.system.service.IGameTeamService;
+import org.dromara.system.domain.vo.*;
+import org.dromara.system.service.*;
+import org.dromara.system.utils.GenderDictUtils;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.multipart.MultipartFile;
@@ -44,6 +42,7 @@ public class IEnrollServiceImpl implements IEnrollService {
     private final IGameEventProjectService gameEventProjectService;
     private final IGameTeamService gameTeamService;
     private final IGameAthleteService gameAthleteService;
+    private final IGameEventService gameEventService;
 
     /**
      * 使用poi生成报名表模板
@@ -68,7 +67,7 @@ public class IEnrollServiceImpl implements IEnrollService {
 
             if (!projectMap.isEmpty()) {
                 // 3. 渲染分类
-                int currentColumnIndex = 6;
+                int currentColumnIndex = 7;
                 Row row = sheet.getRow(1);
                 if (row == null) {
                     row = sheet.createRow(1);
@@ -115,7 +114,7 @@ public class IEnrollServiceImpl implements IEnrollService {
                 }
 
                 // 4. 渲染项目(在第4行,索引为3)
-                currentColumnIndex = 6;
+                currentColumnIndex = 7;
                 Row projectRow = sheet.getRow(2);
                 if (projectRow == null) {
                     projectRow = sheet.createRow(2);
@@ -286,8 +285,8 @@ public class IEnrollServiceImpl implements IEnrollService {
                 int lastCellIndex = findValidLastColumnIndex(headerRow);
                 List<String> projectNames = new ArrayList<>();
 
-                // 从第6列(F列,索引5)开始收集项目名称
-                for (int i = 6; i < lastCellIndex; i++) {
+                // 从第7列(G列,索引6)开始收集项目名称(因为新增了号码列)
+                for (int i = 7; i < lastCellIndex; i++) {
                     Cell cell = headerRow.getCell(i);
                     if (cell != null && cell.getStringCellValue() != null && !cell.getStringCellValue().trim().isEmpty()) {
                         projectNames.add(cell.getStringCellValue().trim());
@@ -307,46 +306,54 @@ public class IEnrollServiceImpl implements IEnrollService {
                     EnrollProjectVo enroll = new EnrollProjectVo();
                     Map<String, Boolean> selections = new LinkedHashMap<>(); // 保持顺序
 
-                    // A列:姓名
-                    Cell nameCell = row.getCell(0);
+                    // A列:号码
+                    Cell codeCell = row.getCell(0);
+                    if (codeCell != null) {
+                        enroll.setAthleteCode(getCellValueAsString(codeCell));
+                    }
+
+                    // B列:姓名
+                    Cell nameCell = row.getCell(1);
                     if (nameCell != null) {
                         enroll.setName(getCellValueAsString(nameCell));
                     }
 
-                    // B列:性别
-                    Cell sexCell = row.getCell(1);
+                    // C列:性别
+                    Cell sexCell = row.getCell(2);
                     if (sexCell != null) {
-                        enroll.setSex(getCellValueAsString(sexCell));
+                        String sexText = getCellValueAsString(sexCell);
+                        String sexValue = GenderDictUtils.convertGenderToDictValue(sexText);
+                        enroll.setSex(sexValue);
                     }
 
-                    // C列:年龄
-                    Cell ageCell = row.getCell(2);
+                    // D列:年龄
+                    Cell ageCell = row.getCell(3);
                     if (ageCell != null) {
                         enroll.setAge(getCellValueAsString(ageCell));
                     }
 
-                    // D列:队伍名称
-                    Cell teamCell = row.getCell(3);
+                    // E列:队伍名称
+                    Cell teamCell = row.getCell(4);
                     if (teamCell != null) {
                         enroll.setTeamName(getCellValueAsString(teamCell));
                     }
 
-                    // E列:领队
-                    Cell leaderCell = row.getCell(4);
+                    // F列:领队
+                    Cell leaderCell = row.getCell(5);
                     if (leaderCell != null) {
                         enroll.setLeader(getCellValueAsString(leaderCell));
                     }
 
-                    // F列:联系方式
-                    Cell phoneCell = row.getCell(5);
+                    // G列:联系方式
+                    Cell phoneCell = row.getCell(6);
                     if (phoneCell != null) {
                         enroll.setPhone(getCellValueAsString(phoneCell));
                     }
 
-                    // 从第6列开始读取项目名称
-                    for (int j = 6; j < lastCellIndex; j++) {
+                    // 从第7列开始读取项目名称
+                    for (int j = 7; j < lastCellIndex; j++) {
                         Cell cell = row.getCell(j);
-                        String projectName = projectNames.get(j - 6); // 对应项目名
+                        String projectName = projectNames.get(j - 7); // 对应项目名
                         boolean selected = isCellSelected(cell);
                         selections.put(projectName, selected);
                     }
@@ -357,11 +364,9 @@ public class IEnrollServiceImpl implements IEnrollService {
             }
             return enrolls;
         } catch (Exception e) {
-
-        } finally {
-
+            log.error("解析Excel文件失败", e);
+            return List.of();
         }
-        return List.of();
     }
 
     // 辅助方法:将 Cell 转为字符串
@@ -460,87 +465,6 @@ public class IEnrollServiceImpl implements IEnrollService {
      * @param eventId
      * @return
      */
-    // @Transactional(rollbackFor = Exception.class)
-    // public boolean saveEnrollData(List<EnrollProjectVo> dataList, Long eventId) {
-    //     // 1. 根据队伍分类成Map
-    //     Map<String, List<EnrollProjectVo>> groupedByTeam = dataList.stream()
-    //         .collect(Collectors.groupingBy(EnrollProjectVo::getTeamName));
-    //     // 查询当前赛事下的项目 名称和id映射关系
-    //     Map<String, Long> projectList = gameEventProjectService.mapProjectAndProjectId(eventId);
-    //     // 1.2 根据队伍生成号码段
-    //     Map<String, String> numberRanges = new HashMap<>();
-    //     Map<String, AtomicInteger> currentNumbers = new HashMap<>(); // 记录每个队伍当前分配到的号码
-    //     AtomicInteger teamIndex = new AtomicInteger(1);
-    //     Snowflake snowflake = IdUtil.createSnowflake(1, 1);
-    //
-    //     for (Map.Entry<String, List<EnrollProjectVo>> entry : groupedByTeam.entrySet()) {
-    //         String teamName = entry.getKey();
-    //         String range = generateNumberRange(dataList.size(), teamIndex.getAndIncrement());
-    //         numberRanges.put(teamName, range);
-    //
-    //         // 解析起始号码作为初始值
-    //         int startNum = Integer.parseInt(range.split("-")[0]);
-    //         currentNumbers.put(teamName, new AtomicInteger(startNum));
-    //     }
-    //
-    //     // 2. 保存参赛队伍 & 队员
-    //     for (Map.Entry<String, List<EnrollProjectVo>> entry : groupedByTeam.entrySet()) {
-    //         String teamName = entry.getKey();
-    //         List<EnrollProjectVo> athletes = entry.getValue();
-    //         GameTeamBo gameTeamBo = new GameTeamBo();
-    //         gameTeamBo.setTeamId(snowflake.nextId());
-    //         Long teamId = gameTeamBo.getTeamId();
-    //         // 获取该队伍的当前编号计数器
-    //         AtomicInteger currentNumber = currentNumbers.get(teamName);
-    //         int width = (dataList.size() > 100) ? 5 : 4; // 决定格式化宽度
-    //         List<Long> athletesId = new ArrayList<>();
-    //         // 3. 保存参赛队员
-    //         for (EnrollProjectVo enrollInfo : athletes) {
-    //             GameAthleteBo gameAthleteBo = new GameAthleteBo();
-    //             gameAthleteBo.setEventId(eventId);
-    //             gameAthleteBo.setTeamId(teamId);
-    //             gameAthleteBo.setTeamName(teamName);
-    //             // 分配编号:从当前计数器获取并递增
-    //             int assignedNumber = currentNumber.getAndIncrement();
-    //             String formattedCode = String.format("%0" + width + "d", assignedNumber);
-    //             gameAthleteBo.setAthleteCode(formattedCode);
-    //
-    //             gameAthleteBo.setName(enrollInfo.getName());
-    //             gameAthleteBo.setGender(enrollInfo.getSex());
-    //             gameAthleteBo.setAge(Long.valueOf(enrollInfo.getAge()));
-    //             gameAthleteBo.setPhone(enrollInfo.getPhone());
-    //             gameAthleteBo.setUnit(teamName);
-    //             Map<String, Boolean> selectProjects = enrollInfo.getProjectSelections();
-    //             //查询出对应的赛事id
-    //             List<Long> selectionProjectIds = new ArrayList<>();
-    //             for (Map.Entry<String, Boolean> selectProject : selectProjects.entrySet()) {
-    //                 Long projectId = projectList.get(selectProject.getKey());
-    //                 if (ObjectUtils.isNotEmpty(projectId)) {
-    //                     selectionProjectIds.add(projectId);
-    //                 }
-    //             }
-    //             gameAthleteBo.setProjectValue(JSONUtil.toJsonStr(selectionProjectIds));
-    //
-    //             gameAthleteBo.setStatus("0");
-    //
-    //             gameAthleteService.insertByBo(gameAthleteBo);
-    //             athletesId.add(gameAthleteBo.getAthleteId());
-    //         }
-    //
-    //         gameTeamBo.setEventId(eventId);
-    //         gameTeamBo.setTeamName(teamName);
-    //         // gameTeamBo.setTeamCode("");
-    //         gameTeamBo.setLeader(athletes.get(0).getLeader());
-    //         gameTeamBo.setAthleteValue(JSONUtil.toJsonStr(athletesId));
-    //         gameTeamBo.setAthleteNum(Long.valueOf(athletes.size()));
-    //         gameTeamBo.setNumberRange(numberRanges.get(teamName));
-    //         gameTeamBo.setStatus("0");
-    //         gameTeamService.insertByBo(gameTeamBo);
-    //
-    //
-    //     }
-    //     return true;
-    // }
     @Transactional(rollbackFor = Exception.class)
     public boolean saveEnrollData(List<EnrollProjectVo> dataList, Long eventId) {
         // 1. 根据队伍分类成Map
@@ -603,8 +527,13 @@ public class IEnrollServiceImpl implements IEnrollService {
                         // 成功获取到有效编号,续着分配
                         currentNumber = new AtomicInteger(maxNumber + 1);
                     }
+                } catch (NumberFormatException e) {
+                    log.warn("解析队伍{}的号码段失败: {},从号码段起始开始分配", teamName, numberRange, e);
+                    String[] rangeParts = numberRange.split("-");
+                    int startNumber = Integer.valueOf(rangeParts[0]);
+                    currentNumber = new AtomicInteger(startNumber);
                 } catch (Exception e) {
-                    // 出现异常,从号码段起始开始分配
+                    log.error("查询队伍{}的最大队员编号时发生异常", teamName, e);
                     String[] rangeParts = numberRange.split("-");
                     int startNumber = Integer.valueOf(rangeParts[0]);
                     currentNumber = new AtomicInteger(startNumber);
@@ -614,14 +543,39 @@ public class IEnrollServiceImpl implements IEnrollService {
                 isNewTeam = true;
                 teamId = snowflake.nextId();
 
-                // 为新队伍生成300个号码段
-                int teamIndex = teamIndexCounter.getAndIncrement();
-                numberRange = generateNumberRange(dataList.size(), teamIndex);
-
-                // 解析号码段起始号码
-                String[] rangeParts = numberRange.split("-");
-                int startNumber = Integer.valueOf(rangeParts[0]);
-                currentNumber = new AtomicInteger(startNumber);
+                // 检查是否有填写的号码
+                List<String> providedCodes = athletes.stream()
+                .map(EnrollProjectVo::getAthleteCode)
+                .filter(StringUtils::isNotBlank)
+                .map(code -> code.trim())
+                .filter(code -> StringUtils.isNotBlank(code))
+                .collect(Collectors.toList());
+
+                if (!providedCodes.isEmpty()) {
+                    // 有填写的号码,使用ASCII值比较来确定号码段
+                    numberRange = generateCustomNumberRangeWithChars(providedCodes, eventId, null);
+
+                    if (numberRange != null) {
+                        // 成功生成自定义号码段
+                        currentNumber = null; // 对于字符号码,不使用数字计数器
+                        log.info("队伍 {} 使用自定义字符号码段: {}", teamName, numberRange);
+                    } else {
+                        // 生成失败,使用默认方式
+                        int teamIndex = teamIndexCounter.getAndIncrement();
+                        numberRange = generateNumberRange(dataList.size(), teamIndex);
+                        String[] rangeParts = numberRange.split("-");
+                        int startNumber = Integer.valueOf(rangeParts[0]);
+                        currentNumber = new AtomicInteger(startNumber);
+                        log.warn("队伍 {} 自定义号码段生成失败,使用默认号码段: {}", teamName, numberRange);
+                    }
+                } else {
+                    // 没有填写的号码,使用默认的300号码段
+                    int teamIndex = teamIndexCounter.getAndIncrement();
+                    numberRange = generateNumberRange(dataList.size(), teamIndex);
+                    String[] rangeParts = numberRange.split("-");
+                    int startNumber = Integer.valueOf(rangeParts[0]);
+                    currentNumber = new AtomicInteger(startNumber);
+                }
             }
 
             // 保存队员信息
@@ -654,6 +608,49 @@ public class IEnrollServiceImpl implements IEnrollService {
         return true;
     }
 
+    /**
+     * 检查字符号码段是否与现有队伍冲突
+     *
+     * @param minCode 最小号码
+     * @param maxCode 最大号码
+     * @param eventId 赛事ID
+     * @param excludeTeamId 排除的队伍ID
+     * @return 是否冲突
+     */
+    private boolean isNumberRangeConflictWithChars(String minCode, String maxCode, Long eventId, Long excludeTeamId) {
+        List<GameTeam> existingTeams = gameTeamService.queryTeamByEventId(eventId);
+
+        for (GameTeam team : existingTeams) {
+            // 排除当前队伍
+            if (excludeTeamId != null && team.getTeamId().equals(excludeTeamId)) {
+                continue;
+            }
+
+            if (StringUtils.isNotBlank(team.getNumberRange())) {
+                String[] rangeParts = team.getNumberRange().split("-");
+                if (rangeParts.length == 2) {
+                    String existingMin = rangeParts[0];
+                    String existingMax = rangeParts[1];
+
+                    // 使用ASCII比较检查重叠
+                    if (isRangeOverlapWithChars(minCode, maxCode, existingMin, existingMax)) {
+                        return true;
+                    }
+                }
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * 检查两个号码段是否有重叠
+     */
+    private boolean isRangeOverlapWithChars(String min1, String max1, String min2, String max2) {
+        // 如果max1 < min2 或 max2 < min1,则没有重叠
+        return !(compareCodesByAscii(max1, min2) < 0 || compareCodesByAscii(max2, min1) < 0);
+    }
+
     /**
      * 生成号码段
      * 规则:
@@ -679,21 +676,258 @@ public class IEnrollServiceImpl implements IEnrollService {
         return startStr + "-" + endStr;
     }
 
+    /**
+     * 生成基于填写号码的号码段(支持字符)
+     *
+     * @param providedCodes 填写的号码列表
+     * @param eventId 赛事ID
+     * @param excludeTeamId 排除的队伍ID
+     * @return 号码段字符串
+     */
+    private String generateCustomNumberRangeWithChars(List<String> providedCodes, Long eventId, Long excludeTeamId) {
+        if (providedCodes.isEmpty()) {
+            return null;
+        }
+
+        // 过滤和验证号码
+        List<String> validCodes = providedCodes.stream()
+            .filter(StringUtils::isNotBlank)
+            .map(String::trim)
+            .filter(this::isValidAthleteCode)
+            .collect(Collectors.toList());
+
+        if (validCodes.isEmpty()) {
+            return null;
+        }
+
+        // 按ASCII值排序
+        List<String> sortedCodes = sortCodesByAscii(validCodes);
+        String minCode = sortedCodes.get(0);
+        String maxCode = sortedCodes.get(sortedCodes.size() - 1);
+
+        // 生成号码段
+        String numberRange = generateRangeFromCodes(minCode, maxCode);
+
+        // 检查是否与现有号码段冲突
+        if (isNumberRangeConflictWithChars(minCode, maxCode, eventId, excludeTeamId)) {
+           // 如果冲突,尝试扩展范围
+            numberRange = expandRangeToAvoidConflict(minCode, maxCode, eventId, excludeTeamId);
+            if (numberRange == null) {
+                log.warn("无法为填写的号码生成合适的号码段,使用默认方式");
+                return null; // 返回null,让调用方使用默认方式
+            }
+        }
+
+        log.info("队伍使用自定义号码段: {} (基于填写的号码: {}-{})",
+            numberRange, minCode, maxCode);
+
+        return numberRange;
+    }
+
+    /**
+     * 根据最小和最大号码生成号码段
+     *
+     * @param minCode 最小号码
+     * @param maxCode 最大号码
+     * @return 号码段字符串
+     */
+    private String generateRangeFromCodes(String minCode, String maxCode) {
+        // 简单的实现:直接使用最小和最大号码
+        return minCode + "-" + maxCode;
+    }
+
+    /**
+     * 通过前缀扩展号码段
+     */
+    private String expandByPrefix(String minCode, String maxCode, String prefix) {
+        return prefix + minCode + "-" + prefix + maxCode;
+    }
+
+    /**
+     * 通过后缀扩展号码段
+     */
+    private String expandBySuffix(String minCode, String maxCode, String suffix) {
+        return minCode + suffix + "-" + maxCode + suffix;
+    }
+
+    /**
+     * 通过填充扩展号码段
+     */
+    private String expandByPadding(String minCode, String maxCode, String padding) {
+        // 确保最小长度一致
+        int maxLength = Math.max(minCode.length(), maxCode.length());
+        String paddedMin = String.format("%-" + maxLength + "s", minCode).replace(' ', padding.charAt(0));
+        String paddedMax = String.format("%-" + maxLength + "s", maxCode).replace(' ', padding.charAt(0));
+
+        return paddedMin + "-" + paddedMax;
+    }
+
+    /**
+     * 扩展号码段以避免冲突
+     *
+     * @param minCode 最小号码
+     * @param maxCode 最大号码
+     * @param eventId 赛事ID
+     * @param excludeTeamId 排除的队伍ID
+     * @return 扩展后的号码段
+     */
+    private String expandRangeToAvoidConflict(String minCode, String maxCode, Long eventId, Long excludeTeamId) {
+        // 尝试不同的扩展策略
+        String[] prefixes = {"A", "B", "C", "D", "E"};
+        String[] suffixes = {"A", "B", "C", "D", "E"};
+
+        // 1. 尝试前缀扩展
+        for (String prefix : prefixes) {
+            String expandedRange = expandByPrefix(minCode, maxCode, prefix);
+            if (!isNumberRangeConflictWithChars(
+                expandedRange.split("-")[0], expandedRange.split("-")[1], eventId, excludeTeamId)) {
+                return expandedRange;
+            }
+        }
+
+        // 2. 尝试后缀扩展
+        for (String suffix : suffixes) {
+            String expandedRange = expandBySuffix(minCode, maxCode, suffix);
+            if (!isNumberRangeConflictWithChars(
+                expandedRange.split("-")[0], expandedRange.split("-")[1], eventId, excludeTeamId)) {
+                return expandedRange;
+            }
+        }
+
+        // 3. 尝试数字扩展
+        for (int i = 1; i <= 9; i++) {
+            String expandedRange = expandByPrefix(minCode, maxCode, String.valueOf(i));
+            if (!isNumberRangeConflictWithChars(
+                expandedRange.split("-")[0], expandedRange.split("-")[1], eventId, excludeTeamId)) {
+                return expandedRange;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * 比较两个号码的ASCII值大小
+     *
+     * @param code1 号码1
+     * @param code2 号码2
+     * @return 比较结果:负数表示code1<code2,0表示相等,正数表示code1>code2
+     */
+    private int compareCodesByAscii(String code1, String code2) {
+        if (code1 == null && code2 == null) {
+            return 0;
+        }
+        if (code1 == null) {
+            return -1;
+        }
+        if (code2 == null) {
+            return 1;
+        }
+
+        // 按字符逐个比较ASCII值
+        int minLength = Math.min(code1.length(), code2.length());
+
+        for (int i = 0; i < minLength; i++) {
+            char char1 = code1.charAt(i);
+            char char2 = code2.charAt(i);
+
+            if (char1 != char2) {
+                return char1 - char2; // 返回ASCII值差
+            }
+        }
+
+        // 如果前面字符都相同,比较长度
+        return code1.length() - code2.length();
+    }
+
+    /**
+     * 对号码列表按ASCII值排序
+     *
+     * @param codes 号码列表
+     * @return 排序后的号码列表
+     */
+    private List<String> sortCodesByAscii(List<String> codes) {
+        return codes.stream()
+            .filter(StringUtils::isNotBlank)
+            .map(String::trim)
+            .sorted(this::compareCodesByAscii)
+            .collect(Collectors.toList());
+    }
+
+    /**
+     * 验证运动员号码格式(支持字符)
+     *
+     * @param athleteCode 运动员号码
+     * @return 是否有效
+     */
+    private boolean isValidAthleteCode(String athleteCode) {
+        if (StringUtils.isBlank(athleteCode)) {
+            return false;
+        }
+
+        String trimmedCode = athleteCode.trim();
+
+        // 检查长度
+        if (trimmedCode.length() < 1 || trimmedCode.length() > 15) {
+            return false;
+        }
+
+        // 允许字母、数字、连字符、下划线、点号
+        if (!trimmedCode.matches("^[A-Za-z0-9\\-_\\.]+$")) {
+            return false;
+        }
+
+        // 检查是否包含连续的特殊字符
+        if (trimmedCode.matches(".*[\\-_\\.]{2,}.*")) {
+            return false;
+        }
+
+        return true;
+    }
+
     /**
      * 创建运动员BO对象
      */
-    private GameAthleteBo createAthleteBo(EnrollProjectVo enrollInfo, Long eventId, Long teamId,
-                                          String teamName, AtomicInteger currentNumber, int width,
-                                          Map<String, Long> projectList) {
+    private GameAthleteBo createAthleteBo(EnrollProjectVo enrollInfo,
+                                            Long eventId, Long teamId,
+                                            String teamName,
+                                            AtomicInteger currentNumber, int width,
+                                            Map<String, Long> projectList) {
         GameAthleteBo gameAthleteBo = new GameAthleteBo();
         gameAthleteBo.setEventId(eventId);
         gameAthleteBo.setTeamId(teamId);
         gameAthleteBo.setTeamName(teamName);
 
-        // 分配编号
-        int assignedNumber = currentNumber.getAndIncrement();
-        String formattedCode = String.format("%0" + width + "d", assignedNumber);
-        gameAthleteBo.setAthleteCode(formattedCode);
+        // 号码处理逻辑
+        String athleteCode;
+        if (StringUtils.isNotBlank(enrollInfo.getAthleteCode())) {
+            // 如果Excel中有号码,使用Excel中的号码
+            athleteCode = enrollInfo.getAthleteCode().trim();
+            
+            // 验证号码格式
+            if (!isValidAthleteCode(athleteCode)) {
+                log.warn("运动员{}的号码格式不正确: {},将使用自动生成的号码", enrollInfo.getName(), athleteCode);
+                // 如果号码格式不正确,使用自动生成的号码
+                if (currentNumber != null) {
+                    int assignedNumber = currentNumber.getAndIncrement();
+                    athleteCode = String.format("%0" + width + "d", assignedNumber);
+                } else {
+                    // 字符号码段情况下,生成一个基于序号的号码
+                    athleteCode = "AUTO_" + (System.currentTimeMillis() % 10000);
+                }
+            }
+        } else {
+            // 如果Excel中没有号码,自动生成
+            if (currentNumber != null) {
+                int assignedNumber = currentNumber.getAndIncrement();
+                athleteCode = String.format("%0" + width + "d", assignedNumber);
+            } else {
+                // 字符号码段情况下,生成一个基于序号的号码
+                athleteCode = "AUTO_" + (System.currentTimeMillis() % 10000);
+            }
+        }
+
+        gameAthleteBo.setAthleteCode(athleteCode);
 
         gameAthleteBo.setName(enrollInfo.getName());
         gameAthleteBo.setGender(enrollInfo.getSex());
@@ -754,4 +988,289 @@ public class IEnrollServiceImpl implements IEnrollService {
     }
 
     // endregion 辅助方法
+
+    /**
+     * 检查号码是否已存在
+     *
+     * @param athleteCode 运动员号码
+     * @param eventId 赛事ID
+     * @return 是否已存在
+     */
+    private boolean isAthleteCodeExists(String athleteCode, Long eventId) {
+        if (StringUtils.isBlank(athleteCode)) {
+            return false;
+        }
+
+        GameAthleteBo bo = new GameAthleteBo();
+        bo.setEventId(eventId);
+        bo.setAthleteCode(athleteCode);
+
+        List<GameAthleteVo> existingAthletes = gameAthleteService.queryList(bo);
+        return existingAthletes.size() > 0;
+    }
+
+    /**
+     * 校验基本数据完整性
+     */
+    private List<EnrollValidationError> validateBasicData(EnrollProjectVo enroll, int rowIndex) {
+        List<EnrollValidationError> errors = new ArrayList<>();
+
+        if (StringUtils.isBlank(enroll.getName())) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setErrorType("MISSING_NAME");
+            error.setErrorMessage("运动员姓名不能为空");
+            errors.add(error);
+        }
+
+        if (StringUtils.isBlank(enroll.getTeamName())) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setAthleteName(enroll.getName());
+            error.setErrorType("MISSING_TEAM");
+            error.setErrorMessage("队伍名称不能为空");
+            errors.add(error);
+        }
+
+        // 性别校验 - 使用字典工具验证
+        if (StringUtils.isBlank(enroll.getSex())) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setAthleteName(enroll.getName());
+            error.setTeamName(enroll.getTeamName());
+            error.setErrorType("MISSING_GENDER");
+            error.setErrorMessage("性别不能为空");
+            errors.add(error);
+        } else if (!GenderDictUtils.isValidGender(enroll.getSex())) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setAthleteName(enroll.getName());
+            error.setTeamName(enroll.getTeamName());
+            error.setErrorType("INVALID_GENDER");
+            error.setErrorMessage("性别值无效,请填写'男'、'女'、'1'或'2'");
+            errors.add(error);
+        }
+
+        return errors;
+    }
+
+    @Override
+    public EnrollValidationResult validateEnrollData(List<EnrollProjectVo> enrollList, Long eventId) {
+        EnrollValidationResult result = new EnrollValidationResult();
+        result.setValid(true);
+        result.setErrors(new ArrayList<>());
+        result.setValidData(new ArrayList<>());
+
+        if (enrollList == null || enrollList.isEmpty()) {
+            log.warn("导入数据为空,赛事ID: {}", eventId);
+            return result;
+        }
+
+        if (eventId == null) {
+            log.error("赛事ID不能为空");
+            throw new IllegalArgumentException("赛事ID不能为空");
+        }
+
+        try {
+            // 1. 获取赛事配置:每人限报项目数
+            Integer maxProjectsPerPerson = getMaxProjectsPerPerson(eventId);
+
+            // 2. 获取项目限制信息
+            Map<String, Integer> projectLimits = getProjectLimits(eventId);
+
+            // 3. 统计每个项目的当前报名人数
+            Map<String, Integer> currentProjectCounts = getCurrentProjectCounts(eventId);
+
+            // 4. 校验每个运动员
+            for (int i = 0; i < enrollList.size(); i++) {
+                EnrollProjectVo enroll = enrollList.get(i);
+                // 先进行基本数据校验
+                List<EnrollValidationError> basicErrors = validateBasicData(enroll, i);
+                if (!basicErrors.isEmpty()) {
+                    result.setValid(false);
+                    result.getErrors().addAll(basicErrors);
+                    continue; // 基本数据有问题,跳过后续校验
+                }
+
+                List<EnrollValidationError> athleteErrors = validateAthlete(eventId, enroll, i,
+                    maxProjectsPerPerson, projectLimits, currentProjectCounts);
+
+                if (athleteErrors.isEmpty()) {
+                    result.getValidData().add(enroll);
+                    // 更新项目计数(模拟保存后的状态)
+                    updateProjectCounts(enroll, currentProjectCounts);
+                } else {
+                    result.setValid(false);
+                    result.getErrors().addAll(athleteErrors);
+                }
+            }
+        } catch (Exception e) {
+            log.error("校验导入数据时发生异常,赛事ID: {}", eventId, e);
+            result.setValid(false);
+            EnrollValidationError error = new EnrollValidationError();
+            error.setErrorType("SYSTEM_ERROR");
+            error.setErrorMessage("系统校验异常:" + e.getMessage());
+            result.getErrors().add(error);
+        }
+
+        return result;
+    }
+
+    @Override
+    public EnrollImportResult importDataWithValidation(MultipartFile file, Long eventId) {
+        // 1. 解析数据
+        List<EnrollProjectVo> enrollList = parseData(file);
+
+        // 2. 校验数据
+        EnrollValidationResult validationResult = validateEnrollData(enrollList, eventId);
+
+        // 3. 保存有效数据
+        boolean saveSuccess = false;
+        if (!validationResult.getValidData().isEmpty()) {
+            saveSuccess = saveEnrollData(validationResult.getValidData(), eventId);
+        }
+
+        // 4. 返回结果
+        EnrollImportResult result = new EnrollImportResult();
+        result.setSuccess(saveSuccess);
+        result.setValidationResult(validationResult);
+        result.setTotalCount(enrollList.size());
+        result.setValidCount(validationResult.getValidData().size());
+        result.setErrorCount(validationResult.getErrors().size());
+
+        return result;
+    }
+
+    /**
+     * 获取每人限报项目数
+     */
+    private Integer getMaxProjectsPerPerson(Long eventId) {
+        GameEventVo event = gameEventService.queryById(eventId);
+        return event.getLimitApplication() != null ? event.getLimitApplication() : 0; // 默认0个
+    }
+
+    /**
+     * 获取项目限制信息
+     */
+    private Map<String, Integer> getProjectLimits(Long eventId) {
+        List<GameEventProjectVo> projects = gameEventProjectService.queryListByEventId(eventId);
+        return projects.stream()
+            .filter(p -> p.getLimitPerson() != null && p.getLimitPerson() > 0)
+            .collect(Collectors.toMap(
+                GameEventProjectVo::getProjectName,
+                GameEventProjectVo::getLimitPerson
+            ));
+    }
+
+    /**
+     * 获取当前项目报名人数
+     */
+    private Map<String, Integer> getCurrentProjectCounts(Long eventId) {
+        GameAthleteBo bo = new GameAthleteBo();
+        bo.setEventId(eventId);
+        List<GameAthleteVo> athletes = gameAthleteService.queryList(bo);
+        Map<String, Integer> counts = new HashMap<>();
+
+        for (GameAthleteVo athlete : athletes) {
+            if (StringUtils.isNotBlank(athlete.getProjectValue())) {
+                List<Long> projectIds = JSONUtil.toList(athlete.getProjectValue(), Long.class);
+                for (Long projectId : projectIds) {
+                    GameEventProjectVo project = gameEventProjectService.queryById(projectId);
+                    if (project != null) {
+                        counts.merge(project.getProjectName(), 1, Integer::sum);
+                    }
+                }
+            }
+        }
+
+        return counts;
+    }
+
+    /**
+     * 校验单个运动员
+     */
+    private List<EnrollValidationError> validateAthlete(Long eventId, EnrollProjectVo enroll, int rowIndex,
+                                                        Integer maxProjectsPerPerson, Map<String, Integer> projectLimits,
+                                                        Map<String, Integer> currentProjectCounts) {
+
+        List<EnrollValidationError> errors = new ArrayList<>();
+
+        // 号码冲突检查
+        if (StringUtils.isNotBlank(enroll.getAthleteCode())) {
+            if (isAthleteCodeExists(enroll.getAthleteCode(), eventId)) {
+                EnrollValidationError error = new EnrollValidationError();
+                error.setRowIndex(rowIndex + 1);
+                error.setAthleteName(enroll.getName());
+                error.setTeamName(enroll.getTeamName());
+                error.setErrorType("DUPLICATE_ATHLETE_CODE");
+                error.setErrorMessage(String.format("运动员号码'%s'已存在", enroll.getAthleteCode()));
+                errors.add(error);
+            }
+        }
+
+        // 检查项目选择数据是否为空
+        if (enroll.getProjectSelections() == null || enroll.getProjectSelections().isEmpty()) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1);
+            error.setAthleteName(enroll.getName());
+            error.setTeamName(enroll.getTeamName());
+            error.setErrorType("NO_PROJECT_SELECTED");
+            error.setErrorMessage("该运动员未选择任何项目");
+            errors.add(error);
+            return errors; // 如果没有选择项目,直接返回
+        }
+
+        // 1. 校验每人限报项目数
+        long selectedProjectCount = enroll.getProjectSelections().values().stream()
+            .mapToLong(selected -> selected ? 1 : 0)
+            .sum();
+
+        if (maxProjectsPerPerson > 0 && selectedProjectCount > maxProjectsPerPerson) {
+            EnrollValidationError error = new EnrollValidationError();
+            error.setRowIndex(rowIndex + 1); // 从1开始计数
+            error.setAthleteName(enroll.getName());
+            error.setTeamName(enroll.getTeamName());
+            error.setErrorType("PERSON_LIMIT_EXCEEDED");
+            error.setErrorMessage(String.format("该运动员选择了%d个项目,超出每人限报%d个项目",
+                selectedProjectCount, maxProjectsPerPerson));
+            errors.add(error);
+        }
+
+        // 2. 校验项目限报人数
+        for (Map.Entry<String, Boolean> entry : enroll.getProjectSelections().entrySet()) {
+            if (entry.getValue()) { // 如果选择了该项目
+                String projectName = entry.getKey();
+                Integer projectLimit = projectLimits.get(projectName);
+
+                if (projectLimit != null && projectLimit > 0) {
+                    int currentCount = currentProjectCounts.getOrDefault(projectName, 0);
+                    if (currentCount >= projectLimit) {
+                        EnrollValidationError error = new EnrollValidationError();
+                        error.setRowIndex(rowIndex + 1);
+                        error.setAthleteName(enroll.getName());
+                        error.setTeamName(enroll.getTeamName());
+                        error.setErrorType("PROJECT_LIMIT_EXCEEDED");
+                        error.setProjectName(projectName);
+                        error.setErrorMessage(String.format("项目'%s'报名人数已达上限%d人",
+                            projectName, projectLimit));
+                        errors.add(error);
+                    }
+                }
+            }
+        }
+
+        return errors;
+    }
+
+    /**
+     * 更新项目计数(模拟保存后的状态)
+     */
+    private void updateProjectCounts(EnrollProjectVo enroll, Map<String, Integer> currentProjectCounts) {
+        for (Map.Entry<String, Boolean> entry : enroll.getProjectSelections().entrySet()) {
+            if (entry.getValue()) {
+                String projectName = entry.getKey();
+                currentProjectCounts.merge(projectName, 1, Integer::sum);
+            }
+        }
+    }
 }

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

@@ -0,0 +1,123 @@
+package org.dromara.system.utils;
+
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.service.DictService;
+import org.dromara.common.core.utils.SpringUtils;
+import org.dromara.common.core.utils.StringUtils;
+
+/**
+ * 性别字典转换工具类
+ */
+@Slf4j
+public class GenderDictUtils {
+    private static final String GENDER_DICT_TYPE = "sys_user_sex";
+
+    /**
+     * 将性别文本转换为字典值
+     * 支持:男/女 -> 1/2,或者直接传入1/2
+     *
+     * @param genderText 性别文本(男、女、1、2等)
+     * @return 字典值(1或2)
+     */
+    public static String convertGenderToDictValue(String genderText) {
+        if (StringUtils.isBlank(genderText)) {
+            return null;
+        }
+
+        String trimmedText = genderText.trim();
+
+        // 如果已经是数字,直接返回
+        if (trimmedText.matches("^[12]$")) {
+            return trimmedText;
+        }
+
+        // 通过字典服务转换
+        try {
+            DictService dictService = SpringUtils.getBean(DictService.class);
+            String dictValue = dictService.getDictValue(GENDER_DICT_TYPE, trimmedText);
+
+            if (StringUtils.isNotBlank(dictValue)) {
+                return dictValue;
+            }
+        } catch (Exception e) {
+            log.warn("通过字典服务转换性别失败,使用默认映射: {}", e.getMessage());
+        }
+
+        // 如果字典服务转换失败,使用默认映射
+        return convertGenderByDefault(trimmedText);
+    }
+
+    /**
+     * 默认性别映射规则
+     *
+     * @param genderText 性别文本
+     * @return 字典值
+     */
+    private static String convertGenderByDefault(String genderText) {
+        switch (genderText) {
+            case "男":
+            case "男性":
+            case "M":
+            case "male":
+            case "Male":
+                return "1";
+            case "女":
+            case "女性":
+            case "F":
+            case "female":
+            case "Female":
+                return "2";
+            default:
+                log.warn("无法识别的性别值: {}", genderText);
+                return null;
+        }
+    }
+
+    /**
+     * 将字典值转换为性别文本
+     *
+     * @param dictValue 字典值(0或1)
+     * @return 性别文本
+     */
+    public static String convertDictValueToGender(String dictValue) {
+        if (StringUtils.isBlank(dictValue)) {
+            return null;
+        }
+
+        try {
+            DictService dictService = SpringUtils.getBean(DictService.class);
+            String dictLabel = dictService.getDictLabel(GENDER_DICT_TYPE, dictValue);
+
+            if (StringUtils.isNotBlank(dictLabel)) {
+                return dictLabel;
+            }
+        } catch (Exception e) {
+            log.warn("通过字典服务转换性别标签失败,使用默认映射: {}", e.getMessage());
+        }
+
+        // 默认映射
+        switch (dictValue) {
+            case "1":
+                return "男";
+            case "2":
+                return "女";
+            default:
+                return dictValue;
+        }
+    }
+
+    /**
+     * 验证性别值是否有效
+     *
+     * @param genderValue 性别值
+     * @return 是否有效
+     */
+    public static boolean isValidGender(String genderValue) {
+        if (StringUtils.isBlank(genderValue)) {
+            return false;
+        }
+
+        String dictValue = convertGenderToDictValue(genderValue);
+        return "1".equals(dictValue) || "2".equals(dictValue);
+    }
+}

BIN
ruoyi-modules/ruoyi-game-event/src/main/resources/template/enroll_template.xlsx