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