毕业设计里,基于SpringBoot的学生选课管理系统是一个出现频率很高的选题。它看起来只是管理课程和学生选课,实际上要处理角色权限、课程资源约束、选课冲突、成绩回填等多个环节,做完之后几乎可以把 JavaWeb 阶段的知识点全部串起来。这篇博客按照一个完整毕设项目的推进顺序展开,从业务拆分、表结构设计、后端接口实现、前端页面联调,到打包部署和常见故障排查,全部走一遍。无论你是准备用这个题目写毕设,还是想复现一个 SpringBoot 管理系统加深理解,都可以按文章步骤跑出一套可演示、可答辩、可扩展的系统。
1. 先理解学生选课管理系统要解决什么问题
1.1 高校选课的核心业务流程
选课管理系统并不是一个单纯的“课程增删改查”项目。它的核心业务是:在一个学期内,学生从开设的课程列表中选择自己要修的课程,系统要保证这门课没有选满、学生没有重复选择、课程时间没有和其他已选课程冲突。选课结束后,教师还需要看到选课名单,录入成绩,学生才能查到最终成绩。
这个流程涉及三类角色:
| 角色 | 主要功能 |
|---|---|
| 学生 | 浏览课程、选课、退课、查看已选课程、查看成绩 |
| 教师 | 维护课程信息、查看选课名单、录入成绩 |
| 管理员 | 管理学生账号、教师账号、课程信息、开课学期 |
很多毕设项目会把管理员功能做得很大,比如部门管理、菜单管理、操作日志统计等。作为毕设系统,这些功能可以做,但真正决定项目质量的是“选课约束”是否严谨。也就是说,学生选课时,系统能不能真的防止重复选、超容量选、同一时间冲突选,而不是只在页面上提示一个空泛的错误。
1.2 四个不能回避的业务边界
实际做这个系统时,会反复遇到这几个边界条件,它们是后端代码设计的主线。
第一,一个学期内只能操作当前学期的课程。课程必须带有学期字段,学生选课记录也要绑定学期,否则上一届的历史数据会混进来。
第二,选课前必须校验是否已经选过同一门课。这里的判断不能只靠代码里的集合判断,还要靠数据库唯一索引兜底,防止并发请求同时写入。
第三,选课时必须判断课程容量。课程有容量上限,已选人数不能超过容量。比如课程容量是 60,第 61 个学生再来选课时必须被拒绝。
第四,同一天同一时间段不能安排两门课。判断时间冲突需要拿到学生所有已选课程,再和当前课程的时间段做交叉比对。
这四条如果全部落地,项目就已经超过很多只做增删改查的毕设。答辩时被问到“系统怎么保证选课数据正确”,可以直接从这四个约束入手展开。
1.3 为什么选 Spring Boot 作为毕设基础框架
选课管理系统的功能集中在业务逻辑和数据操作,适合用 Spring Boot 这种“约定优先于配置”的框架快速搭建。
Spring Boot 自带内嵌的 Tomcat,写完代码后一个java -jar就能启动,不需要单独安装和配置外部服务器,这对计算机设计演示很有帮助。Spring Boot 的自动配置机制能减少大量 XML 配置,数据库连接、事务管理、Web 请求处理都可以在application.yml里集中维护。
更重要的是 Spring Boot 与 MyBatis、MyBatis-Plus、Thymeleaf、Vue 等常见技术的配合非常成熟。遇到问题搜索时,可以找到大量资料,适合第一次完整做项目的学生阅读和排查。选课管理系统的数据量不大、业务链路清晰,不需要微服务和高性能并发方案,Spring Boot 单体架构已经足够。
2. 环境准备与技术栈选型
2.1 版本约定要一致,否则后面全是坑
版本选择是毕设项目最容易被忽略的地方。很多同学把别人的源码拉到本地,一启动就报各种错误,原因往往是本机 JDK 版本和项目的 Spring Boot 版本不匹配。
下面是一套比较稳妥的版本组合,本次示例代码也按这个组合设计:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 8 或 17 | Spring Boot 2.7.x 用 JDK 8,Spring Boot 3.x 用 JDK 17 |
| Maven | 3.6+ | 用于依赖管理和打包 |
| Spring Boot | 2.7.x | 兼容性强,资料最多,适合毕设 |
| MySQL | 8.0 | 8.0 需要关注时区配置 |
| MyBatis-Plus | 3.5.x | 减少基础 SQL 编写,降低工作量 |
| 前端方案 | Thymeleaf 或 Vue 3 | 快速完成选 Thymeleaf,想展示前后端分离选 Vue |
注意:如果本机已经安装了 JDK 17,建议直接选择 Spring Boot 3.x。反过来,如果本机是 JDK 8,却使用了 Spring Boot 3.x,编译时会出现UnsupportedClassVersionError,因为 Spring Boot 3.x 的最低要求是 Java 17。开始编码之前,先使用java -version和mvn -v确认本机环境。
2.2 Maven 工程结构规划
推荐按下面这种结构组织代码,分层清晰,答辩时也容易讲:
course-management-system ├── src/main/java/com/example/course │ ├── CourseManagementApplication.java │ ├── config │ │ ├── InterceptorConfig.java │ │ └── WebMvcConfig.java │ ├── controller │ │ ├── LoginController.java │ │ ├── StudentCourseController.java │ │ ├── TeacherCourseController.java │ │ └── AdminController.java │ ├── entity │ │ ├── User.java │ │ ├── Course.java │ │ └── StudentCourse.java │ ├── mapper │ │ ├── UserMapper.java │ │ ├── CourseMapper.java │ │ └── StudentCourseMapper.java │ ├── service │ │ ├── UserService.java │ │ ├── CourseService.java │ │ └── StudentCourseService.java │ ├── interceptor │ │ └── LoginInterceptor.java │ └── vo │ ├── Result.java │ ├── LoginRequest.java │ └── CourseSelectForm.java └── src/main/resources ├── application.yml ├── mapper │ ├── UserMapper.xml │ ├── CourseMapper.xml │ └── StudentCourseMapper.xml └── templates ├── login.html ├── student │ ├── courseList.html │ └── selectedList.html └── teacher ├── courseManage.html └── scoreInput.html业务层级按照Controller -> Service -> Mapper组织,实体类放在entity包,接口返回数据封装在vo包。这样每个类的职责具体,答辩时可以直接说清楚每一层的作用。
2.3 引入核心依赖
在pom.xml中加入以下依赖:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies>这里把 MyBatis-Plus 放在基础 SQL 编写的位置,可以省去大量重复的insert、selectById代码,让关注点集中在选课规则上。如果希望练习手写 SQL,也可以把依赖换成mybatis-spring-boot-starter,两种方式对毕设都够用。
2.4 配置文件注意时区和驼峰映射
application.yml是项目能否连接数据库的关键,下面是一份完整配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 thymeleaf: cache: false mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: autoserverTimezone=Asia/Shanghai用于解决 MySQL 8.0 的时区报错。map-underscore-to-camel-case: true让数据库的student_id字段自动映射到实体的studentId属性,避免每次手写繁琐的 resultMap。log-impl会把执行的 SQL 打印到控制台,选课逻辑出错时可以直接看到 SQL 语句,这在调试阶段非常有用。
3. 数据库设计要支撑业务约束
3.1 核心表结构总览
选课管理系统的数据库至少包含三张核心表。用户表保存学生、教师、管理员账号,课程表保存课程的容量、时间、学分等基本信息,选课记录表保存学生和课程之间的绑定关系以及成绩。
| 表名 | 用途 |
|---|---|
| user | 用户基本信息,区分角色 |
| course | 课程基本信息、容量、时间、学期 |
| student_course | 学生选课记录、成绩 |
如果需要更完整地模拟高校场景,可以增加student表、teacher表和semester表。毕设阶段为了控制复杂度,可以先合并为上面的三表结构,答辩时再说明扩展思路。
3.2 建表 SQL
下面是一份可以直接执行的建表 SQL:
CREATE DATABASE IF NOT EXISTS course_db DEFAULT CHARACTER SET utf8mb4; USE course_db; CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, `real_name` VARCHAR(50) DEFAULT NULL, `role` VARCHAR(20) NOT NULL COMMENT 'STUDENT/TEACHER/ADMIN', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `course` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `course_name` VARCHAR(100) NOT NULL, `course_code` VARCHAR(50) NOT NULL, `teacher_id` BIGINT DEFAULT NULL, `credit` DECIMAL(3, 1) DEFAULT 0, `capacity` INT NOT NULL DEFAULT 60, `selected_num` INT NOT NULL DEFAULT 0, `start_week` INT NOT NULL DEFAULT 1, `end_week` INT NOT NULL DEFAULT 18, `day_of_week` INT NOT NULL COMMENT '1-7 表示周一至周日', `start_section` INT NOT NULL DEFAULT 1 COMMENT '开始节次', `end_section` INT NOT NULL DEFAULT 2 COMMENT '结束节次', `classroom` VARCHAR(100) DEFAULT NULL, `term` VARCHAR(30) NOT NULL COMMENT '例如 2025-2026-1', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `student_course` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `student_id` BIGINT NOT NULL, `course_id` BIGINT NOT NULL, `select_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `score` DECIMAL(5, 2) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_course` (`student_id`, `course_id`), KEY `idx_course_id` (`course_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;初始化一些演示账号:
INSERT INTO `user` (`username`, `password`, `real_name`, `role`) VALUES ('admin', '123456', '系统管理员', 'ADMIN'), ('teacher01', '123456', '王老师', 'TEACHER'), ('student01', '123456', '张三', 'STUDENT'), ('student02', '123456', '李四', 'STUDENT');3.3 字段设计里的三个关键决策
第一个关键决策是选课记录表必须加唯一索引。只靠 Service 层代码判断“是否已选”在单用户操作时有效,但两个学生同时请求,或者同一个学生快速点击两次选课按钮时,代码判断可能同时通过,唯一索引uk_student_course会在数据库层拦住重复数据。这是选课系统防止脏数据最重要的兜底手段。
第二个关键决策是课程表设计了selected_num冗余字段。它保存当前已选人数,目的是在选课操作时执行UPDATE course SET selected_num = selected_num + 1 WHERE id = ? AND selected_num < capacity这种原子更新,而不是先查人数再更新人数。这样即使并发选课,数据库也能通过行锁保证不会超过容量。
第三个关键决策是时间字段拆分成day_of_week、start_section、end_section,而不是直接存一个字符串。拆分之后,判断两门课是否冲突的逻辑更容易实现。比如课程 A 是周一 1-2 节,课程 B 是周一 2-3 节,两条记录可以按“星期相同,且节次区间有交集”来判断冲突。
4. 后端核心实现:把约束写进代码
4.1 登录认证和角色权限拦截
先实现一个简单的登录接口。登录成功后把用户信息放进 Session,后续请求通过拦截器校验。
@RestController @RequestMapping("/api/auth") public class LoginController { @Resource private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginRequest request, HttpSession session) { User user = userService.login(request.getUsername(), request.getPassword()); if (user == null) { return Result.error("用户名或密码错误"); } session.setAttribute("loginUser", user); return Result.success(user); } @PostMapping("/logout") public Result logout(HttpSession session) { session.invalidate(); return Result.success(null); } }拦截器用来统一校验是否登录,以及访问路径是否匹配角色:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return false; } String uri = request.getRequestURI(); if (uri.startsWith("/api/teacher") && !"TEACHER".equals(user.getRole()) && !"ADMIN".equals(user.getRole())) { response.getWriter().write("{\"code\":403,\"message\":\"无权限\"}"); return false; } return true; } }在配置类里注册拦截器路径:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login"); } }拦截器方案比引入 Spring Security 更容易理解,适合毕设项目的代码讲解。如果评委问为什么不直接用 Spring Security,可以回答:当前系统角色只有三种,业务重点是选课约束,使用拦截器可以把复杂度控制在合理范围,后续需要细粒度权限时再引入安全框架。
4.2 课程管理接口
课程管理主要包含分页查询课程、新增课程、修改课程信息。学生端只能查询,教师端可以维护课程。
@RestController @RequestMapping("/api/course") public class CourseController { @Resource private CourseService courseService; @GetMapping("/list") public Result listCourse( @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, HttpSession session) { User user = (User) session.getAttribute("loginUser"); Page<Course> result = courseService.getPage(keyword, page, size); return Result.success(result); } @PostMapping("/add") public Result addCourse(@RequestBody Course course) { courseService.saveCourse(course); return Result.success(null); } }分页查询使用 MyBatis-Plus 的Page对象:
@Service public class CourseService { @Resource private CourseMapper courseMapper; public Page<Course> getPage(String keyword, int page, int size) { LambdaQueryWrapper<Course> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(keyword)) { wrapper.like(Course::getCourseName, keyword) .or().like(Course::getCourseCode, keyword); } Page<Course> p = new Page<>(page, size); return courseMapper.selectPage(p, wrapper); } }4.3 核心:选课逻辑怎么保证不重复、不超选、不冲突
选课是整个系统技术含量最高的地方。先定义一个选课表单对象:
public class CourseSelectForm { private Long courseId; }然后编写选课 Service 方法:
@Service public class StudentCourseService { @Resource private StudentCourseMapper studentCourseMapper; @Resource private CourseMapper courseMapper; @Autowired private TransactionTemplate transactionTemplate; public Result selectCourse(Long studentId, Long courseId) { Course course = courseMapper.selectById(courseId); if (course == null) { return Result.error("课程不存在"); } // 1. 防止重复选课 Long count = studentCourseMapper.countByStudentAndCourse(studentId, courseId); if (count > 0) { return Result.error("不能重复选择同一门课程"); } // 2. 容量校验 + 原子更新,防止超选 int updated = courseMapper.increaseSelectedNum(courseId); if (updated == 0) { return Result.error("课程已选满"); } // 3. 时间冲突校验 List<Course> selectedCourses = studentCourseMapper.findSelectedCourses(studentId); for (Course selected : selectedCourses) { if (isTimeConflict(course, selected)) { // 容量回滚,因为后续不会真正插入选课记录 courseMapper.decreaseSelectedNum(courseId); return Result.error("与课程[" + selected.getCourseName() + "]时间冲突"); } } StudentCourse record = new StudentCourse(); record.setStudentId(studentId); record.setCourseId(courseId); studentCourseMapper.insert(record); return Result.success(null); } private boolean isTimeConflict(Course newCourse, Course existCourse) { if (!newCourse.getDayOfWeek().equals(existCourse.getDayOfWeek())) { return false; } return newCourse.getStartSection() <= existCourse.getEndSection() && existCourse.getStartSection() <= newCourse.getEndSection(); } }这里要特别注意事务边界。上面示例使用TransactionTemplate手动控制事务,避免多个操作之间出现问题。也可以直接在方法上使用@Transactional(rollbackFor = Exception.class),但这个方法的难点在于“时间冲突”属于业务校验失败,而不是抛出运行时异常,如果直接用@Transactional并且返回Result.error,事务不会回滚,已经执行成功的increaseSelectedNum就会把课程人数加错。
所以要在校验失败时执行courseMapper.decreaseSelectedNum(courseId),或者把整段逻辑拆成两次独立事务。更稳妥的写法是使用TransactionTemplate,把“扣减容量 + 插入选课记录”放在同一个事务中,任何一步抛异常都会回滚。
CourseMapper中对应的原子更新 SQL:
<update id="increaseSelectedNum"> UPDATE course SET selected_num = selected_num + 1 WHERE id = #{courseId} AND selected_num < capacity </update>decreaseSelectedNum对应:
<update id="decreaseSelectedNum"> UPDATE course SET selected_num = selected_num - 1 WHERE id = #{courseId} </update>这种方案的好处是,数据库通过selected_num < capacity条件保证不会超选,即使两个学生同时执行该 SQL,也只有一个会更新成功。
4.4 退课、选课名单和成绩录入
退课逻辑相对简单,删除选课记录的同时把课程的selected_num减 1。
@Transactional(rollbackFor = Exception.class) public void cancelCourse(Long studentId, Long courseId) { studentCourseMapper.deleteByStudentAndCourse(studentId, courseId); courseMapper.decreaseSelectedNum(courseId); }成绩录入由教师操作,直接更新student_course表:
@RestController @RequestMapping("/api/teacher") public class TeacherController { @Resource private StudentCourseService studentCourseService; @GetMapping("/course/{courseId}/students") public Result listSelectedStudents(@PathVariable Long courseId) { List<StudentCourseVO> list = studentCourseService.listByCourse(courseId); return Result.success(list); } @PostMapping("/score") public Result saveScore(@RequestBody ScoreRequest request) { studentCourseService.saveScore(request.getStudentCourseId(), request.getScore()); return Result.success(null); } }5. 前端页面与联调方式
5.1 使用 Thymeleaf 快速完成后台页面
如果不想做前后端分离,可以用 Thymeleaf 直接渲染页面。选课页面的核心代码可以这样写:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>选课</title> </head> <body> <h1>课程列表</h1> <table border="1"> <tr> <th>课程名称</th> <th>教师</th> <th>学分</th> <th>容量</th> <th>已选</th> <th>上课时间</th> <th>操作</th> </tr> <tr th:each="course : ${page.records}"> <td th:text="${course.courseName}">课程名</td> <td th:text="${course.teacherName}">教师</td> <td th:text="${course.credit}">学分</td> <td th:text="${course.capacity}">容量</td> <td th:text="${course.selectedNum}">已选</td> <td th:text="${course.dayOfWeek + ' 第' + course.startSection + '-' + course.endSection + '节'}">时间</td> <td> <form th:action="@{/api/student/course/select}" method="post"> <input type="hidden" name="courseId" th:value="${course.id}"> <button type="submit">选课</button> </form> </td> </tr> </table> </body> </html>Thymeleaf 的优点是部署简单、不需要跨域处理,适合时间紧张的毕设项目。缺点是前后端耦合在一起,遇到复杂交互时需要写较多 JavaScript。
5.2 使用 Vue 前后端分离时需要统一接口格式
如果选择 Vue 3 做前端,后端接口应该返回固定格式的 JSON,方便前端统一处理。可以定义一个Result类:
public class Result { private Integer code; private String message; private Object data; public static Result success(Object data) { Result r = new Result(); r.code = 200; r.message = "success"; r.data = data; return r; } public static Result error(String message) { Result r = new Result(); r.code = 500; r.message = message; return r; } }前端使用 Axios 时,只需要拦截code字段:
axios.post('/api/student/course/select', { courseId: id }) .then(res => { if (res.data.code === 200) { alert('选课成功'); } else { alert(res.data.message); } });前后端分离项目的地址要提前约定好。本地开发时 Vue 一般运行在 5173 端口,后端运行在 8080 端口,需要在 Vue 的vite.config.js里配置代理:
export default { server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这样前端请求/api/xxx时会被代理转发到后端,不会出现跨域问题。
5.3 页面和接口的对应关系
| 页面 | 接口 | 功能 |
|---|---|---|
| 登录页 | POST /api/auth/login | 登录 |
| 学生选课页 | GET /api/course/list | 查询课程列表 |
| 学生选课页 | POST /api/student/course/select | 选课 |
| 学生已选页 | GET /api/student/course/selected | 查询已选课程 |
| 学生已选页 | POST /api/student/course/cancel | 退课 |
| 教师课程页 | GET /api/teacher/course/{courseId}/students | 查询选课名单 |
| 教师成绩页 | POST /api/teacher/score | 录入成绩 |
| 管理员用户页 | POST /api/admin/user/add | 新增用户 |
6. 启动、验证和打包发布
6.1 启动前检查清单
在运行项目之前,按下面的顺序逐项检查,能避免大多数环境问题:
- 检查 MySQL 是否已经启动,是否能通过
mysql -u root -p登录。 - 检查本机 JDK 版本,
java -version输出是否和 Spring Boot 版本匹配。 - 检查 Maven 仓库是否能正常下载依赖,
mvn -v命令是否可用。 - 检查
application.yml中的数据库密码、用户名是否正确。 - 检查 8080 端口是否被占用,执行
netstat -ano | findstr 8080(Windows)或lsof -i:8080(Linux/macOS)。
启动成功后,控制台会出现类似下面的内容:
Tomcat started on port(s): 8080 (http) Started CourseManagementApplication in 2.34 seconds6.2 用接口用例验证核心功能
启动项目后,建议用接口用例把核心链路验证一遍。下面以选课流程为例,给出关键接口调用。
登录接口:
curl -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"student01","password":"123456"}'查询课程列表:
curl -X GET "http://localhost:8080/api/course/list?page=1&size=10"选课接口:
curl -X POST http://localhost:8080/api/student/course/select \ -H "Content-Type: application/json" \ -d '{"courseId":1}'重复选课验证:
curl -X POST http://localhost:8080/api/student/course/select \ -H "Content-Type: application/json" \ -d '{"courseId":1}'第二次请求应该返回“不能重复选择同一门课程”。如果返回成功,说明唯一索引或查询逻辑没有生效。
还可以用以下表格整理验证用例,毕设测试报告中也可以直接引用:
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| 登录成功 | 输入正确账号密码 | 返回用户信息 |
| 登录失败 | 输入错误密码 | 提示用户名或密码错误 |
| 选课成功 | 学生选择未选课程 | 选课记录插入成功 |
| 重复选课 | 学生再次选择同一课程 | 提示不能重复选择 |
| 超容量选课 | 已选人数等于容量时继续选课 | 提示课程已选满 |
| 时间冲突选课 | 选择同一天同一节次的课程 | 提示时间冲突 |
| 退课成功 | 学生退掉已选课程 | 记录删除且人数减一 |
| 成绩录入 | 教师录入成绩 | 学生可查询到成绩 |
6.3 Maven 打包和部署
验证完成之后,使用 Maven 打包:
mvn clean package -DskipTests打包完成后,在target目录下生成course-management-system-0.0.1-SNAPSHOT.jar,使用命令启动:
java -jar target/course-management-system-0.0.1-SNAPSHOT.jar6.4 学习环境与生产环境的关键差异
毕设演示环境可以简化很多配置,但如果是实际高校内部使用,还需要补齐以下内容:
| 项目 | 学习环境 | 生产环境 |
|---|---|---|
| 数据库密码 | 写死可以接受 | 使用环境变量或配置中心 |
| 日志 | 控制台输出 | 文件输出 + 按天切割 + 日志监控 |
| 上传图片 | 本地目录 | 对象存储或独立文件服务 |
| 并发抢课 | 单机测试 | 需要缓存、队列、限流 |
| 数据备份 | 不涉及 | 定期备份,数据库主从 |
| 前端部署 | 本地运行 | Nginx 静态资源部署 |
7. 毕设项目常见问题排查路径
7.1 项目启动失败
现象:SpringApplication启动时报错,程序直接退出。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| Port 8080 was already in use | 端口被占用 | 查看启动日志中的Port already in use | 换端口,或在application.yml中修改server.port |
UnsupportedClassVersionError | JDK 版本和 Spring Boot 版本不匹配 | 用java -version检查版本 | Spring Boot 2.x 配 JDK 8,Spring Boot 3.x 配 JDK 17 |
You aren't using a compiler supported by lombok | Lombok 版本和 JDK 版本不兼容 | 检查编译日志 | 升级 Lombok 为最新稳定版,或者更换 JDK 版本 |
7.2 数据库连接失败
现象:项目能启动,但访问任意接口时报数据库异常。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
Access denied for user 'root'@'localhost' | 数据库密码错误 | 用命令行登录 MySQL 验证 | 修改application.yml中的密码 |
Server returns invalid timezone | 未指定时区 | 查看异常里的时区提示 | 在 JDBC URL 增加serverTimezone=Asia/Shanghai |
Public Key Retrieval is not allowed | MySQL 8.0 的认证方式问题 | 检查连接 URL | 在 JDBC URL 后增加allowPublicKeyRetrieval=true&useSSL=false |
7.3 选课业务逻辑问题
选课是业务核心,最容易出问题的点包括数据重复、容量错误和时间判断不准确。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 重复选课成功 | 只做了代码查询,没有唯一索引 | 直接执行 SQL 插入重复记录 | 给student_course表加唯一索引 |
| 选课人数超过容量 | 先查询再更新,没有用条件更新 | 查看数据库的selected_num和capacity | 改成UPDATE ... WHERE selected_num < capacity |
| 时间冲突判断不准 | 直接用字符串比较时间段 | 打印两门课的时间字段 | 将星期、节次拆分为数字类型,再比较区间交集 |
| 选课失败后人数仍增加 | 业务校验失败没有回滚 | 查看selected_num是否比实际多 | 失败时执行人数递减,或使用TransactionTemplate包裹 |
7.4 前端页面和静态资源问题
现象:浏览器访问页面时出现Whitelabel Error Page,或静态资源加载不出来。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 出现 Whitelabel Error Page | 控制器返回了没有匹配的视图 | 查看控制台 404 日志 | 检查@RestController和@Controller是否用混 |
| 修改 HTML 不生效 | Thymeleaf 缓存开启 | 刷新页面仍是旧内容 | 在配置中设置spring.thymeleaf.cache=false |
| 静态资源 404 | 静态资源路径错误 | 检查浏览器请求 URL | 静态文件放在src/main/resources/static |
7.5 排查链路建议
遇到问题不要盲目改代码,按下面的顺序排查更高效:
- 看控制台完整日志,找到第一处
Caused by,后面的堆栈通常是跟随异常。 - 判断是编译期问题还是运行期问题。编译期问题先看 Maven 依赖和 JDK 版本。
- 运行期问题先看数据库是否能连通,再看 SQL 是否正确。
- 使用 MyBatis 日志输出 SQL,把 SQL 复制到数据库客户端里执行,确认数据状态。
- 业务逻辑问题优先在 Service 层加日志,输出入参、查询结果和每一步的校验结果。
8. 答辩准备与项目扩展方向
8.1 答辩中高频问题及应答思路
答辩功能演示只是一部分,评委更关心的是你有没有理解系统的设计。提前准备下面几个问题,会比临场发挥更从容。
问题一:系统如何防止学生重复选课?
回答思路:两层校验。业务层先查询student_course表是否已有记录,如果已有就返回错误。数据库层通过uk_student_course唯一索引兜底,即使并发请求同时到达,也会因为唯一约束而只插入一条记录。
问题二:课程容量满了怎么办?
回答思路:容量字段是capacity,当前已选人数是selected_num。选课操作执行UPDATE course SET selected_num = selected_num + 1 WHERE id = ? AND selected_num < capacity,数据库返回更新行数为 0 时表示课程已满,选课失败。这种条件更新能避免先查询再更新带来的超选问题。
问题三:为什么不用 Spring Security?
回答思路:本系统角色只有三种,权限规则比较简单,用拦截器可以满足需求且代码更少。如果需要实现细粒度权限、OAuth2 登录、动态菜单,可以在此基础上引入 Spring Security,当前拦截器方案不影响后续扩展。
问题四:如何判断两门课时间冲突?
回答思路:课程表保存了星期几和节次区间。选课时查询学生所有已选课程,对每门已选课程判断星期是否相同,如果相同再判断节次区间是否有交集,即新课程的start_section <= 已选课程的 end_section且 已选课程的start_section <= 新课程的 end_section。
问题五:数据库为什么冗余selected_num字段?
回答思路:如果不冗余,查询选课人数需要每次统计student_course表,课程列表页会反复执行聚合查询。冗余字段把“已选人数”变成课程表的普通字段,选课时通过单条 UPDATE 完成校验和更新,性能更好。代价是需要保证删除选课记录时同步扣减人数。
8.2 可扩展方向
如果做完基础功能还想加深项目难度,可以从下面几个方向选一个:
| 扩展方向 | 难度 | 说明 |
|---|---|---|
| Redis 缓存课程列表 | 中等 | 减少数据库查询压力,答辩时可以讲缓存一致性 |
| RabbitMQ 异步处理选课请求 | 较高 | 缓解抢课瞬时压力,引入消息队列削峰 |
| 定时任务自动释放未交费名额 | 中等 | 使用 Spring 的@Scheduled定时扫描 |
| Vue 3 前端重构 | 中等 | 展示前后端分离能力 |
| JWT 无状态登录 | 中等 | 替代 Session,适合前后端分离 |
| Flowable 工作流审批 | 较高 | 用于退课审批、加选审批等流程 |
新增扩展功能时,要注意保持原有接口兼容。后端接口全部返回统一Result,前端新增页面时只需要调用对应接口即可,不需要重构已有模块。
8.3 代码、文档和讲解如何配套
毕设项目不只是能跑就行,代码、文档和讲解要能互相呼应。
代码要做到小步提交,注释写清楚关键方法。比如选课方法中的容量校验、时间冲突判断,每段逻辑都要写注释,因为答辩时很可能直接讲这个方法。
文档报告建议包含需求分析、数据库设计、接口说明、测试用例、部署说明五个部分。数据库设计直接复制建表 SQL 和字段说明表,接口说明把Result返回格式和核心接口列出来。测试用例可以使用本文第 6.2 节的用例表。
代码讲解时,不要从头到尾逐行念代码。可以按“用户登录 -> 学生选课 -> 教师录成绩”三条业务链路讲,每条链路讲清 Controller、Service、Mapper 三个层次的调用关系。选课链路讲到容量校验和时间冲突时停下来,重点解释为什么使用条件更新和唯一索引,这部分是整个项目的技术亮点。
完成这些工作后,这套选课管理系统不仅能通过答辩,也能作为后续学习微服务、消息队列、安全框架的起步项目。做项目的价值在于理解约束如何从业务转化到代码,再落到数据库,这个过程培养的工程意识,比功能齐全更值得体现。