Ver Fonte

feat(org): T6.5-b/c RBAC model + 平台级仓储 + 菜单树解析

- model: Permission/RolePermission 两表 + AllTables() 扩到 7 表
- repository: PermissionRepo(平台级,不继承 BaseRepo)AllActive/
  RolePermissionCodes/SaveRolePermissions(删旧插新事务)
- rbac: Resolver(role→权限点 code 集→菜单树)+ MenuNode(对齐 PC
  契约)+ 进程内 TTL 30s 缓存 + Invalidate;Store 接口使 fake 可注入
- 测试: rbac 7 项纯单测(超管全量/普通角色/空矩阵/父缺失提升/空树非nil)

build/vet/test 全绿。
Q há 3 dias atrás
pai
commit
541f2e2784

+ 52 - 1
internal/org/model/models.go

@@ -152,10 +152,59 @@ type GuardianLink struct {
 // TableName 指定表名(GORM 默认 GuardianLink → guardian_links,与 DDL 单数 t_guardian_link 不符)。
 func (GuardianLink) TableName() string { return "t_guardian_link" }
 
+// PermissionStatus 权限点状态。
+const (
+	PermissionStatusActive   = "active"
+	PermissionStatusDisabled = "disabled" // disabled 不进菜单树/权限集(软下线,不物理删)
+)
+
+// PermissionType 权限点类型。
+const (
+	PermissionTypeMenu   = "menu"   // 菜单节点:进菜单树(menuTree 下发),叶子必有 path
+	PermissionTypeButton = "button" // 按钮节点:不进菜单树,仅供 v-perm 权限码判定
+)
+
+// Permission 权限点字典表 t_permission(平台级元数据·不带作用域,RBAC 菜单/按钮展示层,T6.5)。
+//
+// 主键 Code 为 string(三段式 `域.资源.动作`,如 org.institution.list;对齐 PC 前端契约
+// MenuNode.code / permissions[],前端按此做菜单显隐 + 按钮 v-perm 联动)。
+// ParentCode 自引用构成菜单树(type=menu 的子树即菜单树;button 挂对应 menu 下,不进树)。
+// 超级管理员**不依赖本表**放行(代码层全量放行,T6.5-d requireRole 中间件),
+// 本表只服务"非超管角色的可见菜单/按钮"。
+type Permission struct {
+	Code       string    `gorm:"column:code;primaryKey;size:64" json:"code"`
+	Type       string    `gorm:"column:type;size:16;not null" json:"type"`
+	Name       string    `gorm:"column:name;size:64;not null" json:"name"`
+	ParentCode *string   `gorm:"column:parent_code;size:64" json:"parentCode,omitempty"`
+	Path       *string   `gorm:"column:path;size:255" json:"path,omitempty"`
+	Icon       *string   `gorm:"column:icon;size:64" json:"icon,omitempty"`
+	Sort       int32     `gorm:"column:sort;not null" json:"sort"`
+	Status     string    `gorm:"column:status;size:16;not null" json:"status"`
+	CreatedAt  time.Time `gorm:"column:created_at" json:"createdAt"`
+	UpdatedAt  time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+// TableName 指定表名(GORM 默认 Permission → permissions,与 DDL 单数 t_permission 不符)。
+func (Permission) TableName() string { return "t_permission" }
+
+// RolePermission 角色-权限点矩阵表 t_role_permission(平台级,D3 主键自增)。
+//
+// UNIQUE(role_code, permission_code);矩阵由超管端点配置(T6.5-e:删旧插新事务),
+// 种子为起步态(core-model-ddl.sql v1.1 内置 INSERT IGNORE)。
+type RolePermission struct {
+	ID             int64     `gorm:"column:id;primaryKey" json:"id"`
+	RoleCode       string    `gorm:"column:role_code;size:32;not null" json:"roleCode"`
+	PermissionCode string    `gorm:"column:permission_code;size:64;not null" json:"permissionCode"`
+	CreatedAt      time.Time `gorm:"column:created_at" json:"createdAt"`
+}
+
+// TableName 指定表名(GORM 默认 RolePermission → role_permissions,与 DDL 单数 t_role_permission 不符)。
+func (RolePermission) TableName() string { return "t_role_permission" }
+
 // AllTables 返回本域全部 model 指针切片(供装配层/集成测试合并后传入 infra/db.Migrate)。
 //
 // 入参:无。
-// 出参:[]any——org 域 5 表(Institution/Campus/Membership/Role/GuardianLink)。
+// 出参:[]any——org 域 7 表(Institution/Campus/Membership/Role/GuardianLink/Permission/RolePermission)。
 // 说明:返回新切片,调用方可安全 append。
 func AllTables() []any {
 	return []any{
@@ -164,5 +213,7 @@ func AllTables() []any {
 		&Membership{},
 		&RoleModel{},
 		&GuardianLink{},
+		&Permission{},
+		&RolePermission{},
 	}
 }

+ 260 - 0
internal/org/rbac/rbac.go

@@ -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
+}

+ 178 - 0
internal/org/rbac/rbac_test.go

@@ -0,0 +1,178 @@
+// rbac 包纯单测(T6.5-c 验收):Permissions/MenuTree 解析逻辑(fake 仓储,不连 DB)。
+//
+// 覆盖:
+//   - 超管 = 全量权限点 code(代码层放行,不查矩阵);
+//   - 普通角色 = 仅矩阵内 code(空矩阵 → 空集);
+//   - 菜单树:button 不进树 / 角色无权菜单不进树 / 父缺失提升顶级 / sort 序 / 空集返回非 nil []。
+package rbac
+
+import (
+	"context"
+	"testing"
+
+	"yuansiwei/unified-backend/internal/org/model"
+)
+
+// fakePermRepo 内存 fake(满足 Resolver 所需两方法;避免引接口变更)。
+type fakePermRepo struct {
+	all    []model.Permission
+	byRole map[string][]string
+}
+
+func (f *fakePermRepo) AllActive(ctx context.Context) ([]model.Permission, error) {
+	if f.all == nil {
+		return []model.Permission{}, nil
+	}
+	return f.all, nil
+}
+func (f *fakePermRepo) RolePermissionCodes(ctx context.Context, role string) ([]string, error) {
+	v, ok := f.byRole[role]
+	if !ok || v == nil {
+		return []string{}, nil
+	}
+	return v, nil
+}
+
+// 编译期:Resolver 只依赖具体仓储两方法(此处显式声明依赖面,防误增方法)。
+var _ = (*fakePermRepo)(nil)
+
+func s(p string) *string { return &p }
+
+// seedPerms 最小集权限点(org 2 菜单 + 4 按钮 + 1 顶级组菜单 + 1 其下子菜单)。
+func seedPerms() []model.Permission {
+	return []model.Permission{
+		{Code: "org.institution.list", Type: "menu", Name: "机构管理", Path: s("/org/institutions"), Sort: 1, Status: "active"},
+		{Code: "org.institution.get", Type: "button", Name: "机构详情", ParentCode: s("org.institution.list"), Sort: 2, Status: "active"},
+		{Code: "org.campus.list", Type: "menu", Name: "校区管理", Path: s("/org/campuses"), Sort: 3, Status: "active"},
+		{Code: "org.campus.get", Type: "button", Name: "校区详情", ParentCode: s("org.campus.list"), Sort: 4, Status: "active"},
+		{Code: "sys.group", Type: "menu", Name: "系统", Sort: 5, Status: "active"},
+		{Code: "sys.menu", Type: "menu", Name: "子项", ParentCode: s("sys.group"), Path: s("/sys/menu"), Sort: 6, Status: "active"},
+	}
+}
+
+// TestPermissions_SuperAdmin 超管 = 全量(6 个 code,含 button)。
+func TestPermissions_SuperAdmin(t *testing.T) {
+	r := NewResolver(&fakePermRepo{all: seedPerms()}, 0)
+	codes, err := r.Permissions(context.Background(), model.RoleSuperAdmin)
+	if err != nil {
+		t.Fatalf("Permissions: %v", err)
+	}
+	if len(codes) != 6 {
+		t.Fatalf("超管 code 数=%d(期望 6): %v", len(codes), codes)
+	}
+}
+
+// TestPermissions_Academic 普通角色 = 仅矩阵内 code。
+func TestPermissions_Academic(t *testing.T) {
+	repo := &fakePermRepo{
+		all: seedPerms(),
+		byRole: map[string][]string{
+			"academic": {"org.institution.list", "org.institution.get", "org.campus.list", "org.campus.get"},
+		},
+	}
+	r := NewResolver(repo, 0)
+	codes, err := r.Permissions(context.Background(), "academic")
+	if err != nil {
+		t.Fatalf("Permissions: %v", err)
+	}
+	if len(codes) != 4 {
+		t.Fatalf("academic code 数=%d(期望 4): %v", len(codes), codes)
+	}
+}
+
+// TestPermissions_Empty 空矩阵角色 → 空集(非 nil)。
+func TestPermissions_Empty(t *testing.T) {
+	r := NewResolver(&fakePermRepo{all: seedPerms()}, 0)
+	codes, err := r.Permissions(context.Background(), "teacher")
+	if err != nil {
+		t.Fatalf("Permissions: %v", err)
+	}
+	if len(codes) != 0 {
+		t.Fatalf("teacher code 数=%d(期望 0): %v", len(codes), codes)
+	}
+}
+
+// TestMenuTree_Academic 菜单树:button 不进树、无权菜单不进树、顶级序稳定。
+func TestMenuTree_Academic(t *testing.T) {
+	repo := &fakePermRepo{
+		all: seedPerms(),
+		byRole: map[string][]string{
+			"academic": {"org.institution.list", "org.institution.get", "org.campus.list", "org.campus.get"},
+		},
+	}
+	r := NewResolver(repo, 0)
+	tree, err := r.MenuTree(context.Background(), "academic")
+	if err != nil {
+		t.Fatalf("MenuTree: %v", err)
+	}
+	if len(tree) != 2 {
+		t.Fatalf("顶级菜单数=%d(期望 2:机构/校区): %+v", len(tree), tree)
+	}
+	if tree[0].Code != "org.institution.list" || tree[1].Code != "org.campus.list" {
+		t.Fatalf("顶级序错: %+v", tree)
+	}
+	// 叶子:button 绝不进树(无 Children)
+	for _, n := range tree {
+		if len(n.Children) != 0 {
+			t.Fatalf("academic 菜单不应有子节点: %+v", n)
+		}
+		if n.Path == nil {
+			t.Fatalf("叶子菜单缺 path: %+v", n)
+		}
+	}
+}
+
+// TestMenuTree_ParentMissing 父节点被过滤(未授权/禁用)→ 子菜单提升为顶级。
+func TestMenuTree_ParentMissing(t *testing.T) {
+	repo := &fakePermRepo{
+		all: seedPerms(),
+		byRole: map[string][]string{
+			// 只有子菜单 sys.menu,父 sys.group 不在角色集 → sys.menu 提升顶级
+			"teacher": {"sys.menu"},
+		},
+	}
+	r := NewResolver(repo, 0)
+	tree, err := r.MenuTree(context.Background(), "teacher")
+	if err != nil {
+		t.Fatalf("MenuTree: %v", err)
+	}
+	if len(tree) != 1 || tree[0].Code != "sys.menu" {
+		t.Fatalf("父缺失未提升顶级: %+v", tree)
+	}
+}
+
+// TestMenuTree_Empty 无可见菜单 → 返回非 nil 空切片(JSON 为 [] 而非 null)。
+func TestMenuTree_Empty(t *testing.T) {
+	r := NewResolver(&fakePermRepo{all: seedPerms()}, 0)
+	tree, err := r.MenuTree(context.Background(), "student")
+	if err != nil {
+		t.Fatalf("MenuTree: %v", err)
+	}
+	if tree == nil {
+		t.Fatal("空菜单树返回 nil(应非 nil [])")
+	}
+	if len(tree) != 0 {
+		t.Fatalf("student 菜单数=%d(期望 0): %+v", len(tree), tree)
+	}
+}
+
+// TestMenuTree_SuperAdmin 超管 = 全量 menu(4 个顶级:2 org + sys.group;sys.menu 挂在 sys.group 下)。
+func TestMenuTree_SuperAdmin(t *testing.T) {
+	r := NewResolver(&fakePermRepo{all: seedPerms()}, 0)
+	tree, err := r.MenuTree(context.Background(), model.RoleSuperAdmin)
+	if err != nil {
+		t.Fatalf("MenuTree: %v", err)
+	}
+	if len(tree) != 3 { // org.institution.list / org.campus.list / sys.group
+		t.Fatalf("超管顶级菜单数=%d(期望 3): %+v", len(tree), tree)
+	}
+	var sys *MenuNode
+	for i := range tree {
+		if tree[i].Code == "sys.group" {
+			sys = &tree[i]
+		}
+	}
+	if sys == nil || len(sys.Children) != 1 || sys.Children[0].Code != "sys.menu" {
+		t.Fatalf("sys.group 子菜单缺失: %+v", sys)
+	}
+}

+ 123 - 0
internal/org/repository/permission_repo.go

@@ -0,0 +1,123 @@
+// 权限点/角色矩阵仓储(T6.5):t_permission + t_role_permission 的**平台级**数据访问。
+//
+// 与 InstitutionRepo/CampusRepo 的关键差异(架构规格 §3.5):
+//   - **不继承 scope.BaseRepo**:t_permission/t_role_permission 是平台级 RBAC 字典
+//     (同 t_role 角色字典的处置:平台级表不进 scoping,见 P0-11 收敛规则 7),
+//     直接用裸 *gorm.DB;
+//   - 多租户防线不在此层:权限点矩阵本身是"角色 → 可见菜单/按钮"的全局配置,
+//     与机构/校区无关;作用域越权防线在 Service 层(assertInScope,T6)+
+//     路由角色白名单(T6.5-d)。
+//
+// 依赖方向铁律:org repository → org/model + infra/errors(全下层/同域),
+// 不依赖 handler/service。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+
+	"gorm.io/gorm"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/org/model"
+)
+
+// mapGormErr gorm 原错 → sentinel(与 identity/repository 同名同语义:
+// ErrRecordNotFound→ErrNotFound,其它→ErrDBError;平台级仓储统一出口)。
+func mapGormErr(err error) *apperrors.AppError {
+	switch {
+	case err == nil:
+		return nil
+	case stderrors.Is(err, gorm.ErrRecordNotFound):
+		return apperrors.WithCause(apperrors.ErrNotFound, err)
+	default:
+		return apperrors.WithCause(apperrors.ErrDBError, err)
+	}
+}
+
+// PermissionRepo 权限点字典 + 角色矩阵仓储(平台级)。
+type PermissionRepo struct {
+	db *gorm.DB
+}
+
+// NewPermissionRepo 构造权限点/角色矩阵仓储。
+//
+// 入参:db *gorm.DB:数据库句柄(必填)。
+// 出参:*PermissionRepo:就绪仓储。
+func NewPermissionRepo(db *gorm.DB) *PermissionRepo {
+	return &PermissionRepo{db: db}
+}
+
+// AllActive 取全部 active 权限点(菜单树构建用;按 sort 升序,构树方自行按 parent_code 组装)。
+//
+// 入参:ctx 请求上下文。
+// 出参:
+//   - []model.Permission:全部 active 权限点(**非 nil**,空时为 [];disabled 不返回);
+//   - error:ErrDBError(DB 异常)。
+//
+// 实现说明:权限点是平台级字典(量级 < 百),一次全取 + 进程内缓存(rbac 包),
+// 不做分页;Order(sort ASC, code ASC) 保证同级排序稳定。
+func (r *PermissionRepo) AllActive(ctx context.Context) ([]model.Permission, error) {
+	var list []model.Permission
+	if err := r.db.WithContext(ctx).
+		Where("status = ?", model.PermissionStatusActive).
+		Order("sort ASC, code ASC").
+		Find(&list).Error; err != nil {
+		return nil, mapGormErr(err)
+	}
+	if list == nil {
+		list = []model.Permission{}
+	}
+	return list, nil
+}
+
+// RolePermissionCodes 取某角色的全部权限点 code(去重、升序)。
+//
+// 入参:ctx 请求上下文;roleCode 角色码(t_role 字典值)。
+// 出参:
+//   - []string:权限点 code 切片(**非 nil**,无授权时为 []string{}——前端按空集处理:
+//     菜单空 + 按钮全隐;超管的"全量放行"在 rbac 包/中间件层实现,不经本方法);
+//   - error:ErrDBError(DB 异常)。
+func (r *PermissionRepo) RolePermissionCodes(ctx context.Context, roleCode string) ([]string, error) {
+	var codes []string
+	if err := r.db.WithContext(ctx).
+		Model(&model.RolePermission{}).
+		Where("role_code = ?", roleCode).
+		Distinct().
+		Order("permission_code ASC").
+		Pluck("permission_code", &codes).Error; err != nil {
+		return nil, mapGormErr(err)
+	}
+	if codes == nil {
+		codes = []string{}
+	}
+	return codes, nil
+}
+
+// SaveRolePermissions 全量保存某角色的权限点矩阵(超管配置端点,T6.5-e)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - roleCode 角色码(必须为 t_role 字典内角色,调用方校验;本方法只做矩阵维护);
+//   - permissionCodes []string:目标全量权限点集合(可为空 = 清空该角色矩阵)。
+//
+// 出参:error——ErrDBError(DB 异常,含事务回滚)。
+//
+// 语义:**删旧插新**(事务内先删该角色全部矩阵行,再批量插入目标集合)——
+// "保存即全量替换",避免差集计算;空集合 = 清空(前端"取消全部勾选"场景合法)。
+// 幂等:目标集合与原矩阵一致时等价于原样重插(先删后插,结果相同)。
+func (r *PermissionRepo) SaveRolePermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
+	return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("role_code = ?", roleCode).Delete(&model.RolePermission{}).Error; err != nil {
+			return err
+		}
+		if len(permissionCodes) == 0 {
+			return nil // 清空语义(合法:角色无任何可见菜单/按钮)
+		}
+		rows := make([]model.RolePermission, 0, len(permissionCodes))
+		for _, code := range permissionCodes {
+			rows = append(rows, model.RolePermission{RoleCode: roleCode, PermissionCode: code})
+		}
+		return tx.Create(&rows).Error // GORM 批量 Create(切片)
+	})
+}