Vue+SpringBoot+MySQL高校学生管理系统:表结构、并发控制与状态机实战

Vue+SpringBoot+MySQL高校学生管理系统:表结构、并发控制与状态机实战 简介一套面向高校学生课程设计与毕业设计场景的完整管理系统源码基于Vue、SpringBoot与MySQL实现覆盖学院课程管理、学生选课、课程补考等核心模块适合计算机及相关专业同学进行项目参考或二次开发。资源包共383个文件主要包括174个Java编译类、62个前端JS、53个样式CSS、数据库SQL脚本、可执行启动脚本及说明材料整体大小仅6.79MB便于下载与部署。内置启动/停止脚本、数据表结构、实体类及增改页面模板从数据库到前端均有覆盖目录结构完整便于按模块阅读和定位。目前已有119人学习下载适合作为课设/毕设项目从搭建到演示的实操参考。1. 高校学生管理系统最容易被低估的是数据关系而不是页面数量这类“基于VueSpringBootMySQL的高校学生管理系统”在毕业设计和校园信息化项目里出现频率极高。技术栈看起来没门槛但真正动手时问题几乎都集中在三件事学院课程模块里课程到底挂学院还是挂教师、学生选课模块里并发抢课怎么防重复、课程补考模块里成绩审核状态怎么流转。本文按最常用的工程化方式把三张核心表、两个状态机、一条事务边界一次讲透直接给表结构、事务代码和查询 SQL。适合已经能写 CRUD、想把这个题目做成可运行可答辩项目的同学和一线工程师参考。2. 学院课程模块的表结构学院、专业、课程三级数据怎么建模2.1 先定主数据学院、专业与学生是层级归属关系动手之前最先要定的不是页面是学院的组织模型。常见做法是拆三张表college 学院表、major 专业表、student 学生表。专业表通过 college_id 关联学院学生表通过 major_id 关联专业。这里有一个容易被忽视的错误把学院名称直接冗余在学生表里导致统计学院人数时要先 distinct 字符串改学院名还得批量更新学生数据。物理外键和逻辑外键的取舍我的建议是学院、专业这种几乎不删除的字典表用物理外键靠数据库保证一致性学生表这种频繁变动的业务表别加物理外键用逻辑关联加索引避免导入数据和逻辑删除时被约束卡住。下面是三张基础表的 DDL。CREATE TABLE college ( id BIGINT AUTO_INCREMENT PRIMARY KEY, college_code VARCHAR(20) NOT NULL UNIQUE COMMENT 学院编码如 CS01, college_name VARCHAR(100) NOT NULL COMMENT 学院名称, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学院表; CREATE TABLE major ( id BIGINT AUTO_INCREMENT PRIMARY KEY, major_code VARCHAR(20) NOT NULL UNIQUE COMMENT 专业编码, major_name VARCHAR(100) NOT NULL, college_id BIGINT NOT NULL COMMENT 所属学院, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_major_college FOREIGN KEY (college_id) REFERENCES college (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT专业表; CREATE TABLE student ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号登录账号一般也用这个, student_name VARCHAR(50) NOT NULL, major_id BIGINT NOT NULL COMMENT 所属专业逻辑外键, enroll_year VARCHAR(4) NOT NULL COMMENT 入学年份, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在读 0休学 2毕业, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_student_major (major_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生表;提示字符集一定要用 utf8mb4 而不是 utf8。MySQL 的 utf8 是历史遗留写法实际最多存 3 字节遇到生僻字或 emoji 会报错或变成问号。MySQL 8.0 排序规则用默认的 utf8mb4_0900_ai_ci5.7 兼容项目写 utf8mb4_general_ci 即可。2.2 课程表与开课计划表学期、教师、容量不能混在一张表里学院课程模块里最容易返工的设计是把 semester学期字段直接放在课程表上。同一个课程下学期还要开就得插入第二条 course 记录course_code 的唯一性被破坏学生历史上选课记录指向的课程也会跟着变。更规范的做法是把课程主数据和开课计划拆成两张表course 表保存课程编码、名称、学分、开课学院这些长期不变的信息course_offering 表保存某个学期实际开设的班次包含学期、任课教师、容量、已选人数、教学周次。CREATE TABLE course ( id BIGINT AUTO_INCREMENT PRIMARY KEY, course_code VARCHAR(20) NOT NULL UNIQUE COMMENT 课程编码长期唯一, course_name VARCHAR(100) NOT NULL, credits DECIMAL(3,1) NOT NULL COMMENT 学分如 3.0, college_id BIGINT NOT NULL COMMENT 开课学院逻辑外键, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_course_college (college_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表; CREATE TABLE course_offering ( id BIGINT AUTO_INCREMENT PRIMARY KEY, course_id BIGINT NOT NULL COMMENT 关联课程主数据, semester VARCHAR(20) NOT NULL COMMENT 学期如 2025-2026-1, teacher_id BIGINT COMMENT 任课教师关联用户表, capacity INT NOT NULL DEFAULT 60 COMMENT 最大选课人数, selected_count INT NOT NULL DEFAULT 0 COMMENT 当前已选人数, begin_week TINYINT DEFAULT 1 COMMENT 起始教学周, end_week TINYINT DEFAULT 16 COMMENT 结束教学周, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_course_semester (course_id, semester), KEY idx_offering_teacher (teacher_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT开课计划表;这里几个字段直接决定后面的选课模块怎么实现重点说两个UNIQUE KEY uk_course_semester (course_id, semester)限定同一门课同一个学期只能有一个开课班次。如果学校允许一门课拆成多个教学班唯一键要改成 (course_id, semester, class_no)。selected_count 和 capacity 以冗余字段的方式存在目的是让选课时可以用一条 UPDATE 原子地把计数加一避免先 SELECT 再 UPDATE 带来的并发竞态。teacher_id 不建物理外键因为教师通常来自用户表或人事导入接口跨系统同步时物理外键很容易失败。前端展示教师姓名时通过 JOIN 查用户表即可。2.3 课程列表与统计的三条常用 SQL学院课程模块的接口基本对应三个页面课程管理、开课计划、学院开课统计。下面三条 SQL 覆盖了最常见的分页和统计需求-- 开课计划列表带出课程名、学分、开课学院和教师姓名 SELECT o.id, o.semester, o.capacity, o.selected_count, c.course_code, c.course_name, c.credits, col.college_name AS owner_college_name, u.real_name AS teacher_name FROM course_offering o JOIN course c ON o.course_id c.id LEFT JOIN college col ON c.college_id col.id LEFT JOIN user u ON o.teacher_id u.id WHERE o.semester 2025-2026-1 ORDER BY c.course_code; -- 各学院本学期开课数量统计 SELECT col.college_name, COUNT(o.id) AS offering_count FROM course_offering o JOIN course c ON o.course_id c.id JOIN college col ON c.college_id col.id WHERE o.semester 2025-2026-1 GROUP BY col.id, col.college_name ORDER BY offering_count DESC; -- 学生端可选的课程列表只显示有余量 SELECT o.id, c.course_name, c.credits, u.real_name AS teacher_name, (o.capacity - o.selected_count) AS remain_count FROM course_offering o JOIN course c ON o.course_id c.id LEFT JOIN user u ON o.teacher_id u.id WHERE o.semester 2025-2026-1 AND o.selected_count o.capacity;参数说明第一个语句里的o、c、col、u是表别名多表 JOIN 时必须写否则 SQL 里两个表都有 id 字段时会产生歧义。第三个语句把selected_count capacity放进 WHERE让 MySQL 在存储引擎层过滤掉满员课程不用等数据返回应用层再判断。remain_count直接作为前端“剩余名额”标签的取值。3. 学生选课模块的并发控制唯一索引、行锁与事务边界3.1 选课业务的三层校验规则学生端看到一个“选课”按钮后端实际要做三层校验资格校验学生是否存在、是否在读、业务校验容量是否满、时间是否冲突、重复校验是否已经选过。任何一层不满足都要抛业务异常不能等到 MySQL 报错变成 500。SpringBoot 里我会定义一个 BizException配合 RestControllerAdvice 统一处理。Service 层只负责业务逻辑不写 try-catch 包全部异常。Transactional(rollbackFor Exception.class) public void selectCourse(Long studentId, Long offeringId) { // 校验一学生状态 Student student studentMapper.selectById(studentId); if (student null || student.getStatus() ! 1) { throw new BizException(学生不存在或不在在读状态); } // 校验二开课计划存在且容量未满 CourseOffering offering courseOfferingMapper.selectByIdForUpdate(offeringId); if (offering null) { throw new BizException(开课计划不存在); } if (offering.getSelectedCount() offering.getCapacity()) { throw new BizException(课程名额已满); } // 校验三放在插入步骤处理见 3.2 ... }这里最关键的是selectByIdForUpdate它用 SELECT ... FOR UPDATE 对 course_offering 这行加排他锁。两个学生同时选同一门课时第二个事务会等第一个提交后才读到最新容量直接从源头避免超卖。3.2 防重复选课唯一索引比代码判断可靠得多“先查有没有选过再插入”这种判断在并发下不可靠两个请求同时执行查询都发现没有记录然后都插入成功。要治本只能靠数据库约束。选课表 student_course 的最简 DDL 如下CREATE TABLE student_course ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL, offering_id BIGINT NOT NULL, semester VARCHAR(20) NOT NULL COMMENT 冗余学期便于按学期查选课单, score DECIMAL(5,2) COMMENT 正常考试成绩null 表示未出分, status TINYINT NOT NULL DEFAULT 1 COMMENT 1有效选课 0退选, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_offering (student_id, offering_id), KEY idx_sc_student_semester (student_id, semester) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生选课表;唯一索引 (student_id, offering_id) 保证同一个学生对同一个开课计划永远不可能插入第二条记录。并发插入时后提交的一方会触发 DuplicateKeyException在 Service 里 catch 住转成友好提示try { studentCourseMapper.insert(sc); } catch (DuplicateKeyException e) { throw new BizException(你已经选过这门课程请勿重复操作); }注意退选逻辑这里有一个坑如果退选时把 status 更新为 0 而不删记录唯一索引会把重新选课挡住。最简做法是退选直接 DELETE彻底移除记录如果业务要求保留历史选课轨迹就需要把唯一索引改成 (student_id, offering_id, status) 或者在表里增加选课批次字段二选一不要两者都想省。3.3 事务边界插入选课和更新容量必须在同一个事务里选课至少包含两步写操作student_course 插一条记录course_offering 的 selected_count 加一。两步必须放进同一个 Transactional 方法否则插入成功但计数没更页面剩余名额就错了。Mapper 用原生注解写两条 SQL 即可public interface CourseOfferingMapper extends BaseMapperCourseOffering { Select(SELECT * FROM course_offering WHERE id #{id} FOR UPDATE) CourseOffering selectByIdForUpdate(Long id); Update(UPDATE course_offering SET selected_count selected_count 1 WHERE id #{id} AND selected_count capacity) int increaseSelectedCount(Long id); }参数说明FOR UPDATE是当前读整个事务提交前锁住该行避免读到未提交数据selected_count capacity这个条件写不写都不影响行锁串行后的结果但写上可以给容量判断多一层保护。如果你的系统并发量很高也可以放弃 FOR UPDATE直接靠这条条件 UPDATE 的影响行数判断是否超员性能更好但业务里就拿不到“当前剩余人数”用于展示。Spring Boot 版本这里顺带提一下3.x 之后所有 javax 前缀都改成了 jakarta导入依赖时注意区别2.7.x 到 3.2.x 都能跑通本项目没必要追最新版。3.4 Vue 前端选课页路由参数传递与接口对接前端最常见的错误是把课程 ID 塞在 DOM 自定义属性里再通过事件对象去取。正确做法是用 Vue Router 传参router.push({ name: CourseDetail, params: { offeringId: row.id }, query: { semester: row.semester } });详情页通过 useRoute() 取参数const route useRoute(); const offeringId route.params.offeringId; const semester route.query.semester;params 传参刷新页面后会丢失所以 semester 放进 queryofferingId 放在 params 是常见约定。如果项目要求链接可分享把两个参数都放进 query 最稳妥。选课按钮的 loading 状态要独立控制防止用户连点提交两次。4. 课程补考模块的状态流转与成绩复核一张表控制整个流程4.1 补考资格由成绩触发不让学生手动申请补考模块第一个要解决的问题不是状态怎么转是补考记录从哪来。常见做法成绩管理接口更新 student_course.score 时判断成绩是否小于 60如果小于 60 就自动生成一条补考记录。补考资格是系统算出来的不需要学生先去“申请补考资格”。补考记录表和选课表是一对一关系DDL 如下CREATE TABLE retake_exam ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_course_id BIGINT NOT NULL COMMENT 关联选课记录, student_id BIGINT NOT NULL, offering_id BIGINT NOT NULL, course_name VARCHAR(100) NOT NULL COMMENT 冗余课程名列表免 JOIN, apply_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未申请 1待审核 2已通过 3已驳回, exam_date DATETIME COMMENT 补考时间审核通过后安排, retake_score DECIMAL(5,2) COMMENT 补考卷面分, review_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未复核 1复核中 2已定稿, review_comment VARCHAR(255) COMMENT 复核意见, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_retake_sc (student_course_id), KEY idx_retake_student (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT补考记录表;student_course_id加唯一索引保证一门课只产生一条补考记录。course_name是冗余字段列表页不用每次 JOIN 课程表“读多写少”的业务里这种冗余很值得。4.2 状态机落地申请、审核、排考、录分、复核五个节点补考流程在业务上是五个状态节点学生发起补考申请、教务处审核、安排补考时间、教师录入补考成绩、学生成绩复核。用字段表达状态比建五张表简单但状态流转必须在后端校验。审核接口的 Service 写法Transactional(rollbackFor Exception.class) public void approveRetake(Long retakeId, Long approverId) { RetakeExam exam retakeExamMapper.selectByIdForUpdate(retakeId); if (exam null) { throw new BizException(补考记录不存在); } if (exam.getApplyStatus() ! 1) { throw new BizException(该记录当前状态不可审核请刷新后重试); } RetakeExam update new RetakeExam(); update.setId(retakeId); update.setApplyStatus(2); update.setReviewerId(approverId); update.setReviewTime(LocalDateTime.now()); retakeExamMapper.updateById(update); }这段代码两个要点状态值 1 表示待审核更新到 2 之前必须确认当前值等于 1否则前置动作被打断比如已经驳回的申请又被重复处理selectByIdForUpdate锁住这行避免两个人同时审核同一条记录。如果你不想用 FOR UPDATE可以把更新 SQL 写成UPDATE ... SET apply_status 2 WHERE id ? AND apply_status 1通过影响行数判断是否被并发抢先。4.3 补考成绩与原始成绩合并GREATEST 而不是覆盖补考成绩录入后成绩单里的最终成绩按“取原始成绩和补考成绩中的较大值”处理但原始成绩字段不能动。查询时用 SQL 直接算出来SELECT c.course_code, c.course_name, sc.score AS original_score, re.retake_score AS retake_score, CASE WHEN re.id IS NULL THEN sc.score WHEN re.retake_score IS NULL THEN sc.score ELSE GREATEST(sc.score, re.retake_score) END AS final_score FROM student_course sc JOIN course_offering o ON sc.offering_id o.id JOIN course c ON o.course_id c.id LEFT JOIN retake_exam re ON re.student_course_id sc.id WHERE sc.student_id #{studentId} AND sc.status 1 AND sc.semester #{semester};必须用 LEFT JOIN 而不是 INNER JOIN因为部分课程没有补考记录LEFT JOIN 才能让这些行的 final_score 直接等于原始成绩。GREATEST 是 MySQL 内置函数两个入参中哪个大返回哪个省掉在 Java 里写 if-else 判断。4.4 成绩复核怎么防止覆盖乐观锁 version 字段成绩复核涉及多次修改 retake_score最怕两个人同时复核后写的人把先写的结果覆盖掉。给 retake_exam 加 version 字段用乐观锁解决UPDATE retake_exam SET retake_score #{newScore}, review_status 2, review_comment #{comment}, version version 1 WHERE id #{id} AND version #{oldVersion};前端详情接口返回 version 值提交复核结果时原样传回。UPDATE 影响行数为 0 说明期间已经有人改过此时抛“成绩已被他人修改请刷新后重试”。这个细节在验收演示时很加分因为评审老师经常会问“如果录错了成绩谁来负责、怎么追溯”。如果你更愿意用 MySQL 存储过程封装这段更新加日志表的逻辑也不是不行但后续状态节点一变调试存储过程比调试 Service 层断点麻烦得多项目里我一般还是把事务交给 SpringBoot 管。5. 收尾三个必查点索引是否命中、时间格式、Vue 打包路由5.1 用 EXPLAIN 验证选课查询真的走了索引写完所有接口别急着打包。先在 MySQL 里跑一条EXPLAIN SELECT * FROM student_course WHERE student_id 1 AND semester 2025-2026-1;看 type 列如果是 ALL说明全表扫描如果是 ref说明联合索引 idx_sc_student_semester 生效。如果没走索引优先检查 WHERE 条件里是否对字段套了函数再看两表关联字段的字符集排序规则是否一致。这两个原因占了索引失效问题的八成。5.2 后端时间字段与 Vue 的格式契约SpringBoot 默认把 LocalDateTime 序列化成数组前端拿到没法直接用。在 application.yml 里固定全局格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8前端 axios 响应拦截器里把 code、message、data 拆开分页接口固定返回 { total, records } 结构不要每个接口各写一套字段名。Vue 项目里我习惯给行内时间字段统一 filter展示层格式和后端约定解耦。5.3 Vue 打包后路由刷新 404 和布局异常开发环境正常、部署后出问题九成是 Vue Router 的 mode 导致的。createWebHistory 模式下 Nginx 没配置回退刷新页面就直接 404createWebHashHistory 虽然 URL 带个 #但任何静态服务器部署都不会 404。如果用了 createWebHistoryNginx 必须加location / { try_files $uri $uri/ /index.html; }另外vue 打包后布局异常这类问题先检查是不是 publicPath 配置成了绝对路径打包后的 index.html 引用资源以 / 开头部署在子路径下就全部 404改成相对路径./或者按部署路径配置即可。最后用npm run build产物在本地起个静态服务验证一遍再交到服务器上能省掉大量联调时间。本文还有配套的精品资源点击获取