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

0 阅读14分钟

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


一、为什么做这个项目

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

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

这个项目把两种机制都做过一遍。当前分支 feature/wish-lottery-selection 实现的是**「志愿填报 + 统一抽签」**:学生在填报期内提交「志愿序 + 投入点数」,截止后后台一次性批算名额,R1 公布后可退选,R2 结果为最终结果。

几个设计上比较在意的点:

  • 名额零竞争:名额在抽签时一次性写定,运行期没有并发扣减,也不需要维护缓存余量;
  • 可复算:抽签批次公开 seed,配合输入快照(gzip + SHA-256)可被第三方独立复算;
  • 结果留痕:逐志愿记录「中签 / 未中原因 / 投档顺位」,不是黑箱;
  • 下游无感:抽签结果仍写入 course_selection,课表、成绩、教师名单、导出全部零改造。

项目前后端分离,另附一个复用同一套接口的原生微信小程序(课表 / 成绩 / 评教)。


二、技术栈

后端

组件版本说明
Java21LTS,使用 Text Block、Record 等特性
Spring Boot3.3.5基础框架
Spring Security6.x认证与鉴权
MyBatis-Plus3.5.7ORM、分页、乐观锁、逻辑删除
MySQL8.0业务数据存储
Redis7.x令牌白名单、抽签锁、热度快照
JJWT0.12.6JWT 令牌签发与解析
Knife4j4.5.0OpenAPI 3 接口文档
Hutool5.8.32通用工具类
Lombok1.18.34简化样板代码

前端

组件版本说明
Vue3.5组合式 API
Vite5.4构建工具
Element Plus2.8UI 组件库
Pinia2.2状态管理
Vue Router4.4路由与权限守卫
Axios1.7HTTP 请求封装
ECharts5.5数据可视化
Day.js1.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 名额口径

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

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:switchString填报通道总开关(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 制换算。

七、快速开始

环境要求

依赖最低版本说明
JDK21后端运行环境
Maven3.8+后端构建
Node.js18+前端构建
MySQL8.0+业务数据库
Redis6.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。


九、仓库与分支说明

分支说明:

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

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


十、结语

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

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

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