rbac.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. // Package rbac 组织域 RBAC 解析(角色 → 权限点集 → 菜单树,T6.5,架构规格 §5 org/rbac 子包)。
  2. //
  3. // 定位(RBAC 三层模型的"展示层"):
  4. // - 本包只做**角色 → 权限点 code 集 → 菜单树/权限码扁平化**,供 /auth/issue 与 /auth/me 下发
  5. // (menuTree + permissions,PC 前端契约:MenuNode{code,label,path,icon,children},前端原样渲染
  6. // 不二次过滤,不落 localStorage);
  7. // - 接口强制**不走本包**(拍板:路由角色白名单中间件 T6.5-d,7 角色粒度,超管放行);
  8. // - 数据权限(老师按班级/家长按绑定学生)随业务域实现,亦不走本包。
  9. //
  10. // 缓存(进程内,TTL 30s):
  11. // - 权限点字典(平台级,量级 < 百)与"角色→code 集"均走短 TTL 缓存,避免每次 issue/me 打 DB;
  12. // - 超管改矩阵(T6.5-e)后**最长 30s 生效**(双实例共享 DB 但缓存各自独立,TTL 到期自然一致;
  13. // 改配端点同步调 Invalidate 清本实例缓存——跨实例不广播,30s 内旧值可接受)。
  14. //
  15. // 依赖方向:rbac → Store 接口(由 repository.PermissionRepo 实现)+ model(同域下层),
  16. // 不依赖 handler/service;Store 接口使单测可注入 fake(同 identity 域依赖倒置纪律)。
  17. package rbac
  18. import (
  19. "context"
  20. "sync"
  21. "time"
  22. "yuansiwei/unified-backend/internal/org/model"
  23. "yuansiwei/unified-backend/internal/org/repository"
  24. )
  25. // Store 权限点/角色矩阵数据访问抽象(*repository.PermissionRepo 隐式满足;
  26. // 接口在消费方 rbac 定义,生产注入具体仓储、单测注入 fake)。
  27. type Store interface {
  28. // AllActive 全量 active 权限点(菜单树构建用,按 sort 升序)。
  29. AllActive(ctx context.Context) ([]model.Permission, error)
  30. // RolePermissionCodes 某角色的权限点 code 集(升序去重;空集 = 无授权)。
  31. RolePermissionCodes(ctx context.Context, roleCode string) ([]string, error)
  32. }
  33. // 编译期接口断言:具体仓储须满足 Store(签名漂移即编译失败)。
  34. var _ Store = (*repository.PermissionRepo)(nil)
  35. // defaultCacheTTL 权限点/角色矩阵的进程内缓存 TTL(超管改配后的最大生效延迟)。
  36. const defaultCacheTTL = 30 * time.Second
  37. // MenuNode 菜单树节点(**字段与 PC 前端 MenuNode 契约严格对齐**,见 PC src/types/api.d.ts):
  38. // 后端按角色/权限过滤后下发,前端原样渲染(不在前端二次过滤,避免权限码→菜单双份漂移)。
  39. //
  40. // 字段:
  41. // - Code:权限码(三段式 `域.资源.动作`;菜单项显隐 + 按钮 v-perm 联动依据,恒下发);
  42. // - Label:菜单中文名(直接渲染,不引入 i18n);
  43. // - Path:路由 path(叶子节点必填;父节点可省略 = 仅展开不跳转;nil 时 JSON 省略该键);
  44. // - Icon:Element Plus 图标组件名(可选;缺省不显示图标);
  45. // - Children:子菜单(递归;无子时省略——前端按 undefined 渲染为叶子)。
  46. type MenuNode struct {
  47. Code string `json:"code"`
  48. Label string `json:"label"`
  49. Path *string `json:"path,omitempty"`
  50. Icon *string `json:"icon,omitempty"`
  51. Children []MenuNode `json:"children,omitempty"`
  52. }
  53. // Resolver RBAC 解析器(启动装配一次,全进程共享;进程内缓存)。
  54. type Resolver struct {
  55. repo Store
  56. ttl time.Duration
  57. mu sync.RWMutex
  58. permCache cacheEntry[[]model.Permission] // 全量 active 权限点
  59. codeCache cacheEntry[map[string][]string] // roleCode → 权限点 code 集
  60. }
  61. // cacheEntry TTL 缓存条目(泛型:同一套机制服务两种缓存)。
  62. type cacheEntry[T any] struct {
  63. value T
  64. loadedAt time.Time
  65. }
  66. // NewResolver 构造 RBAC 解析器。
  67. //
  68. // 入参:
  69. // - repo Store:权限点/角色矩阵数据访问(*repository.PermissionRepo 生产注入、fake 单测注入;
  70. // nil 时返回 nil 由装配层 fail-fast 判定);
  71. // - ttl time.Duration:缓存 TTL;≤0 时取 defaultCacheTTL(30s)。
  72. //
  73. // 出参:*Resolver:就绪解析器(无 DB 操作,构造恒成功)。
  74. func NewResolver(repo Store, ttl time.Duration) *Resolver {
  75. if ttl <= 0 {
  76. ttl = defaultCacheTTL
  77. }
  78. return &Resolver{repo: repo, ttl: ttl}
  79. }
  80. // Permissions 解析某角色的全量权限点 code 集(菜单 + 按钮,供 permissions[] 下发 / v-perm)。
  81. //
  82. // 入参:
  83. // - ctx 请求上下文;
  84. // - roleCode 角色码(token 选定作用域的角色;超管传 "super_admin" 返回**全量** code——
  85. // 代码层放行,不查矩阵,保证超管菜单/按钮永远完整)。
  86. //
  87. // 出参:
  88. // - []string:权限点 code 集(非 nil;空集 = 该角色无可见菜单/按钮,前端全隐);
  89. // - error:ErrDBError(DB 异常;缓存命中时不触 DB 故不报错)。
  90. func (r *Resolver) Permissions(ctx context.Context, roleCode string) ([]string, error) {
  91. if roleCode == model.RoleSuperAdmin {
  92. perms, err := r.allActive(ctx)
  93. if err != nil {
  94. return nil, err
  95. }
  96. codes := make([]string, 0, len(perms))
  97. for _, p := range perms {
  98. codes = append(codes, p.Code)
  99. }
  100. return codes, nil
  101. }
  102. return r.roleCodes(ctx, roleCode)
  103. }
  104. // MenuTree 构建某角色的可见菜单树(menuTree 下发)。
  105. //
  106. // 算法(两阶段,单次全量权限点读取):
  107. // 1. 取全量 active 权限点(TTL 缓存);
  108. // 2. 取角色 code 集(超管=全量);
  109. // 3. 过滤出 type=menu 且 code 在角色集内的节点(button 不进树——它只进 permissions[]);
  110. // 4. 按 parent_code 挂树 + 按 sort 排序:父不在角色集内的 menu 节点**提升为顶级**
  111. // (父被禁用/未授权时不丢菜单——展示层容错,数据层权限仍以路由白名单为准);
  112. // 5. 返回非 nil 切片(无可见菜单 = [],前端渲染空侧边栏)。
  113. //
  114. // 入参:ctx 请求上下文;roleCode 角色码。
  115. // 出参:
  116. // - []MenuNode:可见菜单树(顶级节点列表,非 nil);
  117. // - error:ErrDBError。
  118. func (r *Resolver) MenuTree(ctx context.Context, roleCode string) ([]MenuNode, error) {
  119. perms, err := r.allActive(ctx)
  120. if err != nil {
  121. return nil, err
  122. }
  123. codes, err := r.Permissions(ctx, roleCode)
  124. if err != nil {
  125. return nil, err
  126. }
  127. set := make(map[string]bool, len(codes))
  128. for _, c := range codes {
  129. set[c] = true
  130. }
  131. // menu 节点索引(parent_code → 子节点)
  132. byParent := make(map[string][]*model.Permission)
  133. menuNodes := make(map[string]*model.Permission, len(perms))
  134. for i := range perms {
  135. p := &perms[i]
  136. if p.Type != model.PermissionTypeMenu || !set[p.Code] {
  137. continue // button 不进树;角色无权的不进树
  138. }
  139. menuNodes[p.Code] = p
  140. var parent string
  141. if p.ParentCode != nil {
  142. parent = *p.ParentCode
  143. }
  144. byParent[parent] = append(byParent[parent], p)
  145. }
  146. var build func(parent string) []MenuNode
  147. build = func(parent string) []MenuNode {
  148. kids := byParent[parent]
  149. if len(kids) == 0 {
  150. return nil // 无子(或子被过滤):叶子/空
  151. }
  152. out := make([]MenuNode, 0, len(kids))
  153. for _, k := range kids { // allActive 已按 sort ASC, code ASC,顺序稳定
  154. n := MenuNode{Code: k.Code, Label: k.Name, Path: k.Path, Icon: k.Icon}
  155. n.Children = build(k.Code)
  156. out = append(out, n)
  157. }
  158. return out
  159. }
  160. // 顶级菜单 = parent_code 为 NULL("")的节点(build("") 已含);
  161. // 另把"父节点不在可见集内"(父被禁用/未授权)的 menu 节点提升为顶级(展示层容错,
  162. // 不丢菜单——数据层权限仍以路由白名单为准)。
  163. missingParent := make(map[string]bool)
  164. for _, p := range menuNodes {
  165. if p.ParentCode != nil && !menuNodesHas(menuNodes, *p.ParentCode) {
  166. missingParent[*p.ParentCode] = true
  167. }
  168. }
  169. tree := build("")
  170. for parent, kids := range byParent {
  171. if parent == "" {
  172. continue
  173. }
  174. if !missingParent[parent] {
  175. continue
  176. }
  177. for _, k := range kids {
  178. n := MenuNode{Code: k.Code, Label: k.Name, Path: k.Path, Icon: k.Icon}
  179. n.Children = build(k.Code)
  180. tree = append(tree, n)
  181. }
  182. }
  183. if tree == nil {
  184. tree = []MenuNode{} // 非 nil:JSON 序列化 [] 而非 null
  185. }
  186. return tree, nil
  187. }
  188. // menuNodesHas 判断 code 是否在 menu 节点集内(提升判定辅助)。
  189. func menuNodesHas(m map[string]*model.Permission, code string) bool {
  190. _, ok := m[code]
  191. return ok
  192. }
  193. // Invalidate 清空本实例缓存(超管改矩阵后调用,T6.5-e;跨实例靠 TTL 自然过期)。
  194. func (r *Resolver) Invalidate() {
  195. r.mu.Lock()
  196. defer r.mu.Unlock()
  197. r.permCache = cacheEntry[[]model.Permission]{}
  198. r.codeCache = cacheEntry[map[string][]string]{}
  199. }
  200. // allActive 全量 active 权限点(TTL 缓存收口)。
  201. func (r *Resolver) allActive(ctx context.Context) ([]model.Permission, error) {
  202. r.mu.RLock()
  203. if !r.permCache.loadedAt.IsZero() && time.Since(r.permCache.loadedAt) < r.ttl {
  204. v := r.permCache.value
  205. r.mu.RUnlock()
  206. return v, nil
  207. }
  208. r.mu.RUnlock()
  209. perms, err := r.repo.AllActive(ctx)
  210. if err != nil {
  211. return nil, err
  212. }
  213. r.mu.Lock()
  214. r.permCache = cacheEntry[[]model.Permission]{value: perms, loadedAt: time.Now()}
  215. r.mu.Unlock()
  216. return perms, nil
  217. }
  218. // roleCodes 角色 → code 集(TTL 缓存收口;map 按需逐角色缓存)。
  219. func (r *Resolver) roleCodes(ctx context.Context, roleCode string) ([]string, error) {
  220. r.mu.RLock()
  221. if !r.codeCache.loadedAt.IsZero() && time.Since(r.codeCache.loadedAt) < r.ttl {
  222. if v, ok := r.codeCache.value[roleCode]; ok {
  223. r.mu.RUnlock()
  224. return v, nil
  225. }
  226. }
  227. r.mu.RUnlock()
  228. codes, err := r.repo.RolePermissionCodes(ctx, roleCode)
  229. if err != nil {
  230. return nil, err
  231. }
  232. r.mu.Lock()
  233. if r.codeCache.value == nil {
  234. r.codeCache.value = map[string][]string{}
  235. }
  236. r.codeCache.value[roleCode] = codes
  237. r.codeCache.loadedAt = time.Now()
  238. r.mu.Unlock()
  239. return codes, nil
  240. }