做了这么多年后台管理系统,我越来越觉得所谓"设计和实现"这两件事是真正拉开差距的地方:设计没想清楚,代码写多少返工多少。前阵子帮一家职业技能培训机构整理在线教育管理后台时,我的第一反应不是先写接口,而是把角色、课程、订单这些核心实体在纸面上画了一遍。这套系统基于SpringBoot+Vue实现,后端用Java+MySQL+MyBatis,前端用Vue3+Element Plus,整体源码是完整可复现的。如果你正在做课程管理、培训后台、知识付费类项目,或者想找一套前端分离的实战源码来练手,这篇文章就是这套系统从业务拆解到代码落地的全过程记录。
这套系统最初的需求并不复杂:机构有十来个专职讲师,录好的视频需要一个地方承载,学员要能选购课程,后台要能管理上下架、分类、订单、评论。但真实业务一旦展开,细节远比想象得多——一门课被讲师误操作重复上架怎么办?学员重复下单怎么办?课程没配置视频能不能上架?这些看似边缘的问题,实际上决定了数据库表和接口要怎么写。接下来我会按项目全景、技术选型、数据库设计、后端链路、前端落点、联调部署、源码复现的顺序,把每个决策和踩过的坑都说清楚。
1. 项目全景拆解:这套在线教育管理系统到底要管什么
1.1 它是"管理端",不是"学习端"
很多人看到"在线教育系统"第一反应是做一个像慕课网那样的视频学习平台,但标题里写得很明确——"管理系统"。这两者的差别非常大:学习端面向学员,核心是选课、播放、学习进度;管理端面向机构运营人员,核心是维护数据、审核内容、查看订单。本项目重心在管理端,它解决的是机构内部"内容管理和交易管理"的问题,而不是解决"学员怎么把课程看完"的问题。
管理端需要一个清晰的角色边界。系统里我把用户区分成三类:管理员、讲师、普通学员。管理员看见的菜单最全,用户管理、分类管理、课程审核、订单查询、评论删除都能操作;讲师能维护自己的课程和章节,但看不到全局订单明细;学员在管理端基本只读,实际上学员的主要行为发生在前台购买端,管理端只是提供用户列表和禁用能力。这个三权分离的模型不需要引入重型的权限框架,一张user表加一个role字段就够用。
1.2 功能模块矩阵:一个后台该有的功能清单
需求落到模块上,我梳理出的功能矩阵大概是这样:
| 功能模块 | 核心功能 | 实现要点 |
|---|---|---|
| 登录认证 | 账号密码登录、JWT签发 | 登录后返回token,后续请求携带 |
| 用户管理 | 用户分页、启用禁用、角色查看 | 状态位控制是否可登录 |
| 课程分类 | 两级分类树的管理 | parent_id自关联,删除前校验 |
| 课程管理 | 新增课程、编辑、上下架、章节维护 | 上下架前校验课程完整性 |
| 订单管理 | 订单分页、状态筛选 | 金额后端计算,不能信任前端 |
| 评论管理 | 评论审核、删除 | 状态字段控制前台展示 |
| 轮播管理 | 首页Banner配置 | 排序字段 + 启停用 |
| 数据统计 | 课程数、用户数、订单金额汇总 | SQL聚合即可 |
这套功能清单几乎覆盖了中小型在线教育机构管理后台的全部刚需。设计时我刻意把评论和轮播也放进来了,因为很多演示项目只做课程和用户,一旦面试官问"内容安全怎么处理",就答不上来。评论审核和Banner管理就是很好的补充点,代码量不大,但业务完整度上了一个档次。
1.3 业务流程上的两个关键闭环
管理后台的业务逻辑虽然看起来是增删改查,但至少存在两个需要认真对待的闭环。第一个是课程发布闭环:讲师创建课程 -> 添加章节 -> 添加小节视频 -> 提交上架 -> 管理员审核通过 -> 前台可见。在这个链路里,课程状态的变化不是随心所欲的,必须有明确的状态机。我在course表里用status字段表示:0草稿、1待审核、2已上架、3已下架。讲师只能把课程从草稿提交到待审核,管理员才能做上架或驳回。这样设计的好处是,前台永远不用考虑"课程到底能不能展示"这种业务问题。
第二个是订单闭环:学员下单 -> 生成待支付订单 -> 支付回调 -> 订单已支付 -> 学员获得课程观看权限。管理端不需要自己接支付通道,但要预留支付入口,并按状态流转来展示订单。订单状态用0待支付、1已支付、2已取消来表示。这里最容易出问题的不是状态本身,而是重复下单和金额信任,这两点我会在数据库设计和后端实现部分重点展开。
2. 技术选型推演:为什么是SpringBoot+Vue+MySQL+MyBatis这套组合
2.1 后端版本选型:稳定优先,不盲目追新
SpringBoot的版本选择是这类项目第一个容易踩坑的地方。到项目落地时,SpringBoot 3.x已经发布,但它强制要求JDK17,很多学习环境和机构服务器还在用JDK8。我最终选的是SpringBoot 2.7.18,对应JDK8和JDK11都能跑,Spring生态的资料、依赖兼容性都是最成熟的。选2.7而不是3.x还有一个原因:MyBatis、PageHelper、JWT等常用库在2.7上基本无脑兼容,不需要处理任何版本迁移问题。这不是说3.x不好,而是对"管理系统"这种以业务逻辑为主的项目来说,工具的稳定性比版本新鲜感重要得多。
依赖清单上我加了Lombok来减少实体类getter/setter代码,加了spring-boot-starter-validation做参数校验,JWT选的是jjwt库。Hutool这种工具库看个人习惯,我用它主要是为了生成订单号和日期处理,避免重复造轮子。这些都不是必须的,但能让代码保持简洁。
2.2 前端选型:Vue3的生态已经足够成熟
前端我选择了Vue3 + Vite + Element Plus + Pinia + Vue Router的搭配。很多老项目教程还在教Vue2 + Element UI,因为历史包袱,但新项目再开Vue2的坑是不划算的。Vue3的setup语法写起来比Options API更简洁,Vite的启动速度和热更新体验也比Vue CLI时代好一个量级。Element Plus的组件库对于后台管理场景非常完整,表格、表单、对话框、分页都有成熟组件,能省下大量UI时间。
如果你是从零开始配Vue环境,我的建议是用npm create vite@latest命令初始化项目,然后手动安装element-plus、axios、vue-router、pinia。之所以不推荐官方脚手架一键生成完整后台模板,是因为那种模板会带进来大量用不到的代码和目录结构,反而不利于理解路由、状态管理、请求封装这些核心机制。自己动手装一遍依赖,才知道每个包是干什么用的。
2.3 数据访问层:原生MyBatis和MyBatis-Plus的取舍
数据访问层是很多开发者争论的焦点。MyBatis-Plus确实能把单表CRUD缩短到一行代码,很多人直接选它。但我在这套系统里用的是原生MyBatis + XML写SQL,为什么?因为管理系统的价值恰恰集中在复杂查询上:课程分页要关联分类表,订单分页要关联用户表和课程表,统计面板要聚合汇总。MyBatis-Plus对标准单表操作很友好,但一旦涉及多表关联和动态条件,最后还是得手写SQL,那为什么不一开始就让SQL清晰可控呢?另外一个私心是,面试和招聘场景里,原生MyBatis的动态SQL本身就是考点,用XML写清楚比依赖框架的LambdaQueryWrapper更有示范价值。
当然,这并不意味着所有语句都要手写。user表、banner表这类简单实体的基础增删改查,我在Mapper里用了注解方式;course、order这类复杂查询,则统一走XML。两条腿走路,既不啰嗦也不失控制力。MyBatis的map-underscore-to-camel-case配置记得打开,数据库的create_time才能自动映射到实体的createTime属性,少写很多resultMap。
2.4 数据库与工具链:MySQL8是底线
MySQL我建议直接上8.0版本,5.7在字符集排序、窗口函数、JSON支持上都力不从心。连接驱动用com.mysql.cj.jdbc.Driver,连接串务必带上serverTimezone=Asia/Shanghai和characterEncoding=utf8,否则日期差8小时和控制台乱码这两个问题会一起找上门,具体原因后面联调章节细说。数据库管理工具我用Navicat比较多,也可以用MySQL Workbench或命令行,对这套系统来说只要能执行SQL脚本、看表结构就够了。字符集统一用utf8mb4,因为评论内容里完全可能出现Emoji字符,utf8会报错。
3. 数据库设计:把教育业务拆成不打架的表结构
3.1 用户与权限:单表加角色字段是管理端的最优解
用户表设计我见过不少方案,有的把管理员、讲师、学员拆成三张表,有的引入Shiro的权限模型,但对于管理端来说,单表加role字段是最务实的。理由很简单:同一套登录逻辑、同一套密码校验,拆表只会增加联表和注册流程的复杂度。user表的核心字段包括id、username、password、nickname、avatar、phone、email、role、status、create_time。role取值admin/teacher/student,status取值0禁用1正常。密码字段存储的是加密后的密文,绝不允许明文落库。为了贴近真实项目,我这里用BCrypt加密,如果演示环境想简单点用MD5加盐,思路一样,只是安全性差一个档次。
给username建立唯一索引,同时给role加普通索引,因为后台列表页最常做的筛选就是按角色查用户。还需要注意一点:删除用户要慎重。用户一旦产生过订单,删除就破坏了订单表的关联完整性,所以管理端对用户的操作应该以禁用为主,不做物理删除。这是业务规则对表设计的一种约束,如果代码里用了外键约束,甚至应该在数据库层面禁止直接删除有订单记录的用户。
3.2 课程内容模型:分类、课程、章节、小节四级结构
课程领域是这套系统的核心,所以表结构要格外仔细。course_category表用来存课程分类,parent_id自关联实现两级分类:parent_id为0的是顶级分类,比如"Java开发";子分类比如"SpringBoot实战"的parent_id指向顶级分类id。这样前端下拉树和后端分页筛选的实现都很直接。sort字段控制排序,status控制前台是否展示。
course表和分类表关联,字段包括teacher_id、category_id、title、cover_url、description、price、original_price、status、view_count、buy_count、create_time、update_time。price用decimal(10,2),这是和金额相关的硬性要求——不要用double或float存钱,二进制浮点数无法精确表达金额,哪怕在Java里用double相加会出现0.1+0.2=0.30000000000000004这类问题,数据库里同样不行。buy_count和view_count是冗余统计字段,每次有人购买、有人浏览时单独更新,避免统计面板实时count大表。
课程表下面还有两级内容结构:chapter章表,section节表。chapter表字段是course_id、title、sort,section表字段是chapter_id、title、video_url、duration、sort。为什么课程内容要分成章和节两层?因为真实教学场景里课程是按章节组织的,视频挂在节上,学员购买后学的是"某门课的某章某节"。如果不分层,课程内容就是一长串视频列表,既不符合用户习惯,也无法支持章节维度的学习进度扩展。两层已经足够,再加一层"单元"往往过度设计。
3.3 订单与支付流水:一笔订单怎么记才不糊涂
订单表是管理后台里最容易设计出问题的表。course_order表的核心字段有order_no、user_id、course_id、total_amount、pay_type、status、create_time、pay_time。order_no是业务单号,格式类似202507011030001234,由代码生成,展示给用户,数据库层面加唯一索引。为什么不用自增id做订单号?因为自增id外泄会暴露平台的日订单量,运营数据不该这样泄露。
这里有一个容易被忽视的关键约束:user_id和course_id必须加联合唯一索引。这句话的意思是,同一个用户对同一门课只能生成一单,不管用户手速多快、前端点了几次下单按钮,数据库层面就能挡掉重复订单。没有这个索引,就只能靠代码查一遍再插入,而并发场景下两次同时查询都查不到已有订单,然后各插一条,重复订单就这样产生了。联合唯一索引是幂等的底线,代码里的判断只是优化体验。total_amount字段的值必须由后端从course表读取计算,前端传过来的金额直接丢弃。
3.4 运营位和评论:审核类表设计的两个要点
banner轮播图表和comment评论表看起来简单,但它们体现了管理系统内容审核的设计思路。banner表字段有title、image_url、link_url、sort、status,这个表的业务逻辑是"首页展示什么内容、按什么顺序展示"。运营人员调整sort字段就能改顺序,调整status就能让某个Banner下线,完全没有必要做删除操作。
comment表字段是course_id、user_id、content、reply_content、status、create_time。status在这里承担审核功能:0待审核、1通过、2驳回。前台只查status=1的评论,管理后台看到的是全部评论。这个设计的好处是,不需要"删除"也可以实现"不让用户看到某条评论"——把status改为2即可,数据还在,审计可查。如果有营销风险内容需要彻底下架,status置为2比物理删除更安全。评论和用户、课程表关联,查询时用外连接展示nickname和course_title,这是管理后台列表页的常规操作。
3.5 核心表结构一览
把上面几张核心表的职责关系汇总一下:
| 表名 | 核心字段 | 设计要点 |
|---|---|---|
| user | username, password, role, status | 统一三角色,status控制登录 |
| course_category | parent_id, name, sort, status | 两级分类树 |
| course | teacher_id, category_id, title, price, status | price用decimal |
| chapter | course_id, title, sort | 课程下的章 |
| section | chapter_id, title, video_url, duration | 视频挂在节上 |
| course_order | order_no, user_id, course_id, total_amount, status | 联合唯一索引防重 |
| comment | course_id, user_id, content, status | status做审核 |
| banner | image_url, link_url, sort, status | 顺序和启停 |
这套表结构大概只需要二十来行核心建表语句就能搭起来,但每一张表的字段都是围绕业务闭环设计的。比起一开始就铺开几十张表,我更倾向于让每张表都对应一个明确的业务概念,后面扩展学习进度、优惠券、讲师分成时再增加表,也不会伤到现有结构。
4. 后端核心链路实现:认证、课程与订单是怎么串起来的
4.1 统一返回对象与全局异常:前后端联调的第一块基石
后端接口开发我习惯先定一套"通用响应协议",而不是写一个接口就自定义一堆字段。Result对象统一封装code、message、data三个字段,成功返回Result.ok(data),失败返回Result.error(code, message)。code约定:200成功,400参数错误,401未登录,403无权限,500系统异常。前端axios拦截器只需要判断code,就能统一处理成功和错误分支,不需要每个页面自己try-catch一堆业务异常。
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> Result<T> error(Integer code, String message) { Result<T> r = new Result<>(); r.code = code; r.message = message; return r; } }配合全局异常处理类,用@RestControllerAdvice统一捕获异常。业务异常直接抛出BizException,框架异常和系统异常也在这里收口转成Result格式。这样做有一个很实际的好处:接口永远不会返回非JSON结构,前端处理逻辑单一;日志里也能统一记录异常栈,排查问题时一目了然。
4.2 JWT登录鉴权的完整流程
登录接口接收username和password,校验通过后生成JWT返回前端。JWT的payload里只放userId、role两个必要信息,不要放密码、手机号这类敏感数据。有效期我设置为2小时,前端管理后台的使用强度足够,过期后让用户重新登录。很多初学者会把token有效期设置成7天甚至30天,对管理后台来说这是安全隐患,管理员的身份凭证有效期越短越好。
JWT校验我用拦截器实现,注册到WebMvcConfigurer里。preHandle方法从请求头Authorization读取token,解析失败或者过期直接返回401,不放行。放行规则上,/api/auth/login放行,其余/api/**都拦截。为了业务代码里方便获取当前用户,拦截器解析出userId后存入ThreadLocal,后续Service层可以直接调用UserContext.getUserId()。这里有一个很多人容易忽略的点:JWT的Header头在跨域请求时会有预检OPTIONS请求,拦截器必须对OPTIONS请求直接放行,否则前端跨域调接口会一直报403。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BizException(401, "未登录或登录已过期"); } Long userId = JwtUtil.parseToken(token.replace("Bearer ", "")); UserContext.set(userId); return true; } }角色权限我用了更轻量的方案:在需要管理员权限的接口上手动判断当前用户角色即可。比如课程删除接口里,非admin直接抛403。对管理后台来说,角色数量就三个,引入Spring Security的复杂权限体系反而增加学习成本。
4.3 课程分页查询的动态SQL:多条件筛选的正确写法
课程管理页是后台最常用的页面,筛选项有课程名称、分类、状态、讲师。这种需求如果用逐字段判断来拼SQL,代码会非常丑陋;用MyBatis的XML动态SQL是最自然的方案。核心查询关联分类表,动态拼接where条件,然后分页。
<select id="selectCoursePage" resultType="com.edu.entity.CourseVO"> SELECT c.id, c.title, c.price, c.status, c.cover_url, c.view_count, c.buy_count, c.create_time, cc.name AS categoryName, u.nickname AS teacherName FROM course c LEFT JOIN course_category cc ON c.category_id = cc.id LEFT JOIN user u ON c.teacher_id = u.id <where> <if test="keyword != null and keyword != ''"> AND c.title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND c.category_id = #{categoryId} </if> <if test="status != null"> AND c.status = #{status} </if> </where> ORDER BY c.create_time DESC LIMIT #{offset}, #{pageSize} </select>写动态SQL时有两个细节值得注意。第一,模糊查询不要直接写LIKE '%${keyword}%',要用CONCAT函数拼接参数,因为${}直接拼字符串会有SQL注入风险。第二,统计总条数的count查询和列表查询的where条件要完全一致,所以要复制同样的动态SQL片段。我在Mapper里单独写了selectCourseCount,虽然SQL看起来重复,但分页总数必须精确,省这一步后面前端页码就会错乱。
4.4 课程上下架的级联事务:状态机与校验规则
课程上下架不是简单的update语句,它涉及一个完整的事务:校验课程内容是否完整、更新课程状态、可能还要刷新前台缓存或更新统计字段。我在Service层把上架逻辑拆成三步:第一步查课程是否存在,状态是否合法;第二步查这门课下面是否有章,章下面是否有节,每个节是否有视频URL;第三步才把状态改成已上架。如果第二步校验不通过,直接抛异常,整个事务回滚。
为了保证这三步是一个原子操作,Service方法必须加@Transactional。这里要特别警惕事务失效的三个经典场景:类内部方法自调用导致代理失效、方法不是public、异常被try-catch吞掉。前两个涉及Spring AOP原理,第三个最隐蔽——如果业务代码里catch住异常没有重新抛出,事务管理器根本感知不到异常,回滚自然不会触发。我在代码里对所有Service层方法都贯彻一个原则:业务异常用BizException抛出,框架异常不做本地catch,全部交给全局异常处理器。
4.5 订单创建的幂等与金额校验:后端必须守住的钱袋子
订单创建接口是管理后台里最需要严谨对待的逻辑。接口流程是先根据courseId查出课程,课程不存在或未上架则返回失败;然后用当前userId和courseId查已存在订单,查到就直接返回"请勿重复下单";最后创建待支付订单,金额取course表里的price字段。这四步中,查询已存在订单这个动作是用户无感知的幂等屏障,配合数据库的联合唯一索引,双保险。
课程下架后还能下单吗?不能,需要先校验status。用户被禁用后还能下单吗?不能,需要在流程最前面校验用户状态。这些校验放在Service层统一处理,Controller保持轻薄。支付回调的代码我会再校验一次订单状态,只有待支付订单才能变成已支付,已支付订单遇到重复回调直接忽略。这套状态机虽然简单,但能保证账目不出错。
5. Vue前端的关键落点:请求封装、路由守卫与课程管理页
5.1 工程初始化与目录结构
前端工程用create-vite初始化,选Vue3 + JavaScript。TypeScript当然也是好选择,但对以业务为主的中后台项目,JS的直白和低心智负担更合适,源码看起来门槛也更低。安装完基础依赖后,src目录下我按api、router、store、views、components、utils来组织。api目录统一放请求函数,views目录放页面,components放复用组件。这样的目录结构看一眼就知道该找哪个文件,对于后期维护和交给别人接手都很重要。
main.js里注册router、pinia、element-plus。Element Plus建议完整引入,虽然打包体积大一点,但省去按需引入的配置折腾。管理后台本身就很少关心首屏体积,完整引入的稳定性和便利性更值得。
5.2 axios封装:token注入与401统一处理
axios不封装直接用,到了联调阶段会非常痛苦,因为每个请求都要手动塞token、手动处理错误提示。我在utils/request.js里创建axios实例,baseURL设为/api,请求拦截器自动从localStorage取token并设置Authorization头,响应拦截器统一处理业务码。
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error('网络异常或登录过期') return Promise.reject(error) } ) export default request这里有两个细节:响应拦截器直接返回res.data,这样页面里拿到的就是纯数据,不用每页再剥一层code;401时统一跳登录,页面代码不用关心登录过期逻辑。开发期配好Vite的proxy之后,前端请求路径统一写/api,完全不需要知道后端在哪台服务器。
5.3 路由守卫:未登录拦截和角色控制
Vue Router的路由守卫是前端权限控制的骨架。我在routes的meta字段上声明两个属性:requiresAuth表示这个页面是否需要登录,roles数组表示允许访问的角色。router.beforeEach里按顺序做判断:目标页是登录页直接放行;没有登录且需要登录就重定向到/login;登录了但角色不在允许列表里就重定向到首页。核心判断逻辑如下:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.path === '/login') { next() return } if (!token && to.meta.requiresAuth) { next('/login') return } if (token && to.meta.roles && !to.meta.roles.includes(role)) { next('/dashboard') return } next() })为什么用localStorage而不是把状态放在内存里?因为页面刷新后内存清空,用户身份也就丢了,体验很差。token存localStorage有XSS风险,但只要项目里不用v-html渲染不可信内容,这个风险是可控的。路由守卫和axios的401跳转形成双保险:一个管页面入口,一个管接口响应。
5.4 课程管理页:表格、弹窗、校验的完整交互
课程管理页的操作流程是:进入页面自动拉取分页列表 -> 在表格顶部操作栏选择筛选条件 -> 点击新增按钮弹出Dialog表单 -> 填写课程信息并保存 -> 列表刷新。表格我用el-table配el-pagination,筛选区是el-input、el-select的组合。新增和编辑复用同一个Dialog表单组件,只是打开时初始数据不同,表单校验规则在data里用rules声明,必填项分别是课程标题、归属分类、价格、状态。
完整性上要注意一个体验点:分页组件切换页码时,搜索条件必须随当前状态一起更新。有些实现里只要搜索就重置为第1页,而翻页又丢失搜索条件,这都属于交互细节没打磨到位。我做了一个搜索表单对象和一个页码状态,搜索时pageNum重置为1,翻页时带上搜索表单参数再次请求。表单提交后刷新列表时保持当前页码不清零,避免用户编辑完一条数据后跳回第一页,又要翻很久才能回到刚才的位置。
6. 联调部署踩坑记录:跨域、上传、时区一个都没跑掉
6.1 跨域:开发期交给Vite proxy,生产期交给Nginx
前后端分离项目联调阶段最常见的问题就是跨域。开发期不要在前端代码里把axios的baseURL写成http://localhost:8080,而应该保持/api的相对路径,然后在vite.config.js里配置proxy把/api转发到后端服务:
export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样配置之后,浏览器的请求始终是同源的http://localhost:3000/api/xxx,由Vite开发服务器转发到8080端口,彻底规避了浏览器跨域限制。开发期我甚至不需要在后端写CORS配置,这能少踩很多坑。
生产部署时,前端npm run build生成的dist目录交给Nginx托管,然后同样配置一个反向代理把/api转发到后端服务。后端jar包监听8080,Nginx监听80,前端页面和接口都走同一个域名,同样是同源访问。这里我踩过一次的坑是代理路径配置错误导致接口404,后来确定转发规则为location /api/ { proxy_pass http://127.0.0.1:8080; },记得proxy_pass结尾不加路径直接把原路径带过去,才能保证后端接口路径一致。
6.2 文件上传:本地目录还是对象存储
管理端涉及两个上传场景:课程封面图片和视频文件。开发阶段最简单的方案是后端接收MultipartFile,校验文件大小和类型,用UUID重命名文件后保存到本地目录,然后返回可访问的URL。图片大小限制我设为2MB,视频根据机构上传的内容时长可能比较大,但管理端演示阶段一般也不会传超大文件,所以限制在200MB以内即可。项目里把上传保存目录配置在application.yml的upload.path字段里,通过自定义静态资源映射把该目录暴露成URL访问。
上生产环境就不建议放本地磁盘了,有两个原因:服务器磁盘空间有限,且单机存储难以扩展。更稳妥的方案是接入对象存储服务,逻辑不变,只是把保存文件换成上传到存储桶。如果要完全自建,MinIO是很合适的替代品,社区版就够用。这里有一个我在真实项目里踩过的坑:不要把上传目录放在Linux服务器的/tmp下面,系统会定期清理该目录,过几天用户头像就全部变成404了。正确做法是放在应用家目录下的data目录,并单独配置磁盘空间监控。
6.3 数据库时区与JSON序列化:日期差8小时的两个修复点
日期时间问题是这种系统几乎必踩的坑。数据库连接串没带serverTimezone时,数据库默认使用服务器时区,国内服务器和应用有时差时,查询出来的时间就会差8小时。我的解决方案是在jdbc连接串里明确写上serverTimezone=Asia/Shanghai。另一个坑在Jackson序列化:后端返回的LocalDateTime默认序列化格式是数组或ISO格式,前端显示成"2025-04-01T10:30:00"非常难看。在application.yml里配置统一的日期格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8配置完这两处,前后端传时间就不会再出现"凌晨两点新建的课程显示在昨天"这种诡异现象。排查这类问题最快的方式是直接在MySQL控制台执行SELECT NOW(),看一下数据库当前时间是否和服务器一致,定位到底是数据库时区问题还是Java序列化问题。
6.4 部署流程和Nginx配置模板
后端部署就是传统的jar包启动。执行mvn clean package打包,得到target目录下的jar,上传到服务器后用java -jar命令启动。我习惯加上nohup和&让进程后台运行,并把日志重定向到应用日志文件;生产环境还可以用systemd来管理进程,实现开机自启和异常重启。前端部署更简单,npm run build后把dist目录上传到Nginx的html目录即可。
server { listen 80; server_name edu.example.com; location / { root /opt/edu/dist; index index.html; 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; } }Nginx配置里最容易错的是history模式下刷新页面404,所以try_files指令就派上用场了:请求路径找不到对应文件时,统一回退到index.html,交给前端路由处理。这是Vue Router用history模式部署在Nginx上必须配置的,否则用户一刷新浏览器就白屏。
7. 从零到一复现源码:环境配置与启动排错清单
7.1 环境清单与版本建议
想把这套源码完整跑起来,建议先核对环境版本。JDK8或JDK11都行,Maven用3.6以上,MySQL用8.0,Node最好16以上,IDEA或者VSCode取决于你的习惯。这几个版本组合是我实测下来兼容性最好的,不需要额外处理奇怪的环境问题。
7.2 数据库初始化:执行脚本是第一步
拿到源码后第一步不是启动后端,而是初始化数据库。创建数据库db_edu,字符集选utf8mb4,然后执行项目里的sql/init.sql脚本。这个脚本里建了表结构并预置了基础数据:一个admin账号、一个讲师账号、若干个学员账号、几门测试课程、几条测试订单。执行成功的标志是在Navicat或命令行里能看到全部表,并且user表里有管理员账号。如果脚本执行报错,优先检查字符集设置和SQL文件编码,比较常见的是Windows下SQL文件被以GBK编码读取导致中文乱码。
7.3 后端启动步骤:改配置、启动、看日志
修改application.yml里的数据库地址、账号、密码,确认配置无误之后直接运行启动类。启动成功的标志是控制台出现"Started Application in xx seconds"。如果启动过程中报错,按这个思路排查:数据库连不上,先ping通网段再确认用户名密码;端口被占用,检查8080是不是被其他进程占了,或者直接换个端口;MyBatis的XML映射找不到,确认mybatis.mapper-locations是classpath:mapper/*.xml且文件确实在resources/mapper目录下。
启动之后别急着调前端,先用Postman或浏览器访问http://localhost:8080/api/auth/login,传一个正确账号密码,如果返回JSON里有token,说明后端已经通了。这一步单独验证很有价值,能把后端问题在前端联调之前先隔离掉。
7.4 前端启动步骤:装依赖、配代理、访问页面
进入admin-ui目录,先执行npm install安装依赖。国内网络环境下建议加registry镜像源,否则下载要好几分钟甚至失败。安装完成后执行npm run dev,Vite会启动开发服务器并打印访问地址。打开浏览器访问该地址,如果能打开登录页,用admin账号登录,成功后进入仪表盘和管理菜单。
第一次登录成功后建议把常见业务链路走一遍:新增课程分类、创建一门课程、添加章节和小节、把课程上架、在订单列表看到测试订单。走完这一套,你基本就把后端事务、前端表格交互、权限路由这些关键代码都过了一遍。如果某个环节卡住,优先看浏览器Network面板里接口返回的JSON错误信息,那是定位问题最直接的方式。
7.5 启动排错速查表
| 症状 | 可能原因 | 修复方向 |
|---|---|---|
| 后端启动报数据库连接失败 | 数据库地址/账号/密码错误、MySQL没启动 | 检查application.yml配置,确认MySQL已启动 |
| 前端页面接口403 | 没有登录或token已过期 | 清掉localStorage重新登录 |
| 前端页面接口404 | Vite proxy或Nginx代理路径不对 | 检查proxy配置和context path |
| 日期相差8小时 | 连接串缺serverTimezone或Jackson未配置时区 | 补上Asia/Shanghai配置 |
| 上传文件后无法访问 | 静态资源映射未配置或目录被系统清理 | 自定义资源映射,目录避免/tmp |
| npm install超时 | 网络原因或registry问题 | 配置阿里镜像源后重试 |
这些报错我在帮别人排查时遇到的次数非常多,基本上90%的问题集中在配置项,代码本身反而很少出问题。这也侧面说明这一版源码的稳定性还是可以的。
回到最初那句话,这类管理系统真正难的不是增删改查,而是把状态、幂等、事务这些底层的约束吃透。我写这套系统时最深的体会就是:表结构就是系统的骨架,骨架正了,后面的代码怎么写都比较顺;骨架歪了,接口写一百个也难受。建议拿到源码之后不要只满足于跑起来,试着把下单接口并发打两遍,看看数据库联合唯一索引到底怎么挡住重复订单;再试着把上架流程里的章节校验去掉,看看事务回滚是不是真的生效。这些操作比单纯看代码更有价值,也更有意思。往后要扩展时,可以加上学习记录表、优惠券表、讲师分账表,技术栈完全不用换,这套骨架就能撑起一个真正商用的在线教育后台。