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

28 KiB
Raw Permalink Blame History

赛程编排系统设计文档

📋 文档说明

版本: 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 编排主表

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 编排明细表(场地时间段分配)

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 参赛者关联表

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 编排状态表

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 自动分组算法

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 场地时间段分配算法(负载均衡)

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 数据结构

// 前端数据结构
{
  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 场地按钮点击交互

当用户点击某个场地时间段按钮时:

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 定时任务配置

@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 编排服务接口

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 审核人: 待定 状态: 设计中