☰
课程管理系统设计与源码解析:从权限划分到Spring Boot+Vue实战
2026/10/14 6:40:30 网站建设 项目流程

做课程设计最怕的不是不会写代码,而是拿到需求之后半天不知道怎么下手。这次拿到的项目编号是16731,主题很直白:课程管理系统设计与实现,整套源码跟着一起放出来,很多同学看到“可白嫖源码”几个字就急着去找下载按钮,我的建议是先别急。源码能跑起来只是第一步,真正值得花时间的,是它背后的业务拆解和技术选型。这篇文章我不打算写成一个“下一步下一步”的安装教程,而是把这套系统当成案例来拆:管理边界、数据表设计、核心代码、前后端联调、部署时容易踩的坑,一层层讲清楚。

想从这篇文章里拿到三样东西的人最合适:第一,正在做课程设计,但不知道怎么划分功能模块;第二,看源码容易头晕,希望有人把关键代码讲一遍;第三,已经跑通了别人的系统,想改造成自己拿去答辩的版本。如果你只是想要一个现成链接,那这篇文章也会把源码里的工程结构完整说一遍,你听完再动手反而更稳。

1. 别急着写代码:先看懂16731课程管理系统的业务边界

1.1 三种用户角色,本质上就是三套不同的操作权限

课程管理系统不是“学生能看到课、点一下选课”那么简单。真正落到源码里,最核心的其实是权限划分:谁能看,谁能改,谁能删。这个系统把用户分成三类:管理员、教师、学生。这个划分在数据库里通常体现为一个role字段,但在功能上,它决定了整个系统的操作边界。

管理员负责的是全局数据:用户管理、课程审核、公告发布、数据统计。教师负责自己的教学数据:课程信息维护、查看选课名单、录入和修改成绩。学生只处理两件事:选课退课、查看成绩。这里有个非常容易犯的错——很多初学者以为只要在前端菜单里把“成绩录入”按钮藏起来,学生就没法操作了。实际上学生完全可以绕过页面直接调用后端接口,如果后端接口没有按角色校验,那就是一个严重的越权漏洞。我在分析这套源码时,第一件事就是把每个接口对应的角色权限找出来,凡是没做校验的接口,都值得重点标注。

1.2 核心数据流:从登录到出成绩一共走几步

把业务流程理顺,比看代码更重要。课程管理系统的核心数据流其实可以压缩成七步:管理员维护账号;教师维护课程;课程审核上线;学生浏览选课;记录选课关系并累加选课人数;教师录入成绩;学生查询成绩。每一步都对应一个数据表的变化,前两步是准备数据,中间两步是核心业务,最后两步是结果沉淀。

这里有一个很容易忽略的点:选课表和成绩表必须分开。理由很简单,选课关系是“正在进行时”,学生可以退课,退掉之后这条关系就不该影响了。而成绩是“完成时”,老师录入完了,学生退课也不能把成绩删掉,因为成绩需要留痕。很多课程设计项目把成绩直接挂在选课表上,表面上看省了一张表,实际上后面做退课、做统计、做补考成绩修改的时候会非常难受。

1.3 功能边界:什么该做,什么不该做

我习惯在拆解项目前画一张功能对照表,把每个角色能做的事情列出来。下面是这套系统里典型的模块划分:

功能模块管理员教师学生
登录认证有有有
用户管理有无无
课程发布与审核审核、下架发布、编辑查询
选课退课查看统计查看名单选课、退课
成绩管理查看全部录入、修改查询本人成绩
公告信息发布无查看

边界原则很简单:每个角色只能修改自己业务域内的数据。学生管自己的选课关系,教师管自己课程下的成绩,管理员做审核和全局兜底。搞清楚这条边界之后,后面写接口、写页面都会顺畅很多,不至于出现“学生点了某按钮,把老师账号给删了”这种离谱需求。

2. 技术选型为什么是Spring Boot + Vue + MySQL:对比完你就会明白

2.1 后端框架:为什么不是Servlet,也不是SSH

很多学校还在教Servlet或者SSH,但真正放到课设阶段,这两个方案的开发效率真的不够看。纯Servlet写一个增删改查,需要手动处理请求转发、参数封装、事务控制,代码量翻一倍;SSH虽然是经典组合,但配置文件的繁琐程度足以劝退思路清晰的人。这套源码选了Spring Boot + MyBatis,是务实的选择。

Spring Boot最明显的优势是配置简化。内嵌Tomcat、自动装配、起步依赖,一个application.yml就能把数据源和端口配好。对于课设来说,开发重点应该放在业务逻辑上,而不是浪费在容器配置里。MyBatis则提供了更直接的SQL控制,像“选课时要执行一条带条件的update语句”这种需求,MyBatis可以写得很清楚,调试也方便。相比之下,Spring Data JPA虽然也能用,但对学生来说SQL反而不可见了,出了问题更难排查。

2.2 前端框架:Vue2还是Vue3,要看源码本身怎么选

从源码结构来看,项目大概率是Vue2 + Element UI的组合。为什么这套源码会选Vue2?原因很现实:Vue2生态最稳,Element UI的组件文档多,网上后台管理模板一抓一大把,初学者遇到问题也能很快搜到解决方案。我并不是说Vue2比Vue3好,而是放到课程设计这个场景里,“参考资料多”本身就是最大的优势。

如果你要拿这套源码二次开发,我建议趁这个机会把前端升级成Vue3 + Vite + Element Plus。改造成本主要在组件引入方式和路由写法上,业务逻辑不用动。前端工程一般分成四个目录:api放请求接口,router放路由,store存用户登录状态,views放页面组件。这套结构很清晰,页面和接口分开,至少不会出现一个文件八百行的惨案。

2.3 数据库:MySQL足够,Redis不是必需品

课程管理系统的数据量根本到不了需要上缓存的规模,一张课程表几百条数据,MySQL一个普通查询就能搞定。很多同学喜欢在课设里强行引入Redis,理由是“高并发选课需要缓存”。但说白了,没有压测数据支撑的缓存设计,在答辩时很容易被老师问住。真要解决选课并发问题,正确做法是对数据库行加锁,而不是在系统前面套一个缓存。

MySQL选5.7还是8.0都行,主要看源码里用的驱动版本。如果项目用的是老版本驱动,直接上MySQL 8.0可能会碰到密码插件不认识的问题,到时候又要改配置。我建议先看项目里的SQL脚本和依赖版本,再决定本地装哪个版本的数据库,省得环境折腾半天。

3. 数据库建模的关键:几张表如何把选课、成绩、公告串起来

3.1 核心表结构长什么样

剥开这套源码的SQL脚本,核心表大概有这几张:sys_user用户表、course课程表、course_selection选课表、score成绩表、notice公告表。有些系统会加operation_log操作日志表,用来记录管理员的关键操作,课设里属于加分项。

先看用户表:

CREATE TABLE `sys_user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT '加密后的密码', `name` VARCHAR(50) COMMENT '真实姓名', `role` VARCHAR(20) COMMENT 'admin/teacher/student', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) );

这里有两个细节值得说。第一,密码字段长度至少要留到100,使用BCrypt加密后的字符串长度有60位左右,只有20个长度的字段根本存不下。第二,用role字段区分身份,而不是建三张用户表,是因为用户表本身结构几乎一样,建三张表只会让登录逻辑变成三份复制粘贴。

课程表是最核心的业务表:

CREATE TABLE `course` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL COMMENT '课程名称', `teacher_id` INT NOT NULL COMMENT '授课教师ID', `credit` DECIMAL(3,1) COMMENT '学分', `capacity` INT COMMENT '选课容量', `selected_count` INT DEFAULT 0 COMMENT '已选人数', `status` TINYINT DEFAULT 1 COMMENT '1-上架 0-下架', PRIMARY KEY (`id`), KEY `idx_teacher` (`teacher_id`) );

selected_count这个字段属于典型的“空间换时间”设计。如果不加它,每次课程列表都要统计一次选课表,数据量小的时候没事,但页面会明显变慢。加了它,查询速度提升了,代价是需要保证更新一致性,这个后面讲选课接口时再展开。

3.2 选课表与成绩表:为什么不能合并

选课表负责记录“学生和课程之间的当前关系”:

CREATE TABLE `course_selection` ( `id` INT NOT NULL AUTO_INCREMENT, `student_id` INT NOT NULL, `course_id` INT NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_course` (`student_id`, `course_id`) );

成绩表则单独记录结果数据:

CREATE TABLE `score` ( `id` INT NOT NULL AUTO_INCREMENT, `student_id` INT NOT NULL, `course_id` INT NOT NULL, `score` DECIMAL(5,2), `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_stu_course` (`student_id`, `course_id`) );

这两张表拆开的理由其实前面已经讲过:选课表会随着退课操作删除记录,成绩表一旦录入就应当保留。如果合并在一起,退课之后成绩也跟着消失,这在业务上是说不通的。两张表都加了(student_id, course_id)的唯一键,这是数据库层面的最后一道防线,防止同一学生同一门课出现两条选课记录或两条成绩记录。

3.3 选课人数限制:数据库字段怎么写

课程容量和已选人数的关系就是一个约束条件,在SQL层可以这样表达:

UPDATE `course` SET `selected_count` = `selected_count` + 1 WHERE `id` = #{courseId} AND `selected_count` < `capacity`;

这条语句的意思很清楚:只有当当前已选人数小于容量时,才允许把人数加一。如果影响行数是0,说明课程已经选满。用这种方式比“先查人数,判断,再更新”安全得多,因为查询和更新之间可能插入另一个请求,导致两个人同时选最后一门课。数据库里的条件和业务里的判断,二者缺一不可。

4. 核心功能实现拆解:登录拦截、选课防超、成绩录入

4.1 登录鉴权:不能只靠前端跳转

登录逻辑看起来很简单:接收账号密码,查库,比对,返回一个token。但源码里值得学习的是它对密码和token的处理方式。密码不能明文存,至少要用BCrypt加盐哈希;前端拿到登录成功后返回的token,后续每个请求都带上,后端再统一校验。

后端接口的大致写法:

@PostMapping("/login") public Result login(@RequestBody LoginRequest req) { User user = userService.findByUsername(req.getUsername()); if (user == null || !passwordEncoder.matches(req.getPassword(), user.getPassword())) { return Result.error("账号或密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getRole()); return Result.ok().put("token", token); }

有了token之后,还需要一个拦截器来统一做身份识别:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); Long userId = JwtUtil.parseToken(token); if (userId == null) { throw new BizException("未登录或登录已过期"); } request.setAttribute("userId", userId); return true; } }

这里有个细节:解析token之后,把userId放进request请求域中,后续Controller方法里直接取,而不是每个接口再去解析一遍。这个习惯很值得模仿,代码会干净很多。

4.2 选课接口:锁行、判满、插入,一步都不能少

选课是整个系统里最容易出并发问题的接口。如果不加控制,两个学生同时选最后一门课,可能都会选上,但课程容量只有一个。正确写法应该分三步操作:

@Transactional(rollbackFor = Exception.class) public void selectCourse(Long studentId, Long courseId) { Course course = courseMapper.selectByIdForUpdate(courseId); if (course == null) { throw new BizException("课程不存在"); } if (course.getSelectedCount() >= course.getCapacity()) { throw new BizException("课程已选满"); } if (courseSelectionMapper.exists(studentId, courseId)) { throw new BizException("请不要重复选课"); } courseMapper.increaseSelectedCount(courseId); courseSelectionMapper.insert(studentId, courseId); }

这段代码里最关键的是selectByIdForUpdate。它执行的是SELECT ... FOR UPDATE,意思是把课程这一行锁住,直到当前事务提交。这样第二个选同一门课的学生会被阻塞,等第一个事务提交后,再读取到最新的人数,发现已经满了,就会抛出“课程已选满”的异常。

看到这里可能有同学会问:前面不是写了一条带条件的update语句吗?为什么这里还要先查再更新?因为在真实场景里,先锁行可以拿到课程对象做更多判断,比如判断课程状态是不是上架、判断学生有没有选过。把状态判断和人数更新拆开,业务逻辑更清晰,事务内执行时间也短,不会长时间占用锁。

4.3 成绩录入:先确认关系,再做插入或更新

教师录入成绩时,后端接口首先要判断身份:当前用户是不是教师的角色?这门课是不是这个教师创建的?否则一个教师把另一个教师的课程成绩改了,数据就乱了。第二步是判断学生是否真的选过这门课,没有选课关系就录入成绩,属于非法数据。

成绩录入通常是两种情况:第一次录,属于插入;录错了改分数,属于更新。稳妥的做法是先查成绩记录是否存在,再决定是insert还是update,或者直接用带唯一键的INSERT ... ON DUPLICATE KEY UPDATE。我建议使用事务加先查询的方式,因为这样在成绩表没有唯一键的旧版本结构里也能正常工作。

成绩统计则用SQL聚合。比如统计一个学生所有课程的平均分,可以这样:

SELECT ROUND(AVG(score), 2) AS avg_score FROM score WHERE student_id = #{studentId};

统计某门课的选课人数、及格率、分数段分布,也都是用COUNT、SUM、GROUP BY就能解决。这块内容主要考验SQL熟练度,源码里的写法完全可以作为模板参考。

5. 前端联调要点:路由守卫、Token注入和选课按钮状态

5.1 路由守卫:根据角色放行页面

前端页面一般分学生端、教师端、管理端。最简单的方式是给路由配置meta.roles,然后在全局路由守卫里判断当前用户角色:

const routes = [ { path: '/login', component: Login }, { path: '/student/courses', component: CourseList, meta: { roles: ['student'] } }, { path: '/teacher/scores', component: ScoreManage, meta: { roles: ['teacher'] } }, { path: '/admin/users', component: UserManage, meta: { roles: ['admin'] } } ]; router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); const role = localStorage.getItem('role'); if (to.meta.roles && !to.meta.roles.includes(role)) { next('/login'); } else { next(); } });

前端的角色控制只是提升体验,真正安全的后端接口权限校验不能省。这一点必须在源码分析里反复强调:页面可以骗人,接口不能骗人。

5.2 Token统一注入:用Axios拦截器少写很多代码

如果每个请求都手动往header里塞token,页面一多就会写吐。用Axios拦截器统一处理是标准做法:

axios.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = token; } return config; }); axios.interceptors.response.use( (response) => response, (error) => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );

这里有一个小细节:登录成功后,前端不要只存token,用户角色也要一起存到本地。因为路由守卫需要角色信息来决定放行哪些页面。当然,更严谨的做法是每次进入系统时调用一个接口获取当前用户信息,但从课设角度来说,先存本地已经够用。

5.3 选课按钮的防重复点击

学生选课时,如果接口还没返回结果,按钮又被点了一次,就会发出两个选课请求。虽然后端唯一键能挡住重复数据,但前端体验会非常差。常见的做法是把按钮设置为加载中,同时把选满状态置为禁用:

<el-button :loading="selecting" :disabled="course.selected_count >= course.capacity || selecting" @click="handleSelect(course)" > 选课 </el-button>

在点击事件里:

async handleSelect(course) { if (this.selecting) return; this.selecting = true; try { await selectCourseApi(course.id); this.$message.success('选课成功'); this.loadCourses(); } catch (e) { this.$message.error(e.message); } finally { this.selecting = false; } }

这种“先置灰、请求完成后再恢复”的交互模式,在很多系统里都适用。它不一定能解决后端并发,但至少不会因为用户手抖产生一堆重复请求。

6. 源码落地指南:环境配置、常见报错与改造方向

6.1 环境版本对照表

我拿到这套源码后,先整理了一份环境版本清单,建议按这个思路来配:

组件建议版本说明
JDK1.8 或 17看Spring Boot版本,2.x用1.8,3.x用17
Maven3.8+用于后端依赖管理和打包
Node14 或 16Vue2项目建议14,Vue3项目建议16及以上
MySQL5.7 或 8.0导入源码里的SQL脚本
浏览器Chrome/Edge最新版基本没什么限制

表里最值得留意的是JDK版本。Spring Boot 2.x在JDK17上跑也有可能出兼容问题,反过来Spring Boot 3.x又不支持JDK1.8。我的建议是:先看pom.xml里的spring-boot版本再装环境,别一上来就装最新版。

6.2 我运行这套源码时踩过的四个坑

第一个是数据库连接报错。大多数情况是application.yml里的数据库名、用户名、密码和本地环境不一致,改成自己的配置就行。第二个是前端npm install卡住,尤其是网络环境不好的时候,把镜像源切到国内源会快很多。第三个是端口冲突,后端默认8080,本地其他服务占了就会启动失败,改端口即可。第四个是前后端跨域问题,本地联调时前端通过proxy代理把/api转发到后端端口,比后端写@CrossOrigin更干净。

6.3 拿到源码后如何改造成自己的课设

白嫖源码本身没问题,但直接原封不动交上去,大概率会被老师识破,也很难回答答辩问题。我建议至少做三件事:把数据库表前缀和包名改掉,把前端界面颜色布局重做一版,再加一个你觉得有意义的小功能。比如加一个“教学班管理”,让同一门课可以分多个教学班,或者加一个“选课时间批次”,规定学生在某个时间段内才能选课。

这些改动不需要很高的技术难度,但能证明你看懂了源码,也确实做了二次开发。我之前见过太多学生把源码跑通就以为万事大吉,被老师问一个“你这个选课人数是怎么防止超选的”就当场卡壳。对照本文第4章那段锁行代码,理解清楚原理,这个问题就难不倒了。

我在本地把整套流程跑通之后最大的感受是:这个项目没有用什么高深技术,但每个模块都踏踏实实踩在了业务逻辑的正确节奏上。如果你正准备拿它作为参考,别急着复制粘贴,先把表结构画一遍,把选课流程走一遍,把报错信息记录一遍。这样一来,源码才真正变成了你自己的东西。

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

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

立即咨询