PHP如何实现复杂权限继承避免后台角色管理混乱问题

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)。

他要访问“查看代码”这个权限,这个权限被标记了 path1/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'

逻辑如下:

  1. 用户拥有角色“人事专员”(path=1/4/5)。
  2. 用户拥有权限“新增用户”(path=1/2)。
  3. 判断逻辑
    • 检查“新增用户”的 path (1/2) 是否包含在用户角色的 path (1/4/5) 中?不包含。
    • 检查用户角色的 path (1/4/5) 是否包含在“新增用户”的 path 中?包含!(因为 1 是根节点)。

这就是所谓的“核心路径匹配”或“路径包含判断”。这比递归快得多。

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不挂,权限检查就是瞬间完成。

第六部分:避坑指南与实战演练

好了,理论讲完了,我们来实战。假设我们有一个电商后台,用户层级如下:

  1. 超级管理员 (id=1, path=0)
  2. 运营总监 (id=2, path=1, meta={"scope": "all"})
  3. 产品经理 (id=3, path=1/2, meta={"scope": "product"})
  4. 普通员工 (id=4, path=1/2/3, meta={"scope": "view_only"})

权限:order.create (创建订单)。

场景一:普通员工创建订单

  1. 普通员工 id=4, path=1/2/3
  2. 权限 order.create 假设在 1 分支下。
  3. 检查:1/2/3 包含 1/ 吗?包含。
  4. 结果:允许。

场景二:普通员工删除用户

  1. 权限 user.delete2 分支下 (假设结构是 1/2/user.delete)。
  2. 检查:1/2/3 包含 1/2/ 吗?包含。
  3. 结果:允许。

场景三:产品经理删除用户

  1. 产品经理 id=3, path=1/2
  2. 检查:1/2 包含 1/2/user.delete 吗?包含。
  3. 结果:允许。

场景四:数据权限限制(这才是真正的复杂权限)

现在我们有个需求:普通员工只能看到自己部门的订单。

这时候单纯的角色权限就不行了。我们需要引入数据过滤

这是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 条件。


第七部分:架构图解(脑补版)

想象一下,你的系统架构是这样的:

  1. 数据库层:存着 roles (树), permissions (树), user_roles (连接表)。这里的数据结构必须设计成树状,哪怕是平铺存储 path
  2. 计算层:后台有一个定时任务(或者登录接口),负责递归计算用户的权限列表。
  3. 缓存层:Redis 存储用户的 permissions 数组。
  4. 应用层:拦截器拿到用户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_idpath 这种自引用或路径结构。
  • 不要在每次请求中都进行昂贵的数据库递归查询。
  • 在登录那一刻或缓存失效时,将计算结果序列化存入 Redis。

当你下次再给“实习生”分配权限时,你只需要点一下“继承上级”,然后写一行优雅的代码:
if ($rbac->can('delete_order')) { ... }

你剩下的时间,可以用来喝杯咖啡,而不是在数据库里数着行数加班。这就是架构的魅力,这就是代码的艺术。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注