All checks were successful
continuous-integration/drone/push Build is passing
本次提交完成了武术比赛系统的核心功能模块,包括: ## 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>
594 lines
15 KiB
Markdown
594 lines
15 KiB
Markdown
# 成绩计算引擎 - 详细任务清单
|
||
|
||
**优先级:** 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)
|