Files
martial-master/docs/tasks/03-成绩计算引擎.md
n72595987@gmail.com 21c133f9c9
All checks were successful
continuous-integration/drone/push Build is passing
feat: 实现成绩计算引擎、比赛日流程和导出打印功能
本次提交完成了武术比赛系统的核心功能模块,包括:

## 1. 成绩计算引擎 (Tasks 1.1-1.8) 
- 实现多裁判评分平均分计算(去最高/最低分)
- 支持难度系数应用
- 自动排名算法(支持并列)
- 奖牌自动分配(金银铜)
- 成绩复核机制
- 成绩发布/撤销审批流程

## 2. 比赛日流程功能 (Tasks 2.1-2.6) 
- 运动员签到/检录系统
- 评分有效性验证(范围检查0-10分)
- 异常分数警告机制(偏差>2.0)
- 异常情况记录和处理
- 检录长角色权限管理
- 比赛状态流转管理

## 3. 导出打印功能 (Tasks 3.1-3.4) 
- 成绩单Excel导出(EasyExcel)
- 运动员名单Excel导出
- 赛程表Excel导出
- 证书生成(HTML模板+数据接口)

## 4. 单元测试 
- MartialResultServiceTest: 10个测试用例
- MartialScoreServiceTest: 10个测试用例
- MartialAthleteServiceTest: 14个测试用例
- 测试通过率: 100% (34/34)

## 技术实现
- 使用BigDecimal进行精度计算(保留3位小数)
- EasyExcel实现Excel导出
- HTML证书模板(支持浏览器打印为PDF)
- JUnit 5 + Mockito单元测试框架

## 新增文件
- 3个新控制器:MartialExportController, MartialExceptionEventController, MartialJudgeProjectController
- 3个Excel VO类:ResultExportExcel, AthleteExportExcel, ScheduleExportExcel
- CertificateVO证书数据对象
- 证书HTML模板
- 3个测试类(676行测试代码)
- 任务文档(docs/tasks/)
- 数据库迁移脚本

## 项目进度
已完成: 64% (18/28 任务)
-  成绩计算引擎: 100%
-  比赛日流程: 100%
-  导出打印功能: 80%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 17:11:12 +08:00

15 KiB
Raw Blame History

成绩计算引擎 - 详细任务清单

优先级: P0最高 预计工时: 5天 负责人: 待分配 创建时间: 2025-11-30 最后更新: 2025-11-30


📋 任务概述

成绩计算引擎是武术比赛系统的核心功能,负责从裁判评分到最终排名的自动化计算。

核心流程

裁判打分 → 收集评分 → 去最高/最低分 → 计算平均分
    ↓
应用难度系数 → 计算最终得分 → 自动排名 → 分配奖牌

任务列表

任务 1.1:多裁判评分平均分计算 🔴

状态: 未开始 工时: 0.5天 文件位置: MartialResultServiceImpl.java

需求描述

  • 获取某运动员某项目的所有裁判评分
  • 计算有效评分的平均值
  • 记录最高分、最低分

实现要点

public BigDecimal calculateAverageScore(Long athleteId, Long projectId) {
    // 1. 查询所有裁判评分
    List<MartialScore> scores = scoreService.list(
        new QueryWrapper<MartialScore>()
            .eq("athlete_id", athleteId)
            .eq("project_id", projectId)
            .eq("is_deleted", 0)
    );

    // 2. 提取分数值
    List<BigDecimal> scoreValues = scores.stream()
        .map(MartialScore::getScore)
        .collect(Collectors.toList());

    // 3. 计算平均分(后续会去最高/最低)
    BigDecimal sum = scoreValues.stream()
        .reduce(BigDecimal.ZERO, BigDecimal::add);

    return sum.divide(
        new BigDecimal(scoreValues.size()),
        3,
        RoundingMode.HALF_UP
    );
}

测试用例

  • 单个裁判评分
  • 多个裁判评分3-10人
  • 边界值测试5.000, 10.000

任务 1.2:去最高分/去最低分逻辑 🔴

状态: 未开始 工时: 0.5天 文件位置: MartialResultServiceImpl.java

需求描述

  • 从所有裁判评分中去掉一个最高分
  • 去掉一个最低分
  • 计算剩余有效评分的平均值

实现要点

public BigDecimal calculateValidAverageScore(Long athleteId, Long projectId) {
    // 1. 获取所有评分
    List<MartialScore> scores = scoreService.list(...);

    if (scores.size() < 3) {
        throw new ServiceException("裁判人数不足3人无法去最高/最低分");
    }

    // 2. 找出最高分和最低分
    BigDecimal maxScore = scores.stream()
        .map(MartialScore::getScore)
        .max(Comparator.naturalOrder())
        .orElse(BigDecimal.ZERO);

    BigDecimal minScore = scores.stream()
        .map(MartialScore::getScore)
        .min(Comparator.naturalOrder())
        .orElse(BigDecimal.ZERO);

    // 3. 过滤有效评分(去掉一个最高、一个最低)
    List<BigDecimal> validScores = new ArrayList<>();
    boolean maxRemoved = false;
    boolean minRemoved = false;

    for (MartialScore score : scores) {
        BigDecimal val = score.getScore();
        if (!maxRemoved && val.equals(maxScore)) {
            maxRemoved = true;
            continue;
        }
        if (!minRemoved && val.equals(minScore)) {
            minRemoved = true;
            continue;
        }
        validScores.add(val);
    }

    // 4. 计算平均分
    BigDecimal sum = validScores.stream()
        .reduce(BigDecimal.ZERO, BigDecimal::add);

    return sum.divide(
        new BigDecimal(validScores.size()),
        3,
        RoundingMode.HALF_UP
    );
}

测试用例

  • 正常情况5个裁判去掉最高最低后剩3个
  • 边界情况3个裁判去掉最高最低后剩1个
  • 异常情况少于3个裁判抛出异常

任务 1.3:难度系数应用 🔴

状态: 未开始 工时: 0.5天 文件位置: MartialResultServiceImpl.java

需求描述

  • 从项目表获取难度系数
  • 将平均分乘以难度系数
  • 生成调整后的分数

实现要点

public BigDecimal applyDifficultyCoefficient(
    BigDecimal averageScore,
    Long projectId
) {
    // 1. 获取项目信息
    MartialProject project = projectService.getById(projectId);
    if (project == null) {
        throw new ServiceException("项目不存在");
    }

    // 2. 获取难度系数默认1.00
    BigDecimal coefficient = project.getDifficultyCoefficient();
    if (coefficient == null) {
        coefficient = new BigDecimal("1.00");
    }

    // 3. 应用系数
    return averageScore.multiply(coefficient)
        .setScale(3, RoundingMode.HALF_UP);
}

数据库字段

-- martial_project 表需要添加字段(如果没有)
ALTER TABLE martial_project
ADD COLUMN difficulty_coefficient DECIMAL(5,2) DEFAULT 1.00
COMMENT '难度系数';

测试用例

  • 系数 = 1.00(无调整)
  • 系数 = 1.20(加分)
  • 系数 = 0.80(减分)

任务 1.4:最终得分计算 🔴

状态: 未开始 工时: 1天 文件位置: MartialResultServiceImpl.java

需求描述

  • 整合所有计算步骤
  • 保存完整的成绩记录
  • 记录计算明细(最高分、最低分、有效分数等)

实现要点

public MartialResult calculateFinalScore(Long athleteId, Long projectId) {
    // 1. 获取所有裁判评分
    List<MartialScore> scores = scoreService.list(
        new QueryWrapper<MartialScore>()
            .eq("athlete_id", athleteId)
            .eq("project_id", projectId)
    );

    if (scores.isEmpty()) {
        throw new ServiceException("该运动员尚未有裁判评分");
    }

    // 2. 找出最高分和最低分
    BigDecimal maxScore = scores.stream()
        .map(MartialScore::getScore)
        .max(Comparator.naturalOrder())
        .orElse(BigDecimal.ZERO);

    BigDecimal minScore = scores.stream()
        .map(MartialScore::getScore)
        .min(Comparator.naturalOrder())
        .orElse(BigDecimal.ZERO);

    // 3. 去最高/最低分,计算平均分
    BigDecimal averageScore = calculateValidAverageScore(athleteId, projectId);

    // 4. 应用难度系数
    BigDecimal finalScore = applyDifficultyCoefficient(averageScore, projectId);

    // 5. 获取运动员和项目信息
    MartialAthlete athlete = athleteService.getById(athleteId);
    MartialProject project = projectService.getById(projectId);

    // 6. 保存成绩记录
    MartialResult result = new MartialResult();
    result.setCompetitionId(athlete.getCompetitionId());
    result.setAthleteId(athleteId);
    result.setProjectId(projectId);
    result.setPlayerName(athlete.getPlayerName());
    result.setTeamName(athlete.getTeamName());

    result.setTotalScore(averageScore);  // 平均分
    result.setMaxScore(maxScore);
    result.setMinScore(minScore);
    result.setValidScoreCount(scores.size() - 2);  // 去掉最高最低

    result.setDifficultyCoefficient(project.getDifficultyCoefficient());
    result.setFinalScore(finalScore);  // 最终得分

    result.setIsFinal(0);  // 初始为非最终成绩

    this.saveOrUpdate(result);

    return result;
}

测试用例

  • 完整流程测试5个裁判评分
  • 数据持久化验证
  • 重复计算测试(更新而非新增)

任务 1.5:自动排名算法 🔴

状态: 未开始 工时: 1天 文件位置: MartialResultServiceImpl.java

需求描述

  • 按项目对所有运动员进行排名
  • 处理并列排名情况
  • 更新排名到数据库

实现要点

public void autoRanking(Long projectId) {
    // 1. 获取该项目所有最终成绩,按分数降序
    List<MartialResult> results = this.list(
        new QueryWrapper<MartialResult>()
            .eq("project_id", projectId)
            .eq("is_final", 1)  // 只对最终成绩排名
            .orderByDesc("final_score")
    );

    if (results.isEmpty()) {
        throw new ServiceException("该项目尚无最终成绩");
    }

    // 2. 分配排名(处理并列)
    int currentRank = 1;
    BigDecimal previousScore = null;
    int sameScoreCount = 0;

    for (int i = 0; i < results.size(); i++) {
        MartialResult result = results.get(i);
        BigDecimal currentScore = result.getFinalScore();

        if (previousScore != null && currentScore.equals(previousScore)) {
            // 分数相同,并列
            sameScoreCount++;
        } else {
            // 分数不同,更新排名
            currentRank += sameScoreCount;
            sameScoreCount = 1;
        }

        result.setRanking(currentRank);
        previousScore = currentScore;
    }

    // 3. 批量更新
    this.updateBatchById(results);
}

测试用例

  • 无并列情况
  • 有并列情况2人同分
  • 多人并列情况3人同分

任务 1.6:奖牌自动分配 🔴

状态: 未开始 工时: 0.5天 文件位置: MartialResultServiceImpl.java

需求描述

  • 自动分配金银铜牌给前三名
  • 处理并列情况(如并列第一名,两人都得金牌)
  • 更新奖牌字段

实现要点

public void assignMedals(Long projectId) {
    // 1. 获取前三名(按排名)
    List<MartialResult> topResults = this.list(
        new QueryWrapper<MartialResult>()
            .eq("project_id", projectId)
            .eq("is_final", 1)
            .le("ranking", 3)  // 排名 <= 3
            .orderByAsc("ranking")
    );

    // 2. 分配奖牌
    for (MartialResult result : topResults) {
        Integer ranking = result.getRanking();
        if (ranking == 1) {
            result.setMedal(1);  // 金牌
        } else if (ranking == 2) {
            result.setMedal(2);  // 银牌
        } else if (ranking == 3) {
            result.setMedal(3);  // 铜牌
        }
    }

    // 3. 批量更新
    this.updateBatchById(topResults);
}

测试用例

  • 正常情况前3名分配金银铜
  • 并列第一2人都得金牌第3名得铜牌跳过银牌
  • 并列第二第1名金牌2人都得银牌

任务 1.7:成绩复核机制 🔴

状态: 未开始 工时: 0.5天 文件位置: MartialResultServiceImpl.java

需求描述

  • 提供成绩复核接口
  • 记录复核原因和结果
  • 支持成绩调整

实现要点

public void reviewResult(Long resultId, String reviewNote, BigDecimal adjustment) {
    MartialResult result = this.getById(resultId);
    if (result == null) {
        throw new ServiceException("成绩记录不存在");
    }

    // 记录原始分数
    result.setOriginalScore(result.getFinalScore());

    // 应用调整
    if (adjustment != null) {
        BigDecimal newScore = result.getFinalScore().add(adjustment);
        result.setAdjustedScore(newScore);
        result.setFinalScore(newScore);
        result.setAdjustRange(adjustment);
    }

    result.setAdjustNote(reviewNote);

    this.updateById(result);

    // 重新排名
    autoRanking(result.getProjectId());
}

测试用例

  • 成绩上调
  • 成绩下调
  • 调整后重新排名

任务 1.8:成绩发布审批流程 🔴

状态: 未开始 工时: 0.5天 文件位置: MartialResultServiceImpl.java

需求描述

  • 成绩确认为最终成绩
  • 记录发布时间
  • 限制已发布成绩的修改

实现要点

public void publishResults(Long projectId) {
    List<MartialResult> results = this.list(
        new QueryWrapper<MartialResult>()
            .eq("project_id", projectId)
    );

    for (MartialResult result : results) {
        result.setIsFinal(1);  // 标记为最终成绩
        result.setPublishTime(LocalDateTime.now());
    }

    this.updateBatchById(results);
}

public void unpublishResults(Long projectId) {
    // 撤销发布(管理员权限)
    List<MartialResult> results = this.list(
        new QueryWrapper<MartialResult>()
            .eq("project_id", projectId)
    );

    for (MartialResult result : results) {
        result.setIsFinal(0);
        result.setPublishTime(null);
    }

    this.updateBatchById(results);
}

测试用例

  • 发布成绩
  • 撤销发布
  • 已发布成绩的权限控制

🎯 Controller 层接口设计

新增 API 接口

@RestController
@RequestMapping("/martial/result")
public class MartialResultController extends BladeController {

    @Autowired
    private IMartialResultService resultService;

    /**
     * 计算运动员最终成绩
     */
    @PostMapping("/calculate")
    @Operation(summary = "计算最终成绩")
    public R<MartialResult> calculateScore(
        @RequestParam Long athleteId,
        @RequestParam Long projectId
    ) {
        MartialResult result = resultService.calculateFinalScore(athleteId, projectId);
        return R.data(result);
    }

    /**
     * 项目自动排名
     */
    @PostMapping("/ranking")
    @Operation(summary = "自动排名")
    public R autoRanking(@RequestParam Long projectId) {
        resultService.autoRanking(projectId);
        return R.success("排名完成");
    }

    /**
     * 分配奖牌
     */
    @PostMapping("/medals")
    @Operation(summary = "分配奖牌")
    public R assignMedals(@RequestParam Long projectId) {
        resultService.assignMedals(projectId);
        return R.success("奖牌分配完成");
    }

    /**
     * 成绩复核
     */
    @PostMapping("/review")
    @Operation(summary = "成绩复核")
    public R reviewResult(
        @RequestParam Long resultId,
        @RequestParam String reviewNote,
        @RequestParam(required = false) BigDecimal adjustment
    ) {
        resultService.reviewResult(resultId, reviewNote, adjustment);
        return R.success("复核完成");
    }

    /**
     * 发布成绩
     */
    @PostMapping("/publish")
    @Operation(summary = "发布成绩")
    public R publishResults(@RequestParam Long projectId) {
        resultService.publishResults(projectId);
        return R.success("成绩已发布");
    }
}

📦 依赖配置

无需额外依赖,使用现有的:

  • MyBatis-Plus数据访问
  • Java BigDecimal精度计算

🧪 测试计划

单元测试

  • 平均分计算测试
  • 去最高/最低分测试
  • 难度系数应用测试
  • 排名算法测试
  • 奖牌分配测试

集成测试

  • 完整成绩计算流程
  • 多项目并发计算
  • 成绩发布流程

性能测试

  • 100个运动员同时计算
  • 批量排名性能

📝 开发注意事项

  1. 精度处理: 所有分数计算使用 BigDecimal保留3位小数
  2. 并发控制: 成绩计算可能被多次触发,需要考虑幂等性
  3. 数据一致性: 成绩更新后需要触发排名重新计算
  4. 异常处理: 裁判人数不足、评分缺失等异常情况
  5. 权限控制: 成绩发布、复核等敏感操作需要权限验证

验收标准

  • 所有单元测试通过
  • API接口文档完整Swagger
  • 成绩计算精度达到0.001
  • 排名算法处理并列情况正确
  • 已发布成绩不可随意修改
  • 代码通过Code Review

下一步: 完成后进入 02-比赛日流程功能.md