选课系统这个选题,几乎所有做毕设的同学都绕不开。业务量级不大不小,角色清晰,流程完整,而且前后端都能充分展示,用来应付毕业设计答辩或者课程设计验收,非常稳妥。但问题也恰恰出在“稳妥”上——代码烂大街的太多,如果你的项目从数据库到权限到并发处理都能讲出点门道,答辩时的效果完全不一样。这篇就聊聊我用 SpringBoot + Vue.js + Java + MySQL 从零搭一套高校学生选课系统管理平台的完整经验,重点是那些容易被忽略但一讲就加分的细节,以及我踩过的坑。
跟大多数同学一样,我先想清楚一个问题:这套系统要交付给谁用,用到什么程度。选课系统的核心用户是三个角色——管理员、教师、学生。管理员管课程和基础数据,教师管开课和成绩录入,学生负责选课退课查成绩。这个边界听起来简单,但实际建模时很多细节需要反复推敲,比如“课程容量”和“已选人数”怎么保持一致,“选课冲突”到底靠什么机制拦截。这些问题如果看源码时没想透,答辩被老师追问到就很容易卡住。
1. 为什么选课系统适合作为毕设题目:技术选型背后的逻辑
1.1 选课系统的业务复杂度,其实拿捏得很准
选课系统的业务复杂度很微妙——它比纯增删改查的图书管理难一点,但又不像电商秒杀那么高并发。这种“中间地带”特别适合作为毕设:够你展示技术深度,又不会把自己绕晕在业务细节里。
具体来说,选课系统包含的东西不少:多角色权限管理、课程与选课数据的完整性约束、选课时间窗口的状态控制、不同时段的冲突检测,还有成绩录入与统计展示。这些功能模块拆给三个角色后,前后端加起来大概有大几十个页面/接口的规模,工作量刚好卡在一个人能完成、又能写满毕业论文的区间。
更难得的是,选课系统天然带“状态”和“截止时间”的概念。这促使你在设计时必须考虑时间维度怎么处理——是前端禁用按钮,还是后端直接拒绝选课请求,还是两者同时校验?这些问题一旦展开,项目的深度就比“简单增删改查”高出了一个档次。
1.2 SpringBoot + Vue.js 这套组合,经验的验证
选这套组合并不是因为它是最先进的,而是因为它最适合“一个人独立完成+答辩展示”的场景。
后端选 SpringBoot,理由很直接:Java 生态的资料多、社区成熟,SpringBoot 的自动装配和 starter 机制让项目初始化成本非常低。哪怕你以前只写过 JavaWeb 和 Servlet,上手 SpringBoot 也就是一两周的事,而且它能直接暴露你对依赖注入、事务管理、拦截器这套核心机制的理解,答辩时能聊的内容很多。再加上 Java 本身是主流开发语言,毕业后找 Java 开发工程师相关工作也用得上,属于“毕设顺便积累面试素材”。
前端选 Vue.js 也是同样的逻辑。Vue 上手曲线平滑,组件化思路直观,不用太挣扎就能写出可交互的界面。配合 Element Plus 一类的组件库,学生端选课页面、教师端成绩录入表格、管理端课程管理列表,都能快速搭建得像个正经系统。对非前端专业的同学来说,Vue 比 React 更友好,也比单纯用 JSP 模板渲染更配得上“前后端分离”这个答辩关键词。
数据库自然就是 MySQL。这个不用纠结,SpringBoot 对 MySQL 的整合是最顺滑的,资料铺天盖地,遇到连接问题、乱码问题、ssl 报错都能快速搜到解决方案。我使用的是 MySQL 8.x 版本配 mysql-connector-j,驱动版本保持一致,基本就没什么坑。
1.3 加分的关键点:到底是“能跑”还是“能讲清楚”
很多同学做毕设只追求“能跑通”,但答辩老师看重的往往是你对这个系统的理解深度。
我自己的经验是,把下面几个问题想清楚,答辩就很稳:
- 为什么课程表要有
teacher_id外键,而不直接存教师名字字符串? - 选课接口用什么保证同一个学生不会重复选同一门课?
- 课程人数满了以后,前端展示的已选人数和后端实际入库的数据如何保持一致?
- 选课时间截止后,学生强行用工具发请求会不会被后端拦截?
这些问题在本文后面的章节都会逐步拆解。每回答一个,你的项目就从“演示品”变成了“设计品”。
2. 系统功能边界与角色权限拆解
2.1 三个角色的核心业务流
我习惯把整套系统的功能画成三张角色清单再动手写代码,这样前后端做起来都不会乱。
管理员端的核心动作是:维护院系信息、维护教师账号、维护学生账号、审核课程开课申请、设置学期选课时间窗口。其中“设置选课时间窗口”是管理员端最容易出彩的功能,因为你要用一个全局配置表来控制选课的开启和关闭,这个业务逻辑本身就有讲究。
教师端则是:提交开课申请、查看自己课程的学生名单、录入成绩。注意,教师不能自己创建课程直接上线,要走管理员审核——这个流程增加了一层真实感,也平添了一次外键关联的机会,写论文时“业务流程分析”这一章会好写很多。
学生端是:查看可选课程、选课、退课、查看自己的课表、查成绩。这里有个细节很关键:学生选课时看到的是“还有空位”的课程,已经满的课程前端要置灰或标满,但后端选课接口同样要再校验一次容量,防止极端并发下把课程选爆。
另外,三端共用的模块还包括登录认证和统一权限拦截。我使用的方案是 JWT(JSON Web Token)做无状态认证,登录成功后由后端签发 token,前端存储到 localStorage 并在 axios 拦截器中附加到请求头。后端用 HandlerInterceptor 统一校验 token,并基于角色判断接口访问权限。
2.2 权限控制要怎么做,才能不像玩具项目
一个很常见的反面案例是:前端判断用户角色然后隐藏按钮,后端接口则完全不设防。平时演示没问题,但老师一句“我用 Postman 直接调用你的删除接口,能删掉吗”就能把你问住。
所以我坚持后端权限必须可验证。实现上,我在 SpringBoot 里写了一个基于拦截器的权限控制方案,不用引入 Spring Security(毕设阶段引入它会显著增加学习成本和配置复杂度,反而容易被纠缠在细节里)。具体的做法是:
- 用户登录时,后端生成 JWT,并把角色信息(如
ROLE_STUDENT)写入 token; - 拦截器解析 token,把用户信息放入
ThreadLocal或请求属性中; - 定义接口时,用自定义注解标记接口需要的角色,拦截器里做匹配校验。
为了演示方便,我会按角色划分接口路径前缀,比如/api/student/**、/api/teacher/**、/api/admin/**,然后按前缀做统一拦截校验。同时每个接口内部也尽量再做一次身份与数据归属校验,比如学生A只能查自己的选课记录,不能把选课接口里的 studentId 改成学生B的——这就是防越权,也是完整性约束的一部分。
前端也不能完全不设防,但前端的意义主要在体验而非安全。Vue Router 配置全局前置守卫,从 localStorage 取 token 和角色信息,没有 token 直接跳转到 /login,角色不匹配则跳转对应角色的首页并给出提示。这样用户不会白点一遍按钮再收到接口报错。
2.3 业务状态的流转规则
选课系统的业务状态,最核心的就是选课窗口。我设计了一张系统配置表sys_config,里面存semester、start_time、end_time等字段。选课接口在被调用时,第一时间读取配置,判断当前时间是否在窗口内,不在窗口内直接抛出业务异常“当前不在选课时间内”。
这个设计有一个值得答辩时提的点:配置读取有一个很小的性能开销,但换来的是灵活性——管理员改配置无需重启服务,也无需改代码。每次请求都查库虽然多一次 IO,但这种系统的并发量根本谈不上性能瓶颈,把状态控制放在后端统一收敛是正确取舍。
退课也有状态控制。学生退课不能没有限制地退,比如选课窗口结束后,退课应当被禁止(否则教师已经录了成绩,学生还能退课,数据就乱套了)。我用一个简单的规则做约束:退课的允许条件和选课的允许条件一致,即窗口开启时间内可以退课。这个规则并不复杂,但能引出“为什么前后端都要校验”的讨论,答辩老师通常吃这一套。
3. 数据库设计里那些必须想清楚的细节
3.1 核心表结构与字段设计
数据库设计是整个项目的地基。我建表的时候反复调整过几次,最后落地的核心表大致如下:
| 表名 | 用途 | 关键字段 |
|---|---|---|
sys_user | 用户表(学生/教师/管理员统一账号) | id, username, password, role, status |
student | 学生扩展信息 | id, user_id, student_no, name, major, grade |
teacher | 教师扩展信息 | id, user_id, teacher_no, name, title |
course | 课程表 | id, course_name, course_code, credit, teacher_id, capacity, selected_count, week_day, start_section, end_section, semester |
course_selection | 选课记录表 | id, student_id, course_id, select_time, status |
sys_config | 系统配置表 | id, config_key, config_value, description |
学生和教师字段为什么要单独拆一张扩展表?很多同学图省事,直接在sys_user表里放学生号、专业、职称。这样其实也能跑,但一个sys_user表既要有student_no又要空着teacher_no的同类字段,数据库的范式设计会很难看,答辩提到数据库规范化的时候容易露怯。拆开后,sys_user只负责认证和角色,student和teacher负责业务扩展信息,关联关系用user_id外键连接,逻辑清晰,也符合“一个角色一套扩展属性”的设计预期。
课程表是整个系统里查询最多的表。teacher_id外键关联教师信息,是为了保证开课信息的一致性——如果直接存教师名字字符串,教师改了姓名或系统里删除了教师记录,课程数据就变成脏数据了。外键约束虽然对性能稍有影响,但这个体量完全可以忽略,得到的收益是数据完整性有保障。
3.2 选课冲突与超选的防线
选课冲突有两类:时间冲突和容量冲突。
时间冲突是指同一学生的两门课上课时间重叠。光靠前端判断是不够的,所以我在后端选课接口里做了这么一步:查询该学生所有已选课程的上课时间,与准备选的课程做时段重叠判断。这里的核心字段是week_day(星期几)、start_section(第几节开始)、end_section(第几节结束),用区间重叠公式判断即可,也就是 newStart <= oldEnd && newEnd >= oldStart。
容量冲突则需要靠并发控制。想象一下:课程容量是 40 人,当前已选 39 人,两个学生同时点击选课,理论上第 40 和第 41 个请求都可能通过“已选人数 < 容量”的检查,最终课程被选到 41 人。这个问题怎么解决?
我的方案是分两层:数据库层增加唯一约束uk_student_course(student_id, course_id),确保同一个学生不能重复选同一门课;然后选课接口使用数据库行级锁,SELECT ... FOR UPDATE锁定课程行,在锁定状态下判断selected_count < capacity并更新selected_count。这样并发请求会串行化处理,超选问题从根上解决。
你可能会问:用乐观锁加版本号是不是也可以?可以,但选课场景下,悲观锁更直观,也更容易在答辩时讲清楚。选择悲观锁牺牲了一点并发性能,但这个系统的业务峰值可能只是全班几十个人同时选课,悲观锁完全扛得住。
3.3 冗余字段与查询性能的平衡
course.selected_count就是一个典型的冗余字段。它其实可以从course_selection表里 count 出来,但每次查询课程列表都要统计选课人数,SQL 又复杂展示又慢。所以我直接在课程表里冗余了一个selected_count,每次选课成功 +1,退课成功 -1。
这里要特别注意:冗余字段的更新必须和业务操作在同一个事务里完成,否则就会出现选课记录插入了,但数量没更新上,或者反过来。我用@Transactional保证原子性,数据库层面的事务隔离级别用默认的REPEATABLE_READ即可——至少在 MySQL 8 的 InnoDB 引擎下,配合行锁不会出现幻读问题。
另外一个小心思是,课程表里我存了week_day、start_section、end_section,而不是直接存一个“星期几第几节”的字符串。买这个细节是为了方便写冲突检测 SQL 时做数值比较——存成整数,区间重叠公式才能高效计算。
4. 后端核心接口的落地方式
4.1 选课接口的完整事务实现
选课接口是整套系统的灵魂,也是答辩老师最喜欢追问的地方。我贴出核心逻辑的伪代码结构,帮助理解整体流程:
public Result selectCourse(Long studentId, Long courseId) { // 1. 校验选课时间窗口 SysConfig config = sysConfigMapper.selectByKey("SELECT_WINDOW"); if (now before config.startTime || now after config.endTime) { throw new BusinessException("当前不在选课时间内"); } // 2. 查询课程并锁定行 Course course = courseMapper.selectByIdForUpdate(courseId); if (course == null) { throw new BusinessException("课程不存在"); } // 3. 校验课程容量 if (course.getSelectedCount() >= course.getCapacity()) { throw new BusinessException("该课程已满"); } // 4. 校验时间冲突 List<Course> selectedCourses = courseMapper.selectByStudentId(studentId); for (Course c : selectedCourses) { if (timeOverlap(c, course)) { throw new BusinessException("与课程[" + c.getCourseName() + "]上课时间冲突"); } } // 5. 插入选课记录并更新数量 CourseSelection selection = new CourseSelection(); selection.setStudentId(studentId); selection.setCourseId(courseId); selection.setSelectTime(new Date()); courseSelectionMapper.insert(selection); courseMapper.increaseSelectedCount(courseId); return Result.success("选课成功"); }这个接口里最关键的一行是第 2 步的selectByIdForUpdate。它会在数据库层面给这条课程记录加排他锁,让其他同时处理该课程选课的事务进入等待状态。虽然对性能有一点影响,但在毕设这个体量下完全没问题——你反而能详细讲出“行锁避免超卖”这个经典方案,这是面试和答辩都非常青睐的考点。
4.2 退课与成绩录入的前后端一致性
退课接口也值得琢磨。它的核心逻辑是:校验当前仍处于选课窗口期内,然后删除选课记录,同时把课程的selected_count减一,其他同学的选课记录不受影响。
这里有个细节,删除选课记录后,如果学生想再次选同一门课,理论上也不会受唯一约束拦截了——因为旧的选课记录已经删掉,新插入的记录和旧记录并没有同时存在。这个行为符合预期,但要确保删除与减数在同一事务内。
教师录入成绩是另一个完整性的考验。教师录入成绩,本质是对一个课程下的所有选课记录做批量更新。我是按course_id查选课记录,然后逐条回填成绩字段。成绩也是要加状态约束的:只有选课状态为“已选”的才能录成绩,已退课的记录直接过滤掉,避免教师录到已经退课学生的成绩。
4.3 通用返回体和全局异常处理的细节
为了让前后端对接顺畅,我写了一套统一的返回体Result<T>:
public class Result<T> { private Integer code; // 200 成功,400 业务异常,500 系统异常 private String message; private T data; }业务异常用一个自定义BusinessException,配合@RestControllerAdvice全局异常处理器,把所有的 IllegalArgumentException、SQLException、Exception 统一转换成标准返回结构。这个设计的妙处在于,前端 axios 拦截器只需要判断res.code === 200即可进入成功分支,其余全部进入错误提示分支。整个系统的错误处理逻辑非常干净,后续加功能也不需要到处 try-catch。
密码存储我建议用 BCrypt 哈希,而不是明文。虽然毕设阶段不搞 HTTPS、不搞对公网暴露,但良好的安全习惯本身就能写进论文里的“系统安全设计”章节,是送分项。
5. 前端页面结构与状态管理的实用取舍
5.1 页面组织与组件划分
Vue 项目我使用的是 Vue 3 + Vite + Element Plus。页面按角色拆目录:
/views/login:登录页/views/student:学生端主页、选课页、课表页、成绩页/views/teacher:教师端主页、开课申请、我的课程、成绩录入/views/admin:管理端主页、用户管理、课程审核、选课窗口配置
页面分组清晰,进一步的好处是 Vue Router 的嵌套路由和动态菜单可以直接按目录映射。我采用的菜单结构是登录后根据角色渲染不同的侧边栏菜单项,前端根据角色字段动态生成菜单,不用写三分冗余的路由配置。这也是很多同学容易做笨的地方——直接手写三个路由表,费时且容易漏,不如在一个路由配置里通过 meta.role 来控制访问。
组件复用方面我做了两个通用组件,一个是CourseTable.vue——用来展示课程列表(含分页、搜索、筛选),学生端选课和教师端课程管理都用它;另一个是TimeGrid.vue——用来展示个人课表,学生端查课表、教师端查教学任务都能复用。组件化能极大减少重复代码,也让前端代码结构显得更专业。
5.2 前后端联调中的跨域与代理配置
前后端分离项目遇到的第一道坎就是跨域。我在 Vite 的配置里设置了开发代理,把/api前缀的请求转发到http://localhost:8080:
// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })同时后端也配置了全局跨域支持。为什么要两层都配置?开发阶段用前端代理最省事,前端代码里写相对路径/api/...,部署到生产时也能平滑切换到后端静态资源模式,不用改前端代码。这个细节在答辩时提一句“开发期走代理、生产期走同源部署”,老师会觉得你考虑得很全面。
axios 封装上,我会在请求拦截器里统一附加 token:
axios.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })响应拦截器里则统一处理 code 非 200 的情况,直接弹出 Element Plus 的 Message 提示,并捕获 401 状态跳转登录页。这样业务页面里就不用每个请求都写错误分支,代码会清爽很多。
5.3 三个前端常见的坑,都替你们踩过了
第一个坑:时间格式化。后端返回的 LocalDateTime 默认是一长串序列化文本,如果不对齐,前端显示会很难看。我的做法是后端在application.yml里配置全局 Jackson 格式化yyyy-MM-dd HH:mm:ss,这样前端拿到的就是可直接展示的字符串,不用每个字段手动格式化。
第二个坑:刷新后登录状态丢失。localStorage 存 token 和用户信息后,刷新页面本来不会丢,但如果初始化路由时用的是 Promise 异步加载角色菜单,刷新瞬间 key 还没有被赋值的话,会闪到空白。我的解决方式是 router.beforeEach 里做个同步判断:localStorage 有信息就直接设置到 store,不要再走异步初始化,避免路由守卫和状态初始化打架。
第三个坑:Element Plus 表格的跨页多选。如果学生选课支持分页,每页勾选几门课,跨页勾选后状态会被清掉。需要在表格选择列上设定 reserve-selection 并用 row-key 保持粒度,才能保证跨页选择不丢。这个坑很经典,很多源码里都没有处理好,你踩过之后能讲出来就是一个实践亮点。
6. 联调、部署与答辩演示的实战经验
6.1 本地联调阶段最容易出的问题
本地开发时最容易遇到的三类问题,我单独列出来:
第一类是数据库连接报错。MySQL 8 的驱动如果是老版本,或者时区参数没配,会被The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized这类报错搞疯。我在application.yml的连接串里加了serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true,问题迎刃而解。allowPublicKeyRetrieval 这个参数如果不加,MySQL 8 有时会因为 RSA 公钥检索被拒而连不上,很隐蔽。
第二类是前后端端口冲突或者代理不生效。确认 SpringBoot 端口是 8080,前端 Vite 端口是 3000,代理配置没写错,一般就不会出问题。如果代理不生效,先检查changeOrigin: true是否设置,这个参数漏了会导致后端收到的请求头里还是前端地址,在某些场景下会被拦截。
第三类是数据初始化的问题。自己手动造数据太痛苦,我在 resources 目录下放了data.sql脚本,用 SpringBoot 的spring.sql.init.mode=always在启动时自动初始化管理员账号、测试教师、测试学生和二十多门课程数据。这些数据要刻意设计成能演示的场景:比如有几门课的容量已经接近满员、有几门课上课时间重叠,方便直接演示“满课不可选”和“时间冲突提示”。
6.2 部署方案怎么选,本地演示用什么
部署方案我推荐两种:
方案一,前后端完全分离部署:后端 SpringBoot 跑在 8080,前端通过 Nginx 或静态托管跑在 80。这种方案最贴近企业真实部署方式,但答辩现场如果网络环境不好,反向代理配置出现问题,容易翻车。
方案二,前端打包后放进 SpringBoot 的静态资源目录,打成单 Jar 运行。这个方案我实际使用得更多,因为操作简单、稳定、方便演示——前端npm run build生成的 dist 目录拷贝到后端的src/main/resources/static下,再配合 SpringBoot 的 Web 配置处理 SPA 路由,最终只需要java -jar xxx.jar启动即可。答辩现场只依赖一个 jar 包运行,天然自带前端页面,演示过程几乎零配置。
单 Jar 有一个细节要注意:Vue 路由使用的是 history 模式,刷新页面会 404。解决办法是在后端写一个 Controller 把非/api开头的、无扩展名的路径转发到index.html,或者使用 SpringBoot 提供的WebMvcConfigurer对 SPA 做 forward 处理。这样刷新页面不会白屏。
生产环境建议还是方案一,但毕设答辩现场,方案二能帮你省下大把折腾时间。
6.3 答辩演示前的检查和演示流程
最后说说答辩演示环节的准备。我给自己列过一个检查清单:
- 先以管理员身份登录,展示用户管理和选课窗口配置,把窗口配置成“当前时间处于选课时间内”;
- 切到学生身份,演示选课——选一门正常课程成功、选一门满的课程提示已满、选一门时间冲突的课程提示冲突;
- 切到教师身份,看到学生名单,录入两个成绩;
- 回到学生身份,查课表和查成绩,确认刚才录入的成绩可见;
- 最后演示退课流程,再切到管理员确认人数变化。
这套流程贯穿三个角色,把系统所有的核心功能串成一条完整的业务叙事。演示的时候我还会打开开发者工具的 Network 面板,展示选课请求返回的 JSON 数据和状态码,并顺手指出请求头里带了 JWT ——这些“小动作”能向现场老师直观证明项目的真实性和自己的掌握程度。
关于垃圾数据的清理也要提前做。反复演示选课退课之后,数据库里的数据会比较乱,答辩前我通常会重新初始化一次数据库,确保演示数据干净有序。避免现场出现“课程明明满员却显示 40/40 但名单里只有 10 个人”这种尴尬情况。
这套系统从数据库设计到前后端联调,我前后花了三周左右完成。真正花时间的不是写代码——SpringBoot 和 Vue 的脚手架已经能省掉大半重复工作——而是把业务边界想清楚、把并发冲突和权限控制这些细节打磨扎实。做选课系统的过程里,我最大的收获不是跑通一个 Demo 时的成就感,而是终于意识到:一个靠谱的项目,靠的不是贴更多新技术,而是把业务逻辑和数据一致性真正落到每一天的编码细节中。下次再做类似管理系统,哪怕业务换成了图书馆借阅或实验室预约,这套“角色-权限-状态-事务”的思考框架我也还是底层的那一套。