1. 项目概述与核心痛点解构
高校课程预约和成绩统计,这两个词分开看都不复杂,但合并到一个系统里,事情就变得有意思了。我在跟不少做毕设或课设的同学聊过之后,发现一个普遍的困境:大部分“课程管理系统”要么只做排课,要么只做成绩录入,真正把“学生自主预约课程”和“成绩统计分析”串成一条完整业务链的,少之又少。而“springboot174基于Java的高校学生课程预约成绩统计系统”这个选题,恰恰就是要把这条链路打通。
从项目标题来看,“springboot174”大概率是代码脚手架生成器或课程设计模板库里的编号,这并不影响项目的实质内容。它对应的是一套基于Spring Boot框架、用Java语言实现的Web应用系统,核心服务对象是高校里的三类角色:学生、教师、系统管理员。学生可以在线浏览课程列表、查看可预约时段、提交预约申请;教师可以管理自己负责的课程、确认预约名单、录入学生成绩;管理员则负责基础数据维护——比如院系、专业、教师账号、课程信息,以及查看全校的预约和成绩统计报表。
这个系统的价值在哪里?往小了说,它解决了传统人工预约课程的效率问题——我见过不少学校还在用Excel表格排预约,学生发邮件、老师手动确认,一旦预约冲突,来回沟通的成本极高。往大了说,成绩统计模块能让教师和教务人员从一堆原始分数中快速得到及格率、优秀率、分数段分布等关键指标,省去了大量重复计算工作。对于做毕业设计或课程设计的同学来说,这个系统的业务逻辑复杂度适中——既不像电商系统那样涉及复杂的支付和库存,也不像纯CRUD那样缺乏亮点——可以说是一个非常“划算”的选题:该踩的技术坑(权限管理、预约冲突、数据统计、事务控制)全都能踩到,又不会让人做到崩溃。
接下来,我会从整体设计思路、核心技术选型、关键实现细节、常见问题排查四个维度,把这个系统从零到一的完整过程拆给大家看。无论你是要拿它做毕业设计,还是想理解一个典型Spring Boot业务系统是怎么组织起来的,这篇文章都能给你一个直接可用的参考。
2. 整体设计思路与技术选型解析
2.1 从业务场景倒推系统模块边界
拿到这个题目,我最先做的不是建Spring Initializr项目,而是先把业务场景在白板上画清楚。这个系统里有三个核心角色,每个角色关心的数据完全不一样。
学生关心的是:这学期有哪些课可以选?课程在什么时间段上课?这堂课还有没有剩余名额?我预约成功了没有?期末我的成绩是多少?
教师关心的是:我开了哪些课?预约我这门课的学生有哪些?哪些学生出勤记录异常?我怎么录入成绩?这学期我教的班整体考得怎么样?
管理员关心的是:教师账号和学生账号怎么维护?课程信息怎么发布和下线?全校的预约数据怎么汇总?各学院各课程的成绩分布是否正常?
三条角色主线梳理完之后,系统的模块边界就非常清晰了。我按功能域把系统拆成了五个模块:用户认证模块(登录、注册、权限控制)、课程管理模块(课程CRUD、发布/下线状态管理)、预约管理模块(预约提交、冲突检测、取消预约、人数限制)、成绩管理模块(成绩录入、修改、审核)、统计报表模块(课程预约率、成绩分布、及格率分析等)。这个拆法在后端是五个包,在前端是五个页面组,职责单一,互不纠缠,也方便后续做权限隔离。
2.2 为什么选Spring Boot而不是其他框架
不少同学会纠结:毕业设计用SSH(Spring + Struts + Hibernate)行不行?用Servlet原生写行不行?我的答案很直接:除非你的课题名称里明确写了SSH,否则不要给自己找麻烦。
Spring Boot相比传统SSM(Spring + Spring MVC + MyBatis)最大的优势在于自动装配和起步依赖。传统SSM里,你要手动配置web.xml、Spring容器、MyBatis的SqlSessionFactory、事务管理器、视图解析器,每一步都有踩坑的可能。而Spring Boot把这一堆配置全部收敛成了starter依赖加application.yml里的几行配置。做课程预约系统这种业务密集型项目,开发效率是第一位的,把时间耗在XML配置上太亏了。
我习惯用Spring Boot 2.7.x版本,原因很现实:一是稳定,网上资料最多,遇到任何报错几乎都能搜到解决方案;二是与JDK 8天然兼容,而很多高校机房或老服务器装的还是JDK 8;三是Spring Boot 2.7对应的Spring Cloud、MyBatis-Plus等周边生态兼容性最好。不是不能用Spring Boot 3.x,但3.x强制要求JDK 17及以上,并且javax包名迁移到了jakarta,一旦你和老项目做集成,或者老师要求环境必须用JDK 8,你就得全局改包名,这个隐形工作量非常大。所以除非你确定自己可以完全掌控环境,否则“版本不要追新”这句话在毕设场景下是金玉良言。
2.3 前端方案选择:前后端分离还是服务端渲染
这个系统常见的做法有两种。第一种是经典的服务端渲染方案:Spring Boot + Thymeleaf + Bootstrap。第二种是前后端分离方案:Spring Boot作为纯后端API + Vue 3 + Element Plus。
如果你问我的建议,我推荐前置分离,尤其如果你未来想找工作,前后端分离是目前企业开发的主流形态,面试聊到这个项目时可以顺带展示你对RESTful API设计、跨域处理、JWT认证的理解。但这个方案也有代价——你需要额外维护一个前端工程,前端构建(npm install、vite构建、路由配置)本身就有不少坑。如果不熟悉前端,建议选一个折中路线——用Thymeleaf渲染页面,Bootstrap做样式,也不影响系统功能的完整性。
我自己的实践是选择了前后端分离。后端工程负责所有业务逻辑和API接口,前端工程用Vue 3 + Element Plus搭建。这里有个小经验:端口规划要提前想好,后端用8080,前端开发服务器用5173(Vite默认),上线时用Nginx把前端构建产物和后端API做反向代理,就不存在跨域问题了。开发阶段则在后端配置CORS,允许来自http://localhost:5173的请求,这样调试很顺畅。
2.4 持久层框架:MyBatis-Plus还是Spring Data JPA
持久层是数据访问的核心,选型会直接影响你写SQL的方式和开发效率。我在这个系统里用的是MyBatis-Plus,原因有三条。
第一,MyBatis-Plus的条件构造器(LambdaQueryWrapper)写起来非常直观,比如查“某门课程剩余容量大于0”的课程列表,一行wrapper代码就搞定,不需要手写XML映射,比Spring Data JPA的派生查询更灵活。第二,它的分页插件PaginationInnerInterceptor非常好用,做预约记录和成绩列表的业务时,分页几乎是刚需,接入插件后只需要Page对象加mapper.selectPage方法。第三,MyBatis-Plus的代码生成器可以一键生成实体类、Mapper接口、Service、Controller,把最枯燥的CRUD代码从工作量里抹掉,你可以把精力集中在业务逻辑上。
当然Spring Data JPA也有它的拥趸,尤其在简单的单表操作上,JPA几乎零SQL。但这个系统的成绩统计模块需要写不少复杂聚合查询,比如统计课程分数段的分布,这类SQL用JPA的@Query注解也能写,但动态拼接统计条件(比如按学院、按课程类型、按考试时间筛选)会比较痛苦,而MyBatis-Plus配合XML文件可以灵活控制。所以我的选择很明确:MyBatis-Plus + 自定义XML查询。
3. 核心细节解析与数据库设计
3.1 表结构设计的取舍
数据库设计是一切的基石,表设计不合理的话,后面写业务代码会处处别扭。我按照业务模块把关键表拆成四组,这里把最核心的几张表拉出来讲。
用户表(sys_user)是最基础的一张表。字段包括:id、username、password、real_name、role(区分ADMIN/TEACHER/STUDENT)、college_id(所属学院ID)、phone、email、status(启用/禁用)、create_time。这里我要强调一个经验:密码字段一定要存加密后的摘要,不能明文存储。我用的方案是Spring Security推荐自带的BCryptPasswordEncoder,每次登录校验时用matches方法比对。系统支持用户注册,学生注册时默认角色为STUDENT,管理员无法自助注册而是由管理员在后台创建的。
课程表(course)的字段设计要围绕“预约”这个核心动作来展开。我设计的字段是:id、course_name、course_code(课程编号,唯一约束)、teacher_id(开课教师ID)、college_id、course_type(选修/必修)、credit(学分)、class_hours(学时)、total_capacity(总容量)、reserved_count(已预约人数)、class_week(上课周次,如1-16周)、class_time(节次,如周一3-4节)、class_location(教室)、course_desc、status(0草稿/1发布/2已结束)、create_time、update_time。
这里有一个很容易踩坑的字段设计:total_capacity和reserved_count。为什么不通过count预约表来实时算剩余容量?因为实时count在预约提交并发高时会对数据库造成压力,而且查询课程列表需要展示剩余容量时,每次都count会让列表接口变慢。用reserved_count这个冗余字段来存已预约人数,预约成功时加1,取消时减1,在事务里用乐观锁判断,既快又稳。这个“冗余计数字段”的思路在实际项目中很常见,属于典型的空间换时间。
预约表(course_appointment)记录了学生和课程的预约关系。字段包括:id、student_id(学生ID)、course_id、appointment_time(预约提交时间)、status(0已取消/1已预约/2已完成)、attendance_status(出勤状态:0未出勤/1已出勤)、score(成绩,初始为NULL)。这里我把分数塞进了预约表,而不是独立建一张成绩表,是经过权衡的:一门课一个学生只能有一条预约记录,成绩本质上是这条预约的最终结果属性,放进同一张表可以在查成绩时减少一次表关联,逻辑上也讲得通。如果场景变成“一个学生可以多次选同一门课程”,比如重修多次,那才需要拆独立的成绩表。项目里的学生选课限制是唯一约束(student_id, course_id),一个学生对同一门课只能有一条预约记录。
通知表(notify_message)是加分项。字段包括:id、user_id(接收人)、title、content、type(系统通知/预约结果通知/成绩通知)、is_read(是否已读)、create_time。学生在预约成功后能收到一条“预约成功”通知,教师录入成绩后学生能看到“成绩已发布”通知,这个功能虽然简单,但会明显提升系统的“完整感”,在毕业设计答辩时很加分。
3.2 权限认证方案:JWT还是Session
前后端分离架构下的权限认证,主流方案就是JWT(JSON Web Token)。如果你做的是服务端渲染,直接用Spring Security + Session就行,实现简单、理解容易。但既然我们选了前后端分离,我建议用JWT。
我的实现思路是这样的:用户登录成功后,后端用jwt工具类生成一个token,token里封装userId和role两个核心信息,然后设置有效期(我设置为2小时),响应给前端。前端拿到token后存到localStorage里,每次发请求时在请求头加Authorization: Bearer 。后端用一个拦截器(HandlerInterceptor)统一拦截需要权限的接口,从请求头解析token、校验签名、如果失效则返回401。
这里有个点要特别注意:JWT是无法在服务端主动失效的,除非你引入Redis黑名单机制。所以在课程系统中,如果被强制下线或修改密码,已签发的JWT在过期前仍然有效。对于毕设系统来说这问题不大,但如果你想让系统更完善,可以引入Spring Data Redis,在用户表里维护一个token_version字段,JWT里带上这个版本号,用户修改密码后版本号加1,旧token就会被判定失效。这样既简单又能体现出你的深入思考。
3.3 预约冲突检测:不能让学生“分身”上课
预约冲突检测是这个系统最核心的业务规则,也是面试官最喜欢追问的点。
先说需求场景:同一个学生,在同一时间不能预约两门课。这里的“同一时间”包含两个维度,一个是星期几,一个是第几节课。我在Course表里用class_time字段存了格式如"1-3-4"的字符串,含义是“周一第3-4节”,用"5-7-8"表示“周五第7-8节”。这个设计直观,但要在Java里解析后比较冲突。
预约冲突检测的具体流程是这样的:学生提交预约请求时,后端先按student_id查出该生所有状态为已预约(status=1)的课程,再查出这些课程的class_time字段,逐一解析;同时把新课程的class_time解析出来,比较两个时间段的星期值是否相同,再比较节次区间是否有重叠。星期不同,一定不冲突;星期相同,再比较节次区间,比如已有课程是[第3节, 第4节],新课程是[第4节, 第5节],因为3<=5 && 5>=3的情况比较的是区间相交的逻辑,即max(start1, start2) < min(end1, end2)才算冲突。
这个逻辑看起来不复杂,但要注意的细节是数据格式的规范性。如果class_time字段格式不统一,比如有人存"周一3-4节",有人存"1-3-4",解析代码就需要写一堆分支判断。我的建议是:在课程发布时用前端下拉选择器限定格式,后端接收时再用正则校验,格式不合法直接拒绝保存。数据规范是业务逻辑正确的前提,这点再怎么强调都不为过。
3.4 成绩统计的SQL聚合技巧
成绩统计模块是系统的另一个亮点。教师录入成绩后,管理员和教师应该能在后台查看各类统计报表。我做的统计报表包括:分数段分布图(优秀90-100、良好80-89、中等70-79、及格60-69、不及格60以下)、最高分/最低分/平均分、及格率、选课人数和实考人数。
分数段统计我用了一条SQL解决,核心是SUM(CASE WHEN ...)语法:
SELECT course_id, COUNT(*) AS total_count, SUM(CASE WHEN score >= 90 THEN 1 ELSE 0 END) AS excellent_count, SUM(CASE WHEN score >= 80 AND score < 90 THEN 1 ELSE 0 END) AS good_count, SUM(CASE WHEN score >= 70 AND score < 80 THEN 1 ELSE 0 END) AS medium_count, SUM(CASE WHEN score >= 60 AND score < 70 THEN 1 ELSE 0 END) AS pass_count, SUM(CASE WHEN score < 60 THEN 1 ELSE 0 END) AS fail_count, AVG(score) AS avg_score, MAX(score) AS max_score, MIN(score) AS min_score FROM course_appointment WHERE course_id = #{courseId} GROUP BY course_id这条SQL把成绩统计的全部核心指标一次性查出来了,后端只需要把它包装成一个成绩统计VO返回给前端,前端用ECharts渲染柱状图或饼图,效果非常直观。
如果你需要按学院汇总,或者在汇总基础上再按课程分组,只需调整WHERE条件和GROUP BY字段。比如按学院统计,就先关联course表拿到college_id,GROUP BY改成college_id。在XML里写这种动态SQL时,用 标签处理多样筛选条件,用 标签判断是否拼接学院筛选,可以让这个统计接口适配多种查询场景。
4. 实操过程与核心环节实现
4.1 项目初始化和基础脚手架搭建
我的习惯是先搭好后端骨架,再写业务代码。用Spring Initializr生成项目,关键依赖勾选:Spring Web、Spring Security(后面再决定是否深度集成)、MyBatis Framework、MySQL Driver、Lombok(强烈推荐,省去getter/setter的体力活,前提是你IDE要装Lombok插件)、Validation。
生成之后的application.yml配置如下:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_appointment?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key expire: 7200000有几个细节值得展开。第一,数据库连接串务必加上serverTimezone=Asia/Shanghai,否则MySQL 8.x连接时会有时区报错。第二,map-underscore-to-camel-case开启后,数据库字段course_name可以自动映射到Java属性的courseName,省去大量resultMap。第三,逻辑删除是MyBatis-Plus的实用功能,给表加一个deleted字段,配置逻辑删除之后,所有delete操作都变成update,数据不会物理消失,这对“用户误删课程后要恢复”的场景很有用。
4.2 三层架构与统一返回格式
系统的包结构按功能域组织:
com.example.courseappointment ├── common / Result.java, ResultCode.java, GlobalExceptionHandler.java ├── config / MybatisPlusConfig.java, SecurityConfig.java, CorsConfig.java, JwtInterceptor.java ├── controller / AuthController.java, CourseController.java, AppointmentController.java, ScoreController.java, StatisticsController.java, UserController.java ├── entity / SysUser.java, Course.java, CourseAppointment.java, NotifyMessage.java ├── mapper / SysUserMapper.java, CourseMapper.java, CourseAppointmentMapper.java, NotifyMessageMapper.java ├── service / AuthService.java, CourseService.java, AppointmentService.java, ScoreService.java, StatisticsService.java ├── dto / (LoginDTO, RegisterDTO, AppointmentRequestDTO, ScoreRequestDTO等) ├── vo / (CourseVO, AppointmentVO, ScoreStatisticsVO等) └── utils / JwtUtil.java注意这里我把VO和DTO分开了。DTO是接口入参,专门接收前端提交的数据;VO是接口出参,专门给前端展示。两者脱钩后,即使前端需求变了(比如要合并两个字段、改字段名),后端只需要调整VO,不影响数据库实体。这个习惯在企业开发中是基本要求,面试官看到你的项目里有这种分层意识,会加分。
统一返回格式我用一个泛型类Result 包裹所有接口响应:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }统一返回格式的好处是前端处理响应时只需要对code做判断,而且全局异常处理器能把所有未捕获的异常统一转换成Result.error返回,避免前端拿到一个无法解析的异常栈。全局异常处理我用@RestControllerAdvice注解,里面用@ExceptionHandler分别处理业务异常(自定义BusinessException)、参数校验异常(MethodArgumentNotValidException)、权限异常等。
4.3 学生端课程预约完整流程实现
预约模块是业务核心,我把它的完整流程串一遍,从学生登录到预约成功的所有环节。
第一步,学生登录。前端提交用户名密码,后端AuthController的login方法接收到LoginDTO后,通过SysUserService从数据库查出用户,用BCryptPasswordEncoder.matches校验密码。密码正确后JwtUtil生成token,返回给前端:
@Service public class AuthServiceImpl implements AuthService { @Autowired private SysUserMapper sysUserMapper; @Autowired private PasswordEncoder passwordEncoder; @Autowired private JwtUtil jwtUtil; @Override public String login(LoginDTO loginDTO) { SysUser user = sysUserMapper.selectOne( new LambdaQueryWrapper<SysUser>() .eq(SysUser::getUsername, loginDTO.getUsername()) ); if (user == null || !passwordEncoder.matches(loginDTO.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } if (user.getStatus() == 0) { throw new BusinessException("账号已被禁用,请联系管理员"); } return jwtUtil.generateToken(user.getId(), user.getRole()); } }这里要留意两点:一是明确区分“用户名不存在”和“密码错误”的提示,我统一返回“用户名或密码错误”以避免账号枚举;二是账号禁用状态的判断要走业务异常,给前端一个明确的提示信息。
第二步,学生浏览课程列表。课程列表接口支持分页和条件查询,比如按课程类型筛选。CourseService里的分页查询用MyBatis-Plus的Page和LambdaQueryWrapper配合实现。课程列表返回时需要注意一个细节:课程表里有teacher_id,但要展示教师姓名,所以CourseVO需要关联教师表查出realName。这里可以有两种做法:一种是用JOIN查询一条SQL解决,另一种是先查出课程,再根据teacherIds批量查出教师姓名,在Service层组装。我推荐后者,虽然多一次查询,但避免了XML里写JOIN,代码可读性更好,也符合“对象间组合优于表关联”的领域模型思想。
第三步,学生提交预约。AppointmentController的appoint方法接收到AppointmentRequestDTO(studentId、courseId),AppointmentServiceImpl处理的核心逻辑如下:
- 校验课程是否存在且状态为已发布。
- 校验当前预约人数是否已达上限(reservedCount >= totalCapacity则报“课程已满”)。
- 调用checkTimeConflict方法检测时间冲突,冲突则抛异常。
- 再查一次是否有重复预约记录(studentId + courseId唯一),防止重复提交。
- 在事务里执行插入预约记录和更新reservedCount两个操作。
- 给该学生发送一条“预约成功”的系统通知。
核心代码片段:
@Transactional(rollbackFor = Exception.class) @Override public void appoint(AppointmentRequestDTO requestDTO) { Long studentId = requestDTO.getStudentId(); Long courseId = requestDTO.getCourseId(); Course course = courseMapper.selectById(courseId); if (course == null || course.getStatus() != 1) { throw new BusinessException("课程不存在或未开始选课"); } if (course.getReservedCount() >= course.getTotalCapacity()) { throw new BusinessException("课程容量已满,预约失败"); } List<CourseAppointment> existingAppointments = courseAppointmentMapper.selectList( new LambdaQueryWrapper<CourseAppointment>() .eq(CourseAppointment::getStudentId, studentId) .eq(CourseAppointment::getStatus, 1) ); for (CourseAppointment appointment : existingAppointments) { Course bookedCourse = courseMapper.selectById(appointment.getCourseId()); if (TimeConflictUtil.isConflict(course.getClassTime(), bookedCourse.getClassTime())) { throw new BusinessException("预约课程与您已选课程《" + bookedCourse.getCourseName() + "》时间冲突"); } } Long count = courseAppointmentMapper.selectCount( new LambdaQueryWrapper<CourseAppointment>() .eq(CourseAppointment::getStudentId, studentId) .eq(CourseAppointment::getCourseId, courseId) .eq(CourseAppointment::getStatus, 1) ); if (count > 0) { throw new BusinessException("您已预约过该课程,请勿重复预约"); } CourseAppointment appointment = new CourseAppointment(); appointment.setStudentId(studentId); appointment.setCourseId(courseId); appointment.setStatus(1); courseAppointmentMapper.insert(appointment); course.setReservedCount(course.getReservedCount() + 1); courseMapper.updateById(course); notifyMessageMapper.insert(new NotifyMessage(studentId, "预约成功通知", "您已成功预约《" + course.getCourseName() + "》,请按时到" + course.getClassLocation() + "上课")); }@Transactional注解是预约逻辑的命门。插入预约记录和更新已预约人数必须处于同一个事务,否则如果insert成功但update失败,就会出现“预约记录存在但容量没加”的数据不一致。rollbackFor = Exception.class也很关键,默认情况下Spring事务只在遇到RuntimeException时回滚,设置成Exception.class后,遇到受检异常也会回滚,避免隐藏的数据错误。
TimeConflictUtil.isConflict是时间冲突检测的工具方法,解析class_time格式并判断区间重叠:
public class TimeConflictUtil { private static final int DAY_INDEX = 0; private static final int START_INDEX = 1; private static final int END_INDEX = 2; public static boolean isConflict(String timeA, String timeB) { int[] parsedA = parse(timeA); int[] parsedB = parse(timeB); if (parsedA[DAY_INDEX] != parsedB[DAY_INDEX]) { return false; } return Math.max(parsedA[START_INDEX], parsedB[START_INDEX]) < Math.min(parsedA[END_INDEX], parsedB[END_INDEX]); } private static int[] parse(String time) { String[] parts = time.split("-"); if (parts.length != 3) { throw new BusinessException("课程时间格式异常: " + time); } return new int[]{ Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]) }; } }4.4 教师端成绩录入与统计实现
教师的成绩管理流程相对简单,但同样需要遵循业务规则。教师登录后,进入“我的课程”页面,可以看到自己主讲的所有课程;进入某门课程的详情页,可以查看已预约学生名单;点击“录入成绩”,逐条录入或批量录入学生的成绩。
这里有一个容易忽略的业务规则:是否允许修改已发布的成绩?我的设计是:教师录入成绩后,默认状态是“已录入但未发布”,学生端暂时看不到;教师确认无误后点击“发布成绩”,学生端才能看到自己的分数。这样设计符合学校的实际管理流程,也给教师留了纠错空间。数据库层面,我在预约表上增加了一个score_status字段(0未录入/1已暂存/2已发布),用这个字段控制分数的可见性。
成绩统计的Service层代码调用上面那条聚合SQL,封装成ScoreStatisticsVO返回。如果要按学院汇总统计,就写一个动态SQL方法,参数是collegeId,查询时关联Course表:
<select id="selectScoreStatisticsByCollege" resultType="com.example.courseappointment.vo.ScoreStatisticsVO"> SELECT c.college_id AS collegeId, COUNT(*) AS totalCount, SUM(CASE WHEN a.score >= 90 THEN 1 ELSE 0 END) AS excellentCount, SUM(CASE WHEN a.score >= 80 AND a.score < 90 THEN 1 ELSE 0 END) AS goodCount, <!-- 其他分数段省略 --> AVG(a.score) AS avgScore FROM course_appointment a INNER JOIN course c ON a.course_id = c.id WHERE a.score IS NOT NULL <if test="collegeId != null"> AND c.college_id = #{collegeId} </if> GROUP BY c.college_id </select>XML里的小于号(<)必须写成<,否则XML解析会直接报错,这是写MyBatis XML最容易踩的坑,没有之一。
4.5 前端页面与后端API对接
前端我用Vue 3 + Element Plus + Axios搭了一套管理后台风格的单页应用,路由设计为:/login(登录页)、/student/dashboard(学生首页)、/student/courses(课程列表与预约)、/student/my-appointments(我的预约)、/student/scores(我的成绩)、/teacher/courses(我的课程)、/teacher/score-entry(成绩录入)、/admin/users(用户管理)、/admin/statistics(统计报表)。
路由守卫是前端权限控制的关键,在router.beforeEach里判断本地token是否存在,不存在就跳转登录页。但要注意,前端路由守卫只能做UI层面的跳转限制,真正的权限安全必须依靠后端的JWT拦截器。前端把token藏在localStorage里本身就不安全(XSS攻击可窃取),所以请求中携带的token在后端校验是最后的安全防线。
前端调用后端API时需要统一封装request工具,我基于Axios做了一层封装,request拦截器做token注入,response拦截器统一处理code不等于200的情况,比如401跳转登录页、403提示无权限、500弹出错误信息。这样业务代码里就不需要每个接口都处理异常逻辑,代码会干净很多。
5. 常见问题与排查技巧实录
5.1 MyBatis-Plus分页失效问题
分页是高频功能,很多同学在第一次用MyBatis-Plus分页时都会遇到“分页不生效,查询出全部数据”的问题。原因基本只有一个:忘了配置分页插件。
MyBatis-Plus从3.4版本开始,分页功能不再默认启用,必须手动配置PaginationInnerInterceptor。查别人写的项目时,要留意配置文件里有没有这一段:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); DbType dbType = DbType.MYSQL; interceptor.addInnerInterceptor(new PaginationInnerInterceptor(dbType)); return interceptor; } }另一个容易出错的地方是:分页查询方法里传入Page对象后,如果SQL中还有自定义的JOIN语句,需要确保Page对象作为第一个参数传给Mapper方法,否则MyBatis-Plus不知道要对哪条SQL做分页。
5.2 Spring Boot自动装配与循环依赖
Spring Boot的“自动装配”是它的标志性特性,但很多初学者只会用,不知道背后发生了什么。SpringBootApplication注解是一个组合注解,核心是@EnableAutoConfiguration,它通过引入spring.factories或AutoConfiguration.imports文件里声明的配置类,在满足条件(比如classpath里有DataSource类且没有自定义DataSource Bean)的情况下,自动创建各种Bean。理解了自动装配原理,你就能解释“为什么加了redis依赖之后,如果没有配置连接信息会导致应用启动失败”之类的问题——因为RedisAutoConfiguration自动创建了RedisTemplate Bean,但连接不可用。
循环依赖是另一个高频问题。比如CourseServiceImpl注入了AppointmentServiceImpl,而AppointmentServiceImpl又注入了CourseServiceImpl,Spring Boot 2.6及以后版本默认禁止循环依赖,启动时直接报错“The dependencies of some of the beans in the application context form a cycle”。遇到这个报错,正确的做法不是去application.yml里设置spring.main.allow-circular-references=true,而是检查设计:抽出一个第三方Service,或者用@Lazy注解延迟注入打破循环。我见过很多同学为了省事直接允许循环依赖,这在项目里是埋雷,后续维护一旦触及这个环,会出现难以预料的初始化顺序问题。
5.3 Lombok探针报错与JDK版本冲突
如果你用较新版本的JDK(比如JDK 16或17)运行项目,有时会看到“You aren't using a compiler supported by lombok, so lombok will not work”的黄色警告,或者IDE里getter/setter方法不生效。这是因为Lombok是通过注解处理器在编译期修改抽象语法树的,新版本JDK对内部API的访问限制导致老版本Lombok失效。
解决办法是升级Lombok依赖到较新版本,或者把JDK降到8/11。在毕设场景下,最稳妥的方案是环境统一用JDK 8,Lombok版本用1.18.24以上,实测下来完全稳定。
5.4 接口返回时间格式错误
Jackson默认序列化LocalDateTime时输出的是数组格式(如[2024, 1, 15, 10, 30, 0]),前端根本没法直接展示。解决方式有三个:一是在application.yml里配置全局时间格式(我在前面的配置里已经写了jackson.date-format和time-zone);二是给实体类的日期字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")注解;三是在前端做格式化处理。推荐前两种,因为时间格式问题在后端统一解决,前端拿到的就是标准字符串。
前面提到统一返回里我用了Date类型接收日期参数,在前后端交互时前端一般会用字符串(比如“2024-06-01”)传递日期,后端用@DateTimeFormat(pattern = "yyyy-MM-dd")注解就能正确解析。这个是高频问题,务必牢记。
5.5 并发预约导致超卖
课程容量100人,最后1个名额,同时有10个学生提交预约,如果不对并发做控制,会出现所有请求都读到reservedCount=99而继续执行,最终导致预约人数超过100的情况。这可是系统级的Bug,答辩时被问到“你的系统如何防止超卖”就表现不出来了。
我的解决方案是在更新reservedCount时使用乐观锁。MyBatis-Plus支持@Version注解,给Course实体的reservedCount字段加上版本号,更新时自动带上version = #{version}条件,如果影响行数为0说明version已被别的线程改过,就重新读取再更新,或者直接提示“课程已满”。核心在更新语句:
UPDATE course SET reserved_count = reserved_count + 1, version = version + 1 WHERE id = #{courseId} AND version = #{version}这个方案简单可靠,不需要引入分布式锁,对于单机部署的毕设系统完全够用。在面试里你能主动提这个细节,是实实在在的加分项。
6. 项目部署与扩展方向建议
6.1 本地部署与打包
后端打包成jar。在项目根目录执行mvn clean package,如果用了自定义的测试类,建议打包时跳过测试:mvn clean package -DskipTests。生成target/xxx.jar后,用java -jar xxx.jar启动,默认端口8080。如果部署服务器上8080被占用,用--server.port指定新端口:java -jar xxx.jar --server.port=8081。
前端构建要稍微复杂些。Vue项目执行npm run build后生成dist目录,把dist下的所有文件拷贝到Nginx的html目录(实际是nginx.conf中配置的root路径),然后在nginx.conf里配置一个location,把/api开头的请求反向代理到后端服务:
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; } }try_files $uri $uri/ /index.html这句是单页应用路由的基础配置,如果漏掉,Vue Router使用History模式时刷新页面会报404。
另一种更省事的部署方式是前后端一起打包:把前端dist目录放到后端resources/static下,后端启动后直接访问http://localhost:8080就能看到页面。这种方式部署简单,适合毕设演示,但前后端耦合在一起,真实项目中用得少。我的建议是至少学会Nginx配置,因为面试时大概率会被问到部署。
6.2 可扩展的增强点
如果你做完基础功能还有余力,或者想在答辩时展示更完整的思路,以下几个增强点是投入产出比很高的:
引入Redis做缓存。课程列表、学院列表这些热点数据查询频繁但变化较少,用Redis缓存后能显著减轻数据库压力。同时可以用Redis存JWT的黑名单,解决JWT不能主动失效的问题。
整合消息队列。Spring Boot整合ActiveMQ或RabbitMQ是这个题目的热门增强方向。比如学生预约成功后,异步发送一封邮件通知,或者在成绩发布后异步生成成绩单PDF。消息队列在毕设里的价值是展示“异步解耦”的架构思维,不用做太复杂,一个简单的队列消费就足够。
引入WebSocket做实时通知。目前预约通知是存库之后学生刷新页面才能看到,如果引入WebSocket,学生在线时服务器可以主动推送“预约成功”消息,通知模块的体验会提升一个档次。
对接HanLP分词做课程检索。如果课程数量庞大,学生需要能通过关键字快速找到课程,传统SQL的LIKE '%关键词%'已经够用,但如果你想让系统的搜索模块更有亮点,可以在课程名称和课程描述上应用HanLP分词,构建一个简单的中文检索服务。这个扩展技术含量适中,比写一个毫无技术含量的搜索界面强得多。
数据库层面引入PowerJob做定时任务。比如课程结束后自动把预约状态从“已预约”改成“已完成”,每周日凌晨自动清理三个月前的通知消息。定时任务在课程管理系统中非常实用,选择PowerJob还是Quartz取决于你更熟悉哪个生态,二者的集成方式网上都有大量实践资料。
7. 项目总结与个人经验体会
最后分享一点我做这个项目时的真实感受。这个系统最核心的价值不是代码量多不多,页面好不好看,而是业务逻辑链条是否闭合。
从学生预约课程,到教师录入成绩,再到管理员查看统计报表,这中间每一步都有对应的状态流转和数据约束。预约时要校验时间冲突和容量上限,录入成绩时要区分暂存和发布,统计时要处理多维度筛选。每一处细节都在打磨你的“业务建模能力”,这种能力比单纯会背几个框架技术点重要得多。面试官问我项目经验时,我讲完预约冲突检测和乐观锁防超卖这两个点,明显感觉他对项目的兴趣比听我背“Spring Boot自动装配原理”要浓厚很多。
另外一个很深的体会是:做这种系统,一定要先把数据库表设计清楚再动手写代码。我最早是边写边改表,结果改表名、加字段、改Service层代码来回折腾了三天。后来先花了半天时间把ER图和表结构定下来,后面写代码时基本没有发生因为表结构变动而大改代码的情况。数据模型稳定,业务代码就稳了一大半。
如果你正在做一个类似的Spring Boot项目,或者正准备开题做高校课程预约成绩统计系统的毕设,希望这篇文章能帮你把路线图理清楚。项目本身并不复杂,但把它做完整、做深入,需要投入的心思不少。踏踏实实把每个模块做扎实,你收获的不仅是一个能跑的Demo,更是一套完整的问题分析和解决思路。
最后再分享一个实际写代码时的小技巧:遇到任何奇怪的Bug,第一件事不是去网上乱搜,而是先看控制台日志的异常栈信息,异常栈会直接告诉你是哪一行代码出了问题。Spring Boot的默认日志已经够用了,加上MyBatis-Plus的SQL日志输出,你能看到每条SQL执行的参数值,绝大多数数据相关的问题都能从SQL日志中定位到原因。这个习惯能让你的调试效率提高一倍。