ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ThinkPHP6后台管理框架:前后端分离与RBAC权限管理实战

ThinkPHP6后台管理框架:前后端分离与RBAC权限管理实战

简介:本资源是一套基于PHP主流框架ThinkPHP6构建的后台管理框架源码,面向有一定PHP基础、希望深入理解企业级后台开发与权限设计的开发者。项目采用前后端分离架构,前后端通过API接口交互,并集成RBAC权限管理模型,通过角色关联用户与权限,便于应对复杂多变的权限需求,适合用于学习后台系统架构、权限控制与接口协作的实践场景。压缩包共599个文件,约798KB,以484个PHP源码文件为核心,辅以25个Markdown说明、18个JSON配置、12个stub模板及yml、sql、env等配置与数据库脚本,目录结构围绕think-admin主模块组织,便于按模块阅读与二次开发。目前已有83人学习下载。通过该资源,读者可获取一套结构完整的后台管理框架参考实现,理解前后端分离下的接口设计与RBAC权限落地方式,并借助配置与数据库脚本快速搭建本地运行环境,为自身项目开发提供可复用的架构思路与排错参考。

1. 从一张权限表说起:ThinkPHP6 后台框架到底解决什么问题

很多团队的后台管理系统,最初都是“能跑就行”:菜单写死在模板里,权限判断散落在各个控制器,前端页面和后端接口混在同一个目录。等到运营要加一个“只读财务”角色,或者产品要求把管理端拆成独立 SPA,改造成本立刻失控。ThinkPHP6 后台管理框架配合前后端分离与 RBAC 权限管理,解决的正是这类问题:它把“谁能看到哪个菜单、谁能调用哪个接口”从业务代码里抽出来,变成可配置、可校验、可审计的一层基础设施。

这套方案适合三类人:一是要在一到两周内交付一个可用的运营后台,又不想从零写登录、菜单、角色、日志的开发者;二是已经用 ThinkPHP6 做 API,但权限校验还停留在if ($user['role'] == 'admin')这种硬编码阶段的团队;三是需要把后台前端交给独立前端同学维护,后端只负责提供 JSON 接口的场景。下面按“先立住模型,再动手复现,最后排坑”的顺序展开,中间会给出可直接抄的代码和参数说明。

2. RBAC 模型怎么落到 ThinkPHP6 的表结构和中间件上

2.1 五张核心表与字段设计

RBAC 不是新概念,但落到 ThinkPHP6 项目里,表结构设计直接决定后面写代码顺不顺手。常见做法是五张表:admin_user、role、permission、role_permission、user_role。不要一上来就搞“权限继承”“数据权限”,先把功能权限跑通。

表名关键字段说明
admin_userid, username, password, salt, status后台用户,密码用 password_hash 存储
roleid, name, slug, status角色,slug 用于代码里判断,如 finance_readonly
permissionid, name, slug, type, parent_id, path权限项,type 区分菜单/按钮/接口
role_permissionrole_id, permission_id角色与权限多对多
user_roleuser_id, role_id用户与角色多对多

permission表里的type字段很关键:菜单类型用于前端渲染侧边栏,接口类型用于后端中间件校验。path存接口路由或前端路由,例如admin/user/list。parent_id用来做菜单树,避免前端自己拼层级。

2.2 用中间件做接口级权限校验

ThinkPHP6 的中间件是权限校验最自然的落点。不要在每个控制器方法里写checkAuth(),那样一旦漏写就是越权漏洞。下面是一个可复现的权限中间件骨架:

<?php // app/admin/middleware/CheckPermission.php namespace app\admin\middleware; use think\Request; use think\Response; use app\admin\model\AdminUser; class CheckPermission { public function handle(Request $request, \Closure $next) { $user = $request->userInfo; // 由前置 JWT 中间件注入 if (!$user) { return json(['code' => 401, 'msg' => '未登录'], 401); } // 当前请求的权限标识,约定为 控制器/方法 $slug = strtolower($request->controller()) . '/' . strtolower($request->action()); // 超级管理员直接放行,slug 在角色表里固定为 super_admin if (in_array('super_admin', $user['roles'])) { return $next($request); } $permissions = AdminUser::getPermissions($user['id']); // 返回 slug 数组 if (!in_array($slug, $permissions)) { return json(['code' => 403, 'msg' => '无权限'], 403); } return $next($request); } }

逻辑说明:先取用户,再拼出当前请求的权限标识,然后判断是否超管,最后查用户权限集合。参数说明:$request->controller()返回的是控制器名,ThinkPHP6 默认不带Controller后缀,所以拼出来的 slug 要和permission表里存的一致。如果项目用了多级控制器,建议在permission.path里存完整路由,而不是靠控制器名拼。

AdminUser::getPermissions()的实现要注意缓存。每次请求都查三张表关联,QPS 一上来数据库先扛不住。常见做法是用 Redis 缓存用户权限,key 为admin:perm:{user_id},角色或权限变更时删掉对应 key。

2.3 前后端分离下的登录态与菜单下发

前后端分离后,后端不再渲染菜单 HTML,而是返回 JSON。登录接口返回 token,前端存起来,后续请求带在 header 里。菜单接口根据当前用户权限过滤permission表中type=menu的记录,组装成树返回。

<?php // app/admin/controller/Auth.php public function login() { $data = input('post.'); $user = AdminUser::where('username', $data['username'])->find(); if (!$user || !password_verify($data['password'], $user->password)) { return json(['code' => 400, 'msg' => '账号或密码错误']); } $token = Jwt::encode(['uid' => $user->id, 'exp' => time() + 7200]); return json(['code' => 0, 'data' => ['token' => $token]]); } public function menus() { $user = request()->userInfo; $menus = AdminUser::getMenus($user['id']); // 只返回 type=menu 且有权限的 return json(['code' => 0, 'data' => $menus]); }

参数说明:token 有效期按项目安全要求设,后台管理一般 2 小时到 8 小时。getMenus()里要按parent_id递归组装,返回结构建议是{id, name, path, children},前端直接映射到路由和侧边栏。注意不要把没有权限的菜单也返回给前端再靠前端隐藏,那是自欺欺人,接口该拦的必须拦。

3. 从零跑通一个最小可用的 ThinkPHP6 后台接口

3.1 环境准备与项目初始化

先确认 PHP 版本。ThinkPHP6 要求 PHP >= 7.2.5,实际项目建议 7.4 或 8.0,8.1 以上部分扩展兼容性要自己测。用 Composer 创建项目:

composer create-project topthink/think tp6-admin cd tp6-admin composer require topthink/think-multi-app composer require firebase/php-jwt

逻辑说明:think-multi-app用于多应用模式,把后台接口放在app/admin下,和前台 API 隔离。firebase/php-jwt用来签发和校验 token。参数说明:如果公司内网 Composer 慢,配置国内镜像,但不要用来源不明的镜像包。

数据库配置在config/database.php,建议用环境变量而不是硬编码。.env文件里写DATABASE_HOSTNAME、DATABASE_NAME等,ThinkPHP6 会自动读取。注意.env不要提交到 Git。

3.2 迁移文件与初始数据

用 ThinkPHP 的迁移工具建表,比手写 SQL 更可追溯:

composer require topthink/think-migration php think migrate:create CreateAdminTables

生成的迁移文件里写五张表的up()方法。下面以permission表为例:

public function up() { $this->table('permission', ['engine' => 'InnoDB']) ->addColumn('name', 'string', ['limit' => 50]) ->addColumn('slug', 'string', ['limit' => 100]) ->addColumn('type', 'string', ['limit' => 20, 'default' => 'menu']) ->addColumn('parent_id', 'integer', ['default' => 0]) ->addColumn('path', 'string', ['limit' => 200, 'default' => '']) ->addColumn('create_time', 'integer', ['default' => 0]) ->create(); }

逻辑说明:type默认 menu,接口权限单独标 api。parent_id默认 0 表示顶级菜单。参数说明:slug要唯一,建议加唯一索引,避免重复权限项导致校验歧义。初始数据里至少插入一个超管角色和一个超管用户,超管角色的 slug 固定为super_admin,中间件里直接放行。

3.3 接口联调与跨域处理

前后端分离后,前端跑在localhost:8080,后端跑在localhost:8000,跨域是第一个拦路虎。ThinkPHP6 可以用中间件统一处理:

<?php // app/admin/middleware/Cors.php namespace app\admin\middleware; class Cors { public function handle($request, \Closure $next) { $header = [ 'Access-Control-Allow-Origin' => $request->header('origin', '*'), 'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS', 'Access-Control-Allow-Headers' => 'Authorization,Content-Type', 'Access-Control-Allow-Credentials' => 'true', ]; if ($request->method() == 'OPTIONS') { return response('', 204, $header); } return $next($request)->header($header); } }

逻辑说明:预检请求直接返回 204,正常请求在响应头里加 CORS 字段。参数说明:Access-Control-Allow-Origin不要在生产环境用*配合Allow-Credentials: true,浏览器会拒绝。生产环境应该校验 origin 白名单。如果前端用 Nginx 反代,也可以在 Nginx 层加,但接口层加更可控。

4. 权限系统上线后最容易翻车的几个地方

4.1 现象:超管能进,普通角色登录后菜单空白

原因:菜单接口只查了permission表,没有关联role_permission和user_role,或者关联条件写错。常见的是user_role里存了 role_id,但查询时用了 user_id 去匹配 role 表主键。

解决:把权限查询封装成一个方法,用单元测试覆盖。查询链路是admin_user.id -> user_role.role_id -> role_permission.permission_id -> permission。每一步都打印 SQL 确认,不要靠猜。

4.2 现象:接口返回 403,但数据库里明明有权限

原因:中间件拼的 slug 和permission.path存的不一致。ThinkPHP6 控制器名大小写、多级控制器、路由别名都会影响$request->controller()的返回值。

解决:在中间件里加日志,把拼出来的 slug 和用户权限集合都打出来。更稳妥的做法是直接用路由规则作为权限标识,在路由定义时给每个接口绑定permission名称,而不是靠控制器名拼。

4.3 现象:修改角色权限后,用户要重新登录才生效

原因:权限缓存没有失效。用户权限存在 Redis 里,角色权限变更时只更新了数据库,没删缓存。

解决:在角色权限保存成功后,触发事件删除该角色下所有用户的权限缓存。如果用户量大,可以用版本号机制:每个角色维护一个perm_version,缓存 key 里带上版本号,变更时版本号加一,旧缓存自然失效。

4.4 现象:前端路由能访问,但接口报 401

原因:token 过期或前端请求没带 Authorization header。前后端分离项目里,前端路由守卫只管页面跳转,接口鉴权是另一套。

解决:前端在 axios 拦截器里统一加 header,后端 JWT 中间件校验失败返回 401,前端拦截 401 跳登录页。注意 token 刷新逻辑要幂等,避免并发请求同时刷新导致旧 token 失效。

4.5 现象:删除角色后,用户还能用旧权限访问

原因:删除角色时只删了role表记录,role_permission和user_role里的关联数据成了孤儿数据,查询时仍然能关联出权限。

解决:删除角色用事务,同时清理关联表。或者用软删除,查询时过滤status=0的角色。数据库外键约束在 ThinkPHP 迁移里默认不开,要自己保证一致性。

5. 把权限校验做成可复用的注解与命令行工具

5.1 用注解替代中间件里的硬编码 slug

中间件里拼 slug 的方式在控制器改名后容易失效。进阶做法是用 PHP 8 的 Attributes 或 Doctrine 注解,在控制器方法上直接标权限:

<?php namespace app\admin\controller; use app\admin\annotation\Permission; class User { #[Permission('admin/user/list')] public function list() { // 业务逻辑 } }

然后在中间件里用反射读取当前方法的注解,拿到权限标识再校验。这样权限标识和代码在一起,改方法名时不容易漏改。参数说明:注解解析可以用roave/better-reflection或自己写简单的正则,但生产环境建议用成熟库,避免解析异常导致权限绕过。

5.2 写一个命令行工具检查权限数据一致性

上线前跑一遍权限检查,能省掉很多半夜排障:

php think perm:check

这个命令做三件事:一是找出role_permission里引用了不存在permission_id的脏数据;二是找出user_role里引用了不存在role_id的记录;三是列出所有type=api但没有任何角色关联的权限项,提示可能是漏配。实现上用 Db 查询逐项比对,输出表格。这个工具我一般放在 CI 里,每次迁移后自动跑。

5.3 验证权限是否真的生效:用不同角色跑一遍接口

不要只测超管。建三个测试账号:超管、只读角色、无权限角色。用 curl 或 Postman 分别请求同一批接口,确认超管全通、只读只能 GET、无权限全部 403。把这三组请求写成一个 bash 脚本,每次改权限相关代码后跑一遍。血泪经验是:权限系统的 bug 往往不是“进不去”,而是“不该进的进去了”,正向测试容易过,反向测试才是关键。

我自己的习惯是,任何权限相关的改动,先跑反向用例,再看正向。权限这东西没有后悔药,上线前多花二十分钟,比上线后半夜被叫起来强。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进