做在线课程管理系统几乎是Java Web开发者绕不开的一类项目。最近我把一套完整源码从需求梳理到部署运行整个流程又认真走了一遍技术栈锁定为 SpringBoot2 Vue3 MyBatis-Plus MySQL8.0顺手把初始化脚本、接口说明、部署步骤整理成了一份文档。这套组合谈不上新奇但胜在成熟稳定、结构清晰非常适合用来理解一个全栈项目从零到一的完整链路也更适合拿来进行二次开发。如果你正准备做一个类似的教务、培训类管理系统或者想找一套能直接改改就能用的课程管理源码这篇文章就是写给这样的你。我会从技术选型的真实原因讲起然后逐步拆解数据库建模、后端认证与分层实现、Vue3前端权限与页面设计最后聊一聊部署阶段容易踩的坑和文档怎么用。整个内容不是按模块平铺功能清单而是把这些环节里为什么这样做的思考过程一并交代清楚。1. 技术选型复盘为什么最终锁定这套组合很多人在开始一个项目时习惯直接打开框架官网照着最新版本建工程等写到一半才发现某个依赖不兼容、某个写法在新版本里被废弃回头改起来极其痛苦。这套系统在选型阶段就先确定了稳字优先SpringBoot2 Vue3 MyBatis-Plus MySQL8.0 看着像一套最平常的组合实际用下来会发现它每个环节都恰好卡在了一个非常合理的位置。1.1 为什么锁定 SpringBoot2 而非 SpringBoot3如果现在才接触 Spring 生态的人大概率会直接选 SpringBoot3毕竟它已经发布一段时间了。但 SpringBoot3 强制要求 JDK17 及以上同时基于 Jakarta EE 规范很多老的依赖包需要跟着换坐标、换写法对于做课程管理系统这类业务系统来说这点性能和新特性上的提升根本覆盖不了兼容成本。SpringBoot2.7.x 是目前非常成熟的版本绝大多数教程、开源组件、生产案例都集中在这一代上团队招人上手也快遇到问题时搜索到的解决方案基本都是可用的。从实际工程角度看SpringBoot2 的核心价值在于它把配置繁琐这件事压缩到了极低的程度。内嵌 Tomcat 让部署不再依赖外部容器自动配置机制让数据源、Web MVC、Jackson 序列化这些基础件开箱即用你只需要关心业务代码。当然如果是全新项目并且团队对 JDK17 没有排斥选 SpringBoot3 也没问题但如果你希望项目能让更多初学者或者合作者直接跑起来2.7.x 是更好的选择。1.2 MyBatis-Plus 在课程管理场景中的不可替代性课程管理系统的数据操作有一个明显特征单表 CRUD 占大头多表查询集中在几个固定场景。比如维护课程基本信息、维护章节列表、查看选课学生名单这些操作写原生 MyBatis 的话每个实体都要配套一堆 insert、update、delete、selectById 的 XML 片段写起来重复且枯燥。MyBatis-Plus 正好解决了这个问题它提供了 BaseMapper内置了常用的单表方法配合 LambdaQueryWrapper 可以完全用 Java 代码完成条件拼接SQL 注入的风险也基本被规避了。有人会问为什么不直接用 JPAJPA 在简单实体映射上确实省事但课程管理系统涉及比较复杂的统计查询比如某教师名下的所有课程及其选课人数按分类统计课程数量这些用 JPA 的规范方法名会变得非常绕还是得写 JPQL 甚至原生 SQL。而 MyBatis-Plus 保留了 MyBatis 的 SQL 掌控力复杂查询继续写在 XML 里简单查询用内置方法两者切换很自然。再加上它对逻辑删除、自动填充、分页插件的支持都很完善几乎是为这类管理系统的数据层量身定做的。1.3 Vue3 与 MySQL8.0 带来的实际收益前端选 Vue3 而不是 Vue2主要看中的是组合式 API 带来的代码组织能力。课程管理后台里一个页面上往往同时有搜索表单、数据表格、弹窗表单、状态标签多个模块Vue2 传统写法很容易在 data、methods、computed 之间来回跳而 Vue3 的 setup 组合式函数可以按业务维度把一段逻辑完整收拢在一起。配合 Element Plus 组件库表格、表单、树形选择这些后台管理的高频控件都有现成方案视觉风格也比较统一。MySQL8.0 的选择理由更直接它是当前主流版本性能、窗口函数、JSON 支持都比 5.7 时代强了不少。课程管理系统里统计某门课程的选课人数并按时间趋势输出这类需求用窗口函数可以在 SQL 层面解决不用把数据拉到应用层再处理。字符集方面默认使用 utf8mb4不用担心课程描述里出现生僻字或者表情符号导致乱码。需要留意的是MySQL8.0 的默认认证插件是 caching_sha2_password驱动必须用对应的较新版本这个细节在后面的部署章节会展开讲。2. 需求到模型课程管理系统的数据库设计思路数据库设计如果从一开始就考虑得不清楚后面写代码时的痛苦会成倍放大。设计这套系统的表结构时我的思路是先不看具体页面而是从业务角色出发把谁在使用系统、他们在完成什么动作梳理清楚再倒推需要哪些实体和关联关系。2.1 从角色反推实体用户、课程、选课三条主线在线课程管理系统里至少有三种角色学生、教师、管理员。三者关注的事情完全不同。管理员维护用户状态、管理课程分类、审核课程上下架、发布公告教师创建课程、维护章节资料、查看选课学生名单学生浏览已上架课程、选择课程、查看学习进度从这些动作能提炼出几个核心实体用户user、课程course、课程分类category、章节course_chapter、选课记录course_selection、公告announcement。用户和课程之间不是简单的一对多因为教师创建课程是一种关系学生选择课程又是另一种关系。推荐的做法是课程表里只保存 teacher_id 字段用于查询某教师的课程列表而学生选课关系独立放到选课记录表里这样后续统计选课人数、判断一个人是否已经选过课都会很方便。2.2 核心表的字段取舍与索引设计这里给出几张核心表的结构设计其中一些字段在实际开发中特别容易踩坑我会在每个表后面说明原因。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT 加密后的密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, role tinyint NOT NULL DEFAULT 2 COMMENT 角色1管理员 2教师 3学生, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1正常 0禁用, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, create_time datetime NOT NULL, update_time datetime NOT NULL, deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除标记, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;username 必须加唯一索引这是登录系统的根基。角色用 tinyint 而不是字符串是为了避免字符串比较带来的额外开销同时在 Java 端用枚举或者常量类做映射语义也很清晰。status 这种小状态字段也建议用数字前端通过字典翻译展示成正常/禁用即可。CREATE TABLE course ( id bigint NOT NULL AUTO_INCREMENT, teacher_id bigint NOT NULL COMMENT 创建课程的教师ID, category_id bigint DEFAULT NULL COMMENT 课程分类ID, title varchar(100) NOT NULL COMMENT 课程标题, cover varchar(255) DEFAULT NULL COMMENT 封面图URL, description text COMMENT 课程简介, price decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 课程价格, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0待审核 1已上架 2已下架, student_count int NOT NULL DEFAULT 0 COMMENT 选课人数冗余字段, create_time datetime NOT NULL, update_time datetime NOT NULL, deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_teacher_id (teacher_id), KEY idx_category_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表;课程表里的 student_count 是典型的冗余字段选课人数可以通过统计选课记录表得到但如果每次展示课程列表都去 count 一遍数据库压力会在列表页集中爆发。这里在选课成功后对 student_count 做加一操作在退选时做减一操作能让列表页直接读取这个字段性能好很多。代价是需要保证事务一致性后面会细说。CREATE TABLE course_selection ( id bigint NOT NULL AUTO_INCREMENT, course_id bigint NOT NULL, student_id bigint NOT NULL, progress tinyint NOT NULL DEFAULT 0 COMMENT 学习进度百分比, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_course_student (course_id, student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选课记录表;选课记录表如果没有唯一索引course_id student_id在高并发下可能出现一个学生重复选择同一门课程的数据脏读问题。虽然业务接口层也会做校验但数据库层面仍然需要一道硬约束作为最后防线。章节表和公告表结构相对简单章节表主要保存课程下的章节目录、视频地址和排序号公告表只需要标题、内容和发布时间这里不再展开。2.3 逻辑删除、时间字段与数据一致性的处理管理系统的数据通常不建议物理删除尤其是用户和课程这类可能有关联记录的数据。MyBatis-Plus 通过 TableLogic 注解可以自动在查询、更新、删除时拼接 deleted 条件开发人员几乎不需要关心这个字段的存在。但这里有两个细节需要特别注意。第一加了逻辑删除后唯一索引可能失效。比如选课记录表如果某学生退选了逻辑上记录还在表中只是 deleted1那么再次选课时插入新记录会触发 unique(course_id, student_id) 冲突。解决方式一般有两种一是把 deleted 字段纳入联合唯一索引但这样同一对课程学生只能退选一次二是选课接口里先查一次 deleted1 的旧记录如果存在则把旧记录恢复并重置 deleted0否则插入新记录。我采用的是第二种方案因为它不会无限增加历史垃圾数据业务表现也更符合直觉。第二create_time 和 update_time 的自动填充。MyBatis-Plus 提供了 MetaObjectHandler 接口可以在 insert 和 update 时自动为这两个字段赋值。这样在 Java 代码里就不需要每次都手动 new Date()代码更干净也不会出现某个表忘记维护时间的问题。3. 后端全链路拆解认证、权限、列表查询的落地细节后端实现这块我希望它既具备足够的工程规范又不至于让代码变得过分复杂。课程管理系统最常见的后端需求集中在这三块登录认证与权限控制、统一的响应与异常结构、列表分页查询。把这些核心链路理清楚整个后端骨架就立起来了。3.1 JWT 认证与拦截器轻量方案为何够用很多教程项目会直接引入 SpringSecurity 数据库 Token 的方案功能强大但配置复杂尤其对于课程管理系统这种角色简单、端点也不多的项目显得有点杀鸡用牛刀。我选择的是 JWT 拦截器这套更轻量的组合代码透明易维护也更容易理解。具体流程是用户提交用户名密码登录后端校验通过后生成一个 JWT把 userId 和 role 这两个关键信息放进 claims设置过期时间返回给前端。前端后续请求在 Authorization 请求头里带上 Token后端写一个拦截器在进入 Controller 之前解析 Token 并存入一个 ThreadLocal 形式的上下文对象里后续业务代码直接从中获取当前登录用户。使用 JJWT 库生成和解析 Token 的核心代码如下public String generateToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() TOKEN_TTL)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }拦截器中只需要三步从请求头拿到 Token调用解析方法解析成功就放行并把用户信息放进上下文。这里要特别提醒解析一定要捕获异常Token 过期、被篡改、格式非法都属于正常情况统一返回到登录状态而不是让异常一路抛到全局处理器里。3.2 Controller-Service-Mapper 三层职责与统一响应分层的核心目的不是让代码变多而是让每一层只干一件事。Controller 负责参数接收和简单校验Service 负责业务逻辑Mapper 负责数据访问。很多新手容易在 Controller 里写大量业务判断比如选课之前判断课程是否存在、是否已上架、是否已经选过这些判断全部堆在 Controller 里会让接口路径和业务逻辑完全耦合后续想复用这段逻辑时也无从下手。系统里所有接口都统一返回 Result 对象结构如下public class ResultT { private Integer code; // 200 成功其他为失败 private String message; private T data; }配合全局异常处理器当业务代码抛出业务异常时统一转换为 code500 的返回结构前端只关心 code 和 message 两个字段。举例说明选课接口的业务校验逻辑在 Service 层的完整顺序当前用户角色必须是学生否则抛出无权限选课课程必须存在且状态为已上架检查选课记录若已存在有效记录抛出不可重复选课若存在逻辑删除的历史记录将其恢复插入新选课记录同时将课程表的 student_count 加一这段逻辑中事务需要加在 Service 方法上因为第 5 步涉及两张表的修改任何一个失败都需要回滚。Spring 的 Transactional 默认只回滚 RuntimeException所以业务异常建议继承 RuntimeException避免事务失效。3.3 MyBatis-Plus 条件构造器与分页插件的实际组合列表查询是后台系统最平常也最容易写差的部分。这套系统里课程列表页需要支持名称模糊搜索、分类筛选、状态筛选并且返回教师姓名、分类名称这类关联信息。我使用 MyBatis-Plus 的分页插件配合 LambdaQueryWrapper 完成条件组装。分页插件只需要在配置类中注册一次Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; }分页查询的执行方式如下PageCourseVO page new Page(pageNum, pageSize); LambdaQueryWrapperCourse wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(title), Course::getTitle, title) .eq(categoryId ! null, Course::getCategoryId, categoryId) .eq(status ! null, Course::getStatus, status) .orderByDesc(Course::getCreateTime);这里用了条件构造器的动态拼接特性只有传入参数非空时才拼接对应条件从而避免了一大堆 if 判断。但有一个坑要知道当列表需要关联查询教师名字、分类名时直接在 Wrapper 上 join 并不方便。我的做法是自定义一个 SQL在 XML 中写联表查询并保证查询结果字段映射到 CourseVO分页仍然由插件自动完成。换句话说日常简单筛选用 Wrapper涉及多表关联时就干脆写一段明确的 SQL两者互补这才是 MyBatis-Plus 的正确用法。4. Vue3 前端的实现重点请求层、路由权限与课程页面前端这部分重构到一半时我最大的感受是Vue3 真正的好不是某个单点特性而是组合式 API 让代码组织方式发生了质变。下面从工程搭建、请求层封装、路由权限控制、页面组件拆分几个方面展开这些也是后台管理系统中复用价值最高的部分。4.1 用 Vite 搭建工程axios 拦截器统一处理请求前端工程直接用 Vite 创建 Vue3 项目相比 WebpackVite 基于原生 ESM 的开发服务器热更新速度确实快很多几乎改完代码界面立刻刷新。工程内部按 views、components、api、router、store、utils 划分目录和大多数后台项目保持一致。axios 实例的封装是前后端协作的基础。baseURL 设置为 /api开发环境下由 Vite 代理到后端地址生产环境由 Nginx 转发这样前端代码里不会写死后端主机和端口。请求拦截器统一注入 Token响应拦截器统一处理业务 code如果 code 不是 200直接弹出错误消息如果 HTTP 状态码是 401说明 Token 过期或无效清除本地用户信息并跳转登录页。核心代码大致如下const service axios.create({ baseURL: /api, timeout: 10000 }); 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 ! 200) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res; }, error { if (error.response error.response.status 401) { router.push(/login); } ElMessage.error(error.response?.data?.message || 网络异常); return Promise.reject(error); } );这套封装几乎对所有管理后台都通用团队内可以形成一个标准化的 api 模块模板新页面只要按照约定增加新的 api 函数即可。4.2 路由守卫与角色权限的前端控制权限控制不能只依赖后端隐藏接口前端也需要在路由和按钮层面做出拦截。系统把路由分为两类所有角色都能访问的公共路由比如登录页以及需要特定角色才能进入的受限路由比如教师管理后台、课程审核页。受限路由不能一开始就静态注册否则用户仍然可以通过手动输入路径进入页面。我的做法是在路由守卫中动态注册模块。router.beforeEach((to, from, next) { const token localStorage.getItem(token); const userInfo JSON.parse(localStorage.getItem(userInfo) || null); if (!token to.path ! /login) { next(/login); return; } if (token to.path /login) { next(/); return; } if (token userInfo to.meta.roles !to.meta.roles.includes(userInfo.role)) { next(/403); return; } next(); });页面按钮级的权限我封装了一个自定义指令 v-permission用法是 v-permissioncourse:add指令内部校验当前用户权限列表中是否包含该标识不包含就直接从 DOM 中移除元素。这样页面渲染层就与权限逻辑解耦新页面只需要在按钮上标记所需权限码即可。4.3 课程管理页面的组件拆分与状态管理以教师端我的课程页面为例页面内容无非是搜索区、表格区、分页器和编辑弹窗但如果不注意组件拆分很容易把一大堆逻辑堆在一个 Vue 单文件里导致 setup 函数上百行。我按照三个层次做了拆分页面容器 CourseList.vue负责页面级状态和逻辑编排比如课程列表数据加载、筛选条件重置子组件 CourseSearchForm.vue负责搜索表单的输入与重置通过 emit 把查询条件抛给父组件子组件 CourseEditDialog.vue负责新增和编辑课程的表单弹窗内部管理表单校验和提交逻辑状态管理用 Pinia主要存放两类全局状态当前登录用户的资料和角色权限集合。课程列表的筛选条件虽然也可以存 Pinia但实际上用组件间通信就足够了不必什么都放到全局 Store 里。这里有一个比较实用的经验表格数据的加载逻辑封装成一个 useCourseList 组合函数返回列表数据、加载方法、分页参数、筛选条件页面上只需要调用这个函数并绑定到模板逻辑可以完整复用也方便测试。5. 跑通项目与二次开发部署坑位和文档使用经验标题里写了【含文档】说明这套系统把能跑起来当作最基本的目标。源码写得再好如果部署文档缺失或含糊下载者依然会卡在环境问题上。我在配套文档里整理了三大部分环境要求与安装步骤、数据库初始化与账号说明、接口列表与请求示例。下面我把最容易让人浪费时间的几个坑位单独拿出来讲这些是我实际运行和测试时真实碰到过的。5.1 MySQL8.0 环境配置里最容易翻车的地方很多项目跑不起来问题不是出在业务代码上而是数据库连接串写错或者驱动版本不匹配。MySQL8.0 的驱动类名是 com.mysql.cj.jdbc.Driver不再是 5.x 时代那个 com.mysql.jdbc.Driver如果用老驱动启动时直接会报找不到类。另一个高频问题是时区MySQL8 要求连接串里配置 serverTimezone不配置会报Server returns invalid timezone这一类异常。我推荐在 application.yml 里把连接串写得完整一点spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_system?useUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai username: root password: yourpasswordallowPublicKeyRetrievaltrue 这一个参数很关键。MySQL8 的 caching_sha2_password 认证在首次连接时需要获取服务器的公钥如果连接串没有显式允许部分 MySQL 客户端会直接拒绝连接。第一次配 MySQL8 的人几乎都能被这几个错误卡到怀疑人生连串文档里把这些参数注明后这个问题基本就消掉了。5.2 前后端联调时的跨域与端口问题开发环境下前端跑在 5173 端口后端跑在 8080 端口浏览器必然产生跨域请求。Vite 的代理配置是这个环节最简单的解法在 vite.config.js 中设置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } }这样前端请求 /api/course/list开发服务器会代理转发到后端的 /course/list同时去除 /api 前缀。后端也不需要额外开启 CORS 跨域注解完全规避了跨域问题。但如果项目最终要部署在同一台服务器上更好的方案是把前端 build 后生成的 dist 目录交给 Nginx 托管同时 Nginx 配置把 /api 请求反向代理到后端进程。5.3 文档怎么用从初始化脚本到接口说明拿到源码后的标准操作顺序应该是先看 README 中的环境要求确认本机有 JDK8/11、Maven、Node、MySQL8然后创建数据库并导入初始化脚本脚本里包含了建表语句和内置账号比如管理员账号、测试教师账号、测试学生账号方便登录后快速验证不同角色的权限差异接着修改 application.yml 里的数据库密码最后分别是启动后端和前端。初始化脚本里有一个细节我特意做了处理内置账号的密码都是 BCrypt 加密后的串而不是明文。这样做的好处是防止有人拿着文档里的明文密码直接登进系统同时也保持与系统登录逻辑一致。文档里的每个接口都附带请求地址、请求参数和响应示例新加入的开发者不必逐行读代码对着文档就能完成二次开发。接口文档的部分不用维护独立平台直接整理成 Markdown 文件放在 docs 目录里配合源码和数据库脚本构成完整交付。章节列表、课程详情、选课统计这类接口各自一个文档节点查起来也很方便。如果再进一步建议把这些接口导入到接口调试工具里生成可执行集合整个项目上手成本会更低。6. 二次开发如何扩展给学生列表、教师统计加上缓存和定时任务基础版本跑通之后如果想真正用起来通常还需要做一些面向性能和数据实时性的改造。我在实际操作中优先补了两块一是把高频读取的数据放进缓存二是为统计类数据增加定时刷新机制。课程列表页的筛选条件变化频繁每次切换分类或关键词都会触发一次带查询条件的数据库请求直接把课程列表缓存起来不太现实因为缓存 key 难以覆盖所有组合。业界比较成熟的思路是在列表查询前加一层缓存把热门课程前 20 条或每个分类下的推荐课程缓存起来低粒度的定制查询仍然走数据库。对于课程管理系统来说首页推荐位、分类课程榜单这两类数据很适合缓存设置 10 分钟过期时间即可实时性要求不高数据库压力却能明显下降。选课人数和学生列表的统计另外做了定时任务。基础版本在插入选课记录和更新 student_count 时是实时计算的但某课程近 7 天选课趋势这类图表数据如果每次请求都实时聚合开销会比较大。我增加了一个每小时执行的定时任务把各课程的累计选课人数按天汇总写入临时表前端图表直接从临时表取数。这样实时统计和趋势统计互不干扰架构上也给后面接 Redis 或消息队列留出了空间。再分享一个代码层面的优化经验MyBatis-Plus 的逻辑删除和唯一索引冲突问题在选课模块里我采用了恢复旧记录方案这个方案在并发场景下还有一个小隐患。如果两个请求同时发现历史记录不存在就会同时插入新记录后插入的会触发唯一索引冲突抛异常。实际上这个概率很低而且系统里学生自主选课并不会出现多人同时操作同一账号的场景但如果你要把它改造成并发更高的抢课类系统就需要在选课接口加分布式锁或者改用数据库的 INSERT ... ON DUPLICATE KEY UPDATE 语法这是后续扩展时重点要留意的方向。
阅读完成 · 觉得有帮助?