|
|
@@ -0,0 +1,260 @@
|
|
|
+// Package rbac 组织域 RBAC 解析(角色 → 权限点集 → 菜单树,T6.5,架构规格 §5 org/rbac 子包)。
|
|
|
+//
|
|
|
+// 定位(RBAC 三层模型的"展示层"):
|
|
|
+// - 本包只做**角色 → 权限点 code 集 → 菜单树/权限码扁平化**,供 /auth/issue 与 /auth/me 下发
|
|
|
+// (menuTree + permissions,PC 前端契约:MenuNode{code,label,path,icon,children},前端原样渲染
|
|
|
+// 不二次过滤,不落 localStorage);
|
|
|
+// - 接口强制**不走本包**(拍板:路由角色白名单中间件 T6.5-d,7 角色粒度,超管放行);
|
|
|
+// - 数据权限(老师按班级/家长按绑定学生)随业务域实现,亦不走本包。
|
|
|
+//
|
|
|
+// 缓存(进程内,TTL 30s):
|
|
|
+// - 权限点字典(平台级,量级 < 百)与"角色→code 集"均走短 TTL 缓存,避免每次 issue/me 打 DB;
|
|
|
+// - 超管改矩阵(T6.5-e)后**最长 30s 生效**(双实例共享 DB 但缓存各自独立,TTL 到期自然一致;
|
|
|
+// 改配端点同步调 Invalidate 清本实例缓存——跨实例不广播,30s 内旧值可接受)。
|
|
|
+//
|
|
|
+// 依赖方向:rbac → Store 接口(由 repository.PermissionRepo 实现)+ model(同域下层),
|
|
|
+// 不依赖 handler/service;Store 接口使单测可注入 fake(同 identity 域依赖倒置纪律)。
|
|
|
+package rbac
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "sync"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "yuansiwei/unified-backend/internal/org/model"
|
|
|
+ "yuansiwei/unified-backend/internal/org/repository"
|
|
|
+)
|
|
|
+
|
|
|
+// Store 权限点/角色矩阵数据访问抽象(*repository.PermissionRepo 隐式满足;
|
|
|
+// 接口在消费方 rbac 定义,生产注入具体仓储、单测注入 fake)。
|
|
|
+type Store interface {
|
|
|
+ // AllActive 全量 active 权限点(菜单树构建用,按 sort 升序)。
|
|
|
+ AllActive(ctx context.Context) ([]model.Permission, error)
|
|
|
+ // RolePermissionCodes 某角色的权限点 code 集(升序去重;空集 = 无授权)。
|
|
|
+ RolePermissionCodes(ctx context.Context, roleCode string) ([]string, error)
|
|
|
+}
|
|
|
+
|
|
|
+// 编译期接口断言:具体仓储须满足 Store(签名漂移即编译失败)。
|
|
|
+var _ Store = (*repository.PermissionRepo)(nil)
|
|
|
+
|
|
|
+// defaultCacheTTL 权限点/角色矩阵的进程内缓存 TTL(超管改配后的最大生效延迟)。
|
|
|
+const defaultCacheTTL = 30 * time.Second
|
|
|
+
|
|
|
+// MenuNode 菜单树节点(**字段与 PC 前端 MenuNode 契约严格对齐**,见 PC src/types/api.d.ts):
|
|
|
+// 后端按角色/权限过滤后下发,前端原样渲染(不在前端二次过滤,避免权限码→菜单双份漂移)。
|
|
|
+//
|
|
|
+// 字段:
|
|
|
+// - Code:权限码(三段式 `域.资源.动作`;菜单项显隐 + 按钮 v-perm 联动依据,恒下发);
|
|
|
+// - Label:菜单中文名(直接渲染,不引入 i18n);
|
|
|
+// - Path:路由 path(叶子节点必填;父节点可省略 = 仅展开不跳转;nil 时 JSON 省略该键);
|
|
|
+// - Icon:Element Plus 图标组件名(可选;缺省不显示图标);
|
|
|
+// - Children:子菜单(递归;无子时省略——前端按 undefined 渲染为叶子)。
|
|
|
+type MenuNode struct {
|
|
|
+ Code string `json:"code"`
|
|
|
+ Label string `json:"label"`
|
|
|
+ Path *string `json:"path,omitempty"`
|
|
|
+ Icon *string `json:"icon,omitempty"`
|
|
|
+ Children []MenuNode `json:"children,omitempty"`
|
|
|
+}
|
|
|
+
|
|
|
+// Resolver RBAC 解析器(启动装配一次,全进程共享;进程内缓存)。
|
|
|
+type Resolver struct {
|
|
|
+ repo Store
|
|
|
+ ttl time.Duration
|
|
|
+
|
|
|
+ mu sync.RWMutex
|
|
|
+ permCache cacheEntry[[]model.Permission] // 全量 active 权限点
|
|
|
+ codeCache cacheEntry[map[string][]string] // roleCode → 权限点 code 集
|
|
|
+}
|
|
|
+
|
|
|
+// cacheEntry TTL 缓存条目(泛型:同一套机制服务两种缓存)。
|
|
|
+type cacheEntry[T any] struct {
|
|
|
+ value T
|
|
|
+ loadedAt time.Time
|
|
|
+}
|
|
|
+
|
|
|
+// NewResolver 构造 RBAC 解析器。
|
|
|
+//
|
|
|
+// 入参:
|
|
|
+// - repo Store:权限点/角色矩阵数据访问(*repository.PermissionRepo 生产注入、fake 单测注入;
|
|
|
+// nil 时返回 nil 由装配层 fail-fast 判定);
|
|
|
+// - ttl time.Duration:缓存 TTL;≤0 时取 defaultCacheTTL(30s)。
|
|
|
+//
|
|
|
+// 出参:*Resolver:就绪解析器(无 DB 操作,构造恒成功)。
|
|
|
+func NewResolver(repo Store, ttl time.Duration) *Resolver {
|
|
|
+ if ttl <= 0 {
|
|
|
+ ttl = defaultCacheTTL
|
|
|
+ }
|
|
|
+ return &Resolver{repo: repo, ttl: ttl}
|
|
|
+}
|
|
|
+
|
|
|
+// Permissions 解析某角色的全量权限点 code 集(菜单 + 按钮,供 permissions[] 下发 / v-perm)。
|
|
|
+//
|
|
|
+// 入参:
|
|
|
+// - ctx 请求上下文;
|
|
|
+// - roleCode 角色码(token 选定作用域的角色;超管传 "super_admin" 返回**全量** code——
|
|
|
+// 代码层放行,不查矩阵,保证超管菜单/按钮永远完整)。
|
|
|
+//
|
|
|
+// 出参:
|
|
|
+// - []string:权限点 code 集(非 nil;空集 = 该角色无可见菜单/按钮,前端全隐);
|
|
|
+// - error:ErrDBError(DB 异常;缓存命中时不触 DB 故不报错)。
|
|
|
+func (r *Resolver) Permissions(ctx context.Context, roleCode string) ([]string, error) {
|
|
|
+ if roleCode == model.RoleSuperAdmin {
|
|
|
+ perms, err := r.allActive(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ codes := make([]string, 0, len(perms))
|
|
|
+ for _, p := range perms {
|
|
|
+ codes = append(codes, p.Code)
|
|
|
+ }
|
|
|
+ return codes, nil
|
|
|
+ }
|
|
|
+ return r.roleCodes(ctx, roleCode)
|
|
|
+}
|
|
|
+
|
|
|
+// MenuTree 构建某角色的可见菜单树(menuTree 下发)。
|
|
|
+//
|
|
|
+// 算法(两阶段,单次全量权限点读取):
|
|
|
+// 1. 取全量 active 权限点(TTL 缓存);
|
|
|
+// 2. 取角色 code 集(超管=全量);
|
|
|
+// 3. 过滤出 type=menu 且 code 在角色集内的节点(button 不进树——它只进 permissions[]);
|
|
|
+// 4. 按 parent_code 挂树 + 按 sort 排序:父不在角色集内的 menu 节点**提升为顶级**
|
|
|
+// (父被禁用/未授权时不丢菜单——展示层容错,数据层权限仍以路由白名单为准);
|
|
|
+// 5. 返回非 nil 切片(无可见菜单 = [],前端渲染空侧边栏)。
|
|
|
+//
|
|
|
+// 入参:ctx 请求上下文;roleCode 角色码。
|
|
|
+// 出参:
|
|
|
+// - []MenuNode:可见菜单树(顶级节点列表,非 nil);
|
|
|
+// - error:ErrDBError。
|
|
|
+func (r *Resolver) MenuTree(ctx context.Context, roleCode string) ([]MenuNode, error) {
|
|
|
+ perms, err := r.allActive(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ codes, err := r.Permissions(ctx, roleCode)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ set := make(map[string]bool, len(codes))
|
|
|
+ for _, c := range codes {
|
|
|
+ set[c] = true
|
|
|
+ }
|
|
|
+
|
|
|
+ // menu 节点索引(parent_code → 子节点)
|
|
|
+ byParent := make(map[string][]*model.Permission)
|
|
|
+ menuNodes := make(map[string]*model.Permission, len(perms))
|
|
|
+ for i := range perms {
|
|
|
+ p := &perms[i]
|
|
|
+ if p.Type != model.PermissionTypeMenu || !set[p.Code] {
|
|
|
+ continue // button 不进树;角色无权的不进树
|
|
|
+ }
|
|
|
+ menuNodes[p.Code] = p
|
|
|
+ var parent string
|
|
|
+ if p.ParentCode != nil {
|
|
|
+ parent = *p.ParentCode
|
|
|
+ }
|
|
|
+ byParent[parent] = append(byParent[parent], p)
|
|
|
+ }
|
|
|
+
|
|
|
+ var build func(parent string) []MenuNode
|
|
|
+ build = func(parent string) []MenuNode {
|
|
|
+ kids := byParent[parent]
|
|
|
+ if len(kids) == 0 {
|
|
|
+ return nil // 无子(或子被过滤):叶子/空
|
|
|
+ }
|
|
|
+ out := make([]MenuNode, 0, len(kids))
|
|
|
+ for _, k := range kids { // allActive 已按 sort ASC, code ASC,顺序稳定
|
|
|
+ n := MenuNode{Code: k.Code, Label: k.Name, Path: k.Path, Icon: k.Icon}
|
|
|
+ n.Children = build(k.Code)
|
|
|
+ out = append(out, n)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ }
|
|
|
+ // 顶级菜单 = parent_code 为 NULL("")的节点(build("") 已含);
|
|
|
+ // 另把"父节点不在可见集内"(父被禁用/未授权)的 menu 节点提升为顶级(展示层容错,
|
|
|
+ // 不丢菜单——数据层权限仍以路由白名单为准)。
|
|
|
+ missingParent := make(map[string]bool)
|
|
|
+ for _, p := range menuNodes {
|
|
|
+ if p.ParentCode != nil && !menuNodesHas(menuNodes, *p.ParentCode) {
|
|
|
+ missingParent[*p.ParentCode] = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ tree := build("")
|
|
|
+ for parent, kids := range byParent {
|
|
|
+ if parent == "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if !missingParent[parent] {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ for _, k := range kids {
|
|
|
+ n := MenuNode{Code: k.Code, Label: k.Name, Path: k.Path, Icon: k.Icon}
|
|
|
+ n.Children = build(k.Code)
|
|
|
+ tree = append(tree, n)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if tree == nil {
|
|
|
+ tree = []MenuNode{} // 非 nil:JSON 序列化 [] 而非 null
|
|
|
+ }
|
|
|
+ return tree, nil
|
|
|
+}
|
|
|
+
|
|
|
+// menuNodesHas 判断 code 是否在 menu 节点集内(提升判定辅助)。
|
|
|
+func menuNodesHas(m map[string]*model.Permission, code string) bool {
|
|
|
+ _, ok := m[code]
|
|
|
+ return ok
|
|
|
+}
|
|
|
+
|
|
|
+// Invalidate 清空本实例缓存(超管改矩阵后调用,T6.5-e;跨实例靠 TTL 自然过期)。
|
|
|
+func (r *Resolver) Invalidate() {
|
|
|
+ r.mu.Lock()
|
|
|
+ defer r.mu.Unlock()
|
|
|
+ r.permCache = cacheEntry[[]model.Permission]{}
|
|
|
+ r.codeCache = cacheEntry[map[string][]string]{}
|
|
|
+}
|
|
|
+
|
|
|
+// allActive 全量 active 权限点(TTL 缓存收口)。
|
|
|
+func (r *Resolver) allActive(ctx context.Context) ([]model.Permission, error) {
|
|
|
+ r.mu.RLock()
|
|
|
+ if !r.permCache.loadedAt.IsZero() && time.Since(r.permCache.loadedAt) < r.ttl {
|
|
|
+ v := r.permCache.value
|
|
|
+ r.mu.RUnlock()
|
|
|
+ return v, nil
|
|
|
+ }
|
|
|
+ r.mu.RUnlock()
|
|
|
+
|
|
|
+ perms, err := r.repo.AllActive(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ r.mu.Lock()
|
|
|
+ r.permCache = cacheEntry[[]model.Permission]{value: perms, loadedAt: time.Now()}
|
|
|
+ r.mu.Unlock()
|
|
|
+ return perms, nil
|
|
|
+}
|
|
|
+
|
|
|
+// roleCodes 角色 → code 集(TTL 缓存收口;map 按需逐角色缓存)。
|
|
|
+func (r *Resolver) roleCodes(ctx context.Context, roleCode string) ([]string, error) {
|
|
|
+ r.mu.RLock()
|
|
|
+ if !r.codeCache.loadedAt.IsZero() && time.Since(r.codeCache.loadedAt) < r.ttl {
|
|
|
+ if v, ok := r.codeCache.value[roleCode]; ok {
|
|
|
+ r.mu.RUnlock()
|
|
|
+ return v, nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+ r.mu.RUnlock()
|
|
|
+
|
|
|
+ codes, err := r.repo.RolePermissionCodes(ctx, roleCode)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ r.mu.Lock()
|
|
|
+ if r.codeCache.value == nil {
|
|
|
+ r.codeCache.value = map[string][]string{}
|
|
|
+ }
|
|
|
+ r.codeCache.value[roleCode] = codes
|
|
|
+ r.codeCache.loadedAt = time.Now()
|
|
|
+ r.mu.Unlock()
|
|
|
+ return codes, nil
|
|
|
+}
|