1. 项目到底要做什么:需求拆得越细,后面越省事
光看“前后端分离大学生选修选课系统”这个名字,你可能觉得就是个普通CRUD,能增删改查就能交差。但真上手做一圈你会发现,选课系统最麻烦的不是接口怎么写得漂亮,而是业务规则怎么落地——什么时候允许选课、一个人最多选几门、两门课时间撞了怎么办、退课以后名额怎么释放,这些才是让你熬夜改代码的根源。
所以我建议第一步不是急着搭SpringBoot脚手架,而是先把需求边界划清楚。这套系统的使用者是三类人:学生、教师、管理员。学生关心的是“我能选什么课、还剩多少名额、我的课表长什么样”;教师关心的是“我这门课开了几个班、有多少人选、成绩怎么录”;管理员关心的是“课程信息怎么维护、选课窗口什么时候开、数据怎么导出”。三类角色的权限边界、页面视图、接口范围完全是三套东西,混在一起写后面必乱。
再往下拆,核心业务流程其实就那么六条:课程发布、选课、退课、课表查询、成绩录入、用户管理。每条流程都要回答“谁在什么条件下能做什么事”这个问题。以选课为例,条件至少有四个:选课时间是否在开放时间段内、该课程是否有剩余名额、学生的已选课程是否与新课在时间上冲突、学生是否已经选过这门课。你把这些约束写进需求文档,数据库设计和接口设计就有据可依了,而不是写代码的时候拍脑袋临时加判断。
这套组合选得也很典型:SpringBoot负责提供接口,Vue负责页面渲染,MyBatis处理数据持久化,MySQL存数据。前端彻底独立部署,通过HTTP调用后端API,不直接碰数据库。这种分离的好处第一是职责清晰,前端改样式不碰后端,后端加接口不碰页面;第二是联调效率高,只要约定好接口格式,前后端可以并行开发;第三是部署灵活,前端挂Nginx,后端扔服务器上跑,互不干扰。你现在写课程设计可能体会不深,等以后进团队做真实项目,这套模式就是标配。
如果你是拿这个项目做毕业设计,我的建议是:先把上面的需求拆解写进开题报告或文档里,导师看完会觉得你做了充分调研。如果你只是自学练手,那更要把业务规则想透,因为写业务逻辑比写CRUD有意思得多,也更锻炼人。
2. 数据库设计:选课系统的地基,绕不开的几张表
选课系统的表结构看起来简单,但有几处设计直接决定后续代码好不好写。我把核心表拆成五张,外加一张可选的日志表,整体关系不复杂,但每个字段都有讲究。
2.1 五张核心表的基本结构
用户表,涵盖学生和教师两类账号。字段就那几样:主键id、用户名、密码(存加密后的密文,明文是找骂)、角色标识(可以是一个role字段区分,也可以拆成两张表,我建议用role字段,简单且够用)、姓名、学号工号、班级等个人信息。角色用整数或者字符串都行,关键是后端接口要用它做权限判断。
课程信息表,存课程的基本属性。课程编号、课程名称、学分、授课教师id、课程性质(必修还是选修,选修课系统里其实大多数课都是任选)、课程简介、上课地点。注意,一门课可能同时开多个班,也就是同一门课程有多个教学班,每个班的容量、上课时间、任课老师可能都不一样。
教学班表,这个表容易被新手忽略。它把“课程”和“具体一个班的选课记录”联系起来。字段包括:所属课程id、任课教师id、容量上限、已选人数、上课时间(星期几、第几节、第几周)、教学楼教室。有了这张表,选课记录才能定位到“某个学生选了某个教学班”,而不是笼统地把学生跟课程绑定。
选课记录表,这是整个系统的核心,也是并发压力最集中的地方。字段至少包括:选课记录id、学生id、教学班id、选课时间、状态(正常、已退)。选课时插入一条记录,退课时更新状态或者直接删除,看你的业务需求。
成绩表,教师录入成绩用的。教学班id、学生id、成绩、录入时间。一个教学班对应多个学生成绩,一个学生在一个教学班只应该有一条成绩记录,所以这个表要加联合唯一约束。
2.2 一个关键设计:选课记录表必须有联合唯一约束
这是我最想提醒你的一点。选课系统最容易出的Bug就是同一个人把同一门课选了两遍,尤其是前端因为网络原因重复提交的时候。光靠前端按钮置灰是不够的,后端数据库必须兜底。
-- 选课记录表添加联合唯一约束 ALTER TABLE select_record ADD UNIQUE KEY uk_student_class (student_id, class_id);这条约束的含义是:同一个学生对同一个教学班只能存在一条选课记录。如果应用层漏判,数据库会直接抛DuplicateKeyException,你捕获这个异常返回“你已选过这门课”的提示即可。这就是典型的“应用层兜底不如数据库兜底”的实践,很多人写代码时没这个意识,等到出了脏数据才后悔。
2.3 课程时间段的设计,别只存一个“上课时间”字符串
很多新手图省事,直接在库里存“周一3-4节”这种文本。查询的时候模糊匹配,看着能用,实际是给自己埋雷。你想实现“两门课时间冲突检查”的时候,就会发现自己要对字符串做解析,解析完还得处理跨周、单双周这些情况,代码越写越恶心。
我的做法是结构化存储:星期几存一个整数(1到7),节次存一个字符串(例如"3,4"表示第三四节),起始周和结束周各存一个整数,是否需要单双周用一个标志位。检索某一教学班的时间时,后端解析出结构化数据再做冲突判断,代码写起来清晰得多。
CREATE TABLE teaching_class ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, day_of_week TINYINT NOT NULL COMMENT '1-7 表示周一至周日', period_section VARCHAR(20) NOT NULL COMMENT '如 3,4 表示第3-4节', week_start TINYINT NOT NULL, week_end TINYINT NOT NULL, capacity INT DEFAULT 60, selected_count INT DEFAULT 0, classroom VARCHAR(50) );这样设计以后,选课冲突检测的SQL都不用写太复杂,先把学生已选的教学班列表查出来,再在内存里比对时间段是否重叠即可。数据量不大,性能完全够。真正的高并发场景才需要考虑数据库层的区间查询优化,选修课系统的规模远没到那个级别。
3. 后端实现的关键点:认证、事务与并发控制
SpringBoot写CRUD确实快,但选课系统的后端有几个点不能糊弄过去,我把实践中最重要的三个环节拆开讲。
3.1 JWT认证与权限控制,拦截器只做一半
前后端分离的项目不需要用Session,主流做法是JWT。用户登录成功,后端生成一个token返回,前端存在本地存储里,每次请求在请求头带上Authorization字段。后端用一个拦截器解析token,把用户id和角色信息放进ThreadLocal或者请求上下文里,供后续业务代码使用。
依赖可以这么加:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency>登录接口的核心逻辑:
public String login(String username, String password) { User user = userMapper.findByUsername(username); if (user == null || !BCrypt.checkpw(password, user.getPassword())) { throw new BizException("用户名或密码错误"); } String token = Jwts.builder() .setSubject(user.getId().toString()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); return token; }角色权限这块,我的建议是拦截器里只做token合法性和用户状态的校验,角色判断放在具体的业务代码里,用注解或者手动判断都行。为什么?因为选课系统的接口数量本就不多,没有必要上Spring Security那一整套复杂配置。全局拦截器加自定义注解的方式,既灵活又能控制粒度。
有一个坑必须提醒你:JWT的密钥不要硬编码在代码里。你写毕设或者练手项目可能觉得无所谓,但如果代码要提交到git仓库,等于把签名密钥给泄露了。正确做法是放到配置文件里,用环境变量注入。
3.2 选课的核心逻辑:事务里先锁行再判断
选课的并发问题,是整段代码里最能体现水平的地方。想象一个场景:某门课只剩最后一个名额,两个学生同时点击选课。如果没有控制,两个请求都读到剩余名额为1,然后都插入成功,容量就超了。这就是经典的超卖问题。
解决思路其实不复杂,核心是把判断名额和扣减名额做成原子操作。我的做法是在事务内,先对教学班那一行做悲观锁更新,再判断剩余名额:
@Transactional public void selectCourse(Long studentId, Long classId) { // 先锁住教学班记录 TeachingClass tc = teachingClassMapper.selectByIdForUpdate(classId); if (tc.getSelectedCount() >= tc.getCapacity()) { throw new BizException("该课程名额已满"); } // 检查是否已选 SelectRecord existing = selectRecordMapper.findByStudentAndClass(studentId, classId); if (existing != null) { throw new BizException("你已选过该课程"); } // 检查时间冲突 List<TeachingClass> selectedClasses = teachingClassMapper.findByStudentId(studentId); if (hasConflict(tc, selectedClasses)) { throw new BizException("课程时间与已选课程冲突"); } // 插入选课记录,更新名额 selectRecordMapper.insert(studentId, classId); teachingClassMapper.increaseSelectedCount(classId); }这里的核心是selectByIdForUpdate,对应SQL里的SELECT ... FOR UPDATE,它会把教学班这一行锁住,直到事务提交或者回滚。第二个请求执行到同一行时会阻塞等待,第一个请求提交后它再读到最新的已选人数,发现满员了,直接抛出业务异常。
你可能想问,为什么不用乐观锁?乐观锁的典型做法是加一个version字段,更新时检查version是否变化。但选课场景下,一个学生只能选一门课,冲突的焦点在同一行数据上,“先锁行再判断”用悲观锁的思路更直接,代码也更简单。对于这种低频写、高频读的场景,锁粒度小,性能影响完全可以忽略。
3.3 MyBatis的SQL编写注意点
用MyBatis写Mapper,我有几个习惯想跟你分享。第一,动态SQL尽量用注解或者XML里的<where>标签包裹,避免出现“WHERE 1=1”这种写法,虽然能用,但看起来实在业余。第二,插入选课记录时,不需要先查询再插入,直接 insert 然后捕获唯一约束冲突即可,少一次查询也能降低并发情况下的判断漏洞。第三,批量查询用foreach标签时要控制好in列表的长度,几百个id没问题,几万个id就该换思路了。
另外,数据库连接池的参数也要调一下。选课高峰期会出现大量并发连接,默认的HikariCP配置一般够用,但可以把最大连接数往上调一点,同时设置合理的超时时间,避免请求堆积。
4. 前端Vue的实现要点:路由守卫、请求封装与状态同步
前端这块容易被当成“画页面”的活儿,但真正写起来,路由权限、接口调用、状态同步这些环节同样有讲究。
4.1 Vue Router的路由守卫做登录拦截
前端路由守卫的作用是:用户没有登录就访问需要登录的页面时,直接跳转到登录页。核心逻辑写在router.beforeEach里:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else if (to.meta.role && hasRole(to.meta.role) === false) { next('/403'); } else { next(); } });学生、教师、管理员的路由表分开配置,在路由的meta字段里标记需要的角色。比如教师管理成绩的页面meta: { role: 'TEACHER' },学生访问时就拦下来。前端拦截只是体验优化层面的事,后端接口同样要校验,千万别把前端的权限校验当成唯一的控制手段。
4.2 Axios拦截器统一处理token和错误
每个请求都手动加token太傻了,用Axios拦截器统一处理即可。请求拦截器从localStorage取出token塞进header,响应拦截器统一处理业务错误码和HTTP状态码。遇到401时,说明token过期或无效,直接跳转登录页。
axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); axios.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );这里有个小细节:后端返回的业务错误码和HTTP状态码要区分清楚。我习惯HTTP 200表示请求本身成功,body里用code字段表示业务是否成功。比如{ code: 500, message: "课程名额已满" },前端根据code决定弹什么提示。这样HTTP层永远不用处理奇奇怪怪的业务异常,排查问题也容易。
4.3 选课页面改造成动态的,别写死状态
选课页面在开发前后端分离应用时有个容易踩的坑:页面上显示“已选”“未选”“名额已满”这些状态,不能依赖刷新时才去后端拉数据,而是要基于当前用户已选课程的实时数据进行计算。我的做法是:进入选课页时,并行发起两个请求,一个查所有可选课程,一个查当前学生的已选课程。前端根据两个结果,在渲染时动态计算每门课的状态,选课成功或退课后,更新本地状态的同时调用接口,本地状态先行更新,实现即时反馈。
Vuex或者Pinia在这里作用很大。把用户信息、已选课程列表、课程列表放进store,各页面共享,避免反复请求。注意:store里的状态要和后端保持一致,每次选课操作成功后,记得同步更新store里的已选列表和课程剩余名额。如果你不更新,页面显示就会和实际数据库不一致,用户看到的是假数据,体验非常差。
5. 从零到上线:完整部署流程实录
项目开发完,部署上线才是最终考验。这里我走一遍完整的部署流程,分环境准备、后端部署、前端部署三个环节。
5.1 环境准备与项目初始化
我假设你的服务器是Linux系统,数据库用MySQL。先把基础环境装好:
# 安装 JDK 17 sudo apt install openjdk-17-jdk # 安装 MySQL sudo apt install mysql-server # 安装 Nginx sudo apt install nginxMySQL初始化数据库,建议用utf8mb4字符集,不然存中文会出问题:
CREATE DATABASE course_selection DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;创建完数据库后,把建表SQL文件导入。这一步别拖延,越早建库越早能跑通本地联调。
后端配置文件是整套部署里最容易出错的地方。application.yml里要把数据库地址、用户名密码、JWT密钥都改成服务器环境的实际值,尤其是密码,不要用弱口令。
spring: datasource: url: jdbc:mysql://localhost:3306/course_selection?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码5.2 后端打包与启动
SpringBoot项目的打包用Maven,在项目根目录执行:
mvn clean package -DskipTests打包完成后,target目录下会生成一个jar包。把它上传到服务器,然后用nohup方式启动:
nohup java -jar course-selection-backend-1.0.0.jar --spring.profiles.active=prod > app.log 2>&1 &关于启动配置,我建议把开发环境和生产环境的配置文件分开,用--spring.profiles.active切换。开发环境用本地的MySQL,生产环境用服务器的MySQL,端口也可以分开。这样本地调试和线上部署互不干扰。
5.3 前端构建与Nginx配置
前端Vue项目构建前,把接口地址改成服务器的实际地址。很多新手在这步栽跟头:本地开发时接口地址写http://localhost:8080,打包后还是这个地址,线上访问自然失败。正确做法是把接口地址配置成环境变量,构建时动态注入。
修改.env.production:
VITE_API_BASE_URL=http://你的服务器IP:8080然后构建:
npm run build构建产物是dist目录,把它上传到服务器。Nginx配置大概长这样:
server { listen 80; server_name 你的域名或IP; root /var/www/course-selection; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意try_files $uri $uri/ /index.html;这一行,它的作用是:如果请求的路径在服务器上不存在,就回退到index.html。这是Vue Router的history模式必须的配置,否则刷新非首页路由会报404。
6. 常见问题与排查技巧实录
最后这部分,我把实际部署和开发过程中最容易遇到的几个问题整理出来,每一条都是踩过的坑。
6.1 跨域问题:前后端分离的头号拦路虎
现象:前端在开发环境用npm run dev访问localhost:5173,请求后端localhost:8080,浏览器报跨域错误。
解决办法是后端开启CORS配置。我推荐用Spring Boot的WebMvcConfigurer统一处理,而不是在每个接口上加注解:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }生产环境如果用了Nginx反向代理,跨域问题一般不会出现,因为前后端共用同一个域名,/api/路径已经被代理到后端了。所以线上还报跨域,基本都是Nginx配置不对,先检查location匹配。
6.2 选课超卖问题又来了
你以为加了事务就万事俱备,但并发测试还是出现了超卖?这种情况大概率是锁没有生效。SELECT ... FOR UPDATE必须用在事务里才有效,如果方法上忘了加@Transactional,或者调用链里事务被切断了,锁会立刻释放,等于没锁。
另外,如果selectByIdForUpdate对应的Mapper方法返回值是空,锁就直接不执行了。所以教学班ID必须在调用前确认存在,拿不到记录要提前报错,而不是空着往下走。
6.3 MySQL时区问题的坑
部署后前端展示的时间比实际时间早了8个小时?这是时区配置问题。JDBC连接串上加上serverTimezone=Asia/Shanghai可以解决大部分情况。如果你用Docker部署MySQL,记得在启动容器时也设置时区环境变量。
6.4 前端刷新页面404
前面提到了,Vue Router的history模式需要服务器配合。本地开发没问题,部署到Nginx后刷新404,就是try_files没配置或者配置错了。确认一下location / 块里有没有那一行,没有就加上,加完记得nginx -s reload。
6.5 数据库连接池连接耗尽
选课开放当天,如果访问量上来,日志里出现 “HikariPool-1 - Connection is not available” 之类的报错,就是连接被占满了。解决办法:一是调大连接池最大连接数,二是检查是否有慢SQL拖长了事务时间,三是确认没有连接泄漏——事务方法里别手动开连接不关闭。
我最后说句实在话:选课系统这类项目,最大的价值不是“我用了SpringBoot和Vue”这种技术堆叠,而是把并发控制、权限隔离、状态同步这些真实业务里绕不开的问题完整走了一遍。你把它吃透了,以后遇到任何带业务规则的Web系统,心里都有底。后面如果你想扩展,可以考虑引入消息队列做选课请求削峰,或者用Redis缓存热门课程的名额数据,甚至做成微服务把选课和成绩拆分出去。但前提是先把这套单体系统跑稳,每一步都验证过再往上加东西。