简介:这份资源面向具备一定Java与Vue基础的研发人员、软件工程专业师生及教育信息化开发者,聚焦中小学课后服务中流程不规范、资源错配、选课不公、家校沟通低效等痛点,给出基于Spring Boot与Vue前后端分离架构的全栈项目实例。内容覆盖多角色权限与未成年人信息保护、事务与锁机制下的选课容量一致性、候补转正队列、教师负荷与场地匹配评分、时间冲突检测、半自动智能排班、消息触达与阅读率统计,以及数据库建模、API接口规范与部署方案,形成可落地、可维护的工程案例。资源包共1个docx文档,压缩包约171KB,以图文文档形式串联需求分析、系统设计、代码示例与前端交互说明,便于按模块查阅。目前已有205人学习,适合作为课程设计、毕业设计或教育信息化项目立项的参考,帮助读者从数据库实体关系到核心业务接口逐步实践,掌握事务控制、并发处理与算法设计的落地思路。
1. 课后服务排课这件事,难的不是写代码而是约束太多
很多学校的课后服务排课,第一年靠 Excel,第二年在微信群里接龙,第三年就顶不住了。一个年级 300 个学生、20 门课程、每门课限 30 人、每周两次、要避开主科老师的教研时间、还要兼顾场地不撞车——这些条件叠加起来,手工排一次要三天,改一个学生就要重来。
基于 Java + Vue 的中小学课后服务智能选课排班与家校协同平台,要解决的就是这件事:把选课、分班、排课、通知、请假、评价串成一条线,让教务从"表格管理员"变成"规则维护者"。技术选型上,Java 侧负责业务约束与排课算法,Vue 侧负责把选课结果和课表可视化成家长看得懂的样子。
适合读者:正在做教育信息化项目的后端与前端工程师、需要把课程设计和真实业务对齐的学生开发者。下面从数据模型讲到排课算法,再讲到前端交互和踩坑。
2. Java 后端:选课与排班的数据建模和约束落地
排班系统的稳定性,八成取决于表结构设计是否留了余地。随口就建一张course表、一张student_course表,做到第二步"同一位老师在两个班同一时段"就崩了。常见做法是把"课程"和"课程开设的这个具体班次"拆开,再单独抽出"时间段"这个维度。
2.1 五张核心表的职责划分
我一般会保留这样五个核心实体,其余都是它们的组合:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| student | 学生档案,含年级、班级 | id, name, grade_id, class_id |
| course | 课程定义,不含时间地点 | id, name, capacity, teacher_id, grade_scope |
| course_slot | 课程开设的具体班次 | id, course_id, term_id, weekday, period, room_id |
| enrollment | 选课报名记录 | id, student_id, slot_id, status, created_at |
| slot_conflict | 冲突检测结果缓存 | id, slot_a, slot_b, conflict_type |
拆成course+course_slot的好处是:同一门"篮球"可以开三个班次,分别放在周二、周四、周五,容量各自独立;而课程本身的学分、教师、适用年级只维护一份。enrollment里的status至少要有PENDING、CONFIRMED、DROPPED、WAITLIST四种,抽签和补录都靠它。slot_conflict是排课时的中间产物,把"哪些班次撞了"算好落库,前端展示冲突矩阵时直接查,不用每次重算。
2.2 用 JPA 定义实体与唯一约束
下面这段是course_slot和enrollment的实体定义,重点看约束注解,它决定了数据库层面能不能拦住脏数据:
@Entity @Table( name = "course_slot", uniqueConstraints = @UniqueConstraint( name = "uk_room_weekday_period", columnNames = {"room_id", "weekday", "period", "term_id"} ) ) public class CourseSlot { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "course_id", nullable = false) private Course course; @Column(nullable = false) private Integer weekday; // 1=周一 ... 7=周日 @Column(nullable = false) private Integer period; // 第几节,取值受 period_dict 控制 @Column(name = "room_id", nullable = false) private Long roomId; @Column(name = "term_id", nullable = false) private Long termId; } @Entity @Table( name = "enrollment", uniqueConstraints = @UniqueConstraint( name = "uk_student_slot", columnNames = {"student_id", "slot_id"} ) ) public class Enrollment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "student_id", nullable = false) private Long studentId; @Column(name = "slot_id", nullable = false) private Long slotId; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 16) private Status status = Status.PENDING; public enum Status { PENDING, CONFIRMED, WAITLIST, DROPPED } }逻辑说明:course_slot上的联合唯一约束保证"同一学期、同一教室、同一个星期几的同一节次"只能有一条记录,把场地冲突挡在数据库层,比在业务代码里到处 if 判断可靠得多。enrollment上的uk_student_slot防止同一学生对同一班次重复报名——这是接龙改成系统后最常见的一类脏数据来源。
参数说明:weekday用 1~7 而不是枚举字符串,是为了排序和位图运算方便;period不写死 1~8,而是留整数位,因为不同学校的作息节次不同。status用EnumType.STRING而不是 ORDINAL,避免以后插一个枚举值导致历史数据错位。
提示:外键约束在一个学校几千名学生、几万条选课记录的量级下完全撑得住,不要因为怕慢就全省掉,冲突数据清理成本远高于加索引。
2.3 容量控制:不要用 count 判断,用带条件的更新
选课高并发发生在开放选课的头几分钟。用"先 count 再 insert"的写法,两个请求同时读到 29,会双双插入变成 31 人。常见可靠做法是库存扣减思想,把已用名额放到course_slot上直接做原子更新:
UPDATE course_slot SET enrolled_count = enrolled_count + 1 WHERE id = #{slotId} AND enrolled_count < capacity; -- 再根据影响行数决定后续动作 SELECT enrolled_count, capacity FROM course_slot WHERE id = #{slotId};逻辑说明:第一条 SQL 把"有没有余量"和"占用名额"合成一个原子操作,enrolled_count < capacity写在 WHERE 里,MySQL 的 InnoDB 会加行锁,返回影响行数为 1 才代表抢到名额,为 0 就直接返回"已满,是否加入候补"。
参数说明:capacity不要和enrolled_count放两张表,否则无法在一条语句里做条件比较。如果还要考虑"候补自动转正",可以在DROPPED时反向enrolled_count - 1,再查enrollment里最早的WAITLIST记录补位。
3. 排课算法:把约束写成可校验的评分函数
排课在很多项目里被写成"一个巨大的 if 网络",最后没人敢改。我的经验是把它拆成两件事:一是把硬约束写成独立的合法性校验方法,二是把软约束写成分数,用局部搜索去优化。
3.1 硬约束与软约束分别是什么
硬约束是"违反了就不能用"的规则,典型有四条:同一教师同一时段只能有一个班次、同一教室同一时段只能有一个班次、同一学生同一时段只能选一门课、班次人数不能超过容量。这四条必须在生成候选解的时候就用校验器过滤掉。
软约束是"违反了不好但可以接受"的规则,例如:尽量不让同一学生连上两节、尽量让同一门课分散在不同星期的不同节次、尽量把低年级学生安排在离教室楼近的场地。软约束用加权分数表达,权重由教务在后台配。
| 约束类型 | 举例 | 处理方式 |
|---|---|---|
| 硬约束 | 教师时段冲突 | 候选解生成时直接过滤 |
| 硬约束 | 教室时段冲突 | 数据库唯一约束 + 过滤 |
| 软约束 | 学生连堂 | 减分,权重可调 |
| 软约束 | 课程时间分布 | 减分,权重可调 |
3.2 用回溯 + 评分做排班的最小实现
完整的智能排课可以上遗传算法或约束求解器,但在一个学期几百个班次的规模下,带评分的回溯搜索足够,而且结果可解释。下面是一个精简版本:
public class Scheduler { private final List<CourseSlot> slots; private final ConflictChecker checker; private int bestScore = Integer.MIN_VALUE; private List<Assignment> bestSolution; // 对每个待排班次,尝试可用的(星期,节次,教室)组合 public void solve(int index, List<Assignment> current, int score) { if (index == slots.size()) { if (score > bestScore) { bestScore = score; bestSolution = new ArrayList<>(current); } return; } CourseSlot slot = slots.get(index); for (TimeRoom tr : availableTimeRooms(slot)) { if (checker.hasHardConflict(slot, tr, current)) { continue; // 硬约束冲突,剪枝 } current.add(new Assignment(slot, tr)); // 软约束打分:连堂 -5,同周分布 +2,时段偏好 +3 solve(index + 1, current, score + softScore(slot, tr, current)); current.remove(current.size() - 1); // 回溯 } } }逻辑说明:hasHardConflict检查教师、教室、学生三条维度上是否已经被占用,占用即剪枝,不再往下走。softScore每接受一个安排就把软约束的加减分累加,最终保留分数最高的完整方案。回溯保证了在可接受的时间内遍历到合法解空间的大部分。
参数说明:slots的顺序很关键。实践中的做法是按"约束最紧的班次先排"——比如只有一位老师能教的课、只有一间场地能容纳的课排在列表前面,剪枝效率显著高于按 ID 排序。软约束的权重建议做可视化配置页,让教务自己拖,而不是写死在代码里。
3.3 冲突查询接口与结果返回
排完课要能回答家长最关心的问题:"我孩子下周到底去哪上课。"所以需要一个按学生维度返回课表的接口:
@GetMapping("/api/student/{id}/schedule") public Result<List<ScheduleItem>> schedule( @PathVariable Long id, @RequestParam Long termId) { // 只取 CONFIRMED 状态,候补和已退选不展示 List<Enrollment> list = enrollmentRepo .findByStudentIdAndStatusAndSlotTermId(id, Status.CONFIRMED, termId); return Result.ok(list.stream().map(ScheduleItem::from).toList()); }逻辑说明:接口只返回CONFIRMED的记录,候补中的课不落到常规课表,避免家长误解。返回结构里带上weekday、period、roomName、teacherName,前端可以直接渲染成周视图。
参数说明:termId必传而不是取当前学期,是因为期末前后两个学期的数据会并存,由前端显式选择。查询上加(student_id, status, term_id)的联合索引,学生课表打开速度基本在几十毫秒。
4. Vue 前端:课表可视化与家校协同的交互细节
后端把数据组织好了,前端的核心任务是把"周课表"和"选课入口"做得家长和学生都能一眼看懂,同时把"谁改了课、谁请了假"这类协同动作留下痕迹。这一层出了问题,前面算法再聪明也没用。
4.1 周课表组件的网格布局思路
课表本质是一个 7 列 × N 行的二维网格,星期做列、节次做行。用 CSS Grid 而不是表格,是因为跨节次的课程(一次两节)用grid-row: span 2表达更自然:
<template> <div class="schedule-grid"> <div class="head" v-for="d in weekdays" :key="d.value">{{ d.label }}</div> <div v-for="item in placed" :key="item.id" class="cell" :style="{ gridColumn: item.weekday, gridRow: `${item.period} / span ${item.span}` }" @click="openDetail(item)" > <span class="course">{{ item.courseName }}</span> <span class="room">{{ item.roomName }}</span> </div> </div> </template> <script setup> import { computed } from 'vue' const props = defineProps({ items: Array }) // 把同一天连续两节的同一课程合并为一个 span=2 的块 const placed = computed(() => { const sorted = [...props.items].sort( (a, b) => a.weekday - b.weekday || a.period - b.period ) return sorted.map((it, i) => { const next = sorted[i + 1] const continuous = next && next.weekday === it.weekday && next.period === it.period + 1 && next.courseId === it.courseId return { ...it, span: continuous ? 2 : 1 } }).filter((it, i, arr) => { const prev = arr[i - 1] return !(prev && prev.weekday === it.weekday && prev.period + 1 === it.period && prev.courseId === it.courseId) }) }) </script>逻辑说明:placed计算属性先按星期和节次排序,再把连续两节的同课程标记为span: 2,然后用 filter 把被合并的第二节剔除,避免同一节课渲染两次。gridColumn直接用星期数字定位,gridRow用period / span N控制跨行。
参数说明:weekday从 1 开始,与网格第几列对齐(第 1 列留给节次标签,所以实际渲染时列号可加 1)。span只处理两节的情况,三节及以上按需扩展,但中小学课后服务一次超过两节的情况极少。
注意:打包后课表布局错位是 Vue 项目高频问题,多与 Grid 的隐式行列有关。给容器显式写死
grid-template-columns: 60px repeat(7, 1fr),不要依赖自动推断。
4.2 选课页面的名额实时反馈
选课页要让家长在点击的瞬间知道"还剩几个名额、点下去会不会变成候补"。前端不要自己维护计数,而是以后端返回的enrolledCount和capacity为准:
async function enroll(slotId) { try { const res = await api.post(`/api/enroll`, { slotId }) if (res.data.code === 'WAITLIST') { ElMessage.warning('名额已满,已加入候补队列') } else { ElMessage.success('选课成功,可在课表中查看') } await refreshSlots() // 重新拉取名额,避免本地计数漂移 } catch (e) { ElMessage.error(e.response?.data?.message || '选课失败,请重试') } }逻辑说明:前端不做乐观更新,成功后统一refreshSlots重拉列表,宁可多一次请求,也不让家长看到的数字和实际不一致。候补状态由后端返回的code区分,前端只负责提示。
参数说明:refreshSlots建议带节流,避免家长连点造成请求风暴。真正的高并发防护在后端,前端这层是体验优化。
4.3 家校协同:通知已读、请假与调课留痕
协同部分最容易被做成一堆单向通知,家长不点就不知道看没看。让每条通知带readBy列表,请假和调课走审批流:
-- 通知已读记录,readBy 不要用逗号分隔存大字段 CREATE TABLE notice_read ( id BIGINT PRIMARY KEY AUTO_INCREMENT, notice_id BIGINT NOT NULL, user_id BIGINT NOT NULL, read_at DATETIME NOT NULL, UNIQUE KEY uk_notice_user (notice_id, user_id) ); -- 请假申请,携带审批状态 CREATE TABLE leave_request ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, reason VARCHAR(255), status VARCHAR(16) DEFAULT 'SUBMITTED', approver_id BIGINT, approved_at DATETIME );逻辑说明:已读单独建表而不是在主表里存字符串,好处是能精确查"哪位家长没读过",也让"一键催读"这类功能有据可依。请假表带status,从SUBMITTED到APPROVED或REJECTED,教师的审批动作就是一次状态更新。
参数说明:reason长度控制在 255 以内,避免大字段拖慢列表查询。approver_id可空,因为提交瞬间还没有审批人。
5. 上线前必须过的验证与几个能省下大量返工的技巧
功能能跑通和能被教务放心用,中间隔着一轮"专门去找它错"的验证。这一步做扎实,后面的返工量能砍掉一大半。
5.1 用冲突检测脚本验证排课结果
排完课不要靠人眼盯,写个脚本把结果重新扫一遍,看有没有违反硬约束:
public void verifySchedule(Long termId) { List<CourseSlot> all = slotRepo.findByTermId(termId); Map<String, CourseSlot> teacherMap = new HashMap<>(); Map<String, CourseSlot> roomMap = new HashMap<>(); for (CourseSlot s : all) { String tKey = s.getTeacherId() + "-" + s.getWeekday() + "-" + s.getPeriod(); String rKey = s.getRoomId() + "-" + s.getWeekday() + "-" + s.getPeriod(); if (teacherMap.putIfAbsent(tKey, s) != null) { throw new IllegalStateException("教师冲突: " + tKey); } if (roomMap.putIfAbsent(rKey, s) != null) { throw new IllegalStateException("教室冲突: " + rKey); } } }逻辑说明:把所有已排班次按"教师-时段"和"教室-时段"两个维度塞进 Map,putIfAbsent返回非空说明键已存在,即冲突。这个方法可以挂成接口或单元测试,每次排完课跑一次。
参数说明:termId维度不能省,跨学期同一教师同一时段重复是正常的。
5.2 学生维度冲突用位图查更快
学生冲突比教师冲突量级大得多,逐个比较是 O(n²)。把一个学生选的所有班次按时段映射成位图,用 AND 判断有没有重叠,几千人的数据也能秒级查出:
// weekday 1-7, period 1-8,共 56 位 long bitmapOf(List<CourseSlot> slots) { long mask = 0L; for (CourseSlot s : slots) { int bit = (s.getWeekday() - 1) * 8 + (s.getPeriod() - 1); long flag = 1L << bit; if ((mask & flag) != 0) { return -1L; // 该生内部已有冲突 } mask |= flag; } return mask; }逻辑说明:56 位正好塞进一个 long,两个学生的位图做 AND 为 0 表示无重叠冲突。返回 -1 表示单个学生自己的选课就撞了,要在选课阶段拦住。
参数说明:位宽按"星期数 × 每天节次数"计算,如果学校一天有 10 节,就把 8 改成 10,总位数仍然小于 64,不用换BigInteger。
5.3 三个容易踩的坑
第一个坑是候补转正没有触发时机。很多项目只在用户手动退选时补位,结果候补永远等不到。做法是在退选事务里同步查最早候补并更新状态,或者用定时任务每几分钟扫一次。
第二个坑是学期切换没做数据隔离。第二年开学把新的班次塞进同一批表,上学期课表就乱套。所有查询强制带term_id,列表默认只显示当前学期。
第三个坑是选课时间窗口没控住。开放时间应在后端校验,而不是前端隐藏按钮,否则改个请求时间就能绕过。用term表存select_start和select_end,进入选课接口先比对服务器时间。
5.4 一键导出课表给家长的小技巧
家长最想要的是能把课表存到手机里。做一个后端导出接口,按学生生成 ics 日历文件,比图片更实用,也能自动同步到手机日历:
@GetMapping("/api/student/{id}/schedule.ics") public ResponseEntity<byte[]> exportIcs(@PathVariable Long id, @RequestParam Long termId) { List<ScheduleItem> items = scheduleService.list(id, termId); StringBuilder sb = new StringBuilder(); sb.append("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//afterschool//CN\r\n"); for (ScheduleItem it : items) { sb.append("BEGIN:VEVENT\r\n") .append("SUMMARY:").append(it.courseName()).append("\r\n") .append("LOCATION:").append(it.roomName()).append("\r\n") .append("DTSTART:").append(it.startTime()).append("\r\n") .append("DTEND:").append(it.endTime()).append("\r\n") .append("END:VEVENT\r\n"); } sb.append("END:VCALENDAR\r\n"); return ResponseEntity.ok() .header("Content-Type", "text/calendar;charset=UTF-8") .body(sb.toString().getBytes(StandardCharsets.UTF_8)); }逻辑说明:按 iCalendar 规范拼出 VCALENDAR,每个班次一个 VEVENT,时间用本地时间格式即可。家长点链接就能导入系统日历,比截图清晰,临时调课时也能重新导出覆盖。
参数说明:换行必须用\r\n,否则部分手机日历解析失败。DTSTART需要是yyyyMMdd'T'HHmmss形式,时区可在DTSTART前加TZID或统一按本地时间处理,视部署环境而定。导出接口同样要校验学生与当前登录用户的关系,防止越权拿到别人课表。
本文还有配套的精品资源,点击获取