PHP权限继承:从面条代码到蜘蛛网的进化之路
各位同学,大家好。今天我们要聊一个让无数PHP开发者,甚至架构师深夜惊醒的话题——权限管理。
如果在座的各位有做过后台管理系统,你一定经历过那个时刻:你在权限表里给“超级管理员”分配了所有权限,然后发现“部门经理”也有了“超级管理员”的权限,接着你试图给“实习生”减去“删除用户”的权限,结果发现连数据库都删了。
这就是权限继承的噩梦。今天,我们就来用PHP这种“胶水语言”,把那团乱糟糟的意大利面条,织成一张精密、优雅且能自我复制的蜘蛛网——也就是我们要讲的主题:复杂权限继承架构。
第一部分:痛,在心口难开
我们先来聊聊为什么简单的权限管理是个坑。
刚开始做项目时,我们的逻辑通常是这样的:
// 惨不忍睹的简单实现
if ($user->hasRole('admin') || $user->hasRole('editor')) {
return true;
}
听着挺美对吧?但现实是残酷的。随着公司规模扩大,部门树形结构长了出来:
- 总公司
- 财务部
- 财务经理
- 会计
- 技术部
- 技术总监
- 架构师
- 高级开发
- 实习生
- 财务部
如果你还是用上面那种“白名单”模式,你可能会在数据库里填满成百上千行记录。
比如,“高级开发”需要“查看代码”、“提交代码”、“合并代码”、“写周报”。
“实习生”只需要“查看代码”、“提交代码”。
“会计”需要“审批报销”、“查看账本”。
如果你想给“技术总监”加个“删除服务器”的权限,你不仅要给总监加,还得给下面的架构师、高级开发、实习生统统加一遍。
这就是我们今天要解决的问题:继承。我们要让权限像生物细胞一样,父传子,子传孙,甚至子传侄,而不用我们在数据库里重复劳动。
第二部分:数据结构决定命运
要实现继承,首先得有一棵树。
很多新手在存角色关系时,喜欢在 roles 表里加一个 parent_id 字段。这是对的,但怎么用才是艺术。
1. 数据库设计的艺术
我们要构建三层核心结构:角色、权限、关联。
CREATE TABLE `roles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '角色名称',
`parent_id` int(11) DEFAULT '0' COMMENT '父角色ID,0为根节点',
`path` varchar(255) DEFAULT '' COMMENT '路径,比如 1/5/9 表示该角色在ID为1的分支下',
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
CREATE TABLE `permissions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`slug` varchar(100) NOT NULL COMMENT '权限标识,如 user.create',
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
CREATE TABLE `role_permissions` (
`role_id` int(11) NOT NULL,
`permission_id` int(11) NOT NULL,
PRIMARY KEY (`role_id`, `permission_id`)
) ENGINE=InnoDB;
注意那个 path 字段。这是实现高效继承的关键。
什么意思呢?
假设我们有一个角色叫“超级管理员”,ID是1。
它的 path 应该是 0。
它的子角色“技术总监”,ID是2。它的 path 应该是 1(代表属于管理员)。
“架构师”,ID是3。它的 path 应该是 1/2(属于管理员,且属于技术总监)。
有了这个 path,我们就可以在MySQL里通过一条SQL语句完成“身份校验”,不需要PHP写递归循环!
第三部分:PHP核心算法——路径匹配
这是今天的压轴戏。我们要写一个类,叫 RBACTree。
这个类的核心思想是:权限判断就是两个字符串的比较。
当用户登录后,我们获取他的角色。假设他有两个角色:“技术总监”(path=1/2)和“财务经理”(path=1/5)。
他要访问“查看代码”这个权限,这个权限被标记了 path 为 1/2(或者它父角色的path也是 1/2,我们在数据库里存储权限的时候,通常存储该权限所在分支的根path,或者把权限平铺,这里我们演示平铺思路,如果权限也是树状结构会更复杂,我们这里假设权限是树状继承的,即父权限包含子权限)。
为了简化,我们采用最通用的递归+缓存模式。
1. 构建角色树
首先,我们要把数据库里的扁平数据变成树。
class RBACTree {
private $roles = [];
private $permissions = [];
private $userRoles = [];
/**
* 初始化
*/
public function __construct($userId) {
// 模拟从数据库获取用户角色
// 实际开发中,这步应该在登录后通过Redis缓存,不要每次都查库
$userRoles = $this->fetchUserRolesFromDB($userId);
$this->userRoles = $userRoles;
// 加载所有角色的权限(包含继承来的)
foreach ($userRoles as $role) {
$this->loadRolePermissions($role['id']);
}
}
/**
* 加载角色及其所有父角色的权限
*/
private function loadRolePermissions($roleId) {
// 1. 获取当前角色的直接权限
$directPerms = $this->getDirectPermissions($roleId);
// 2. 获取该角色的所有父角色ID
$parentIds = $this->getParentRoleIds($roleId);
// 3. 获取父角色的权限,递归
foreach ($parentIds as $pid) {
$this->loadRolePermissions($pid);
}
// 合并权限
$this->permissions = array_merge($this->permissions, $directPerms);
}
/**
* 简单的获取父ID递归
*/
private function getParentRoleIds($roleId) {
static $cache = []; // 防止递归死循环
if (isset($cache[$roleId])) return $cache[$roleId];
$role = $this->getRoleById($roleId);
$parents = [];
if ($role && $role['parent_id'] > 0) {
$parents[] = $role['parent_id'];
$parents = array_merge($parents, $this->getParentRoleIds($role['parent_id']));
}
$cache[$roleId] = $parents;
return $parents;
}
/**
* 检查是否有权限
*/
public function can($permissionSlug) {
// 转小写,防止大小写不一致导致误判
$slug = strtolower($permissionSlug);
// 假设 $this->permissions 已经被 fillRolePermissions 填满了
// 它包含了这个用户所有层级的权限
foreach ($this->permissions as $perm) {
if (strtolower($perm['slug']) === $slug) {
return true;
}
}
return false;
}
}
2. 优化:如果权限也是树呢?
上面的方法是把权限“平铺”到一个数组里。如果权限结构也很复杂,比如“用户管理”包含“新增用户”、“编辑用户”、“删除用户”。如果“管理员”拥有“用户管理”,那么管理员理应拥有下面的所有权限。
这时候,我们就不能光靠 array_merge 了,我们需要一个二叉树(或N叉树)的查询逻辑。
这里推荐一个经典的算法:路径匹配算法。
修改一下我们的数据库设计思路。给 permissions 表也加上 path 字段。
-- 假设权限树结构
-- id=1 (用户管理) -> path='0'
-- id=2 (新增用户) -> path='1'
-- id=3 (编辑用户) -> path='1'
-- id=4 (部门管理) -> path='0'
-- id=5 (人事专员) -> path='4'
逻辑如下:
- 用户拥有角色“人事专员”(path=
1/4/5)。 - 用户拥有权限“新增用户”(path=
1/2)。 - 判断逻辑:
- 检查“新增用户”的 path (
1/2) 是否包含在用户角色的 path (1/4/5) 中?不包含。 - 检查用户角色的 path (
1/4/5) 是否包含在“新增用户”的 path 中?包含!(因为1是根节点)。
- 检查“新增用户”的 path (
这就是所谓的“核心路径匹配”或“路径包含判断”。这比递归快得多。
class PathBasedRBAC {
private $userRolePaths = []; // ['1/4/5', '1/8/9']
private $userPermissionPaths = []; // ['1/2', '1/4/3']
public function check($permissionSlug) {
$targetPath = $this->getPermissionPath($permissionSlug);
// 暴力循环虽然慢,但在权限数量不多时完全没问题
// 如果权限几万条,这里需要优化成 Hash Set
foreach ($this->userPermissionPaths as $path) {
if ($this->pathContains($targetPath, $path)) {
return true;
}
}
return false;
}
/**
* 判断 pathA 是否在 pathB 的路径中
* pathA='1/2', pathB='1/4/5' -> true
* pathA='1/2', pathB='2/5' -> false
*/
private function pathContains($pathA, $pathB) {
if ($pathA == $pathB) return true;
if ($pathA == '0') return true; // 根节点拥有所有权限
if (strpos($pathB, $pathA . '/') === 0 || $pathB === $pathA) {
return true;
}
return false;
}
}
第四部分:动态权限与层级限制
在实际业务中,我们会遇到更变态的需求。
比如:“财务经理”可以审批金额 1万以下的报销,但“财务总监”可以审批所有金额。
这时候简单的字符串匹配 pathContains 就失效了。我们需要引入策略模式。
在角色表里加个字段 meta (JSON格式)。
{
"approval_limit": 10000
}
我们的权限检查逻辑要升级:
class StrategyRBAC {
public function checkWithMeta($permission, $context) {
// 1. 基础检查:角色是否有权限
if (!$this->basicCheck($permission)) {
return false;
}
// 2. 策略检查
$role = $this->getUserRole();
$meta = $role['meta'];
if (isset($meta['approval_limit'])) {
$limit = $meta['approval_limit'];
$amount = $context['amount']; // 比如传入的报销金额
if ($amount > $limit) {
return false;
}
}
return true;
}
}
这就叫元数据驱动。我们通过在角色里存JSON数据,动态地控制权限的行为。这比写死在代码里灵活一万倍。
第五部分:Redis缓存——性能的救命稻草
讲到这里,很多同学可能会说:“老师,你说的递归和路径匹配,每次请求都查数据库或者算一遍,那服务器CPU不得干烧?”
同学,你是对的。但在高并发场景下,我们绝不能每次都走 RBACTree 类的逻辑。
最佳实践是:计算一次,缓存永久。
1. 登录时计算
当用户登录成功后,我们不要只存个 token,我们要干一件事:把该用户拥有的所有权限(包括继承来的)全部查出来,扔进Redis。
public function login($userId) {
// 1. 计算该用户的所有权限 Path
$allPaths = $this->computeAllUserPaths($userId);
// 2. 存入 Redis
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->setex("user_perms_{$userId}", 86400, json_encode($allPaths));
// 3. 生成 Token
return $this->generateToken($userId);
}
// 计算逻辑(伪代码)
private function computeAllUserPaths($userId) {
$paths = [];
$roles = $this->db->query("SELECT path FROM roles WHERE user_id = ?", [$userId]);
foreach ($roles as $role) {
// 这里要递归获取该角色的所有父路径
$paths[] = $role['path'];
$paths = array_merge($paths, $this->getParentPaths($role['path']));
}
return array_unique($paths); // 去重
}
2. 请求时检查
当用户访问页面时,PHP只需要查一下Redis有没有缓存,有就直接比,没有再进复杂的计算逻辑。
public function middleware() {
$userId = Auth::userId();
$permission = $_GET['action'];
$redis = new Redis();
$cachedPerms = $redis->get("user_perms_{$userId}");
if ($cachedPerms) {
$paths = json_decode($cachedPerms, true);
if (in_array($permission, $paths)) {
return true;
}
}
// 如果缓存没命中(比如用户刚登录没刷新缓存),走兜底逻辑
return (new RBACTree($userId))->can($permission);
}
性能对比:
- 纯递归/SQL: O(N*M),N是角色数,M是权限数。如果是复杂的组织架构,可能慢到让人想砸键盘。
- Redis Hash Set: O(1) 查找。只要你Redis不挂,权限检查就是瞬间完成。
第六部分:避坑指南与实战演练
好了,理论讲完了,我们来实战。假设我们有一个电商后台,用户层级如下:
- 超级管理员 (id=1, path=
0) - 运营总监 (id=2, path=
1, meta={"scope": "all"}) - 产品经理 (id=3, path=
1/2, meta={"scope": "product"}) - 普通员工 (id=4, path=
1/2/3, meta={"scope": "view_only"})
权限:order.create (创建订单)。
场景一:普通员工创建订单
- 普通员工 id=4, path=
1/2/3。 - 权限
order.create假设在1分支下。 - 检查:
1/2/3包含1/吗?包含。 - 结果:允许。
场景二:普通员工删除用户
- 权限
user.delete在2分支下 (假设结构是1/2/user.delete)。 - 检查:
1/2/3包含1/2/吗?包含。 - 结果:允许。
场景三:产品经理删除用户
- 产品经理 id=3, path=
1/2。 - 检查:
1/2包含1/2/user.delete吗?包含。 - 结果:允许。
场景四:数据权限限制(这才是真正的复杂权限)
现在我们有个需求:普通员工只能看到自己部门的订单。
这时候单纯的角色权限就不行了。我们需要引入数据过滤。
这是PHP权限继承的高级进阶:逻辑权限。
class DataScopeChecker {
public function filterOrders($userId) {
$role = $this->getUserRole($userId);
if ($role['id'] == 1) {
// 超管:看所有
return "SELECT * FROM orders";
} else if ($role['id'] == 4) {
// 普通员工:只能看自己部门的
// 假设用户表里存了 department_id
$myDept = $this->getUserDepartment($userId);
return "SELECT * FROM orders WHERE department_id = {$myDept}";
}
return "";
}
}
这就像我们在做权限的“免疫系统”。功能权限(能不能点那个按钮)是第一道防线,数据权限(能不能看到那个数据)是第二道防线。
很多系统把这两者分开,这其实是不对的。最好的做法是:在RBAC类里,除了判断 can(),还要在查询构建器里动态拼接 WHERE 条件。
第七部分:架构图解(脑补版)
想象一下,你的系统架构是这样的:
- 数据库层:存着
roles(树),permissions(树),user_roles(连接表)。这里的数据结构必须设计成树状,哪怕是平铺存储path。 - 计算层:后台有一个定时任务(或者登录接口),负责递归计算用户的权限列表。
- 缓存层:Redis 存储用户的
permissions数组。 - 应用层:拦截器拿到用户ID -> 查Redis -> 拿到数组 -> 查表/查对象 -> 判断。
为什么推荐用 Redis 存权限列表而不是存角色ID?
因为权限列表是用户资产。一旦登录,这个用户在接下来的8小时里,所有的业务操作都依赖这个列表。把它算好存在内存里,比每次都查树要快几百倍。
第八部分:关于“无限继承”的警告
最后,作为一个资深专家,我必须给你们泼一盆冷水。
你在设计权限树时,一定要做闭环检测。
如果用户A的父角色是用户B,用户B的父角色又是用户A,这叫死循环。在PHP里,这会导致你的脚本无限递归,CPU飙红,最后被服务器强制Kill。
怎么防止?
在递归加载父角色权限时,加一个全局集合 static $visited_roles = []。
每次进入递归函数,先检查 $visited_roles 是否包含当前角色ID。如果有,直接抛出异常或跳过。
private function getParentRoleIds($roleId) {
static $visited = [];
if (in_array($roleId, $visited)) {
throw new Exception("检测到权限循环引用: Role ID {$roleId}");
}
$visited[] = $roleId;
// ... 后续逻辑
}
总结:从混乱到有序
回到我们的主题。PHP实现复杂权限继承,核心在于不要把权限当成一个列表,而要把它当成一棵树。
- 不要在数据库里给每个子角色重复复制父角色的权限。
- 要使用
parent_id和path这种自引用或路径结构。 - 不要在每次请求中都进行昂贵的数据库递归查询。
- 要在登录那一刻或缓存失效时,将计算结果序列化存入 Redis。
当你下次再给“实习生”分配权限时,你只需要点一下“继承上级”,然后写一行优雅的代码:
if ($rbac->can('delete_order')) { ... }
你剩下的时间,可以用来喝杯咖啡,而不是在数据库里数着行数加班。这就是架构的魅力,这就是代码的艺术。