☰
Spring Boot学生选课系统设计与实现:从数据库到业务闭环
2026/10/14 5:27:12 网站建设 项目流程

1. 选课业务在高校的真实运转逻辑:为什么需要一个系统

每年到了选课季,高校教务处的电话基本就没停过。学生问"为什么这门课点进去就显示已满",教师问"为什么我申请调个时间系统里还是旧的",教务老师自己对着Excel表格手动核对名单,一核对就是好几天。作为一个做过不止一套教务相关系统的开发者,我很清楚这事儿的核心矛盾:选课业务看起来只是简单的"学生选课程",实际牵扯的是时间切片、容量控制、成绩归属、院系配额、教室资源等一系列约束条件,人工处理根本兜不住。

这套基于 Spring Boot 的学生选课系统,把整个流程压缩成三端协作:学生端登录后完成选课、退课、查看课表;教师端负责开设课程、维护课程信息、查看选课名单;管理员端统一编排课程时间、管控容量、发布选课窗口。核心解决的问题就一个——把"选课"这个高并发、强约束的业务从线下搬进系统,用数据库保证数据一致,用代码保证业务规则不被人为绕过。

适合谁来参考?我建议这样的人群重点看:正在做课程设计或毕业设计的学生,想快速理解 Spring Boot 完整业务闭环的初级开发者,以及需要给学校做一套内部教学管理 Demo 的技术人员。这套系统的价值不在技术多前沿,而在于它把真实业务里的边界条件和异常场景都覆盖住了,非常适合用来练手和二次扩展。

1.1 三个角色的诉求各不相同

选课系统的用户不是铁板一块,拆开看是三拨人,他们的诉求甚至有些是冲突的。

  • 学生:只关心"这门课还有没有名额、时间是否和已选课程冲突、能不能顺利退掉再换一门"。对系统的稳定性和响应速度有天然的高要求,尤其是热门课放出的前几分钟。
  • 教师:关心"我的课被多少人选了、名单在哪、学生是不是符合先修条件、如何调整课程信息"。教师端的操作频次低,但权限边界要清楚。
  • 管理员(教务):最关心的是"排课是否撞车、每个专业学生的必修学分是否覆盖、选课数据能否导出核对、结课后成绩能否正确归档"。管理员操作全是批量化和审批型需求。

做系统设计时,如果只盯着学生的选课动作,忽略教师端和管理员端,那就是把一个场景做成了半成品。我在实际开发中明确了一个原则:学生端做减法,教师端做配置,管理员端做控制。

1.2 人工时代常见的三个失控场景

我做这个项目前,专门听某高校教务处的朋友讲过他们以前靠 Excel 工作的日子,典型失控场景有三个:

  1. 时间撞车靠肉眼识别:同一时间段的课程没有统一校验手段,学生选完了才发现周一上午同时有两门必修课,最后只能手动调课,极容易漏。
  2. 容量超员靠手速:课程容量 60 人的限额是写在纸上的,学生超过后没有拦截机制,只能靠老师回头筛人,迟选的学生被莫名清退,体验极差。
  3. 成绩归属混乱:学生中途换课之后,纸质记录和电子名单对不上,期末登成绩的时候就会出现"人不在名单里、成绩却要录进去"的乌龙。

这些问题的共性在于"缺少一个集中的数据约束点"。数据库里的关联关系、唯一索引、事务控制,本质上就是把这些几乎靠自觉的规则变成系统强制执行的逻辑。

1.3 系统的核心任务清单

对应上面的痛点,这个系统必须完成以下核心任务:

  • 学生信息、教师信息、课程信息的统一维护;
  • 选课、退课、改选的业务闭环;
  • 选课冲突检测(时间冲突 + 容量冲突);
  • 课程名单查询、导出;
  • 管理员视角的数据统计与课程上下架控制。

把这些任务落到 Spring Boot 的工程结构里,就是标准的 MVC 分层。业务不复杂,但每一层都必须职责清晰,否则后期照样改不动。

2. 技术栈选型:Spring Boot 为什么是这类项目的稳妥答案

如果你问我"学生选课系统用什么技术栈最稳",我第一反应就是 Spring Boot。不是因为它时髦,而是因为它在当前的 Java 生态里几乎是"教育类项目的最优解":入门文档多、社区问答多、面试官认可度高、部署方式简单。

2.1 为什么是 Spring Boot,而不是 SSH 或 SSM

早几年的选课系统经常用 SSH(Struts + Spring + Hibernate)或 SSM(Spring + SpringMVC + MyBatis)。这两套东西不是不能做,但问题非常统一:配置地狱。一个 SSM 项目跑起来,需要手写大量 XML 配置,数据源、事务、扫描包、视图解析器一堆东西,每一样都可能因为写错一个标签导致启动失败。对于以"完成业务功能"为首要目标的场景来说,这完全是在浪费精力。

Spring Boot 的核心价值是"约定优于配置"。内嵌 Tomcat、自动装配、统一依赖管理,这些特性让开发者不用再折腾环境,直接专注写业务代码。如果你正在纠结技术选型,我建议直接记一句话:

选型不是选"最流行的",而是选"能让你最快把业务跑通,且后续有路可退的"。Spring Boot 恰好就是这样——它屏蔽了环境复杂度,保留了 Java 语言的严谨性,也让项目可以轻松改造为分布式微服务。

2.2 持久层选型:MyBatis 还是 JPA

我见过不少基于 Spring Boot 的选课系统使用 JPA/Hibernate,理由是"省事"。但老实讲,对于选课这种涉及关联查询、统计分组、锁控制的业务,我更推荐 MyBatis。原因有三个:

  • SQL 可控性强:选课系统的核心查询是"查学生已选课程的时间段"和"查课程剩余容量",这类关联查询用 SQL 写出来逻辑非常清楚。MyBatis 直接写 SQL,执行计划一眼能看懂,出了问题好排查。
  • 事务边界明确:MyBatis 与 Spring 事务的整合很清晰,@Transactional注解一加,代码层级不混乱。
  • 面试/答辩友好:绝大部分课程设计项目都要求体现 SQL 能力,MyBatis 的 XML 文件天然展示了你对 SQL 的掌握。

JPA 也不是不能用,如果你对 Hibernate 的缓存机制、懒加载坑位非常熟,它能减少大量重复代码。但对一个选课系统来说,可控性优先于开发效率,所以我建议:

对比项MyBatisJPA/Hibernate
SQL 掌控度高,完全手写低,由框架生成
复杂关联查询顺手需要调优
学习成本低中偏高
适合场景业务规则清晰的系统快速 CRUD 原型

2.3 前端方案的务实处理

很多同学拿到这个项目会纠结:前端要不要用 Vue?要不要做前后端分离?我劝你先降降火。课程设计、毕业设计以及内部管理系统,Bootstrap + Thymeleaf + jQuery的组合其实更务实:

  • 不需要额外启动前端服务,打成一个 JAR 包即可运行,答辩演示零负担;
  • Spring Boot 的 Thymeleaf 可以直接渲染数据,省去跨域和接口联调的琐碎问题;
  • Bootstrap 的组件足够撑起一个干净的界面,不会因为样式问题扣分。

如果你确实想体现前后端分离的能力,作为二次开发方向完全可以加一个 Vue3 前端,后端提供 RESTful API。但第一版我强烈建议用模板渲染,先把业务闭环打通,再谈视觉升级。

2.4 工程结构分包怎么设计

分包这事看着老生常谈,实际直接决定后期改需求时你会不会抓狂。我采用的包结构非常简单清晰:

com.example.course ├── controller # 控制层:接口入口 ├── service # 业务层:核心逻辑、事务控制 │ └── impl ├── mapper # 持久层:MyBatis Mapper接口 ├── entity # 实体类:表结构映射 ├── vo # 视图对象:页面数据封装 ├── config # 配置类:拦截器、CORS等 ├── common # 通用工具、统一返回结果 └── CourseApplication.java # 启动类

这么分的理由很简单:controller 不写业务代码,mapper 不写业务代码,service 只干一件事。选课这种涉及多表联动的操作,如果业务逻辑散落在 controller 里,后面加一个"选课窗口期判断"你都得分三处改。

3. 数据库设计:选课系统最关键的若干张表与表关系边界

数据库设计是整个选课系统最值得抠细节的地方。业务逻辑再花哨,表设计翻车一切白搭。我直接按照实际项目里的表结构来讲,每一张表都是经过业务验证的。

3.1 最核心的三张主表:学生、教师、课程

学生表、教师表、课程表是基础元数据。设计要点是"信息完整但不冗余"。以下是我实际用的表结构的核心字段:

-- 学生表 CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', password VARCHAR(100) NOT NULL COMMENT '密码(MD5)', name VARCHAR(50) NOT NULL COMMENT '姓名', gender TINYINT DEFAULT 0 COMMENT '性别 0未知 1男 2女', major VARCHAR(100) COMMENT '专业', class_name VARCHAR(50) COMMENT '班级', phone VARCHAR(20) COMMENT '联系方式', status TINYINT DEFAULT 1 COMMENT '状态 1正常 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT='学生表';

教师表类似,多一个title职称字段。课程表则需要重点展开:

CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE COMMENT '课程编号', course_name VARCHAR(100) NOT NULL COMMENT '课程名称', teacher_id BIGINT NOT NULL COMMENT '授课教师ID', credit DECIMAL(3,1) DEFAULT 2.0 COMMENT '学分', course_time VARCHAR(100) NOT NULL COMMENT '上课时间,如 周一-1-2节', course_place VARCHAR(100) COMMENT '上课地点', capacity INT DEFAULT 60 COMMENT '容量上限', selected_count INT DEFAULT 0 COMMENT '已选人数', status TINYINT DEFAULT 1 COMMENT '1可选 0不可选', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT='课程表';

这里有三个细节要说透:

第一,course_time字段我用的是字符串存储,比如"周一-1-2节"。可能有同学问,为什么不拆成星期几、第几节课两个字段单独存?拆成独立字段在查询时更灵活,比如"查所有周一上午的课",可以直接用 SQL 条件筛选。但我用字符串的理由是:选课系统的课程表展示、冲突检测逻辑都需要完整的时间短语,拆开会增加表关联和维护成本。实际编码里用LIKE匹配就能做简单过滤,冲突检测的精细逻辑放在 Service 层处理。

第二,selected_count是一个典型的冗余字段。它存储的是从选课明细表实时COUNT(*)出来的结果。为什么冗余?因为在"查所有可选课程"的列表中,如果每次都对选课明细表做COUNT(*)聚合,数据量稍大就会出现明显的性能瓶颈。用一个冗余字段配合事务更新,能实现 O(1) 的容量查询。

第三,course_time里预留了lesson_plan的位置,实际项目中建议增加weeks VARCHAR(100)字段,表示"第1-16周上课",方便处理那些只上 8 周就结课的短期课程。

3.2 选课关系表:业务的核心战场

选课关系表,我习惯叫student_course,是学生和课程的多对多关联表,也是整个系统事务控制和并发控制的重点:

CREATE TABLE student_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, selected_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', status TINYINT DEFAULT 1 COMMENT '1已选课 0已退课', score DECIMAL(4,1) DEFAULT NULL COMMENT '成绩' ) COMMENT='学生选课表';

这张表有三个极其重要的设计点:

唯一索引设计:必须给(student_id, course_id, status)加联合唯一索引吗?我的答案是——student_id和course_id联合唯一就够了,但要注意这是一个逻辑陷阱。如果只加联合唯一索引,学生会出现在退课后无法重新选同一门课的尴尬境地,因为记录还在表里。所以我的方案是:保留联合唯一索引uk_student_course (student_id, course_id),用status字段表示当前状态,退课不是物理删除而是逻辑删除。这样"退过课再重新选课"的场景可以直接 UPDATE 状态,不触碰唯一索引限制。

为什么要逻辑删除而不是物理 DELETE:教务系统需要保留完整的历史选课痕迹,包括学生的退课记录。如果物理删除,期末导出"某学生本学期选课轨迹"时你是做不出来的。另外,成绩录入时,即使学生已经退课,如果教师成绩库里还有数据,也能通过状态字段追踪。

为什么选课时间单独存:这个字段在"选课窗口期管理"和"数据统计分析"时非常有用,比如教务想看"第三轮选课放出的名额到底有多少人抢到了",查一下selected_time就行。

3.3 成绩与统计:别让一切变成死数据

表结构最后要处理的是成绩。我把成绩直接放进student_course表里,而不是单独建一张成绩表。原因很实在:选课系统和成绩管理在这个 Demo 里是紧耦合关系,学生选完课、上完课、教师录入成绩,这就是完整闭环。如果单独建成绩表,会多出student_course_id外键、成绩类型、录入人等字段,对课程设计项目来说复杂有余、价值不足。

做成成绩字段后,"查学生的成绩单"一条 SQL 就搞定:

SELECT c.course_name, c.course_no, c.credit, sc.score FROM student_course sc JOIN course c ON sc.course_id = c.id WHERE sc.student_id = #{studentId} AND sc.status = 1;

3.4 外键到底用不用

这是每个做数据库设计的人都会纠结的问题。我的建议是:逻辑外键优先,物理外键慎用。

物理外键在插入、删除时会自动触发一致性校验,看着很安全,实际在大批量操作时会拖慢性能,并且在业务层做复杂事务时会成为绊脚石。选课系统里,学生表的主键、课程表的主键在选课表里出现,我们完全可以在 Service 层做存在性校验,然后依赖程序的事务保证一致性。物理外键留到论文里讲概念时再提,实际编码中不建。

4. 核心功能落地:选课、退课、冲突检测的代码实现

表结构设计完了,接下来是重头戏——把业务逻辑落到代码里。这一部分也是大家在答辩时最容易露怯的地方。我按业务动作拆解,每个动作都给出完整的实现思路。

4.1 选课接口的业务闭环

选课不是一个"INSERT 一条记录"那么简单,它背后是一整套完整性约束。标准的选课流程包含以下步骤:

  1. 校验学生账号状态;
  2. 校验课程状态(是否可被选、选课窗口是否开放);
  3. 校验课程容量(已选人数是否小于容量);
  4. 校验时间冲突(是否与已选课程的上课时间重叠);
  5. 校验重复选课(是否已经在这门课的名单里);
  6. 插入选课记录(状态为1);
  7. 更新课程已选人数(+1)。

这 7 步缺一不可,且必须在一个事务里完成。我给出的核心实现(关键代码):

@Override @Transactional(rollbackFor = Exception.class) public Result selectCourse(SelectCourseDTO dto) { Long studentId = dto.getStudentId(); Long courseId = dto.getCourseId(); // 1. 查询课程,使用行锁防止超选 Course course = courseMapper.selectByIdForUpdate(courseId); if (course == null) { return Result.error("课程不存在"); } if (course.getStatus() == 0) { return Result.error("该课程当前不可选"); } // 2. 容量校验 if (course.getSelectedCount() >= course.getCapacity()) { return Result.error("课程已满员"); } // 3. 重复选课校验 StudentCourse exist = studentCourseMapper.selectByStudentAndCourse(studentId, courseId); if (exist != null && exist.getStatus() == 1) { return Result.error("您已选过这门课程"); } // 4. 冲突校验:查学生已选的有效课程 List<Course> selectedCourses = courseMapper.selectSelectedCoursesByStudent(studentId); for (Course selected : selectedCourses) { if (TimeUtil.isConflict(course.getCourseTime(), selected.getCourseTime())) { return Result.error("课程时间冲突:" + course.getCourseName() + " 与 " + selected.getCourseName() + " 的上课时间重叠"); } } // 5. 执行选课:如果之前退过课,则恢复状态;否则新增 if (exist != null && exist.getStatus() == 0) { exist.setStatus(1); exist.setSelectedTime(new Date()); studentCourseMapper.updateById(exist); } else { StudentCourse sc = new StudentCourse(); sc.setStudentId(studentId); sc.setCourseId(courseId); sc.setStatus(1); sc.setSelectedTime(new Date()); studentCourseMapper.insert(sc); } // 6. 更新课程已选人数 course.setSelectedCount(course.getSelectedCount() + 1); courseMapper.updateById(course); return Result.success("选课成功"); }

代码逻辑是清晰的,但你可能会问:selectByIdForUpdate是什么意思?这是 MySQL 的行级锁,SELECT ... FOR UPDATE。这一步是整个选课系统防"超选"的命门。

4.2 并发选课的超选问题:事务和锁缺一不可

现在我要重点讲一个特别容易翻车的点:并发超选。

假设课程容量是 60 人,目前已经选了 59 人。同时有 3 个学生发起选课请求:

  • 没有锁的情况下,3 个请求都读到selected_count = 59,都认为小于 60,全部通过校验并 INSERT,结果课程变成 62 人,超员了。
  • 加了SELECT ... FOR UPDATE之后,第一个请求锁住课程行,第二个、第三个请求必须等待第一个请求提交事务后才读取数据。此时读到的已经是selected_count = 60,校验失败,返回"课程已满员"。

这就是为什么我在上面代码第一步就要用selectByIdForUpdate。如果没有这一步,只靠@Transactional是没用的——事务保证的是"操作要么全成功、要么全失败",但不保证并发读到的数据是最新的。

<select id="selectByIdForUpdate" resultType="com.example.course.entity.Course"> SELECT * FROM course WHERE id = #{id} FOR UPDATE </select>

注意:FOR UPDATE锁的是行,必须在事务内使用才有意义。所以选课方法上一定要标注@Transactional。

除了行锁,你还可以考虑乐观锁方案:在course表加一个version字段,每次更新时SET version = version + 1 WHERE version = #{version},根据影响行数判断是否被人抢先修改。这个方案适合读多写少的场景,但对于选课这种"高冲突"场景,悲观锁(行锁)更直观、更稳。

4.3 冲突检测怎么实现最省事

"这门课和我已选的时间是不是撞了"是选课系统最日常的校验。有没有必要用全排列的定时算法去精确比较?没必要。绝大多数高校的课程时间都可以用统一的时间片格式表示,我的做法是定义好时间段枚举,写一个时间串解析和比较的工具。

来看一个简单但实用的实现:

public class TimeUtil { /** * 检查两门课时间是否冲突 * 格式如:周一-1-2节 */ public static boolean isConflict(String time1, String time2) { // 简单实现:按 "周" 和 "节" 两个维度比较 // 支持 "周一-3-4节" 和 "周三-5-6节" 这类标准的周-节格式 String[] parts1 = time1.split("-"); String[] parts2 = time2.split("-"); String week1 = parts1[0]; String week2 = parts2[0]; if (!week1.equals(week2)) { return false; } // 提取节次范围 比如 "1-2" String range1 = parts1[1].split("节")[0]; String range2 = parts2[1].split("节")[0]; int start1 = Integer.parseInt(range1.split("-")[0]); int end1 = Integer.parseInt(range1.split("-")[1]); int start2 = Integer.parseInt(range2.split("-")[0]); int end2 = Integer.parseInt(range2.split("-")[1]); // 区间重叠判断 return start1 <= end2 && start2 <= end1; } }

这个代码处理了最常见的"星期几 + 起始结束节次"冲突判断。要不要支持单双周、第几周到第几周?可以做,但那属于锦上添花。第一版把星期和节次的冲突挡住了,就能覆盖 90% 的实际情况。

如果你追求更正规的方案,可以设计一张time_slot表,把每周的每个时间段作为一条记录存储,课程通过course_time_slot关联表与多个时间段关联。这样冲突检测可以用纯 SQL 的 JOIN 解决。但这样做的问题是配置工作量变大,教师开课时的操作成本也显著增加。对学生选课系统来说,用字符串解析反而是最务实的。

4.4 退课与状态流转

退课不是简单的 DELETE,设计原则是"可追溯"。退课业务相对简单,但在和"重新选课"耦合时有一个细节很容易被忽略——退课必须校验时间窗口。

我的做法是给课程增加一个选课窗口字段:start_time和end_time,在退课接口中首先判断当前时间是否在窗口内。过了选课窗口再想退,就需要走"退课申请"而不是直接调接口,这属于教务管理的进阶需求。

退课的核心逻辑:

@Override @Transactional(rollbackFor = Exception.class) public Result cancelCourse(CancelCourseDTO dto) { Long studentId = dto.getStudentId(); Long courseId = dto.getCourseId(); // 1. 验证当前时间是否在退课窗口内 Course course = courseMapper.selectById(courseId); Date now = new Date(); if (now.before(course.getStartTime()) || now.after(course.getEndTime())) { return Result.error("不在选课/退课窗口期内"); } // 2. 检查选课记录是否存在且有效 StudentCourse sc = studentCourseMapper.selectByStudentAndCourse(studentId, courseId); if (sc == null || sc.getStatus() == 0) { return Result.error("未找到有效的选课记录"); } // 3. 状态置为已退课 sc.setStatus(0); studentCourseMapper.updateById(sc); // 4. 已选人数减一 course.setSelectedCount(course.getSelectedCount() - 1); courseMapper.updateById(course); return Result.success("退课成功"); }

这里有个容易让人纠结的点:退课时要不要也加FOR UPDATE?理论上退课和选课可能在同一门课上并发发生。比如课程正好 60 人满员,一个学生要退,另一个正在选。如果不加锁,一个更新的同时删掉人数,一个读取的瞬间刚好看到 60 人,就会出现"退课的同时选课失败"的不公平结果。稳妥处理是在退课时也同样使用selectByIdForUpdate锁住课程行,保证两个操作串行化。

4.5 权限控制与登录会话

这个项目是教务团队使用,所以权限必须做,但不能做得太重。我的方案是在 Spring Boot 中实现一个简单的拦截器(HandlerInterceptor),基于 Session 判断登录状态和角色。角色用枚举定义:

public enum RoleType { STUDENT(1, "学生"), TEACHER(2, "教师"), ADMIN(3, "管理员"); private final int code; private final String desc; // getter / constructor }

登录后把用户ID和角色类型放进 Session,拦截器统一校验。选课的 Controller 只放行STUDENT角色,课程管理的 Controller 只放行TEACHER和ADMIN。

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); if (session == null || session.getAttribute("userId") == null) { response.sendRedirect("/login"); return false; } // 检查访问路径的角色要求 String uri = request.getRequestURI(); if (uri.startsWith("/teacher") && !RoleType.TEACHER.getCode().equals(session.getAttribute("role"))) { response.sendRedirect("/error/403"); return false; } return true; }

理论上可以引入 Spring Security,但对课程设计项目来说,Security 的过滤器链配置本身就是大坑,不如自己写一个 30 行的拦截器来得明白。答辩时老师问起安全框架,你能说出"自研拦截器 + Session 校验的取舍"反而是加分项。

5. 把项目跑起来并做二次开发:环境准备和经典坑位

拿到源码、数据库脚本和文档之后的第一个动作是什么?很多人上来就点启动,然后被各种异常劝退。我先给一份整理过的启动清单。

5.1 环境清单

JDK 1.8+ Maven 3.6+ MySQL 5.7+(8.0 也兼容,但注意驱动版本) IDEA 或 Eclipse

如果你的机器上 MySQL 是 8.0,application.yml里的数据库驱动要写成新版,同时需要在连接串上指定时区参数。

5.2 application.yml 的几个关键配置

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_system? useUnicode=true&characterEncoding=utf8& useSSL=false&serverTimezone=Asia/Shanghai& allowPublicKeyRetrieval=true username: root password: your_passwd thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.course.entity

这里有几个坑必须标注出来:

  • serverTimezone=Asia/Shanghai不加的话,数据库和 Java 的时间差 8 小时,登录和选课的时间戳全错位。
  • useSSL=false是为了避免 MySQL 8.0 的 SSL 握手警告刷屏。
  • allowPublicKeyRetrieval=true是 MySQL 8.0 的必选项,不加会出现连接被拒绝的诡异报错。
  • Thymeleaf 的cache: false必须开,否则你改完 HTML 页面不刷新,恨不得砸电脑。

5.3 数据库脚本的执行顺序

数据库脚本一般包含schema.sql建库建表和data.sql初始化数据。顺序不能乱:

mysql -u root -p source /path/to/schema.sql; source /path/to/data.sql;

导入完先验证表数量和数据条数,再启动项目。初始账号一般是:

  • 管理员:admin / admin123
  • 学生:看脚本注释里的初始化学号,一般是1001开头
  • 教师:T001 / teacher

如果初始账号没写在文档里,去data.sql里搜INSERT INTO student和INSERT INTO teacher一眼就能看到。

5.4 我踩过的三个经典坑

端口占用:默认 8080 经常被其他程序占用。如果启动日志报Port 8080 was already in use,用下面命令定位并处理:

# 找出占用8080端口的进程 netstat -ano | findstr 8080 # 或 Linux / Mac lsof -i:8080

JDBC 驱动版本不匹配:如果你用的是 MySQL 8.0,pom.xml 里的mysql-connector-java版本要 8.0 以上;如果机器是 MySQL 5.7 却用了 8.0 的驱动,控制台会疯狂刷Loading class com.mysql.jdbc.Driver警告。解决方案是统一版本:

<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>

数据库时区不对导致登录立刻失败:如果项目里用Token或者Session设置了超时时间,而数据库返回的时间早于当前时间,会导致会话判定过期。这个问题十次有八次是serverTimezone没配好。

5.5 二次开发建议

这部分是给想在这个项目上继续加分的同学看的。基础功能做完后,有三个低成本的扩展方向:

  1. 新增公告通知模块:教务处发布选课通知、老师发布课程调整信息。只需要新增一张notice表和对应的前端页面,技术难度低但业务真实性强。
  2. 引入 Spring Security + JWT 实现无状态鉴权:把原来的 Session 方案替换掉,顺便把角色权限模型做得更细(比如管理员只能管学生不能改课程)。
  3. 管理员端数据可视化:增加"每门课选课人数趋势"、"各院系选课热度排行"等统计图表,后端提供查询接口,前端用 ECharts 渲染。

这三个方向都能在两周内完成,且都有独立的展示价值,面试时挑一个展开讲,绝对比你只报一门课说"我做了CRUD"有说服力。

6. 做完这个项目之后,我对选课系统开发的三点体会

在这个项目收尾时,我复盘了一下整个过程,有几个认知层面的东西想分享。比起一张表、一段代码,这些东西更能帮你在后续项目和面试中稳住阵脚。

6.1 业务规则比技术本身更优先

选课系统技术点其实不难,难的是"业务规则是否梳理到位"。比如"退课后再选同一门课"这种边缘场景,没有经历过真实业务的人根本想不到。如果你也正在做这类系统,我建议在写第一行代码前,把业务流程画清楚,把每个分支的使用者身份、前置条件、失败提示全部列出来。我见过不少项目,技术上没问题,但学生选课时经常碰见"莫名其妙选不了"的反馈,就是因为规则只覆盖了主线,没覆盖边界。

6.2 用户体验的细节决定这个系统能不能真正投入使用

你可能觉得内部系统无所谓,但实际用起来完全是另一回事。选课页面要显示课程的剩余名额,学生才能判断要不要"抢占";选课成功后要有明显的成功反馈;冲突报错不要只说"选课失败",要具体到"与某一门课在周一第3-4节冲突"。这些细节不复杂,但决定了这套系统会不会被学生骂。

6.3 把项目变成可扩展的底座,而不是一次性作业

课程设计型项目的通病是"交完就死"。但选课系统本身是个特别好的业务载体,往下可以做学分绩点统计、先修课程依赖、教室资源管理;往上可以做权限模型升级、前后端分离、消息推送。你的技术成长完全可以沿着这个业务骨架持续加功能。等项目经历过一次完整的需求迭代之后,你再看原本的数据库设计,就会明白哪些字段当初该拆、哪些索引该建、哪些状态该加。这种"回看"本身就是开发经验沉淀最快的方式。

现阶段如果你拿到的是一份已经能跑通的源码,我的建议是:不要立刻大改,先把默认账号登录进去,把选课、退课、开课、排课四个核心动作全部点一遍,再对照表结构去理解业务。跑熟了之后,再挑一个我上面提到的扩展方向动手,那时候你对"为什么这套系统这么设计"的理解才算是真的到位了。

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

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

立即咨询