// 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 }