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

594 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 成绩计算引擎 - 详细任务清单
**优先级:** P0最高
**预计工时:** 5天
**负责人:** 待分配
**创建时间:** 2025-11-30
**最后更新:** 2025-11-30
---
## 📋 任务概述
成绩计算引擎是武术比赛系统的核心功能,负责从裁判评分到最终排名的自动化计算。
### 核心流程
```
裁判打分 → 收集评分 → 去最高/最低分 → 计算平均分
应用难度系数 → 计算最终得分 → 自动排名 → 分配奖牌
```
---
## ✅ 任务列表
### 任务 1.1:多裁判评分平均分计算 🔴
**状态:** 未开始
**工时:** 0.5天
**文件位置:** `MartialResultServiceImpl.java`
#### 需求描述
- 获取某运动员某项目的所有裁判评分
- 计算有效评分的平均值
- 记录最高分、最低分
#### 实现要点
```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`
#### 需求描述
- 从所有裁判评分中去掉一个最高分
- 去掉一个最低分
- 计算剩余有效评分的平均值
#### 实现要点
```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`
#### 需求描述
- 从项目表获取难度系数
- 将平均分乘以难度系数
- 生成调整后的分数
#### 实现要点
```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);
}
```
#### 数据库字段
```sql
-- 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`
#### 需求描述
- 整合所有计算步骤
- 保存完整的成绩记录
- 记录计算明细(最高分、最低分、有效分数等)
#### 实现要点
```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`
#### 需求描述
- 按项目对所有运动员进行排名
- 处理并列排名情况
- 更新排名到数据库
#### 实现要点
```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`
#### 需求描述
- 自动分配金银铜牌给前三名
- 处理并列情况(如并列第一名,两人都得金牌)
- 更新奖牌字段
#### 实现要点
```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`
#### 需求描述
- 提供成绩复核接口
- 记录复核原因和结果
- 支持成绩调整
#### 实现要点
```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`
#### 需求描述
- 成绩确认为最终成绩
- 记录发布时间
- 限制已发布成绩的修改
#### 实现要点
```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 接口
```java
@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](./02-比赛日流程功能.md)