
前后端分离在线互动学习网站这套东西我前后折腾了大半个月从数据库建模到接口联调再到最后部署上线踩了不少坑也理出了不少门道。如果你手头正好有类似选题——比如毕设、课程项目或者公司需要一个轻量的内训平台这篇内容应该能帮你少走很多弯路。我会围绕 SpringBoot Vue MyBatis MySQL 这套组合从项目拆分、数据设计、后端模块、前端交互、部署上线到常见暗坑一条线讲完。先把这个项目到底是什么说清楚它不是一个简单的视频网站而是一个带互动闭环的学习平台。学生可以浏览课程、报名学习、逐节看内容、做题、写评论系统会记录每个人的学习进度管理员和教师则在后台管理课程、章节、题目和用户。放在前后端分离的架构下后端只提供 JSON 接口前端负责页面渲染和交互两者之间通过标准的 HTTP 接口通信。对学习、练手、二次开发来说这套结构最清晰也最容易拉开工作量。1. 项目核心边界与功能拆分这个在线学习平台到底要做什么1.1 三种角色的权限边界管理员、教师、学生各管哪一块在线互动学习网站最容易犯的毛病就是功能列了一堆结果权限边界一塌糊涂。动手之前先把角色分清楚后面写接口、设计数据库都会省很多事。我当时把系统里的用户分成三类角色核心诉求主要操作学生找课、学课、验证学习效果浏览课程、报名、学习章节、做题、评论、查看进度教师生产课程内容、了解学生情况维护课程、上传章节、出题、查看选课人数与平均分管理员管人、管内容、管系统运行审核课程、管理用户、处理举报、查看数据统计这个分法在实现上对应着后端接口的三种权限级别。学生能访问/api/user/**下面的个人和学习接口教师额外能访问/api/teacher/**下面的内容管理接口管理员再往上走一层能访问/api/admin/**。如果你把权限粒度拉平后期想加一个“课程助教”之类的角色会非常痛苦。1.2 为什么这个场景非要用前后端分离放在五六年前很多人做这类系统还是 JSP 或者 Thymeleaf 一套到底。现在再这么做有一个明显问题页面上有大量异步交互。比如学生看视频课时需要实时回传进度、做选择题要立刻判分、评论区要即时刷新这些东西如果靠服务端模板刷新页面体验会很差。前后端分离做这件事就顺手很多。Vue 负责维护当前页面的状态用户点击了哪个章节、当前进度是多少、哪些内容已经学完这些都在前端内存里只有需要持久化时才调用接口。Spring Boot 后端则专心输出结构化的 JSON 数据不关心页面长什么样。两边各自演进前端要改版不至于动到后端逻辑后端要加接口也不影响前端页面结构。这套组合还有一个实际好处Vue 生态里现成的 UI 组件库、状态管理工具、路由方案非常成熟做后台管理类页面基本是搭积木Spring Boot 这边对拦截器、注解、事务的支持很顺手写权限校验和业务事务都不费劲。配合我的经验来看一个单人或者两人小团队做这个体量的系统SpringBoot Vue 比传统服务端渲染方案的开发效率高出一大截。1.3 互动功能到底“互动”在哪里很多人把“互动”理解成在课程下面放一个评论框这其实远远不够。我做的这个项目里“互动”主要体现在三块学习进度互动学生学到哪一章节、哪一节课前端会实时把进度同步给后端重新登录之后能接着上次的位置继续学。即时测验互动小节学完后配套几道练习题提交答案后立刻出得分和解析形成“学—练—评”的闭环。社区式评论互动每个节下面都能发评论其他学生可以回复教师也可以置顶某个答疑。这三块是区分“教学网站”和“视频网站”的关键。视频网站只关心播放量教学网站必须关心学习深度。后面讲到数据库设计和接口划分时你会发现这个定位会直接影响表怎么建、接口怎么拆。2. 数据库设计与接口约定先把房子地基画好2.1 核心表结构与关系梳理如果数据库表设计得稀烂后面所有代码都会跟着遭殃。我在这个项目里把表收敛成了下面几组用户域user用户、role角色、user_role用户角色关联。课程域course课程、category课程分类、chapter章、lesson节、lesson_resource课时内容或视频地址。学习域course_selection选课记录、learning_progress学习进度、favorite收藏。互动域comment评论、reply回复、test_paper测试卷、question题目、test_record答题记录。运营域notice站内通知、operation_log操作日志。这里重点说两个细节。第一个是课程内容的存储方式。课程内容有富文本、图片混排、视频地址、附件链接如果每一段内容都单独拆表查询时要把好几张表拼起来效率很低代码也啰嗦。我的做法是lesson表里的content字段用TEXT长文本保存前端用富文本组件渲染视频地址单独放video_url字段这样后端只把整段 HTML 或 Markdown 字符串取出返回即可。对于这种以展示为主的内容冗余一点不是坏事简化查询才是王道。第二个是学习进度的记录方式。为了精确到“这个学生的这一节看到了第几秒”learning_progress表里我存了user_id、lesson_id、progress_percent、last_position、update_time五个关键字段。last_position是视频播放秒数或文档滚动位置前端每隔 15 秒左右上报一次后端做 upsert 更新。2.2 MyBatis 动态 SQL 在查询里的应用场景MyBatis 在这个项目里最强的优势就是写复杂查询时足够灵活。比如学生端课程列表可能有按分类过滤、按关键字搜索、按最新/最热排序、按报名人数过滤等多种组合条件。如果用 JPA这种多条件拼接得靠 Specification代码很抽象用 MyBatis 的 XML 写动态 SQL一眼就能看明白。我举一个课程列表查询的例子select idselectCourseList resultTypecom.example.entity.Course SELECT c.*, u.nickname AS teacher_name, (SELECT COUNT(*) FROM course_selection cs WHERE cs.course_id c.id) AS student_count FROM course c LEFT JOIN user u ON c.teacher_id u.id where if testkeyword ! null and keyword ! AND (c.title LIKE CONCAT(%, #{keyword}, %) OR c.introduction LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND c.category_id #{categoryId} /if if teststatus ! null AND c.status #{status} /if /where choose when testsort hot ORDER BY student_count DESC /when when testsort new ORDER BY c.create_time DESC /when otherwise ORDER BY c.id DESC /otherwise /choose /select这段话读下来即使你不熟悉 MyBatis也能猜到它是在干嘛搜索条件有空就不拼接有值就往 SQL 里加。相比在 Java 代码里拼字符串这种方式安全且好维护SQL 注入风险也低因为#{keyword}会走预编译。2.3 接口风格和统一返回结构前后端分离协作最怕的就是接口定义不规范。我在项目里统一定义了一套返回体{ code: 200, message: 操作成功, data: { } }后端用泛型封装成ResultT成功是code200业务异常是400未登录是401无权限是403服务器错误是500。前端 axios 拦截器里统一判断code不是 200 的弹提示或者跳登录页这样每个页面都不需要反复写错误处理逻辑。API 划分也遵循一个原则资源式路径。课程相关就是/api/course章节相关是/api/course/{courseId}/chapter评论相关是/api/lesson/{lessonId}/comment。这种约定俗成的写法让前后端同学可以根据路径一眼猜出接口归属排查问题的时候效率很高。3. 后端模块实现Spring Boot 项目不是写接口而是搭一套规范3.1 包结构拆分和 JWT 登录状态管理后端如果用单包揽全部模块多了之后必然混乱。我用的分层结构是com.example.learnplatform ├── common // 通用类统一返回、异常处理、常量 ├── config // 配置类跨域、拦截器、WebMvc ├── controller // 接口层 ├── service // 业务层 ├── mapper // MyBatis 映射接口 ├── entity // 实体类 ├── dto // 入参出参对象 ├── util // 工具类JWT、密码加密 └── interceptor // 拦截器登录拦截、权限校验登录这块用的是 JWT。用户登录成功后后端签发一个包含userId和role的 Token前端存在 localStorage 里请求时放到Authorization头。后端通过拦截器解析 Token再把用户信息写入ThreadLocal业务代码里直接取当前用户即可。要注意的是Token 只适合这种前后端分离且对单点登录要求不高的场景。如果以后要做多端登录互踢、强制下线、会话管理那要么引入 Redis 存 Token要么换 Spring Security 的 Session 方案。这个项目里我用 JWT 是因为实现简单、无状态、接口调试方便但代码里把拦截器的扩展点预留好后面升级不至于推倒重来。3.2 权限校验的低成本实现方案有的项目一上来就引 Spring Security Sa-Token把复杂度拉得很高。说实话这个体量的系统用自定义拦截器 注解完全够用。我定义一个RequireRole注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }在控制器方法上加RequireRole({teacher, admin})拦截器里读取当前用户的角色不在白名单就直接返回 403。这样有一个很直观的好处每个接口允许谁访问看方法上的注解就能知道不用翻配置文件。3.3 学习进度更新的并发处理学习进度上报这个接口学生可能每 15 秒调一次后端如果每次都先查再插再更新数据库压力会很大。我的做法是直接一条INSERT ... ON DUPLICATE KEY UPDATE解决。INSERT INTO learning_progress (user_id, lesson_id, progress_percent, last_position, update_time) VALUES (#{userId}, #{lessonId}, #{progressPercent}, #{lastPosition}, NOW()) ON DUPLICATE KEY UPDATE progress_percent VALUES(progress_percent), last_position VALUES(last_position), update_time NOW();user_id lesson_id建联合唯一索引第一次上报是插入后面再上报是更新全程不需要两次 SQL。这种写法在处理“以唯一键为准的滚动更新”时非常省心。3.4 评论与通知的消息联动这个项目做了评论后的站内通知功能。学生 A 评论了某节课教师和其他选课学生收到通知。后端怎么实现不阻塞主流程我在发布评论的事务里只做两件事写入comment表、写入notice表。因为通知本质上只是给相关用户生成一条记录并不需要实时推送到浏览器。前端通过轮询接口/api/notice/unread-count每隔 30 秒拉一次未读数量有变化就更新顶部铃铛角标。真要追求“秒级”提醒可以后续把轮询换成 WebSocket但业务早期完全没必要上轮询十分钟就能搞定WebSocket 要维护连接状态和心跳复杂度高一个量级。4. 前端工程实现Vue 这套界面怎样做得既快又稳4.1 路由、状态管理与请求封装前端我用 Vue 3 Vite Pinia。选 Vite 而不是 Vue CLI是因为冷启动快热更新快开发体验好很多。项目里主要分两大块前台学习端课程列表、课程详情、章节内容页、个人中心后台管理端课程管理、题目管理、用户管理、统计面板路由设计上两块走不同的布局。前台是常见的顶部导航 内容区后台左侧菜单 右侧工作台。通过路由嵌套可以很轻松地把布局拆开{ path: /admin, component: AdminLayout, children: [ { path: course, component: CourseManage }, { path: user, component: UserManage } ] }状态管理用 Pinia存四类东西用户信息、侧边栏折叠状态、当前学习的课程进度、通知未读数。这里我有个经验不要把接口返回的全部数据都塞进 Pinia只保留全局真正共享的部分。课程详情页的数据存组件内部页面切换就重新请求反而不会有脏数据问题。请求封装在utils/request.js里统一做 axios 实例。请求拦截器自动带 Token响应拦截器统一处理错误码。写组件的时候代码就能非常干净const { data } await request.get(/api/course/list, { params: { page: 1, size: 10 } });4.2 学习页面和课程目录的交互细节学习页是用户停留时间最长的页面。左侧是课程目录树中间是视频或富文本内容右侧是评论和测验。我踩过的一个细节是目录树和当前学习进度之间的联动。理想效果是学生进入课程后目录自动展开到上次学习的章节并且已学完的章节打勾。这里不能靠后端每次算完再返回因为前端交互状态太频繁。我的做法是进入页面时请求一次/api/course/{id}/my-progress拿到该学生所有章节的进度 Map存到前端组件里之后每次点击章节播放器上报进度成功后前端把 Map 更新一下打勾状态即时刷新不等后端重新查。后端只做持久化前端负责即时反馈这种分工是前后端分离的一个核心心法。4.3 富文本渲染和题目交互的坑课程内容用富文本存到数据库后前端渲染时要注意安全性。如果你直接v-html渲染服务端返回的 HTML等于把 XSS 漏洞开了一个大口子。我当时的处理方案是后端返回内容前用白名单过滤器把script、onclick、javascript:这些危险标签全部清理掉前端展示时再用 DOMPurify 做二次清洗图片路径统一做 upload 处理不让学生自己填外链地址。这样做完富文本这条链路才算基本安全。在线测验这里也有一个值得注意的交互题目提交后立刻判分但学生如果在判分前切走页面答案可能丢失。最好在提交前把答题记录暂存到本地sessionStorage如果接口超时或用户误刷新恢复后还能自动回填。这个小功能花了不到半天但用户体感差别很大。5. 部署上线从本地跑通到服务器可用5.1 环境准备和构建产物本地开发跑通是第一步真正上线是第二步。先明确一下部署环境后端JDK 11 或 17Maven 3.6前端Node 16数据库MySQL 8.0服务器Linux 即可至少 2 核 4G后端打包很简单mvn clean package -DskipTests打完包后target目录下会生成一个learn-platform-0.0.1.jar。这个 jar 是 Spring Boot 内置 Tomcat 的可执行包服务器上只要有 Java 就能直接跑。前端构建npm install npm run build构建完成后生成dist目录里面是纯静态的 HTML、CSS、JS 文件直接扔给 Nginx 托管即可。5.2 Nginx systemd 的常规部署方案我最推荐的一套常规方案是Nginx 托管前端静态文件同时代理/api请求到后端服务。Nginx 配置核心部分server { listen 80; server_name your-domain.com; # 前端静态资源 root /var/www/learn-platform/dist; index index.html; # 解决 Vue Router 刷新 404 的问题 location / { try_files $uri $uri/ /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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }后端服务用systemd管起来[Unit] DescriptionLearn Platform Backend Afternetwork.target mysql.service [Service] Userwww WorkingDirectory/var/www/learn-platform ExecStart/usr/bin/java -Xms256m -Xmx512m -jar learn-platform-0.0.1.jar Restartalways RestartSec10 [Install] WantedBymulti-user.target配置里有两个关键点一是try_files必须有否则刷新页面时 Vue Router 找不到路由直接 404二是代理到 127.0.0.1 后跨域问题在入口层面就解决了前端不需要配置 devServer 之外的跨域处理。只要在开发环境开启 CORS上线后 Nginx 这一层就把同源策略问题消化掉了。5.3 Docker Compose 一键部署的进阶方式如果服务器还要迁移、环境重装那更推荐 Docker Compose 一把梭。我整理了个最小可用的配置文件version: 3 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: learn_platform volumes: - ./mysql_data:/var/lib/mysql ports: - 3306:3306 backend: build: ./backend depends_on: - mysql environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/learn_platform SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: root123 ports: - 8080:8080 frontend: build: ./frontend ports: - 80:80用 Docker 之后最大的好处是 MySQL、后端、Nginx 三者的启动顺序由编排工具保证数据库密码、数据卷、镜像版本全部落在配置里。以后换服务器一行docker compose up -d就把整套环境拉起来了。6. 从开发到上线我踩过的几个大坑和排查思路6.1 登录态失效的“偶发”问题JWT 过期策略没定好这个项目开发到一半出现过一个诡异现象前端刚登录成功跳转首页后一会儿就提示“登录已过期”。排查到最后发现后端签发的 JWT 有效期只设了 30 分钟用户在看课程视频的过程中Token 自然过期了下一次上报进度接口就直接 401。修复方案不是把有效期调成 7 天就算完。过期时间越长安全风险越大。正确做法是Token 有效期设 2 小时前端在 axios 响应拦截器里捕获 401如果当前有 refreshToken就静默刷新 Token 再重放原请求刷新失败才强制跳转登录页。这一步看起来是后端配置问题实际是需要前后端配合的会话保活策略。6.2 评论区查询的 N1 问题学生进入一个热门课的学习页时评论区接口如果实现得不好响应会非常慢。我第一次写评论列表时先查出评论 List再循环查每条评论的用户昵称和头像这产生了典型的 N1 查询100 条评论要多查 100 次用户表。排查思路很简单把 SQL 版本用 Log 打出来看到几十条 SELECT 刷屏基本就能判断是循环查询。修复方式是用一条带关联查询的 SQL 一次取出select idselectCommentsByLessonId resultTypecom.example.dto.CommentVO SELECT c.*, u.nickname, u.avatar FROM comment c LEFT JOIN user u ON c.user_id u.id WHERE c.lesson_id #{lessonId} ORDER BY c.create_time ASC /select改完之后100 条评论的查询从 101 条 SQL 降到了 1 条。这种问题不复杂但特别典型。任何“列表 关联用户名”的场景都要先下意识想想能不能用 JOIN。6.3 MySQL 中文乱码从根上就要定好很多系统上线后吐槽数据库中文变问号多数是因为建表时没有统一字符集。水深的地方在于MySQL 服务端、连接层、表字段、前端页面四个位置任何一处字符集不一致都会出问题。我在项目的 JDBC 连接串里显式写明jdbc:mysql://localhost:3306/learn_platform?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai建库的时候也用CREATE DATABASE learn_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;在 Spring Boot 2.x 之后serverTimezone还必须配否则服务器默认时区不是中国时区存进去的时间差 8 小时。这个坑特别隐蔽你看到的时间永远是昨天晚上的第一反应是代码问题其实只是连接参数少了一个。6.4 事务注解失效的常见误会有个接口发布课程时要同时写课程表、章节表、课时表看起来必须开事务。我在 Service 方法上加Transactional本地测试没问题但有一次把方法放到类内部互相调用后发现异常时数据没回滚。原因不复杂Spring 的事务是代理机制this.method()调用不经过代理注解直接失效。这也算 Spring 的经典暗坑。排查和修复思路记录一下确认方法调用是否来自另一个 Bean而不是同类内部 this 调用确认异常是否被 try-catch 吃掉了Transactional默认只在 RuntimeException 时回滚检查时要想清楚是否需要 rollbackFor确认是不是接口方法的public权限Spring 的代理对非 public 方法不生效。这类问题如果没有清晰的排查清单开一次会可能就要花一下午。把上面这些内容消化掉你已经能独立把这个系统从零写到部署了。最后再分享一个小经验这种业务型项目的价值不只在“会增删改查”更在于把权限、进度、互动、部署这些横切逻辑理顺。我做的这个过程里最受益的不是某个框架 API而是学会“先画清边界再动手写接口”——这个习惯放到任何技术栈里都不过时。