Files
martial-web/doc/schedule/archive/schedule-system-design.md
宅房 5b806e29b7
Some checks failed
continuous-integration/drone/push Build is failing
fix bugs
2025-12-11 16:56:19 +08:00

820 lines
28 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.
# 赛程编排系统设计文档
## 📋 文档说明
**版本**: v2.0
**创建日期**: 2025-12-08
**最后更新**: 2025-12-08
**状态**: 设计阶段
---
## 1. 业务需求概述
### 1.1 核心需求
武术赛事管理系统需要实现**自动赛程编排功能**,将参赛者智能分配到不同的场地和时间段,确保比赛有序进行。
### 1.2 关键特性
-**后端自动编排**使用Java后端定时任务自动编排前端只负责展示
-**集体优先原则**:集体项目优先编排,个人项目随后
-**负载均衡**:均匀分配到所有场地和时间段
-**定时刷新**每10分钟自动重新编排未保存状态
-**手动调整**:支持用户手动调整编排结果
-**锁定机制**:保存后锁定,不再自动编排
---
## 2. 业务规则
### 2.1 项目类型
#### 集体项目type=2
- **定义**:多人一场表演
- **时长**约5分钟/场
- **场地占用**:独占整个场地
- **示例**:太极拳男组(泰州太极拳小学:张三、李四、王五、小红、小花)
- **分组规则**:按"项目+组别"分组,同一分组内按单位列出
#### 个人项目type=1
- **定义**:单人表演
- **时长**约1分钟/人
- **场地占用**场地可同时容纳6人
- **示例**:太极拳个人男组(台州太极拳馆:洪坚立;泰州太极拳小学:李四)
- **分组规则**:按"项目+组别"分组,不限人数
### 2.2 时间段划分
```
每天分为两个时间段:
- 上午场08:30 - 11:30180分钟预留30分钟机动
- 下午场13:30 - 17:30240分钟预留30分钟机动
实际可用时间:
- 上午150分钟扣除间隔时间
- 下午210分钟扣除间隔时间
间隔时间每场比赛间隔1-2分钟选手准备
```
### 2.3 编排优先级
```
优先级排序:
1. 集体项目type=2
2. 个人项目type=1
同类型内部排序:
- 按项目ID升序
- 按组别category排序
- 按报名时间先后
```
### 2.4 分配策略
#### 场地分配
- **集体项目**:每个分组独占一个场地时间段
- **个人项目**每个场地时间段可容纳多个分组按6人/批次计算)
#### 时间段分配
- **负载均衡**:优先填充负载较轻的时间段
- **连续性**:同一项目的多个分组尽量安排在相邻时间段
- **容量检查**:确保不超过时间段容量
#### 计算公式
```
集体项目占用时长 = 队伍数 × 5分钟 + (队伍数-1) × 2分钟间隔
个人项目占用时长 = ⌈人数/6⌉ × (6分钟 + 2分钟间隔)
场地时间段容量:
- 上午150分钟
- 下午210分钟
```
---
## 3. 数据库设计
### 3.1 编排主表
```sql
CREATE TABLE `martial_schedule_group` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`competition_id` bigint(20) NOT NULL COMMENT '赛事ID',
`group_name` varchar(200) NOT NULL COMMENT '分组名称:太极拳男组',
`project_id` bigint(20) NOT NULL COMMENT '项目ID',
`project_name` varchar(100) DEFAULT NULL COMMENT '项目名称',
`category` varchar(50) DEFAULT NULL COMMENT '组别:成年组、少年组',
`project_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1=个人 2=集体',
`display_order` int(11) NOT NULL DEFAULT '0' COMMENT '显示顺序(集体优先)',
`total_participants` int(11) DEFAULT '0' COMMENT '总参赛人数',
`total_teams` int(11) DEFAULT '0' COMMENT '总队伍数(集体项目)',
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_competition` (`competition_id`),
KEY `idx_project` (`project_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛程编排分组表';
```
### 3.2 编排明细表(场地时间段分配)
```sql
CREATE TABLE `martial_schedule_detail` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`schedule_group_id` bigint(20) NOT NULL COMMENT '分组ID',
`competition_id` bigint(20) NOT NULL COMMENT '赛事ID',
`venue_id` bigint(20) NOT NULL COMMENT '场地ID',
`venue_name` varchar(100) DEFAULT NULL COMMENT '场地名称',
`schedule_date` date NOT NULL COMMENT '比赛日期',
`time_period` varchar(20) NOT NULL COMMENT '时间段morning/afternoon',
`time_slot` varchar(20) NOT NULL COMMENT '时间点08:30/13:30',
`estimated_start_time` datetime DEFAULT NULL COMMENT '预计开始时间',
`estimated_end_time` datetime DEFAULT NULL COMMENT '预计结束时间',
`estimated_duration` int(11) DEFAULT '0' COMMENT '预计时长(分钟)',
`participant_count` int(11) DEFAULT '0' COMMENT '参赛人数',
`sort_order` int(11) DEFAULT '0' COMMENT '场内顺序',
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_group` (`schedule_group_id`),
KEY `idx_venue_time` (`venue_id`, `schedule_date`, `time_slot`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛程编排明细表';
```
### 3.3 参赛者关联表
```sql
CREATE TABLE `martial_schedule_participant` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`schedule_detail_id` bigint(20) NOT NULL COMMENT '编排明细ID',
`schedule_group_id` bigint(20) NOT NULL COMMENT '分组ID',
`participant_id` bigint(20) NOT NULL COMMENT '参赛者ID',
`organization` varchar(200) DEFAULT NULL COMMENT '单位名称',
`player_name` varchar(100) DEFAULT NULL COMMENT '选手姓名',
`project_name` varchar(100) DEFAULT NULL COMMENT '项目名称',
`category` varchar(50) DEFAULT NULL COMMENT '组别',
`performance_order` int(11) DEFAULT '0' COMMENT '出场顺序',
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_detail` (`schedule_detail_id`),
KEY `idx_participant` (`participant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛程编排参赛者关联表';
```
### 3.4 编排状态表
```sql
CREATE TABLE `martial_schedule_status` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`competition_id` bigint(20) NOT NULL UNIQUE COMMENT '赛事ID',
`schedule_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=未编排 1=编排中 2=已保存锁定',
`last_auto_schedule_time` datetime DEFAULT NULL COMMENT '最后自动编排时间',
`locked_time` datetime DEFAULT NULL COMMENT '锁定时间',
`locked_by` varchar(100) DEFAULT NULL COMMENT '锁定人',
`total_groups` int(11) DEFAULT '0' COMMENT '总分组数',
`total_participants` int(11) DEFAULT '0' COMMENT '总参赛人数',
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_competition` (`competition_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛程编排状态表';
```
---
## 4. 后端编排算法设计
### 4.1 算法流程
```
┌─────────────────────────────────────────┐
│ 定时任务每10分钟执行一次 │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ 1. 检查赛事状态 │
│ - 如果已锁定(status=2),跳过 │
│ - 如果未开始,继续 │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ 2. 加载数据 │
│ - 赛事信息(开始/结束时间) │
│ - 场地列表 │
│ - 参赛者列表 │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ 3. 生成时间段网格 │
│ - 计算比赛天数 │
│ - 生成所有时间段(上午/下午) │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ 4. 自动分组 │
│ - 集体项目按"项目+组别"分组 │
│ - 个人项目按"项目+组别"分组 │
│ - 集体项目排在前面 │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ 5. 分配场地和时间段(负载均衡) │
│ - 初始化所有场地×时间段的负载 │
│ - 按时长降序处理分组 │
│ - 贪心算法:选择负载最小的位置 │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ 6. 保存到数据库 │
│ - 清空旧的编排数据 │
│ - 插入新的编排结果 │
│ - 更新编排状态 │
└─────────────────────────────────────────┘
```
### 4.2 核心算法伪代码
#### 4.2.1 自动分组算法
```java
public List<ScheduleGroup> autoGroupParticipants(List<Participant> participants) {
List<ScheduleGroup> groups = new ArrayList<>();
int displayOrder = 1;
// 1. 分离集体和个人项目
List<Participant> teamParticipants = participants.stream()
.filter(p -> p.getProjectType() == 2)
.collect(Collectors.toList());
List<Participant> individualParticipants = participants.stream()
.filter(p -> p.getProjectType() == 1)
.collect(Collectors.toList());
// 2. 集体项目分组:按"项目ID_组别"分组
Map<String, List<Participant>> teamGroupMap = teamParticipants.stream()
.collect(Collectors.groupingBy(p ->
p.getProjectId() + "_" + p.getCategory()
));
for (Map.Entry<String, List<Participant>> entry : teamGroupMap.entrySet()) {
List<Participant> members = entry.getValue();
Participant first = members.get(0);
// 统计队伍数(按单位分组)
long teamCount = members.stream()
.map(Participant::getOrganization)
.distinct()
.count();
ScheduleGroup group = new ScheduleGroup();
group.setGroupName(first.getProjectName() + " " + first.getCategory());
group.setProjectId(first.getProjectId());
group.setProjectType(2);
group.setDisplayOrder(displayOrder++);
group.setTotalParticipants(members.size());
group.setTotalTeams((int) teamCount);
group.setParticipants(members);
// 计算预计时长:队伍数 × 5分钟 + 间隔时间
int duration = (int) teamCount * 5 + ((int) teamCount - 1) * 2;
group.setEstimatedDuration(duration);
groups.add(group);
}
// 3. 个人项目分组:按"项目ID_组别"分组
Map<String, List<Participant>> individualGroupMap = individualParticipants.stream()
.collect(Collectors.groupingBy(p ->
p.getProjectId() + "_" + p.getCategory()
));
for (Map.Entry<String, List<Participant>> entry : individualGroupMap.entrySet()) {
List<Participant> members = entry.getValue();
Participant first = members.get(0);
ScheduleGroup group = new ScheduleGroup();
group.setGroupName(first.getProjectName() + " " + first.getCategory());
group.setProjectId(first.getProjectId());
group.setProjectType(1);
group.setDisplayOrder(displayOrder++);
group.setTotalParticipants(members.size());
group.setParticipants(members);
// 计算预计时长:人数/6向上取整× (6分钟 + 2分钟间隔)
int batches = (int) Math.ceil(members.size() / 6.0);
int duration = batches * 8;
group.setEstimatedDuration(duration);
groups.add(group);
}
return groups;
}
```
#### 4.2.2 场地时间段分配算法(负载均衡)
```java
public void assignVenueAndTimeSlot(List<ScheduleGroup> groups,
List<Venue> venues,
List<TimeSlot> timeSlots) {
// 1. 初始化负载表(场地 × 时间段)
Map<String, Integer> loadMap = new HashMap<>();
for (Venue venue : venues) {
for (TimeSlot timeSlot : timeSlots) {
String key = venue.getId() + "_" + timeSlot.getKey();
loadMap.put(key, 0);
}
}
// 2. 获取时间段容量
Map<String, Integer> capacityMap = new HashMap<>();
for (TimeSlot timeSlot : timeSlots) {
int capacity = timeSlot.getPeriod().equals("morning") ? 150 : 210;
capacityMap.put(timeSlot.getKey(), capacity);
}
// 3. 按预计时长降序排序(先安排时间长的)
groups.sort((a, b) -> b.getEstimatedDuration() - a.getEstimatedDuration());
// 4. 贪心算法分配
for (ScheduleGroup group : groups) {
String bestKey = null;
int minLoad = Integer.MAX_VALUE;
// 遍历所有场地×时间段组合
for (Venue venue : venues) {
for (TimeSlot timeSlot : timeSlots) {
String key = venue.getId() + "_" + timeSlot.getKey();
int currentLoad = loadMap.get(key);
int capacity = capacityMap.get(timeSlot.getKey());
// 检查容量是否足够
if (currentLoad + group.getEstimatedDuration() <= capacity) {
if (currentLoad < minLoad) {
minLoad = currentLoad;
bestKey = key;
}
}
}
}
// 分配到最佳位置
if (bestKey != null) {
String[] parts = bestKey.split("_");
long venueId = Long.parseLong(parts[0]);
String timeSlotKey = parts[1];
group.setVenueId(venueId);
group.setTimeSlotKey(timeSlotKey);
// 更新负载
loadMap.put(bestKey, loadMap.get(bestKey) + group.getEstimatedDuration());
}
}
}
```
### 4.3 算法复杂度分析
- **自动分组算法**: O(n)n为参赛者数量
- **场地分配算法**: O(g × v × t)g为分组数v为场地数t为时间段数
- **总体复杂度**: O(n + g×v×t)
对于1000人5个场地10个时间段
- 分组: O(1000) ≈ 1ms
- 分配: O(100×5×10) = O(5000) ≈ 5ms
- **总耗时**: < 10ms
---
## 5. 前端展示设计
### 5.1 页面布局
```
┌────────────────────────────────────────────────────────────┐
│ 编排 - 郑州协会全国运动大赛 [返回] │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ [竞赛分组] [场地] │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ 竞赛分组内容区 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 太极拳男组 集体 2队 2组 1101 │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 1. 少林寺武校 │ │ │
│ │ │ [场A 2025-11-06 08:30] [场A 2025-11-06 13:30] ...│
│ │ │ 2. 洛阳武校 │ │ │
│ │ │ [场B 2025-11-06 08:30] [场B 2025-11-06 13:30] ...│
│ │ └─────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 2. 长拳个人男组 个人 3人 1个A 1102 │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 1. 少林寺武校 张三 │ │ │
│ │ │ [场A 2025-11-06 08:30] │ │ │
│ │ │ 2. 洛阳武校 李四 │ │ │
│ │ │ [场A 2025-11-06 08:30] │ │ │
│ │ │ 3. 少林寺武校 王五 │ │ │
│ │ │ [场B 2025-11-06 13:30] │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ [保存编排] │
└────────────────────────────────────────────────────────────┘
```
### 5.2 数据结构
```javascript
// 前端数据结构
{
competitionInfo: {
competitionId: 200,
competitionName: "郑州协会全国运动大赛",
startDate: "2025-11-06",
endDate: "2025-11-10"
},
scheduleGroups: [
{
id: 1,
groupName: "太极拳男组",
projectType: 2, // 集体
displayOrder: 1,
totalParticipants: 10,
totalTeams: 2,
// 按单位组织的参赛者(集体项目)
organizationGroups: [
{
organization: "少林寺武校",
participants: [
{ id: 1, playerName: "张三", ... },
{ id: 2, playerName: "李四", ... }
],
scheduleDetails: [
{
venueId: 1,
venueName: "场A",
scheduleDate: "2025-11-06",
timePeriod: "morning",
timeSlot: "08:30"
}
]
},
{
organization: "洛阳武校",
participants: [...],
scheduleDetails: [...]
}
]
},
{
id: 2,
groupName: "长拳个人男组",
projectType: 1, // 个人
displayOrder: 2,
totalParticipants: 3,
// 个人项目直接列出参赛者
participants: [
{
id: 10,
organization: "少林寺武校",
playerName: "张三",
scheduleDetail: {
venueId: 1,
venueName: "场A",
scheduleDate: "2025-11-06",
timePeriod: "morning",
timeSlot: "08:30"
}
},
{
id: 11,
organization: "洛阳武校",
playerName: "李四",
scheduleDetail: {...}
}
]
}
]
}
```
### 5.3 场地按钮点击交互
当用户点击某个场地时间段按钮时:
```javascript
handleVenueTimeClick(participant, scheduleDetail) {
// 弹出对话框显示该时间段该场地的详细信息
this.$alert(`
<h3>场地详情</h3>
<p>场地: ${scheduleDetail.venueName}</p>
<p>时间: ${scheduleDetail.scheduleDate} ${scheduleDetail.timeSlot}</p>
<p>参赛者: ${participant.organization} - ${participant.playerName}</p>
<p>项目: ${participant.projectName}</p>
`, '场地时间段详情', {
dangerouslyUseHTMLString: true
});
}
```
---
## 6. 后端定时任务设计
### 6.1 定时任务配置
```java
@Component
@EnableScheduling
public class ScheduleAutoArrangeTask {
@Autowired
private IScheduleService scheduleService;
/**
* 每10分钟执行一次自动编排
* cron: 0 */10 * * * ?
*/
@Scheduled(cron = "0 */10 * * * ?")
public void autoArrangeSchedule() {
log.info("开始执行自动编排任务...");
try {
// 查询所有未锁定的赛事
List<Long> competitionIds = scheduleService.getUnlockedCompetitions();
for (Long competitionId : competitionIds) {
try {
// 执行自动编排
scheduleService.autoArrange(competitionId);
log.info("赛事[{}]自动编排完成", competitionId);
} catch (Exception e) {
log.error("赛事[{}]自动编排失败", competitionId, e);
}
}
} catch (Exception e) {
log.error("自动编排任务执行失败", e);
}
}
}
```
### 6.2 编排服务接口
```java
public interface IScheduleService {
/**
* 自动编排
* @param competitionId 赛事ID
*/
void autoArrange(Long competitionId);
/**
* 获取未锁定的赛事列表
* @return 赛事ID列表
*/
List<Long> getUnlockedCompetitions();
/**
* 保存编排(锁定)
* @param competitionId 赛事ID
* @param userId 用户ID
*/
void saveAndLock(Long competitionId, String userId);
/**
* 获取编排结果
* @param competitionId 赛事ID
* @return 编排数据
*/
ScheduleResult getScheduleResult(Long competitionId);
/**
* 手动调整编排
* @param adjustRequest 调整请求
*/
void adjustSchedule(ScheduleAdjustRequest adjustRequest);
}
```
---
## 7. API接口设计
### 7.1 获取编排结果
```
GET /api/martial/schedule/result/{competitionId}
Response:
{
"code": 200,
"msg": "success",
"data": {
"competitionId": 200,
"scheduleStatus": 1, // 0=未编排 1=编排中 2=已锁定
"lastAutoScheduleTime": "2025-11-06 10:00:00",
"totalGroups": 45,
"totalParticipants": 1100,
"scheduleGroups": [
{
"id": 1,
"groupName": "太极拳男组",
"projectType": 2,
"displayOrder": 1,
"organizationGroups": [...]
},
...
]
}
}
```
### 7.2 保存并锁定编排
```
POST /api/martial/schedule/save-and-lock
Request:
{
"competitionId": 200,
"userId": "admin"
}
Response:
{
"code": 200,
"msg": "编排已保存并锁定"
}
```
### 7.3 手动调整编排
```
POST /api/martial/schedule/adjust
Request:
{
"competitionId": 200,
"participantId": 123,
"targetVenueId": 2,
"targetDate": "2025-11-06",
"targetTimeSlot": "13:30"
}
Response:
{
"code": 200,
"msg": "调整成功"
}
```
---
## 8. 测试数据设计
### 8.1 集体项目测试数据
需要生成100个集体项目的参赛队伍
```
项目分布:
- 太极拳集体20个单位
- 长拳集体20个单位
- 剑术集体20个单位
- 刀术集体20个单位
- 棍术集体20个单位
每个单位5人共100个队伍500人
```
### 8.2 测试数据总计
```
原有个人项目1000人
新增集体项目500人100个队伍
总计1500人
预计分组:
- 集体项目分组约20个按项目+组别)
- 个人项目分组约25个
- 总计约45个分组
```
---
## 9. 技术实现要点
### 9.1 后端技术栈
- **Spring Boot**: 2.x
- **MyBatis-Plus**: 数据访问
- **Quartz**: 定时任务调度
- **Redis**: 编排结果缓存(可选)
### 9.2 前端技术栈
- **Vue 3**: 前端框架
- **Element Plus**: UI组件
- **Axios**: HTTP请求
### 9.3 性能优化
1. **批量查询**:一次性加载所有参赛者
2. **结果缓存**编排结果缓存10分钟
3. **增量编排**:只对新增参赛者进行增量编排(可选)
4. **索引优化**:场地、时间段联合索引
---
## 10. 实施计划
### 阶段1数据库和测试数据第1天
- ✅ 创建数据库表
- ✅ 生成集体项目测试数据
- ✅ 验证数据完整性
### 阶段2后端编排算法第2-3天
- ⏳ 实现自动分组算法
- ⏳ 实现场地时间段分配算法
- ⏳ 实现定时任务
- ⏳ 单元测试
### 阶段3后端API接口第4天
- ⏳ 获取编排结果接口
- ⏳ 保存锁定接口
- ⏳ 手动调整接口
### 阶段4前端展示页面第5-6天
- ⏳ 修改页面布局
- ⏳ 实现集体/个人不同展示
- ⏳ 实现场地时间段按钮点击
- ⏳ 集成后端API
### 阶段5测试和优化第7天
- ⏳ 功能测试
- ⏳ 性能测试
- ⏳ 用户验收测试
---
## 11. 风险和注意事项
### 11.1 容量不足风险
**风险**:参赛人数过多,所有场地时间段容量不足
**解决方案**
- 编排前进行容量校验
- 提示用户增加比赛天数或场地
- 自动建议最少需要的天数
### 11.2 数据一致性
**风险**:定时任务执行时用户正在查看页面
**解决方案**
- 前端轮询检查编排时间戳
- 如有更新,提示用户刷新
- 锁定状态下不再自动编排
### 11.3 并发冲突
**风险**:多个定时任务同时执行
**解决方案**
- 使用分布式锁Redis
- 数据库乐观锁
- 任务执行状态标记
---
**文档版本**: v2.0
**创建人**: Claude Code
**审核人**: 待定
**状态**: 设计中