
前两天有朋友问我手上有没有一套适合二次开发的学习管理系统源码要求前后端分离结构能扛住真实业务别拿学生作业糊弄。我翻出自己整理的这套 SpringBootVueMyBatisMySQL 学习管理系统仔细过了一遍觉得里面有挺多值得展开讲的东西就写了这篇文章把底层思路、数据库设计、权限模型、部署细节和踩过的坑一次说透。这套系统不是简单增删改查而是把在线学习最常见的场景——课程管理、章节视频学习、进度记录、在线考试、成绩统计都串了起来前后台用户角色分明接口设计基本能直接对接到真实业务。如果你正准备做毕业设计、想跳进 Java 后端实战项目或者公司需要一个内部培训平台这篇内容应该能帮你省不少事。1. 先说整体设计为什么是这套技术栈1.1 技术选型的底层考量很多刚入行的开发者选技术栈喜欢跟风听说 Spring Cloud 火就上微服务听说 MongoDB 香就把业务全塞进去。但企业级学习平台这类项目核心诉求其实是“稳定、可控、好招人、能交付出东西”并不是越新越复杂越好。这套源码选 SpringBoot Vue MyBatis MySQL就是基于这几个维度权衡来的。SpringBoot 的作用不用多说它把 Spring 生态里的配置地狱收拾干净了内嵌 Tomcat支持java -jar直接启动。以前用 SSH 写个接口要配一堆 XML现在一个注解搞定。对于做学习平台这种业务密集型系统SpringBoot 的自动配置和 MVC 分层模型非常合适开发效率高出了问题也好排查。Vue 选型是因为前端要做成单页应用交互体验比传统 JSP jQuery 强太多。学习平台里最典型的就是课程列表、视频播放、在线答题这些交互场景用 Vue 的组件化开发维护起来非常舒服。源码里用的是 Vue2.x ElementUI虽然现在 Vue3 ElementPlus 已经很流行但 Vue2 的存量项目和企业需求仍然非常多作为学习项目去理解组件通信、路由权限这些思想完全没有过时。MyBatis 是这套系统的关键选择。有人会问为什么不用 JPA JPA 对简单 CRUD 确实省事但一旦涉及多表关联查询、动态条件筛选、复杂报表统计写起来要么 JPQL 一堆字符串要么性能不可控。学习平台里有一堆“用户学习进度统计”“课程完成率排行”这类查询用 MyBatis 写 XML SQL每一个条件、每一段 join 都能精准控制尤其在老 DBA 眼里这种写法才是最可维护的。MyBatis 的动态 SQL 也能比较优雅地解决多条件筛选问题比如用户列表按姓名/角色/状态组合查询。MySQL 作为存储层一是成本低社区版免费二是生态成熟从数据备份、主从复制到云上 RDS 都有大量现成方案。学习平台的读写模型并不复杂MySQL 在合理索引设计下完全撑得起几千到几万并发读的场景。相比 Oracle 或 PostgreSQL国内团队的 MySQL 维护经验更普遍招人面更宽后期接第三方数据分析工具也容易。1.2 功能模块与三种用户角色一个合格的企业级学习平台绝对不是只有“用户登录后看视频”这么简单。这套系统的功能全景可以按三端来划分管理员端、教师端、学生端。三端共用一套后端接口只是通过权限控制访问范围。管理员端主要负责系统基础配置包括用户管理、角色分配、课程分类管理、课程上下架、公告发布、全局数据看板。教师端的核心是把课程“生产”流程走通创建课程、添加章节、上传视频或文档、配置习题、创建试卷、查看考试成绩和统计报表。学生端面向学习者提供课程浏览、章节学习、进度断点续传、在线考试、查看历史成绩等功能。三个角色之间的权限边界必须清晰这直接决定后端接口的安全性设计。我用一个表格把典型权限整理一下方便对照功能区域管理员教师学生用户管理可增删改查、分配角色不可不可课程分类管理可维护不可不可课程管理全权限可创建管理自己的课程仅浏览已上架课程章节学习记录可查看全部可查看自己课程下记录可查看自己的记录题库与试卷可管理可管理仅参加考试考试成绩可查看所有可查看自己课程的统计可查看本人成绩公告管理可管理可查看可查看这种按角色分的权限模型本质上就是 RBAC基于角色的访问控制。数据库里用户和角色是多对多关系一个用户可以有多个角色比如某人既是管理员又是教师。后面的接口鉴权只需要校验当前用户是否拥有所需角色或权限点即可。2. 数据库设计把学习进度、课程、考试拆开看2.1 核心表结构设计思路我在维护这套源码时第一件事就是看表结构。数据库设计得好不好决定了系统后面一半的坑有多少。这套系统遵循了比较规范的命名和设计习惯表名用小写下划线主键统一用bigint自增数据表基本都带create_time、update_time、deleted这三个通用字段。deleted是逻辑删除标志位企业级系统很少物理删除数据因为后续要恢复、要审计、要统计历史数据物理删了就没法追了。以用户和角色为例核心表包含tb_user、tb_role、tb_user_role三张表CREATE TABLE tb_user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 用户ID, username varchar(50) NOT NULL COMMENT 登录名, password varchar(100) NOT NULL COMMENT BCrypt加密密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, email varchar(100) DEFAULT NULL COMMENT 邮箱, phone varchar(20) DEFAULT NULL COMMENT 手机号, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, status tinyint(4) DEFAULT 1 COMMENT 1启用 0禁用, deleted tinyint(4) DEFAULT 0 COMMENT 逻辑删除标记, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;tb_user_role就是最简单的关联表存user_id和role_id两个外键。很多初学者喜欢直接在tb_user里加一个role字段用逗号分隔多个角色比如1,2,3。这么做查询时会非常痛苦统计某个角色的用户数要写FIND_IN_SET而且没法用索引。正确做法就是加关联表逻辑清晰SQL 能走索引维护也方便。课程表和章节表也是类似结构一道课程下面有多个章节章节表用course_id关联课程表同时用sort_no字段控制展示顺序。排序字段一定要预留否则后面加章节插队就会很麻烦。2.2 学习进度与断点续学的数据库实现学习平台最有特色的功能不是课程 CRUD而是“学习进度追踪”。学生看视频看到第 18 分钟退出下次进来还要能从 18 分钟接着看这个功能背后没有太多玄学靠的就是一张设计得比较稳的进度记录表。我以章节维度做记录表名叫tb_learning_record核心字段包括user_id学习用户course_id课程 IDchapter_id章节 IDlast_position上次看到的视频位置单位秒total_duration视频总时长秒finish_flag是否学完0未完成 1已完成update_time上次上报的时间为了防止同一个用户对同一章节产生多条重复记录我在这张表的user_id、course_id、chapter_id上建了联合唯一索引。有了这个唯一索引上报接口就变得非常优雅查询不存在就 INSERT存在就 UPDATE。MyBatis 里可以写一种“存在即更新不存在即插入”的 SQL 方言也可以先查再决定。实际项目中我更推荐直接写一个带有唯一索引的INSERT ... ON DUPLICATE KEY UPDATE语句高并发场景下能省一次数据库请求。什么叫“学习完成”这里有一个细节需要确定不能要求用户看完整整一个视频才算完成一般控制在“播放进度超过总时长的 95%”就算完成。比如视频总长 100 秒学生看到第 96 秒我们就认为学完了避免学生拖到最后 1 秒硬等。这个 95% 的阈值放在后端做比较前端把当前播放时间和总时长传给后端后端统一判断这样即使前端改代码绕过校验后端数据也是真实可靠的。2.3 题库与考试模块的数据结构考试模块是这套系统里最有“报表味”的地方表结构设计直接决定了教师端统计成绩时能不能少熬夜。题目表tb_question字段大概有题干内容、题型单选/多选/判断、选项内容、正确答案、所属课程或章节、题目解析、难度等级等。选择题的选项用 JSON 数组存储比如[A. Java,B. Python,C. Go,D. Rust]这样不必为每个选项再建一张子表查询一次就能取到完整题目数据。试卷表tb_exam和题目之间建议用中间表tb_exam_question关联不见把题目 ID 塞进 JSON。原因很简单如果教师以后要在试卷里有独立的题目分数、独立排序中间表能直接加字段而 JSON 只能整串读取再处理改动代价大。中间表里存exam_id、question_id、score、sort_no这样每个题目在每张试卷里的分值和顺序都是独立的。学生提交试卷后系统会逐个题目比对答案计算得分然后把答题明细写进tb_exam_record_detail汇总成绩写入tb_exam_record。汇总表和明细表分开的好处是列表页查成绩只读汇总表几十万条数据也能很快点进去看答题详情才去查明细表避免大数据量的全表扫描。这种“主表 明细表”的模式在电商订单、考试、报销等场景里随处可见本质上是冗余了一部分汇总数据换查询性能。3. 后端核心功能实现与代码细节3.1 SpringBoot 集成 MyBatis 的配置与 Mapper 写法这套源码的后端不是那种“一个 Controller 干所有事”的玩具代码而是标准的三层架构Controller 负责接口接收参数和结果封装Service 负责业务逻辑Mapper 负责数据库访问。我重点讲讲 MyBatis 的整合细节因为不少人在这里栽过跟头。先把application.yml里数据源相关的核心配置列出来spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/learn_admin?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: yourpassword mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.learn.entity configuration: map-underscores-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有一堆细节要说明。第一map-underscores-to-camel-case: true是必须开的它会自动把数据库里的create_time映射成实体类里的createTime省去一大堆ResultMap手写字段映射。第二mapper-locations要指向 XML 文件目录否则 Mapper 接口配了注解也找不到 SQL。第三log-impl建议在开发环境打开能直接在控制台看到生成的 SQL 和传参排错效率翻倍。Mapper 接口我习惯只写方法签名SQL 放 XML 文件里。拿用户多条件分页查询举例动态 SQL 写法如下select idselectUserPage resultTypecom.learn.entity.User SELECT id, username, real_name, phone, email, status, create_time FROM tb_user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if AND deleted 0 /where ORDER BY create_time DESC /select这种whereif的组合是 MyBatis 核心动态 SQL 里最常用的写法。它有两个好处第一写少了一个条件不会留下多余 AND第二所有条件都是可选的前端页面加个筛选框后端只改一个test条件就行不需要重写 SQL。如果分页需要做物理分页一样要引入 PageHelper 插件是在引入依赖后只需在查询前调用PageHelper.startPage(pageNum, pageSize)后面的查询结果会自动被拦截成分页数据返回的 PageInfo 对象里带了总条数、总页数等信息。这个插件虽然对性能有一点点影响但在常规管理系统里完全够用没必要自己写拦截器。3.2 JWT 登录认证与权限控制的实现企业级系统最重要的就是不能让随便一个人调用管理接口。这套源码采用的是目前前后端分离项目最主流的方案JWT SpringBoot 拦截器。用户在登录接口输入用户名和密码后端先通过username查出来用户再用 BCrypt 的matches方法比对密码。比对成功就可以生成一个 JWT tokentoken 过期时间一般设置为 2 小时然后返回给前端。前端拿到后存在 localStorage 里之后每个请求在 axios 请求头里带上Authorization: Bearer token。后端必须有一个拦截器来处理 token 校验。拦代码的核心逻辑很简单白名单路径直接放行非白名单路径先从请求头里拿 token解析失败就返回 401。解析成功之后把用户 ID 存进 ThreadLocal方便 Controller 和 Service 层直接获取当前用户 ID不需要每个接口都去解析一遍 token。ThreadLocal 用完之后必须记得 remove否则 Tomcat 线程池复用时会数据串掉。密码存储这里要单独提醒别再明文存储也别再用 MD5 了。MD5 虽然不可逆但现在彩虹表基本能秒破简单的密码。这套源码用的是 Spring Security 里的BCryptPasswordEncoder它自动加盐每次 hash 结果都不同暴力破解成本极高。很多教程项目把密码直接用 MD5 存面试官一眼就知道你没做过真实系统。注册接口里把密码encoder.encode()一下再存数据库登录时再用同一个 encoder 去matches()这个模式非常成熟。角色权限这块简单的拦截器只能保证“你是否登录了”不能保证“你有没有权限”。源码里做了一个RequireRole注解拦截器拿到用户 ID 后查出来角色列表再判断当前接口是否允许该角色访问。有人觉得这么做每次请求都查数据库性能会不会有问题在数据量不大时可以接受如果想优化就把角色信息在登录时存进 JWT 或 Redis 缓存后续直接内存判断。过度设计没有意义先把权限控制闭环跑通。3.3 几个容易被忽略的接口设计细节后端代码里有一个课程学习页接口值得单独说说。前端进入课程详情页时需要展示课程基本信息、章节列表、每个章节的学习状态、当前用户进度。很多新手会设计成前端先调课程详情接口再调章节列表接口再调进度接口最后自己在页面里拼数据这样前端的逻辑会非常绕。更合理的方案是后端提供一个“课程学习页聚合接口”一次性返回如下结构课程信息、章节列表每个章节带是否学完、上次学习位置、总学习进度。前端只需要调用一个接口就能渲染整个页面。这种“面向页面聚合接口”的设计思路在互联网企业级项目里很常见目的是减少前后端联调成本和渲染等待时间。但也不是所有地方都聚合像题目详情这种需要单独加载的数据还是应该独立接口避免一次查询太多数据造成响应慢。考试成绩统计接口也很考验后端功力。统计某个教师所有课程的平均分、及格率、参加人数如果只是用纯Group By能写但多表 join 之后 SQL 往往很长。核心思路是分清哪张表做基础表哪张表做子查询。比如统计每个学生的平均分先按用户分组查tb_exam_record再 jointb_user拿姓名涉及课程筛选时要先通过tb_exam定位某一门课程的考试 id 列表再过滤考试记录。索引设计的重点是tb_exam_record上必须有(exam_id, user_id)联合索引tb_exam_question上必须有(exam_id)索引不然统计接口数据一多就会变成慢查询。4. 前端 Vue 侧的实现要点4.1 项目结构和环境准备前端不是把 Vue 当成引入一个 JS 文件就完事的而是用标准脚手架工程化管理。这套源码的前端目录大致如下src ├─ api // 所有接口请求封装 │ ├─ user.js │ ├─ course.js │ └─ exam.js ├─ assets // 静态资源 ├─ components // 公共组件 ├─ router // 路由配置 ├─ store // Vuex 状态管理 ├─ utils // 封装 axios、工具函数 └─ views // 页面组件axios 封装是前端项目里第一步要做好的事。把所有请求集中到utils/request.js统一配置baseURL统一设置请求头统一拦截错误码。比如后端返回 401 时前端不只是弹一个报错而是清空登录态并跳转登录页。返回业务错误码时用 ElementUI 的 Message 组件统一提示。如果不做这层封装每个页面写一段 axios 代码测试环境改一次接口地址就得改几十个文件想想就崩溃。环境配置上开发环境和生产环境的接口地址一定分开。Vue CLI 工程里会用.env.development和.env.production两个文件里面写VUE_APP_BASE_API。开发环境通常配成后端地址生产环境配成同域路径然后由 Nginx 做反向代理。这里有一个关键点如果把后端地址直接写在代码里跨域问题会一直困扰你。开发时用 proxy 代理上线时用 Nginx 代理前端代码里永远只写相对路径/api这个问题就彻底消失了。4.2 路由守卫与按钮级权限登录访问控制这块前端路由守卫和后天按钮级权限分别负责两个层面。路由守卫负责防止未登录用户直接访问系统页面。在router.beforeEach里判断如果访问的路径不在白名单里同时 localStorage 没有 token就强制跳转到登录页。否则放行。除此之外前端路由还应该根据角色做动态菜单。动态菜单的实现思路管理员、教师、学生看到的左侧菜单不同。最好方案是后端在用户登录后返回当前用户的菜单列表前端根据菜单列表addRoutes动态添加路由。但很多小项目为了省事会在前端写死三个菜单数组根据角色字段切换。这两种方案各有优劣源码中采用了一种折中菜单列表在前端配置好通过 Vuex 里保存的角色标识来筛选可见菜单按钮级权限则用自定义指令v-permission控制。比如只有管理员才能看到“删除用户”按钮那就给按钮加v-permission[admin]指令内部发现当前用户角色不满足时直接把 DOM 移除。需要特别注意的是前端权限永远只能提升体验不能作为安全屏障。就算前端隐藏了删除按钮懂技术的用户照样可以自己调接口删除数据所以权限控制的核心还是后端拦截器和角色校验。前端这些工作只是让普通用户操作界面更干净。4.3 视频播放与学习进度上报的方法学习平台的核心页面是视频学习页。前端用video.js或原生video标签播放后端上传的 MP4 或者 m3u8 视频。如果视频源是 m3u8我不会直接给video标签 src而是用 Hls.js 库转码播放这也是现在比较主流的方案免安装插件浏览器原生 JS 播放。播放器需要监听timeupdate事件获取当前播放位置。这里有个性能问题timeupdate事件每秒会触发很多次不可能每次都调后端上报接口。我采用的策略是每 10 秒或者播放位置累计增加 10 秒时上报一次同时在页面beforeRouteLeave和组件beforeDestroy时做一次最后的强制上报确保最后的学习位置不会丢失。前端的last_position取当前播放器的currentTime取整total_duration取duration。还有一个细节如果学生直接拖进度条到结尾进度上报会立刻触发完成。从产品角度这可能被算作刷课但企业内训系统往往默认信任用户如果要做防刷需要后端校验学习总时长是否达到视频时长比例或者在前端屏蔽大幅跳转。这套源码目前在接口层做了一个最小限制如果接口上报的总时长比例明显不合理比如只学习 1 分钟但当前位点跳到了第 50 分钟后端会拒绝该次更新并返回警告码。这个逻辑虽然简单但已经能挡住大部分无意拖拽造成的假进度。5. 本地部署从零跑通完整流程5.1 环境准备与 MySQL 初始化拿到源码后第一件事不是看代码而是先把环境跑通我会按下面这个顺序准备工具JDK 1.8版本太高会有兼容问题建议 1.8 或 11Maven 3.6MySQL 5.7 或 8.0Node.js 12建议 14.20 稳定版IDEA 或 VSCode数据库初始化看起来简单但很多人会在编码上踩坑。源码里一般会提供一份.sql文件直接用 Navicat 或命令行导入。这里我说一个自己的习惯建库时一定指定默认字符集命令是CREATE DATABASE learn_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;utf8mb4和utf8的区别在于 emoji 和生僻字。如果只建了utf8用户昵称里带个 emoji 表情就会插入失败那种报错特别难排查。表结构里的字符集我一般也统一改成utf8mb4避免后续导入数据乱码或报错。MySQL 8 和 MySQL 5.7 的连接参数有一点不同。MySQL 8 的驱动类名是com.mysql.cj.jdbc.DriverURL 里必须设置serverTimezoneAsia/Shanghai否则 JDBC 会报时区错误。如果用的是 MySQL 8 缓存认证插件还可能要加allowPublicKeyRetrievaltrueuseSSLfalse。这些参数看着琐碎但没有它们项目连数据库都启动不了。5.2 后端启动步骤与参数修正后端项目是一个标准的 Maven 工程。打开application.yml或application-dev.yml把username、password改成自己本地的数据库账号密码确认表名与 SQL 脚本里一致。然后执行mvn clean install -DskipTests如果是用 IDEA 打开直接找启动类LearningApplication右键运行即可。建议第一次运行前先在 Maven 面板里clean再install把依赖完整拉一遍。这里我要多说一句国内网络环境下载 Maven 依赖容易慢或失败建议在settings.xml里配置阿里云 Maven 镜像。这一步是所有 Java 开发者的标配就不过多展开了。启动后看到控制台出现Started LearningApplication说明后端已成功运行在 8080 端口。这时可以先测试登录接口用 POST 请求http://localhost:8080/api/login传username和password正常情况下会返回一个 token 字符串。我用 Apifox 或 Postman 测接口的习惯是先把登录接口调通再测需要鉴权的接口可以在调试过程中确认拦截器是否生效。5.3 前端启动与前后端联调前端项目目录通常叫learn-admin-web或frontend。打开终端执行npm install npm run serve如果 node_modules 安装太慢可以先配置 npm 镜像。启动后默认访问http://localhost:8081端口和 vebpack 配置有关通常不会和 8080 冲突。前后端联调最先遇到的问题就是跨域。让我强调前面的观点不要在前端 axios 里写上http://localhost:8080全路径应该配置代理。Vue CLI 项目的vue.config.js里可以通过如下配置解决module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样前端所有请求都写/api/login开发服务器会自动转发到后端 8080 端口浏览器端没有跨域问题。上面的changeOrigin: true会把请求头里的 Host 改成 target 地址后端不容易配置一些边界情况我一直提醒不要漏掉。登录流程联调通过后基本剩下就是功能测试。我建议按角色分别测先用管理员创建课程、分配教师再用教师账号上传章节最后用学生账号学习并考试。这套流程走通后整个系统的主链路才算闭环。5.4 部署上线时的配置调整本地跑通只是第一步真正上线部署时还要做几件事。前端执行npm run build生成dist目录里面是纯静态文件。在服务器上用 Nginx 托管dist目录并配置反向代理server { listen 80; server_name your.domain.com; root /opt/learn/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }最后一行try_files是前端 history 路由模式必备配置。如果没有它用户直接访问/course/123刷新页面Nginx 会去找course/123这个文件结果返回 404。加了这个配置后找不到文件就回退到index.html由前端路由接管。后端的部署只需要打成 jar 包Java 环境直接执行java -jar learning-platform.jar --spring.profiles.activeprod生产环境的application-prod.yml里要替换正式数据库地址同时把日志输出到文件并设置合理的日志保留策略。条件允许的话建议用 systemd 托管java进程不然进程挂了没人能拉起来。这个点虽然普通但对运营稳定性很重要。6. 常见问题排查那些年踩过的坑6.1 数据库连接与字符集问题我先整理几个最高频的数据库问题做成速查表供参考现象常见原因解决方案连接报Unknown initial character setURL 没指定字符集加characterEncodingutf8中文显示乱码建库/表不是utf8mb4重建库表或用ALTER TABLE转字符集报Public Key Retrieval is not allowedMySQL8 安全机制URL 加allowPublicKeyRetrievaltrue报Connection refused端口/防火墙没放行检查 MySQL 监听地址和端口报Access denied for user账号密码不对或权限不足确认密码用GRANT授权有一次我在 TablePlus 里导入 SQL 脚本看起来全部成功但启动项目后报某张表不存在。后来检查发现是导入时选错了数据库脚本执行到了information_schema。这种错误很搞笑但很常见大家导入 SQL 前要确认左上角选择的库名不要顺手点错。6.2 MyBatis 相关报错与排查MyBatis 最常见的报错应该是Invalid bound statement (not found)。出现这个错误通常有两个原因一是 Mapper 接口的全限定名和 XML 文件的namespace对不上二是 XML 文件没有编译到 target/classes 目录里或者mapper-locations没写对。排查思路是先看 XML 的 namespace 是否等于接口的全限定名再看 target 目录下有没有对应的 XML。如果用的是 Maven 工程还要在 pom.xml 配置 resources 包含mapper/*.xml否则打包后会漏掉。TooManyResultsException也比较常见。某次写了一个查询单条记录的接口但业务上同一条数据重复插入导致查询返回多条记录MyBatis 直接抛异常。这个问题的核心是唯一索引没建好比如用户学习记录表如果没建(user_id, course_id, chapter_id)唯一索引进度上报接口很可能查出多条数据。数据库约束和业务逻辑是双保险不能只靠代码里加 if 判断。6.3 跨域与前端联调问题如果前端没有走代理直接访问后端接口大概率遇到 CORS 错误。浏览器同源策略会拦截响应控制台出现No Access-Control-Allow-Origin header is present。解决方案分两种一种是后端允许跨域比如写一个 CorsFilter 放行指定域名另一种是前端走代理我强烈推荐用代理法。生产环境用 Nginx 反向代理开发环境用 devServer proxy这样前端代码里始终是相对路径跨域问题从头到尾都不用担心。还有一个常见现象是接口能调通但浏览器里打印的数据是红色的仔细看发现是 401。这种情况大多是 token 过期或者请求头名称不对。前端的 axios 拦截器里拿 token 时要和后端拦截器取的头部名对应比如后端从Authorization里取那前端就要设置config.headers[Authorization] Bearer token。字母大小写无所谓但少一个 Bearer 前缀就会解析失败。我的习惯是写一个常量类统一管理请求头名称避免前后端各写各的埋下隐患。6.4 视频进度不记录的坑很多学习者把系统跑起来之后发现学习记录始终为 0。我看过几个项目最终都是这几个原因第一前端没有在timeupdate里做节流上报甚至忘了绑定事件第二上报接口请求失败被 axios 拦截器吞掉了页面毫无感知第三后端判断finish_flag时用的阈值是“播放时长大于总时长”但前端上报的“播放时长”是累计观看时长不是当前位点逻辑不对导致永远没法完成。我的建议是先把后端接口日志打开确认真实有请求进来。再用开发者工具看 Network查看请求体和响应体。如果请求没有发出就检查前端currentTime获取方式和触发时机。如果请求发出去了但没更新数据库就查 SQL 和事务。定位思路无非是三层前端有没有发、后端有没有收到、收到后有没有执行更新。按这个思路来99% 的进度问题都能解决。7. 二次开发扩展方向与个人经验7.1 还可以往哪些方向扩展这套系统作为基础框架是很完整的但在真实商业环境中至少还有几个方向可以继续增强。第一个是缓存与性能优化。现在课程详情、公告列表这类读多写少的数据每次都查 MySQL并发一高就会吃力。推荐引入 Redis把课程主页缓存起来章节学习进度可以只在 Redis 里累积定时刷新到 MySQL。考试排行榜、在线人数展示这种数据更适合用 Redis 的 sorted set。不必一开始就上等真的出现性能瓶颈再引入也不迟。第二个是文件存储与 CDN。目前视频资源可能是上传到本地磁盘但做正经产品一定要换对象存储和 CDN因为视频流量大单机磁盘既扛不住容量也扛不住带宽。代码层面只需要把上传逻辑封装成一个FileStorageService接口以后接入阿里云 OSS 或腾讯云 COS 时只换一个实现类就行。这个抽象设计在源码里已经预留后续扩展成本很低。第三个是在线考试防作弊。目前客观题自动判分已经实现但如何防止学生切换页面搜索答案、如何限制考试时间到点自动交卷、如何从题库随机抽题打乱题目顺序这些都是可以继续迭代的模块。防作弊不能依赖前端后端必须记录每道题的答题时长、学生切屏次数、交卷 IP 等信息。第四个是数据大屏与智能分析。标题里有“智能”二字真正的智能可以落到学习行为分析上按用户分析学习时长趋势、按课程分析完课率、按题目分析错误率。这些报表数据可以从tb_learning_record、tb_exam_record里聚合出来再用 ECharts 展示。想做加分项的同学把这几张表做成统计接口和图表页面项目完成度会直接上一个台阶。7.2 作为维护者最想提醒几位后来的开发者这套源码我实际维护过一段时间最大的感受是不要急着加花哨功能先把基础权限和数据结构设计清楚后面迭代会舒服很多。网上很多管理系统要么没有角色权限要么表全部塞在一起等我接手真实项目就会很被动。如果你要拿这套系统去面试或做二次开发我建议重点关注三个点一是能不能把 RBAC 权限模型讲明白二是能不能解释清楚学习进度表的唯一索引和幂等设计三是能不能说出考试模块为什么要拆主表和明细表。这三个问题一旦答上来面试官基本不会再觉得你是“只会复制 CRUD”的项目选手。最后再分享一个小技巧代码里所有接口返回结构都保持了统一格式也就是包含 code、message、data 的标准响应体。无论前端还是后端改起来都能少吵架。新接手的同学在加功能时千万要沿用这个格式不要突然自己包一层 data否则前端拿到数据后解析规则不统一bug 一个接一个冒出来。做成习惯之后你会发现组队协作的摩擦会少很多。