做过后台管理系统的人都知道,权限模块和内容管理模块看起来“人人都会”,真要落地却全是坑。ElementAdmin 是我多年来一直习惯用来快速搭后台的那套 Vue + Element UI 方案,而 CMS 管理系统更像是给这个后台装上“能真正运营内容”的躯干。这两块拼在一起,就是一个完整的、可以直接复制到真实项目里的后台权限管理系统 + CMS 内容管理组合。
这篇文章不打算写成项目文档,而是把我在实际开发里怎么设计、怎么编码、怎么排查问题的过程完整摊开讲。内容包括 RBAC 权限模型的落地方式、动态路由和菜单生成逻辑、CMS 的栏目/文章/标签数据模型设计、富文本编辑与图片上传的取舍,以及一批我查过很久才解决的故障实录。无论你是刚接触后台管理系统的新手,还是想重构老旧权限模块的开发,应该都能从这里找到可以直接“抄作业”的部分。
1. 项目定位与整体设计思路
1.1 为什么把权限和 CMS 放在同一个系统里
如果你只做一个企业内部后台,权限管理基本就是全部;如果你要做的是门户网站、内容站点或运营后台,那 CMS 才是日常使用频率最高的模块。可现实情况是,很多团队把两套系统分开维护,后台账号体系一套,内容管理平台又一套,结果就是用户要记两套密码,权限逻辑在两边各写一遍,改一处漏一处。
ElementAdmin 方案的价值在于:权限系统提供“谁能进哪个菜单、谁能点哪个按钮”的基础能力,CMS 模块负责“谁能发布文章、谁能审核内容、谁能管理栏目”,两者共用同一套用户角色体系。这样设计之后,运营人员登录一次就能同时完成内容管理和账号权限操作,开发人员也只需要维护一套 RBAC 逻辑,而不是为每个业务模块单独造轮子。
从实际使用来看,这种整合还有一个隐藏好处——内容审核权限可以做得很细。比如“普通编辑只能创建草稿,主编才能发布上线”,用权限系统的按钮级控制就能直接实现,不需要在 CMS 业务代码里写 if else 判断当前用户是谁。权限问题是通用问题,内容管理是业务问题,把它们分层处理,代码才不容易腐化。
1.2 技术选型与架构拆解
这个项目的核心前端技术栈是 Vue 2 + Element UI,因为 ElementAdmin 生态最成熟、踩坑资料最多,工作稳定性也经过大量项目验证。如果你是新项目,我建议用 Vue 3 + Element Plus,组件 API 更现代,TypeScript 支持也更好。后端我用的是 Spring Boot + MyBatis Plus,配合 MySQL 存储业务数据,Redis 缓存用户的权限信息和 Token。这套组合在中小企业项目里非常常见,招聘容易、排查问题资料多,几乎不会卡住你。
先看整体架构分层。前端负责渲染菜单、拦截路由、控制按钮显隐;后端负责校验身份、下发权限数据、执行 RBAC 鉴权。两者之间不是简单的“前端把菜单写死、后端只做登录验证”,而是后端统一返回当前用户的菜单树、按钮权限码,前端根据这些数据动态生成路由和菜单。这样设计的核心原则是:权限数据的唯一真相源在后端,前端只做展示和交互控制,绝不在前端写死用户角色。
我建议把权限相关的代码独立到src/permission目录,CMS 相关的代码放入src/views/cms和src/api/cms,业务组件再单独放src/components/Cms。这样哪怕未来把 CMS 拆成专门的微服务,改动也只是替换 API 层,不会牵一发动全身。
1.3 目录结构与模块划分参考
实际项目中我习惯用下面的目录结构区分职责,这里给出一个经过多个项目检验的版本:
src/ ├── api/ # 所有接口请求定义 │ ├── auth.js # 登录、退出、获取用户信息 │ ├── permission.js # 菜单、角色、权限码接口 │ └── cms/ # CMS相关接口 │ ├── article.js # 文章管理 │ ├── category.js # 栏目管理 │ └── upload.js # 文件上传 ├── permission/ # 权限核心逻辑 │ ├── guard.js # 路由守卫 │ └── auth.js # 权限指令/校验工具 ├── router/ │ ├── index.js # 路由实例 │ └── dynamic-routes.js # 动态路由表 ├── store/ │ ├── modules/ │ │ ├── user.js # 用户状态 │ │ ├── permission.js # 菜单/权限状态 │ │ └── cms.js # CMS编辑状态等 ├── views/ │ ├── cms/ │ │ ├── article/ # 文章列表、编辑 │ │ ├── category/ # 栏目管理 │ │ └── media/ # 素材管理 │ └── system/ │ ├── user/ # 用户管理 │ ├── role/ # 角色管理 │ └── menu/ # 菜单管理这个结构的特点是:权限逻辑和业务页面隔离得比较干净。CMS 页面只关心“如何编辑文章”“如何选择栏目”,不需要关心“当前用户是不是管理员”,因为路由守卫和按钮指令已经在上层把这些事做完了。
2. 权限管理系统的核心设计与实现
2.1 RBAC 模型落地:用户、角色、菜单、按钮的四层关系
做权限管理,绕不开 RBAC(基于角色的访问控制)模型。很多初学者会问:为什么不能直接给用户绑定菜单权限,非要搞一个角色出来?答案很简单——维护成本。当你有 50 个用户、20 个菜单时,如果直接做用户与菜单的多对多关系,新增一个菜单就要给 50 个用户逐个配置;但中间加一个“角色”层,你只需要给“运营人员”“内容编辑”“管理员”这几个角色设置菜单,再把用户挂到角色下,后续批量调整就方便多了。
这次项目的数据库表结构我按经典 RBAC 的方式设计,一共五张核心表:用户表sys_user、角色表sys_role、菜单表sys_menu、用户角色关联表sys_user_role、角色菜单关联表sys_role_menu。菜单表里需要包含“目录”“菜单”“按钮”三种类型,分别用menu_type字段区分。按钮本质上也是一种“资源”,它挂在某个菜单下面,用权限码(例如cms:article:add)标识。
这里有一个细节很容易被忽略:删除角色时,必须同时清理sys_role_menu和sys_user_role中对应的关联数据,否则会出现“角色没了但用户还残留着角色ID”的脏数据,导致用户彻底无法登录。我一般会在删除角色的 SQL 事务里同时处理这三张表,避免后续排查时被这种隐性 bug 折磨。
2.2 动态路由与菜单生成逻辑:后端返回什么,前端就渲染什么
ElementAdmin 最核心的体验是“登录后按角色显示不同菜单”。有两种常见实现:第一种是前端把所有路由写死,根据角色字段过滤显示;第二种是后端动态返回菜单。我强烈建议用第二种,因为第一种方式虽然省事,但菜单数据仍暴露在前端代码里,稍微懂行的人看一眼前端文件就能把所有路由摸清,安全性和灵活性都不太行。
动态路由的实现过程分成三步。第一步:用户登录成功后,后端返回一个由当前用户可访问菜单组成的数据结构,里面包含菜单名称、路径、组件地址、图标、排序号等字段。第二步:前端拿到这个结构后,调用router.addRoutes()动态注册路由,同时把菜单树存入 Vuex。第三步:侧边栏组件完全根据 Vuex 里的菜单树渲染,不再写死任何菜单项。
组件地址的映射要注意:后端返回的component字段不是真实组件对象,而是一个字符串路径,例如cms/article/index。前端需要把字符串转换成组件对象,我一般使用import.meta.glob或require.context去读所有views目录下的.vue文件,建立路径与组件的映射表。这步处理好了,新增一个页面时只需在数据库菜单表里加一行记录,不用改前端路由文件,真正实现“菜单可配置”。
2.3 权限树结构和后端接口设计:递归加载与 N 叉树的实际应用
菜单必然是层级结构,顶部一级导航,下面挂二级菜单,再下面挂按钮。这种结构就是典型的 N 叉树。后端从数据库查出所有菜单后,需要把它们组装成树形结构返回给前端。说实话,这个递归算法本身不难,但很容易在“排序”和“父子关系缺失”上踩坑。
我给一个稳定做法。数据库菜单表加parent_id和order_num两个字段。后端先把所有菜单查出来,放进一个 Map,key 是菜单 ID,然后遍历一次全部菜单,把每个菜单挂到其父节点的children数组中;最后只返回parent_id为 0 的根节点列表。这样只需两次遍历就能构建完整树,时间复杂度 O(n)。
Java 代码大致长这样,核心思路是“一次遍历挂树”:
List<MenuVO> menuList = menuMapper.selectAllMenus(); Map<Long, MenuVO> menuMap = menuList.stream() .collect(Collectors.toMap(MenuVO::getId, item -> item)); List<MenuVO> roots = new ArrayList<>(); for (MenuVO menu : menuList) { if (menu.getParentId() == 0L) { roots.add(menu); } else { MenuVO parent = menuMap.get(menu.getParentId()); if (parent != null) { parent.getChildren().add(menu); } } } roots.sort(Comparator.comparingInt(MenuVO::getOrderNum));这里有个容易踩的坑:如果你只按parent_id分组再递归子查询数据库,会导致 SQL 执行次数成倍增长。一次全量查 + 内存组装是效率最优的方案。菜单表本身数据量很小,全量查出来通常只有几百行,完全不需要担心性能。
2.4 按钮级权限控制:自定义指令和权限码的配合
菜单级权限解决的是“能进哪个页面”的问题,但运营后台里经常还要控制“这个用户能不能点新增、能不能点删除”。按钮级权限的标准做法是用自定义指令v-permission,传入一个权限码,指令内部校验当前用户的权限码列表里是否包含它,不包含就直接把 DOM 删掉。
项目里我用 ElementAdmin 常见的指令写法:
import Vue from 'vue' Vue.directive('permission', { inserted(el, binding) { const required = binding.value const userStore = store.getters.permissions const hasPermission = userStore.some(code => required.includes(code)) if (!hasPermission) { el.parentNode && el.parentNode.removeChild(el) } } })使用方式很简单,在按钮上写v-permission="['cms:article:delete']",当前用户没有文章删除权限时,这个按钮就不会出现在页面上。要注意的是,前端按钮隐藏只是体验层面的控制,真正的安全边界必须在后端接口做。后端每个修改类接口都要根据当前登录用户的角色校验权限码,否则别人直接调接口就能删数据。这是权限系统里“前后端配合”的典型场景:前端管体验,后端管安全。
3. CMS 管理系统的核心环节实现
3.1 内容模型设计:栏目、文章、标签怎么建表
CMS 的核心业务对象就是内容,但内容本身的信息结构是有层次的。我把 CMS 的数据模型拆成三大部分:栏目表(分类)、文章表(内容)、标签表(附加属性),另外还有文章栏目的多对多关系表。这里不建议把栏目设计成单表无限极分类之后还在文章表里存一个冗余的父级名称,因为那样做一旦栏目改名,文章列表里显示的名称会不一致。
栏目表cms_category的字段包括:id、name、parent_id、sort、status,支持无限层级的栏目结构,同时使用parent_id组成树。文章表cms_article的字段比较多,我列举几个关键的:title、summary、content、cover_image、category_id、status、author_id、publish_time、view_count。这里注意status字段建议用整型枚举,0=草稿、1=待审核、2=已发布、3=已下架,不要用字符串,否则查询和判断会比较别扭。
写文章和栏目关系时,有人习惯在文章表直接存category_id,我建议再想想。如果未来一篇文章需要挂在多个栏目下(比如一篇技术文章同时属于“教程”和“推荐”),单字段就满足不了。稳妥的方案是增加一张cms_article_category关联表,文章和栏目多对多。虽然初期复杂一点,但线上运营要调整栏目关系时你会感谢这个设计的。
3.2 富文本编辑器选型与内容处理
CMS 无法避开富文本编辑器。我用过的方案有几种:早期用 UEditor,功能全但维护不太活跃,需要自己处理图片上传兼容问题;后来项目里大量使用 wangEditor,轻量、接入快,中文文档也齐全;如果要求更高的协同编辑和排版自由度,可以考虑阅读器和编辑器分离的富文本框架。选型的核心依据是团队维护成本和需求复杂度,不要盲目追求功能大而全。
富文本编辑器的内容存储我建议直接保存经过清洗的 HTML 字符串到数据库content字段。但保存前必须做过滤,这一步非常关键。很多 CMS 被入侵就是因为在富文本内容里嵌入了<script>标签或者带onerror的图片标签,后台渲染时触发 XSS 攻击。我会在服务端用白名单策略过滤所有标签,只保留p、img、a、h1-h6、ul、ol、li、table等安全标签,并移除所有on*事件属性。前端展示时,再调用第三方库做一次 HTML 转义。
图片上传也是富文本模块的高频功能。编辑器里点击上传图片后,图片文件需要先传到服务器或对象存储,再把返回的 URL 插入编辑器。我的建议是:所有上传接口统一走同一个上传入口,返回的 URL 必须是完整可访问的地址,并且在存储路径中加入日期分目录,例如2025/06/12/uuid.png,避免图片堆积在一个目录影响性能和管理效率。
3.3 内容发布流程与状态机设计
CMS 的发布流程如果不设计清楚,很容易出现“编辑点了发布、文章直接上线,结果标题有错别字”这种事故。我常用的状态流是:草稿 → 待审核 → 已发布 → 已下架,其中“已发布”状态允许重新变为“待审核”或“已下架”。
配合权限系统,这个状态机可以这样跑:普通编辑只有“创建草稿”和“提交审核”的权限;主编有“审核通过”“驳回”的权限;管理员可以强制下架。每个状态流转在后端接口里都要做校验,不能只是前端按钮隐藏。比如编辑直接调用“审核通过”接口,后端必须判断当前用户角色是否有对应权限码,没有就返回 403。
文章上线时,我会额外处理两个小细节:一是记录publish_time,列表页默认按发布时间倒序,不要用创建时间;二是更新首页缓存或通知搜索引擎更新接口,如果项目里接了搜索功能,上线后要异步刷新索引。如果状态是定时发布,还需要一个定时任务扫描publish_time在五分钟内且状态是待审核的文章,自动置为已发布。
3.4 附件与图片上传的完整处理方案
后台 CMS 的另一大块是素材和附件管理。我建议单独建一个cms_media表记录所有上传的文件信息,包括原始文件名、存储路径、文件大小、MIME 类型、上传者、上传时间。这样素材中心页面可以直接读取这张表展示“最近上传”,也可以快速按上传者或时间筛选。
上传的实现细节有几个关键点。
第一,前端用el-upload组件时,action设置为后端上传接口,同时通过headers携带 Token。如果配置的接口路径有跨域问题,记得在后端或网关层统一处理跨域,不要把跨域配置散落到每个业务接口上。
第二,后端接收文件后,一定不要使用用户提供的原始文件名直接存磁盘,否则既可能重名覆盖,又会带来路径穿越风险。我用 UUID 重命名文件,再拼接日期目录,并把原始文件名单独存到数据库original_name字段,下载时通过接口返回。
第三,图片压缩可以考虑在服务器端完成。原始上传图可能有几兆大小,展示在文章列表时浏览器加载很慢。目前成熟的方案是在上传时生成缩略图和中等尺寸图,分别用于列表和详情,CDN 或静态资源服务会根据 URL 参数返回对应规格。这个方案能明显提升内容页的打开速度。
4. 权限与 CMS 结合的完整实操流程
4.1 从登录到渲染菜单的完整链路走读
整个系统的运行链路可以这样理解。用户打开系统进入登录页,输入账号密码后,前端调用登录接口,后端校验成功后返回 JWT Token。前端把 Token 存到本地并写入请求拦截器,之后所有接口自动携带。接着前端调用“获取当前用户信息”接口,得到用户基本信息、角色列表、权限码列表和菜单树。
拿到菜单树后,前端做两件事:一是把菜单树更新到 Vuex,侧边栏响应式渲染;二是把菜单树转换成路由配置,调用router.addRoutes注册。这两步完成后,页面跳转到用户首页。如果用户直接访问一个没有权限的 URL,路由守卫会检查当前路由是否在用户可访问的权限码列表里,不在则重定向到 403 页面。
需要注意,路由守卫不能只检查“是否已登录”。我见过很多项目只判断 Token 存在就放行,结果用户手动修改 URL 就能进入未授权页面。正确的做法是:在beforeEach守卫里判断用户信息和权限信息是否已经加载,如果未加载,先调用获取用户信息接口,再根据返回的菜单判断目标路由是否可访问。
4.2 角色分配 CMS 操作权限的场景演练
假设我们要在系统里新增一个“内容编辑”角色,并只允许他管理文章和素材,禁止进入系统设置。操作流程是:在系统管理的角色管理里新增角色,勾选菜单权限时只勾选 CMS 栏目、文章管理、素材管理这几个菜单,按钮权限只勾选“新增文章”“修改自己文章”“上传素材”“删除素材”,不勾选任何系统管理相关菜单。
保存之后,把一个测试账号绑定到该角色。重新登录测试账号,侧边栏只显示 CMS 相关菜单。进入文章列表页面,“删除”按钮因为权限码不匹配而隐藏。此时如果直接手动调用删除接口,后端会判断该角色没有cms:article:delete权限码,返回 403。这就是前后端配合的完整闭环。
为了验证权限是否真的生效,我常用两个测试手段。一是用无权限账号直接请求受保护的 API,看是否返回 403。二是切换不同角色登录同一浏览器无痕窗口,检查菜单和按钮的差异是否符合预期。不要只在前端肉眼检查按钮显隐,后端接口的权限校验才是安全底线。
4.3 CMS 中的 SQL 注入风险与防御实践
CMS 这类内容系统天然是 SQL 注入的重灾区,因为内容查询条件多、关键词搜索多、排序字段可能由前端传入。最容易出问题的有两个地方:一个是后台文章列表的标题搜索,另一个是栏目 ID 的拼接。
防御方式其实很简单,核心原则就是“永远不要手动拼接 SQL”。使用 MyBatis 时,参数一律用#{}占位符,不要用${};如果是 MyBatis Plus,直接用 Wrapper 的like、eq方法。排序字段如果必须由前端传,服务端要维护一个白名单映射,比如只允许publish_time、view_count、id这三个字段排序,前端传任何其他字段一律使用默认排序。
这也是搜索热词里经常出现“sql注入&cms”这个关键词组合的原因。很多 CMS 被攻击,不是系统多复杂,而是开发时为了方便,把查询条件直接拼进 SQL。养成用参数化查询的习惯后,这部分风险基本可以彻底规避。
4.4 内容上下线后如何同步页面与搜索
CMS 还有一个容易被忽略的环节:内容上下线后,前端展示页面和搜索索引需要更新。如果站点是传统的服务端渲染,发布文章后可能要生成静态页;如果是前后端分离的 SPA,前端页面动态请求接口,那么只要接口返回的数据是最新的就没问题,但搜索索引还是需要主动推送或自动抓取。
我用过比较轻量的方案是:在文章发布成功、下架成功后,发送一条消息到消息队列,由消费端更新页面级缓存,并调用搜索服务的索引更新接口。如果项目规模不大,没有引入消息队列,也可以直接用 Spring 的事件发布机制,在事务提交后执行同步操作。这样即使同步失败,也不会影响主流程的正常响应。
5. 常见问题与排查技巧实录
5.1 动态路由刷新后 404 的经典问题
这个坑几乎所有人都会踩:登录后进入系统一切正常,但一刷新页面就跳 404 或白屏。原因很典型——刷新后 Vuex 里保存的菜单数据消失,动态路由没有重新注册,当前路径找不到对应的路由记录。
解决办法是在路由守卫的最开始判断动态路由是否已经注册。用一个标记字段(比如store.getters.dynamicRoutesAdded)记录是否已添加。如果为 false,先调用获取用户信息接口,拿到菜单数据后注册动态路由,再next({ ...to, replace: true })重新进入目标路由。这样刷新后的第一次跳转会被拦截,等到路由注册完成后再放行,就不会再出现 404。
我在项目里还遇到过另一个变体:多个角色之间切换登录时,旧角色的路由没有清空。解决方式是退出登录或切换角色时,调用router.matcher重置为一个全新的路由实例,再注册新角色的动态路由。这一步没做,就会出现“A 角色登录后退出,B 角色登录还能看到 A 角色的菜单”。
5.2 按钮权限指令在 v-if 场景下失效
自定义指令v-permission在页面初始化时插入 DOM,如果按钮外层还有v-if控制,就可能出现指令没来得及执行就因条件变化被销毁的异常。另一个常见问题是:按钮是通过表格插槽动态渲染的,指令插入时权限数据还没加载完成,导致所有按钮都被移除。
排查这类问题,我建议按以下顺序检查:
- 确认权限码列表是否在指令执行前已经从后端返回,可以用
console.log打印store.getters.permissions。 - 确认指令绑定值是数组格式,
v-permission="['cms:article:add']"不要写成v-permission="'cms:article:add'",类型不同,判断会出错。 - 如果表格行内按钮需要权限控制,不要在
v-for里动态销毁插入,改为在渲染函数里用计算属性过滤后再渲染按钮数组,这样更稳定。
如果项目里按钮显隐逻辑大量存在,也可以考虑用全局混入加v-if的方式统一处理,而不是一个指令走天下。指令适合简单场景,复杂场景建议抽成一个可复用的权限组件。
5.3 富文本图片上传的跨域与接口异常
CMS 里富文本图片上传报错,是客服反馈频率很高的问题。常见错误有几种:接口 500、上传成功但回显不了、图片能插入编辑器但前端展示时被拦截。
先说跨域问题。如果前端域名是admin.example.com,后端接口是api.example.com,这种情况下上传请求会被浏览器拦截。解决方法是后端统一配置 CORS 过滤器或者在网关层统一处理,需要注意的是处理OPTIONS预检请求,让它在未登录校验前就返回允许跨域。上传接口的 Token 鉴权也要注意,有些组件库上传自定义请求头时不会带上 Token,需要在before-upload钩子里手动设置 header。
再解释一下“播放器导入请求上传接口出现异常”这类问题。很多 CMS 的富文本内容里嵌入了视频或音频,视频文件通常较大,普通接口请求容易出现超时。我建议视频单独走分片上传流程,富文本里只保存最终播放地址。同时在后端要设置合理的文件大小上限和上传超时时间,否则大文件上传会导致整个后台接口卡顿。
5.4 常见问题排查速查表
| 问题现象 | 可能原因 | 排查优先级 | 解决办法 |
|---|---|---|---|
| 刷新后 404 | Vuex 动态路由丢失 | 高 | 路由守卫中重新注册动态路由 |
| 按钮不显示但接口可调用 | 按钮指令权限码不匹配 | 高 | 检查角色权限码配置与接口注解是否一致 |
| 接口本来就没权限却返回 200 | 后端接口缺权限注解 | 高 | 在 Controller 方法上加权限码校验 |
| 上传图片后回显 403 | Token 未携带或 CORS 未配置 | 中 | 统一设置上传请求头与后端 CORS |
| 文章搜索关键词带引号报错 | SQL 拼接导致语法错误 | 高 | 改用参数化查询 |
| 切换账号后菜单残留 | 路由未重置 | 高 | 退出登录时重置路由 matcher |
| 富文本标签被浏览器过滤 | XSS 白名单清洗过严 | 低 | 调整服务端标签白名单配置 |
这张表是我在真实项目里迭代了很多次总结出来的,排查问题时按优先级从上往下看,大部分问题都能快速定位到根因。
6. 几个值得再深入的扩展方向
如果你已经能够熟练搭建这套 ElementAdmin 权限 + CMS 系统,后续有几个方向非常值得继续深化。
第一个方向是更细粒度的数据权限。比如“编辑只能看到自己创建的文章,主管能看到团队成员的文章”。这需要从 RBAC 的“功能权限”扩展到“数据权限”,常见方案是在用户表或角色表里维护一个数据范围字段(全部、本部门、仅本人),后端在查询文章列表时根据数据范围动态拼接查询条件。
第二个方向是操作日志与审计。CMS 后台内容一旦发布,影响是公开的,因此“谁在什么时候改了什么”非常重要。可以用 AOP 切面在文章新增、修改、上下线接口上记录详细日志,保存操作前后字段对比。这套日志系统对排查恶意操作和合规审计都非常有用。
第三个方向是内容多端发布。后台 CMS 写好的文章不仅要展示在 PC 站点,可能还要推送到小程序或 App。可以设计一个发布渠道的概念,文章发布后按渠道生成对应的适配内容,通过消息队列异步推送或生成静态页面。这部分比较重,但对内容运营平台来说是刚需。
我自己的体会是,权限管理和 CMS 的合体项目,最大的挑战不在技术难点,而在于把权限设计想清楚之后,让业务模块自然复用这套能力。很多项目做到后面乱,就是因为一开始没有把权限抽离成通用模块,到处散落着角色判断代码。只要这一步做好了,后续加任何业务功能都会很顺手。