开源实战:基于 Spring Boot 3 + Vue 3 + Redis 的高校选修课管理系统(志愿填报 + 统一抽签)

一个可以直接跑起来的教务级选课系统:学生端志愿填报与抽签结果、教师端成绩录入与发布、管理端抽签控制台与批次报告,全流程可复算、可留痕。


一、为什么做这个项目

高校选课有两类典型场景:

  • 先到先得(实时抢课):拼网速、拼脚本,热门课程几秒售罄,容易出现超卖与缓存一致性问题;
  • 志愿 + 抽签:填报期内随时提交志愿,截止后统一批算名额,结果与提交时间、网络快慢无关,更公平也更好解释。

这个项目把两种机制都做过一遍。当前分支 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 投档规则

  1. 学生维度投档 :按权重(MIXED = 点数 → 已获学分 → HMAC-SHA256(seed, studentId))排序,一名学生只被处理一次,避免「课程维度轮转」造成的无解死锁;
  2. 志愿序只在本生内部生效:第 1 志愿优先于第 2 志愿;一人可中多门,不 break;
  3. 硬约束 :不撞课(与已选 / 已中签课程)、不超学分、不重复占用、已选中课程不再投、停开课程记 COURSE_CANCELLED;
  4. 软约束:志愿之间撞课只提示、不阻断------否则会扼杀「A 不中落 B」;
  5. 调剂:仅对零中签且接受调剂的学生,按同类课程 / 同院系(或公选)静态调剂,且仍受撞课与学分约束;
  6. 不超发:名额在内存中扣减,落库前不再对外释放。

5.3 时间冲突判定

两条排课记录被判定为冲突,需同时满足:

  1. 同一天:day_of_week 相同;
  2. 节次重叠:startA ≤ endB && endA ≥ startB;
  3. 周次重叠:[startWeekA, endWeekA] 与 [startWeekB, endWeekB] 有交集;
  4. 周类型兼容:若一方为「每周」则冲突;若为「单周 / 双周」则需存在同为奇偶数的公共周次。

判定实现在 ScheduleConflictUtils,被「提交前复核(/student/lottery/precheck)」与「教务代选」共用;抽签批算内部使用同一套规则做硬约束。

5.4 名额口径

余量统一按数据库口径计算:

sql 复制代码
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 关键配置

yaml 复制代码
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 一键启动(推荐)

bash 复制代码
# 首次部署:复制环境变量模板并填写强口令
#   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 完成建表与初始化数据。

方式二:本地开发启动

bash 复制代码
# 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。


九、仓库与分支说明

分支说明:

分支 选课机制
master Redis + Lua 实时抢课(余量预占、排队削峰)
feature/wish-lottery-selection 志愿填报 + 统一抽签(本文展示的版本)

两个分支共用同一套教务管理、成绩、评教与小程序能力,可以对比阅读两种选课实现。


十、结语

这个项目最初是为了把「选课」这件事做扎实:不只是能选上,还要说得清、验得了、退得掉。志愿 + 抽签的方案牺牲了一点「立刻知道结果」的爽感,换来的是公平性与可解释性------提交时间不再重要,结果可以复算,每个未中志愿都有明确原因。

如果这个项目对你有帮助,欢迎 Star 支持。有问题可以在评论区交流,或者提 Issue。

最后感谢上一篇文章【Spring Boot 3 + Vue 3 高校选修课系统:用 Redis + Lua 彻底解决选课超卖 】中网友【神码不是浮云】的留言建议

说明:本文图片托管在 jsDelivr(GitHub 镜像),若个别网络环境下加载缓慢,可直接访问仓库 docs/images/ 目录查看原图。