简介:智能选课系统是一套面向高校教务场景的Web应用源码,适合计算机专业学生、Java Web初学者及课程设计开发者参考学习,用于解决学生选课、教师发布课程与管理员维护平台等实际需求。资源包共45个文件,约167KB,以aspx页面、cs后台代码与resx资源文件为主体,辅以sln解决方案、csproj项目文件、config配置、mdf与ldf数据库文件及说明文档,构成一套结构完整的选课系统项目。系统涵盖用户角色与权限管理、课程信息展示、选课与退选操作、成绩记录、报表统计及论坛互动等模块,并预留接口设计与性能优化思路,便于读者理解教务系统的业务分层与数据流转。目前已有307人学习下载,可帮助读者快速掌握选课系统的页面组织、数据库设计与后台逻辑实现方式,适合作为课程设计或二次开发的参考底稿。
1. 智能选课系统:从抢课崩溃到一键排课,这套源码能跑出什么
每到选课季,教务系统就像早高峰的地铁——页面转圈、名额秒没、志愿冲突。很多同学以为是自己网速不行,其实背后往往是选课逻辑没设计好:名额扣减没有原子性、志愿优先级没排序、并发请求全压在一个接口上。这套「智能选课系统」源码,就是围绕这些真实痛点搭出来的一个完整工程:学生端能查课、选课、退课、看课表,管理端能排课、控名额、导数据,核心还带了一套志愿匹配和冲突检测逻辑。它适合两类人:一是想拿一个能跑通的课程设计或毕设底座的开发者,二是想研究并发扣减、排课算法怎么落地的前后端同学。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲。
2. 技术栈与目录结构:一个选课系统由哪些模块拼起来
2.1 技术选型:为什么是这套组合
从工程结构看,这套系统走的是典型的前后端分离路线。后端用 Spring Boot 承载业务接口,MySQL 存课程、学生、选课记录三张主表,Redis 用来扛选课高峰期的名额扣减和热点课程缓存,前端用 Vue 做学生端和管理端两个入口。这个组合不是随便凑的,选课场景有两个硬约束:一是名额必须准,不能超选;二是高峰期读多写少,课程列表和余量查询要快。MySQL 保证最终一致性,Redis 把「查余量」和「预扣名额」这两步从数据库里挪出来,是这类系统最常见的做法。
我一般会把后端拆成四层:controller 接请求、service 写业务、mapper 碰数据库、common 放统一返回和异常。前端则按角色分路由,学生登录进学生端,管理员登录进管理端,两边共用一套 axios 封装。下面这张表是核心模块和职责的对应关系,看源码时可以先对着它找入口。
| 模块 | 职责 | 关键依赖 |
|---|---|---|
| 课程管理 | 课程增删改查、排课时间维护 | MySQL、MyBatis |
| 选课服务 | 志愿提交、名额扣减、冲突检测 | Redis、MySQL |
| 课表服务 | 按学生聚合已选课程、生成周课表 | MySQL |
| 用户与权限 | 学生/管理员登录、角色鉴权 | JWT、拦截器 |
| 数据导出 | 选课名单、课程统计导出 | EasyExcel |
2.2 目录结构:先找到入口再读代码
拿到源码别急着从第一个文件读起,先看目录骨架。后端大致是这样组织的:
course-selection/ ├── backend/ │ ├── src/main/java/com/example/selection/ │ │ ├── controller/ # 接口层:CourseController、SelectionController │ │ ├── service/ # 业务层:SelectionService、ScheduleService │ │ ├── mapper/ # 数据访问:CourseMapper、SelectionMapper │ │ ├── entity/ # 实体:Course、Student、SelectionRecord │ │ ├── common/ # 统一返回 Result、全局异常处理 │ │ └── config/ # RedisConfig、WebMvcConfig │ └── src/main/resources/ │ ├── application.yml # 数据源、Redis、端口配置 │ └── mapper/ # MyBatis XML ├── frontend/ │ ├── src/views/student/ # 学生端页面:选课、课表 │ ├── src/views/admin/ # 管理端页面:排课、名单 │ ├── src/api/ # 接口封装 │ └── src/router/ # 按角色分路由 └── sql/ └── init.sql # 建表 + 初始数据读代码的顺序建议是:先看sql/init.sql弄清表关系,再看SelectionController找到选课入口,顺着SelectionService往下读扣名额和冲突检测,最后回头看前端api目录怎么调。这样不会一上来就陷进配置文件的细节里。
2.3 数据模型:三张主表撑起整个选课链路
选课系统的数据关系其实不复杂,核心就是学生、课程、选课记录三者。init.sql里最关键的是选课记录表,它同时承担了「谁选了什么」和「志愿状态」两个职责:
CREATE TABLE selection_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT '学生ID', course_id BIGINT NOT NULL COMMENT '课程ID', priority TINYINT DEFAULT 1 COMMENT '志愿优先级,1最高', status TINYINT DEFAULT 0 COMMENT '0待处理 1已选中 2未选中 3已退课', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id) ) COMMENT '选课记录表';这里有两个设计点值得注意。第一,uk_student_course唯一索引防止同一个学生对同一门课重复提交,这是最省事的防重手段,比在代码里查一遍再插要可靠。第二,status用状态位而不是直接删记录,退课只是把状态改成 3,这样选课历史和统计都留得住。课程表里则要有capacity(容量)和selected_count(已选人数)两个字段,前者是上限,后者是实时计数,扣减逻辑就是围绕这两个字段展开的。
3. 核心链路拆解:一次选课请求到底经过了什么
3.1 选课接口:从 Controller 到 Service 的调用链
选课是整个系统最不能出错的接口。Controller 层只做参数校验和身份提取,真正的判断全在 Service 里:
@PostMapping("/select") public Result selectCourse(@RequestBody SelectRequest req, @RequestHeader("Authorization") String token) { // 从 token 解析学生ID,避免前端伪造 Long studentId = jwtUtil.getUserId(token); if (req.getCourseIds() == null || req.getCourseIds().isEmpty()) { return Result.fail("请至少选择一门课程"); } // 志愿列表按优先级排序后交给 service selectionService.submitSelection(studentId, req.getCourseIds()); return Result.ok("提交成功"); }这段代码的关键在两点:学生 ID 从 token 里取而不是前端传,防止越权替别人选课;课程 ID 以列表形式提交,对应「一次填多个志愿」的真实场景。参数courseIds的顺序就是志愿优先级,前端按学生拖拽顺序传过来,Service 里再落库成priority。如果你二次开发时要支持「同一门课多个教学班」,这里就要把courseIds换成带班次 ID 的结构,改动会顺着 Service 一路往下传。
3.2 名额扣减:Redis 预扣 + 数据库兜底
名额超选是选课系统最经典的血泪坑。这套源码的做法是先在 Redis 里做原子预扣,扣成功再落库:
public void submitSelection(Long studentId, List<Long> courseIds) { for (int i = 0; i < courseIds.size(); i++) { Long courseId = courseIds.get(i); String stockKey = "course:stock:" + courseId; // Lua 脚本保证「判断余量 + 扣减」原子执行 Long remain = redisTemplate.execute(DECREMENT_SCRIPT, Collections.singletonList(stockKey)); if (remain == null || remain < 0) { // 余量不足,回滚本次已扣的其它课程 rollback(studentId, courseIds.subList(0, i)); throw new BizException("课程名额已满:" + courseId); } // 预扣成功,异步落库选课记录 selectionMapper.insert(studentId, courseId, i + 1); } }DECREMENT_SCRIPT是一段 Lua,逻辑是「读余量,大于 0 则减 1 返回剩余值,否则返回 -1」。用 Lua 而不是先 get 再 decr,是因为两条命令之间可能被别的请求插进来,导致超卖。remain < 0时要把本次循环里已经扣掉的课程逐个回滚,否则学生会出现「部分课程扣了名额但整体提交失败」的脏数据。落库这一步在示例里是同步的,生产环境常见做法是丢进消息队列异步处理,接口先返回「提交成功,结果稍后刷新」,这样高峰期响应会快很多。
3.3 冲突检测:时间片比对怎么算
选课不能只看名额,还要看时间冲不冲突。课程表里存的是weekday(周几)、start_section、end_section(第几节到第几节),检测逻辑就是把新课和已选课逐条比对:
public boolean hasConflict(Long studentId, Course newCourse) { List<Course> selected = courseMapper.listSelectedByStudent(studentId); for (Course c : selected) { if (!c.getWeekday().equals(newCourse.getWeekday())) continue; // 两个区间有交集即冲突:新开始 < 旧结束 且 旧开始 < 新结束 boolean overlap = newCourse.getStartSection() < c.getEndSection() && c.getStartSection() < newCourse.getEndSection(); if (overlap) return true; } return false; }区间重叠的判断用的是「新开始小于旧结束且旧开始小于新结束」,这个写法比逐个节次遍历要快,也不依赖节次数上限。注意这里只比对了同一周几,如果课程有单双周属性,还要加一个week_type字段参与判断,否则单周课和双周课会被误判成冲突。二次开发时如果要做「推荐不冲突课程」,这段逻辑可以直接复用,把返回值从布尔改成冲突课程列表即可。
3.4 志愿匹配:优先级排序与落选处理
学生一次填多个志愿,系统要按优先级依次尝试。源码里的做法是先按priority升序排,逐个尝试扣名额,第一个成功的作为选中结果,后面的标记为未选中:
public void matchByPriority(Long studentId, List<Long> courseIds) { List<SelectionRecord> records = new ArrayList<>(); boolean matched = false; for (int i = 0; i < courseIds.size(); i++) { Long courseId = courseIds.get(i); SelectionRecord r = new SelectionRecord(studentId, courseId, i + 1); if (!matched && tryOccupy(courseId)) { r.setStatus(1); // 已选中 matched = true; } else { r.setStatus(2); // 未选中 } records.add(r); } selectionMapper.batchInsert(records); }matched标志保证一个学生一轮只中一个志愿,符合大多数学校的选课规则。tryOccupy内部就是 3.2 里的 Redis 预扣。这里有个容易翻车的地方:如果学生已经选过某门课,batchInsert会撞唯一索引报错,所以调用前要先过滤掉已选课程,或者用INSERT IGNORE。落选记录也要保留,学生端要能看到「哪些志愿没中」,方便补选。
4. 跑起来:环境准备、建库与前后端启动
4.1 环境与配置:改哪几个参数就能连上
跑这套系统需要 JDK 8 以上、Maven、MySQL 5.7 以上、Redis 和 Node.js。后端配置集中在application.yml,拿到源码后主要改三处:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/course_selection?useUnicode=true&characterEncoding=utf8 username: root password: your_password redis: host: localhost port: 6379 database: 0数据库名要和init.sql里建库语句一致,Redis 默认没有密码,如果本地启了密码要补password字段。端口 8080 被占用就改server.port,前端api里的 baseURL 也要跟着改,否则请求会打到旧端口上。这一步看着简单,但新手最常卡在这里——配置改了没重启、或者前端代理没同步,接口一直 404。
4.2 建库与初始化数据
先建库再导数据,顺序不能反:
# 登录 MySQL 后执行 CREATE DATABASE course_selection DEFAULT CHARSET utf8mb4; # 退出后导入初始化脚本 mysql -u root -p course_selection < sql/init.sqlinit.sql里除了建表,还会插入一批测试课程和测试账号,学生端和管理端各一个。导入后建议先SELECT COUNT(*) FROM course;确认课程数据进来了,再看selection_record是否为空。如果导入报「Unknown character set」,说明脚本用了旧字符集,把建库语句的utf8mb4对齐即可。
4.3 启动后端与前端
后端用 Maven 起:
cd backend mvn clean package -DskipTests java -jar target/selection-0.0.1-SNAPSHOT.jar启动日志里看到Started SelectionApplication就说明起来了。前端:
cd frontend npm install npm run devnpm run dev会起一个本地开发服务器,默认通过代理把/api转发到 8080。登录测试账号后先做三件事验证链路:查课程列表能不能出来、选一门有余量的课看名额是否减 1、再选一门时间冲突的课看是否被拦。三步都通过,说明数据库、Redis、冲突检测都是通的。
4.4 验证选课链路的三个检查点
跑通之后别急着改代码,先按检查点确认核心逻辑没被环境问题掩盖。第一,用两个浏览器同时登录不同学生账号抢同一门只剩 1 个名额的课,看是否只有一个成功——这是验证 Redis 预扣有没有生效。第二,选课后去数据库看selection_record的status和priority是否符合预期。第三,退课后再看 Redis 里的余量有没有加回去,如果没加,说明退课逻辑漏了回补,后面会越选越少。
5. 避坑与排查:选课系统最容易翻车的五个地方
5.1 名额超卖:现象是已选人数超过容量
现象:压测或多人同时选课时,selected_count最终大于capacity。原因通常是扣减走了「先查再改」两步,中间被并发插队,或者 Redis 扣了但数据库更新失败没回滚。解决:扣减必须用 Lua 或数据库行锁保证原子性,落库失败要有补偿;上线前用并发脚本压一轮,专门盯余量字段。
5.2 重复选课:唯一索引报错导致整批失败
现象:学生重复提交同一门课,接口报 500,整批志愿都没落库。原因是batchInsert撞了uk_student_course。解决:提交前先查已选课程做过滤,或者把插入改成INSERT IGNORE,单条失败不影响整批。更稳的做法是在 Service 入口就做一次去重。
5.3 时间冲突误判:单双周课程被当成冲突
现象:单周课和双周课在同一时间段被判定冲突,学生选不了。原因是冲突检测只比了周几和节次,没看周次类型。解决:课程表加week_type字段(全周/单周/双周),检测时先判断周次是否有交集,再比节次区间。
5.4 退课不回补名额:越退越少
现象:学生退课后,课程余量没有恢复,后面的人选不了。原因是退课只改了status,没动 Redis 计数和selected_count。解决:退课逻辑里加一步INCR回补 Redis,同时数据库selected_count减 1,两步要么都成功要么都回滚。
5.5 前端代理没配:接口全 404
现象:前端页面能打开,但所有请求报 404 或跨域。原因是vite.config或vue.config里的代理没指向后端实际端口。解决:检查代理配置的target是否和server.port一致,改完重启前端开发服务器,浏览器缓存也清一下。
6. 二次开发:把选课系统改成你自己的排课引擎
这套源码最适合改造的点在志愿匹配和排课这两块。如果你要做「按专业方向推荐课程」,可以在matchByPriority前面加一层推荐打分:根据学生已修课程、专业培养方案给每门课算一个匹配分,再按分数排序生成志愿列表,原来的优先级匹配逻辑完全不用动。改造时建议先写一个独立的RecommendService,输入学生 ID 输出课程 ID 列表,用单元测试固定住打分规则,再接进选课入口。
另一个高频需求是「管理员批量排课」。现有ScheduleService是单条排课,改成批量时要注意教室和时间片的双重冲突:同一教室同一时间只能排一门课,同一班级同一时间也只能排一门。我一般会先把所有待排课程按班级分组,每组内用贪心算法找第一个不冲突的时间片,排不下的落到候补列表返回给管理员。验证方法很直接:造 50 条课程数据跑一遍批量排课,再查一遍冲突检测,确认没有同教室同时间的记录。
| 改造方向 | 改动位置 | 验证方式 |
|---|---|---|
| 推荐选课 | 新增 RecommendService,接在选课入口前 | 单测固定打分规则 |
| 批量排课 | ScheduleService 增加批量方法 | 造数据后跑冲突检测 |
| 多教学班 | Course 表加班次,选课记录关联班次 | 并发抢同一班次看余量 |
| 选课结果通知 | 落库后发消息,前端轮询状态 | 模拟异步延迟看状态流转 |
有个习惯我保持了挺久:每次改选课相关逻辑,不管改动多小,都强制走一遍「并发抢 1 个名额 + 退课回补 + 冲突检测」这三步验证,因为这三处出问题最难在页面上直接看出来,等学生反馈时往往已经错了一批数据。希望这套源码和上面的拆解,能帮你少走几段弯路。
本文还有配套的精品资源,点击获取