ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java+Vue 课后服务选课排班系统:并发控制与智能冲突检测

Java+Vue 课后服务选课排班系统:并发控制与智能冲突检测 简介面向中小学教育信息化场景的JavaVue全栈项目资料适合具备一定前后端基础、关注课后服务数字化管理的开发者与师生。围绕选课排班不透明、资源配置低效、家校沟通不畅等痛点给出可落地的业务方案。资源共1个docx文件压缩包约171KB以文档形式集中呈现数据库建模、API接口规范、后端业务逻辑与前端交互设计便于按模块查阅与二次开发。内容覆盖多角色权限与未成年人信息保护、选课并发与容量一致性、候补转正排序、时间冲突检测、教师负荷与场地匹配评分、半自动智能排班以及消息触达与阅读率统计等模型并配有对应代码示例与实现思路。目前已有205人学习适合作为课程设计、毕业设计或教育信息化项目的参考案例帮助读者理顺从需求分析、领域建模到前后端联调的完整流程。1. 课后服务选课排班为什么不能靠 Excel从业务约束到技术选型一所 1200 人的小学秋季学期开出 40 门课后服务课程每周 5 个时段60 位教师、28 间教室可用。教务老师用一台电脑收选课数据等家长群里的接龙刷到几百条再合并单元格统计结果是三件事往往同时发生热门课报了 58 人但只招 30 人两位老师在同一时段被排了不同的课同一间美术教室被两门课同时占用。真正麻烦的不是统计慢而是约束没人守——课程容量、教师时间、教室占用、年级范围全靠人眼交叉核对改一次志愿就要全表重排。这个标题要落地的是把选课、排班、家校沟通三条线收进一套 Java Vue 的系统Java 侧承担事务、并发控制和排班求解Vue 侧给教务、教师、家长三类角色分别做界面数据库把业务约束写成唯一索引与检查条件让违规数据在写入那一刻就被挡住。适合正在做教育信息化项目的开发者、需要完整课程设计或毕业设计选题的同学以及要评估自研还是采购现成系统的校内信息化负责人。2. 基于 JavaVue 的课后服务平台数据建模从志愿到排班的库表设计数据模型决定了后面算法能写多简单。很多人一上来只建student、course、teacher三张表等做排班时才发现同一门课在不同时段开几个教学班一个学生在一个时段只能上一门课这些规则没地方放只能在 Java 里写一堆 if。正确的做法是先把实体边界定清楚再让数据库帮忙守一部分约束。2.1 先定边界哪些实体必须拆开哪些可以并表必须拆开的是「课程」和「教学班」。课程是元信息名称、类别、适用年级、默认容量教学班是某个学期某位老师某个时段的具体开班一门课可以开多个教学班分散容量。把这两者合一就会出现课程容量字段不知道该填每班人数还是总人数的经典歧义。时段也要独立成表而不是在排班表里存weekday start_time字符串。独立成time_slot后冲突检测只需要比较slot_id索引能用上SQL 也短。选课志愿和学生最终选中的课程建议分开存志愿表记录想选什么、优先级多少录取表记录实际选上了什么这样抽签、调剂、退课三条业务流程互不污染。2.2 MySQL 建表容量、志愿优先级与时段唯一约束下面是核心几张表的最小可用定义字段注释直接写清楚业务含义方便后续对接前端表单。-- 课程元信息 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 课程名如 Scratch 编程, category VARCHAR(32) NOT NULL COMMENT 体育/艺术/科技/学业辅导, grade_scope VARCHAR(32) NOT NULL COMMENT 可参与年级逗号分隔如 1,2,3, capacity INT NOT NULL DEFAULT 30 COMMENT 单个教学班容量, status TINYINT NOT NULL DEFAULT 1 COMMENT 0 停开 1 开放选课 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 时段字典一周 5 个课后服务时段 CREATE TABLE time_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, term_id BIGINT NOT NULL, weekday TINYINT NOT NULL COMMENT 1~7, start_at TIME NOT NULL, end_at TIME NOT NULL, UNIQUE KEY uk_slot (term_id, weekday, start_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 教学班排班的基本单位 CREATE TABLE teach_class ( id BIGINT PRIMARY KEY AUTO_INCREMENT, term_id BIGINT NOT NULL, course_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, room_id BIGINT NULL, slot_id BIGINT NOT NULL, capacity INT NOT NULL COMMENT 本班容量通常等于 course.capacity, taken INT NOT NULL DEFAULT 0 COMMENT 已录取人数唯一可变的计数列, UNIQUE KEY uk_teacher_slot (term_id, slot_id, teacher_id), UNIQUE KEY uk_room_slot (term_id, slot_id, room_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课志愿一个学生一个学期内同一门课只能填一次 CREATE TABLE elective_volunteer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, term_id BIGINT NOT NULL, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, priority TINYINT NOT NULL COMMENT 1 为第一志愿, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course (term_id, student_id, course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_teacher_slot和uk_room_slot这两个唯一索引是整套设计里性价比最高的两行代码教师同一时段被排两门课、教室同一时段被占两次这两种错误在数据库层就写入失败了不需要等到排班算法跑完再去人工检查。代价是排班求解时要做冲突预判不能盲目写入。2.3 家校协同的消息、考勤与评价表怎么落家校沟通这部分经常被当成发个通知而已随手建一张message表了事结果家长端刷新不出来未读、老师看不到谁没看。至少要拆成三张notice存通知正文notice_receipt存每个接收人的阅读回执attendance存考勤。表名关键字段设计要点noticeid, term_id, class_id, title, content, publish_at按教学班发布不做全校广播notice_receiptnotice_id, student_id, read_at, channel联合唯一键回执幂等attendanceteach_class_id, student_id, lesson_date, statusstatus 用枚举整数便于统计eval_recordteach_class_id, student_id, score, comment家长评价课程一学生对一班一条考勤表的唯一键建议设成(teach_class_id, student_id, lesson_date)教师重复点提交时不会插出两条记录前端也不需要额外做去重逻辑。评价表同理一学期一学生对一个教学班只留一条评价允许更新不允许新增。3. Spring Boot 选课接口与并发控制把「抢课」写对选课是典型的短时高并发场景。一个小学在开放选课的十分钟内可能有六百个家长同时点提交热门课的教学班taken字段就成了热点行。这一章把选课链路拆开分别说清哪些步骤该放在事务里、哪些该异步做以及 Vue 端怎么配合。3.1 选课链路志愿提交、按优先级录取、结果确认链路分三段。第一段是志愿提交家长在开放窗口内提交 1 到 3 个志愿写进elective_volunteer这一步只做格式和数量校验不碰容量。第二段是录取按志愿优先级分轮次处理先处理所有第一志愿容量够就直接录超了就按规则抽签第一志愿没录上的进入第二志愿轮以此类推。第三段是结果确认生成录取记录并把taken落定家长端看到结果未录满的教学班开放补选。把三段拆开的好处是每段都可以重跑。抽签算法调整后只要清空录取表就能重放志愿数据不用让家长重填。3.2 用条件更新挡住超额录取而不是先查后改最常见的错误写法是先select出taken和capacity在 Java 里比较一下再update。两个请求同时进入时都会读到taken 29然后都判定可以录最后taken变成 31。正确做法是把判断塞进update的where里Service public class ElectiveService { Autowired private TeachClassMapper classMapper; Autowired private ElectiveMapper electiveMapper; /** 录取一个学生到指定教学班返回是否成功 */ Transactional(rollbackFor Exception.class) public boolean admit(Long termId, Long studentId, Long classId) { // 条件更新只有余量 0 时才 1影响行数为 0 说明已满 int rows classMapper.increaseTaken(classId); if (rows 0) { return false; // 容量已满本轮不录取留给下一志愿 } // 录取表加唯一索引 (term_id, student_id, class_id)重复录取会被拦截 electiveMapper.insertAdmit(termId, studentId, classId); return true; } }对应的 Mapper SQL 只有一句注意taken capacity必须写在where条件里不能写在 Java 里update idincreaseTaken UPDATE teach_class SET taken taken 1 WHERE id #{classId} AND taken lt; capacity /updatetaken capacity放进where之后InnoDB 会在这一行上加排他锁后续并发请求排队等待等第一个事务提交后重新读取最新值判断。影响行数为 0 就是标准信号名额已经没了。这个模式比select ... for update更省一次往返也不需要显式加锁。提示taken与录取表必须放在同一个事务里。如果先更新计数再插录取记录中间抛异常脏计数会永久占掉一个名额。3.3 Vue 选课页路由划分与提交后的状态处理前端按角色拆路由是最省事的组织方式/parent/elective走家长选课/teacher/class走教师查看本班名单/admin/schedule走教务排班。Vue 路由参数里带上termId避免用户切换学期后页面数据串台。提交逻辑用一个按钮加互斥状态就够了不要靠前端去算余量// 家长端提交选课志愿 async function submitVolunteer() { loading.value true; try { // courseIds 已按家长拖拽顺序排好数组下标即优先级 const res await api.post(/api/elective/volunteer, { termId: route.params.termId, courseIds: selected.value }); // 后端返回成功只代表志愿已受理不代表已录取 message.success(志愿已提交录取结果将在截止后公布); await loadVolunteerState(); // 重新拉取回显状态 } finally { loading.value false; } }前端页面上显示的剩余名额是参考值真实判定以后端条件更新的结果为准。两者不一致时以后端为准页面刷新后自然收敛。课程的增删改查、教学班的名单查看这类后台功能用统一的表格组件按termId过滤即可不建议给每个角色单独写一套 CRUD 页面。4. 智能排班落地教师、教室、时段三重冲突检测排班是这套系统里唯一真正带算法味道的部分。先把问题说清楚给定若干门课的开班需求、教师可用时段、教室可用时段和班级可用时段为每个教学班分配一个(teacher, room, slot)三元组满足全部硬约束尽量满足软约束。4.1 硬约束与软约束分开建模硬约束必须满足一条不满足整个方案就不合法软约束用来在多个合法解之间挑更好的那个。这个区分很关键因为只有硬约束能交给数据库唯一索引兜底软约束只能在求解阶段打分。约束类型落地方式教师同一时段只能带一个班硬uk_teacher_slot唯一索引教室同一时段只能被一个班占用硬uk_room_slot唯一索引班级同一时段只能上一门课硬uk_class_slot唯一索引教师不可用时段不得排课硬求解前预过滤候选集合同一教师课程尽量集中在相邻时段软打分函数优先选相邻解热门课程的时段分散软打分函数避免全挤在周一班级维度的唯一索引要和前两个一起加上否则会出现同一个班同时被排了两门课这种情况——它不违反教师和教室约束索引也拦不住只能靠约束表补齐。4.2 回溯求解按候选数最少的班优先分配排班规模不大几十个班、几十个时段不需要上遗传算法或整数规划求解器。用带启发式的回溯就足够关键两个优化按可选值个数升序排序MRV以及每步分配后立即做前向检查把必然无解的候选剪掉。public class ScheduleSolver { private final ListClassDemand demands; // 待排教学班 private final SetString occupied; // key slotId:teacherId / slotId:roomId / slotId:classId /** 返回排班结果无解返回空列表 */ public ListAssignment solve() { // MRV 启发式候选时段最少的班先排尽早暴露无解分支 demands.sort(Comparator.comparingInt(d - d.availableSlots.size())); ListAssignment result new ArrayList(); return backtrack(0, result) ? result : Collections.emptyList(); } private boolean backtrack(int idx, ListAssignment result) { if (idx demands.size()) { return true; // 全部排完 } ClassDemand d demands.get(idx); for (Long slotId : d.availableSlots) { // 已按软约束打分排序 if (!canPlace(d, slotId)) { continue; // 三重冲突检查含前向检查 } mark(d, slotId, true); result.add(new Assignment(d.classId, slotId, d.teacherId, d.roomId)); if (backtrack(idx 1, result)) { return true; } // 回退撤销占用和结果 result.remove(result.size() - 1); mark(d, slotId, false); } return false; } private boolean canPlace(ClassDemand d, Long slotId) { return !occupied.contains(slotId : d.teacherId) !occupied.contains(slotId : d.roomId) !occupied.contains(slotId : d.classId); } }availableSlots在进入回溯前就要算好教师不可用时段、教室不可用时段、班级已有安排时段全部剔除再按软约束得分从高到低排序。这一步预过滤能砍掉大部分无效分支实测比边搜边判断快得多。mark方法负责往occupied集合里加或删三个键和数据库里的三个唯一索引一一对应。求解失败时不要直接报排班失败而是输出冲突最集中的班和时段统计每个ClassDemand的可用时段数量数量小于 2 的先列出来通常是教师可用时段填得太窄或教室数量不足。教务拿着这份清单去调资源比看一个布尔值有用得多。4.3 冲突检测 SQL 与索引复核排班结果写库前用一段 SQL 做最终复核比在 Java 里逐条比对更可靠因为它读的是库里的真实数据包括其他并发操作可能刚写入的行-- 检查同一时段教师是否被重复排课 SELECT term_id, slot_id, teacher_id, COUNT(*) AS cnt FROM teach_class WHERE term_id #{termId} GROUP BY term_id, slot_id, teacher_id HAVING cnt 1; -- 检查同一时段教室是否被重复占用 SELECT term_id, slot_id, room_id, COUNT(*) AS cnt FROM teach_class WHERE term_id #{termId} AND room_id IS NOT NULL GROUP BY term_id, slot_id, room_id HAVING cnt 1;这两条查询要跑得快靠的就是前面建的uk_teacher_slot和uk_room_slot索引分组统计可以走索引顺序扫描。如果实测变慢先看EXPLAIN是不是出现了临时表通常是term_id选择性太低可以考虑把term_id放到联合索引最前面。注意room_id允许为 NULL 的时段MySQL 唯一索引不会把多个 NULL 判定为重复。如果某类课程确实不需要固定教室冲突检测要单独写逻辑处理不能只依赖索引。5. 家校协同上线前的验证回执对账、排班验收与压测切入点系统开发完最容易翻车的地方不是算法而是上线第一天的数据一致性。家长说没收到通知、老师说考勤少了一个人、教务说排好的课表在家长端看不全这些都属于结果验证环节没做扎实。5.1 通知回执对账用一条 SQL 找出没触达的人通知发布后不要只看已发送的数量。以教学班为单位做接收人对账把应接收名单和实际回执做差集-- 找出某条通知中未产生回执的学生 SELECT s.id, s.name FROM teach_class_member m JOIN student s ON s.id m.student_id LEFT JOIN notice_receipt r ON r.notice_id #{noticeId} AND r.student_id m.student_id WHERE m.teach_class_id #{classId} AND r.id IS NULL;查到未回执名单后教师端页面上直接给一个再次提醒按钮按student_id重投即可。重投要允许重复执行靠notice_receipt上的联合唯一键做幂等不要用先删后插的方式处理。5.2 排班结果的自动化验收脚本排班方案定稿后写一个可重复执行的验收脚本把三项硬约束和人数合理性一次性跑完。以下用脚本调 SQL 的方式说明实际项目里可以直接写成集成测试#!/usr/bin/env bash # 排班结果验收任一检查失败即退出非零 set -e TERM_ID${1:?用法: verify_schedule.sh termId} run_check () { local name$1 sql$2 local cnt cnt$(mysql -N -B -e $sql edu_service) if [ $cnt ! 0 ]; then echo 校验失败: $name, 异常记录数$cnt; exit 1 fi echo 通过: $name } run_check 教师时段冲突 \ SELECT COUNT(*) FROM (SELECT 1 FROM teach_class WHERE term_id$TERM_ID GROUP BY slot_id,teacher_id HAVING COUNT(*)1) t run_check 教室时段冲突 \ SELECT COUNT(*) FROM (SELECT 1 FROM teach_class WHERE term_id$TERM_ID AND room_id IS NOT NULL GROUP BY slot_id,room_id HAVING COUNT(*)1) t run_check 班级时段冲突 \ SELECT COUNT(*) FROM (SELECT 1 FROM teach_class WHERE term_id$TERM_ID GROUP BY slot_id,class_id HAVING COUNT(*)1) t run_check 超额录取 \ SELECT COUNT(*) FROM teach_class WHERE term_id$TERM_ID AND takencapacity脚本把校验逻辑和学期参数解耦换学期只要传新的termId。把它挂到发布流程里每次调整排班算法后自动跑一遍比人工抽查可靠。5.3 三个容易被忽略的参数与验证习惯第一个是志愿数量的上限。小学低年级家长倾向于把三个志愿全填热门课上限设成 3 还是 2直接影响抽签环节的落选率建议先用一个学期的历史数据回放一遍再定。第二个是capacity的缓冲值实际到课率通常低于录取数可按 5% 到 10% 预留但别在数据库里直接改容量用超额录取控制更透明。第三个是考勤的补录时间窗教师隔天补录属于常态lesson_date之外要允许一个updated_at字段记录实际录入时间方便后续追溯。验证习惯上建议每次改完排班代码都用同一个学期的固定数据跑一次全量求解记录求解耗时和解的数量。耗时突然翻倍通常意味着预过滤失效解的数量突然减少通常意味着某条硬约束被写严了这两种变化都比页面看起来正常更早暴露问题。本文还有配套的精品资源点击获取
返回列表