3天写完的权限系统,上线第一天就崩了
2026/8/22 19:49:53 网站建设 项目流程

Aether 的 RBAC 权限设计,从数据库到 UI 全链路


之前赶工期,花 3 天写了一个权限系统。

if (user.role == "admin")写满了整个项目。

第 7 个角色加进来的时候,代码已经改不动了。第 15 个页面加进来的时候,我决定重写。

你问我当时怎么想的?年轻呗,觉得权限不就是判断一下角色嘛。结果被现实狠狠教育了。


一、if-else 权限 vs RBAC 权限

先说那个让我崩溃的版本。

❌ 坏例子:权限判断散落在各个页面

// 3天赶出来的"权限系统"——散落在 30 多个文件里voidMainWindow::initNavBar(){// 到处是这种硬编码判断if(user.role=="admin"||user.role=="supervisor"){addNavButton("nav.settings");}if(user.role=="admin"){addNavButton("nav.permission");// ← 只有 admin 能看权限页addNavButton("nav.audit");}if(user.role=="operator"||user.role=="engineer"){addNavButton("nav.production");addNavButton("nav.recipe");}}voidRecipePage::onLoad(){// 另一个文件的另一个判断boolcanEdit=(user.role=="admin"||user.role=="engineer");m_btnSave->setVisible(canEdit);// 但配方删除权限更严格boolcanDelete=(user.role=="admin");// ← 又写一遍m_btnDelete->setVisible(canDelete);}

问题在哪?权限逻辑和业务代码绑死了

  • 加一个角色 → 你得翻遍所有文件找if (role == ...)改一遍
  • 改一个角色的权限 → 你得知道所有涉及的文件
  • 新页面的开发者 → 他得猜自己的权限判断要怎么写
  • 上线后想调整 → 改代码、编译、发版,3 天起

第 7 个角色进来的时候,项目里的if-else嵌套已经像一盘意大利面。第 15 个页面加进来的时候,我彻底放弃了。

✅ 好例子:统一授权 + 运行时查询

// 改造后:再也不散落判断了voidRecipePage::onLoad(){// 全都问同一个服务——PermServicem_btnSave->setVisible(PS->canAction("recipe.save"));m_btnDelete->setVisible(PS->canAction("recipe.delete"));m_btnExport->setVisible(PS->canAction("recipe.export"));}voidMainWindow::initNavBar(){// 导航栏也统一问for(auto&nav:m_allNavItems){nav.button->setVisible(PS->canNav(nav.titleKey));}}

看到区别了吗?改造后:

  • 加角色 → 不用改代码,在角色管理里勾选就行
  • 调权限 → 不涉及编译,改数据库一条记录
  • 新开发者 → 只需要知道PS->canNav()PS->canAction()两个 API
  • 上线后想调整 → 管理后台点几下,即时生效

这就是 RBAC 的核心:把"谁能做什么"从代码里抽出来,变成数据。

对比维度旧方式RBAC 方式
加角色改所有文件数据库加一行
调权限改代码+发版管理后台勾选
新人上手猜角色体系问 PermService
审计追溯没有自动记录
测试成本每个页面单独测统一测权限层

二、Aether 的权限架构:三步走

改完后的架构,说起来很简单——注册、分配、查询

┌─────────────────────────────────────────────────────────────┐ │ 三步权限架构 │ │ │ │ ① 注册 ② 分配 ③ 查询 │ │ 插件启动时 管理员在后台 运行时每个页面 │ │ 告诉框架: 给角色勾选: 问框架: │ │ "我有这些页面" "角色A能看到X" "当前用户能看X吗?" │ │ "这些操作" "角色B能操作Y" "能执行操作Y吗?" │ │ │ │ 写入目录 写入关联表 查 grants 集合 │ │ (perm_permission) (perm_role_permission) (QSet<QString>) │ └─────────────────────────────────────────────────────────────┘

第 1 步:注册(插件告诉框架自己有什么资源)

每个插件启动时,用PermCatalogScope声明自己的资源:

// AOI 插件启动时:注册自己的页面和操作voidAoiPlugin::extensionsInitialized(){aoi::setPluginName(pluginSpec()->name());PermCatalogScopescope(aoi::pluginName());// 注册导航页PS->registerNav("nav.aoi","AOI 检测",10);PS->registerNav("nav.recipe","配方管理",20);PS->registerNav("nav.production","生产管理",30);// 注册子页面PS->registerSubPage("sub.aoi.main","nav.aoi","检测主界面",10);PS->registerSubPage("sub.aoi.log","nav.aoi","检测日志",20);PS->registerSubPage("sub.recipe.edit","nav.recipe","配方编辑",10);// 注册操作资源PS->registerAction("recipe.save","sub.recipe.edit","保存配方");PS->registerAction("recipe.delete","sub.recipe.edit","删除配方");PS->registerAction("recipe.export","sub.recipe.edit","导出配方");}

关键点:这里只声明"有这个资源",不决定谁能用。决定权在管理员手里。

第 2 步:分配(管理员在后台勾选)

管理员登录后,在权限管理页面:

  1. 看到所有已注册的资源树(nav → sub_page → action)
  2. 给每个角色勾选需要的资源
  3. 保存后,数据写入perm_role_permission关联表

联动规则:取消 nav,下面的子页和操作全部取消。勾了 action,它的父级必须已勾选。这套规则叫 normalize,防止数据库里出现逻辑上不可能的组合。

第 3 步:查询(运行时每个页面问)

用户登录时:

// 登录成功后PS->reloadForUser(userId);// 内部做了什么?// 1. 查用户绑定的角色// 2. 查该角色的全部 grants// 3. 缓存到 QSet<QString> m_grants// 4. 发射 permissionsChanged 信号// 所有窗口收到信号后重新检查显隐

然后每个页面问PS->can(key),回答只有 yes/no。


三、PermService 核心源码

这个全局单例是整个权限系统的枢纽。核心方法没多少行,但设计很讲究。

// PermService 全局单例(宏 PS 就是它的快捷方式)#definePS(PermService::instance())classPermService:publicQObject{Q_OBJECTpublic:staticPermService*instance();// 全局单例// ---------- 查询(运行时高频调用) ----------boolcan(constQString&resourceKey)const;// 三个快捷方法,底层都走 can()boolcanNav(constQString&navTitleKey)const{returncan(navTitleKey);}boolcanSubPage(constQString&subPageKey)const{returncan(subPageKey);}boolcanAction(constQString&actionKey)const{returncan(actionKey);}// ---------- 注册(插件初始化时调用) ----------boolregisterNav(constQString&key,constQString&displayName,intsortOrder=0);boolregisterSubPage(constQString&key,constQString&parentNavKey,constQString&displayName,intsortOrder=0);boolregisterAction(constQString&key,constQString&pageKey,constQString&displayName);// ---------- 生命周期 ----------voidreloadForUser(qint64 userId);// 登录时加载 grantsvoidlogout();// 登出 → 回到最小用户voidreloadAsMinimalUser();// 冷启动用signals:voidpermissionsChanged();// 权限变化 → UI 重新检查显隐private:QSet<QString>m_grants;// 当前有效身份的全部 grants// 运行时 can() 只查这个集合:O(1) 查询};

can() 的实现?就一行:

boolPermService::can(constQString&resourceKey)const{if(!isReady()||resourceKey.isEmpty())returnfalse;returnm_grants.contains(resourceKey);}

reloadForUser 的实现?也不复杂:

voidPermService::reloadForUser(qint64 userId){// 1. 找用户的角色qint64 roleId=0;m_backend->primaryRoleIdForUser(userId,&roleId);// 2. 加载该角色的全部 grantsm_effectiveUserId=userId;m_effectiveRoleId=roleId;autokeys=m_backend->loadGrantKeysForRole(roleId);m_grants=keys;// ← QSet<QString> 直接赋值// 3. 通知所有 UI 刷新emitpermissionsChanged();}

注意一个设计细节registerNav可能在权限插件还没初始化完就被调用。Aether 的处理是——先排队:

boolPermService::registerNav(constQString&key,constQString&displayName,intsortOrder){if(m_catalogRegistrar&&m_catalogRegistrar->isReady())returnm_catalogRegistrar->registerNav(key,displayName,sortOrder);// 还没准备好?先缓存起来PendingCatalogEntry entry;entry.kind=PendingCatalogEntry::Kind::Nav;entry.key=key;entry.displayName=displayName;entry.sortOrder=sortOrder;m_pendingCatalog.append(entry);returntrue;}

权限插件初始化完成后,调用flushPendingCatalog()一次性把缓存的注册全部刷进去。这样插件的初始化顺序就不影响了——A 插件先跑还是 B 插件先跑,结果都一样。


四、JSON 配置驱动的权限目录

资源注册到哪了?不写在代码里,存在数据库。

但是运行时我们需要一个"权限目录快照"——也就是当前系统有哪些 nav、哪些 sub_page、哪些操作,它们的父子关系是什么。

这个目录在 Aether 里虽然是 DB 存的,但理解时完全可以想象成 JSON:

{"navs":[{"key":"nav.aoi","displayName":"AOI 检测","sortOrder":10,"children":[{"key":"sub.aoi.main","displayName":"检测主界面","actions":["aoi.inspect.start","aoi.inspect.stop"]},{"key":"sub.aoi.log","displayName":"检测日志","actions":["aoi.log.query","aoi.log.export"]}]},{"key":"nav.recipe","displayName":"配方管理","sortOrder":20,"children":[{"key":"sub.recipe.edit","displayName":"配方编辑","actions":["recipe.save","recipe.delete","recipe.export"]}]}]}

这套结构的好处:

  • 菜单 = 权限目录。菜单的结构和权限的树结构是同一个东西。不再需要维护两份配置。
  • 新的业务模块加入时:加一个 nav 节点,下面挂 sub_page 和 action,结构一清二楚。
  • 导航框架拿到这个结构就能渲染菜单,不需要额外逻辑。

管理员在管理后台看到的"权限树",就是这个结构在 UI 上的渲染。


五、前端拦截 + 后端校验

权限不能只靠 UI 隐藏。万一有人直接调接口呢?

Aether 做了两层防护:

第 1 层:UI 隐藏(防君子)

// 导航栏:没权限就不显示voidMainWindow::onPermissionsChanged(){for(auto&nav:m_navItems){nav.button->setVisible(PS->canNav(nav.titleKey));}}// 按钮:没权限也不显示(或灰掉)m_btnSave->setVisible(PS->canAction("recipe.save"));// Command 按钮:自动绑定权限m_saveCommand->setName("recipe.save");// → FunctionalCommand::canExecute() 会自动检查 PS->can("recipe.save")// → 没权限时按钮灰掉,且不会触发执行和审计

第 2 层:Command 中间件(防小人)

用户走到 UI 隐藏,但还有办法绕过——比如直接调接口、注入事件、改浏览器控制台(如果是 Web 端)。

Aether 的 MVVM 框架里,所有操作走Command::run(),中间有一层全局中间件:

Command::run() → 权限中间件: PS->can(name())? 不行就拦截 → 审计中间件: 记下谁在什么时候做了什么 → execute(): 真正的业务逻辑

就算 UI 上的按钮被某种方式点到了,中间件也会再拦一道。

不被 UI 绑死:同一个 Command 可以被菜单、快捷键、工具栏等多处触发,权限判定统一走中间件,不会漏掉。


六、一些你可能也会踩的坑

  1. 按钮灰了还点得了?检查是否绑定的是run()而不是execute()run()走中间件,execute()直接跳过。

  2. 登录后权限没生效?确认调了reloadForUser(userId)。只是登录成功还不够,grants 得重新加载。

  3. 未登录时不该看到的页面出现了?冷启动时默认用minimal角色的 grants,可能给了太多。检查minimal角色的授权范围。

  4. 加了新操作但角色管理里看不到?检查插件里是否调了registerAction,而且PermCatalogScope是否包住了注册代码。


花 3 天写个凑合能用的权限系统不难。花 3 天写一个能撑到 30 个页面、10 个角色、不用改代码的权限系统,几乎不可能。

Aether 的这套 RBAC 设计,核心思路就一句话:把"谁有什么权限"从代码里踢出去,变成数据,让管理员来决定,让系统来执行。

如果你也在做权限相关的设计,或者被 if-else 权限坑过,评论区聊聊你的方案。

觉得有用?点个"在看"让更多人看到,也鼓励我继续写下去。

下一篇,我们拆解 Aether 的 i18n 架构。做了全球市场才知道,国际化不只是翻译的问题——日期格式、数字千分位、图片嵌文字……还有哪些坑等你踩?


📚系列目录

第 1 篇:从 0 到 1 写一个插件化架构
第 2 篇:插件系统的 6 种死法
第 3 篇:DLL 地狱与依赖倒置
第 4 篇:服务容器:new 是病,得治
第 5 篇:MVVM 模式落地实战
第 6 篇:DataBinding 绝不等于 Qt 信号槽
第 7 篇:中间件:AOP 的正确打开方式
第 8 篇:3天写完的权限系统,上线第一天就崩了
第 9 篇:做了海外项目才懂:国际化根本不是翻译的问题(预告)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询