3 Achegas b8f4538b68 ... cef0d125ae

Autor SHA1 Mensaxe Data
  Q cef0d125ae feat(org): T6.5-e 超管角色-权限点矩阵配置端点 + 缓存联动 hai 3 días
  Q be1630c3eb feat(identity+org): T6.5-d issue/me 下发 menuTree/permissions + 路由角色白名单 hai 3 días
  Q 541f2e2784 feat(org): T6.5-b/c RBAC model + 平台级仓储 + 菜单树解析 hai 3 días

+ 45 - 9
cmd/api/main.go

@@ -48,9 +48,15 @@ import (
 	"yuansiwei/unified-backend/internal/infra/redis"
 	orghandler "yuansiwei/unified-backend/internal/org/handler"
 	orgmodel "yuansiwei/unified-backend/internal/org/model"
+	"yuansiwei/unified-backend/internal/org/rbac"
+	orgrepo "yuansiwei/unified-backend/internal/org/repository"
 	orgservice "yuansiwei/unified-backend/internal/org/service"
 )
 
+// 编译期断言:*rbac.Resolver 须满足 identity 的 MenuProvider 接口(签名漂移即编译失败,
+// 杜绝"装配注入了但方法签名不匹配"的隐蔽错误——生产注入点见 main() 内 service.Config.MenuProvider)。
+var _ service.MenuProvider = (*rbac.Resolver)(nil)
+
 // main 进程入口:装配 → 监听 → 信号驱动优雅退出。
 //
 // 入参(命令行 flag):
@@ -146,16 +152,21 @@ func main() {
 		logger.Warn("Auth.PublicKeysPEM 未配置,已从 PrivateKeyPEM 派生公钥(生产建议显式配置以支持密钥轮换)")
 	}
 
+	// RBAC 解析器(T6.5:role→菜单树/权限码;平台级 PermissionRepo 直连 gdb,TTL 取 0 走缺省 30s)
+	// 作为 identity 的 MenuProvider 注入:/auth/issue 与 /auth/me 据此下发 menuTree + permissions。
+	rbacResolver := rbac.NewResolver(orgrepo.NewPermissionRepo(gdb), 0)
+
 	authSvc, err := service.NewAuthService(service.Config{
-		Users:       repository.NewUserRepo(gdb),
-		Identities:  repository.NewIdentityRepo(gdb),
-		Memberships: repository.NewMembershipRepo(gdb),
-		Issuer:      issuer,
-		Tickets:     tickets,
-		AccessTTL:   accessTTL,
-		PublicKeys:  pubKeys,
-		IssuerStr:   cfg.Auth.Issuer,
-		Revocation:  revocation,
+		Users:        repository.NewUserRepo(gdb),
+		Identities:   repository.NewIdentityRepo(gdb),
+		Memberships:  repository.NewMembershipRepo(gdb),
+		Issuer:       issuer,
+		Tickets:      tickets,
+		AccessTTL:    accessTTL,
+		PublicKeys:   pubKeys,
+		IssuerStr:    cfg.Auth.Issuer,
+		Revocation:   revocation,
+		MenuProvider: rbacResolver,
 	})
 	if err != nil {
 		logger.Error("AuthService 装配失败", zap.Error(err))
@@ -183,6 +194,18 @@ func main() {
 		logger.Error("org Handler 装配失败", zap.Error(err))
 		os.Exit(1)
 	}
+	// RBAC 矩阵配置服务 + handler(T6.5-e 超管可配置后台;平台级,不走 scoping)
+	// 保存成功后经 rbacResolver.Invalidate() 清本实例缓存(跨实例靠 TTL 30s 自然一致)。
+	rbacSvc, err := orgservice.NewRolePermissionService(gdb, orgrepo.NewPermissionRepo(gdb), rbacResolver)
+	if err != nil {
+		logger.Error("RBAC 矩阵配置服务装配失败", zap.Error(err))
+		os.Exit(1)
+	}
+	rbacH, err := orghandler.NewRBAC(orghandler.RBACConfig{Svc: rbacSvc})
+	if err != nil {
+		logger.Error("RBAC Handler 装配失败", zap.Error(err))
+		os.Exit(1)
+	}
 
 	app := fiber.New(fiber.Config{
 		AppName:               "yuansiwei-unified-backend",
@@ -221,13 +244,26 @@ func main() {
 
 	// org 域(机构/校区只读 + 作用域越权拦截,经同一验签中间件;作用域由中间件注入,
 	// Repository Scoping 强制机构/校区过滤——跨校区越权防线,架构规格 §3.5)
+	// 路由角色白名单(T6.5-d 接口强制层):机构/校区目录对 staff 角色开放
+	// (academic 教务 / finance 财务 / teacher 老师),业务级 student/parent 拒绝(1003),
+	// 超管经 SuperAdmin 恒放行。与 e2e TestOrgE2E_RouteRoleWhitelist 白名单一致。
 	org := app.Group("/org")
 	org.Use(authMw)
+	org.Use(handler.RequireRole("academic", "finance", "teacher"))
 	org.Get("/institutions", orgH.Institutions)
 	org.Get("/institutions/:institutionId", orgH.Institution)
 	org.Get("/campuses", orgH.Campuses)
 	org.Get("/campuses/:campusId", orgH.Campus)
 
+	// RBAC 矩阵配置(T6.5-e 超管可配置后台):平台级,不走 scoping;独立兄弟组,仅超管可读写。
+	// 与上面的 /org 组并列(不继承其 staff 白名单),自带 authMw + RequireRole("super_admin"):
+	// 超管经 SuperAdmin 恒放行;其他任何角色(含 academic/finance/teacher)在子组白名单处被 1003。
+	rbacCfg := app.Group("/org/role-permissions")
+	rbacCfg.Use(authMw)
+	rbacCfg.Use(handler.RequireRole("super_admin"))
+	rbacCfg.Get("/:roleCode", rbacH.RolePermissions)
+	rbacCfg.Put("/:roleCode", rbacH.SaveRolePermissions)
+
 	// 其余域(academic/teaching/ai)路由挂载点(域模块实现后在此注册):
 	//
 	//	app.Get("/auth/jwks", identityHandler.JWKS)            // 公开

+ 73 - 0
internal/identity/handler/rbac.go

@@ -0,0 +1,73 @@
+// 路由角色白名单中间件(T6.5-d,RBAC 三层模型的"接口强制层",2026-11-06 用户拍板)。
+//
+// 定位(RBAC 三层模型第②层,与展示层/数据层分工):
+//   - 展示层(org/rbac.Resolver):role→菜单树/权限码,供 /auth/issue·/auth/me 下发;
+//   - **本中间件(接口强制层)**:受保护路由声明允许访问的**角色白名单**,请求角色不在
+//     白名单内 → code 1003(无权限);超管(super_admin)代码层全量放行,不查白名单;
+//   - 数据层(org/service.assertInScope + Repository Scoping):随业务域,本中间件不涉。
+//
+// 为什么是"角色白名单"而非"权限点判定"(拍板 #1 依据):
+//   - 粒度=7 角色(super_admin/academic/finance/teacher/student/parent/market),与
+//     membership.role 对齐,路由声明直观(如 GET /org/institutions 只允许 academic+super_admin);
+//   - 权限点(域.资源.动作)走展示层下发 + 前端 v-perm,接口强制用"角色"已足够——
+//     避免每次请求查 t_role_permission 矩阵(TTL 缓存只覆盖菜单,路由白名单是静态声明);
+//   - 白名单在路由注册处**声明式**挂上(org.Use(authMw, handler.RequireRole("academic"))),
+//     改权限=改路由声明(随域模块演进),不动中间件代码。
+//
+// 数据流(与 T4-5 验签中间件衔接,须在其**之后**挂上):
+//  1. authMw 验签 + scope.Set(c, sc)(scope.Scope 已写入 c.Locals);
+//  2. 本中间件:scope.FromFiber(c) 取 *Scope;
+//  3. sc==nil → 1001(中间件链配置错误:白名单挂在公开路由上);
+//  4. sc.SuperAdmin → 放行(超管代码层全量,不查白名单);
+//  5. sc.Role ∈ 白名单 → c.Next();否则 1003(无权限,不泄漏"哪些角色可访问")。
+//
+// 错误码:
+//   - 1001(ErrUnauth):无已验签 scope(白名单误挂公开路由 / 验签中间件未先执行);
+//   - 1003(ErrForbidden):角色不在白名单(已登录但无该接口权限)。
+package handler
+
+import (
+	"github.com/gofiber/fiber/v2"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+	"yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// RequireRole 构造"路由角色白名单"中间件(受保护路由专用,须挂在验签中间件之后)。
+//
+// 入参:roles []string:允许访问该路由的角色码白名单(≥1 个;空切片视为配置错误——
+// 见 fail-fast:返回固定 1003 不放行,杜绝"空白名单=全放行"的灾难性误配)。
+// 出参:fiber.Handler——可直接 app.Group(...).Use(authMw, RequireRole("academic","finance"))。
+//
+// 语义:
+//   - 超管(sc.SuperAdmin=true)恒放行(代码层全量,拍板:超管不受白名单约束);
+//   - 普通角色:sc.Role 命中白名单任一角色才放行,否则 1003;
+//   - sc==nil:1001(白名单误挂公开路由,纵深防御 fail-closed,绝不静默放行)。
+//
+// 使用样例(main.go 路由挂载):
+//
+//	org := app.Group("/org")
+//	org.Use(authMw)                                          // 先验签+scope 注入
+//	org.Use(handler.RequireRole("academic", "finance"))        // 再角色白名单
+//	org.Get("/institutions", orgH.Institutions)
+func RequireRole(roles ...string) fiber.Handler {
+	// 空白名单 fail-closed:拒绝所有(含超管除外,超管恒放行)。
+	// 配置"空白名单"通常是想放行全部角色——但正确做法是显式列全 7 角色或不挂本中间件;
+	// 静默放行会让"忘了填白名单"的开发者以为"没配=不限制",实际限制的是空集,语义反直觉。
+	return func(c *fiber.Ctx) error {
+		sc := scope.FromFiber(c)
+		if sc == nil {
+			return response.Fail(c, apperrors.ErrUnauth) // 1001:无 scope(白名单误挂公开路由)
+		}
+		if sc.SuperAdmin {
+			return c.Next() // 超管代码层全量放行(不查白名单)
+		}
+		for _, r := range roles {
+			if r == sc.Role {
+				return c.Next()
+			}
+		}
+		return response.Fail(c, apperrors.ErrForbidden) // 1003:角色不在白名单
+	}
+}

+ 120 - 39
internal/identity/service/auth_service.go

@@ -7,11 +7,12 @@
 //   - Handler(T4-4):把 Service 的 *AppError 映射为统一响应信封(response.Fail)。
 //
 // 两步登录协议(T4 契约,2026-11-06 拍板):
-//   ① Login(ctx, identityType, identifier, password):验账密 + 惰性升级 → 返回 loginTicket + 用户基础信息 +
-//     memberships[](机构/校区/角色,供前端"作用域选择页"展示)。**不签 token**。
-//   ② Issue(ctx, loginTicket, institutionId, campusId):消费一次性票据(Redis GETDEL 原子)→ 服务端校验
-//     (机构,校区) 确属该用户(防越权)→ 取该条 role → 签 access+refresh。
-//   移动端"自动取第一条" = ①之后立即用 memberships[0] 调 ②,同一套协议,无独立端点。
+//
+//	① Login(ctx, identityType, identifier, password):验账密 + 惰性升级 → 返回 loginTicket + 用户基础信息 +
+//	  memberships[](机构/校区/角色,供前端"作用域选择页"展示)。**不签 token**。
+//	② Issue(ctx, loginTicket, institutionId, campusId):消费一次性票据(Redis GETDEL 原子)→ 服务端校验
+//	  (机构,校区) 确属该用户(防越权)→ 取该条 role → 签 access+refresh。
+//	移动端"自动取第一条" = ①之后立即用 memberships[0] 调 ②,同一套协议,无独立端点。
 //
 // 安全要点:
 //   - **防枚举(anti-user-enumeration)**:"账号不存在 / 密码错 / 账号禁用 / 无密码哈希" 一律统一
@@ -41,6 +42,7 @@ import (
 	apperrors "yuansiwei/unified-backend/internal/infra/errors"
 	appredis "yuansiwei/unified-backend/internal/infra/redis"
 	orgmodel "yuansiwei/unified-backend/internal/org/model"
+	"yuansiwei/unified-backend/internal/org/rbac"
 )
 
 // errAuthFailed 登录主链路统一失败(防枚举:账号不存在/密码错/禁用/无哈希 全部用此)。
@@ -73,6 +75,9 @@ type AuthService struct {
 	pubKeys    []string
 	issuerStr  string
 	revocation token.RevocationStore
+	// menuProvider 角色→菜单树/权限码 解析器(T6.5-d;可空——未注入时 menuTree/permissions 恒空集,
+	// 菜单全隐/按钮全隐,不阻断登录。生产装配注入 *rbac.Resolver)。
+	menuProvider MenuProvider
 
 	// dummyHash 防枚举的 timing floor:① "账号不存在"路径补跑一次 argon2 校验(对 dummyHash),
 	// 使该路径 CPU 成本与"存在 + argon2 用户"同量级,响应时间不泄漏账号存在性(稳态全 argon2 后对齐)。
@@ -92,6 +97,9 @@ type Config struct {
 	Revocation  token.RevocationStore
 	// LoginTicketTTL 可选:登录票据有效期;0/缺省用 defaultLoginTicketTTL(5min)。
 	LoginTicketTTL time.Duration
+	// MenuProvider 可选:角色→菜单树/权限码 解析器(T6.5-d,注入 *rbac.Resolver);
+	// nil 时 menuTree/permissions 恒空集(菜单全隐,不阻断登录——RBAC 未装配时的兜底语义)。
+	MenuProvider MenuProvider
 }
 
 // NewAuthService 构造身份域业务服务(启动装配一次,全进程共享)。
@@ -122,29 +130,57 @@ func NewAuthService(cfg Config) (*AuthService, error) {
 	// 失败不阻断启动(dummy 仅优化计时,非安全必需)——但会告警级降级。
 	dummy, _ := credential.HashArgon2("timing-equalization-dummy")
 	return &AuthService{
-		users:       cfg.Users,
-		identities:  cfg.Identities,
-		memberships: cfg.Memberships,
-		issuer:      cfg.Issuer,
-		tickets:     cfg.Tickets,
-		accessTTL:   cfg.AccessTTL,
-		ticketTTL:   ttl,
-		pubKeys:     cfg.PublicKeys,
-		issuerStr:   cfg.IssuerStr,
-		revocation:  cfg.Revocation,
-		dummyHash:   dummy,
+		users:        cfg.Users,
+		identities:   cfg.Identities,
+		memberships:  cfg.Memberships,
+		issuer:       cfg.Issuer,
+		tickets:      cfg.Tickets,
+		accessTTL:    cfg.AccessTTL,
+		ticketTTL:    ttl,
+		pubKeys:      cfg.PublicKeys,
+		issuerStr:    cfg.IssuerStr,
+		revocation:   cfg.Revocation,
+		menuProvider: cfg.MenuProvider,
+		dummyHash:    dummy,
 	}, nil
 }
 
+// resolveMenu 按角色解析菜单树 + 权限码(T6.5-d;MenuProvider 未注入时返回空集,不阻断登录)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - roleCode 选定作用域的角色码(token 选定 membership 的 role;通常单条)。
+//
+// 出参:
+//   - []MenuNode:可见菜单树(**非 nil**,解析失败/未注入时返回 []);
+//   - []string:全量权限点 code 集(**非 nil**,解析失败/未注入时返回 [])。
+//
+// 语义:菜单/权限是"展示层"(拍板),DB 异常不应阻断已签发的 token 下发——
+// 返回空集 + 前端菜单全隐,用户仍可登录(数据层权限由路由白名单/Scoping 兜底)。
+func (s *AuthService) resolveMenu(ctx context.Context, roleCode string) (menu []MenuNode, perms []string) {
+	menu = []MenuNode{}
+	perms = []string{}
+	if s.menuProvider == nil {
+		return menu, perms
+	}
+	if m, err := s.menuProvider.MenuTree(ctx, roleCode); err == nil && m != nil {
+		menu = m
+	}
+	if p, err := s.menuProvider.Permissions(ctx, roleCode); err == nil && p != nil {
+		perms = p
+	}
+	return menu, perms
+}
+
 // ---- 出参 DTO(Service 层领域对象;Handler 层映射为统一响应信封 R<T>)----
 
 // UserView 用户对外视图(不含密码三列——model 已 json:"-" 脱敏,这里再显式收口,防误带出)。
 type UserView struct {
-	ID         string `json:"id"`          // canonical_user_id(ULID)
-	Username   string `json:"username"`    // 用户名(可空)
-	RealName   string `json:"realName"`    // 真实姓名(可空)
-	PersonType string `json:"personType"`  // 业务身份:student/teacher/parent/staff
-	Status     string `json:"status"`      // active/disabled/merged
+	ID         string `json:"id"`         // canonical_user_id(ULID)
+	Username   string `json:"username"`   // 用户名(可空)
+	RealName   string `json:"realName"`   // 真实姓名(可空)
+	PersonType string `json:"personType"` // 业务身份:student/teacher/parent/staff
+	Status     string `json:"status"`     // active/disabled/merged
 }
 
 // MembershipView 一条成员关系(登录第一步② 作用域选择列表项)。
@@ -152,7 +188,7 @@ type MembershipView struct {
 	MembershipID  int64  `json:"membershipId"`
 	InstitutionID int64  `json:"institutionId"`
 	CampusID      int64  `json:"campusId"`
-	Role          string `json:"role"` // 字符串角色码(super_admin/academic/...)
+	Role          string `json:"role"`   // 字符串角色码(super_admin/academic/...)
 	Status        string `json:"status"` // active/inactive
 }
 
@@ -163,7 +199,14 @@ type LoginStep1 struct {
 	Memberships []MembershipView `json:"memberships"` // 可选作用域列表(空 = 该用户暂无机构归属)
 }
 
-// TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户)。
+// TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户 + RBAC 菜单/权限码,T6.5-d 起)。
+//
+// MenuTree / Permissions 由注入的 MenuProvider(rbac.Resolver)按选定角色解析:
+//   - MenuTree:可见菜单树(PC 前端原样渲染,不二次过滤;契约见 rbac.MenuNode);
+//   - Permissions:全量权限点 code 集(含 button,前端 v-perm 依据);
+//
+// 两者**非 nil**(无可见菜单/权限 = [],JSON 序列化为 [] 而非 null);
+// 未注入 MenuProvider 时恒为空集(菜单全隐、按钮全隐,不阻断登录——RBAC 子系统装配失败的兜底)。
 type TokenPair struct {
 	AccessToken   string     `json:"accessToken"`
 	RefreshToken  string     `json:"refreshToken"`
@@ -172,16 +215,38 @@ type TokenPair struct {
 	Roles         []string   `json:"roles"`         // 选定作用域的角色码(通常单条)
 	InstitutionID int64      `json:"institutionId"` // 选定作用域
 	CampusID      int64      `json:"campusId"`
-	Permissions   []string   `json:"permissions"` // 占位:细粒度权限码(RBAC,T6 域补;当前空)
+	MenuTree      []MenuNode `json:"menuTree"`    // 可见菜单树(按选定角色过滤后;PC 原样渲染)
+	Permissions   []string   `json:"permissions"` // 全量权限点 code 集(含 button,v-perm 依据)
+}
+
+// MenuNode 菜单树节点,直接复用 rbac.MenuNode(PC 前端契约的权威定义在 org/rbac 包)。
+// 不在此包重定义:*rbac.Resolver.MenuTree 返回 []rbac.MenuNode,复用同一类型使
+// MenuProvider 接口与生产实现签名严格一致(否则需装配层做类型转换,徒增漂移风险)。
+// 依赖方向说明:identity/service 已 import org/model(登录引用 membership),再 import
+// org/rbac(纯解析器,无 DB/handler 依赖)不破坏分层——rbac 是更下层的展示逻辑。
+type MenuNode = rbac.MenuNode
+
+// MenuProvider 角色 → 权限点 code 集 / 菜单树 解析器(T6.5-d 引入的依赖倒置接口)。
+//
+// 生产实现:*rbac.Resolver(org/rbac 包,TTL 30s 进程内缓存;超管全量、普通查 t_role_permission)。
+// 接口定义在消费方 identity/service(消费方定义接口,供装配层注入 *rbac.Resolver / 单测注入 fake);
+// rbac.Resolver 的方法签名与本接口一致,故 *rbac.Resolver 隐式满足(编译期断言在 main.go)。
+type MenuProvider interface {
+	// Permissions 角色 code 集(超管返回全量;普通角色查矩阵;空集 = 该角色无授权)。
+	Permissions(ctx context.Context, roleCode string) ([]string, error)
+	// MenuTree 角色可见菜单树(button 不进树;父缺失提升顶级;非 nil 切片)。
+	MenuTree(ctx context.Context, roleCode string) ([]rbac.MenuNode, error)
 }
 
-// MeResult 登录态自查出参(/auth/me,菜单刷新恢复用)。
+// MeResult 登录态自查出参(/auth/me,菜单刷新恢复用;T6.5-d 起含 MenuTree/Permissions)。
 type MeResult struct {
 	*UserView
 	Roles         []string         `json:"roles"`
 	InstitutionID int64            `json:"institutionId"` // 当前作用域(来自已验签 claims)
 	CampusID      int64            `json:"campusId"`
 	Memberships   []MembershipView `json:"memberships"`
+	MenuTree      []MenuNode       `json:"menuTree"`    // 可见菜单树(同 TokenPair;菜单刷新恢复)
+	Permissions   []string         `json:"permissions"` // 全量权限点 code 集
 }
 
 // ---- 两步登录 ----
@@ -198,13 +263,14 @@ type MeResult struct {
 // 出参:
 //   - *LoginStep1:验账密成功(含一次性 loginTicket + memberships)。
 //   - error(*AppError):
-//     - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举);
-//     - ErrBadParam(2001):phone 非 11 位(入参格式非法);
-//     - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。
+//   - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举);
+//   - ErrBadParam(2001):phone 非 11 位(入参格式非法);
+//   - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。
 //
 // 时序纪律(防枚举 timing floor):
-//   "账号不存在" 与 "无密码哈希" 路径都会补跑一次 argon2(对 dummyHash),使响应时间与
-//   "存在 + argon2 用户"同量级——稳态(全员惰性升级到 argon2)下不可区分账号是否存在。
+//
+//	"账号不存在" 与 "无密码哈希" 路径都会补跑一次 argon2(对 dummyHash),使响应时间与
+//	"存在 + argon2 用户"同量级——稳态(全员惰性升级到 argon2)下不可区分账号是否存在。
 func (s *AuthService) Login(ctx context.Context, identityType, identifier, password string) (*LoginStep1, error) {
 	ident := s.normalize(identityType, identifier)
 	if identityType == model.IdentityTypePhone && !isValidPhone(ident) {
@@ -306,9 +372,9 @@ func (s *AuthService) Login(ctx context.Context, identityType, identifier, passw
 // 出参:
 //   - *TokenPair:签发成功的凭证对 + 选定作用域 + 用户。
 //   - error(*AppError):
-//     - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①);
-//     - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive);
-//     - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。
+//   - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①);
+//   - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive);
+//   - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。
 //
 // 安全要点:
 //   - 票据**原子消费**(Redis GETDEL):同一 ticket 并发双请求只有一个能签 token(防重放)。
@@ -358,6 +424,7 @@ func (s *AuthService) Issue(ctx context.Context, loginTicket string, institution
 	if err != nil {
 		return nil, apperrors.WithCause(apperrors.ErrDBError, err) // 写 refresh 白名单失败(Redis)等
 	}
+	menu, perms := s.resolveMenu(ctx, m.Role) // T6.5-d:按选定角色下发菜单树 + 权限码(失败→空集,不阻断)
 	return &TokenPair{
 		AccessToken:   access,
 		RefreshToken:  refresh,
@@ -366,7 +433,8 @@ func (s *AuthService) Issue(ctx context.Context, loginTicket string, institution
 		Roles:         []string{m.Role},
 		InstitutionID: institutionID,
 		CampusID:      campusID,
-		Permissions:   []string{}, // RBAC 占位(T6 域补)
+		MenuTree:      menu,
+		Permissions:   perms,
 	}, nil
 }
 
@@ -379,8 +447,8 @@ func (s *AuthService) Issue(ctx context.Context, loginTicket string, institution
 // 出参:
 //   - *MeResult:用户 + 角色 + 当前作用域 + 全部 membership 列表。
 //   - error(*AppError):
-//     - ErrNotFound(3001):claims.Sub 对应用户不存在(token 仍在有效期但用户被删,罕见——前端按登出处理);
-//     - ErrDBError(5002):取用户/成员关系 DB 故障。
+//   - ErrNotFound(3001):claims.Sub 对应用户不存在(token 仍在有效期但用户被删,罕见——前端按登出处理);
+//   - ErrDBError(5002):取用户/成员关系 DB 故障。
 func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, error) {
 	u, err := s.users.GetByCanonicalUserID(ctx, claims.Sub)
 	if err != nil {
@@ -390,12 +458,19 @@ func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, e
 	if err != nil {
 		return nil, err
 	}
+	roleCode := ""
+	if len(claims.Roles) > 0 {
+		roleCode = claims.Roles[0] // 主角色(签发侧保证多角色时主角色在前)
+	}
+	menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:菜单刷新恢复(同 Issue)
 	return &MeResult{
 		UserView:      toUserView(u),
 		Roles:         claims.Roles,
 		InstitutionID: claims.InstitutionID,
 		CampusID:      claims.CampusID,
 		Memberships:   toMembershipViews(mergs),
+		MenuTree:      menu,
+		Permissions:   perms,
 	}, nil
 }
 
@@ -408,8 +483,8 @@ func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, e
 // 出参:
 //   - *TokenPair:新凭证对(claims 从旧 refresh 载荷恢复,role/作用域原样)。
 //   - error(*AppError):
-//     - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录;
-//     - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。
+//   - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录;
+//   - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。
 //
 // 实现说明:Rotate 只回 access+refresh 不回 claims,故轮换后用 token.Verify 回读新 access 的 claims
 // (stateless,与验签中间件同源),再取用户视图——避免在 Issuer 上额外暴露"带 claims 的 Rotate"。
@@ -431,6 +506,11 @@ func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPai
 	if uerr != nil {
 		return nil, uerr // ErrNotFound / ErrDBError
 	}
+	roleCode := ""
+	if len(claims.Roles) > 0 {
+		roleCode = claims.Roles[0]
+	}
+	menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:轮换后同补菜单/权限(claims 原样恢复角色)
 	return &TokenPair{
 		AccessToken:   newAccess,
 		RefreshToken:  newRefresh,
@@ -439,7 +519,8 @@ func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPai
 		Roles:         claims.Roles,
 		InstitutionID: claims.InstitutionID,
 		CampusID:      claims.CampusID,
-		Permissions:   []string{},
+		MenuTree:      menu,
+		Permissions:   perms,
 	}, nil
 }
 

+ 119 - 1
internal/identity/service/auth_service_test.go

@@ -12,8 +12,8 @@ package service
 import (
 	"context"
 	"crypto/md5"
-	stderrors "errors"
 	"encoding/hex"
+	stderrors "errors"
 	"testing"
 
 	"yuansiwei/unified-backend/internal/identity/credential"
@@ -21,6 +21,7 @@ import (
 	"yuansiwei/unified-backend/internal/identity/token"
 	apperrors "yuansiwei/unified-backend/internal/infra/errors"
 	orgmodel "yuansiwei/unified-backend/internal/org/model"
+	"yuansiwei/unified-backend/internal/org/rbac"
 )
 
 // codeOf 取业务错误码(*AppError.Code);非 *AppError 或 nil 时 Fatal(测试 bug)。
@@ -325,6 +326,123 @@ func TestMe_UnknownSub(t *testing.T) {
 	}
 }
 
+// ---- T6.5-d:MenuProvider 注入后 Issue/Me 下发 menuTree + permissions ----
+
+// fakeMenuProvider MenuProvider 测试假实现(按角色返回固定菜单树/权限码集;不连 DB)。
+type fakeMenuProvider struct {
+	codes map[string][]string
+	tree  map[string][]rbac.MenuNode
+}
+
+func (f *fakeMenuProvider) Permissions(_ context.Context, role string) ([]string, error) {
+	if c, ok := f.codes[role]; ok {
+		return c, nil
+	}
+	return []string{}, nil
+}
+func (f *fakeMenuProvider) MenuTree(_ context.Context, role string) ([]rbac.MenuNode, error) {
+	if tr, ok := f.tree[role]; ok {
+		return tr, nil
+	}
+	return []rbac.MenuNode{}, nil
+}
+
+// TestIssue_WithMenuProvider 注入 MenuProvider 后 Issue 出参含 menuTree + permissions(按角色)。
+func TestIssue_WithMenuProvider(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "menuser")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 11, CampusID: 3, Role: "academic", Status: "active",
+	})
+
+	// 注入假解析器:academic 角色 2 权限码 + 1 菜单节点
+	fx.svc.menuProvider = &fakeMenuProvider{
+		codes: map[string][]string{"academic": {"org.institution.list", "org.campus.list"}},
+		tree: map[string][]rbac.MenuNode{
+			"academic": {{Code: "org.institution.list", Label: "机构管理", Path: strPtr("/org/institutions")}},
+		},
+	}
+
+	step1, err := fx.svc.Login(ctx, model.IdentityTypeUsername, "menuser", "pw")
+	if err != nil {
+		t.Fatalf("Login: %v", err)
+	}
+	pair, err := fx.svc.Issue(ctx, step1.LoginTicket, 11, 3)
+	if err != nil {
+		t.Fatalf("Issue: %v", err)
+	}
+	if len(pair.Permissions) != 2 || pair.Permissions[0] != "org.institution.list" {
+		t.Fatalf("Issue.Permissions=%v(期望 2 个 org 权限码)", pair.Permissions)
+	}
+	if len(pair.MenuTree) != 1 || pair.MenuTree[0].Code != "org.institution.list" {
+		t.Fatalf("Issue.MenuTree=%+v(期望 1 个机构管理菜单)", pair.MenuTree)
+	}
+}
+
+// TestMe_WithMenuProvider 注入 MenuProvider 后 Me 出参含 menuTree + permissions(菜单刷新恢复)。
+func TestMe_WithMenuProvider(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "memuuser")
+	fx.svc.menuProvider = &fakeMenuProvider{
+		codes: map[string][]string{"super_admin": {"org.institution.list", "org.campus.list", "sys.menu"}},
+		tree: map[string][]rbac.MenuNode{
+			"super_admin": {
+				{Code: "org.institution.list", Label: "机构管理"},
+				{Code: "org.campus.list", Label: "校区管理"},
+			},
+		},
+	}
+
+	claims := token.Claims{Sub: uid, Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0}
+	me, err := fx.svc.Me(ctx, claims)
+	if err != nil {
+		t.Fatalf("Me: %v", err)
+	}
+	if len(me.Permissions) != 3 {
+		t.Fatalf("Me.Permissions=%v(超管期望 3 个权限码)", me.Permissions)
+	}
+	if len(me.MenuTree) != 2 {
+		t.Fatalf("Me.MenuTree=%+v(超管期望 2 个顶级菜单)", me.MenuTree)
+	}
+}
+
+// TestIssue_NilMenuProvider MenuProvider 未注入(默认 fixture):menuTree/permissions 非 nil 空集(不阻断)。
+func TestIssue_NilMenuProvider(t *testing.T) {
+	fx := newFixture(t) // 不注入 menuProvider
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "nilmenu")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
+	})
+	step1, _ := fx.svc.Login(ctx, model.IdentityTypeUsername, "nilmenu", "pw")
+	pair, err := fx.svc.Issue(ctx, step1.LoginTicket, 11, 3)
+	if err != nil {
+		t.Fatalf("Issue: %v", err)
+	}
+	if pair.MenuTree == nil || pair.Permissions == nil {
+		t.Fatalf("MenuTree/Permissions 必须非 nil(实际 %v / %v),空集应是 [] 而非 null", pair.MenuTree, pair.Permissions)
+	}
+	if len(pair.MenuTree) != 0 || len(pair.Permissions) != 0 {
+		t.Fatalf("nil MenuProvider 时 menuTree/permissions 应空集(实际 %d / %d)", len(pair.MenuTree), len(pair.Permissions))
+	}
+}
+
+// strPtr 字符串指针辅助(测试用)。
+func strPtr(s string) *string { return &s }
+
 // ---- Refresh ----
 
 // TestRefresh_Happy 用有效 refresh 轮换 → 新 TokenPair。

+ 288 - 5
internal/org/handler/org_e2e_test.go

@@ -26,12 +26,14 @@
 package handler
 
 import (
+	"bytes"
 	"context"
 	"crypto/rand"
 	"crypto/rsa"
 	"crypto/x509"
 	"encoding/json"
 	"encoding/pem"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -48,10 +50,15 @@ import (
 	infra "yuansiwei/unified-backend/internal/infra/db"
 	appredis "yuansiwei/unified-backend/internal/infra/redis"
 	"yuansiwei/unified-backend/internal/org/model"
+	"yuansiwei/unified-backend/internal/org/rbac"
+	orgRepo "yuansiwei/unified-backend/internal/org/repository"
 	orgservice "yuansiwei/unified-backend/internal/org/service"
 )
 
-// orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由)。
+// ptrStr 字符串指针辅助(e2e 测试用,构造可空字段的指针值)。
+func ptrStr(s string) *string { return &s }
+
+// orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由 + RBAC 解析器)。
 type orgEnv struct {
 	t          *testing.T
 	app        *fiber.App
@@ -59,6 +66,70 @@ type orgEnv struct {
 	revocation *appredis.RevocationStore
 	gdb        *gorm.DB
 	issuer     *token.Issuer
+	resolver   *rbac.Resolver // T6.5-d:真 DB 菜单树/权限码解析(超管配置端点 + menuTree 下发 e2e)
+	rbacH      *RBACHandler   // T6.5-e:超管矩阵配置端点(/org/role-permissions/:roleCode)
+}
+
+// newE2eResolver 构造真 DB 的 RBAC 解析器(平台级 PermissionRepo + TTL 0=不缓存,测试每次重读)。
+func newE2eResolver(gdb *gorm.DB) *rbac.Resolver {
+	return rbac.NewResolver(orgRepo.NewPermissionRepo(gdb), 0)
+}
+
+// seedRBAC 落最小集权限点 + 角色矩阵(超管配置端点 + menuTree 下发 e2e 用)。
+//   - 权限点:org.institution.list(menu)/ org.institution.get(button)/ org.campus.list(menu)/
+//     org.campus.get(button),对齐 core-model-ddl.sql v1.1 最小集种子;
+//   - 矩阵:academic 拥有 4 个 org 权限点(菜单 2 + 按钮 2)。
+func (e *orgEnv) seedRBAC() {
+	e.t.Helper()
+	ctx := context.Background()
+	now := time.Now()
+	must := func(err error, what string) {
+		if err != nil {
+			e.t.Fatalf("%s: %v", what, err)
+		}
+	}
+	instList := "org.institution.list"
+	instGet := "org.institution.get"
+	campusList := "org.campus.list"
+	campusGet := "org.campus.get"
+	must(e.gdb.WithContext(ctx).Create(&model.Permission{
+		Code: instList, Type: model.PermissionTypeMenu, Name: "机构管理", Path: ptrStr("/org/institutions"), Sort: 1, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入权限 org.institution.list")
+	must(e.gdb.WithContext(ctx).Create(&model.Permission{
+		Code: instGet, Type: model.PermissionTypeButton, Name: "机构详情", ParentCode: ptrStr(instList), Sort: 2, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入权限 org.institution.get")
+	must(e.gdb.WithContext(ctx).Create(&model.Permission{
+		Code: campusList, Type: model.PermissionTypeMenu, Name: "校区管理", Path: ptrStr("/org/campuses"), Sort: 3, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入权限 org.campus.list")
+	must(e.gdb.WithContext(ctx).Create(&model.Permission{
+		Code: campusGet, Type: model.PermissionTypeButton, Name: "校区详情", ParentCode: ptrStr(campusList), Sort: 4, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
+	}).Error, "插入权限 org.campus.get")
+	// 矩阵:academic 角色拥有全部 4 个 org 权限点
+	repo := orgRepo.NewPermissionRepo(e.gdb)
+	must(repo.SaveRolePermissions(ctx, "academic", []string{instList, instGet, campusList, campusGet}), "配置 academic 矩阵")
+}
+
+// seedRoles 落 t_role 角色字典行(T6.5-e 超管配置端点的 validateRole 依赖 t_role 字典存在性)。
+// 至少落 super_admin + academic(矩阵配置/读取的目标角色;其余角色按需)。
+func (e *orgEnv) seedRoles(codes ...string) {
+	e.t.Helper()
+	ctx := context.Background()
+	now := time.Now()
+	must := func(err error, what string) {
+		if err != nil {
+			e.t.Fatalf("%s: %v", what, err)
+		}
+	}
+	for _, code := range codes {
+		scopeLevel := model.ScopeLevelInstitution
+		if code == model.RoleSuperAdmin {
+			scopeLevel = model.ScopeLevelPlatform
+		}
+		must(e.gdb.WithContext(ctx).Create(&model.RoleModel{
+			RoleCode: code, RoleName: code, ScopeLevel: scopeLevel, LoginAllowed: code != model.RoleMarket,
+			CreatedAt: now,
+		}).Error, "插入角色 "+code)
+	}
 }
 
 // orgE2eSetup 建真库 + AutoMigrate(t_institution/t_campus) + miniredis + 真 Issuer + OrgService + /org 路由。
@@ -73,10 +144,16 @@ func orgE2eSetup(t *testing.T) *orgEnv {
 		t.Fatalf("NewDB: %v", err)
 	}
 	t.Cleanup(func() { _ = infra.Close(gdb) })
-	if err := infra.Migrate(gdb, &model.Institution{}, &model.Campus{}); err != nil {
+	// 迁移 org 域全 7 表(含 RBAC:t_permission/t_role_permission,T6.5-d 路由白名单 + 菜单树 e2e 用)
+	if err := infra.Migrate(gdb, model.AllTables()...); err != nil {
 		t.Fatalf("Migrate: %v", err)
 	}
-	t.Cleanup(func() { _ = gdb.Migrator().DropTable("t_institution", "t_campus") })
+	t.Cleanup(func() {
+		_ = gdb.Migrator().DropTable(
+			"t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link",
+			"t_permission", "t_role_permission",
+		)
+	})
 
 	mr, err := miniredis.Run()
 	if err != nil {
@@ -104,6 +181,16 @@ func orgE2eSetup(t *testing.T) *orgEnv {
 	if err != nil {
 		t.Fatalf("org handler New: %v", err)
 	}
+	// 共享解析器(TTL 0=不缓存,每次重读真 DB):供 T6.5-d menuTree 断言 + T6.5-e 保存后 Invalidate。
+	resolver := newE2eResolver(gdb)
+	rbacSvc, err := orgservice.NewRolePermissionService(gdb, orgRepo.NewPermissionRepo(gdb), resolver)
+	if err != nil {
+		t.Fatalf("NewRolePermissionService: %v", err)
+	}
+	rbacH, err := NewRBAC(RBACConfig{Svc: rbacSvc})
+	if err != nil {
+		t.Fatalf("NewRBAC: %v", err)
+	}
 	mw, err := idhandler.NewAuth(idhandler.AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"})
 	if err != nil {
 		t.Fatalf("NewAuth: %v", err)
@@ -111,13 +198,21 @@ func orgE2eSetup(t *testing.T) *orgEnv {
 
 	app := fiber.New(fiber.Config{DisableStartupMessage: true})
 	org := app.Group("/org")
-	org.Use(mw)
+	// 验签 + 路由角色白名单(T6.5-d 接口强制层):org 目录允许 staff 角色(academic/finance/teacher),
+	// 业务级(student/parent)拒绝 1003,超管经 SuperAdmin 恒放行。whitelist 与 main.go 生产路由一致。
+	org.Use(mw, idhandler.RequireRole("academic", "finance", "teacher"))
 	org.Get("/institutions", h.Institutions)
 	org.Get("/institutions/:institutionId", h.Institution)
 	org.Get("/campuses", h.Campuses)
 	org.Get("/campuses/:campusId", h.Campus)
 
-	return &orgEnv{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, issuer: issuer}
+	// RBAC 矩阵配置(T6.5-e):独立兄弟组 + 超管白名单(与 main.go 一致;非超管 1003)。
+	rbacCfg := app.Group("/org/role-permissions")
+	rbacCfg.Use(mw, idhandler.RequireRole("super_admin"))
+	rbacCfg.Get("/:roleCode", rbacH.RolePermissions)
+	rbacCfg.Put("/:roleCode", rbacH.SaveRolePermissions)
+
+	return &orgEnv{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, issuer: issuer, resolver: resolver, rbacH: rbacH}
 }
 
 // genOrgRSAKey 生成 RS2048 私钥 + 公钥 PEM(本包私有,避免与身份域测试 helper 重名冲突)。
@@ -196,6 +291,32 @@ func (e *orgEnv) get(path, bearer string) *env {
 	return &en
 }
 
+// put 带 Bearer 头 PUT(JSON 请求体)并解信封(T6.5-e 矩阵保存用;HTTP 恒 200,断 body.code)。
+// body 为请求体 JSON 字节(nil 时不发 body = 清空矩阵)。
+func (e *orgEnv) put(path, bearer string, body []byte) *env {
+	e.t.Helper()
+	var reader io.Reader
+	if body != nil {
+		reader = bytes.NewReader(body)
+	}
+	req := httptest.NewRequest(http.MethodPut, path, reader)
+	if body != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	if bearer != "" {
+		req.Header.Set("Authorization", "Bearer "+bearer)
+	}
+	resp, err := e.app.Test(req, -1)
+	if err != nil {
+		e.t.Fatalf("PUT %s 请求失败: %v", path, err)
+	}
+	var en env
+	if err := json.NewDecoder(resp.Body).Decode(&en); err != nil {
+		e.t.Fatalf("PUT %s 解析响应: %v", path, err)
+	}
+	return &en
+}
+
 // instList 从 PageData 解机构列表(institutionId 集合)。
 func instList(t *testing.T, data json.RawMessage) []int64 {
 	t.Helper()
@@ -323,3 +444,165 @@ func TestOrgE2E_ScopeMissingID(t *testing.T) {
 		t.Fatalf("教务跨机构 code=%d(期望 1004)", r.Code)
 	}
 }
+
+// TestOrgE2E_RouteRoleWhitelist 路由角色白名单(T6.5-d 接口强制层):
+// 白名单 [academic, finance, teacher] + 超管放行:
+//   - student(业务级,不在白名单)→ 1003(即使有有效 token);
+//   - teacher(在白名单)→ 200(正常放行,进入数据层 scoping);
+//   - super_admin(超管恒放行)→ 200(不查白名单)。
+//
+// 验证"已登录但角色不符"被中间件拦在数据层之前(code 1003 而非 1004/5002)。
+func TestOrgE2E_RouteRoleWhitelist(t *testing.T) {
+	e := orgE2eSetup(t)
+	e.seedOrg()
+
+	// student 业务级角色:有有效 token 但角色不在 /org 白名单 → 1003
+	student := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"student"}, InstitutionID: 11, CampusID: 3,
+	})
+	if r := e.get("/org/institutions", student); r.Code != 1003 {
+		t.Fatalf("student 访问 /org/institutions code=%d(期望 1003 无权限,路由白名单拦截)", r.Code)
+	}
+	if r := e.get("/org/campuses", student); r.Code != 1003 {
+		t.Fatalf("student 访问 /org/campuses code=%d(期望 1003)", r.Code)
+	}
+
+	// teacher 在白名单:正常放行(进入数据层 scoping,返回本机构/校区)
+	teacher := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
+	})
+	if r := e.get("/org/institutions", teacher); r.Code != 200 {
+		t.Fatalf("teacher(白名单内)访问 /org/institutions code=%d(期望 200)", r.Code)
+	}
+
+	// super_admin:超管恒放行(不查白名单)
+	super := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
+	})
+	if r := e.get("/org/institutions", super); r.Code != 200 {
+		t.Fatalf("super_admin 访问 /org/institutions code=%d(期望 200 超管放行)", r.Code)
+	}
+}
+
+// TestOrgE2E_MenuTreeRealDB 真 DB 菜单树/权限码解析(T6.5-d 展示层 e2e):
+// 种最小集权限点 + academic 矩阵后,Resolver 解析 academic 角色 →
+//   - Permissions = 4 个 code(2 菜单 + 2 按钮);
+//   - MenuTree = 2 个顶级 menu 节点(button 不进树)。
+//
+// 验证 PermissionRepo + rbac.Resolver 在真 GORM 仓储下联动正确。
+func TestOrgE2E_MenuTreeRealDB(t *testing.T) {
+	e := orgE2eSetup(t)
+	e.seedRBAC()
+	ctx := context.Background()
+
+	codes, err := e.resolver.Permissions(ctx, "academic")
+	if err != nil {
+		t.Fatalf("Permissions: %v", err)
+	}
+	if len(codes) != 4 {
+		t.Fatalf("academic Permissions=%v(期望 4 个 org 权限码)", codes)
+	}
+
+	tree, err := e.resolver.MenuTree(ctx, "academic")
+	if err != nil {
+		t.Fatalf("MenuTree: %v", err)
+	}
+	if len(tree) != 2 {
+		t.Fatalf("academic MenuTree 顶级=%v(期望 2:机构/校区,button 不进树)", tree)
+	}
+	// 超管全量:同样 4 权限码(代码层放行,不查矩阵)
+	superCodes, err := e.resolver.Permissions(ctx, "super_admin")
+	if err != nil {
+		t.Fatalf("Permissions(super_admin): %v", err)
+	}
+	if len(superCodes) != 4 {
+		t.Fatalf("super_admin Permissions=%v(期望全量 4 个)", superCodes)
+	}
+}
+
+// TestOrgE2E_RolePermConfig 超管角色-权限点矩阵配置端点(T6.5-e 配置后台 e2e):
+//   - 白名单:非超管(academic)访问 /org/role-permissions/academic → 1003;
+//   - 读取:超管 GET /org/role-permissions/academic → 200,assigned 含 4 个 org 权限码 + dictionary 4 项;
+//   - 保存:超管 PUT 全量替换 academic 矩阵为 2 码 → 200;GET 回读 assigned=2 码(保存即全量替换);
+//   - 校验:PUT 含未知权限码 → 2001;PUT 非法 roleCode → 2001。
+//
+// 验证 RolePermissionService 的 validateRole/validateCodes/SaveRolePermissions +
+// RBACHandler 的路由白名单 + 真 DB 删旧插新事务 + 保存后 Invalidate 联动。
+func TestOrgE2E_RolePermConfig(t *testing.T) {
+	e := orgE2eSetup(t)
+	e.seedRBAC()
+	e.seedRoles("academic", "super_admin")
+
+	super := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
+	})
+	academic := e.issue(token.Claims{
+		Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"academic"}, InstitutionID: 11, CampusID: 0,
+	})
+
+	// ① 非超管访问配置端点 → 1003(路由白名单拦截,非 200/1004)
+	if r := e.get("/org/role-permissions/academic", academic); r.Code != 1003 {
+		t.Fatalf("academic 访问配置端点 code=%d(期望 1003 无权限)", r.Code)
+	}
+	if r := e.put("/org/role-permissions/academic", academic, []byte(`{"permissions":["org.campus.list"]}`)); r.Code != 1003 {
+		t.Fatalf("academic 保存配置 code=%d(期望 1003 无权限)", r.Code)
+	}
+
+	// ② 超管读取 academic 矩阵:assigned 4 码 + dictionary 4 项
+	got := e.get("/org/role-permissions/academic", super)
+	if got.Code != 200 {
+		t.Fatalf("超管读 academic 矩阵 code=%d msg=%s(期望 200)", got.Code, got.Message)
+	}
+	var view struct {
+		RoleCode   string   `json:"roleCode"`
+		Assigned   []string `json:"assigned"`
+		Dictionary []struct {
+			Code string `json:"code"`
+		} `json:"dictionary"`
+	}
+	if err := json.Unmarshal(got.Data, &view); err != nil {
+		t.Fatalf("解析 RolePermissionView: %v", err)
+	}
+	if view.RoleCode != "academic" || len(view.Assigned) != 4 || len(view.Dictionary) != 4 {
+		t.Fatalf("RolePermissionView 不符:roleCode=%q assigned=%d dict=%d(期望 academic/4/4)",
+			view.RoleCode, len(view.Assigned), len(view.Dictionary))
+	}
+
+	// ③ 超管保存:全量替换 academic 为 2 码(org.institution.list + org.campus.list)
+	body, _ := json.Marshal(map[string][]string{
+		"permissions": {"org.institution.list", "org.campus.list"},
+	})
+	if r := e.put("/org/role-permissions/academic", super, body); r.Code != 200 {
+		t.Fatalf("超管保存 academic 矩阵 code=%d msg=%s(期望 200)", r.Code, r.Message)
+	}
+	// 回读:assigned 应为 2 码(全量替换语义;原 4 码被删)
+	got2 := e.get("/org/role-permissions/academic", super)
+	if got2.Code != 200 {
+		t.Fatalf("保存后回读 code=%d(期望 200)", got2.Code)
+	}
+	var view2 struct {
+		Assigned []string `json:"assigned"`
+	}
+	if err := json.Unmarshal(got2.Data, &view2); err != nil {
+		t.Fatalf("解析回读: %v", err)
+	}
+	if len(view2.Assigned) != 2 {
+		t.Fatalf("保存后 assigned=%v(期望 2 码,全量替换)", view2.Assigned)
+	}
+	// Resolver 经 Invalidate 后重读:academic 权限码同步为 2 个(本实例缓存已清)
+	if codes, _ := e.resolver.Permissions(context.Background(), "academic"); len(codes) != 2 {
+		t.Fatalf("Invalidate 后 resolver.Permissions(academic)=%v(期望 2 码)", codes)
+	}
+
+	// ④ 校验:含未知权限码 → 2001
+	badBody, _ := json.Marshal(map[string][]string{
+		"permissions": {"org.institution.list", "not.exist.code"},
+	})
+	if r := e.put("/org/role-permissions/academic", super, badBody); r.Code != 2001 {
+		t.Fatalf("含未知权限码 code=%d(期望 2001 参数错误)", r.Code)
+	}
+	// 校验:非法 roleCode → 2001
+	if r := e.put("/org/role-permissions/ghost_role", super, body); r.Code != 2001 {
+		t.Fatalf("非法 roleCode code=%d(期望 2001 角色码非法)", r.Code)
+	}
+}

+ 93 - 0
internal/org/handler/rbac_handler.go

@@ -0,0 +1,93 @@
+// RBAC 角色-权限点矩阵配置端点(T6.5-e,超管可配置后台,2026-11-06 用户拍板 #2)。
+//
+// 端点(平台级,不带多租户作用域;访问入口由路由角色白名单 RequireRole("super_admin") 限定):
+//   - GET /org/role-permissions/:roleCode   查某角色当前矩阵 + 全量权限点字典(超管配置页渲染勾选树)
+//   - PUT /org/role-permissions/:roleCode   全量保存某角色矩阵(删旧插新;保存后清本实例 RBAC 缓存)
+//
+// 安全模型(RBAC 三层第②层的"配置侧"):
+//   - 路由挂 RequireRole("super_admin"):仅平台超管可读写(其他角色 1003;超管恒放行);
+//   - 服务层校验 roleCode ∈ t_role 字典 + 权限码 ∈ t_permission 字典(防保存脏 code 污染矩阵);
+//   - 保存成功后 Resolver.Invalidate() 清本实例缓存(跨实例靠 TTL 30s 自然一致)。
+//
+// 与同包 handler.Handler(机构/校区只读)的分工:本 handler 面向平台级 RBAC 字典
+// (t_role / t_permission / t_role_permission),**不走 scope**(无机构/校区过滤——
+// 全局配置与租户无关,越权防线完全由路由角色白名单承担)。
+package handler
+
+import (
+	"encoding/json"
+
+	"github.com/gofiber/fiber/v2"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+	"yuansiwei/unified-backend/internal/org/service"
+)
+
+// RBACHandler RBAC 矩阵配置 HTTP 处理器(启动装配一次,全进程共享)。
+//
+// 字段:
+//   - svc:RBAC 矩阵配置服务(List/Save 编排,见 org/service.rbac_service.go)。
+type RBACHandler struct {
+	svc *service.RolePermissionService
+}
+
+// RBACConfig RBAC handler 装配参数(T6.5-e main 按此构造)。
+type RBACConfig struct {
+	// Svc RBAC 矩阵配置服务(必填,nil 时 NewRBAC 报错 fail-fast)。
+	Svc *service.RolePermissionService
+}
+
+// NewRBAC 构造 RBAC 矩阵配置处理器(fail-fast:Svc 未注入不允许挂载端点)。
+//
+// 出参:(*RBACHandler, error)——Svc 为 nil 时 error(装配层 fail-fast)。
+func NewRBAC(cfg RBACConfig) (*RBACHandler, error) {
+	if cfg.Svc == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "org rbac handler: RolePermissionService 未注入")
+	}
+	return &RBACHandler{svc: cfg.Svc}, nil
+}
+
+// saveRolePermReq PUT /org/role-permissions/:roleCode 请求体。
+//
+// 字段:Permissions []string——目标全量权限点 code 集(可为空 = 清空该角色矩阵;
+// 每项须存在于 t_permission;空切片/缺失均合法)。
+type saveRolePermReq struct {
+	Permissions []string `json:"permissions"`
+}
+
+// RolePermissions 查某角色的矩阵 + 全量权限点字典(GET /org/role-permissions/:roleCode,超管)。
+//
+// 出参(成功):code 200,data 为 service.RolePermissionView
+//
+//	{roleCode, assigned[], dictionary[]}(assigned=该角色当前权限码;dictionary=全量 active 权限点,前端构树勾选)。
+//
+// 出参(失败):code 2001(roleCode 不在 t_role 字典)/ 1003(非超管,RequireRole 拦截)/ 5002(DB)。
+func (h *RBACHandler) RolePermissions(c *fiber.Ctx) error {
+	roleCode := c.Params("roleCode")
+	view, err := h.svc.ListRolePermissions(c.Context(), roleCode)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, view)
+}
+
+// SaveRolePermissions 全量保存某角色的权限点矩阵(PUT /org/role-permissions/:roleCode,超管)。
+//
+// 入参:请求体 {permissions: ["域.资源.动作", ...]}(可空 = 清空;每项须存在于 t_permission)。
+// 出参(成功):code 200,data 省略(操作成功无数据体);保存后清本实例 RBAC 缓存。
+// 出参(失败):code 2001(roleCode 非法 / 含未知权限码 / 请求体解析失败)/ 1003(非超管)/ 5002(DB 含事务回滚)。
+func (h *RBACHandler) SaveRolePermissions(c *fiber.Ctx) error {
+	var req saveRolePermReq
+	// 请求体可空(= 清空矩阵);非空但非 JSON 时校验失败 → 2002
+	if len(c.Body()) > 0 {
+		if err := json.Unmarshal(c.Body(), &req); err != nil {
+			return response.Fail(c, apperrors.New(apperrors.CodeBadFormat, "请求体 JSON 解析失败"))
+		}
+	}
+	roleCode := c.Params("roleCode")
+	if err := h.svc.SaveRolePermissions(c.Context(), roleCode, req.Permissions); err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, nil)
+}

+ 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(切片)
+	})
+}

+ 157 - 0
internal/org/service/rbac_service.go

@@ -0,0 +1,157 @@
+// RBAC 角色-权限点矩阵配置服务(T6.5-e,RBAC 三层模型的"超管配置后台",2026-11-06 用户拍板 #2)。
+//
+// 职责(平台级,不带多租户作用域——角色权限矩阵是全局配置,与机构/校区无关):
+//   - 查某角色当前矩阵 + 全量权限点字典(超管配置页渲染勾选树用);
+//   - 全量保存某角色矩阵(删旧插新事务;校验 roleCode 存在于 t_role + 权限码存在于 t_permission);
+//   - 保存成功后调 rbac.Resolver.Invalidate() 清本实例缓存(跨实例靠 TTL 30s 自然过期)。
+//
+// 与 OrgService 的分工:OrgService 面向机构/校区(随请求注入 *scope.Scope,强制 Scoping);
+// 本服务面向 RBAC 平台级字典(t_role / t_permission / t_role_permission),无作用域过滤——
+// 多租户防线不在本层(路由角色白名单 RequireRole("super_admin") 是唯一访问入口,见 handler)。
+//
+// 依赖方向:service → org/model + org/repository(PermissionRepo) + org/rbac(Resolver) +
+// infra/errors(全下层/同域),不依赖 handler/main。
+package service
+
+import (
+	"context"
+
+	"gorm.io/gorm"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/org/model"
+	"yuansiwei/unified-backend/internal/org/rbac"
+	"yuansiwei/unified-backend/internal/org/repository"
+)
+
+// RolePermissionView 角色-权限点矩阵查询出参(超管配置页渲染依据)。
+type RolePermissionView struct {
+	RoleCode   string             `json:"roleCode"`   // 角色码(回显,t_role 字典值)
+	Assigned   []string           `json:"assigned"`   // 该角色当前拥有的权限点 code(升序去重;非 nil)
+	Dictionary []model.Permission `json:"dictionary"` // 全量 active 权限点(非 nil;前端按 parent_code 构树做勾选)
+}
+
+// RolePermissionService RBAC 矩阵配置服务(启动装配一次,全进程共享)。
+type RolePermissionService struct {
+	db       *gorm.DB
+	permRepo *repository.PermissionRepo
+	resolver *rbac.Resolver // 保存成功后调 Invalidate 清本实例缓存(nil 时跳过)
+}
+
+// NewRolePermissionService 构造 RBAC 矩阵配置服务(fail-fast:db/permRepo 必填)。
+//
+// 入参:
+//   - db *gorm.DB:数据库句柄(必填,用于 t_role 角色存在性校验——平台级直查,无作用域);
+//   - permRepo *repository.PermissionRepo:权限点/矩阵仓储(必填);
+//   - resolver *rbac.Resolver:RBAC 解析器(可空;nil 时保存后不清缓存——纯单测/无缓存场景,
+//     生产装配必给,否则超管改配最长 30s 才生效)。
+func NewRolePermissionService(db *gorm.DB, permRepo *repository.PermissionRepo, resolver *rbac.Resolver) (*RolePermissionService, error) {
+	if db == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "rbac service: DB 未注入")
+	}
+	if permRepo == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "rbac service: PermissionRepo 未注入")
+	}
+	return &RolePermissionService{db: db, permRepo: permRepo, resolver: resolver}, nil
+}
+
+// ListRolePermissions 查某角色的矩阵 + 全量权限点字典。
+//
+// 入参:ctx 请求上下文;roleCode 角色码(t_role 字典值)。
+// 出参:
+//   - *RolePermissionView:角色矩阵 + 字典(非 nil 切片)。
+//   - error:ErrBadParam(2001) roleCode 不在 t_role 字典 / ErrDBError(5002)。
+func (s *RolePermissionService) ListRolePermissions(ctx context.Context, roleCode string) (*RolePermissionView, error) {
+	if err := s.validateRole(ctx, roleCode); err != nil {
+		return nil, err
+	}
+	assigned, err := s.permRepo.RolePermissionCodes(ctx, roleCode)
+	if err != nil {
+		return nil, err
+	}
+	dictionary, err := s.permRepo.AllActive(ctx)
+	if err != nil {
+		return nil, err
+	}
+	return &RolePermissionView{RoleCode: roleCode, Assigned: assigned, Dictionary: dictionary}, nil
+}
+
+// SaveRolePermissions 全量保存某角色的权限点矩阵(删旧插新;保存后清本实例缓存)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - roleCode 角色码(须存在于 t_role 字典);
+//   - codes []string:目标全量权限点集合(可为空 = 清空该角色矩阵;每项须存在于 t_permission)。
+//
+// 出参:
+//   - error:ErrBadParam(2001) roleCode 非法 / 含未知权限码 / ErrDBError(5002)(含事务回滚)。
+//
+// 语义:保存即全量替换(前端"取消全部勾选"合法 = 传空集);幂等(目标与原矩阵一致时等价于原样重插)。
+// 保存成功后调 resolver.Invalidate() 清本实例缓存,使超管改配立即对本实例生效(跨实例靠 TTL 30s)。
+func (s *RolePermissionService) SaveRolePermissions(ctx context.Context, roleCode string, codes []string) error {
+	if err := s.validateRole(ctx, roleCode); err != nil {
+		return err
+	}
+	if err := s.validateCodes(ctx, codes); err != nil {
+		return err
+	}
+	if err := s.permRepo.SaveRolePermissions(ctx, roleCode, codes); err != nil {
+		return err
+	}
+	if s.resolver != nil {
+		s.resolver.Invalidate() // 清本实例缓存(跨实例不广播,TTL 30s 自然一致)
+	}
+	return nil
+}
+
+// validateRole 校验 roleCode 存在于 t_role 字典(平台级直查,无作用域)。
+//
+// 出参:error——ErrBadParam(2001) 角色码非法 / ErrDBError(5002)。
+func (s *RolePermissionService) validateRole(ctx context.Context, roleCode string) error {
+	if roleCode == "" {
+		return apperrors.ErrBadParam
+	}
+	var count int64
+	if err := s.db.WithContext(ctx).Model(&model.RoleModel{}).
+		Where("role_code = ?", roleCode).
+		Count(&count).Error; err != nil {
+		return apperrors.WithCause(apperrors.ErrDBError, err)
+	}
+	if count == 0 {
+		return apperrors.New(apperrors.CodeBadParam, "角色码非法(不在 t_role 字典)")
+	}
+	return nil
+}
+
+// validateCodes 校验权限码全部存在于 t_permission(防"保存未知 code"污染矩阵)。
+//
+// 语义:空集合法(清空矩阵);非空时要求每项都在 t_permission(任意 status——
+// 允许预置 disabled 权限点,待其 active 后自然显现,不强制必须 active)。
+// 出参:error——ErrBadParam(2001) 含未知权限码 / ErrDBError(5002)。
+func (s *RolePermissionService) validateCodes(ctx context.Context, codes []string) error {
+	if len(codes) == 0 {
+		return nil // 清空语义(合法)
+	}
+	// 去重(避免重复 code 干扰 count 比对)
+	seen := make(map[string]struct{}, len(codes))
+	unique := make([]string, 0, len(codes))
+	for _, c := range codes {
+		if c == "" {
+			return apperrors.New(apperrors.CodeBadParam, "权限码不能为空字符串")
+		}
+		if _, ok := seen[c]; !ok {
+			seen[c] = struct{}{}
+			unique = append(unique, c)
+		}
+	}
+	var count int64
+	if err := s.db.WithContext(ctx).Model(&model.Permission{}).
+		Where("code IN ?", unique).
+		Count(&count).Error; err != nil {
+		return apperrors.WithCause(apperrors.ErrDBError, err)
+	}
+	if count != int64(len(unique)) {
+		return apperrors.New(apperrors.CodeBadParam, "含未知权限码(不在 t_permission 字典)")
+	}
+	return nil
+}