刚帮一个学弟把SpringBoot3 + Vue3的学生选课管理系统从零到一跑通,他之前连Maven是什么都不知道,最后居然顺利过了毕业答辩。写这篇文章的念头就是那时候冒出来的。
市面上的教程,要么只讲前端,要么只贴后端代码,数据库脚本和文档往往要加群才给,出了问题也没人管。真正能让你从“不会”到“能答辩”的完整链路,其实很缺。我自己在做这个项目的时候也踩了不少坑,比如SpringBoot3的jakarta包名迁移、Vue3组合式API的响应式丢失问题、Maven依赖冲突,每一个都能卡你好几天。
这篇就把我实际开发中用到的、能够跑通的完整方案拆开来讲。你不需要有很强的编程基础,只要照着思路一步步来,把核心逻辑吃透,毕业设计和答辩都不会是问题。
1. 选题拆解与整体方案设计
1.1 为什么选“学生选课管理系统”做毕业设计
先说选题这件事。每年毕业设计,老师手上那一堆题目里,学生选课管理系统几乎是“常青树”。原因很简单:业务场景清晰,技术覆盖全面,规模大小合适。
从业务上看,选课系统涉及三种角色:学生、教师、管理员。学生要登录、选课、退课、查成绩;教师要登录、开课、录入成绩;管理员要管理学生信息、教师信息、课程信息。这已经涵盖了典型的RBAC权限模型。从技术上看,它需要完整的CRUD、分页查询、多表关联、事务处理,还要应对并发选课这种经典场景。对一个本科或专科的毕业设计来说,这个复杂度恰到好处——太简单显得没工作量,太难又担心做不完。
最关键的是,它好讲。答辩的时候,老师一定会问“你这个系统的亮点是什么”“数据库为什么这么设计”“选课超卖问题怎么解决的”。选课系统的每一个模块都是一个可以展开讲五分钟的知识点,你不会冷场。
1.2 技术栈确定的背后逻辑
这里直接用我最终确定并跑通的方案:
| 层次 | 技术选型 | 主要用途 |
|---|---|---|
| 后端核心 | SpringBoot 3.2.x | 项目基础框架,自动配置,简化开发 |
| 持久层 | MyBatis-Plus 3.5.x | 数据库操作,提供通用Mapper,大幅减少SQL编写 |
| 数据库 | MySQL 8.x | 数据存储,事务支持好,用户群体大 |
| 前端框架 | Vue 3.4.x + Vite | 构建前端页面,组合式API开发效率高 |
| UI组件 | Element Plus | 提供现成的表格、表单、弹窗组件,不用手写CSS |
| 权限 | JWT + Spring Security | 登录认证与接口权限控制 |
| 构建工具 | Maven 3.9.x | 项目依赖管理和后端打包 |
为什么在SpringBoot2和3之间选了3?现在已经是2025年了,SpringBoot3是绝对的主流。虽然网上SpringBoot2的教程更多,但3在性能和安全性上有明显提升,而且毕业设计用新版本,答辩老师问起来的时候你可以说“我选用了目前主流的SpringBoot3版本”。当然代价是——你踩坑时搜到的很多旧教程不能用,这个我后面会详细讲。
为什么用MyBatis-Plus而不是直接用MyBatis或JPA?对于零基础的学生,MyBatis-Plus的BaseMapper直接帮你把单表CRUD写好了,你不需要手写每一句SQL。选课系统大部分是单表操作,即使有联表查询,写几个XML映射文件也完全够用。JPA虽然也能做,但默认的懒加载和级联操作在只学了半桶水的情况下容易搞出N+1查询问题,不太好调试。
1.3 系统功能模块与整体架构
整个系统按角色划分为三大模块,我整理成一张功能清单,你跟着这个清单开发就不会漏功能:
管理员端
- 学生信息管理:新增、修改、删除、按学号或姓名模糊搜索、重置密码
- 教师信息管理:新增、修改、删除、按教师编号或姓名搜索
- 课程信息管理:新增、修改、删除、课程状态控制(开放选课/关闭选课)
- 选课记录查看:查看所有学生的选课记录,可以手动退课
教师端
- 登录与个人信息维护
- 课程管理:开设新课、修改自己的课程信息、查看自己课程的学生选课情况
- 成绩管理:为选了课的学生录入成绩、修改成绩
学生端
- 注册与登录:学号+密码登录,首次需要注册
- 选课:浏览所有开放选课的课程,点击选课,同一门课不能重复选
- 退课:在选课时间内可以退课,退课后学分自动释放
- 我的课表:查看自己已选课程和成绩
前后端通过RESTful API通信,后端返回统一的JSON格式,前端用Vue3接收后渲染。整个项目的分层结构是标准的三层架构,后期我会详细展开。
2. 数据库设计与核心表结构拆解
2.1 数据库设计的基本原则
很多零基础的同学拿到项目先写代码再想数据库,这是本末倒置。数据库是系统的地基,地基打不好,后面代码写再多都是空中楼阁。我的习惯是:先画表结构,再写实体类,再写Mapper,最后写Service和Controller。
选课系统的数据库设计遵循几个基本原则:
第一,宁可多拆表,不要一表多存。比如学生有学号、姓名、班级、系别,如果你把课程名和教师名都存在选课记录表里,就会出现数据冗余。更新了教师名称,所有关联的选课记录还要跟着改,非常麻烦。
第二,所有关联字段都要建索引。选课记录表里的student_id和course_id是高频查询字段,不建索引的话,学生一多查询就会变慢,答辩时候老师如果问起来也好解释。
第三,主键不要用自增,用逻辑主键。为什么?自增主键在分布式或数据迁移时容易出问题。我这里用的是雪花算法生成的Long类型ID,MyBatis-Plus支持ASSIGN_ID策略,你不需要手动生成,框架自己会处理。
2.2 四张核心表的结构说明
整个系统一共需要四张核心表加一张角色表,下面是我实际用的表结构和设计理由,每张我都标了注意点:
管理员表 sys_admin
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,雪花算法 |
| username | varchar(50) | 登录用户名 |
| password | varchar(100) | BCrypt加密后的密码 |
| name | varchar(50) | 姓名 |
| create_time | datetime | 创建时间 |
学生表 sys_student
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_no | varchar(20) | 学号,唯一索引 |
| password | varchar(100) | 登录密码 |
| name | varchar(50) | 姓名 |
| gender | varchar(10) | 性别 |
| class_name | varchar(50) | 班级 |
| major | varchar(50) | 专业 |
| create_time | datetime | 创建时间 |
教师表 sys_teacher
结构与学生表类似,字段改成teacher_no、title(职称)、department(所属院系)。
课程表 course
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| course_code | varchar(20) | 课程编号,唯一 |
| course_name | varchar(100) | 课程名称 |
| teacher_id | bigint | 授课教师ID,外键关联教师表 |
| credit | decimal(3,1) | 学分,比如2.0、3.5 |
| max_student | int | 选课人数上限 |
| selected_count | int | 已选人数 |
| status | tinyint | 0-关闭 1-开放 |
| course_time | varchar(50) | 上课时间,如“周一 3-4节” |
选课记录表 course_selection
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| student_id | bigint | 学生ID |
| course_id | bigint | 课程ID |
| selected_time | datetime | 选课时间 |
| score | decimal(4,1) | 成绩,教师录入,默认NULL |
| status | tinyint | 0-已退课 1-在读 |
这个表就是学生和课程之间多对多关系的中间表,同时这个表还承担了成绩存储的功能。另外我特别强调,student_id和course_id要建联合唯一索引,这是防止同一学生重复选课的最后底线。
2.3 数据库脚本与初始数据准备
你拿到的源码包里面会有一个sql文件夹,下面有初始化脚本。我自己实际跑通的版本里,初始化脚本做了两件事:创建库表和插入初始数据。
初始数据包括一个管理员账号(admin,密码经过BCrypt加密)、几个测试学生账号、几个教师账号和几门课程。我把所有的SQL都写在一个init.sql里,执行顺序是:建库、建表、插数据。
注意:MySQL8默认字符集是utf8mb4,建表的时候一定要用
DEFAULT CHARSET=utf8mb4,不然后续插入中文数据会乱码。我这里用的是utf8mb4 + utf8mb4_general_ci排序规则,足够用了。
建完表之后我建议你自己验证一下:用Navicat或DataGrip连上数据库,手动插几个测试数据,然后用SELECT * FROM course;查一下。这一步看着多余,但能确认环境没问题,省得以后写代码出了问题分不清是代码的锅还是数据库的锅。
3. 后端开发:SpringBoot3核心模块实现
3.1 环境准备与项目初始化
先把自己的环境检查一遍,这是我带人做项目的第一步,也是最容易翻车的一步:
- JDK 17以上(SpringBoot3强制要求,别用JDK8,编译都过不了)
- Maven 3.6+
- IDEA 2023或2024版本
- MySQL 8.x
- Node.js 18+(前端开发用)
确认JDK版本,在命令行执行java -version,如果是17以上就可以。IDEA打开后,配置文件里确认Project Structure里的SDK确实指向了17,同时设置里把Java Compiler的Target bytecode version改成17。
项目初始化用IDEA自带的Spring Initializr。Group填com.example,Artifact填course-selection,Java版本选17或21,依赖选择:Spring Web、Spring Security、MyBatis Framework、MySQL Driver、Lombok、Validation。这一步能省掉后面很多手动配pom的时间。
生成完项目后,在pom.xml里加上MyBatis-Plus的依赖:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>这里有一个非常关键的点:MyBatis-Plus的mybatis-plus-boot-starter是给SpringBoot2用的,SpringBoot3必须用mybatis-plus-spring-boot3-starter。我第一次在这个上面栽了跟头,启动直接报ClassNotFoundException,折腾了一晚上才发现是兼容性问题。另外JWT用的是jjwt 0.11.5,这个版本对Java17兼容很好。
3.2 项目目录结构与代码分层
后端项目的包结构按职责划分,参考我实际用的这套结构:
com.example.course ├── config # 配置类 │ ├── CorsConfig.java # 跨域配置 │ ├── MybatisPlusConfig.java # 分页插件配置 │ └── SecurityConfig.java # Spring Security配置 ├── controller # 接口层 │ ├── AdminController.java │ ├── StudentController.java │ ├── TeacherController.java │ └── CourseController.java ├── entity # 实体类 │ ├── Student.java │ ├── Teacher.java │ ├── Course.java │ └── CourseSelection.java ├── mapper # 数据访问层 ├── service # 业务逻辑层 │ └── impl # 实现类 ├── common # 公共组件 │ ├── Result.java # 统一返回结果 │ └── JwtUtil.java # JWT工具类 └── CourseApplication.java为什么要分这么多层?答辩的时候这就是你的加分项。Controller只管接收请求和返回数据,不写业务逻辑;Service层存真正的业务代码,比如选课要判断人数、要防重复;Mapper层只和数据库打交道。这样分层的好处是职责单一,出了问题知道去哪里找,以后扩展也方便。
Controller层的代码尽量瘦,记得加@RestController和@RequestMapping,统一接口前缀。Service层要面向接口编程,写一个接口和一个实现类,这也是被很多教程忽略但对答辩很重要的习惯。
3.3 统一返回结果与跨域配置
前后端分离项目中,前后端要约定好数据格式。我这里定义了一个Result<T>类:
@Data public class Result<T> { private Integer code; // 200成功,4xx业务失败,5xx系统异常 private String msg; // 提示信息 private T data; // 响应数据 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMsg("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String msg) { Result<T> result = new Result<>(); result.setCode(500); result.setMsg(msg); return result; } }前端拿到code=200就正常处理数据,code=500就弹一条提示。这套格式很简单,但能避免很多“前端不知道有没有成功”的尴尬。
跨域配置也很重要。你的前端跑在localhost:5173,后端跑在localhost:8080,浏览器会拦截跨域请求,你需要在后端加一个CORS配置。最简单的办法是写一个配置类:
@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); } }同时在前端的vite.config.js里配置代理,把/api开头的请求转发到后端:
export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })这样前端请求/api/course/list,后端实际收到的是/course/list,不跨域,省心。
3.4 登录认证与接口权限控制
登录认证这块,一开始很多人觉得难,其实拆开了就是把两个核心问题解决掉:怎么生成凭证,怎么校验凭证。这里用JWT(JSON Web Token)方案,吐个通俗的比喻:JWT就像一张进入景区的门票,门票上写着你的身份信息,游客只要拿着门票,景区就不用每次重新查你的身份证了。
我在JwtUtil工具类里封装了三个方法:生成Token、解析Token、判断Token是否过期。生成Token时把userId和角色类型放进去,设置过期时间默认2小时。前端每次请求在请求头带上Authorization: Bearer xxx,后端通过拦截器统一校验。
Spring Security的配置类大概是这个逻辑:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/student/login", "/student/register", "/teacher/login", "/admin/login").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/teacher/**").hasRole("TEACHER") .requestMatchers("/student/**").hasRole("STUDENT") .anyRequest().authenticated() ) .exceptionHandling(ex -> ex.authenticationEntryPoint((req, resp, e) -> { resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); })); return http.build(); } }提醒一个新手很容易踩的坑:Spring Security拦截了请求之后,如果你直接用
POST请求登录接口,默认还会开启CSRF防护,导致403。记得显式关闭CSRF,代码里那一行csrf.disable()就是这个作用。另一个坑是如果你自定义鉴权失败处理器或放行了某个接口,但配置顺序不对,后面会全部被拦截掉,排查的时候先看permitAll()有没有生效。
3.5 选课业务与并发防超卖逻辑
选课是整个系统的核心业务,也是答辩时老师最喜欢抓的点。你先想清楚选课需要满足哪些规则:
- 同一学生不能重复选同一门课
- 课程已选人数不能超过上限
- 选课后课程人数要实时更新
为了防止“多个学生同时选同一门课导致超卖”,我在代码里用了乐观锁。具体实现是,在course表加一个version字段,更新已选人数的时候带上版本号判断,用MyBatis-Plus的@Version注解标记。
@TableField(fill = FieldFill.INSERT) @Version private Integer version;在配置类里注册乐观锁插件:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }选课Service的实现逻辑,伪代码如下:
@Transactional(rollbackFor = Exception.class) public Result<String> selectCourse(Long studentId, Long courseId) { // 1. 查课程信息 Course course = courseMapper.selectById(courseId); if (course == null || course.getStatus() == 0) { return Result.error("课程不存在或未开放选课"); } // 2. 判断是否已选过 Long count = courseSelectionMapper.selectCount(new LambdaQueryWrapper<CourseSelection>() .eq(CourseSelection::getStudentId, studentId) .eq(CourseSelection::getCourseId, courseId) .eq(CourseSelection::getStatus, 1)); if (count > 0) { return Result.error("你已经选过这门课了"); } // 3. 判断人数是否已满 if (course.getSelectedCount() >= course.getMaxStudent()) { return Result.error("课程人数已满"); } // 4. 插入选课记录 CourseSelection selection = new CourseSelection(); selection.setStudentId(studentId); selection.setCourseId(courseId); selection.setSelectedTime(new Date()); selection.setStatus(1); courseSelectionMapper.insert(selection); // 5. 更新课程已选人数 course.setSelectedCount(course.getSelectedCount() + 1); courseMapper.updateById(course); return Result.success("选课成功"); }注意看这里的@Transactional注解,这个非常关键。如果没有事务,选课记录插入成功、但课程人数更新失败时,数据就不一致了。加了事务之后,要么全部成功,要么全部回滚。
还有一个隐藏问题:为什么不用同步锁?因为我这里是单机部署,用ReentrantLock就能解决并发问题。但如果部署在多台服务器上,每个进程各自一把锁是锁不住全局的,这时候需要用分布式锁。毕业设计的场景下写清楚“当前是单机部署,用乐观锁解决超卖问题”,再提一句“生产环境需要分布式锁”,老老师会觉得你有思考。
3.6 分页查询与条件搜索
管理员查看学生列表、学生查看课程列表,这些都是高频功能。直接全部查出来返回给前端的话,数据量一大就卡死。用MyBatis-Plus的分页插件,几行就能实现。
配置分页插件在3.5.5版本是:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }Service层的实现:
public Page<Course> getCourses(int page, int size, String name) { Page<Course> p = new Page<>(page, size); LambdaQueryWrapper<Course> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(name)) { wrapper.like(Course::getCourseName, name); } wrapper.orderByDesc(Course::getId); return courseMapper.selectPage(p, wrapper); }Controller层接收参数并返回:
public Result<Page<Course>> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String name) { return Result.success(courseService.getCourses(page, size, name)); }前端Element Plus的el-pagination组件正好对接这套参数,当前页和每页条数传给后端就行。
4. 前端开发:Vue3核心页面与交互
4.1 Vue3项目初始化与基础配置
前端脚手架用Vite创建,这是目前Vue3生态最主流的构建工具。打开命令行工具,在项目根目录执行:
npm create vue@latest里面选上Router和Pinia(状态管理),其他默认就好。创建完成后,进入项目目录安装依赖:
npm install npm install element-plus axios为什么用Element Plus?它的表格、表单、分页、弹窗组件都是现成的,零基础也能快速搞定一个看着很专业的后台管理系统。如果是自己手写这些组件,没个三四周搞不定,而且丑。
在main.js里全局引入Element Plus:
import { createApp } from 'vue' import App from './App.vue' import router from './router' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' const app = createApp(App) app.use(router) app.use(createPinia()) app.use(ElementPlus) app.mount('#app')4.2 路由配置与页面骨架设计
按照角色拆分路由,整体结构如下:
/src ├── api/ # 接口请求封装 │ ├── student.js │ ├── teacher.js │ └── course.js ├── router/index.js # 路由配置 ├── store/ # Pinia状态管理 ├── views/ │ ├── Login.vue │ ├── admin/ │ │ ├── AdminLayout.vue │ │ ├── StudentManage.vue │ │ ├── TeacherManage.vue │ │ └── CourseManage.vue │ ├── student/ │ │ ├── StudentLayout.vue │ │ ├── SelectCourse.vue │ │ └── MyCourse.vue │ └── teacher/ │ ├── TeacherLayout.vue │ ├── TeacherCourse.vue │ └── ScoreManage.vue路由配置里需要加一个“守卫”。没有登录就访问内部页面,直接跳转到登录页。实现方式是:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } })同时每个Layout组件内部,左侧菜单栏根据角色显示不同菜单。这里用Pinia存一下当前登录用户的信息和角色,页面刷新后还能保持登录状态。
4.3 登录页面实现
登录页面是用户第一个见到的页面,UI上不能太寒酸。Element Plus的el-form加基本的校验规则就够了。
一个需要处理的细节是:登录后根据用户角色跳转到不同的首页。我这里后端登录接口返回的数据里带了一个字段role,前端登录成功后判断角色,然后router.push('/admin')或router.push('/student')。
前端调用登录接口的代码,所有接口用axios统一封装,同时加拦截器:
import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:自动携带token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理错误码 request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.msg) return Promise.reject(new Error(res.msg)) } return res }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )4.4 选课页面的核心交互逻辑
选课页面的核心是一个课程表格,加一个“选课”按钮。写代码的过程有两个要点:
第一,表格的el-table绑定课程列表数据,el-pagination对接分页参数。每次点击“选课”,先调用选课接口,成功后再刷新当前页数据。
第二,根据课程状态动态控制按钮是否可用。如果selectedCount >= maxStudent,按钮置灰并显示“已满”;如果status === 0,显示“未开放”;已选过的课程显示“已选”。这个逻辑不复杂,但能体现出前端基本功。
核心代码片段:
<el-table :data="courseList" stripe> <el-table-column prop="courseCode" label="课程编号" width="120" /> <el-table-column prop="courseName" label="课程名称" /> <el-table-column prop="credit" label="学分" width="80" /> <el-table-column prop="selectedCount" label="已选人数" width="100"> <template #default="scope"> {{ scope.row.selectedCount }} / {{ scope.row.maxStudent }} </template> </el-table-column> <el-table-column label="操作" width="120"> <template #default="scope"> <el-button type="primary" size="small" :disabled="!canSelect(scope.row)" @click="handleSelect(scope.row)" >{{ getBtnText(scope.row) }}</el-button> </template> </el-table-column> </el-table>我踩过一个坑:scope.row直接往后端传递时,如果请求头没有带上token,会被后端拦截返回401,前端弹了一堆“未登录”的错误。后来才意识到是axios拦截器没生效,排查发现localStorage.getItem('token')返回了null,是登录成功后存token那一步写错了位置。排查思路就是Controller加日志、前端打印请求头,很快就定位了。
4.5 API接口文件封装规范
强烈建议把每个模块的接口单独写一个文件,比如src/api/course.js:
import request from '@/utils/request' export function getCourseList(data) { return request({ url: '/course/list', method: 'get', params: data }) } export function selectCourse(courseId) { return request({ url: '/course/select', method: 'post', data: { courseId } }) } export function dropCourse(courseId) { return request({ url: '/course/drop', method: 'post', data: { courseId } }) }这样做的最大好处是,当后端接口改动时,你只需要改一个文件。而且组件里的调用代码会非常干净,只关注页面逻辑,不用关心URL拼接和传参。
5. 调试部署与答辩避坑指南
5.1 本地联调的三板斧
前后端分离项目的联调阶段,最常见的三个问题是:接口返回404、接口返回500、跨域报错。其实排查思路都很固定。
404的排查思路:先看前端请求的URL和后端Controller映射的是否一致。比如前端请求/api/course/list,通过代理转成了/course/list,后端CourseController的@RequestMapping值是/course,方法上是/list,那就能对应上。确认没问题后,在Controller方法第一行加一个System.out.println("进来了"),看请求有没有到达后端。如果没到达,看代理配置;如果到达了还404,看方法路径。
500的排查思路:看IDEA控制台的异常栈。空指针还是SQL错误一眼就能看出来。这里推荐先用@Slf4j加日志,关键方法打印入参和出参。比如查询学生列表接口,打印入参page、size、name,打印出参total和records。日志打出来,问题自动就浮出水面了。
跨域报错的排查思路:看浏览器Network标签页,如果请求是OPTIONS类型且响应里没有Access-Control-Allowed-Origin头,就是后端跨域配置没生效。检查CorsConfig有没有被Spring扫描到。
5.2 打包部署的完整流程
虽然毕业设计可能只需要跑通本地,但如果你能说出打包部署的流程,答辩印象分会高很多。
后端打包非常简单,IDEA右侧Maven面板双击package,然后到target目录下找到course-selection-0.0.1-SNAPSHOT.jar文件。打开命令行,执行:
java -jar course-selection-0.0.1-SNAPSHOT.jar前提是MySQL已启动,且application.yml里的数据库配置正确。如果你的项目里配置了数据库密码,打包后部署时想改配置,可以加启动参数覆盖:
java -jar course-selection-0.0.1-SNAPSHOT.jar --spring.datasource.password=你的密码前端打包:
npm run build打包完成后dist文件夹里面就是静态文件,可以用Nginx部署。Nginx配置的关键是:把静态文件路径指向dist目录,把/api请求代理到后端8080端口。
server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }5.3 答辩现场的高频问题与应答策略
答辩答辩,答和辩各占一半。我见过太多人代码敲得麻溜,一到台上被问几句话就卡壳。这里提前准备几个高频问题,背下来不吃亏。
“你的系统有哪些功能角色?”答:整个系统分为管理员、教师、学生三个角色。管理员负责基础数据的维护,包括管理学生和教师信息、管理课程信息;教师可以开设课程、查看选课情况、录入成绩;学生可以浏览课程、选课退课、查看成绩。每个角色登录后看到的功能菜单完全不同,权限控制是通过Spring Security拦截器配合JWT实现的。
“选课并发冲突你怎么解决的?”答:我用了乐观锁。课程表设置version字段,更新已选人数时框架自动比对版本号,冲突就更新失败。这种方式在并发量不高的场景下够用,从理论层面来说,如果要支撑更大的并发量,可以考虑用Redis分布式锁或者数据库悲观锁。这里要顺着老师的话往下讲,显得从容。
“为什么选SpringBoot3不选SpringBoot2?”答:SpringBoot3是一个主流的长期支持版本,基于Spring Framework6,底层使用Jakarta EE9规范,性能和安全性都有大幅提升。同时SpringBoot3要求Java17起步,这是一个长期支持的LTS版本,新项目选新版本有利于以后维护和扩展。用词尽量往规范上靠。
“你的数据库为什么要分四张表?”答:这是基于第三范式的设计。学生、教师、课程、选课记录各自独立成表,避免数据冗余。选课记录表是学生表和课程表的关联表,同时扩展了成绩字段和选课状态字段。独立表的好处是维护方便,修改课程名称只需要改course表,不用改动其他表的引用。
还有个答辩细节:带一份数据库的ER图。提前用PowerDesigner或者Draw.io画好,答辩时展示出来,老师一看就知道你系统设计是下了功夫的。我学弟答辩时就是靠这个加了不少分。
5.4 常见运行报错速查表
我整理了开发过程中最常遇到的报错和解决办法,你在做的时候照着排查就行:
| 错误现象 | 原因 | 解决方案 |
|---|---|---|
启动直接报ClassNotFoundException: jakarta.servlet | SpringBoot3依赖跟Servlet API不兼容 | 检查pom中依赖版本,改用spring-boot-starter-web自带依赖 |
| Maven下载依赖失败 | 网络问题或镜像源不稳定 | 配置阿里云镜像,IDEA中Setting的Maven仓库换成https://maven.aliyun.com/repository/public |
| 前端页面请求401 | 未携带token或token过期 | 检查登录成功后是否存token到localStorage;检查axios拦截器是否设置请求头 |
Data too long for column | 数据库字段长度不够 | 修改表结构,如name字段从varchar(50)加大到varchar(100) |
| 中文乱码 | 建表或连接字符串没指定utf8mb4 | application.yml中数据库连接URL加上?useUnicode=true&characterEncoding=UTF-8 |
Could not autowire报错 | IDEA识别问题或没有配置Mapper扫描 | 启动类加@MapperScan("com.example.course.mapper"),同时确认Mapper接口上有@Mapper注解 |
| 端口被占用 | 8080端口被其他程序占用 | 命令行执行netstat -ano | findstr 8080,找到PID并杀掉,或改配置文件端口 |
6. 调试工具推荐与我的个人经验
最后分享两个后端开发绕不开的调试经验。
第一个是接口测试工具。很多人用Postman,但如果你懒得单独开一个软件,IDEA自带的HTTP Client非常好用。在项目里建一个http目录,写好测试请求,随时可以重放接口:
GET http://localhost:8080/api/student/login Content-Type: application/json { "username": "20230101", "password": "123456" }第二个是Lombok的使用。项目里大量使用了@Data注解,你要是配置不对会导致IDE报错说找不到getter/setter。检查一下IDEA的Settings里Plugins是否安装了Lombok插件,同时确认注解处理(Annotation Processing)已经开启。
再讲讲我个人的体会。带人做完这个项目后,最深的感受是:零基础的同学遇到困难时容易怀疑自己——HTTP状态码看不懂、报错信息全是英文、框架自动装配的机制云里雾里。但事实上,这些难点不是因为个人能力差,而是因为知识体系里缺少了“工程化”的那一环。你不需要一开始就理解SpringBoot自动配置的底层原理,你只需要知道:配置好依赖,按规范写代码,框架会自动帮你完成绝大部分工作。剩下的问题,靠日志、靠搜索引擎都能解决。
我当时给学弟定的计划表,你可以参考一下:
| 阶段 | 任务 | 预计时间 |
|---|---|---|
| 第1周 | 环境准备,跑通一个最简单的登录接口 | 1天 |
| 第2周 | 数据库设计,完成所有建表脚本 | 2天 |
| 第3-4周 | 后端全部接口开发 | 14天 |
| 第5-6周 | 前端全部页面开发与联调 | 14天 |
| 第7周 | 打包部署,整理文档,准备答辩 | 3天 |
这套节奏坚持下来,时间完全来得及。
最后再说一个实用的小技巧。开发过程中一定要用Git管代码,每完成一个模块就提交一次。不用把代码传到GitHub上,本地建一个仓库就行。这样做的好处是,改崩了随时可以回退到上一个能跑的版本,不用从头再来。我自己吃过这个亏,搞了半天结果把好的代码改坏了,最后的解决方案是删掉重来。从那以后,我每做一个功能点就commit一次,这个习惯让我少走了很多弯路。
学生选课管理系统这个题目,说到底是毕业设计里的一张安全牌。技术点不偏门,业务逻辑不复杂,但要做到完整、规范、有亮点,还是需要花心思的。上面这些内容,从数据库建模到后端实现再到前端交互,是我实际跑通过一遍的完整方案,你照着走,大概率能在两周内把代码写完,然后拿下一份漂亮的毕业论文和答辩表现。