SpringBoot+Vue选课排课系统:并发选课与排课冲突检测实践 简介面向Java毕业设计的选课与排课管理系统采用SpringBoot后端与Vue.js前端的前后端分离结构配套完整源码、SQL脚本、说明文档和演示PPT适合需要完成课程设计、毕业设计或希望练习SpringBootVue全栈开发的学生与初中级开发者。系统围绕用户管理、课程管理、学生管理、排课管理和数据统计展开涉及JWT身份验证、MyBatis或JPA数据访问、MySQL建表与初始化数据、Vuex状态管理及Axios接口请求等常见技术点也覆盖课程时段与教室安排冲突检测、选课人数与课程热门度统计等业务场景可用于理解模块划分、数据库设计与前后端联调流程。压缩包约17.55MB文件总数与类型明细上游暂未提供但从资源说明可知包含源码、SQL脚本、文档与PPT等主要材料。已有561人学习文档可用于梳理架构设计与功能实现PPT便于答辩汇报源码与脚本则能作为二次开发和功能扩展的起点。1. 从一张撞车的排课表说起这套 SpringBootVue 选课排课系统解决什么教务排课最怕的不是课多是两门课被塞进同一间教室、同一个老师被排到两个班学生端更刺激选课页刷新三下名额从 0 变成 -3退课还要人工对账。这套 SpringBootVue 选课排课管理系统就是冲着这两件事来的管理员端管课程、教学班、教室、时间片并排课教师端看自己的授课任务和学生名单学生端抢课、退课、查课表中间用 Java 后端把容量、时间重叠、重复选课这些约束卡死。资源包里是前后端完整源码外加建表 SQL 脚本、接口文档和答辩用的 PPT做 Java 毕业设计的同学可以直接拿来做二次开发想练手「约束型业务」的初中级开发者也合适——功能不花哨但每个坑都真实。2. 技术栈与工程结构SpringBoot MyBatis-Plus Vue2 的三层骨架2.1 为什么毕设场景下这套组合最省事后端是 SpringBoot 2.7 MyBatis-Plus MySQL 8.0 JWT前端是 Vue 2.6 Element UI Axios ECharts。这个组合在毕业设计里出镜率极高原因不在先进而在「不给你添堵」SpringBoot 内嵌 Tomcat一个 main 方法就能跑不用折腾 war 包和外部容器MyBatis-Plus 把单表 CRUD 全部包掉BaseMapper里selectPage、selectList直接调省下来的时间够你写完整个排课逻辑Element UI 的el-table、el-form、el-cascader拿来就拼管理页面课程-班级-教室三级联动不用自己写ECharts 挂两个饼图柱状图答辩演示时的观感直接拉满。权限这一层用的是 JWT 加拦截器没用 Spring Security。别觉得这是偷懒毕设里把 Spring Security 的过滤器链调通往往要耗掉两三天而 JWT HandlerInterceptor三十行代码就能把「未登录跳登录页、学生不能调管理接口」这件事做完整。数据库选 MySQL 8.0主要是看中它默认的utf8mb4和窗口函数做课表冲突统计时写起来舒服。2.2 后端目录结构与分层职责schedule-server/ ├── src/main/java/com/example/schedule/ │ ├── ScheduleApplication.java 启动类 │ ├── config/ │ │ ├── WebMvcConfig.java 跨域 拦截器注册 │ │ └── MybatisPlusConfig.java 分页插件 │ ├── interceptor/ │ │ └── AuthInterceptor.java 登录态与角色校验 │ ├── controller/ 按角色分Admin/Teacher/Student │ ├── service/impl/ 业务实现冲突检测在这里 │ ├── mapper/ Mapper 接口 自定义 SQL │ ├── entity/ 与表一一对应 │ ├── dto/ 接收前端入参 │ ├── vo/ 返回给前端的组装对象 │ └── common/ │ ├── Result.java 统一响应体 │ └── BizException.java 业务异常 └── src/main/resources/ ├── application.yml ├── mapper/ XML 映射文件 └── sql/init.sql 建库建表 初始化数据分层没什么花活但有两处值得说。第一controller按角色拆成了三个包AdminCourseController、StudentSelectController这种命名让后期改需求时定位特别快比一个CourseController里塞二十个接口强。第二entity和vo是分开的比如课表返回给前端时要带上课程名、教师名、教室名这些字段在t_schedule表里没有得靠ScheduleVO组装混用实体类会导致insert时多出一堆null字段。启动类和普通 SpringBoot 项目没区别注意MapperScan别漏SpringBootApplication MapperScan(com.example.schedule.mapper) // 漏了这行Mapper 注入会直接失败 public class ScheduleApplication { public static void main(String[] args) { SpringApplication.run(ScheduleApplication.class, args); } }application.yml里几个关键配置尤其是时区和驼峰映射server: port: 8089 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_schedule?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # selected_count - selectedCount log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 调试期打开能看到实际 SQLserverTimezone必须写不写的话 MySQL 8 驱动会按 UTC 解析datetime课表里的「第 3 节 08:00」存进去变成 00:00查出来再对不上这种问题查起来极其玄学。map-underscore-to-camel-case打开后表里的selected_count才能自动映射到实体的selectedCount否则字段永远是 null。2.3 前端目录结构与请求封装schedule-web/ ├── public/index.html ├── src/ │ ├── api/ 每个模块一个文件course.js、select.js、schedule.js │ ├── router/index.js 路由 前置守卫 │ ├── store/index.js Vuex只存 token 和用户信息 │ ├── views/ │ │ ├── admin/ 课程、教学班、教室、排课、统计 │ │ ├── teacher/ 我的授课、学生名单 │ │ └── student/ 选课中心、我的课表 │ ├── components/ 课表网格、时间片选择器 │ └── utils/request.js Axios 实例 └── vue.config.js 开发代理前端最值得看的是utils/request.js所有接口的错误处理都收敛在这里业务代码里不用重复写if (res.code ! 200)import axios from axios import { Message } from element-ui import router from /router const request axios.create({ baseURL: /api, timeout: 8000 }) // 请求拦截统一带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) config.headers[Authorization] token return config }) // 响应拦截统一拆包 401 跳登录 request.interceptors.response.use(res { const body res.data if (body.code 200) return body.data // 业务代码直接拿 data if (body.code 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(body.msg) } Message.error(body.msg || 请求失败) return Promise.reject(body.msg) }, err { Message.error(网络异常请检查后端是否启动) return Promise.reject(err) }) export default request注意return body.data这一句它意味着api/course.js里写const list await getCourseList(params)拿到的直接是数组而不是res.data.data。如果中途有人把它改成return res所有页面都会因为多套一层而白屏这种改动最好在文件顶部写注释锁死。vue.config.js的代理是联调的关键前端跑 8080后端跑 8089不配代理就是跨域module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8089, changeOrigin: true, pathRewrite: { ^/api: } // 后端接口没有 /api 前缀这里要抹掉 } } } }2.4 前后端约定统一响应体接口返回值统一成三段式前端拦截器就是按这个结构拆的字段类型含义常见取值codeInteger业务状态码200 成功、401 未登录、500 业务失败msgString提示语「该教学班已选满」dataObject业务数据列表、详情、null这套约定的价值在排课保存这类多步操作上后端返回 500 加一句「与张老师在周三第 3-4 节冲突」前端Message.error直接弹出来不用前端自己拼错误文案。3. 数据库设计拆解学生、课程、教学班、选课记录与时间片六张表怎么搭3.1 六张核心表的关系整个系统的数据模型其实是一条直线t_course课程比如「数据结构」3 学分→t_teaching_class教学班某学期某老师开的某个班带容量→t_schedule排课记录一个教学班可以有多条对应多个时间段→t_course_select选课记录学生和教学班的关联。旁边挂着t_student、t_teacher、t_classroom三张主数据表。必须把「课程」和「教学班」拆开这是新手最容易合并的地方。课程是抽象的数据结构3 学分教学班是具体的2024-2025-1 学期李老师周一 1-2 节容量 60。合并成一张表的后果是同一门课开三个班就要录三条课程数据统计「全校开了多少门课」时直接算错。核心建表语句大概长这样CREATE TABLE t_teaching_class ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT 课程 id, teacher_id BIGINT NOT NULL COMMENT 授课教师 id, class_no VARCHAR(32) NOT NULL COMMENT 教学班编号如 CS-DS-01, semester VARCHAR(16) NOT NULL COMMENT 学期如 2024-2025-1, capacity INT NOT NULL DEFAULT 60 COMMENT 容量, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1开放 0关闭, UNIQUE KEY uk_no_semester (class_no, semester), KEY idx_teacher_sem (teacher_id, semester) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教学班;selected_count这个冗余字段是刻意的。如果每次判断名额都去count(*)选课记录表选课高峰期一个请求一次全表统计QPS 上来直接拖垮。用冗余计数 条件更新把并发压力压在单行上是这类系统的标准做法。uk_no_semester保证同一学期教学班号不重复避免导入 Excel 时手抖录两遍。3.2 选课记录表的唯一索引才是防重复的底牌CREATE TABLE t_course_select ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, teaching_class_id BIGINT NOT NULL, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1 COMMENT 1已选 0已退, UNIQUE KEY uk_student_class (student_id, teaching_class_id), KEY idx_class (teaching_class_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选课记录;这里有个设计取舍要讲清楚status字段和唯一索引是冲突的。如果学生退课是把status改成 0再次选同一门课时会撞唯一索引。两种解法一是退课时直接物理删除记录二是把唯一索引改成(student_id, teaching_class_id, status)。我一般选第一种退课记录本来也不需要在选课表里留痕真要审计就在t_select_log里单独记一条职责更干净。idx_class这个索引是给「查某教学班的学生名单」用的没有它教师端点开 60 人的班级名单会扫全表。3.3 排课时间片为什么不用一个 datetime 字段新手常见的写法是给排课表一个start_time、end_time存2024-09-02 08:00:00。这个模型能跑但一到冲突检测就难受要判断两个时间段是否重叠得写start1 end2 AND end1 start2跨周次还要按日期推算而排课业务的实际语义是「第 3 周到第 16 周每周三第 3 到第 4 节」本质是离散的格子不是连续的时间轴。CREATE TABLE t_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, teaching_class_id BIGINT NOT NULL, classroom_id BIGINT NOT NULL, semester VARCHAR(16) NOT NULL, week_start TINYINT NOT NULL COMMENT 起始周1-20, week_end TINYINT NOT NULL COMMENT 结束周, day_of_week TINYINT NOT NULL COMMENT 1-7周一到周日, section_start TINYINT NOT NULL COMMENT 起始节次, section_end TINYINT NOT NULL COMMENT 结束节次, KEY idx_room (classroom_id, semester, day_of_week), KEY idx_class (teaching_class_id, semester) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT排课记录;拆成day_of_week section_start section_end week_start week_end之后冲突判断变成三段区间重叠SQL 和 Java 都能写得很短。周次之所以要week_start/week_end而不是一个weeks字符串是因为「1-8 周」和「5-12 周」在 5-8 周是重叠的字符串比对完全失效必须拆成数值区间。提示节次不要用具体钟点存用「第几节」。上午第 1 节到底是 8:00 还是 8:10是教务处的作息表决定的把它单独放一张t_time_section配置表将来改作息不用动排课数据。4. 核心模块拆解一次选课请求和一次排课校验的完整链路4.1 选课接口从参数校验到名额扣减选课是整个系统唯一有并发压力的接口开学第一分钟几百个学生同时点处理不好就出负数名额。完整的链路是四步校验教学班状态 → 条件更新扣名额 → 写选课记录 → 异常回滚。PostMapping(/select) public ResultVoid select(RequestBody SelectDTO dto) { // 1. 参数兜底学生 id 从 token 里取不信任前端传的 Long studentId UserContext.getUserId(); if (dto.getTeachingClassId() null) { return Result.fail(请选择教学班); } // 2. 查教学班先做一次快速失败省掉无谓的写操作 TeachingClass tc teachingClassMapper.selectById(dto.getTeachingClassId()); if (tc null || tc.getStatus() 0) { return Result.fail(教学班不存在或已关闭选课); } // 3. 关键条件更新名额没满才 1返回受影响行数 int rows teachingClassMapper.increaseSelected(tc.getId()); if (rows 0) { return Result.fail(该教学班已选满); } // 4. 写选课记录唯一索引兜住重复选课 try { CourseSelect cs new CourseSelect(); cs.setStudentId(studentId); cs.setTeachingClassId(tc.getId()); courseSelectMapper.insert(cs); } catch (DuplicateKeyException e) { // 重复选课把刚才扣的名额还回去 teachingClassMapper.decreaseSelected(tc.getId()); return Result.fail(你已经选过这门课了); } return Result.ok(); }第 3 步是整个接口的核心对应的 Mapper SQL 必须带selected_count capacity这个条件update idincreaseSelected UPDATE t_teaching_class SET selected_count selected_count 1 WHERE id #{id} AND selected_count lt; capacity AND status 1 /update这条 UPDATE 在 InnoDB 里会对目标行加排他锁两个事务同时进来后一个会等前一个提交后再判断selected_count capacity所以名额永远扣不成负数。这比「先 select 判断再 update」可靠得多——后者在并发下必然超卖因为判断和写入之间有空窗。参数说明#{id}是教学班主键capacity和status都是行内字段靠数据库自己保证原子性Java 层不用加任何锁。第 4 步的DuplicateKeyException是兜底而不是主逻辑。正常情况下前端会先查「我的选课」把已选课程置灰但学生开两个浏览器标签页同时点前端拦不住唯一索引能拦住。4.2 排课冲突检测教师、班级、教室三维校验排课保存时的冲突检测要同时看三个维度同一个老师同一时间不能上两门课同一个班级行政班同一时间不能上两门课同一间教室同一时间不能被占两次。三个维度的查询结构是一样的只是过滤字段不同。public void checkConflict(ScheduleDTO dto) { // 时间区间重叠已有区间 [s1,e1] 与新插入 [s2,e2] 重叠的条件是 s1e2 e1s2 // 教师维度 int teacherConflict scheduleMapper.countTeacherConflict( dto.getTeacherId(), dto.getSemester(), dto.getDayOfWeek(), dto.getSectionStart(), dto.getSectionEnd(), dto.getWeekStart(), dto.getWeekEnd(), dto.getExcludeId()); if (teacherConflict 0) { throw new BizException(该教师在此时间段已有授课安排); } // 教室维度 int roomConflict scheduleMapper.countRoomConflict( dto.getClassroomId(), dto.getSemester(), dto.getDayOfWeek(), dto.getSectionStart(), dto.getSectionEnd(), dto.getWeekStart(), dto.getWeekEnd(), dto.getExcludeId()); if (roomConflict 0) { throw new BizException(该教室在此时间段已被占用); } // 行政班维度一个班的学生不能同时上两门课 int classConflict scheduleMapper.countClassConflict( dto.getAdminClassId(), dto.getSemester(), dto.getDayOfWeek(), dto.getSectionStart(), dto.getSectionEnd(), dto.getWeekStart(), dto.getWeekEnd(), dto.getExcludeId()); if (classConflict 0) { throw new BizException(该班级在此时间段已有课程); } }对应的 Mapper 查询select idcountRoomConflict resultTypeint SELECT COUNT(1) FROM t_schedule WHERE classroom_id #{roomId} AND semester #{semester} AND day_of_week #{dayOfWeek} !-- 节次区间重叠 -- AND section_start lt; #{sectionEnd} AND section_end gt; #{sectionStart} !-- 周次区间重叠 -- AND week_start lt; #{weekEnd} AND week_end gt; #{weekStart} !-- 编辑时排除自己 -- if testexcludeId ! null AND id ! #{excludeId} /if /selectsection_start sectionEnd AND section_end sectionStart这个写法看着别扭但它才是区间重叠的通用解。很多人会写成section_start #{sectionStart}只能拦住「节次完全相同」的情况第 3 节排了课第 3-4 节照样能塞进去翻车点就在这。excludeId是编辑场景的必备参数否则修改一条排课记录时它自己会跟自己冲突保存永远失败。week_start/week_end的重叠判断同理。单周课和双周课的建模要留个心眼「1-16 周单周」这种排法要么在t_schedule加一个week_type字段0 全周、1 单周、2 双周要么拆成 1、3、5 三条记录。我一般选加字段数据量小查询时多一个AND week_type 0 OR week_type #{newType}的条件。4.3 课表渲染与列表分页学生端的课表是一个 7 列 × 12 行的网格前端拿到的是排课记录列表转成二维数组渲染。后端返回的ScheduleVO里要有courseName、teacherName、classroomName、dayOfWeek、sectionStart、sectionEnd这几个字段靠一次三表 join 拼出来select idlistByStudent resultTypecom.example.schedule.vo.ScheduleVO SELECT s.day_of_week, s.section_start, s.section_end, s.week_start, s.week_end, c.course_name, t.teacher_name, r.room_name FROM t_course_select cs JOIN t_teaching_class tc ON tc.id cs.teaching_class_id JOIN t_schedule s ON s.teaching_class_id tc.id JOIN t_course c ON c.id tc.course_id JOIN t_teacher t ON t.id tc.teacher_id JOIN t_classroom r ON r.id s.classroom_id WHERE cs.student_id #{studentId} AND cs.status 1 AND s.semester #{semester} ORDER BY s.day_of_week, s.section_start /select前端拿到之后按day_of_week分桶同一时间片有课的单元格填课程名没有的留空。week_start、week_end也要传给前端因为课表通常要支持「查看第 N 周」的切换前端过滤比后端每次重新请求省事。管理端的教学班列表用 MyBatis-Plus 分页条件是动态的public IPageTeachingClassVO pageClasses(ClassQuery query) { PageTeachingClassVO page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperTeachingClass wrapper new LambdaQueryWrapper(); wrapper.eq(TeachingClass::getSemester, query.getSemester()) .like(StringUtils.hasText(query.getKeyword()), TeachingClass::getClassNo, query.getKeyword()) .eq(query.getTeacherId() ! null, TeachingClass::getTeacherId, query.getTeacherId()) .orderByDesc(TeachingClass::getId); return teachingClassMapper.selectVOPage(page, wrapper); }LambdaQueryWrapper的好处是字段名用方法引用写重构时改名不会漏改 SQL 字符串like前面那个StringUtils.hasText判断保证关键词为空时不会拼出LIKE %%导致全表扫描。分页插件在MybatisPlusConfig里注册忘了注册的话selectPage不会报错但返回的 total 永远是 0翻页按钮直接消失这个坑我踩过。4.4 登录态与角色拦截拦截器做两件事校验 token 有效性校验当前角色有没有权限访问这个路径。Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { if (!(handler instanceof HandlerMethod)) return true; // 静态资源放行 String token req.getHeader(Authorization); if (token null || !jwtUtil.verify(token)) { writeJson(resp, 401, 登录已过期请重新登录); return false; } String role jwtUtil.getRole(token); // student / teacher / admin String uri req.getRequestURI(); if (uri.startsWith(/admin/) !admin.equals(role)) { writeJson(resp, 403, 无权访问); return false; } UserContext.set(jwtUtil.getUserId(token)); // ThreadLocal 存当前用户 return true; } Override public void afterCompletion(...) { UserContext.clear(); // 必须清线程池复用会串数据 }afterCompletion里的UserContext.clear()是个血泪经验。线程池复用线程如果不清理 ThreadLocal下一个请求可能拿到上一个用户的 id测的时候单线程跑不出来一上并发就出现「A 学生查到了 B 学生的课表」排查起来就是黑匣子。5. 启动与排查从 SQL 脚本导入到前后端联调的踩坑清单5.1 环境准备与建库导入需要 JDK 8 或 11、Maven 3.6、MySQL 8.0、Node.js 14 或 16。版本别贪新Node 18 以上跑 Vue 2 的node-sass会报编译错误这是环境层面最常见的翻车点。建库直接跑脚本注意用source而不是复制粘贴脚本里有中文注释和大量 INSERT# 登录 MySQL mysql -u root -p # 建库 导入注意路径换成你解压后的实际位置 CREATE DATABASE course_schedule DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE course_schedule; source /你的路径/sql/init.sql; # 验证应该能看到 8 张表 show tables; select count(*) from t_course;init.sql里一般会带一个管理员账号和若干测试用的学生、教师、课程数据。测试账号的密码是加密存的如果文档里没写明文就看脚本里的 MD5 或 BCrypt 值对应的原始密码通常在 README 的「测试账号」一节。5.2 后端启动与配置核对cd schedule-server # 先编译确认依赖能拉下来 mvn clean package -DskipTests # 启动 mvn spring-boot:run启动成功的标志是控制台打出Tomcat started on port(s): 8089。如果卡在Starting Application后报Communications link failure说明数据库连不上按顺序查三件事MySQL 服务起没起、application.yml里的端口是不是 3306、密码对不对。如果报Unknown database course_schedule就是 5.1 的建库没做。后端起来后先别急着开前端用 curl 打一个不需要登录的接口验证链路curl http://localhost:8089/course/list?pageNum1pageSize5 # 返回 {code:200,msg:ok,data:{...}} 说明数据库和 MyBatis 都通了5.3 前端启动与联调cd schedule-web npm install --registryhttps://registry.npmmirror.com # 用镜像源别硬扛 npm run serve控制台打出App running at: http://localhost:8080就成功了。首次登录用管理员账号进后台先建课程再建教学班选课程、选教师、填容量然后去排课页面给教学班分配教室和时间片最后建几个学生账号去选课端验证。这个顺序不能反因为教学班依赖课程和教师排课依赖教学班。5.4 五条踩坑记录现象、原因、解决坑一导入 SQL 报Unknown collation: utf8mb4_0900_ai_ci现象是脚本跑到第一行 CREATE TABLE 就中断后面全是语法错误。原因是脚本在 MySQL 8.0 上导出的utf8mb4_0900_ai_ci是 8.0 才有的排序规则本机装的是 5.7。解决办法是把脚本里的排序规则批量替换成utf8mb4_general_ci用编辑器全局替换一次就行。反过来如果本机是 8.0 而脚本是老版本导出的utf8_general_ci不用改8.0 兼容。坑二前端所有接口 404Network 里请求地址是http://localhost:8080/api/course/list现象是页面能打开但数据全是空的。原因两种一是vue.config.js的代理没生效改完配置必须重启npm run serve热更新不会重载代理二是pathRewrite没配后端接口路径是/course/list没有/api前缀代理转发过去就 404。加一行pathRewrite: { ^/api: }重启即可。坑三选课成功但名额不减少或者减了两次现象是学生端显示已选管理端教学班的selected_count纹丝不动或者一次操作涨了 2。原因通常是increaseSelected的 Mapper XML 里把写成了XML 解析时直接报错被吞掉或者 Service 上加了Transactional但异常被try-catch吃掉事务没回滚扣减和插入一半成功一半失败。排查方法是在application.yml打开log-impl看实际 SQL 有没有执行同时确认Transactional(rollbackFor Exception.class)写的是Exception而不是默认的RuntimeException。坑四排课保存报「与自己在同一时间冲突」现象是新建排课正常点编辑保存就提示冲突。原因是冲突检测的 SQL 忘了带excludeId条件记录自己跟自己比当然冲突。给三个count*Conflict方法都加上if testexcludeId ! null AND id ! #{excludeId} /if并且前端编辑时把当前记录 id 传过去。这类问题属于「只测新增不测编辑」的典型漏测测试用例里必须补一条。坑五课表查出来时间是空的或者日期显示成一串数字现象是ScheduleVO里selectTime字段返回[2024,9,2,8,0]这种数组。原因是实体用了LocalDateTimeJackson 默认按 ISO 格式序列化。两种解法字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)或者在application.yml里全局配spring.jackson.date-format和time-zone。局部注解更可控我一般只在返回给前端的 VO 上加实体类保持干净。注意改完application.yml一定要重启后端SpringBoot 的配置不是热加载的改完不重启然后怀疑代码有问题是新手最常见的空转。6. 二次开发三个改造点让这套源码真正变成你的东西拿到源码直接答辩是过不了的老师一句「你改了什么」就问了。我一般从三个方向动手成本低但改动看得见。第一个是加「选课时间窗口」。现在教学班只有status一个开关改成加select_start、select_end两个datetime字段选课接口里加一段时间判断LocalDateTime now LocalDateTime.now(); if (now.isBefore(tc.getSelectStart()) || now.isAfter(tc.getSelectEnd())) { return Result.fail(当前不在选课时间段内); }前端在教学班列表加一列显示窗口状态红黄绿三色标签。改动不到五十行但演示时能讲「防止学生在系统开放前抢跑」是个真实的业务点。第二个是给排课加「自动推荐教室」。现在的排课是手动选教室改成保存时不传教室后端按容量匹配找capacity 教学班人数且该时间段空闲的教室取容量最小的那个。查询用一条NOT EXISTS子查询就能实现讲起来有算法味写起来只有十几行 SQL。第三个是把统计页做成 ECharts 双图。原来可能只有一个课程数量柱状图加一个「各教室使用率」饼图和「各教师周课时」横向条形图数据从t_schedule按classroom_id、teacher_id分组统计。答辩 PPT 里放这两张图比放十页代码有用得多。改造点改动文件数难度答辩可讲性选课时间窗口4实体、SQL、Service、Vue低中自动推荐教室2Service、Mapper XML中高统计图表扩展3Controller、Mapper、Vue低高改完之后一定要把init.sql同步更新新增的字段、新增的测试数据都补进去。我见过太多人代码改了但脚本没改换台机器重新部署直接起不来答辩现场重装环境那场面相当难受。从那以后我每次改表结构都强制走一遍「删库 → 跑 init.sql → 从头点一遍功能」的流程宁可多花二十分钟也不要在关键时候赌运气。希望帮到你。本文还有配套的精品资源点击获取