☰
前后端分离在线互动学习网站开发实战:从数据库设计到部署上线全流程解析
2026/10/18 15:26:15 网站建设 项目流程

前后端分离在线互动学习网站这套东西,我前后折腾了大半个月,从数据库建模到接口联调,再到最后部署上线,踩了不少坑也理出了不少门道。如果你手头正好有类似选题——比如毕设、课程项目,或者公司需要一个轻量的内训平台,这篇内容应该能帮你少走很多弯路。我会围绕 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 互动功能到底“互动”在哪里

很多人把“互动”理解成在课程下面放一个评论框,这其实远远不够。我做的这个项目里,“互动”主要体现在三块:

  1. 学习进度互动:学生学到哪一章节、哪一节课,前端会实时把进度同步给后端,重新登录之后能接着上次的位置继续学。
  2. 即时测验互动:小节学完后配套几道练习题,提交答案后立刻出得分和解析,形成“学—练—评”的闭环。
  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 id="selectCourseList" resultType="com.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 test="keyword != null and keyword != ''"> AND (c.title LIKE CONCAT('%', #{keyword}, '%') OR c.introduction LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND c.category_id = #{categoryId} </if> <if test="status != null"> AND c.status = #{status} </if> </where> <choose> <when test="sort == 'hot'"> ORDER BY student_count DESC </when> <when test="sort == '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": { } }

后端用泛型封装成Result<T>,成功是code=200,业务异常是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 漏洞开了一个大口子。我当时的处理方案是:

  1. 后端返回内容前,用白名单过滤器把<script>、onclick、javascript:这些危险标签全部清理掉;
  2. 前端展示时再用 DOMPurify 做二次清洗;
  3. 图片路径统一做 upload 处理,不让学生自己填外链地址。

这样做完,富文本这条链路才算基本安全。

在线测验这里也有一个值得注意的交互:题目提交后立刻判分,但学生如果在判分前切走页面,答案可能丢失。最好在提交前把答题记录暂存到本地sessionStorage,如果接口超时或用户误刷新,恢复后还能自动回填。这个小功能花了不到半天,但用户体感差别很大。

5. 部署上线:从本地跑通到服务器可用

5.1 环境准备和构建产物

本地开发跑通是第一步,真正上线是第二步。先明确一下部署环境:

  • 后端:JDK 11 或 17,Maven 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] Description=Learn Platform Backend After=network.target mysql.service [Service] User=www WorkingDirectory=/var/www/learn-platform ExecStart=/usr/bin/java -Xms256m -Xmx512m -jar learn-platform-0.0.1.jar Restart=always RestartSec=10 [Install] WantedBy=multi-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 天就算完。过期时间越长,安全风险越大。正确做法是:

  1. Token 有效期设 2 小时;
  2. 前端在 axios 响应拦截器里捕获 401,如果当前有 refreshToken,就静默刷新 Token 再重放原请求;
  3. 刷新失败才强制跳转登录页。

这一步看起来是后端配置问题,实际是需要前后端配合的会话保活策略。

6.2 评论区查询的 N+1 问题

学生进入一个热门课的学习页时,评论区接口如果实现得不好,响应会非常慢。我第一次写评论列表时,先查出评论 List,再循环查每条评论的用户昵称和头像,这产生了典型的 N+1 查询:100 条评论要多查 100 次用户表。

排查思路很简单:把 SQL 版本用 Log 打出来,看到几十条 SELECT 刷屏,基本就能判断是循环查询。修复方式是用一条带关联查询的 SQL 一次取出:

<select id="selectCommentsByLessonId" resultType="com.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?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/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,而是学会“先画清边界,再动手写接口”——这个习惯,放到任何技术栈里都不过时。

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

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

立即咨询