1. 项目整体架构与设计思路
1.1 为什么选SpringBoot+Vue+MyBatis+MySQL这套组合
先聊一个很多新手容易误解的点:做这类企业级管理系统,技术选型根本不是越新越好,也不是越复杂越好,而是要讲“投入产出比”。学生选课系统听着简单,但其实它天然包含用户认证、角色权限、复杂业务事务、高并发抢课、数据统计这些典型企业级场景。用SpringBoot+Vue+MyBatis+MySQL这套组合,核心原因有三:
第一,SpringBoot把Spring生态里大量繁琐配置收口了,项目能快速启动,内嵌Tomcat也让部署变得非常简单,非常适合做前后端分离架构下的后端服务。第二,选课系统的核心难点在SQL层面——学生选课前需要查已选人数、判断时间冲突、更新课程余量、写选课记录,这些都是强数据一致性业务,MyBatis把SQL完全暴露给开发者,更容易按业务去控制查询和更新的粒度,比全自动ORM更直观也更稳妥。第三,Vue在前端生态里的组件化和状态管理成熟度够高,Element Plus这类现成组件库可以三天内把后台管理界面拼完,不会在前端上耗太多精力。
如果你是自己做毕设或者简历项目,这套组合也是最容易被面试官认可的经典组合。SpringBoot、Vue、MyBatis、MySQL几乎是Java全栈岗位的标配,项目写进简历,面试时每一个点都能深挖下去讲出原理。不要觉得这套技术栈太常见就刻意搞个微服务,把单体项目的业务边界、事务控制、权限模型做扎实,远比堆一堆花哨技术有用得多。
1.2 三种角色与核心业务流转
学生选课系统的业务其实围绕三种角色展开:学生、教师、管理员。在设计整个系统前,首先要在这三者之间画清楚权限边界。
管理员负责基础数据维护,包括学生信息管理、教师信息管理、课程信息审核与发布、学期设置。教师可以创建课程、设置课程容量、查看选课名单、录入成绩。学生则是整个系统的核心用户,浏览课程列表、查看课程详情、选课、退课、查看个人课表。典型流程是这样的:管理员维护好学期和学生教师基础数据,教师在当前学期内发布课程(包含课程名称、学分、上课时间、上课地点、容量),管理员审核通过后课程变成可选状态,学生登录后从课程列表里选择课程,系统实时判断课程是否已满、个人课表是否时间冲突,选课成功后课程已选人数加一,学生可以在个人课表里确认选课结果。
这里有一个特别重要的设计思路:课程表不要直接把“上课时间”做成一个字符串字段交给前端展示,而是要拆出可判断的字段结构。比如说周几上课、第几节上课,如果只塞一个“周一第三大节”的文本,后端就无法程序化判断两个课是否时间冲突。很多初版系统都栽在这个地方,后面做冲突检测只能在地狱级别的字符串解析里挣扎。正确做法是把课程时间拆成像week_day、start_section、end_section这样的结构化字段,或者独立一张schedule表,让后端通过可比较的数值字段去做冲突判断。
1.3 接口设计规范与统一返回体
做企业级项目,接口规范决定了前后端联调效率。我在这个系统里要求所有Controller统一返回一个Result对象,结构类似下面的格式:
java复制{
"code": 200,
"message": "操作成功",
"data": {}
}
code=200表示成功,400表示参数错误,401表示未登录或token过期,500表示业务异常或系统异常。前端axios接到响应后只看code和message,不用关心每个接口内部的异常分支。这个习惯一定要从第一版代码就做好,不然项目做到中途接口返回格式各写各的,联调会非常痛苦。
针对业务异常,我习惯自定义一个BizException,然后在全局异常处理器里统一捕获并转成Result返回。这样Service层不需要每个方法都try-catch,业务代码干净很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与表结构拆解
2.1 核心表有哪些、为什么这样设计
项目整体数据库建议拆八张核心表:用户表(sys_user)、学生表(student)、教师表(teacher)、学期表(semester)、课程表(course)、选课记录表(student_course)、课程安排表(course_schedule)、操作日志表(operation_log)。当然根据业务需要可以合并或增删,比如用户表直接带角色字段就能区分学生和教师,但这样做有两个问题:一是学生可能有学号、班级、年级等扩展字段,教师有职称、院系等扩展字段,堆在一个表里字段会变得杂乱;二是如果以后要做多端登录、第三方登录,公共用户信息放sys_user、扩展信息放子表这种拆分方式更容易扩展。
从企业级开发习惯来看,我建议这样划分:
- sys_user:维护登录账号、密码密文、角色类型(student/teacher/admin)、状态。
- student:维护学号、姓名、院系、班级等业务属性,用user_id关联sys_user。
- teacher:维护工号、姓名、职称、院系等属性,用user_id关联sys_user。
- semester:学期名称、学期开始时间、结束时间、是否当前学期。
- course:课程代码、课程名称、学分、授课教师、学期、课程描述、容量、已选人数、上课校区等。
- student_course:学生选课记录表,核心是student_id、course_id、选课时间、状态(已选/退课)。
系统里不建议在数据库层强加物理外键。很多学院派课程设计喜欢在DDL里写FOREIGN KEY,但你到企业里待一阵子就会明白,物理外键在高并发插入、分库分表、以及后续做数据归档时都是灾难。这个系统里我在Service层做逻辑关联和数据完整性的控制,表结构里只保留普通索引。
2.2 选课记录与“防超选”设计
先给一段核心的建表SQL,你拿去直接改字段名就能用。
sql复制CREATE TABLE `course` (
`id` bigint NOT NULL AUTO_INCREMENT,
`course_code` varchar(32) NOT NULL COMMENT '课程代码',
`course_name` varchar(64) NOT NULL COMMENT '课程名称',
`credit` decimal(3,1) NOT NULL DEFAULT '0.0' COMMENT '学分',
`teacher_id` bigint NOT NULL COMMENT '教师ID',
`semester_id` bigint NOT NULL COMMENT '学期ID',
`week_day` tinyint DEFAULT NULL COMMENT '周几上课 1-7',
`start_section` tinyint DEFAULT NULL COMMENT '开始节次',
`end_section` tinyint DEFAULT NULL COMMENT '结束节次',
`location` varchar(128) DEFAULT NULL COMMENT '上课地点',
`capacity` int NOT NULL DEFAULT '0' COMMENT '课程容量',
`selected_count` int NOT NULL DEFAULT '0' COMMENT '已选人数',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态 0待审核 1可选 2已结束',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_course_code_semester` (`course_code`,`semester_id`),
KEY `idx_semester_status` (`semester_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';
sql复制CREATE TABLE `student_course` (
`id` bigint NOT NULL AUTO_INCREMENT,
`student_id` bigint NOT NULL COMMENT '学生ID',
`course_id` bigint NOT NULL COMMENT '课程ID',
`semester_id` bigint NOT NULL COMMENT '学期ID',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '状态 1选课中 2退课',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_student_course` (`student_id`,`course_id`,`semester_id`),
KEY `idx_student_status` (`student_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生选课记录表';
注意这两张表里的三个关键设计:
- course表里加了capacity和selected_count,这就是防超选的基础。
- student_course表里建了唯一索引uk_student_course,让同一学生在同一学期对同一门课只能有一条有效记录,这是数据库层的最后一道防线,应用层即使出现重复提交,也会在这里被拦下来报错。
- course_code和semester_id的组合也加了唯一约束,保证同一学期内不允许出现两门相同课程代码的课。
2.3 常用SQL与索引建议
几个高频SQL建议直接固化在Mapper里:
- 查询当前学期可选课程列表(排除学生已选过的课):
sql复制SELECT c.* FROM course c
WHERE c.semester_id = #{semesterId}
AND c.status = 1
AND c.selected_count < c.capacity
AND c.id NOT IN (
SELECT course_id FROM student_course
WHERE student_id = #{studentId} AND status = 1
)
- 选课成功后原子更新已选人数,这个语句是整个防超选的核心:
sql复制UPDATE course
SET selected_count = selected_count + 1
WHERE id = #{courseId}
AND selected_count < capacity
这里用UPDATE影响行数来判定是否成功:如果影响行数为1,说明更新成功,课程没满,可以继续插入选课记录;如果影响行数为0,说明课程已经满员,直接抛业务异常。
索引建得不用贪多,核心是这几个方向:
- course表:semester_id + status的联合索引非常有用,因为首页列表总是按学期过滤再按状态过滤。
- student_course表:student_id + status联合索引,查学生课表会非常快。
- course表:如果有按课程名称模糊搜索的需求,甚至可以建一个课程名的索引,但因为模糊查询前缀带%通常走不了索引,实际还是全表扫描,课程表几万条以内完全扛得住,不需要过度优化。
3. SpringBoot后端核心实现
3.1 工程结构与基础配置
后端工程我用标准的Maven多模块思路做模块内分包,虽然没有强制拆成多模块,但包结构必须清晰。通常按这种层级分:
text复制com.example.courseselect
├── controller // 接口层
├── service // 业务层
│ └── impl
├── mapper // MyBatis数据访问层
├── entity // 数据库实体类
├── dto // 请求和响应对象
├── common // 统一返回、异常、常量
└── config // 配置类
核心配置文件application.yml有几点容易踩坑,我直接把常用配置贴出来:
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/course_select?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.courseselect.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
三个关键点:url里必须带serverTimezone=Asia/Shanghai,不然高版本MySQL驱动会报时区错误;map-underscore-to-camel-case一定要设为true,否则数据库的create_time映射不到Java的createTime字段;log-impl配置成StdOutImpl后控制台会打印完整SQL,这对联调排错特别重要。如果你用了MyBatis-Plus,则对应配置是mybatis-plus.configuration.log-impl,别弄混。
3.2 登录鉴权与用户权限控制
企业级项目登录鉴权用JWT是标配做法。核心流程:用户提交账号密码,后端先通过BCrypt算法校验密码,校验通过后生成一段JWT token返回前端。token里可以塞userId、username、role这几个字段。前端拿到token后存到localStorage,每次请求在axios请求头里带上Authorization: Bearer xxx,后端通过拦截器解析token并校验是否过期。
后端拦截器我建议按路径做权限划分:
java复制public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/api/auth/login", "/api/auth/register");
}
然后在需要做更细粒度控制的Controller上加角色校验,比如管理员的接口只允许管理员访问,学生的选课接口只允许学生访问。这种判断写在拦截器里,或者写在Service层用@RequireRole之类的自定义注解,都可以。
密码这块一定不要明文存储。我项目里用的是Spring Security自带的BCryptPasswordEncoder。如果你不想引入整个Spring Security,也可以单独引入spring-security-crypto这个轻量库,只使用它的加密工具类。
3.3 选课业务接口的并发处理
选课接口是整个系统里最具并发风险的场景,尤其到了抢课高峰,几十上百个学生同时提交选课,如果没有控制就会出现“明明显示有5个名额,最后却有6个学生选课成功”的超卖问题。
先给一个标准的选课Service核心逻辑:
java复制@Transactional(rollbackFor = Exception.class)
public void selectCourse(Long studentId, Long courseId) {
// 1. 判断课程是否存在、状态是否为可选
Course course = courseMapper.selectById(courseId);
if (course == null || course.getStatus() != 1) {
throw new BizException("课程不存在或不在选课时间内");
}
// 2. 判断学生是否已经选过这门课
int count = studentCourseMapper.countByStudentAndCourse(studentId, courseId);
if (count > 0) {
throw new BizException("您已经选过这门课程");
}
// 3. 判断学生当前学期的选课时间冲突
List<Course> selectedCourses = courseMapper.selectStudentCoursesInSemester(studentId, course.getSemesterId());
boolean conflict = selectedCourses.stream().anyMatch(c ->
c.getWeekDay().equals(course.getWeekDay()) &&
c.getStartSection() < course.getEndSection() &&
course.getStartSection() < c.getEndSection()
);
if (conflict) {
throw new BizException("课程时间与其他已选课程冲突");
}
// 4. 原子更新课程已选人数,核心防超选
int rows = courseMapper.increaseSelectedCount(courseId);
if (rows == 0) {
throw new BizException("该课程名额已满");
}
// 5. 插入选课记录
StudentCourse sc = new StudentCourse();
sc.setStudentId(studentId);
sc.setCourseId(courseId);
sc.setSemesterId(course.getSemesterId());
sc.setStatus(1);
studentCourseMapper.insert(sc);
}
注意步骤2和步骤3之间理论上存在时间窗口,并发极端时可能两个人同时都判断通过。这里最终在数据库层靠唯一索引兜底,插入student_course表时如果已经有记录,唯一索引直接报DuplicateKeyException,事务回滚之后已选人数的增加也会一并回滚。加上步骤4的原子更新,这个方案足以满足绝大多数课程场景。如果未来要做到万人级抢一门课,可以在Redis里用Lua脚本预扣库存,再异步同步到MySQL,这是题外话。
时间冲突判断的逻辑当初我写了好几个版本,总结下来这类区间重叠判断公式最通用:两个时间段之间只要满足startSectionOfA < endSectionOfB 且 startSectionOfB < endSectionOfA,就说明有重叠。这个公式建议背下来,很多业务场景都能套用。
3.4 MyBatis的XML与注解取舍
用了MyBatis就必然面临一个选择:SQL写注解里还是XML里。我的团队规范是:简单CRUD用注解,复杂动态SQL用XML。比如根据主键查询、删除这种接口,就直接在Mapper接口方法上加@Select、@Delete,代码量少很多。但像课程列表那种需要条件动态拼接的查询,一定放到XML文件里。
原因很简单,注解拼接动态SQL可读性差,一旦SQL超过十行就很难维护,而且换行缩进在Java字符串里处理非常痛苦。下面这个课程分页条件查询的XML片段对这种场景就很典型:
xml复制<select id="selectCoursePage" resultType="com.example.courseselect.entity.Course">
SELECT c.*, t.name AS teacherName
FROM course c
LEFT JOIN teacher t ON c.teacher_id = t.id
<where>
<if test="semesterId != null">
AND c.semester_id = #{semesterId}
</if>
<if test="courseName != null and courseName != ''">
AND c.course_name LIKE CONCAT('%', #{courseName}, '%')
</if>
<if test="status != null">
AND c.status = #{status}
</if>
</where>
ORDER BY c.create_time DESC
</select>
这段SQL注意两点:
- 表连接查询时实体类里的teacherName字段在resultType自动映射时,如果开启了驼峰映射,别名写成teacherName和teacher_name都能映射上,但我习惯在SQL里直接给别名取成对应的驼峰属性名,这样最不会出问题。
- LIKE模糊查询最好用CONCAT函数拼%,不要直接在SQL里写'%${courseName}%',${}会有SQL注入风险,用#{}加CONCAT才是最安全的写法。
在多个参数传入Mapper方法时,建议加上@Param注解,否则MyBatis只默认支持param1、param2这类位置参数,写多几个条件很容易乱套。别问为什么,问就是我被不带@Param的SQL坑过好几个小时。
4. Vue前端开发与页面实现
4.1 前端工程搭建与技术选择
前端这部分,如果项目源码给的是Vue3版本,我建议用Vite作为构建工具,不管是冷启动还是热更新时间都比Vue CLI时代的Webpack快一个量级。我自己在复现项目时用的是Vue3 + Vite + Element Plus + Pinia这套组合。Vue2版的思路其实也差不多,无非是Vuex换Pinia、Element UI换Element Plus,路由和组件的核心思想是一致的。
下面是创建项目并安装依赖的完整步骤:
bash复制npm create vite@latest course-select-web -- --template vue
cd course-select-web
npm install
npm install vue-router@4 pinia axios element-plus
有几个前端环境配置的真实教训值得记录。第一个是Node版本,Vite 5要求Node 18以上,如果你本机还是Node 14,要么升级Node,要么老老实实降级用Vue CLI。第二个是npm安装依赖经常卡在某个包上,这时候优先换淘宝镜像源安装,命令是npm config set registry https://registry.npmmirror.com,后面再装依赖会快很多。第三个是Element Plus的按需引入很容易配置翻车,新手我建议直接全局引入,反正系统不大,全量打包也就多了几百KB。
4.2 路由守卫与权限控制
前端要先做一套基础权限控制。这里我讲的不是多复杂的动态权限,而是“区分角色”这种基础权限:学生登录后只能访问学生端页面,管理员登录后不能进入学生选课页面。方案是登录成功后,前端根据用户角色动态拼接出可访问的路由表,或者更简单地在路由meta里声明允许的角色列表,然后在路由守卫里拦截。
路由meta方式实现起来最快,也更适合中小型系统。直接把代码示例给出来:
javascript复制const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{ path: '/course/list', component: CourseList, meta: { roles: ['student'] } },
{ path: '/admin/course', component: AdminCourse, meta: { roles: ['admin'] } },
{ path: '/teacher/course', component: TeacherCourse, meta: { roles: ['teacher'] } }
]
}
]
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (!token && to.path !== '/login') {
next('/login')
return
}
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
if (to.meta.roles && !to.meta.roles.includes(userInfo.role)) {
next('/403')
return
}
next()
})
这里最值得注意的就是刷新页面后token和userInfo仍然要从localStorage里恢复,否则一刷新就跳回登录页,这是新手最容易漏掉的问题。
4.3 axios封装与接口联调
axios的二次封装决定了后面所有接口调用的体验。我建议做一个统一的request工具,把baseURL、超时时间、请求拦截器、响应拦截器都收敛在一起:
javascript复制import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
request.interceptors.response.use(
response => {
const res = response.data
if (res.code === 200) {
return res.data
}
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
},
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
ElMessage.error('网络异常,请稍后再试')
return Promise.reject(error)
}
)
export default request
关于baseURL,开发环境有个非常关键的配置问题:如果前端和后端不在同一个端口下,直接请求后端地址必然遇到跨域。最稳妥的方式不是在后端加@CrossOrigin,而是利用Vite的devServer代理转发。如果你在源码里看到后端已经搞了一堆跨域配置,建议优先移掉,改成前端代理:
javascript复制// vite.config.js
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
这样前端代码里请求/api/auth/login时,Vite开发服务器会自动代理转发到后端8080端口。你只需要确保后端接口统一定义在/api这个前缀下就行。这个方案在开发环境零跨域配置,生产环境再用Nginx配一层反向代理,路径不用改一行代码。
4.4 学生选课页功能拆解
选课页是展示前端功力的重点,不是简单的表格摆数据。我通常把页面拆成几个逻辑块:
顶部是学期筛选和搜索区,筛选条件是当前学期、课程名称关键词。课程列表用表格展示,每个课程后面放一个选课按钮。点击选课按钮之前先调用课程详情接口或直接读取当前行数据,检查课程容量状态,前端在按钮上先做一次预判断没满员才允许点击。点击后弹出确认框,防止误触。选课成功后局部刷新列表,并且提示已选人数+1。
这里有个提升体验的小细节:已选人数和容量可以在列里直接渲染成进度条,比如用Element Plus的Progress组件,接近满员时进度条变红,学生一眼就能看出哪些课是热门课。这个展示比单纯放一个“50/60”的数字有感知多了。
维护已选列表和退课逻辑同样重要。退课操作会释放名额,后端要实现事务,先删除或标记student_course表记录,再把course表selected_count减一。很多没有经验的人只做了标记没做减额,结果学生退课后课程名额越来越少,这个bug我得专门在后面的问题排查里再次强调。
5. 本地运行部署全流程
5.1 初始化数据库
拿到源码后第一个动作一定是看SQL脚本,不要一上来就改后端代码。我建议的启动顺序是:数据库→后端→前端。
进入数据库命令行或Navicat,先创建数据库:
sql复制CREATE DATABASE IF NOT EXISTS course_select DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
然后直接执行项目doc目录下的init.sql脚本。如果脚本文件里有创建数据库的语句,就不要再手动建一次,避免重复执行报错。执行完后检查一下核心表是否都创建出来,尤其看course和student_course两张表是否存在,如果有缺失可以单独执行对应表片段。
还需要检查init.sql里是否包含初始数据,比如默认管理员账号。正常完整源码会提供admin账号和若干测试学生、教师、课程数据。如果登录时发现密码不对,先看SQL脚本里密码是不是BCrypt密文,明文123456在数据库里是不可能的,因为登录接口只会用BCrypt去校验。
5.2 启动后端服务
后端启动前确认几个信息:
- JDK版本要与pom.xml里配置的java.version一致,建议JDK 8或JDK 11。
- Maven已安装,并且settings.xml里配置了阿里云公共镜像。
- MySQL数据库账号密码与application.yml里的配置一致。
在IDEA里打开项目后,先执行mvn clean install或者让IDEA自动导入依赖,等依赖下载完后再运行启动类。看到控制台出现“Started CourseSelectApplication”后,可以先用浏览器访问http://localhost:8080,看是否有SpringBoot默认错误页或接口文档页面。
如果启动直接报错连不上数据库,优先检查MySQL服务是否启动了。Windows下按Win+R输入services.msc查看MySQL服务状态。Mac下可以brew services list查看。端口被占用就改server.port,改完记得同步修改前端的proxy target端口。
5.3 启动前端开发服务
前端启动步骤很常规:
bash复制cd course-select-web
npm install
npm run dev
这里我遇到过最典型的问题就是npm install报错,尤其是Vite项目经常因为依赖版本兼容性问题装到一半就失败。遇到这种情况不要反复删node_modules重装,可以先看报错信息里是哪个包冲突,然后去package.json里把该包版本调整一下。另一个常用技巧是删除package-lock.json后再重装,有时候lock文件残留了旧的依赖树也会导致问题。
如果代码仓库里提供了package-lock.json,建议优先执行npm ci而不是npm install,npm ci会严格按照lock文件安装,版本一致性更好。
启动成功后终端会输出Local地址,通常是http://localhost:5173,打开后如果能在浏览器看到登录页说明前端环境已经通了。这时候先用自己的管理员账号登录,如果登录失败去浏览器F12看Network里请求的状态码,是401还是500,再倒回后端排查。
5.4 验证主流程
环境全部起来后,建议按下面路径走一遍完整验证:
- 管理员登录系统,进入课程管理页,创建一个新课程,填写基本信息并发布。
- 退出登录,用学生账号登录,在课程列表看到刚才创建的课程。
- 学生点击选课,选课成功后检查个人课表里是否出现该课程。
- 再用第二个学生账号登录,把课程选到满员,再尝试选课时应该提示“课程已满”。
- 学生退掉这门课,回到第一个学生账号刷新,确认课程名额释放。
- 退出学生账号,用教师账号登录,查看选课名单里是否有刚才选课成功的学生。
这六步覆盖了系统三大角色、核心CRUD、选课事务、防超选逻辑、名额释放逻辑。只要这套流程跑通,项目就算真正能在简历上写“可运行的企业级选课系统”了。
6. 高频问题排查与避坑实录
6.1 环境类问题速查
先看一下项目运行过程中我遇到频率最高的环境问题,做成一张速查表供你对照排查。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 后端启动时报SQLException: No suitable driver | pom.xml缺少mysql-connector-java依赖 | 检查依赖并重新导入Maven项目 |
| 数据库连接报Access denied for user | 账号或密码错误、root用户不允许远程登录 | 确认application.yml用户名密码,本地用root默认密码直接改账号配置 |
| 启动报端口占用 | 8080或3000端口被其他进程占用 | 后端改server.port,前端改vite.config.js里的port |
| 前端npm install卡住或一直失败 | npm源不稳定或依赖版本冲突 | 用npm config set registry指定国内镜像源 |
| 页面访问后端接口CORS报错 | 前端直连后端域名端口不一致 | 优先走Vite的proxy代理,前端把baseURL配成/api或相对路径 |
| 接口请求头带token还是返回401 | token过期或拦截器没生效 | 看token有没有被正确塞到Authorization头,后端排除路径是否正确 |
如果你跑的项目是Vue2版本,node-sass是非常经典的痛点。node-sass依赖node版本编译,换了Node版本基本必须重新npm install。这种项目我建议直接替换成sass(dart-sass),兼容性好很多。如果代码里只有.scss文件没用到特殊语法,替换成本通常很低。
6.2 业务逻辑隐藏坑
环境跑通了不代表业务逻辑没坑。我复盘这套系统时至少踩过下面几类逻辑坑:
第一个是退课不退名额。源码里如果标注退课是物理删除student_course记录,那么同时必须把course表中的selected_count减回来。这个操作必须放在同一事务里。只删记录不减名额,课程就会越选越满甚至提前锁死。验证方法很简单:学生选课成功后,到数据库里执行select * from course看目标课程selected_count是1;退课后立刻再查一次,如果是1就说明退课逻辑有bug。
第二个是时间冲突判断只判断“星期几相同”,没有判断节次是否重叠。有一门课周一第1-2节,另一门课周一第3-4节,这其实是可以同时选的。如果判断条件写得太粗暴,会出现误拦。用我前面提到的重叠式判断就能解决。
第三个是学期隔离。选课时如果不校验课程所属学期和当前学期是否一致,就可能出现学生看到下学期已发布课程并且提前选课。这个问题容易发生在创建课程后管理员忘记维护学期状态时,后端必须在选课Service里做一道当前学期的检查,不能让前端按钮控制一切。
第四个是分页查询时实体类里的teacherName字段没有映射成功,表格里教师列显示为空。这类问题多半是实体类里没加这个字段,或者SQL没有把teacher关联进来。排查时先在后端日志里看打印出来的SQL,用客户端拿过去手动执行,看能不能查出teacherName,再往前端排查字段名是否拼错。
6.3 项目可扩展方向
课设做完或简历项目做完之后,可以再往这系统里加几个真实企业会用到的模块,把系统从课程设计水平拉到接近生产线的水平。
我个人比较推荐加这几个扩展方向:
- 成绩管理模块:给教师增加录成绩页面,学生只能查看自己的成绩。这里涉及成绩表与选课记录的关联,同时要做角色隔离,不能让学生访问教师成绩录入接口。
- 消息通知模块:选课成功、课程被退、管理员审核结果都往消息表插数据,前端加消息铃铛做未读红点提醒。这个功能很讨好面试官,因为涉及了典型的未读状态设计。
- 课程评价模块:学生选课后可以给课程评分留言,教师能看到评价但看不到评价人身份,这里涉及到评价表结构和脱敏逻辑,能体现出对真实场景的理解。
- 文件导入导出模块:管理员导Excel批量导入学生教师名单,或者导出选课名单。用EasyExcel或POI都能实现,给项目增加一个很实用的亮点。
另外一个面试加分点是缓存。当前系统每次查看课程列表都要查询MySQL并实时计算已选人数,如果访问量上来,数据库压力比较大。可以引入Redis缓存课程列表,选课时在Redis里预扣库存再异步回写MySQL数据库。这种“先更新缓存再异步落库”的思路不仅适合选课,很多高并发库存扣减场景都通用,但实现成本不算低,要谨慎处理缓存与数据库的一致性问题。如果你对并发有足够把握再往这个方向改,否则面试时被问趴下反而适得其反。
回到开头说的,学生选课系统这类源码之所以值得反复研究,不是因为它用到什么高深技术,而是这套业务里几乎包含了传统管理系统的所有典型问题。用户认证、权限控制、事务一致性、并发控制、前后端联调、权限路由,每一个点单独拿出来面试都够聊一阵子。实际跑一遍、改一个功能模块、修一个并发bug,你学到的项目经验会远大于看十篇教程。
最后分享一个我做这个项目时养成的习惯:每次改完一个功能,不急着提交代码,先把后端SQL日志打开,把前端Network面板打开,完整走一遍功能流程,观察每一步后端实际执行的SQL和前端发起的请求。坚持两周之后,你对数据怎么流转、接口怎么设计的理解会有一个明显的提升。这种调试习惯比项目本身带来的成长价值更大,也更容易在后续真正的企业开发里给你省下大把排查问题的功夫。
