文章

巧用 RightCode 前缀匹配,一条 SQL 解决树形权限补全问题

巧用 RightCode 前缀匹配,一条 SQL 解决树形权限补全问题

背景

在做角色权限系统重构时,遇到了一个经典问题:对权限做了重新归类调整后,出现了角色拥有子权限,但缺少对应父权限的数据不一致情况。

例如角色 A 被分配了权限「仓库管理 > 入库管理 > 入库单编辑」,但「仓库管理」和「入库管理」这两个父级权限却没有被分配。这会导致前端菜单渲染异常、权限校验逻辑出错等问题。

需要写一个 MySQL 脚本,自动找出所有这类缺口并补全。


表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 权限表(树形结构)
rightinfo (
    RightId       BIGINT,        -- 权限ID
    RightName     VARCHAR,       -- 权限名称
    ParentRightId BIGINT,        -- 父权限ID
    RightCode     VARCHAR,       -- 权限编码(关键字段)
    IsDelete      BIT
)

-- 角色表
role (
    RoleId    BIGINT,
    RoleName  VARCHAR,
    IsDelete  BIT
)

-- 角色权限关联表
rolerightrelation (
    RoleRightRelationId  BIGINT,
    RoleId               BIGINT,
    RightId              BIGINT,
    CreateTime           INT
)

关键发现:RightCode 隐含了树的层级关系

权限表中有一个 RightCode 字段,每级固定 5 位,层级通过字符串长度区分:

1
2
3
第1级(根):00003
第2级:     0000300004
第3级:     000030000400001

规律非常明显——子节点的 RightCode 一定以所有祖先节点的 RightCode 为前缀。

这个特性让我们可以完全绕开 ParentRightId,不需要递归查询,仅用字符串前缀匹配就能判断任意两个权限节点之间的祖先关系:

1
2
3
4
5
6
-- ancestor 是 child 的祖先,当且仅当:
LEFT(child.RightCode, CHAR_LENGTH(ancestor.RightCode)) = ancestor.RightCode
AND CHAR_LENGTH(ancestor.RightCode) < CHAR_LENGTH(child.RightCode)
-- 同时要求两者长度都是5的倍数,过滤脏数据
AND CHAR_LENGTH(child.RightCode)    MOD 5 = 0
AND CHAR_LENGTH(ancestor.RightCode) MOD 5 = 0

解题思路

整体逻辑分三步:

第一步:展开所有祖先

对角色已拥有的每一个权限,通过前缀匹配 JOIN,一次性展开它的所有祖先节点,不限层级。

第二步:找出缺口

用 NOT EXISTS 做差集:从「该角色应有的祖先权限」中,排除「该角色已有的权限」,剩下的就是缺失的。

第三步:去重后插入

同一角色可能有多个子权限都指向同一个缺失的祖先,用 GROUP BY 去重,只插入一条。


完整 SQL

STEP 1:预览将要补全的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
SELECT
    r.RoleId,
    r.RoleName,
    ancestor.RightId        AS MissingAncestorRightId,
    ancestor.RightCode      AS MissingAncestorRightCode,
    ancestor.RightName      AS MissingAncestorRightName,
    child.RightCode         AS TriggerChildRightCode,
    child.RightName         AS TriggerChildRightName
FROM rolerightrelation rrr
JOIN rightinfo child    ON child.RightId   = rrr.RightId  AND child.IsDelete  = b'0'
JOIN role r             ON r.RoleId        = rrr.RoleId   AND r.IsDelete      = b'0'
JOIN rightinfo ancestor ON ancestor.IsDelete = b'0'
    AND ancestor.RightCode IS NOT NULL
    AND ancestor.RightCode != ''
    AND CHAR_LENGTH(ancestor.RightCode) MOD 5 = 0
    AND CHAR_LENGTH(child.RightCode)    MOD 5 = 0
    AND CHAR_LENGTH(ancestor.RightCode) < CHAR_LENGTH(child.RightCode)
    AND LEFT(child.RightCode, CHAR_LENGTH(ancestor.RightCode)) = ancestor.RightCode
WHERE NOT EXISTS (
    SELECT 1 FROM rolerightrelation e
    WHERE e.RoleId  = rrr.RoleId
      AND e.RightId = ancestor.RightId
)
GROUP BY r.RoleId, r.RoleName, ancestor.RightId, ancestor.RightCode,
         ancestor.RightName, child.RightCode, child.RightName
ORDER BY r.RoleId, CHAR_LENGTH(ancestor.RightCode), ancestor.RightCode;

STEP 2:确认无误后执行插入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
INSERT INTO rolerightrelation (RoleId, RightId, CreateTime)
SELECT
    r.RoleId,
    ancestor.RightId,
    UNIX_TIMESTAMP() AS CreateTime
FROM rolerightrelation rrr
JOIN rightinfo child    ON child.RightId   = rrr.RightId  AND child.IsDelete  = b'0'
JOIN role r             ON r.RoleId        = rrr.RoleId   AND r.IsDelete      = b'0'
JOIN rightinfo ancestor ON ancestor.IsDelete = b'0'
    AND ancestor.RightCode IS NOT NULL
    AND ancestor.RightCode != ''
    AND CHAR_LENGTH(ancestor.RightCode) MOD 5 = 0
    AND CHAR_LENGTH(child.RightCode)    MOD 5 = 0
    AND CHAR_LENGTH(ancestor.RightCode) < CHAR_LENGTH(child.RightCode)
    AND LEFT(child.RightCode, CHAR_LENGTH(ancestor.RightCode)) = ancestor.RightCode
WHERE NOT EXISTS (
    SELECT 1 FROM rolerightrelation e
    WHERE e.RoleId  = rrr.RoleId
      AND e.RightId = ancestor.RightId
)
GROUP BY r.RoleId, ancestor.RightId
ORDER BY r.RoleId, CHAR_LENGTH(ancestor.RightCode);

用一个具体例子走一遍

假设角色 RoleId=100,已分配权限 RightCode = 000030000400001(第3级):

1
2
3
4
5
6
7
8
9
10
11
12
13
展开祖先(前缀匹配):
  ancestor1 → RightCode = 00003        (第1级)
  ancestor2 → RightCode = 0000300004  (第2级)

NOT EXISTS 过滤:
  00003        → 角色已有 ✓ 跳过
  0000300004   → 角色缺失 ✗ 保留

GROUP BY 去重:
  同角色下其他子权限可能也触发了 0000300004,合并为一条

最终插入:
  (RoleId=100, RightId=ancestor2的Id)

几个设计细节

为什么不用 ParentRightId 递归?

用 ParentRightId 向上找祖先需要递归 CTE(WITH RECURSIVE),写法复杂,性能也随层级增加而下降。而 RightCode 前缀匹配本质上是把树的层级关系编码进了字符串,一个普通 JOIN 就能替代递归,任意层级都适用。

MOD 5 = 0 的作用

过滤掉历史脏数据中 RightCode 长度不合规的记录,防止错误的前缀匹配。

NOT EXISTS vs LEFT JOIN ... WHERE IS NULL

两者语义等价,NOT EXISTS 在有索引的情况下通常更优,且语义更直观。

GROUP BY 的必要性

一个角色可能有 N 个子权限都缺同一个父权限,展开后会产生 N 行重复组合。GROUP BY (RoleId, ancestor.RightId) 确保最终只插入一条,保证幂等。

NOT EXISTS 保证幂等

脚本可以安全地重复执行,已存在的关联不会被重复插入。


总结

这道题的核心技巧是:把树的结构问题转换成字符串前缀匹配问题。

当权限编码本身已经隐含了层级信息时,不需要递归,一个 JOIN + LEFT() 就能完成祖先关系的全量展开。结合 NOT EXISTS 做差集,GROUP BY 去重,整个逻辑清晰且高效。

这个思路同样适用于其他使用编码前缀表达树形结构的场景,比如组织架构编码、商品分类编码等。

本文由作者按照 CC BY 4.0 进行授权