FEATURED · 精选文章

基于ThinkPHP6的后台管理框架实战:RBAC权限设计与前后端分离

发布时间 / 2026/8/30 6:39:15
来源 / 创域科博编辑部
栏目 / 资讯中心
基于ThinkPHP6的后台管理框架实战:RBAC权限设计与前后端分离 简介这是一套基于ThinkPHP6开发的现代化后台管理框架面向PHP中高级开发者及企业级应用架构学习者解决传统后台系统权限松散、前后端耦合度高、扩展性差等痛点。资源包共599个文件含484个核心PHP业务与框架代码文件、25个Markdown文档含部署说明与API设计、18个JSON配置文件用于路由与权限规则定义、16个LICENSE协议文件以及.env、.gitignore等工程规范文件整体仅798KB轻量易部署。目前已有83人学习下载适合快速搭建RBAC权限体系、实践前后端分离架构、理解ThinkPHP6新特性如多应用模式、事件驱动机制的实战场景。读者可直接复用完整的权限控制模块、标准化API接口层、前端对接示例及SQL初始化脚本大幅降低后台系统从零开发门槛。 每次新项目一来第一件事就是把后台管理那一套重写一遍。写登录、写用户管理、写角色权限一个月里有十天都在重复劳动。后来我干脆把这类固定内容沉淀成一套基于ThinkPHP6的后台管理框架前后端分离权限部分直接用RBAC模型最后打成一个zip包方便复用。这篇文章就围绕这套框架展开讲清楚它怎么拆、权限表怎么建、联调期和部署期最常踩的坑以及我在实际开发中被问得最多的几个问题。如果你正在做PHP后台管理系统或者正要给公司内部搭一套带权限控制的前后端分离项目这篇笔记应该能帮你省不少试错时间。1. 动手之前先明确这套后台管理框架的边界在哪里1.1 为什么最终选了ThinkPHP6后台管理框架可选的技术方案太多了Java有Spring Boot、若依PHP有Laravel、ThinkPHP还有一堆国产快速开发平台。这套框架选TP6主要基于三个现实原因。第一团队熟悉PHPTP6在国内开发圈的资料密度足够高。碰到一个报错中文搜索几乎都能找到对应解法这对项目长期维护很重要。第二TP6本身不算重自带ORM、验证器、中间件、依赖注入后台管理需要的功能基本都覆盖了不需要为了一个管理端把Laravel全家桶都背上。第三版本上建议直接上TP6而不是继续用TP3.2.3这样的老版本。网上搜thinkphp3.2.3的人现在依然不少但TP3和TP6的目录结构、Composer生态、中间件机制完全是两个时代的思路。新项目用旧框架等于一开始就给自己制造技术债。我实际用下来的感受是TP6在本地开发和线上部署之间的行为差异很小。本地跑起来是什么样上线之后基本也是什么样这对做后台管理框架来说非常关键。1.2 前后端分离的边界后端只出JSON前端只做渲染很多团队对“前后端分离”理解不太一致。有人觉得只要不用服务端模板就算分离了有人觉得只要分成两个工程就算分离了。我在这套框架里的定义很简单后端通过HTTP接口提供JSON数据不关心页面长什么样前端负责路由跳转、页面渲染和交互逻辑通过Ajax请求拿数据。这个边界一旦定清楚很多争议自然就消失了。比如菜单。传统做法是后端输出页面时直接把菜单渲染好前后端分离之后菜单就应该由前端根据后端返回的权限数据动态生成。后端只告诉你当前用户能看到哪些菜单项、哪些操作按钮前端拿到数据自己渲染。这样权限模型和数据格式是前后端共用的契约后端改权限规则前端不需要跟着改页面代码。再比如接口路径。我习惯用/api开头区分业务模块和管理模块。路由层面就把/api/manage/*和/api/client/*分开中间件也能分别挂载。1.3 框架目录结构别在Controller里堆业务这套框架的后端工程目录划分参考了TP6多应用模式但做了简化。如果你解压zip包会看到类似下面的结构app/ controller/ admin/ # 后台管理端控制器 api/ # 对外接口控制器 middleware/ Cors.php # 跨域中间件 Auth.php # 登录认证中间件 Permission.php # 权限校验中间件 model/ # 数据模型 validate/ # 验证器 service/ # 业务逻辑层 ExceptionHandle.php # 全局异常处理 config/ route/ public/ index.php runtime/我特意把Service层从Controller里拆了出来。Controller只做参数接收和响应返回业务逻辑放在Service里数据交互放在Model里。一开始可能觉得多了一层文件很麻烦但等业务复杂到一定程度比如一个用户导入功能同时要做文件解析、数据校验、权限审计、消息通知如果没有Service层Controller就会膨胀到几百行既难读又难测。前端工程我选了Vue配合Element Plus市面上这类后台管理模板非常多不需要从零写。框架重点在后端前端只是作为配套示例工程存在。2. RBAC权限表怎么建才能既控菜单又控按钮2.1 五张核心表的设计思路RBAC全称Role-Based Access Control基于角色的访问控制。它的核心思想是用户不直接绑定权限而是通过“角色”这层中间层间接获得权限。为什么要这么设计因为如果用户直接绑权限几十个用户每人几十个权限维护工作量大到不可控通过角色统一管理你要改一批人的权限只需要改角色成员关系。对应到MySQL表结构我用了五张表CREATE TABLE admin_user ( id int(11) unsigned NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL DEFAULT , password varchar(255) NOT NULL DEFAULT , nickname varchar(50) NOT NULL DEFAULT , status tinyint(1) NOT NULL DEFAULT 1, last_login_time int(11) DEFAULT NULL, create_time int(11) DEFAULT NULL, update_time int(11) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uniq_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE admin_role ( id int(11) unsigned NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL DEFAULT , code varchar(100) NOT NULL DEFAULT , status tinyint(1) NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uniq_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE admin_permission ( id int(11) unsigned NOT NULL AUTO_INCREMENT, parent_id int(11) unsigned NOT NULL DEFAULT 0, name varchar(100) NOT NULL DEFAULT , code varchar(100) NOT NULL DEFAULT , type tinyint(1) NOT NULL DEFAULT 1, path varchar(255) NOT NULL DEFAULT , sort int(11) NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_parent (parent_id), KEY idx_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE admin_user_role ( user_id int(11) unsigned NOT NULL, role_id int(11) unsigned NOT NULL, PRIMARY KEY (user_id,role_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE admin_role_permission ( role_id int(11) unsigned NOT NULL, permission_id int(11) unsigned NOT NULL, PRIMARY KEY (role_id,permission_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;用户表和角色表之间通过admin_user_role关联角色表和权限表之间通过admin_role_permission关联。两张中间表的联合主键都设为用户ID角色ID、角色ID权限ID的组合这样既保证唯一性又天然带有索引。admin_permission表里的parent_id用来做菜单的树形结构。后台菜单有父子层级比如“系统管理”是顶级菜单下面挂“用户管理”“角色管理”“菜单管理”这种关系通过parent_id0和parent_id父菜单ID来表达。2.2 菜单权限、按钮权限、接口权限的粒度控制权限表字段里的type是容易被忽略但很重要的设计。我定义为值含义用途1菜单控制侧边栏菜单是否展示2按钮控制页面操作按钮是否显示3接口控制后端API是否允许调用菜单权限用于前端动态路由按钮权限用于页面上的“新增”“编辑”“删除”等操作按钮接口权限用于后端中间件校验。三者并不冲突菜单和按钮权限更偏交互层接口权限才是真正的安全边界。前端按钮控制可以写一个自定义指令比如v-permissionuser:addimport { permissionCodes } from /utils/auth const permission { mounted(el, binding) { const code binding.value if (code !permissionCodes.value.includes(code)) { el.parentNode?.removeChild(el) } } } export default permission后端对应接口校验public function handle($request, \Closure $next, $code null) { $userId $request-userId; $permissionCodes Cache::get(user_permissions_ . $userId); if (!is_array($permissionCodes) || !in_array($code, $permissionCodes)) { return json([code 40300, msg 无权限访问, data null], 403); } return $next($request); }这里要强调一点按钮权限只是前端体验的优化真正的安全防线在后端。前端隐藏了按钮用户依然可以手动构造请求去调接口所以后端接口权限校验绝对不能省。很多人觉得做了RBAC表就等于有权限了其实前端隐藏按钮和后端拦截请求是两件事缺一不可。2.3 权限缓存实时查库还是登录时拉取权限校验最简单的写法是每次请求都实时联表查询判断当前用户是否拥有某个权限码。在后台管理这种用户量基本在几百到几千的系统里性能其实没有压力。但有个问题系统里菜单和权限数据量会随着业务增加每次请求都做三表联查虽然不慢却不是最优解。我采用的方案是登录成功后把这个用户的所有权限码一次性查出来存到缓存里后面每个请求只从缓存读不再查库。缓存结构类似user_permissions:{userId} [user:list, user:add, role:list, ...]如果管理员在后台修改了某个角色的权限只需要调用一个清理方法把这个角色下所有用户的权限缓存删掉下次请求会触发重新拉取。管理员手动调整某个用户的角色后同样清理该用户的缓存。这个方案在我看来是后台管理系统性价比最高的实现简单效果明显而且权限变更不会出现超过一分钟的延迟。如果你用的是单机PHP环境缓存文件就够了如果有多台服务器就换成Redis。3. 跨域、异常和统一返回前后端联调期最磨人的三个点3.1 Access-Control-Allow-Origin跨域中间件的正确姿势前后端分离项目一开始联调9成概率会碰到浏览器控制台报错Access to XMLHttpRequest at http://localhost:8000/api/manage/user/list from origin http://localhost:3000 has been blocked by CORS policy原因很简单浏览器同源策略。前端工程跑在localhost:3000后端接口跑在localhost:8000端口不同就被视作跨域。浏览器为了安全默认不允许页面里发出的跨域Ajax请求拿到响应。解决方式有两种主流方案。第一种是Nginx反代把前端域名下所有/api开头的请求转发到后端这样浏览器看请求是同源的不存在跨域问题。这个方案上线后很推荐。第二种就是后端加CORS响应头其实是在后端告诉浏览器“我允许指定域名的页面来请求我”这是开发联调阶段最快的方法。我在框架里写了一个CORS中间件?php namespace app\middleware; use Closure; class Cors { public function handle($request, Closure $next) { $origin $request-header(Origin, *); $headers [ Access-Control-Allow-Origin $origin, Access-Control-Allow-Methods GET, POST, PUT, PATCH, DELETE, OPTIONS, Access-Control-Allow-Headers Authorization, Content-Type, X-Requested-With, Access-Control-Allow-Credentials true, Access-Control-Max-Age 86400, ]; if ($request-method() OPTIONS) { return response(, 204, $headers); } return $next($request)-header($headers); } }然后在app/middleware.php注册为全局中间件return [ \app\middleware\Cors::class, ];这里有几个细节特别容易踩坑。第一个坑Access-Control-Allow-Origin不能和Access-Control-Allow-Credentials同时使用通配符*。如果你设置Allow-Origin: *同时又设置了Allow-Credentials: true浏览器会拦截响应因为携带Cookie的请求不允许对任意来源开放。所以我在上面代码里是取请求的Origin字段动态回显开发时方便上线后建议在服务端加一个域名白名单校验不是自己的域名就不返回CORS头。第二个坑预检请求。前端如果自定义了Authorization请求头浏览器会先发一个OPTIONS预检请求确认后端允许后才会真正发POST。如果中间件没有单独处理OPTIONS请求它可能被认证中间件拦截返回401前端就会看到“跨域失败”的假象。正确做法是跨域中间件在认证中间件之前执行并且OPTIONS请求直接返回204不往下走。3.2 统一返回JSON先把规范定下来再写业务联调阶段另一个很常见的问题后端不同接口返回格式五花八门。有的直接返回数组有的返回{code:200}有的返回{code:1}表示成功前端对接每个接口都要单独看文档效率极低。我在这套框架里把返回结构定死了{ code: 0, msg: ok, data: {} }场景HTTP状态码codemsg成功2000根据业务指定参数错误20040001错误详情未登录40140100请先登录无权限40340300无权限访问服务器错误50050000服务器开小差了业务成功统一code: 0业务失败用非0的业务码HTTP状态码只在未登录、无权限等少数场景使用。前端axios拦截器里这样统一处理service.interceptors.response.use( response { const res response.data if (res.code 40100) { router.push(/login) return Promise.reject(new Error(未登录)) } if (res.code ! 0) { ElMessage.error(res.msg) return Promise.reject(new Error(res.msg)) } return res }, error { if (error.response?.status 401) { router.push(/login) } ElMessage.error(error.response?.data?.msg || 网络异常) return Promise.reject(error) } )这样一个拦截器覆盖所有接口的异常提示和登录态失效跳转业务代码里就不需要每个接口都写错误处理了。我建议不管你是用TP6、Laravel还是Java项目第一天就确定这套规范中途再改会牵扯所有前端页面的重写。3.3 接管异常把ThinkPHP的默认异常页改成JSON响应TP6开发模式下如果代码报错页面会展示一堆橙色或灰色的调试信息。这些信息对开发者有价值但对接前端时前端拿到的是一个HTML页面而不是JSON解析直接失败。更危险的是如果线上环境不小心开着调试模式数据库密码、文件路径、代码逻辑都会直接暴露出去。所以我在app/ExceptionHandle.php里重写了render方法namespace app; use think\db\exception\DataNotFoundException; use think\db\exception\ModelNotFoundException; use think\exception\Handle; use think\exception\HttpException; use think\exception\ValidateException; use Throwable; class ExceptionHandle extends Handle { public function render($request, Throwable $e): \think\Response { if ($e instanceof ValidateException) { return json([code 40001, msg $e-getError(), data null]); } if ($e instanceof HttpException) { return json([code $e-getStatusCode(), msg 请求错误, data null], $e-getStatusCode()); } if ($e instanceof DataNotFoundException || $e instanceof ModelNotFoundException) { return json([code 50000, msg 数据不存在, data null], 500); } // 记录详细错误日志线上不向前端暴露堆栈 record_error_log($e); return json([code 50000, msg 服务器开小差了, data null], 500); } }这里用了一个自定义函数record_error_log实际就是把异常信息写入runtime日志文件。线上环境设置.env中的APP_DEBUGfalse后TP6会走这个统一异常处理前端看到的是固定JSON不会暴露任何敏感信息。排查问题时去runtime/log/日期.log查看详细堆栈即可。TP6自带的验证器异常类是ValidateException如果忘记捕获默认会被父类当成普通异常返回蓝图页面前端拿到HTML会非常迷惑。所以这个类必须单独处理。4. 一次带权限的请求在TP6里到底怎么走完4.1 登录流程从校验密码到签发Token这套框架的登录逻辑不复杂但每一环都有讲究。用户提交用户名和密码后后端先查询用户是否存在、状态是否正常然后用password_verify校验密码。密码存储用的是password_hash这个函数生成的哈希串自带随机盐比md5($password)安全得多。校验通过后生成一个Token$token md5(uniqid(, true) . $userId . time()); Cache::set(login_token_ . $token, $userId, 7200);因为是后台管理系统我选择了用缓存保存Token而不是引入JWT。原因很简单后台管理场景下服务器端可以完全掌控Token生命周期。要让某个用户强制下线删掉缓存里的Token就行了要调整有效期改缓存TTL即可。JWT本身无状态签发之后要等它自然过期才能失效主动踢人很麻烦。Token有效期我默认设为2小时用户前端发请求时在请求头里带上Authorization: Bearer {token}后端中间件解析这个头即可拿到用户ID。为什么不用URL参数传Token因为URL会被Web服务器写进访问日志Token一旦打到日志里就是泄露风险。放请求头虽然也不是绝对安全但至少不会进Nginx默认访问日志。4.2 认证中间件与权限中间件执行顺序错了就是全场崩TP6路由中中间件可以先注册后按顺序执行。我建议必须保持这个顺序CORS中间件 → Auth认证中间件 → Permission权限中间件。原因刚刚提到CORS中间件要处理OPTIONS预检请求。如果CORS放在Auth后面浏览器发来的OPTIONS预检请求会先被Auth拦截而OPTIONS请求根本没有Authorization头直接返回401前端会一脸懵。Auth中间件的核心逻辑?php namespace app\middleware; use Closure; class Auth { public function handle($request, Closure $next) { $token $request-header(Authorization, ); $token str_replace(Bearer , , $token); $userId Cache::get(login_token_ . $token); if (!$userId) { return json([code 40100, msg 请先登录, data null], 401); } $request-userId $userId; return $next($request); } }注意这里把$userId挂到$request对象上后续的权限中间件和控制器都可以直接从$request-userId取不需要重复解析Token。Permission中间件通过路由参数接收权限码比如Route::group(user, function () { Route::get(list, admin.User/list)-middleware(permission:user:list); Route::post(save, admin.User/save)-middleware(permission:user:save); Route::delete(delete/:id, admin.User/delete)-middleware(permission:user:delete); })-middleware(\app\middleware\Auth::class);这个设计的好处是权限码和接口路由绑定在一起看路由文件就能大概知道系统有哪些权限点。权限中间件内部从缓存读取当前用户权限码数组本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻