一个可以直接跑起来的教务级选课系统:学生端志愿填报与抽签结果、教师端成绩录入与发布、管理端抽签控制台与批次报告,全流程可复算、可留痕。
一、为什么做这个项目
高校选课有两类典型场景:
- 先到先得(实时抢课):拼网速、拼脚本,热门课程几秒售罄,容易出现超卖与缓存一致性问题;
- 志愿 + 抽签:填报期内随时提交志愿,截止后统一批算名额,结果与提交时间、网络快慢无关,更公平也更好解释。
这个项目把两种机制都做过一遍。当前分支 feature/wish-lottery-selection 实现的是**「志愿填报 + 统一抽签」**:学生在填报期内提交「志愿序 + 投入点数」,截止后后台一次性批算名额,R1 公布后可退选,R2 结果为最终结果。
几个设计上比较在意的点:
- 名额零竞争:名额在抽签时一次性写定,运行期没有并发扣减,也不需要维护缓存余量;
- 可复算:抽签批次公开
seed,配合输入快照(gzip + SHA-256)可被第三方独立复算; - 结果留痕:逐志愿记录「中签 / 未中原因 / 投档顺位」,不是黑箱;
- 下游无感:抽签结果仍写入
course_selection,课表、成绩、教师名单、导出全部零改造。
项目前后端分离,另附一个复用同一套接口的原生微信小程序(课表 / 成绩 / 评教)。
二、技术栈
后端
| 组件 | 版本 | 说明 |
|---|---|---|
| Java | 21 | LTS,使用 Text Block、Record 等特性 |
| Spring Boot | 3.3.5 | 基础框架 |
| Spring Security | 6.x | 认证与鉴权 |
| MyBatis-Plus | 3.5.7 | ORM、分页、乐观锁、逻辑删除 |
| MySQL | 8.0 | 业务数据存储 |
| Redis | 7.x | 令牌白名单、抽签锁、热度快照 |
| JJWT | 0.12.6 | JWT 令牌签发与解析 |
| Knife4j | 4.5.0 | OpenAPI 3 接口文档 |
| Hutool | 5.8.32 | 通用工具类 |
| Lombok | 1.18.34 | 简化样板代码 |
前端
| 组件 | 版本 | 说明 |
|---|---|---|
| Vue | 3.5 | 组合式 API |
| Vite | 5.4 | 构建工具 |
| Element Plus | 2.8 | UI 组件库 |
| Pinia | 2.2 | 状态管理 |
| Vue Router | 4.4 | 路由与权限守卫 |
| Axios | 1.7 | HTTP 请求封装 |
| ECharts | 5.5 | 数据可视化 |
| Day.js | 1.11 | 时间处理 |
小程序与部署
- 原生微信小程序(WXML / WXSS / JS,无构建依赖,基础库 3.5.5+)
- Docker、Docker Compose、Nginx
三、系统架构
┌──────────────────────────────────────────────────────────────┐
│ 浏览器 / 客户端 │
└──────────────────────────────┬───────────────────────────────┘
│ HTTP (JWT)
┌──────────────────────────────▼───────────────────────────────┐
│ Vue 3 前端(Nginx 托管) │
│ Element Plus · Pinia · Vue Router · Axios · ECharts │
└──────────────────────────────┬───────────────────────────────┘
│ /api/** 反向代理
┌──────────────────────────────▼───────────────────────────────┐
│ Spring Boot 3 后端 │
│ ┌────────────┬────────────┬────────────┬──────────────────┐ │
│ │ Controller │ Service │ Mapper │ Security / AOP │ │
│ └────────────┴─────┬──────┴─────┬──────┴──────────────────┘ │
└──────────┬─────────┼────────────┼─────────────────────────────┘
│ │ │
┌───────▼──────┐ │ ┌────────▼────────┐
│ MySQL 8.0 │ │ │ Redis 7 │
│ 业务数据持久化 │ │ │ 令牌/锁/快照 │
└──────────────┘ └───┴─────────────────┘
后端分层清晰:controller → service → mapper,安全层负责 JWT 签发解析,切面层用注解式 @OperationLog + AOP 异步落库做审计,抽签执行集中在 LotteryServiceImpl(AUTO 模式由阶段查询触发推进,没有额外调度器)。
四、系统展示
4.1 交互动画
学生 · 志愿填报:浏览课程、调整志愿序与投入点数,右侧实时汇总。
管理员 · 抽签报告:从抽签控制台打开本轮批次,查看 seed、逐课程名额与逐志愿结论。
学生 · 课表查看:周课表渲染,点击课程卡片查看上课详情。
管理员 · 数据概览:统计卡片、图表与选课开关。
4.2 学生端
志愿填报:填报期内按「志愿序 + 投入点数」提交志愿;撞课、超学分等硬约束课程置灰并说明原因,软约束只提示不阻断,右侧实时汇总点数与志愿数。

抽签结果:中签门数、随机种子(可复制用于复算)、逐志愿投档顺位与未中原因;支持按学期与轮次回看,R1 公布后可退选,R2 结果为最终结果。

我的选课:学期 / 状态 / 操作筛选、当前筛选学分合计、退课与查看评价入口。

我的课表:9 节次 × 7 天周课表,含周次、节次时间与上课地点。

我的成绩:总学分 / 已获学分 / 平均分 / 平均绩点四项统计与逐门成绩明细。

教学评价:待评价课程与我的评价两个页签,四维星级、快捷标签与匿名提交。
4.3 教师端
我的授课:按学期查看本人课程与选课进度,每门课直达「学生名单」与「成绩录入」。
教学课表:本人全部课程按节次排布,排课冲突一眼可见。
成绩录入:总评按 平时成绩 × 30% + 期末成绩 × 70% 自动计算;支持批量填充、分页、状态筛选与「已发布 / 草稿」计数徽标,保存草稿、整门课发布 / 撤回,并对个别学生做单生更正(必须填写原因,改前改后留痕可查);还支持「导出成绩单 → Excel 线下批改 → 导入成绩」的闭环。
教学评价:所授课程的综合平均分、四维得分与参评人数。

4.4 管理端
数据概览:学生 / 教师 / 课程 / 选课 / 院系统计,院系开课分布、成绩分布、热门课程 TOP 10,以及选课通道开关与填报时间快捷设置。
抽签管理:阶段控制台(R1 填报 → 抽签 → 公布 → R2 → 关闭)、选课开关与「重算选课人数」、批次列表与批次的 seed / 名额分配报告。
学期与选课设置:学期起止日期、志愿填报窗口、评价开放时间与「设为当前」。

课程管理:课程增删改查、排课、批量导入导出与「重算选课人数」。

学生管理:学生信息维护、批量导入 / 启用 / 禁用 / 重置密码,以及教务代选与代退选。
教师管理:教师信息维护、批量导入、重置密码与账号状态管理。
评价管理:评价总数 / 已公开 / 已隐藏 / 平均分 / 参评率统计,支持公开、隐藏、删除与批量操作。
公告管理:置顶、目标角色(学生 / 教师)、发布 / 下架与批量操作。
基础信息:院系、专业、教室三类字典维护,支持批量导入。

操作日志:注解式审计,记录操作人、耗时、请求参数与异常堆栈。

登录锁定:账号与来源 IP 双维度计数,管理员可查看失败次数、剩余时间并一键解锁。

4.5 通用页面
登录页:账号密码登录,连续失败触发临时锁定。

公共信息:学生与教师的公告中心,未读标记、仅看未读与一键全部已读。

消息中心:系统公告、成绩发布、评价审核等站内信,支持全部 / 未读 / 已读筛选。
五、核心机制:志愿填报 + 统一抽签
5.1 一次完整的选课周期
① 管理员开启 R1 填报(lottery_phase = R1_COLLECTING)
↓
② 学生在填报期提交志愿(整批覆盖,幂等 upsert,无锁无竞争)
↓
③ 管理员截止填报(R1_LOCKED)
↓
④ 管理员执行抽签(R1_DRAWING):读数据库快照 → 内存批算 → 事务内落库
↓
⑤ 公布结果(R1_PUBLISHED):开放 R1 退选窗口
↓
⑥ R2:仅剩余名额的课程可再填一轮,R2_PUBLISHED 为最终结果(禁退)
执行方式默认「自动」:填报截止后,下一次打开页面即自动完成「截止 → 抽签 → 公布」;管理员可随时切「手动」,逐步点击核对停开课程与数据。
5.2 投档规则
- 学生维度投档:按权重(
MIXED= 点数 → 已获学分 →HMAC-SHA256(seed, studentId))排序,一名学生只被处理一次,避免「课程维度轮转」造成的无解死锁; - 志愿序只在本生内部生效:第 1 志愿优先于第 2 志愿;一人可中多门,不 break;
- 硬约束:不撞课(与已选 / 已中签课程)、不超学分、不重复占用、已选中课程不再投、停开课程记
COURSE_CANCELLED; - 软约束:志愿之间撞课只提示、不阻断——否则会扼杀「A 不中落 B」;
- 调剂:仅对零中签且接受调剂的学生,按同类课程 / 同院系(或公选)静态调剂,且仍受撞课与学分约束;
- 不超发:名额在内存中扣减,落库前不再对外释放。
5.3 时间冲突判定
两条排课记录被判定为冲突,需同时满足:
- 同一天:
day_of_week相同; - 节次重叠:
startA ≤ endB && endA ≥ startB; - 周次重叠:
[startWeekA, endWeekA]与[startWeekB, endWeekB]有交集; - 周类型兼容:若一方为「每周」则冲突;若为「单周 / 双周」则需存在同为奇偶数的公共周次。
判定实现在 ScheduleConflictUtils,被「提交前复核(/student/lottery/precheck)」与「教务代选」共用;抽签批算内部使用同一套规则做硬约束。
5.4 名额口径
余量统一按数据库口径计算:
remaining = max_capacity - selected_count -- 不小于 0
selected_count 由抽签落库、退选、教务代选在事务内同步维护;抽签公布后会按 course_selection 真实记录数重算一次,管理员也可随时触发 POST /admin/courses/selection/sync。
余量只保留数据库一份,避免出现「缓存与库两份数字」的口径漂移。
5.5 退选
- 学生退选复用
POST /student/courses/{courseId}/drop; - R1 公布后可退,超过
r1_drop_deadline关闭;R2 结果drop_locked = 1,退选返回3013; - 已发布成绩的课程不可退(草稿成绩不锁定退课);教务代退选不受窗口限制(补救场景)。
5.6 Redis 在这一版里做什么
| Redis Key | 类型 | 说明 |
|---|---|---|
elective:selection:switch | String | 填报通道总开关(on / off),关闭后提交志愿返回 3007 |
elective:auth:token:{userId} | String | 令牌白名单 |
elective:lottery:draw:lock:{semesterId}:{round} | String | 抽签单写者锁(10 分钟) |
elective:lottery:hot:{semesterId}:{round} | String(JSON) | 填报热度快照(2 小时) |
课程余量与已选集合不占用 Redis。
5.7 落库与重跑
- 结果明细
lottery_result按 500 条批量插入; - 中签记录写入
course_selection时采用「有则复用、无则新增」,规避逻辑删除行占用唯一键; - 重跑前先把本轮
select_type in (3,4)置回「已退选」,并把旧批次置为SUPERSEDED(批次与明细保留留档),因此重跑幂等且历史可追溯; - 抽签使用 Redis 单写者锁,失败自动回退到
*_LOCKED。
5.8 公平性与可验证
- 提交时间不参与权重,填报期内任何时刻提交等价;
- 公布时公开
lottery_batch.seed,配合志愿快照即可被第三方独立复算; - 逐志愿留下「中签 / 未中原因 / 投档顺位」,避免黑箱;
- 热度只给原始人数与分档,不给排名 / 中签率 / 百分比概率,聚合公告走 2 小时快照,压制羊群效应。
5.9 关键配置
elective:
lottery:
enabled: true # 总开关(紧急回退用)
weight-strategy: MIXED # RANDOM / POINTS / MIXED
force-adjust: true # 强制接受调剂
max-wishes: 8 # 每轮最多志愿数
points-budget: 100 # 每轮点数总额度
min-open-wish-count: 0 # 最低开课志愿数(生产建议 15)
hot-cache-seconds: 7200 # 热度快照缓存
hot-threshold-medium: 1.0
hot-threshold-high: 2.0
hot-threshold-extreme: 4.0
draw-lock-seconds: 600 # 抽签单写者锁
六、数据库设计
共 22 张表,完整 DDL 见仓库 backend/src/main/resources/db/schema.sql。抽签相关的核心表:
| 表名 | 说明 | 关键约束 |
|---|---|---|
semester | 学期与填报窗口 | semester_code 唯一;is_current 标记当前学期;lottery_phase 驱动抽签阶段 |
course | 课程与开课信息 | (course_code, semester_id) 唯一;version 乐观锁 |
course_selection | 选课记录(抽签 / 调剂 / 代选结果) | (student_id, course_id, semester_id) 唯一;lottery_round / drop_locked 标记来源与禁退 |
course_wish | 抽签志愿(每轮草稿) | (student_id, course_id, semester_id, round_no) 唯一;物理删除避免唯一键残留 |
lottery_batch | 抽签批次(seed、统计、状态与公布信息) | (semester_id, round_no, attempt_no) 唯一;重跑 / 回滚只改状态,历史留档 |
lottery_batch_snapshot | 抽签输入快照(课程 / 学生 / 志愿) | 一批次一份,gzip + SHA-256,支持独立复算 |
lottery_result | 逐志愿中签 / 未中原因 | 索引 (batch_id, student_id)、(batch_id, course_id) |
几个设计细节:
- 逻辑删除:除
sys_log与course_review外,所有表使用deleted字段,由 MyBatis-Plus 自动处理; - 选课唯一约束:
course_selection的唯一索引是防重复选课的最后一道数据库级保障;抽签重跑通过「有则更新、无则新增」复用同一行; - 成绩计算:
total_score = usual_score × 0.3 + exam_score × 0.7,绩点采用 4.0 制换算。
七、快速开始
环境要求
| 依赖 | 最低版本 | 说明 |
|---|---|---|
| JDK | 21 | 后端运行环境 |
| Maven | 3.8+ | 后端构建 |
| Node.js | 18+ | 前端构建 |
| MySQL | 8.0+ | 业务数据库 |
| Redis | 6.0+ | 令牌白名单、抽签锁与热点快照 |
方式一:Docker Compose 一键启动(推荐)
# 首次部署:复制环境变量模板并填写强口令
# MYSQL_ROOT_PASSWORD / REDIS_PASSWORD / JWT_SECRET(JWT_SECRET 至少 32 字节)
cp .env.example .env
# 在项目根目录执行
docker compose up -d --build
# 查看服务状态
docker compose ps
# 查看后端日志
docker compose logs -f backend
启动完成后访问:
| 服务 | 地址 |
|---|---|
| 前端页面 | http://localhost |
| 后端接口 | http://localhost:8080/api |
| 接口文档(Knife4j) | http://localhost:8080/api/doc.html |
MySQL 与 Redis 首次启动会自动执行
schema.sql与data.sql完成建表与初始化数据。
方式二:本地开发启动
# 1. 初始化数据库(表结构由应用启动时自动补齐,缺失表会自动创建)
mysql -uroot -p < scripts/init-db.sql
# 2. 启动 Redis(默认开发配置无密码)
redis-server
# 3. 启动后端
cd backend
mvn clean compile
mvn spring-boot:run
# 4. 启动前端
cd frontend
npm install
npm run dev
前端默认运行在 http://localhost:5174 ,已配置 /api 代理到 http://localhost:8080。
内置演示数据规模:20 名学生、8 名教师、5 个学期、38 门课程、53 条排课、344 条选课记录、298 条成绩记录、159 条课程评价,全部选课组合已通过时间冲突校验。
八、演示账号
初始化脚本 data.sql 已内置演示数据,所有账号初始密码均为 123456。
| 角色 | 账号 | 说明 |
|---|---|---|
| 管理员 | admin | 系统管理员 |
| 教师 | T2026001 | 张明远 · 教授(计算机学院) |
| 教师 | T2026002 | 李承阳 · 副教授(电信学院) |
| 教师 | T2026003 | 王雅琴 · 教授(经管学院) |
| 学生 | 2026010101 | 刘思远 · 计科2601 |
| 学生 | 2026010102 | 孙嘉怡 · 计科2601 |
| 学生 | 2026010201 | 周子昂 · 软工2601 |
| 学生 | 2026020101 | 吴雨桐 · 电信2601 |
| 学生 | 2026030101 | 郑宇轩 · 工商2601 |
初始化数据中的密码使用
{noop}前缀表示明文,便于快速体验;通过管理端新建用户或修改密码时,系统会自动使用 bcrypt 加密存储。 生产环境请务必修改所有默认密码,并通过环境变量覆盖JWT_SECRET。
九、仓库与分支说明
- Gitee(主仓库):gitee.com/huangrenhui…
- GitHub(镜像):github.com/huangrenhui…
分支说明:
| 分支 | 选课机制 |
|---|---|
master | Redis + Lua 实时抢课(余量预占、排队削峰) |
feature/wish-lottery-selection | 志愿填报 + 统一抽签(本文展示的版本) |
两个分支共用同一套教务管理、成绩、评教与小程序能力,可以对比阅读两种选课实现。
十、结语
这个项目最初是为了把「选课」这件事做扎实:不只是能选上,还要说得清、验得了、退得掉。志愿 + 抽签的方案牺牲了一点「立刻知道结果」的爽感,换来的是公平性与可解释性——提交时间不再重要,结果可以复算,每个未中志愿都有明确原因。
如果这个项目对你有帮助,欢迎 Star 支持。有问题可以在评论区交流,或者提 Issue。
说明:本文图片托管在 jsDelivr(GitHub 镜像),若个别网络环境下加载缓慢,可直接访问仓库
docs/images/目录查看原图。