# 赛程编排系统设计文档 ## 📋 文档说明 **版本**: 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:30(180分钟,预留30分钟机动) - 下午场:13:30 - 17:30(240分钟,预留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 autoGroupParticipants(List participants) { List groups = new ArrayList<>(); int displayOrder = 1; // 1. 分离集体和个人项目 List teamParticipants = participants.stream() .filter(p -> p.getProjectType() == 2) .collect(Collectors.toList()); List individualParticipants = participants.stream() .filter(p -> p.getProjectType() == 1) .collect(Collectors.toList()); // 2. 集体项目分组:按"项目ID_组别"分组 Map> teamGroupMap = teamParticipants.stream() .collect(Collectors.groupingBy(p -> p.getProjectId() + "_" + p.getCategory() )); for (Map.Entry> entry : teamGroupMap.entrySet()) { List 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> individualGroupMap = individualParticipants.stream() .collect(Collectors.groupingBy(p -> p.getProjectId() + "_" + p.getCategory() )); for (Map.Entry> entry : individualGroupMap.entrySet()) { List 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 groups, List venues, List timeSlots) { // 1. 初始化负载表(场地 × 时间段) Map loadMap = new HashMap<>(); for (Venue venue : venues) { for (TimeSlot timeSlot : timeSlots) { String key = venue.getId() + "_" + timeSlot.getKey(); loadMap.put(key, 0); } } // 2. 获取时间段容量 Map 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(`

场地详情

场地: ${scheduleDetail.venueName}

时间: ${scheduleDetail.scheduleDate} ${scheduleDetail.timeSlot}

参赛者: ${participant.organization} - ${participant.playerName}

项目: ${participant.projectName}

`, '场地时间段详情', { 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 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 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 **审核人**: 待定 **状态**: 设计中