Эх сурвалжийг харах

feat(org): T6.5-e 超管角色-权限点矩阵配置端点 + 缓存联动

- service: RolePermissionService(平台级,不走 scoping)List/Save;
  validateRole(∈t_role)+validateCodes(∈t_permission) 防脏 code 污染矩阵;
  保存后 Resolver.Invalidate() 清本实例缓存(跨实例靠 TTL 30s)
- handler: RBACHandler(GET/PUT /org/role-permissions/:roleCode)+
  saveRolePermReq(空 body=清空矩阵;非 JSON→2002)
- main:独立兄弟组 /org/role-permissions 挂 RequireRole("super_admin")
  (非超管 1003,超管恒放行;与 /org staff 白名单组并列不继承)
- e2e:TestOrgE2E_RolePermConfig(非超管 1003 / 读取 4码+字典 /
  全量替换 4→2 码 / 含未知码 2001 / 非法 roleCode 2001 / Invalidate 联动)

build/vet/test 全绿(DB-gated e2e 未设 DSN 时 skip)。
Q 3 өдөр өмнө
parent
commit
cef0d125ae

+ 21 - 0
cmd/api/main.go

@@ -194,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",
@@ -243,6 +255,15 @@ func main() {
 	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)            // 公开

+ 156 - 1
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"
@@ -65,6 +67,7 @@ type orgEnv struct {
 	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=不缓存,测试每次重读)。
@@ -106,6 +109,29 @@ func (e *orgEnv) seedRBAC() {
 	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 路由。
 func orgE2eSetup(t *testing.T) *orgEnv {
 	t.Helper()
@@ -155,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)
@@ -170,7 +206,13 @@ func orgE2eSetup(t *testing.T) *orgEnv {
 	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, resolver: newE2eResolver(gdb)}
+	// 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 重名冲突)。
@@ -249,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()
@@ -451,3 +519,90 @@ func TestOrgE2E_MenuTreeRealDB(t *testing.T) {
 		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)
+}

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