学生选课系统是Java全栈开发里最常被拿来练手的项目之一,每逢毕业设计和课程设计季节,问得最多的一定是它。原因很简单:业务模型清晰,学生、教师、管理员三种角色,选课、退课、排课这些场景贴近校园生活,逻辑复杂度又刚好能撑起一套完整的前后端分离项目。
这篇东西我打算直接用做项目的思路来讲。从需求拆解到数据库设计,从后端接口实现到前端页面联调,再到最后的部署上线和文档整理,一套走完。不管你是拿这套源码做课程设计、毕业设计,还是想彻底搞懂Spring Boot + Vue前后端分离项目的完整玩法,应该都能找到你需要的部分。
1. 项目整体设计:先搞清楚选课系统到底在解决什么问题
很多初学者拿到选课系统的题目,上来就建表、写代码,结果做着做着发现自己不知道在实现什么。做任何系统之前,先把业务梳理清楚,后面每一步才有依据。
1.1 核心角色与业务场景梳理
学生选课系统本质上是解决一个“资源分配”的问题。课程有容量上限,学生有选课需求,系统要保证资源分配公平、数据准确、操作可追溯。围绕这个核心,业务场景可以拆成以下几块:
- 学生端:浏览可选课程、查看课程详情(教师、时间、地点、学分、容量)、选课、退课、查看自己已选的课表、查看成绩。
- 教师端:查看自己负责的课程、查看选课学生名单、录入成绩。
- 管理员端:课程信息管理(新增、修改、上下架)、学生和教师账号管理、统计选课人数、处理特殊调整需求。
这三个角色对应三种权限,前端路由要和权限对应起来,后端接口也要有拦截校验。我见过很多学生项目把所有接口都开放出来,没有任何鉴权,这样的系统虽然能跑,但在答辩时很容易被问到“你怎么保证一个学生不能改别人的成绩”这类问题。
比较好的做法是引入JWT做登录态管理。用户登录后,后端签发一个token,前端把token存在localStorage或者内存里,每次请求带上 Authorization 头,后端通过拦截器统一校验token并解析出用户身份和角色。角色不同能访问的接口不同,这就是最简单的权限控制模型。
1.2 技术栈选型:为什么是Spring Boot + Vue + MySQL
这套组合在校园项目和中小型企业项目中都非常主流,原因在于每一层选的都是“刚好够用且生态成熟”的技术。
后端用Spring Boot,理由非常实在。它内嵌Tomcat,不需要单独部署容器,打包成jar就能直接跑。自动配置机制省掉了大量XML配置,Spring Security、MyBatis-Plus、Validation等组件都有一键集成的starter。对学生来说,这意味着可以把更多精力放在业务逻辑上,而不是浪费在环境配置上。更重要的是,Spring Boot的面试题非常多,做完这个项目再去复习IoC、AOP、自动装配这些概念,理解深度会完全不同。
前端用Vue全家桶,核心是Vue 2或Vue 3 + Vue Router + Pinia/Vuex + Element UI。Vue的组件化开发方式特别适合后台管理系统这种“页面结构相似、逻辑重复”的场景。表格、表单、弹窗、分页这些UI组件用Element UI一行就能引入,开发效率非常高。而且Vue生态的文档和社区资料全,遇到问题几乎都能搜到解决方案。
数据库选择MySQL,本身是关系型数据库的经典选择。选课系统里有明显的数据关联关系:学生选课表要关联学生表和课程表,成绩表要关联选课记录和教师表。MySQL的ACID事务特性也正好能解决选课时的并发一致性问题,这块后面会详细讲。
不做技术选型对比的话,很多同学会纠结“要不要用Redis做缓存”“要不要用MyBatis-Plus还是JPA”“要不要前后端不分离”。我的建议是:如果你是课程设计或者毕设,不要为了炫技引入过多组件。项目里用到的每一项技术,你都要能解释清楚它解决什么问题,这才是答辩时加分的点。我这套系统里,核心就是Spring Boot + MyBatis-Plus + MySQL + Vue 2 + Element UI,每一样都能讲清楚为什么选它。
2. 数据库设计与后端核心接口:把选课逻辑落进代码
数据库设计是整套系统的地基。表结构如果设计得不合理,后面写接口、做联调、应对并发,都会遇到大量麻烦。我见过很多选课系统的表设计,最常见的问题是:选课关系表设计得太随意,没有唯一约束、没有级联关系、字段类型不合理。
2.1 数据库表结构设计与关系说明
一套标准的选课系统,核心表至少有这几张:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| student | 学生信息 | id、student_no、name、password、major、class_name |
| teacher | 教师信息 | id、teacher_no、name、password、department |
| course | 课程信息 | id、course_no、name、teacher_id、credit、max_student、selected_count、week_time、classroom |
| student_course | 选课关系表 | id、student_id、course_id、status、score、create_time |
student表和teacher表可以各自独立,也可以统一做成user表加角色字段。我倾向于独立建表,因为字段差异比较大,而且业务角色清晰。course表和teacher表通过teacher_id建立关联,一门课程对应一个教师,一个教师可以有多门课程,这是很典型的一对多关系。
student_course表是整套系统的核心枢纽。它通过student_id关联学生、course_id关联课程,形成多对多关系的中间表。这张表的设计有几个关键点:
- student_id和course_id必须加联合唯一索引。这个索引的作用是从数据库层面保证同一个学生不能重复选择同一门课程。光靠代码判断是不安全的,并发请求下代码判断可能失效,但数据库约束不会。
- score字段默认设为0或者NULL。成绩还没录入时,前端展示应该显示“未录入”,不要用0去表示,因为0分和未录入在语义上完全不同。
- status字段建议保留。虽然基本只有选课和退课两个状态,但保留这个字段,以后要加“待选”“已选”“退课中”等状态时不用改表结构。
课程表里需要特别注意max_student和selected_count这两个字段。一个表示课程容量上限,一个表示已选人数。每次选课成功,selected_count就加1,退课就减1。这就是最朴素的“库存扣减”模型。
2.2 后端接口设计与核心业务实现
后端接口设计我建议遵循RESTful风格,用HTTP方法表达操作意图。以课程和选课相关的核心接口为例:
GET /api/course/page分页查询课程列表GET /api/course/{id}查询课程详情POST /api/course新增课程(管理员)PUT /api/course/{id}修改课程信息(管理员)DELETE /api/course/{id}删除课程(管理员)POST /api/student/course/{courseId}学生选课DELETE /api/student/course/{courseId}学生退课GET /api/student/course/list查询我的选课列表PUT /api/teacher/score教师录入成绩
工程结构用标准的Controller-Service-Mapper三层架构。Controller层只做参数接收和结果封装,不写业务逻辑;Service层写核心业务逻辑,事务注解加载这里;Mapper层用MyBatis-Plus,单表CRUD不需要写SQL,复杂查询用LambdaQueryWrapper或者注解SQL解决。
选课这个接口是最核心的,业务逻辑大致是这样:
- 校验课程是否存在、是否在上架状态。
- 校验学生是否已经选过这门课(联合唯一索引兜底)。
- 校验当前已选人数是否小于课程容量。
- 校验课程时间是否和其他已选课程冲突。
- 插入选课记录,课程已选人数加1。
这五步必须在同一个事务里,要么全部成功,要么全部回滚。用@Transactional注解即可。注意第5步的“已选人数加1”不能先查出数量再在代码里加,应该用一条原子SQL去更新:
UPDATE course SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < max_student这种写法的好处是,即使两个学生同时发起选课请求,数据库行锁也会保证只有一个请求能更新成功,另一个请求会因为条件不满足而影响行数为0,从而在数据库层面防止超选。这是这套系统里我认为最核心的一个细节。
2.3 并发选课与事务控制要点
学生选课系统的并发量不像电商秒杀那么夸张,但在选课高峰期,比如开放选课的第一分钟,几百个学生同时点选课按钮是非常正常的。如果代码写得不讲究,很容易出现两个问题:一是超选,二是重复选课。
重复选课靠数据库唯一索引解决,前面已经说了。超选问题必须靠事务和原子更新解决。我见过很多同学这样写:
Course course = courseMapper.selectById(courseId); if (course.getSelectedCount() >= course.getMaxStudent()) { return "课程已满"; } course.setSelectedCount(course.getSelectedCount() + 1); courseMapper.updateById(course);这种写法在并发请求下必出问题。两个线程同时读到selected_count=49,容量是50,两个请求都判断通过,然后都执行加1,最后selected_count变成51,超卖了。根源在于“读”和“写”之间没有锁保护。解决办法就是前面提到的条件更新SQL,把判断和更新合并成一个原子操作。
事务控制上还要注意一个细节:@Transactional默认只在RuntimeException和Error时回滚,如果方法里catch了异常但没有重新抛出,事务是不会回滚的。我当时就踩过这个坑,日志里明明看到异常了,但数据也插进去了。正确的做法是异常往上抛,或者手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
3. 前端Vue实现与前后端联调:从写页面到真正跑通
后端接口写好后,前端就是把这些接口一个一个对接到页面上。很多同学第一次做前后端分离项目,最迷茫的不是Vue语法,而是“怎么把一个后端接口变成页面上能点的按钮”。
3.1 前端工程结构与路由设计
前端工程我用Vue CLI创建,目录结构大致如下:
src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── store/ # 状态管理 ├── views/ # 页面组件 │ ├── admin/ │ ├── student/ │ └── teacher/ ├── utils/ # 工具函数 └── App.vueapi目录下按照业务模块拆文件,比如course.js、student.js、user.js。每个文件里统一用axios发请求,导出对应的方法。这样做的好处是页面里不需要直接写请求路径,改后端地址时只需要修改一个配置文件。
路由设计上,用Vue Router的嵌套路由把管理员、学生、教师三个角色的页面分开。每个角色的页面放在对应的layout里,再加上路由守卫。路由守卫的作用是:用户没登录时跳转登录页,登录后访问没有权限的页面时跳转403页。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else { if (!token) { next('/login') } else if (to.meta.roles && !to.meta.roles.includes(store.state.user.role)) { next('/403') } else { next() } } })这里有个很容易被忽略的点:路由守卫只是前端体验层面的控制,真正的权限校验必须以后端接口为准。前端隐藏掉按钮不代表接口不能调,该做的后端拦截一个都不能少。
3.2 页面开发与组件封装
页面开发的核心思路是“先搭骨架,再填细节”。以学生选课页为例,这个页面通常是整个系统最复杂的页面,需要同时处理课程表格展示、搜索条件、分页、选课操作、已选课状态展示。
我习惯先把页面分为三个板块:搜索区、表格区、分页区。搜索区用el-form和el-input做条件输入,表格区用el-table渲染数据,操作列放选课/退课按钮。分页用el-pagination组件。数据获取逻辑写在methods里,页面加载时在created生命周期中调用。
选课按钮的处理要特别注意交互体验。一个学生选过课之后,按钮应该显示“已选”且置灰,不能等用户点下去才提示。这要求后端返回的数据里包含当前用户和课程的关系状态,或者前端在拿到选课列表后做一个id集合,渲染表格时判断一下。
后端课程列表接口返回的字段里,我建议加上一个selected布尔字段,表示当前登录用户是否已经选了这门课,以及countLeft剩余名额。这样前端渲染起来非常顺畅,不用做二次请求。
表单处理上,Element UI的el-form提供了很方便的校验规则,但是重置表单时有个经典坑:this.$refs.form.resetFields()重置的是初始值,而不是清空所有值。如果你在dialog打开时才通过接口异步赋值,resetFields可能不会生效。解决办法是在打开弹窗时用nextTick重新设置初始值,或者直接手动清空数据对象。
3.3 前后端联调与跨域处理
前后端分离项目里,跨域是最常见的联调问题。前端跑在8080端口,后端跑在8081端口,浏览器的同源策略会把请求拦下来。
解决方式有两种常用方案。第一种是在后端配置CORS,写一个WebMvcConfigurer实现类,配置允许的跨域来源。第二种是前端利用Vue CLI的devServer代理,把请求代理到后端地址。我个人推荐第二种,因为开发环境和生产环境的配置可以完全一致,前端请求直接写相对路径。
// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } } }配置好后,前端请求/api/course/page,开发服务器会把它转发到http://localhost:8081/api/course/page。这样前端代码里不需要写任何绝对地址,以后生产环境部署时也只需要配置Nginx指向后端服务,前端代码一行都不用改。
联调过程中,一定要让后端和前端约定好统一的返回结构。我用的统一结构是这样:
{ "code": 200, "message": "操作成功", "data": {} }前端在axios响应拦截器里统一处理code,code为200时正常返回data,code不为200时弹出message提示。这样前端业务代码里不用每个请求都写错误处理,代码会干净很多。
4. 部署上线与文档交付:源码、数据库、文档缺一不可
项目写完之后,很多同学就抱着“跑起来就算完事”的心态,其实部署和文档同样是项目非常重要的一部分。尤其当你拿这个项目作为课程设计或毕设,答辩时老师一定会问部署过程,也会翻看文档。一个能跑、能部署、有完整文档的项目,和一个只能在本机IDE里跑的项目,评分完全不在一个层级。
4.1 环境配置与本地运行步骤
本地运行需要的环境清单如下:
| 软件 | 版本建议 | 用途 |
|---|---|---|
| JDK | 1.8或11 | 后端运行环境 |
| Maven | 3.6+ | 后端依赖管理和打包 |
| Node.js | 14+ | 前端运行和构建 |
| MySQL | 5.7或8.0 | 数据库 |
| Navicat或DBeaver | 任意 | 数据库管理工具 |
拿到源码之后,第一步不是启动,而是检查配置文件。后端有一个application.yml,重点检查数据库连接配置。我建议数据库密码不要写在代码里用明文,虽然在项目中图省事可以这样做,但至少要做到每个环境用一个独立的配置文件,通过Spring Boot的多环境配置功能切换。开发环境用application-dev.yml,生产环境用application-prod.yml,启动时通过--spring.profiles.active=prod指定使用哪个环境。
数据库导入时有个容易踩坑的地方:脚本文件的字符集。如果你的SQL脚本里包含中文注释或者中文数据,导入前确认文件编码是UTF-8,否则会导入乱码数据。导入时选择好目标数据库,注意脚本里如果有CREATE DATABASE语句,可能会和你本地已有的数据库重名,部分导入工具会提示覆盖。
4.2 打包部署:jar + dist + Nginx
生产环境部署是整个项目最见功力的一步。后端用Maven打包:
mvn clean package -DskipTests打包完成后,target目录下会生成一个jar文件。用java -jar xxx.jar就能直接启动。注意Spring Boot内嵌了Tomcat,不需要额外安装。
如果你想在服务器上长期运行,不推荐直接用java -jar命令挂在前台。用nohup命令放到后台:
nohup java -jar student-course-system.jar --spring.profiles.active=prod > app.log 2>&1 &这样启动的好处是,即使断开SSH连接,服务也不会停止。日志输出到app.log文件,排查问题时可以随时查看。
前端构建:
npm run build构建完成后生成dist目录,里面是纯静态文件。把这些文件交给Nginx托管,Nginx配置里要同时做两件事:一是将根路径指向dist目录,二是把/api开头的请求反向代理到后端jar服务。
server { listen 80; server_name your-domain.com; location / { root /opt/student-course/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }有个细节一定要记住:try_files $uri $uri/ /index.html。这一行是解决前端路由刷新404的问题。Vue Router的history模式下,浏览器访问/student/courses这个地址时,如果Nginx没有配置try_files,它会去找服务器上是否存在/student/courses这个文件,找不到就返回404。加上try_files后,所有找不到的路径都会回退到index.html,由前端路由接管。
4.3 数据库脚本与项目文档的整理
源码和数据库之外,文档是整个交付包里最容易被忽视、却最能体现专业度的部分。一套完整的学生选课系统文档,至少应该包含:
- 需求分析文档:角色分析、功能列表、用例图或用例描述。
- 数据库设计文档:ER图、每张表的字段说明、索引说明、核心SQL逻辑。
- 接口文档:每个接口的请求方式、请求路径、参数说明、返回示例。
- 部署文档:环境要求、初始化步骤、启动步骤、常见问题。
- 测试报告:功能测试用例、测试结果、发现的问题和修复情况。
数据库脚本建议拆成两个文件:schema.sql负责建表,data.sql负责插入初始数据。初始数据至少要包含一个管理员账号、一个测试教师账号、一个测试学生账号,以及若干门课程数据。评分老师拿过去直接就能用,这个体验会非常好。
文档并不是写完就完事了,我见过很多同学在项目答辩前才临时补文档,写出来的东西跟代码严重脱节。正确的做法是边写代码边记录,每完成一个功能模块就同步更新对应文档。如果你是从零开始做这个项目,我建议你把文档写进git的提交记录里,每次提交代码时顺手更新一下文档,这样保证文档和代码永远同步。
5. 常见问题与排查技巧实录
这部分是我自己实际做项目时遇到的问题汇总。很多问题网上也能搜到,但我把最典型的场景和排查思路直接列出来,供你参考。
5.1 高频报错与解决方案速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端请求接口报404 | 请求路径和后端接口路径不一致,或Nginx代理配置错误 | 先在浏览器直接访问后端接口地址,确认后端接口是否存在,再检查前端请求路径和代理配置。 |
| 报错Access-Control-Allow-Origin | 前端直接访问了后端地址,跨域拦截 | 开发环境用代理,生产用Nginx代理,后端也可以配置CORS作为兜底。 |
| 数据库连接失败 | 数据库地址、端口、密码配置错误 | 检查application.yml中jdbc连接串,用Navicat或命令行工具先测一下数据库能否连接。 |
| MyBatis-Plus查询结果为空 | 表名、字段名大小写或下划线映射问题 | 检查实体类字段和表字段的驼峰映射配置,确认map-underscore-to-camel-case配置为true。 |
| 前端打包后访问空白页 | 资源路径配置不对 | vue.config.js中publicPath改为相对路径./,或部署时保证dist资源路径正确。 |
| 部署后刷新页面404 | 没有配置try_files | 在Nginx的location /中配置try_files,或者改成hash路由模式。 |
这里重点说一下MyBatis-Plus的字段映射问题。数据库字段通常用下划线命名,比如student_no,Java实体类字段用驼峰命名,比如studentNo。MyBatis-Plus默认开启了驼峰映射,但如果你自定义SQL里写的是SELECT *,一般情况下没问题。如果你自己写了带别名的SQL,一定要给列名起别名,否则字段会映射不上。这个坑排查起来很麻烦,因为不报错,就是字段为null。
5.2 并发选课时容易忽略的几个坑
前面讲接口时我已经说了超选问题的核心解法,这里再补充几个容易被忽视的细节。
第一,选课按钮没有做防重复点击。用户快速点两下选课按钮,请求发了两次,即使数据库有唯一索引,第二次请求会报重复选课的错,这个可以接受。但如果你的代码是先查再插,两次请求可能都通过查询,然后第一次插入成功第二次插入失败。用户体验上,用户看到第一次请求成功,第二次请求弹出“重复选课”的提示,会觉得很奇怪。解决办法是把选课请求在接口层面做到幂等,最简单的方式是前端在请求未返回时把按钮禁用,加上loading状态。
第二,退课和选课并发。如果一个学生选课时课程满了,恰好另一个学生正在退课,此时退课还没提交事务,选课请求查询到的剩余名额还没变,选课就失败了。这个场景在实际中概率不高,但确实是存在的。如果要对这个场景做优化,可以在退课接口里用同步锁或者悲观锁,但课程设计阶段一般不需要做到这一步,能应对典型的超选问题就已经足够了。
第三,跨事务的缓存问题。如果你用了Redis缓存课程剩余名额,缓存和数据库之间的一致性就是一个大坑。我建议初级项目不要引入Redis缓存课程信息,直接在数据库层做原子扣减,性能和一致性都能满足需求,还能把代码逻辑保持在可解释的范围内。
5.3 提升答辩和评分的几个细节
答辩时老师通常不会只看功能是否齐全,更多会考察你对项目的理解深度和细节的处理。有几个细节我认为能明显拉开差距。
第一,项目里要有日志。你可以在关键业务节点加上日志记录,比如选课成功、退课成功、成绩录入等操作,打印请求参数和耗时。老师问“你怎么排查线上问题”的时候,你说“我在关键操作上加了日志,可以通过日志追踪用户操作链路”,这个回答会非常加分。
第二,异常提示要够具体。不要统一的“系统错误”,而是区分参数缺失、数据不存在、状态不允许、操作冲突等不同场景,给用户明确的提示。这能说明你真的考虑过业务的边界情况。
第三,代码要有注释但不要过度注释。关键的算法逻辑、事务边界、防超卖设计,这些地方加注释说明原因;简单的方法不需要注释。老师翻代码时,看到关键地方有解释,会觉得这个项目是你认认真真写的。
第四,项目里要有一两个“亮点设计”。比如联合唯一索引防重复选课、原子更新防超选、统一返回结构、路由守卫做权限控制、Nginx部署时配置try_files解决刷新404,这些都是能拿得出手的细节,答辩前可以把它们提前准备好,组织好语言思路就好。
6. 一些个人总结
学生选课系统这个项目我带过不少学生做过,也自己完整地开发过好几版。我越来越觉得,这类项目的价值不在于“技术有多新”,而在于“它逼你把一套完整软件的流程走完”:分析需求、设计库表、写后端接口、写前端页面、联调、部署、写文档。每一步都会遇到具体的问题,每个问题都能在真实的开发场景里找到对应。
如果你是在校学生,做完这样一个项目,简历上可以写成“基于Spring Boot + Vue的学生选课系统,涵盖了权限管理、事务控制、前后端分离开发、Nginx部署”。面试官问细节时,你能把选课防超卖的原理讲清楚,把跨域解决方案说清楚,这已经比很多只背八股文的候选人有说服力了。
最后说一个我的个人习惯:开发和联调阶段,尽量把错误信息完整地暴露出来,不要在前端把错误全部吞掉。很多初学者觉得前端报错不好看,把错误弹窗全部删掉了,但这样反而让排查问题变得更困难。我一般都会保留错误提示,哪怕是500错误,也要在控制台打印出完整的堆栈信息。等项目稳定了,再根据不同的错误类型去优化提示文案。这个习惯帮我节省了大量排查问题的时间,希望对你也有效。