
1. 项目背景与技术选型思考去年接了一个面向基层党组织的教育学习管理平台开发任务需求听起来并不复杂管理员维护课程、学员在线学习、记录学时、在线考试、统计积分。但实际做下来这绝不是一个简单的CRUD项目涉及多角色权限、学习进度追踪、考试答题、数据统计还要考虑后续可能接入移动端所以我在技术选型上直接锁定了这套前后端分离方案SpringBoot Vue MyBatis MySQL。先说说选型的逻辑。做后台管理系统套装方案不少JSP时代那种单体应用就不提了。前后端分离的核心价值在于后端只提供RESTful接口前端用Vue这类SPA框架承担页面渲染和交互两端可以并行开发互不阻塞。这个项目里后端同学可以专注处理学习记录、考试成绩、权限控制这些逻辑前端同学专心做页面联调时只要按约定好的接口格式对接就行。团队协作效率高只是其一更关键的是以后如果要接小程序或APP后端接口可以直接复用不用重新开发一套。SpringBoot作为后端框架就不用多说了最直观的好处是省掉了大量XML配置内嵌Tomcat让本地开发和线上部署都简单很多打包成一个jar直接运行。MyBatis在这个场景下比MyBatis-Plus和JPA更可控因为教育管理业务里有大量动态查询报表比如按时间段统计学习时长、按组织维度汇总考试通过率这类SQL变化很多MyBatis的XML动态SQL可以精确控制每一条查询语句调优起来也方便。MySQL的选取很直接项目数据量没有到需要上分库分表的程度MySQL稳定且运维成本低配合Navicat做日常管理足够。技术栈里还有一个容易被忽略的关键点——这套组合的学习资料很多遇到问题能搜到大量解决方案。实际开发过程中JWT认证、Vue路由守卫、MyBatis分页这些通用问题前人已经踩平了路技术债务基本可控。最终定下来的整体架构是前端Vue2 Element UI构建管理后台SPA后端SpringBoot 2.7提供接口MyBatis负责数据库访问MySQL 8存储数据认证走JWT部署用Nginx托管前端静态资源并反向代理后端接口。2. 数据库设计从用户表如何长出几十张关联表2.1 我的建表思路这个系统的业务对象其实很集中人用户、学习内容课程、行为学习记录/考试、管理组织/菜单/权限/公告但关系比较复杂。一上来我就确定了几条原则第一权限相关表单独成组与业务表完全分离。系统至少有两类角色组织管理员和普通学员管理员还能分不同层级的管理单位所以RBAC权限模型是必需的用户表、角色表、菜单表、用户角色关联表单独设计互不交叉。第二学习业务要体现过程。仅仅记录学过/没学是不够的组织方希望看到学习进度、学习时长、考试得分这些细节所以学习记录表要同时记录多个维度的字段。第三所有表统一用自增主键、逻辑外键关联保持建表习惯一致。时间字段一律用datetime金额、进度、学时这种小数用decimal避免浮点数误差。第四字符集统一utf8mb4。这一点吃过亏早期用utf8遇到课程标题里的生僻字或者特殊符号直接存入失败换成utf8mb4之后再没出现过类似问题。按照这个思路核心表主要分为四大块系统管理sys_user、sys_role、sys_menu、sys_user_role、组织架构sys_dept、学习资源edu_category、edu_course、edu_video、学习行为edu_study_record、edu_exam_paper、edu_exam_record、edu_notice、edu_study_score。2.2 核心表的字段设计与取舍用户表是最基础的字段上除了常规账号密码我特意加了dept_id关联组织表方便按组织维度做数据隔离。密码存的是BCrypt加密后的串绝对不能明文后端登录时用BCryptPasswordEncoder比对原始密码和加密串。CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT BCrypt加密密码, real_name varchar(50) DEFAULT NULL COMMENT 姓名, portrait varchar(255) DEFAULT NULL COMMENT 头像URL, dept_id bigint DEFAULT NULL COMMENT 所属组织ID, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1启用 0禁用, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT系统用户表;课程表是学习内容的载体。这里的学时段sort字段很有心机——管理员录入课程时可以设置课程学习必须达到的时长比如45分钟学员看完就等于完成学时这个字段直接参与了后面的学分计算。视频地址我存的是相对路径完整访问地址由后端拼接返回这样将来迁移服务器或者换视频域名时不用改数据库。CREATE TABLE edu_course ( id bigint NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 课程标题, cover_url varchar(255) DEFAULT NULL COMMENT 封面图路径, video_url varchar(255) DEFAULT NULL COMMENT 视频路径, type_id bigint DEFAULT NULL COMMENT 课程分类ID, required_hours decimal(5,1) DEFAULT NULL COMMENT 规定学习时长(小时), content text COMMENT 课程简介, status tinyint DEFAULT 1 COMMENT 状态1上架 0下架, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学习教育课程表;学习记录表是整个系统的核心业务表它记录每个学员和每门课的关系。我用唯一索引(user_id, course_id)保证同一用户同一门课只有一条记录学习进度更新时直接UPDATE而不是INSERT新行每次观看时长累加到duration字段。CREATE TABLE edu_study_record ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 用户ID, course_id bigint NOT NULL COMMENT 课程ID, progress int NOT NULL DEFAULT 0 COMMENT 学习进度百分比, duration int NOT NULL DEFAULT 0 COMMENT 累计学习时长(秒), exam_status tinyint NOT NULL DEFAULT 0 COMMENT 考试状态0未考 1已考, exam_score decimal(5,2) DEFAULT NULL COMMENT 考试成绩, study_status tinyint NOT NULL DEFAULT 0 COMMENT 学习状态0学习中 1已完成, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_course (user_id, course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学习记录表;关于外键我整个项目一张物理外键都没建全部靠应用层保证数据一致性。原因有两个一是物理外键在批量导入、初始化数据时容易制造麻烦删改的顺序卡得很死二是生产环境做分库分表时外键会成为障碍。这是业界常见做法对多数互联网和后台项目都适用只要开发时注意先删子表再删主表就行。2.3 菜单与权限的数据库表达菜单表用的是一棵父子树通过parent_id自关联。字段包含路由路径、组件路径、图标、排序、是否显示。菜单数据初始化时我直接手动INSERT不搞动态SQL生成因为菜单是相对稳定的资源管理员动态增删菜单的需求很小避免过度设计。CREATE TABLE sys_menu ( id bigint NOT NULL AUTO_INCREMENT, parent_id bigint DEFAULT 0 COMMENT 父菜单ID, menu_name varchar(50) NOT NULL COMMENT 菜单名称, path varchar(200) DEFAULT NULL COMMENT 前端路由地址, component varchar(200) DEFAULT NULL COMMENT 前端组件路径, icon varchar(100) DEFAULT NULL COMMENT 图标, sort int DEFAULT 0 COMMENT 显示排序, visible tinyint DEFAULT 1 COMMENT 是否显示, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜单权限表;角色表和菜单表是多对多关系通过sys_role_menu关联。用户从角色拿到菜单权限集合前端根据这个集合动态生成左侧菜单后端再在接口层用注解校验细粒度权限两层配合下来权限才不会漏。3. 后端开发的重头戏SpringBoot与MyBatis的落地细节3.1 项目分层与统一返回结构后端我把包结构分成了controller、service、mapper、entity、dto、config、common、utils这几层。entity对应数据库表dto接收前端传参两者不混用。数据传输遵循这样一个约定前端传参用dto后端返回用vo避免直接把数据库实体塞给前端防止多查了password这类敏感字段。统一返回结构是所有接口的基操。我写了一个Result类包含code、message、data三个字段。成功时code固定为200业务失败时code为500未认证时code为401。前端axios拦截器拿到响应后统一判断code这样每个接口函数不用重复写成功失败逻辑。public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(String message) { ResultT r new Result(); r.code 500; r.message message; return r; } }全局异常处理也非常重要。不用try-catch把Controller包得全是异常而是写一个RestControllerAdvice方法里抛出的所有异常统一到这里处理。业务异常我自定义了一个BusinessException代码里发现参数不对等场景直接就throw由全局处理拦截后转换成Result返回。这样Controller层代码非常干净。3.2 JWT认证与权限控制的完整逻辑登录认证走的是JWT方案。用户提交用户名密码后后端用BCryptPasswordEncoder验证密码成功后生成一个Token返回前端Token里包含userId、username、角色标识这些关键信息有效期设置为24小时。生成Token用的密钥放在application.yml配置里不要写死在代码中部署时用环境变量覆盖。Token的拦截在SpringBoot拦截器里做。我写了一个JwtInterceptor注册时指定拦截路径为/api/**排除登录接口。拦截器做的事有三件从请求头Authorization里取Token没有直接返回401。解析Token校验签名和有效期失败返回401。解析成功就把userId和角色信息放到ThreadLocal里供后面的业务代码随时取用。权限控制则在Controller方法上打注解比如RequirePermission(course:add)通过Spring AOP拦截查看当前用户角色拥有的菜单权限里是否包含这个权限标识。这套RBAC方案比Shiro轻量又比纯手写宽松足够支撑这种管理系统的权限需求。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(401, 请先登录); } Claims claims JwtUtil.parseToken(token.replace(Bearer , )); if (claims null) { throw new BusinessException(401, 登录状态已过期请重新登录); } UserContext.set(claims.get(userId, Long.class), claims.get(role, String.class)); return true; } }3.3 MyBatis动态SQL与分页插件的正确用法MyBatis构建查询条件非常灵活。课程列表页往往要支持关键字搜索、分类筛选、状态筛选、时间排序如果每个组合都写一个SQL方法显然不现实动态SQL的where标签就能解决。select idselectCoursePage resultTypecn.demo.entity.EduCourse SELECT * FROM edu_course where if testkeyword ! null and keyword ! AND title LIKE CONCAT(%, #{keyword}, %) /if if testtypeId ! null AND type_id #{typeId} /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select注意这里为什么用CONCAT拼接模糊查询而不是直接写%${keyword}%因为$传参会直接拼SQL字符串存在注入风险#是预编译占位符安全。分页直接用PageHelper这个分页插件。用法很简单在mapper查询前一行调用PageHelper.startPage(pageNum, pageSize)后面紧跟的查询语句就会自动拼接LIMIT同时返回一个Page对象可以取总数。PageHelper.startPage(pageNum, pageSize); ListEduCourse list courseMapper.selectCoursePage(queryDto); PageInfoEduCourse pageInfo new PageInfo(list);实际使用中有个坑PageHelper只对紧随startPage后的第一条查询语句生效如果那后面不小心先执行了别的查询分页就乱了。所以我会把PageHelper.startPage写在Service层紧挨着真正要分页的mapper调用之前中间不做任何其他数据库操作。后端还有两个高频功能点是文件上传和定时统计。文件上传用MultipartFile接参限制单个文件大小在Spring配置里设置。定时统计用Spring自带的Scheduled注解每天凌晨统计前一天的学员学时数据并更新汇总表注意定时任务发方法要加Async避免阻塞主线程生产环境还要注意集群部署时多个节点同时执行定时任务会造成重复计算这个项目因为单节点部署暂时没处理。4. 前端Vue从脚手架到接口联调的每一步4.1 项目初始化和目录结构前端用的Vue2生态脚手架直接vue create admin-ui创建UI组件库选的Element UI当时Element Plus还不够成熟Vue3的坑也还没填完。项目结构如下api目录按业务模块拆分的接口文件views目录页面组件router目录路由配置store目录Vuex状态管理utils目录axios封装和工具函数components目录公共业务组件跑起来之前必须先确认Node环境和npm源。我用nvm管理Node版本项目指定Node 16npm源切到国内镜像不然创建项目时下载依赖能等半小时。4.2 axios封装与Token处理axios封装是整个前端联调的基石。核心注意点有两个请求拦截器统一附加Token响应拦截器统一处理业务码和HTTP错误码。import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }) 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) { Message.error(res.message || 请求失败) if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.message || Error)) } return res }, error { if (error.response error.response.status 401) { Message.error(登录已过期请重新登录) localStorage.removeItem(token) router.push(/login) } else { Message.error(error.message || 网络异常) } return Promise.reject(error) } ) export default servicebaseURL用的是环境变量VUE_APP_BASE_API开发环境指向http://localhost:8080/api生产环境这个值会被nginx代理转发逻辑覆盖不用改代码。4.3 路由守卫与动态权限菜单路由守卫控制未登录用户无法访问内部页面在每次路由跳转前检查localStorage里有没有token没有就弹回登录页。这是第一层前端防护虽然真正的权限校验在后端但前端不能直接让用户看到无权限的页面。动态菜单的实现稍微复杂一点用户登录成功后后端根据角色返回该用户可见的菜单列表前端把这棵菜单树存到Vuex里在路由跳转后的回调里根据当前路径找到对应组件渲染。持有不同角色的用户看到的侧边栏菜单天然不同。这里我踩过一个坑动态菜单如果用静态路由表一次性注册所有页面用户即使没有权限访问也能通过URL直接跳转到达页面组件。解决方式是路由表只保留登录页和404页和基本框架页其余页面组件全部通过后端菜单列表动态注册这样URL访问没有权限的路由时会落到404。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else { if (!token) { next(/login) } else { if (!store.state.hasGotMenu) { store.dispatch(getMenuList).then(() { next({ ...to, replace: true }) }) } else { next() } } } })4.4 典型业务页面的实现要点课程学习页是前端最有技术含量的部分。视频播放用的video.js插件学习时长上报写了一个定时器每10秒向后端上报一次当前观看进度。这个上报接口设计成幂等更新后端拿到用户ID和课程ID直接UPDATE学习记录表。考试页面则是把题目从后端一次性取回题量控制在50题以内前端用radio组件渲染单选题多选题用checkbox答完提交时前端只负责把答案组装成JSON传给后端由后端统一判分前端不参与分数计算防止有人篡改。后台管理页面主要是表单和表格的组合。Elment UI的el-tableb分页组件配合后端分页接口页面切换时重新请求数据不是把全量数据拉到前端再前端分页数据量大时前端分页会卡成幻灯片。查询条件做成表单提交点击查询时重置pageNum为1再请求这个细节忘了的话就会出现在第5页按条件搜索搜不到结果的问题。联调阶段我踩得最狠的坑是跨域。开发环境下前端跑在8080端口后端跑在8080两边端口不一致浏览器直接报跨域。我用了Vue CLI的devServer代理把/api开头的请求转发到后端地址。生产环境则是由Nginx统一代理彻底规避跨域。5. 部署上线实录前端dist与后端jar如何配合5.1 前后端各自的打包方式后端打包我用的Maven执行mvn clean package -DskipTests跳过测试直接产出jar包。打包前记得把application.yml里的profile切到生产配置数据库连接信息改成服务器上的MySQL地址。mvn clean package -DskipTests nohup java -jar edu-admin.jar --spring.profiles.activeprod app.log 21 前端打包就一条命令npm run build产出dist目录。这个目录就是整个前端站点的静态资源把它上传到服务器指定目录比如/home/www/edu/dist后面交给Nginx解读。build之前要关注一个文件vue.config.js里的publicPath。如果部署在域名根路径publicPath设成/如果部署在子路径比如https://xxx.com/edu/就要设成/edu/否则JS和CSS全部404页面白屏。5.2 Nginx配置与反向代理服务器我用的CentOS 7 Nginx安装没什么说的重点在配置。整个配置的核心思路Nginx监听80端口收到请求后如果是静态资源的路径就从dist目录取文件如果是/api开头的路径就反向代理到后端jar端口。server { listen 80; server_name example.com; root /home/www/edu/dist; index index.html; 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; } }这里有个非常关键的配置location /里的try_files $uri $uri/ /index.html。因为Vue是SPA单页应用路由切到/course时实际上后端并没有这个物理路径如果Nginx不去找index.html就会直接404。加上这一句所有不存在的路径都会回退到index.html由Vue的路由接管渲染这才算真正能跑起来。反向代理方面我把/api统一转发到后端的http://127.0.0.1:8080。后端Controller的路由设计就要配合这个约定所有接口路径必须以/api开头。我遇到过有人后端接口直接写成/course/list前端请求/api/course/list后端怎么都接收不到就是路径前缀对不上。5.3 单机部署与后续延伸这套配置跑通的是最基础的单机部署一台服务器上同时部署Nginx、MySQL 8、Jar包。服务器配置建议至少2核4GMySQL和Java进程都吃内存1G内存会频繁触发交换分区导致接口响应变慢。我实际部署时用的是云服务器加域名的方式Docker方案也验证过但考虑到维护简易度和团队熟悉程度最终生产选择了直接跑Jar包。如果在Windows上本地演示后端同样打成Jar包运行前端dist目录扔到任意静态服务器即可浏览器访问静态服务器地址接口请求走Nginx代理或者前端直接配后端地址加允许跨域都行。如果团队熟悉容器化下一步可以把这套环境迁移成docker-compose编排MySQL、后端、Nginx各一个容器配置会清爽很多但这是项目稳定运行之后的事了。6. 复盘与踩坑那些让项目差点延期的问题6.1 MyBatis字段映射与时间字段的坑第一个坑是MyBatis的驼峰映射问题。数据库字段用的是下划线命名比如create_timeJava实体类用的是驼峰命名createTime如果MyBatis全局配置没开启驼峰映射查出来的create_time字段永远为null。解决方案是在application.yml里加一行mybatis: configuration: map-underscore-to-camel-case: true加了这行后MyBatis自动将下划线字段名转换为驼峰实体属性名常见问题但新人容易忽略。另一个相关的坑是数据库表字段评论和代码实体类字段必须严格对应不然写动态SQL时稍微错一个字母运行期才报错。第二个坑是MySQL时区问题。部署后前端页面显示时间和本地时间差了8个小时排查了一圈发现是JDBC连接串没指定时区。MySQL 8默认时区是UTC本地开发环境没注意上了服务器时间就对不上。修复方式是连接参数加serverTimezoneAsia/Shanghai同时数据库连接池配置里统一时区设置。jdbc:mysql://127.0.0.1:3306/edu_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai6.2 前端Long类型精度丢失这个坑是在联调阶段突然被发现的。后端用户表主键是bigint自增但到了第19位的时候前端JavaScript的Number类型精度不够ID末尾几位变成了0。比如数据库里存的ID是1534891200303108096前端拿到后却显示成1534891200303108000。这导致涉及用户详情、课程详情的操作全部错乱。理解原因后一下就明白了Java的Long是64位整数JS的Number是双精度浮点能精确表示的最大整数是2^53-1超过这个值就会丢精度。解决方案是我在后端统一把Long类型字段序列化时转成字符串让前端接收到的是字符串而不是数字。JsonSerialize(using ToStringSerializer.class) private Long id;或者在Jackson全局配置中把Long转String序列化。这个坑在有大量自增主键的后端项目中很典型一定要注意。6.3 文件上传大小限制与Nginx的配置联动课程封面、附件这些上传文件一开始明明没超过限制但还是报413错误。排查后发现有两层限制SpringBoot默认限制单文件1MB我把配置改成了20MB但Nginx那边也有一个client_max_body_size参数默认是1m如果不改请求到达SpringBoot之前就被Nginx拦下来了。配置加在nginx.conf的http块或server块里。client_max_body_size 50m;这个经历提醒我多一层代理就多一层限制排查问题不能只看应用本身要顺着请求链路一个一个环节查。6.4 Nginx代理后获取用户真实IP的坑日志记录里发现所有用户的请求IP都显示成127.0.0.1这是因为Nginx反代之后后端拿到的是代理服务器的地址。解决方式是在Nginx配置中添加X-Real-IP和X-Forwarded-For请求头后端用HttpServletRequest.getHeader(X-Real-IP)获取真实IP。如果不用这些头后面做登录日志、操作审计时IP数据就没法看了。这个项目从前端脚手架搭建到最终部署上线前后大概用了三周其中第一版上线后修复各种小问题用了一周。说实话这套系统并没有用到特别高深的技术但每个环节都踩了一遍很经典但也很有代表性的坑。总结下来前后端分离项目的核心不只是把页面和接口分开而是要在设计阶段就想清楚权限怎么控制、跨域怎么处理、部署怎么配合这几件事想清楚了后面写代码都很快。如果你正在做类似的管理系统这几个关键点提前想明白是可以少走不少弯路的。