☰
SpringBoot+Vue选课排课系统:并发选课与排课冲突检测实践
2026/11/5 8:04:21 网站建设 项目流程

简介:面向Java毕业设计的选课与排课管理系统,采用SpringBoot后端与Vue.js前端的前后端分离结构,配套完整源码、SQL脚本、说明文档和演示PPT,适合需要完成课程设计、毕业设计或希望练习SpringBoot+Vue全栈开发的学生与初中级开发者。系统围绕用户管理、课程管理、学生管理、排课管理和数据统计展开,涉及JWT身份验证、MyBatis或JPA数据访问、MySQL建表与初始化数据、Vuex状态管理及Axios接口请求等常见技术点,也覆盖课程时段与教室安排冲突检测、选课人数与课程热门度统计等业务场景,可用于理解模块划分、数据库设计与前后端联调流程。压缩包约17.55MB,文件总数与类型明细上游暂未提供,但从资源说明可知包含源码、SQL脚本、文档与PPT等主要材料。已有561人学习,文档可用于梳理架构设计与功能实现,PPT便于答辩汇报,源码与脚本则能作为二次开发和功能扩展的起点。

1. 从一张撞车的排课表说起:这套 SpringBoot+Vue 选课排课系统解决什么

教务排课最怕的不是课多,是两门课被塞进同一间教室、同一个老师被排到两个班;学生端更刺激,选课页刷新三下,名额从 0 变成 -3,退课还要人工对账。这套 SpringBoot+Vue 选课排课管理系统就是冲着这两件事来的:管理员端管课程、教学班、教室、时间片并排课,教师端看自己的授课任务和学生名单,学生端抢课、退课、查课表,中间用 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true 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 # 调试期打开,能看到实际 SQL

serverTimezone必须写,不写的话 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 Result<Void> 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 id="increaseSelected"> 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] 重叠的条件是 s1<=e2 && e1>=s2 // 教师维度 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 id="countRoomConflict" resultType="int"> 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 test="excludeId != null"> AND id != #{excludeId} </if> </select>

section_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 id="listByStudent" resultType="com.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 IPage<TeachingClassVO> pageClasses(ClassQuery query) { Page<TeachingClassVO> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<TeachingClass> 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?pageNum=1&pageSize=5 # 返回 {"code":200,"msg":"ok","data":{...}} 说明数据库和 MyBatis 都通了

5.3 前端启动与联调

cd schedule-web npm install --registry=https://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 兼容。

坑二:前端所有接口 404,Network 里请求地址是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 test="excludeId != null"> AND id != #{excludeId} </if>,并且前端编辑时把当前记录 id 传过去。这类问题属于「只测新增不测编辑」的典型漏测,测试用例里必须补一条。

坑五:课表查出来时间是空的,或者日期显示成一串数字

现象是ScheduleVO里selectTime字段返回[2024,9,2,8,0]这种数组。原因是实体用了LocalDateTime,Jackson 默认按 ISO 格式序列化。两种解法:字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),或者在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)低中
自动推荐教室2(Service、Mapper XML)中高
统计图表扩展3(Controller、Mapper、Vue)低高

改完之后一定要把init.sql同步更新,新增的字段、新增的测试数据都补进去。我见过太多人代码改了但脚本没改,换台机器重新部署直接起不来,答辩现场重装环境,那场面相当难受。从那以后我每次改表结构,都强制走一遍「删库 → 跑 init.sql → 从头点一遍功能」的流程,宁可多花二十分钟,也不要在关键时候赌运气。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询