1. 项目定位与需求拆解
1.1 为什么选课系统是计算机毕设的“常青树”
我每年都会看到大量类似的项目标题,学生选课系统在计算机毕业设计里出现频率极高,甚至一度让人误以为“烂大街”。但恰恰是这种“看起来普通”的系统,反而最适合用来完整地训练一个应届生的工程思维。原因很简单:选课系统覆盖了信息管理类系统的全部核心要素——多角色登录、权限控制、核心业务事务、一对多/多对多数据关联、前端数据展示、复杂查询条件组合,以及最让答辩老师感兴趣的“并发冲突”问题。
想想看,一个登录注册加CRUD的“管理系统”撑死几千行代码,答辩时被问两句业务逻辑就露馅了。而选课系统天然具备“业务规则”,比如同一门课不能重复选、课程名额满了不能再选、课程之间不冲突才能选、选课时间窗口控制。这些规则一旦用代码实现出来,技术含量立刻上去了。这也是我当年给某位学弟推荐这个题目的核心理由——它的难点不是“写不出来”,而是“有没有把规则想周全”,而这正是评委们最爱提问的地方。
我这里要强调一下,标题里提到的“基于Java”其实是个非常宽泛的描述。我实际落地时采用的是Spring Boot + MyBatis-Plus + MySQL + Vue(或Thymeleaf)这套组合。这套组合是当前就业市场的绝对主流,同时也是毕设代码查重和答辩演示最不容易出岔子的方案。
1.2 核心角色与业务流程梳理
任何一个信息管理类系统,第一步永远不是建表,而是梳理“谁在用系统”。选课系统的用户分为三类:学生、教师、管理员。三者的关注点完全不一样,必须分开梳理。
学生端关注的是“选课前”和“选课后”:选课前要看课程列表、查开课教师、看剩余名额和上课时间;选课后要确认已选课程、退掉不想上的课、查看期末成绩。
教师端关注的是“开课”和“成绩”:教师需要发布课程、设置课程容量和上课时间、查看选课学生名单,以及最后录入成绩。
管理员端关注的是“全局统筹”和“兜底”:年级、专业、班级的基础数据维护,教师账号分配,课程数据审核,选课轮次的开放与关闭,以及偶尔的人工调整。
梳理完之后,业务流程我用一句话就能概括:管理员维护基础数据并开放选课窗口,学生按培养计划和兴趣挑选课程(先到先得),教师管理自己的课程并录入成绩,所有环节的数据都沉淀在三个核心角色关联的数据库表里。
1.3 选课系统“容易踩的坑”提前预警
想要让系统真正可用,而不是停留在“能跑就行”的Demo水平,以下四件事我建议尽早闭环。
第一,排课冲突检测。同一学生同一时间不能选两门课。这个看似简单,实际涉及时间片段的比较,如果上课时间字段建模得不合理(比如用字符串存“周一 1234节”),后面做冲突判断会非常痛苦,我后面会给出推荐的字段设计。
第二,退选后的名额释放。学生退课之后如果名额不恢复,后面再有人想选就被卡死,这在并发场景下很容易出bug。
第三,事务一致性。选课成功 = 插入选课记录 + 课程已选框数加1,这两个操作必须同时成功或同时失败。用Spring的@Transactional是最基本的操作。
第四,角色权限拦截。毕设虽然不需要做到生产级的SSO权限体系,但如果一个普通学生通过改URL就能访问管理员的接口,答辩老师演示一次就能把你问住,这属于底线问题。
2. 技术选型解析与开发环境准备
2.1 Spring Boot + MyBatis-Plus + MySQL这套组合做了什么选择
技术选型不是一个无关紧要的步骤,它直接决定了项目开发的工作量和答辩的稳定性。我的建议是版本不要追新,要追“资料多、问题少”的版本组合,这一点我在2.3节会给出完整参考环境。
后端框架我用了Spring Boot 2.7.x。为什么不用3.x?原因很简单:3.x普遍要求JDK 17起步,而很多学生的笔记本上装的是JDK 8,环境配不起来再好的功能都是零。Spring Boot 2.7.x既兼容JDK 8,又支持JDK 11,资料库极其丰富,遇到诡异报错一搜就能找到答案。另外,2.7.x的自动装配和配置方式和3.x区别不算大,答辩时提一句“使用的稳定版本”完全说得过去。
持久层我选了MyBatis-Plus。它最大的价值不是“语法糖”,而是不用写一堆样板式的增删改查SQL。选课系统里大量会用到单表查询——比如根据学生ID查已选课程、根据课程ID查班级列表,这些用MyBatis-Plus的QueryWrapper两行就能搞定。而复杂的两表关联查询(比如带课程名称筛选的选课记录分页),我再用自定义XML来写。这种“单表用Wrapper、多表用XML”的混合策略,是工程里最主流、也最不容易翻车的写法。
前端我是按“答辩现场是否方便演示”来选的。如果你选择前后端分离,那肯定是用Vue 3 + Element Plus开发单独的页面项目;如果不想多维护一个前端工程,那就用Thymeleaf服务端渲染。我这次的实际推荐是前端用Vue 3 + Element Plus,原因有三:页面美观、数据交互直观(选课时实时刷新剩余名额)、答辩时对API的调用逻辑讲起来非常清晰。
2.2 后端核心依赖清单
pom.xml里我认为必须有且值得加粗说明的依赖,给大家一个可直接抄的参考列表:
<dependencies> <!-- Web启动器,提供内嵌Tomcat与Spring MVC支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 安全框架,用于登录认证与角色权限控制 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- 持久层框架,简化单表操作 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok,简化实体类代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>注意:如果不想引入Spring Security的复杂过滤器链配置,也可以直接用拦截器(HandlerInterceptor)加JWT/Redis Token来实现登录权限控制。个人更推荐后者,因为毕设里安全框架容易引起配置冲突,而拦截器逻辑你能一行一行给评委讲清楚,加分更多。
2.3 环境版本与统一配置
我实际调试过的配置组合如下,直接照做基本不会因为版本起冲突:
| 组件 | 版本 | 备注 |
|---|---|---|
| JDK | 1.8 | 兼容性最佳,稳定前提 |
| Spring Boot | 2.7.x | 支持javax.*命名空间,资料最多 |
| MySQL | 8.0.x | 字符集建议utf8mb4 |
| MyBatis-Plus | 3.5.x | 自动填充、逻辑删除都靠它 |
| Redis | 5.0+ | 用于Token存储或验证码缓存(可选) |
| Node.js | 16.x | 前端Vue项目构建环境 |
| Maven | 3.8.x | 依赖管理和打包 |
配置文件的几个关键项:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/course_select?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里特别说三个容易出问题的配置项。第一,serverTimezone必须指定为GMT+8,否则MySQL 8的驱动会报时区错误。第二,logic-delete-field: deleted开启逻辑删除后,MyBatis-Plus会自动在所有查询里追加WHERE deleted = 0,这样即使用户误删课程数据也能保留痕迹,答辩是个加分小细节。第三,StdOutImpl开启SQL日志,开发调试时看控制台能直接观察到执行的SQL和参数。
3. 数据库设计与核心表结构
3.1 数据建模:用五张表支撑完整业务
选课系统的表结构,是很多学生做得最粗糙的部分。我建议不要做成“一张用户表打天下”,那是典型的烂设计。合理的做法是维护用户角色、业务数据、关联关系三个维度,用五张核心表加若干张辅助表来完成:
第一张是sys_user用户表,字段包含id、username、password、real_name、role(1=管理员,2=教师,3=学生)、major_id、created_time。用户表不区分三张子表的原因很简单:毕设阶段用一个字段区分角色能极大简化登录逻辑。当然,如果你追求更标准的范式,可以拆成student、teacher、admin三张表。但我要强调的是,拆分之后登录认证会变成噩梦,每次登录得查三张表才知道你是谁,对毕设来说得不偿失。
第二张是course课程表,字段包含id、course_name、credit、course_time、course_location、teacher_id、capacity、selected_count、status。我用teacher_id外键直接关联用户表,并在查询时连表查出教师姓名。selected_count是指已选人数,每次选课成功就加1,展示剩余名额时用capacity - selected_count计算即可。
第三张是student_course选课关联表,这是整个系统的“核心表”,字段包含id、student_id、course_id、select_time、status。这里要特别提一下,不要在学生表和课程表里互相存储对方ID的字符串集合,那种设计(比如用逗号分隔存课程ID)看起来简单,后面查数据、做统计、判断冲突时全是雷。必须用单独的关联表,一行代表一次选课行为。
辅助表我建议再加major(专业表)和course_time(课表时间段表),前者用来维护学生的专业归属,后者用来解决排课冲突检测问题。课表时间段表可以设计为id、day_of_week、section_start、section_end,比如“周一、第3节、第4节”,然后用一条记录就可以准确表示该课程的上课时间段。
3.2 建表SQL关键片段
下面是三个核心表的最关键DDL语句,我注释了字段含义,方便你直接拿去改:
CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名', `role` tinyint DEFAULT 3 COMMENT '角色 1管理员 2教师 3学生', `major_id` bigint DEFAULT NULL COMMENT '所属专业ID', `status` tinyint DEFAULT 1 COMMENT '账号状态 1正常 0禁用', `deleted` tinyint DEFAULT 0 COMMENT '逻辑删除标记', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `course` ( `id` bigint NOT NULL AUTO_INCREMENT, `course_name` varchar(100) NOT NULL COMMENT '课程名称', `credit` decimal(3,1) DEFAULT 2.0 COMMENT '学分', `course_time` varchar(50) DEFAULT NULL COMMENT '排课时间段ID,可存JSON数组', `course_location` varchar(100) DEFAULT NULL COMMENT '上课地点', `teacher_id` bigint DEFAULT NULL COMMENT '授课教师用户ID', `capacity` int DEFAULT 60 COMMENT '课程容量', `selected_count` int DEFAULT 0 COMMENT '已选人数', `status` tinyint DEFAULT 0 COMMENT '0未开放 1选课中 2已结束', `deleted` tinyint DEFAULT 0 COMMENT '逻辑删除', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; CREATE TABLE `student_course` ( `id` bigint NOT NULL AUTO_INCREMENT, `student_id` bigint NOT NULL COMMENT '学生用户ID', `course_id` bigint NOT NULL COMMENT '课程ID', `select_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', `status` tinyint DEFAULT 1 COMMENT '状态 1已选 0已退', PRIMARY KEY (`id`), UNIQUE KEY `uk_student_course` (`student_id`, `course_id`, `status`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='学生选课记录表';uk_student_course这个唯一索引非常关键,它是在数据库层面保证同一个学生同一门课最多只有一条有效选课记录。就算后端代码有漏洞,并发请求同时涌进来,数据库的唯一约束也会帮忙拦截重复选课。
3.3 为什么必须给选课表加“唯一索引”
很多人会忽略索引设计,觉得那是运维的事。但实际上,选课系统在“开放选课”的那几分钟,并发压力是非常真实的——全班同学同时在刷新页面点选同一门热门课,这就不是一个纯前端问题了。
MySQL默认的事务隔离级别是REPEATABLE READ,如果一个请求正在插入选课记录但还没提交,另一个请求发起相同插入时会被唯一索引挡住,从而报DuplicateKeyException。这个时候我们捕获异常并提示“您已选过该课程”,就是一条非常干净的用户反馈链路。如果没有唯一索引,两个请求都通过查询发现“当前还没选过”,然后都插入成功,这就是典型的“并发脏读写入”。在答辩时讲到这一层,评委一定认为你的系统考虑了真实场景。
我实际项目的做法是:选课接口先从业务上判断(用SELECT COUNT(*)查是否有已选记录),再到数据库层面靠唯一索引做兜底,两层防线缺一不可。
4. 项目工程结构与功能模块实现详解
4.1 后端包结构设计
工程结构我习惯这样划分,直观、规范、也方便答辩时展示给评委看:
com.example.courseselect ├── config // 配置类:跨域、拦截器注册、WebMvc配置 ├── controller // 接口层:接收请求、返回统一结果 ├── service // 业务逻辑层:核心选课、退课、成绩录入逻辑 ├── mapper // 数据访问层:继承BaseMapper或自定义XML ├── entity // 实体类:对应数据库表 ├── dto // 请求/响应对象:封装VO和请求参数 ├── common // 通用类:统一结果、异常处理、常量枚举 └── utils // 工具类:JWT工具、密码加密工具等每个包职责单一,controller不写业务、service不直接操作HttpSession、mapper只做数据读写。这种清晰的“三层架构”在答辩时会被评委认为是受过正规训练的做法。
4.2 登录认证与权限控制:用JWT + 拦截器替代复杂安全配置
我这里采用JWT + 拦截器的方案,比直接在毕设里引入完整的Spring Security配置更轻量、更可控。原理不复杂:用户登录成功后,后端签发一个有效期12小时的Token,前端拿到后存到本地,每次请求在Header里带上Authorization: Bearer <token>。我用一个拦截器统一解析Token并判断角色,方法如下:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 String uri = request.getRequestURI(); if (uri.contains("/api/auth/login") || uri.startsWith("/static/")) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token) || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } try { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); Integer role = claims.get("role", Integer.class); request.setAttribute("userId", claims.get("userId", Integer.class)); request.setAttribute("role", role); // 管理员接口必须以role==1访问 if (uri.startsWith("/api/admin/") && role != 1) { response.setStatus(403); return false; } // 教师接口必须以role==2访问 if (uri.startsWith("/api/teacher/") && role != 2) { response.setStatus(403); return false; } return true; } catch (Exception e) { response.setStatus(401); return false; } } }拦截器注册进Spring Boot的WebMvcConfigurer:
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/**") .excludePathPatterns("/api/auth/login", "/static/**", "/error"); } }因为后端把userId和role放到request attribute里,controller里拿到当前用户身份变得非常方便,也不用每次去Session里取。比如学生选课接口就能直接Integer studentId = (Integer) request.getAttribute("userId")拿当前用户,这比让前端把学生ID传过来更安全——你根本不能信任前端传的ID。
4.3 选课核心业务:事务、校验、名额扣减
选课是整个项目最核心的场景,代码再强调也不过分。我习惯把这个接口写成服务层的一个事务方法,逻辑顺序是:先做一轮参数校验,再做四道业务校验,最后插入记录和更新名额。核心代码如下:
@Transactional(rollbackFor = Exception.class) public ResultVO selectCourse(Integer studentId, Integer courseId) { // 1. 校验课程是否存在、是否开放选课 Course course = courseMapper.selectById(courseId); if (course == null || course.getStatus() != 1) { return ResultVO.error("课程不存在或未开放选课"); } // 2. 检查是否已经选过(业务层校验) Long count = studentCourseMapper.selectCount( new LambdaQueryWrapper<StudentCourse>() .eq(StudentCourse::getStudentId, studentId) .eq(StudentCourse::getCourseId, courseId) .eq(StudentCourse::getStatus, 1) ); if (count > 0) { return ResultVO.error("您已选择过该课程,请勿重复选择"); } // 3. 检查剩余名额 if (course.getSelectedCount() >= course.getCapacity()) { return ResultVO.error("该课程选课人数已满"); } // 4. 检查上课时间是否冲突 boolean conflict = checkTimeConflict(studentId, courseId, course.getCourseTime()); if (conflict) { return ResultVO.error("所选课程与已选课程时间冲突"); } // 5. 插入选课记录并更新已选人数 StudentCourse sc = new StudentCourse(); sc.setStudentId(studentId); sc.setCourseId(courseId); sc.setStatus(1); studentCourseMapper.insert(sc); course.setSelectedCount(course.getSelectedCount() + 1); courseMapper.updateById(course); return ResultVO.success("选课成功"); }时间冲突检测是这个接口里最容易被忽略的地方。我这里用了一个约定:course_time字段存储的是JSON数组,比如["1,3,4","3,5,6"],含义是周一第3-4节、周三第5-6节。后端把这个字段解析之后,再与学生已选课程的时间段做“区间是否重叠”判断。
private boolean checkTimeConflict(Integer studentId, Integer courseId, String newCourseTime) { List<String> targetTimes = JSON.parseArray(newCourseTime, String.class); if (targetTimes == null || targetTimes.isEmpty()) { return false; } // 查出学生当前已选课程(状态=1) List<StudentCourse> selectedList = studentCourseMapper.selectList( new LambdaQueryWrapper<StudentCourse>() .eq(StudentCourse::getStudentId, studentId) .eq(StudentCourse::getStatus, 1) ); for (StudentCourse sc : selectedList) { if (sc.getCourseId().equals(courseId)) continue; Course c = courseMapper.selectById(sc.getCourseId()); if (c == null || StringUtils.isBlank(c.getCourseTime())) continue; List<String> existsTimes = JSON.parseArray(c.getCourseTime(), String.class); for (String t1 : targetTimes) { for (String t2 : existsTimes) { // t1和t2格式如 "1,3,4" if (isOverlap(t1, t2)) { return true; } } } } return false; } private boolean isOverlap(String time1, String time2) { String[] p1 = time1.split(","); String[] p2 = time2.split(","); // 星期几相同才继续判断节次 if (!p1[0].equals(p2[0])) return false; int start1 = Integer.parseInt(p1[1]); int end1 = Integer.parseInt(p1[2]); int start2 = Integer.parseInt(p2[1]); int end2 = Integer.parseInt(p2[2]); // 区间重叠判断:某个课程的开始时间 <= 另一课程的结束时间,且反过来也成立 return start1 <= end2 && start2 <= end1; }你也许觉得解析JSON、遍历判断低效,但这个是毕设系统,数据量最多也就几百条,性能完全没有问题。更重要的是这套逻辑能“讲得清楚”,答辩时你可以直接画个区间图说明重叠判定原理。
4.4 退课与名额释放:把小细节做好了才叫工程能力
退课的逻辑看起来是选课的逆操作,但有几个细节要处理好:必须保证只有该学生能退自己的课,只能退状态为“已选”的记录,退课后selected_count要减一,并且唯一索引的设计要能支持重新选课。
唯一索引需要考虑一个坑:如果我把status字段放进联合唯一索引,退课时直接物理删除该记录就没问题;如果采用逻辑删除(status改成0或者删除标记改成1),一个新记录插进去会跟旧记录组成不同的(student_id, course_id, status)值,从而绕开唯一索引,这样也不会误报重复。所以我建议退课直接执行DELETE FROM student_course WHERE student_id=? AND course_id=? AND status=1,这是最干净的方式。
退课接口的Service方法是这样:
@Transactional(rollbackFor = Exception.class) public ResultVO cancelCourse(Integer studentId, Integer courseId) { int deleted = studentCourseMapper.delete( new LambdaQueryWrapper<StudentCourse>() .eq(StudentCourse::getStudentId, studentId) .eq(StudentCourse::getCourseId, courseId) .eq(StudentCourse::getStatus, 1) ); if (deleted == 0) { return ResultVO.error("未找到有效选课记录,退课失败"); } Course course = courseMapper.selectById(courseId); if (course != null && course.getSelectedCount() > 0) { course.setSelectedCount(course.getSelectedCount() - 1); courseMapper.updateById(course); } return ResultVO.success("退课成功,名额已释放"); }我特别希望强调这一点:退课和选课一样都要放到同一个事务里,因为“删除选课记录”和“扣减已选人数”必须作为一个原子操作。如果先删记录、后更新人数,中途抛异常的话,数据库里就是“没选课但占了名额”的脏状态。
4.5 教师端功能:课程发布与成绩录入
教师端在选课系统里是“承上启下”的环节。教师在登录后进入课程管理页面,可以发布新课程,也可以对自己名下课程进行改课。课程发布表单里需要选择上课时间、地点、容量,提交后课程状态默认是“未开放”,等到管理员统一打开选课窗口后学生才能看到。
由于课程表和用户表通过teacher_id关联,教师在接口查询时只查自己的课程即可:
public List<CourseVO> getTeacherCourses(Integer teacherId) { List<Course> courses = courseMapper.selectList( new LambdaQueryWrapper<Course>() .eq(Course::getTeacherId, teacherId) .orderByDesc(Course::getCreateTime) ); // 转换VO,补充剩余名额 = capacity - selectedCount }成绩录入功能相对简单,本质是“根据课程ID找到选了这个课的学生列表,再对每个学生填成绩”。我建议把成绩字段放在student_course表上,新增一列score,因为成绩是一个学生在一门课上的属性,天然应该存在关联记录上。录入成绩时用Excel批量上传是个提效点,但考虑到毕设演示,保存多条JSON数据也行,我选的是前端表格填写、批量保存的方案。
4.6 管理员端功能:用户导入、课程审核、数据统计
管理员端最提亮的部分是“多条件组合查询”和“数据看板”。多条件查询可以支持按课程名、授课教师、选课人数区间、课程状态等条件筛选,本质就是一个动态拼接SQL的过程:
public PageResult queryCourses(CourseQueryDTO dto, Page page) { LambdaQueryWrapper<Course> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.isNotBlank(dto.getCourseName())) { wrapper.like(Course::getCourseName, dto.getCourseName()); } if (dto.getStatus() != null) { wrapper.eq(Course::getStatus, dto.getStatus()); } if (dto.getTeacherName() != null) { // 先查出教师ID集合 List<Integer> teacherIds = userMapper.selectTeacherIdsByName(dto.getTeacherName()); if (teacherIds.isEmpty()) { return PageResult.empty(); } wrapper.in(Course::getTeacherId, teacherIds); } return courseMapper.selectPage(page, wrapper); }管理员模块还有一个很实用的小功能:课程开放轮次控制。管理员能把系统状态字段设置成“开放选课中”“选课已结束”,学生端页面根据这个状态切换按钮是否可点。这个功能虽然代码量不大,但在答辩里很能体现“业务流程意识”。
5. 前端页面设计与交互逻辑
5.1 页面划分与路由设计
前端我使用的是Vue 3 + Element Plus + Vue Router + Axios。整体页面结构围绕角色不同而分,建议的路由表格如下:
| 路径 | 页面 | 可用角色 | 核心功能描述 |
|---|---|---|---|
| /login | 登录页 | 所有角色 | 账号密码登录、角色切换展示 |
| /student/courses | 可选课程列表 | 学生 | 显示课程卡片、剩余名额、选课按钮 |
| /student/mycourses | 我的课表 | 学生 | 已选课程列表、退课按钮、成绩查询 |
| /teacher/courses | 我的课程 | 教师 | 课程管理、发布课程、查看选课名单 |
| /teacher/score | 成绩录入 | 教师 | 给选修该课程的学生打分 |
| /admin/users | 用户管理 | 管理员 | 学生/教师账号管理、导入 |
| /admin/courses | 课程审核 | 管理员 | 课程上下架、开放选课窗口 |
| /admin/stats | 数据统计 | 管理员 | 各课程选课人数柱状图、专业选课统计 |
在前端做的最重要的一个交互细节是“选课按钮的双重确认”。学生点击选课时,先弹窗确认课程名和上课时间,再调用后端接口,拿到成功或失败的结果后局部刷新按钮状态。这样既降低了后端压力,也避免了学生误触。
5.2 Axios拦截器统一处理Token和错误码
前端代码里我会在Axios请求拦截器统一加上Token:
service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; }); service.interceptors.response.use( response => { const res = response.data; if (res.code === 401) { localStorage.removeItem('token'); router.push('/login'); } return res; }, error => { ElMessage.error(error.response?.data?.message || '网络请求失败'); return Promise.reject(error); } );前端目录结构我习惯拆成views(页面)、components(公共组件)、api(接口封装)、router(路由配置)、store(状态管理)。这里有一个经验:不要把每个接口调用都写在页面里,建议按模块封装在api目录下,比如api/course.js里统一放获取课程列表、选课、退课的接口方法。代码整洁度一目了然,导师检查代码的时候也能轻松看懂。
5.3 剩余名额实时刷新:两种实现方式我都做了
名额刷新的实现方式往往代表着系统设计的层次。第一种最简单:选课成功后重新查一遍课程列表,整个页面刷新。这么做实现简单,但交互相比较差,重复点击会感到不流畅。
第二种用定时轮询:每10秒请求一次后端,刷新当前页面的课程剩余名额。实现也不复杂,用的是Vue组件里的setInterval。但这里要注意一个潜在问题——如果学生停留在列表页超过10分钟,频繁请求会对后端造成无效压力。我建议的方案是“选课动作后立即局部刷新 + 进入页面时加载一次 + 每次路由切换时重新加载”。对毕设系统而言,这个频率已经足够,还容易跟评委解释清楚为什么没有做实时推送。
6. 项目部署、测试和答辩准备
6.1 本地环境跑通的完整部署流程
毕设最常见的灾难不是代码写不出来,而是“在我电脑上能跑,换个环境就崩”。为了避免这种情况,这里给出一份干净利落的部署流程清单:
- 按2.3节的版本对照表装好JDK、Maven、MySQL、Node.js。
- MySQL中创建数据库:
CREATE DATABASE course_select DEFAULT CHARACTER SET utf8mb4。 - 依次执行项目的
sql目录下的建表脚本和初始数据脚本(包含测试账号)。 - 在
application.yml里改自己机器的数据库账号密码。 - 后端目录执行
mvn spring-boot:run,启动成功后访问http://localhost:8080。 - 前端目录执行
npm install(建议使用registry镜像,能快很多),再执行npm run dev。 - 联调前先确认后端接口地址是否跨域,本地开发前端默认端口是5173,后端是8080,因此必须在后端配置跨域,或者用前端Vite的代理转发。
关于跨域,我在后端单独写了一个CorsConfig,允许本地开发的一切来源:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意:如果
allowCredentials(true)且allowedOriginPatterns("*")同时配置,实际请求时浏览器不会出问题,因为我们用的是allowedOriginPatterns而不是allowedOrigins("*"),后者配上allowCredentials在部分浏览器里会被拦截。
6.2 功能测试用例怎么设计才能答辩不露怯
很多学生只用“管理员给自己发了两个账号,自己点了点”就算测完了,这非常不够。严谨的做法是至少写一份覆盖正常流程和异常流程的测试清单,并按表格整理。我列一份亲测有效的最小测试集:
| 编号 | 场景 | 操作与预期 |
|---|---|---|
| T01 | 学生正常选课 | 学生登录后,在可选列表点“选课”,提示成功,剩余名额减1 |
| T02 | 重复选课 | 再次点击同课程选课,提示“您已选过该课程” |
| T03 | 名额不足 | 把课程容量设为1,另一个账号先选掉,本账号再选,提示已满 |
| T04 | 排课冲突 | 先选“周一3-4节”的课程A,再选“周一3-4节”的课程B,提示时间冲突 |
| T05 | 退课释放名额 | 学生退课后,该课程名额加回,其他学生可以再选 |
| T06 | 未登录访问接口 | 不带Token请求选课接口,返回401 |
| T07 | 学生访问教师接口 | 学生Token访问/api/teacher/courses,返回403 |
| T08 | 教师录入成绩 | 教师对选了该课程的学生填写成绩,保存后学生端可见 |
这份清单我在交付给学弟之前实际跑过一遍。很多bug就是在这份清单里暴露出来的,尤其是T04,当初一度出现“跨天时间段被误判为冲突”的疏忽,后来修复成区间交叉判定才算通过。能在答辩演示时说出“这是我测试过并发和异常场景后的结果”,比单纯说“功能都实现了”有说服力得多。
6.3 评委必问的十个问题清单
这一节属于纯经验分享。我总结出评委对选课系统最常问的十个问题,并附上参考应答思路。提前背熟,现场几乎不会卡壳。
第一个:“选课人数满了之后,几个人同时点选课,怎么保证不多人?”——答:数据库层面用了唯一索引和事务,业务层先查再插,事务提交时如果插入冲突会抛异常并被捕获。
第二个:“学生退课之后名额怎么处理?”——答:删除选课记录和更新已选人数在同一个事务里,事务提交后名额立刻恢复。
第三个:“课程时间冲突怎么判断?”——答:课程时间用JSON数组存储“星期+节次”,选课前遍历已选课程做一个区间重叠判断。
第四个:“密码是明文存储的吗?”——真实项目当然不能明文存储。我建议使用BCrypt加密,Spring Security里自带的BCryptPasswordEncoder可以直接用,哪怕不做完整的Security过滤器链,单独拿这个工具类做加密也完全可行。
第五个:“系统如何防止学生访问教师接口?”——用得是JWT里存的role属性,在拦截器统一做角色校验。
第六个:“为什么用MyBatis-Plus而不是原生MyBatis?”——MyBatis-Plus对单表操作做了大量简化,同时保留了XML自定义SQL扩展,项目开发效率和可维护性都更好。
第七个:“数据库为什么用逻辑删除?”——避免误删导致数据不可恢复,同时MyBatis-Plus全局配置后不用在每条SQL里手动加条件。
第八个:“这个系统有哪些可以扩展的方向?”——可以从消息通知选课结果、加入预选课和权重随机分配、用定时任务控制选课批次、接入Redis缓存热门课程数据等方面扩展。
第九个:“项目里你觉得最大的难点是什么?”——不要回答“没有难点”,要选一个真实的问题:比如排课冲突判断模型设计、并发场景下选课数据一致性、JWT拦截器的Token校验。我建议重点讲并发和事务,评委最容易共鸣。
第十个:“前后端数据交互流程是怎样的?”——从前端Axios发送请求、后端controller接收参数、service校验、mapper操作数据库、返回统一结果对象、前端根据code渲染提示,完整走一遍即可。
6.4 我踩过的三个坑(毕设最容易翻车的地方)
第一个坑:密码使用了MD5存储。我坦白说,早期版本我用的是MD5加盐,后来在答辩模拟中被老师追问“MD5为什么不适合存储密码”,我花了两天时间才把密码改成BCrypt。MD5主要问题是计算速度太快,暴力破解的成本极低,而BCrypt内置盐并且计算速度可调参数控制。改成BCrypt之后,密码列长度一定要留够,建议varchar(60)到varchar(64)。
第二个坑:选课表没有加唯一索引。某次压力测试中,我用脚本开了20个并发,同一学生选同一门课,结果生成了两条记录。后来加上唯一索引,这个问题从数据库根源上被杜绝。真的建议所有做类似系统的同学,在设计表的那一天就加上这个索引,不要等到测试才被迫补。
第三个坑:前端路由没有做权限控制。虽然后端有拦截器托底,但前端如果不控制路由,学生手动改地址能进教师页面,页面加载接口才会被后端弹回403。体验极差。所以我后来在前端路由配置里增加了meta: { role: 2 }之类的字段,在路由守卫里统一判断用户角色,进不去的页面直接重定向到首页。那之后前端就稳了。
7. 本项目的扩展方向与我的个人体会
7.1 选课算法改进:从“先到先得”到“随机分配”
学生选课系统如果只做“先到先得”其实是一个业务上不够公平的机制。很多高校实际采用的是“预选 + 随机抽签”策略:学生先提交志愿,选课结束后由系统根据权重(比如高年级优先、必修课优先)进行随机分配。我研究过这个改进方向,本质上是在选课关联表上增加一个priority字段和一个is_assigned状态,配一个定时任务在选课结束后统一做匹配。这个扩展如果做进毕设,属于锦上添花,而且很能展示算法能力。
7.2 技术增强要点:缓存、消息通知与数据可视化
如果你希望项目体现的性能意识和“生产化”水平更高,可以考虑三件事:第一,用Redis缓存热门课程列表和剩余名额,降低数据库的查询压力;第二,在选课成功或课程被退时给相关用户发送站内信提醒;第三,按学院、专业统计选课情况,用ECharts展示“课程热度排行”或“各专业选课率”。这三样加起来,代码量并不算大,但对项目的“完成度”提升非常显著。
7.3 我给下一次做类似系统同学的三条建议
第一,需求文档必须先写清楚,至少把角色、核心用例、异常流程列出来再动手写代码。我见过太多同学表结构建了又删,代码写了又推倒重写,浪费大量时间。
第二,遇到报错一定先看日志,再用搜索引擎。很多时候错误信息里已经把解决思路写得非常明白了。不建议一报错就四处复制代码,那样往往越搞越乱。
第三,代码写完后至少要留出一周做整系统联调和答辩演练。真实的“翻车现场”往往发生在演示中途,比如数据库没启动、端口被占用、前端没构建生产包。这些看似小细节,在答辩现场都是灾难级的。
8. 写在最后的实际体验
做这个学生选课系统,我最深的体会是:一个看似普通的毕设题目,只有把选课规则、并发安全、权限拦截这些细节真正做对,它才值得写进简历。如果你只是用“增删改查”把功能拼起来,那它真的只是一个练习;但如果你把时间冲突检测、事务一致性、角色权限这些设计讲透彻,它就是你面试时可以拿出来聊上十分钟的完整项目。
我自己在用这个系统做演示时最常演示的三件事是:不同角色登录后的页面差异、试着重复选一门课或选时间冲突的课程看系统提示、把一个课程容量改成1后让两个账号抢选。每次演示到这些场景,听的人基本都会点头,因为他们看到的不只是“能做”,而是“考虑到了真实场景里的问题”。
这个项目后续其实还可以继续生长:把前端换成小程序、把数据库换成PostgreSQL、把选课策略升级成抽签模式、做成微服务拆开选课和成绩模块。每一次改动背后,都是一轮新的工程思考。有时候毕设的价值不在于题目本身多高深,而在于你能不能把一个简单题做完整、做严谨。
如果你正卡在选课冲突判断、并发重复选课这类细节上,不妨按我上面写的方案先复现一遍,再根据自己的业务规则调整。跑通一个能讲清楚“为什么选它”“为什么这么设计”的系统,才算是真正完成了这份毕设。