Quellcode durchsuchen

feat(academic): AC-14 第一批路由接线(40 端点)+ RBAC 种子

Q vor 12 Stunden
Ursprung
Commit
c1b8e47825

+ 21 - 9
cmd/api/main.go

@@ -11,10 +11,15 @@
 //     公开路由须在 Auth Group 之前——铁律 8 路由纪律);
 //  7. 任务工作池启动与优雅退出(task 域,GracePeriodSec 内排空在途任务,§3.7)。
 //
-// 当前状态:identity 模块(两步登录 / me / refresh / logout)已装配(T4-6),
-// 含 DB 连接 + 核心表 AutoMigrate(guarded:仅 UNIFIED_AUTO_MIGRATE=1 时执行,生产幂等不漂移)、
-// Redis 连接 + token 存储 + RS256 签发器 + 仓储 + AuthService + Handler + 验签中间件 + /auth/* 路由。
-// 其余域(org/academic/teaching/ai)尚未实现,域模块就绪后在"业务域路由挂载点"注册。
+// 当前状态:
+//   - identity 模块(两步登录 / me / refresh / logout)已装配(T4-6),含 DB 连接 + 核心表 AutoMigrate
+//     (guarded:仅 UNIFIED_AUTO_MIGRATE=1 时执行,生产幂等不漂移)、Redis 连接 + token 存储 +
+//     RS256 签发器 + 仓储 + AuthService + Handler + 验签中间件 + /auth/* 路由;
+//   - org 模块(机构/校区只读 + RBAC 矩阵配置 `/org/role-permissions`)已装配(T6);
+//   - academic 模块 AC-14 第一批已接线(40 个端点:财务 16 + 账单/仪表盘 2 + 活动 16 + 分账 6),
+//     注册表与运行期断言在 `internal/academic/handler/route.go`,装配见 `wiring_academic.go`
+//     的 `registerAcademicRoutes`(main 只调这一个函数);
+//   - teaching/ai 域尚未实现,域模块就绪后在"业务域路由挂载点"注册。
 //
 // 启动方式(dev):
 //
@@ -313,12 +318,19 @@ func main() {
 	rbacCfg.Get("/:roleCode", rbacH.RolePermissions)
 	rbacCfg.Put("/:roleCode", rbacH.SaveRolePermissions)
 
-	// 其余域(academic/teaching/ai)路由挂载点(域模块实现后在此注册):
+	// academic 域路由接线(AC-14 第一批:财务 16 + 账单/仪表盘 2 + 活动 16 + 分账 6 = 40 个端点)。
+	// 端点清单、注册顺序(静态段先于 `:id`)、角色白名单全部收在
+	// `internal/academic/handler/route.go` 的注册表里;本处**只调用一个注册函数**。
+	// 注册函数内当场跑两条运行期断言:① 全局 `method+path` 唯一(Fiber 对重复注册是
+	// "先注册优先、后者静默失效");② academic 一级段 ⊆ {/admin,/student,/pay}。
+	// 任一断言失败 → 进程起不来(这类缺陷在运行期不可见、只能靠启动期 fail-fast 暴露)。
+	if err := registerAcademicRoutes(app, gdb, authMw, logger); err != nil {
+		logger.Error("academic 路由挂载失败", zap.Error(err))
+		os.Exit(1)
+	}
+
+	// 其余域(teaching/ai)路由挂载点(域模块实现后在此注册):
 	//
-	//	app.Get("/auth/jwks", identityHandler.JWKS)            // 公开
-	//	auth := app.Group("/", middleware.Auth(logger))        // 以下全部需登录
-	//	auth.Get("/auth/me", identityHandler.Me)
-	//	auth.Get("/admin/courses/:id", academicHandler.Get)    // PC 端
 	//	auth.Get("/student/homeworks", teachingHandler.List)   // 移动端
 
 	// 优雅退出(SIGTERM/SIGINT;GracePeriodSec 内排空存量请求,§7.3)

+ 75 - 0
cmd/api/wiring_academic.go

@@ -18,10 +18,18 @@
 package main
 
 import (
+	"context"
+	"os"
+
+	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
 	"gorm.io/gorm"
 
+	academichandler "yuansiwei/unified-backend/internal/academic/handler"
+	academicport "yuansiwei/unified-backend/internal/academic/port"
 	academicrepo "yuansiwei/unified-backend/internal/academic/repository"
 	academicservice "yuansiwei/unified-backend/internal/academic/service"
+	identityhandler "yuansiwei/unified-backend/internal/identity/handler"
 )
 
 // 编译期契约断言(签名漂移即编译失败,杜绝"注入了但方法对不上"的隐蔽错误):
@@ -62,3 +70,70 @@ func newAcademicServices(gdb *gorm.DB) *academicServices {
 		FirstChargedInjected: true,
 	}
 }
+
+// registerAcademicRoutes academic 域路由接线(AC-14 第一批:40 个已交付端点)。
+//
+// 这是 `cmd/api/main.go` 里**唯一**的 academic 挂载调用点(main 不关心端点清单、不关心顺序,
+// 全部收在 `internal/academic/handler/route.go` 的注册表里)。
+//
+// 入参:
+//   - app *fiber.App:应用实例;
+//   - gdb *gorm.DB:数据库句柄(academic 的 Service 按请求作用域现场装配,Handler 只持 DB);
+//   - authMw fiber.Handler:identity 的验签 + 作用域注入中间件(本批 40 端点**全部**需登录);
+//   - log *zap.Logger:结构化日志器。
+//
+// 出参:error——Handler 装配失败 / 路由表非法(含 `/api`、空白名单、未知角色码)/
+// 断言 ①(`method+path` 全局重复)/ 断言 ②(一级段越界,如误注册 `/auth/*`)。
+// **任一失败都必须让进程起不来**:这类缺陷在运行期是"静默失效"(Fiber 后注册者被丢弃、
+// 或某角色恒 1003),启动失败是唯一可靠的暴露点。
+//
+// 副作用(仅 dev):`UNIFIED_AUTO_MIGRATE=1` 时顺带写 RBAC 种子(三张表,`INSERT IGNORE` 幂等)。
+// 生产不在此处写权限表——按"DDL 先行"纪律随迁移脚本执行(种子 SQL 由
+// `academichandler.RBACSeed.SQL("mysql")` 渲染,与路由表 1:1,不会手抄漏行)。
+// 种子写入失败**不阻断启动**(表未建时告警;空策略是 fail-closed,不会放行越权)。
+func registerAcademicRoutes(app *fiber.App, gdb *gorm.DB, authMw fiber.Handler, log *zap.Logger) error {
+	academicH, err := academichandler.New(academichandler.Config{
+		DB: gdb,
+		// 成员校验器(Service 层对象级越权预检):academic 域经 port 只读 t_membership,
+		// 不去 import org 的 repository(依赖方向铁律,见 academic/port/orgadapter.go)。
+		Member: academicport.NewOrgMembershipChecker(gdb),
+		Log:    log,
+	})
+	if err != nil {
+		return err
+	}
+	reg, err := academichandler.Register(app, academicH, academichandler.RegisterOptions{
+		Auth:      authMw,
+		RoleGuard: identityhandler.RequireRole,
+	})
+	if err != nil {
+		return err
+	}
+	log.Info("academic 路由已挂载",
+		zap.Int("routes", len(reg.Routes)),
+		zap.Strings("firstSegments", reg.Prefixes))
+
+	if os.Getenv("UNIFIED_AUTO_MIGRATE") == "1" {
+		res, serr := academichandler.SeedRBAC(context.Background(), gdb, reg.Routes)
+		switch {
+		case serr != nil:
+			// **不阻断启动**:种子是"运维动作"而不是"启动前提"。
+			// 三张表里只有 t_permission/t_role_permission 在 AutoMigrate 名单内,`casbin_rule`
+			// 按"目标态 DDL 先行"纪律**不**由 GORM 建表(见 infra/rbac/enforcer.go 包注释),
+			// 因此 dev/预发若还没跑 DDL,这里一定报 "no such table"。
+			// 若此时 os.Exit(1),结果是"没跑 DDL 就完全起不来"(连带登录也不可用),
+			// 代价远大于"策略表为空"——策略为空是 fail-closed(非超管全 1003),不会放行越权。
+			log.Warn("academic RBAC 种子未写入(通常是 casbin_rule 等权限表尚未按 DDL 建表)",
+				zap.Error(serr))
+		default:
+			log.Info("academic RBAC 种子已应用(幂等:changed=0 表示已是最新)",
+				zap.Int("casbinRules", res.CasbinRules),
+				zap.Int64("insertedCasbinRules", res.InsertedCasbinRules),
+				zap.Int("permissions", res.Permissions),
+				zap.Int64("insertedPermissions", res.InsertedPermissions),
+				zap.Int("rolePermissions", res.RolePermissions),
+				zap.Int64("insertedRolePermissions", res.InsertedRolePermissions))
+		}
+	}
+	return nil
+}

+ 480 - 0
internal/academic/handler/route.go

@@ -0,0 +1,480 @@
+// academic 域**路由装配单元**(AC-14 第一批:40 个已交付端点的接线 + RBAC 种子来源)。
+//
+// 依据:unified/03-migration/academic-frontend-contract-v1.md §2.1/§2.2/§2.3(method/path/角色白名单,
+// **唯一权威**)/ §1.6(后端路由不含 `/api`)/ §2(63 端点清单);设计 `academic-domain-design.md`
+// AC-14 行(四条运行期断言 + 种子 1:1 对齐 + 一级段白名单断言对象 = 本装配单元)。
+//
+// ---------------------------------------------------------------------------
+// 一、本批范围(**只挂已交付的 40 个端点**)
+// ---------------------------------------------------------------------------
+//
+//	财务 16  端点 1~16   fund_handler.go
+//	账单 2   端点 17、21 bill_handler.go
+//	活动 16  端点 22~37  activity_handler.go
+//	分账 6   端点 38~43  divide_handler.go
+//
+// **端点 18/19/20(`/pay/*`)本批不挂**:handler 尚未实现(不属 AC-12 的 1~17/21,
+// 也不属 AC-13 的 22~63)。**不挂空壳/占位路由**——空壳会让"未实现"看起来像"已实现但返回怪码",
+// 比 404 更难查(前端拿到的不是 404 而是业务错误码,会去查业务逻辑)。
+//
+// ---------------------------------------------------------------------------
+// 二、四条运行期断言(本文件是唯一实现点,e2e 复用同一函数,不另写一套)
+// ---------------------------------------------------------------------------
+//
+//	① AssertUniqueMethodPath:遍历 `app.GetRoutes()` 断言 `method+path` 全局唯一。
+//	   **为什么必须运行期**:Fiber 对重复 `method+path` 是"**先注册优先、后者静默失效**",
+//	   症状是"改了没生效";静态 grep 抓不到这种语义重叠,人眼也抓不到(40 条路由里有一对
+//	   重复,review 时几乎不可能看出来)。
+//
+//	② AssertFirstSegments:断言**本装配单元注册出的**一级段集合 ⊆ {`/admin`,`/student`,`/pay`}。
+//	   ⚠ **断言对象是 `Registration.Prefixes`(本域注册出来的段),不是全局 `app.GetRoutes()`**:
+//	   全局表天然含 identity 的 `/auth/*` 与 org 的 `/org/*`,对全局表断言**必红**,
+//	   维护者最后只会做两件事之一——删断言,或把白名单放宽成 `{admin,student,pay,auth,org}`,
+//	   两种都把"academic 不得重复注册他域路径"的意图彻底丢掉。**作用域故意不同,不得合并**。
+//
+//	③ 变异反例(证明 ② 不是"恒过"):`RegisterOptions.Routes` 允许注入自定义路由表,
+//	   e2e 用它注册一条 `/auth/xxx` → ② 必须变红。若把 ② 误写成"先过滤掉 /auth 再断言",
+//	   则该用例恒绿——这正是"守卫本身要能被证伪"的意义(设计 §10.3)。
+//
+//	④ 静态段优先:见 `Routes` 的注册顺序(静态段全部在 `:id` 之前)。
+//	   行为断言在 e2e:`GET /admin/activities/discounts/usable` 必须 200,
+//	   被 `:id` 吃掉会变成 2002(`:id` 解析 "discounts" 失败)。
+//
+// ---------------------------------------------------------------------------
+// 三、为什么中间件按"每条路由一条链"挂,而不是按 Group 挂
+// ---------------------------------------------------------------------------
+//
+// 40 条端点的角色白名单**只有 3 种组合**,但静态段/`:id` 段的注册顺序**跨组合交错**
+// (如 `discounts/*` 是 finance 组、`/admin/activities/:id` 是 academic 组,前者必须先注册)。
+// 用 Group 挂就必须在多个 group 之间来回跳,注册顺序变成"跟着 group 走",
+// 一旦新增端点很容易把静态段放到 `:id` 之后。因此改为**单层平铺 + 每行自带
+// `Auth → RoleGuard(白名单) → handler`**:注册顺序 = 表顺序,一眼可审;
+// 白名单与 `SeedRBAC` 的 casbin 策略同源(都取 `Route.Roles`),不会打字不一致。
+package handler
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+// 角色码常量(契约 §1.7 的 6 个 + `market`;与 org 域 `t_role` 字典一致)。
+const (
+	// RoleSuperAdmin 超级管理员(代码层恒放行,见 identity/handler.RequireRole)。
+	RoleSuperAdmin = "super_admin"
+	// RoleAcademic 教务。
+	RoleAcademic = "academic"
+	// RoleFinance 财务。
+	RoleFinance = "finance"
+	// RoleTeacher 老师。
+	RoleTeacher = "teacher"
+	// RoleStudent 学生(移动端)。
+	RoleStudent = "student"
+	// RoleParent 家长(移动端)。
+	RoleParent = "parent"
+	// RoleMarket 市场(推广/分账候选角色;本批 40 端点未用到,保留以支持白名单校验)。
+	RoleMarket = "market"
+)
+
+// knownRoleCodes 合法角色码集合(`Route.Roles` 的**拼写校验**用)。
+//
+// 为什么要在注册期校验角色码:白名单写错(如 `"finance "` 带尾空格、`"Finance"` 大小写错)
+// **不会让任何用例变红**——它只会让该角色在生产上永远 1003,而 1003 看起来像"权限没配",
+// 排查会先怀疑 RBAC 种子,最后才发现是路由表里的一个空格。注册期 fail-fast 一次即可。
+var knownRoleCodes = map[string]struct{}{
+	RoleSuperAdmin: {}, RoleAcademic: {}, RoleFinance: {},
+	RoleTeacher: {}, RoleStudent: {}, RoleParent: {}, RoleMarket: {},
+}
+
+// AcademicFirstSegments academic 域**允许**出现的一级段白名单(契约 §1.6/§2)。
+//
+// `/auth` 属 identity、`/org` 属 org,academic **不得**注册(重复注册同一 method+path 时
+// Fiber 先注册优先,identity 的 handler 会被 academic 的悄悄顶掉,症状是"登录接口返回 1003")。
+var AcademicFirstSegments = []string{"/admin", "/student", "/pay"}
+
+// Route 一条 academic 路由(**注册表行**:既是注册输入,也是 RBAC 种子的唯一来源)。
+//
+// 字段:
+//   - No int:契约端点号(§2 表内编号 1~63);用于种子排序与交付报告逐条对账。
+//   - Method string:HTTP method(大写,`GET`/`POST`/`PUT`/`DELETE`)。
+//   - Path string:**不含 `/api`** 的后端路径(契约 §1.6),参数段用 Fiber 的 `:name` 写法。
+//   - Name string:中文名(日志/种子的权限点名称用;不参与路由语义)。
+//   - Roles []string:契约 §2 该行给出的角色白名单(含 `super_admin`,便于审计与种子的可读性)。
+//   - Handler fiber.Handler:已交付的处理函数(**本批不改 handler 实现**)。
+type Route struct {
+	No      int
+	Method  string
+	Path    string
+	Name    string
+	Roles   []string
+	Handler fiber.Handler
+}
+
+// Key `method + path` 唯一键(断言 ① 与种子去重共用同一口径)。
+func (r Route) Key() string { return r.Method + " " + r.Path }
+
+// RoleGuard 角色白名单中间件**工厂**(由装配层注入 `identity/handler.RequireRole`)。
+//
+// 为什么不直接 import identity 的 handler:academic → identity 是跨域依赖,
+// 本域与 org 的耦合一律经 `port` 包或装配层注入(见 `academic/port` 包注释的依赖方向铁律)。
+// 注入后 `academic/handler` 对 identity **零 import**,签名漂移在 `cmd/api` 装配处即编译失败。
+type RoleGuard func(roles ...string) fiber.Handler
+
+// RegisterOptions 路由注册参数。
+//
+// 字段:
+//   - Auth fiber.Handler:验签 + 作用域注入中间件(**必填**;本批 40 端点**全部**需登录,
+//     公开端点将来在别的装配单元里挂,不得混进这里)。
+//   - RoleGuard RoleGuard:角色白名单中间件工厂(**必填**)。
+//   - Routes []Route:注册表(**nil = 用 `Routes(h)` 的 40 条**)。
+//     非 nil 只给"能自证失败"的变异用例用(e2e 注册一条 `/auth/xxx` 验证断言 ② 会红),
+//     生产装配恒传 nil。
+type RegisterOptions struct {
+	Auth      fiber.Handler
+	RoleGuard RoleGuard
+	Routes    []Route
+}
+
+// Registration 注册结果(断言 ② 的断言对象 + 种子生成输入 + 交付报告的对账表)。
+//
+// 字段:
+//   - Routes []Route:实际注册的路由(顺序 = 注册顺序)。
+//   - Prefixes []string:本装配单元注册出的**一级段**集合(去重、升序,如 `["/admin","/student"]`)。
+type Registration struct {
+	Routes   []Route
+	Prefixes []string
+}
+
+// Routes 返回本批 40 条路由(**顺序即注册顺序,静态段全部在 `:id` 之前**)。
+//
+// ⚠ 顺序规则(Fiber 按注册顺序匹配;违反即"静态段被 `:id` 吃掉"):
+//
+//	① 同深度下 **字面段必须先于 `:param` 段**:
+//	   `/admin/activities/discounts/*`(深度 4)必须先于 `/admin/activities/:id`(深度 3)——
+//	   深度不同时不会互相吃掉,但同深度的 `discounts|recharge-gifts` 与 `:id` 必须字面优先;
+//	② `/admin/finance/refunds/custom` 先于 `/admin/finance/refunds`(深度不同,仍按"具体优先"排);
+//	③ 本函数把**整个 40 条按"静态优先"统一排序**,而不是分段排——分段排最容易在
+//	   "以后再加一条"时破坏顺序,整表排序只有一处可错。
+func Routes(h *Handler) []Route {
+	return []Route{
+		// ---------------------------------------------------------------- 财务(端点 1~16、21)
+		{No: 2, Method: fiber.MethodGet, Path: PathFinanceRefundable, Name: "可退金额",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.GetRefundable},
+		{No: 1, Method: fiber.MethodGet, Path: PathFinanceAccounts, Name: "账户快照",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.GetAccount},
+		{No: 6, Method: fiber.MethodPost, Path: PathFinanceRefundsCustom, Name: "自定义退费(含坏账)",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateCustomRefund},
+		{No: 5, Method: fiber.MethodPost, Path: PathFinanceRefunds, Name: "退费",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateRefund},
+		{No: 3, Method: fiber.MethodPost, Path: PathFinanceRecharges, Name: "充值",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateRecharge},
+		{No: 4, Method: fiber.MethodPost, Path: PathFinanceExpenditures, Name: "消费",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateExpenditure},
+		{No: 7, Method: fiber.MethodPost, Path: PathFinanceTransfers, Name: "课费转结",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateTransfer},
+		{No: 8, Method: fiber.MethodPost, Path: PathFinanceBadDebts, Name: "坏账",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CreateBadDebt},
+		{No: 9, Method: fiber.MethodPost, Path: PathFinanceClearedBalances, Name: "余额清零",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.ClearBalance},
+		{No: 10, Method: fiber.MethodPost, Path: PathFinanceSpends, Name: "累计消费",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.AddSpend},
+		{No: 11, Method: fiber.MethodGet, Path: PathFinanceLogs, Name: "流水列表",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.ListLogs},
+		{No: 14, Method: fiber.MethodPost, Path: PathFinanceRequestApprove, Name: "工单通过",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.ApproveRequest},
+		{No: 15, Method: fiber.MethodPost, Path: PathFinanceRequestReject, Name: "工单驳回",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.RejectRequest},
+		// 端点 16:契约角色列写的是"申请人本人 或 super_admin/finance"。
+		// 路由白名单是**粗粒度**门禁,必须 ⊇ 可能成为申请人的角色(端点 13 允许 academic 建单),
+		// 否则"教务本人撤自己的单"会被路由层 1003 挡住、走不到 Service 的归属判定;
+		// **"本人"这一层由 Service 判**(`BalanceRequestService.Cancel` isOwner/isManager,越权 → 1003)。
+		{No: 16, Method: fiber.MethodPost, Path: PathFinanceRequestCancel, Name: "工单撤销",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.CancelRequest},
+		{No: 13, Method: fiber.MethodPost, Path: PathFinanceRequests, Name: "创建工单",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.CreateRequest},
+		{No: 12, Method: fiber.MethodGet, Path: PathFinanceRequests, Name: "工单列表",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.ListRequests},
+		{No: 21, Method: fiber.MethodGet, Path: PathFinanceDashboard, Name: "仪表盘",
+			Roles:   []string{RoleSuperAdmin, RoleFinance, RoleAcademic},
+			Handler: h.GetDashboard},
+		// 端点 17 是 `/student` 段(移动端账单)。
+		{No: 17, Method: fiber.MethodGet, Path: PathStudentBill, Name: "学生账单",
+			Roles:   []string{RoleStudent, RoleParent, RoleSuperAdmin},
+			Handler: h.GetBill},
+
+		// ---------------------------------------------------------------- 活动(端点 22~37)
+		{No: 22, Method: fiber.MethodGet, Path: PathActivities, Name: "活动列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance, RoleTeacher},
+			Handler: h.ListActivities},
+		{No: 23, Method: fiber.MethodPost, Path: PathActivities, Name: "新建活动",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.CreateActivity},
+		// ↓ 静态段(`discounts/*`、`recharge-gifts/*`)全部先于 `:id` 段注册。
+		{No: 31, Method: fiber.MethodGet, Path: PathActivityDiscountsUsable, Name: "可用赠送余额",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.GetActivityUsableDiscount},
+		{No: 32, Method: fiber.MethodPost, Path: PathActivityDiscountsUse, Name: "使用赠送",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.UseActivityDiscount},
+		{No: 33, Method: fiber.MethodPost, Path: PathActivityDiscountsRefund, Name: "赠送退回",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.RefundActivityDiscount},
+		{No: 34, Method: fiber.MethodPost, Path: PathActivityDiscountsRefundByActivity, Name: "按活动退回赠送",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.RefundDiscountByActivity},
+		{No: 35, Method: fiber.MethodPost, Path: PathActivityRechargeGiftsUse, Name: "充值赠送使用",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.UseRechargeGift},
+		{No: 36, Method: fiber.MethodPost, Path: PathActivityRechargeGiftsRefund, Name: "充值赠送退回",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.RefundRechargeGift},
+		{No: 24, Method: fiber.MethodGet, Path: PathActivityDetail, Name: "活动详情",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
+			Handler: h.GetActivity},
+		{No: 25, Method: fiber.MethodPut, Path: PathActivityDetail, Name: "更新活动",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.UpdateActivity},
+		{No: 26, Method: fiber.MethodPost, Path: PathActivitySign, Name: "活动报名",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleStudent, RoleParent},
+			Handler: h.SignActivity},
+		{No: 27, Method: fiber.MethodPost, Path: PathActivityCancelSign, Name: "取消报名",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleStudent, RoleParent},
+			Handler: h.CancelActivitySign},
+		{No: 28, Method: fiber.MethodGet, Path: PathActivitySigneds, Name: "报名列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.ListActivitySigneds},
+		{No: 29, Method: fiber.MethodPost, Path: PathActivityRecommendSettle, Name: "老带新结算",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.SettleActivityRecommend},
+		{No: 30, Method: fiber.MethodPost, Path: PathActivityCancelRecharge, Name: "取消充值活动",
+			Roles:   []string{RoleSuperAdmin, RoleFinance},
+			Handler: h.CancelActivityRecharge},
+		{No: 37, Method: fiber.MethodPost, Path: PathActivityVisits, Name: "回访登记",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleTeacher},
+			Handler: h.CreateActivityVisit},
+
+		// ---------------------------------------------------------------- 分账(端点 38~43)
+		{No: 38, Method: fiber.MethodGet, Path: PathDivideTree, Name: "分账树",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.GetDivideTree},
+		{No: 39, Method: fiber.MethodPost, Path: PathDivideMembers, Name: "加入下线",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.AddDivideMember},
+		{No: 40, Method: fiber.MethodPut, Path: PathDivideMemberQuota, Name: "设置分成额度",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.SetDivideQuota},
+		{No: 41, Method: fiber.MethodPost, Path: PathDivideLogs, Name: "记分账流水",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.CreateDivideLogRecord},
+		{No: 42, Method: fiber.MethodDelete, Path: PathDivideLogs, Name: "删分账流水",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic},
+			Handler: h.DeleteDivideLogRecord},
+		{No: 43, Method: fiber.MethodGet, Path: PathDivideLogs, Name: "分账流水列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.ListDivideLogs},
+	}
+}
+
+// Register 把 academic 域路由挂到 app 上,并**当场**跑断言 ①②(生产路径与 e2e 同一函数)。
+//
+// 入参:
+//   - app *fiber.App:应用实例(非 nil);
+//   - h *Handler:academic Handler(非 nil;本批不改 handler 实现,只接线);
+//   - opt RegisterOptions:中间件 + 可选注册表覆盖(生产恒 nil,变异用例才用)。
+//
+// 出参:
+//   - *Registration:注册结果(路由表 + 一级段集合);
+//   - error:参数缺失 / 路由表非法(空 handler、空白名单、未知角色码、含 `/api` 前缀)/
+//     断言 ①(`method+path` 重复)/ 断言 ②(一级段越界)。**任一失败都必须让进程起不来**——
+//     这类错误在生产上是"静默失效",启动失败是唯一可靠的暴露方式。
+func Register(app *fiber.App, h *Handler, opt RegisterOptions) (*Registration, error) {
+	if app == nil {
+		return nil, fmt.Errorf("academic route: app 未注入")
+	}
+	if opt.Auth == nil {
+		return nil, fmt.Errorf("academic route: Auth 中间件未注入(本批 40 端点全部需登录)")
+	}
+	if opt.RoleGuard == nil {
+		return nil, fmt.Errorf("academic route: RoleGuard(角色白名单工厂)未注入")
+	}
+	routes := opt.Routes
+	if routes == nil {
+		if h == nil {
+			return nil, fmt.Errorf("academic route: Handler 未注入")
+		}
+		routes = Routes(h)
+	}
+	if err := validateRoutes(routes); err != nil {
+		return nil, err
+	}
+
+	for _, r := range routes {
+		// 单层平铺:`Auth → RoleGuard(本行白名单) → handler`。
+		// 顺序即匹配顺序,与 `Routes` 的"静态优先"排序一一对应(见文件头 §三)。
+		app.Add(r.Method, r.Path, opt.Auth, opt.RoleGuard(r.Roles...), r.Handler)
+	}
+
+	// 断言 ①:**全局**路由表 `method+path` 唯一(跨域重复也算错,故不只看本域 40 条)。
+	if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
+		return nil, err
+	}
+	// 断言 ②:**本装配单元**的一级段白名单(不碰全局表,理由见文件头 §二)。
+	prefixes := FirstSegments(routes)
+	if err := AssertFirstSegments(prefixes); err != nil {
+		return nil, err
+	}
+	return &Registration{Routes: routes, Prefixes: prefixes}, nil
+}
+
+// validateRoutes 注册期校验路由表本身(拼写类错误在这里暴露,不留给运行期)。
+//
+// 入参:routes []Route。
+// 出参:nil 合法;否则描述第一条问题(fail-fast,一次报一条便于修)。
+func validateRoutes(routes []Route) error {
+	if len(routes) == 0 {
+		return fmt.Errorf("academic route: 路由表为空")
+	}
+	for _, r := range routes {
+		if r.Handler == nil {
+			return fmt.Errorf("academic route: 端点 %d(%s %s)未绑定 handler", r.No, r.Method, r.Path)
+		}
+		if r.No <= 0 {
+			return fmt.Errorf("academic route: %s %s 缺少契约端点号", r.Method, r.Path)
+		}
+		if !strings.HasPrefix(r.Path, "/") {
+			return fmt.Errorf("academic route: 端点 %d 路径必须以 / 开头(当前 %q)", r.No, r.Path)
+		}
+		// `/api` 是代理层外部前缀(契约 §1.6),**不得**进后端路由表:
+		// 一旦混入,对外就变成 `/api/api/...`(vite/nginx 已去掉一层),症状是"一类环境能通、另一类 404"。
+		if r.Path == "/api" || strings.HasPrefix(r.Path, "/api/") {
+			return fmt.Errorf("academic route: 端点 %d 路径不得含 /api 前缀(当前 %q,见契约 §1.6)", r.No, r.Path)
+		}
+		if len(r.Roles) == 0 {
+			// 空白名单在 `RequireRole` 里是 fail-closed(除超管外全 1003),
+			// 但那种"配置错误"在运行期只表现为 1003,看起来像 RBAC 种子没配 —— 注册期直接拒。
+			return fmt.Errorf("academic route: 端点 %d(%s %s)角色白名单为空", r.No, r.Method, r.Path)
+		}
+		for _, role := range r.Roles {
+			if _, ok := knownRoleCodes[role]; !ok {
+				return fmt.Errorf("academic route: 端点 %d 角色码 %q 非法(合法值:super_admin/academic/finance/teacher/student/parent/market)",
+					r.No, role)
+			}
+		}
+		switch r.Method {
+		case fiber.MethodGet, fiber.MethodPost, fiber.MethodPut, fiber.MethodDelete:
+		default:
+			return fmt.Errorf("academic route: 端点 %d method %q 非法", r.No, r.Method)
+		}
+	}
+	return nil
+}
+
+// FirstSegments 取路由表的一级段集合(去重、升序)。
+//
+// 入参:routes []Route(或任意带 Path 的路由行)。
+// 出参:[]string,如 `["/admin","/student"]`。
+//
+// 这是断言 ② 的数据来源:**集合必须是"本装配单元注册了什么",而不是"全局表里有什么"**
+// (见文件头 §二)。空路径段(如 `/`)会得到空串,断言会因"不在白名单"而报错——符合预期。
+func FirstSegments(routes []Route) []string {
+	seen := make(map[string]struct{}, len(routes))
+	for _, r := range routes {
+		seg := firstSegment(r.Path)
+		seen[seg] = struct{}{}
+	}
+	out := make([]string, 0, len(seen))
+	for seg := range seen {
+		out = append(out, seg)
+	}
+	sort.Strings(out)
+	return out
+}
+
+// firstSegment 取路径第一段(含前导 `/`;`/admin/finance` → `/admin`)。
+func firstSegment(path string) string {
+	trimmed := strings.TrimPrefix(path, "/")
+	if trimmed == "" {
+		return "/"
+	}
+	if idx := strings.IndexByte(trimmed, '/'); idx >= 0 {
+		return "/" + trimmed[:idx]
+	}
+	return "/" + trimmed
+}
+
+// AssertUniqueMethodPath 断言路由表 `method+path` **唯一**(断言 ①)。
+//
+// 入参:routes []fiber.Route(生产路径传 `app.GetRoutes()`,e2e 传同一份)。
+// 出参:nil 唯一;否则 error 列出**全部**重复项(一次报全,避免改一个跑一遍)。
+//
+// 为什么必须运行期断言:Fiber 的 `app.Add` 对重复 `method+path` 不报错、不 panic,
+// 后注册者**静默失效**。症状是"改了没生效/新接口返回旧逻辑",且:
+//   - 静态 grep 抓不到(两条注册语句长得不一样,只有 method+path 相同);
+//   - 单端点用例抓不到(打的还是先注册那个 handler,行为正确)。
+//
+// 注意:同一 path 不同 method 是**合法**的(如端点 12/13 共用 `/admin/finance/requests`),
+// 故键是 `method + " " + path`,不是 path。
+func AssertUniqueMethodPath(routes []fiber.Route) error {
+	seen := make(map[string]int, len(routes))
+	dups := make([]string, 0, 4)
+	for _, r := range routes {
+		key := r.Method + " " + r.Path
+		if n, ok := seen[key]; ok {
+			dups = append(dups, fmt.Sprintf("%s(重复 %d 次)", key, n+1))
+			seen[key] = n + 1
+			continue
+		}
+		seen[key] = 1
+	}
+	if len(dups) > 0 {
+		sort.Strings(dups)
+		return fmt.Errorf("academic route: 路由表存在重复 method+path(Fiber 会静默丢弃后注册者):%s",
+			strings.Join(dups, ";"))
+	}
+	return nil
+}
+
+// AssertFirstSegments 断言一级段集合 ⊆ {`/admin`,`/student`,`/pay`}(断言 ②)。
+//
+// 入参:prefixes []string(**必须是本装配单元注册出的段集合**,见 `FirstSegments`)。
+// 出参:nil 合法;否则 error 指出越界段。
+//
+// ⚠ 本函数**不做任何过滤**(不跳过 /auth、不跳过 /org):一旦加了过滤,断言就恒过,
+// 与"没写断言"等价,但看起来像有防线——比没有更危险。
+func AssertFirstSegments(prefixes []string) error {
+	allowed := make(map[string]struct{}, len(AcademicFirstSegments))
+	for _, seg := range AcademicFirstSegments {
+		allowed[seg] = struct{}{}
+	}
+	bad := make([]string, 0, 2)
+	for _, seg := range prefixes {
+		if _, ok := allowed[seg]; !ok {
+			bad = append(bad, seg)
+		}
+	}
+	if len(bad) > 0 {
+		sort.Strings(bad)
+		return fmt.Errorf(
+			"academic route: 一级段越界 %v(academic 只允许 %v;/auth 属 identity、/org 属 org,重复注册会让先注册者静默失效)",
+			bad, AcademicFirstSegments)
+	}
+	return nil
+}

+ 719 - 0
internal/academic/handler/route_e2e_test.go

@@ -0,0 +1,719 @@
+// AC-14 路由接线 e2e(**四条断言的守卫**——本文件的价值不在于"接口能返回 200")。
+//
+// 覆盖结构(每条对到 AC-14 验收项原文):
+//
+//	① 40 端点注册表与契约逐条对齐 → TestRouteRegistryMatchesContract
+//	   (含"不含 18/19/20"与"/pay 段本批不挂"的反向断言)
+//	② 断言①:全局 `method+path` 唯一 → TestAssertUniqueMethodPath(正例 + 变异反例)
+//	③ 断言②:一级段 ⊆ {/admin,/student,/pay} → TestFirstSegmentWhitelist(正例 + 变异反例)
+//	④ 断言④(行为):静态段优先 → TestStaticSegmentBeatsParam
+//	   被 `:id` 吃掉时 `GET /admin/activities/discounts/usable` 会返回 **2002**(`:id` 解析
+//	   "discounts" 失败),比"路由表里存在这条"更能证明顺序正确。
+//
+//	另含:全部 40 条都必须过 Auth(无 token → 1001)/ 角色白名单真的生效(非白名单角色 1003)/
+//	RBAC 种子幂等 + 与路由表 1:1 + **种子在真 Casbin 引擎下可判定**(含 `:param` 不过度匹配)。
+//
+// 断言纪律:只断 body 契约(HTTP 恒 200 + body.code,铁律 6),不断 HTTP 状态码。
+package handler
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+	"testing"
+
+	"github.com/gofiber/fiber/v2"
+	"gorm.io/gorm"
+
+	identityhandler "yuansiwei/unified-backend/internal/identity/handler"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	infrarabc "yuansiwei/unified-backend/internal/infra/rbac"
+	"yuansiwei/unified-backend/internal/infra/response"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// ---------------------------------------------------------------- 测试装配
+
+// newAcademicRouteTestApp 构造**只挂 academic 域**的 app(断言② 的合法断言对象)。
+//
+// 中间件链与生产一致:`stubAuth(验签+作用域注入)→ identity.RequireRole(本行白名单) → handler`。
+// `stubAuth` 用 `X-Test-Token` 模拟登录态(缺省 = 未登录 → 1001),`X-Test-Scope` 选角色。
+func newAcademicRouteTestApp(t *testing.T, db *gorm.DB, opt RegisterOptions) (*fiber.App, *Registration) {
+	t.Helper()
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	if opt.Auth == nil {
+		opt.Auth = stubAuth()
+	}
+	if opt.RoleGuard == nil {
+		// 用**生产同一个**工厂(identity/handler.RequireRole):若测试自写一份白名单判定,
+		// 装配处把 guard 传错也测不出来。
+		opt.RoleGuard = identityhandler.RequireRole
+	}
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+	reg, err := Register(app, h, opt)
+	if err != nil {
+		t.Fatalf("Register 失败: %v", err)
+	}
+	return app, reg
+}
+
+// stubAuth 模拟 identity 的验签中间件:无 token → 1001;有 token → 按 `X-Test-Scope` 注入作用域。
+func stubAuth() fiber.Handler {
+	return func(c *fiber.Ctx) error {
+		if strings.TrimSpace(c.Get("X-Test-Token")) == "" {
+			return response.Fail(c, infraerrors.ErrUnauth) // 1001
+		}
+		switch c.Get("X-Test-Scope") {
+		case "super":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 0, SuperAdmin: true,
+				UserID: "01HADMIN0000000000000001", Role: RoleSuperAdmin,
+			})
+		case "academic":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10,
+				UserID: "01HACADEMIC0000000000001", Role: RoleAcademic,
+			})
+		case "teacher":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10,
+				UserID: "01HTEACHER00000000000001", Role: RoleTeacher,
+			})
+		case "student":
+			appscope.Set(c, &appscope.Scope{
+				InstitutionID: 1, CampusID: 10,
+				UserID: "01HSTUDENT00000000000001", Role: RoleStudent,
+			})
+		default:
+			appscope.Set(c, testScope()) // finance / 机构1 / 校区10
+		}
+		return c.Next()
+	}
+}
+
+// requestPath 把路由模板转成可请求的具体路径(`/admin/activities/:id` → `/admin/activities/1`)。
+func requestPath(tmpl string) string {
+	segs := strings.Split(tmpl, "/")
+	for i, seg := range segs {
+		if strings.HasPrefix(seg, ":") {
+			segs[i] = "1"
+		}
+	}
+	return strings.Join(segs, "/")
+}
+
+// ---------------------------------------------------------------- ① 注册表 ↔ 契约
+
+// TestRouteRegistryMatchesContract 40 条端点与契约 §2.1/§2.2/§2.3 逐条对齐。
+//
+// 期望值**手写**(测试即契约快照):与 `route.go` 的生成逻辑不同源,才有对齐价值——
+// 若也写成"从路由表推导",这条用例就只能证明"路由表等于自己"。
+func TestRouteRegistryMatchesContract(t *testing.T) {
+	db := newHandlerTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	routes := Routes(h)
+
+	want := []struct {
+		no     int
+		method string
+		path   string
+		roles  string // 逗号连接,便于逐条目视核对契约
+	}{
+		{1, "GET", "/admin/finance/accounts/:userId", "super_admin,finance,academic"},
+		{2, "GET", "/admin/finance/accounts/:userId/refundable", "super_admin,finance,academic"},
+		{3, "POST", "/admin/finance/recharges", "super_admin,finance"},
+		{4, "POST", "/admin/finance/expenditures", "super_admin,finance"},
+		{5, "POST", "/admin/finance/refunds", "super_admin,finance"},
+		{6, "POST", "/admin/finance/refunds/custom", "super_admin,finance"},
+		{7, "POST", "/admin/finance/transfers", "super_admin,finance"},
+		{8, "POST", "/admin/finance/bad-debts", "super_admin,finance"},
+		{9, "POST", "/admin/finance/cleared-balances", "super_admin,finance"},
+		{10, "POST", "/admin/finance/spends", "super_admin,finance,academic"},
+		{11, "GET", "/admin/finance/logs", "super_admin,finance,academic"},
+		{12, "GET", "/admin/finance/requests", "super_admin,finance,academic"},
+		{13, "POST", "/admin/finance/requests", "super_admin,finance,academic"},
+		{14, "POST", "/admin/finance/requests/:id/approve", "super_admin,finance"},
+		{15, "POST", "/admin/finance/requests/:id/reject", "super_admin,finance"},
+		// 端点 16:契约写"申请人本人 或 super_admin/finance";路由层是粗粒度门禁,
+		// 必须覆盖"可能成为申请人"的角色(端点 13 允许 academic 建单),"本人"由 Service 判。
+		{16, "POST", "/admin/finance/requests/:id/cancel", "super_admin,finance,academic"},
+		{17, "GET", "/student/bill", "student,parent,super_admin"},
+		{21, "GET", "/admin/finance/dashboard", "super_admin,finance,academic"},
+		{22, "GET", "/admin/activities", "super_admin,academic,finance,teacher"},
+		{23, "POST", "/admin/activities", "super_admin,academic"},
+		{24, "GET", "/admin/activities/:id", "super_admin,academic,teacher"},
+		{25, "PUT", "/admin/activities/:id", "super_admin,academic"},
+		{26, "POST", "/admin/activities/:id/sign", "super_admin,academic,student,parent"},
+		{27, "POST", "/admin/activities/:id/cancel-sign", "super_admin,academic,student,parent"},
+		{28, "GET", "/admin/activities/:id/signeds", "super_admin,academic,finance"},
+		{29, "POST", "/admin/activities/:id/recommend-settle", "super_admin,academic"},
+		{30, "POST", "/admin/activities/:id/cancel-recharge", "super_admin,finance"},
+		{31, "GET", "/admin/activities/discounts/usable", "super_admin,academic,finance"},
+		{32, "POST", "/admin/activities/discounts/use", "super_admin,academic,finance"},
+		{33, "POST", "/admin/activities/discounts/refund", "super_admin,academic,finance"},
+		{34, "POST", "/admin/activities/discounts/refund-by-activity", "super_admin,academic,finance"},
+		{35, "POST", "/admin/activities/recharge-gifts/use", "super_admin,academic,finance"},
+		{36, "POST", "/admin/activities/recharge-gifts/refund", "super_admin,academic,finance"},
+		{37, "POST", "/admin/activities/:id/visits", "super_admin,academic,teacher"},
+		{38, "GET", "/admin/divide-into/tree", "super_admin,academic"},
+		{39, "POST", "/admin/divide-into/members", "super_admin,academic"},
+		{40, "PUT", "/admin/divide-into/members/:userId/quota", "super_admin,academic"},
+		{41, "POST", "/admin/divide-into/logs", "super_admin,academic"},
+		{42, "DELETE", "/admin/divide-into/logs", "super_admin,academic"},
+		{43, "GET", "/admin/divide-into/logs", "super_admin,academic,finance"},
+	}
+
+	if len(routes) != len(want) {
+		t.Fatalf("路由条数 = %d,期望 %d(本批只挂已交付的 40 个端点)", len(routes), len(want))
+	}
+
+	// 逐条对齐**用集合比**而不是按下标比:注册顺序是"静态段优先"(≠ 契约端点号顺序),
+	// 两者是不同的纪律——端点覆盖面按集合校验(下面),顺序另有三条断言(本函数末尾)。
+	type contractRow struct {
+		no    int
+		roles string
+	}
+	gotByKey := make(map[string]contractRow, len(routes))
+	for _, r := range routes {
+		if _, dup := gotByKey[r.Key()]; dup {
+			t.Errorf("%s 在路由表里出现了两次", r.Key())
+		}
+		gotByKey[r.Key()] = contractRow{no: r.No, roles: strings.Join(r.Roles, ",")}
+	}
+	for _, w := range want {
+		key := w.method + " " + w.path
+		got, ok := gotByKey[key]
+		if !ok {
+			t.Errorf("端点 %d(%s %s)未注册", w.no, w.method, w.path)
+			continue
+		}
+		if got.no != w.no {
+			t.Errorf("%s 的端点号 = %d,期望 %d", key, got.no, w.no)
+		}
+		if got.roles != w.roles {
+			t.Errorf("端点 %d 角色白名单 = %q,期望 %q", w.no, got.roles, w.roles)
+		}
+		delete(gotByKey, key)
+	}
+	for key := range gotByKey {
+		t.Errorf("注册了契约外的路由 %s(本批只挂已交付的 40 个端点)", key)
+	}
+
+	// 反向断言:18/19/20(支付)**本批不得挂**——handler 尚未实现,挂空壳会让"未实现"
+	// 看起来像"已实现但返回怪码"(前端不会看到 404,会去查业务逻辑)。
+	for _, r := range routes {
+		if strings.HasPrefix(r.Path, "/pay") {
+			t.Errorf("端点 %d(%s %s)不该在本批注册:18/19/20 handler 尚未实现", r.No, r.Method, r.Path)
+		}
+		switch r.No {
+		case 18, 19, 20:
+			t.Errorf("端点 %d 不该在本批注册", r.No)
+		}
+	}
+
+	// 静态段先于 `:id`(断言④ 的**顺序**部分;行为断言见 TestStaticSegmentBeatsParam)。
+	//
+	// ⚠ 实测结论(变异测试记录,见交付报告):本批 40 条路径**不存在会被 `:id` 吃掉的同深度冲突**
+	// —— `:id` 是 3 段(`/admin/activities/:id`),而 `discounts/*` / `recharge-gifts/*` 都是 4 段,
+	// Fiber 按段数匹配,3 段路由匹配不上 4 段请求。因此"把 `:id` 提到静态段之前"这条变异
+	// **不会**让 `GET /admin/activities/discounts/usable` 变 2002(已实测:仍是 200)。
+	// 纪律仍然保留(同级静态优先),并且下面用一个**真正会冲突的同深度路由对**证明守卫不是恒过。
+	assertStaticBeforeParam(t, routes)
+
+	// 守卫自证:同一对路由,正确顺序 → 无违规;反序 → 必须报出违规。
+	// 这对路由(`/admin/x/:id/sign` vs `/admin/x/discounts/sign`)才是 Fiber 里真会被吃掉
+	// 的形态:**同 method、同段数、同尾段、中间一位是字面 vs `:param`**。
+	badPair := []Route{
+		{No: 1, Method: fiber.MethodPost, Path: "/admin/x/:id/sign", Roles: []string{RoleAcademic}},
+		{No: 2, Method: fiber.MethodPost, Path: "/admin/x/discounts/sign", Roles: []string{RoleAcademic}},
+	}
+	if violations := staticShadowViolations(badPair); len(violations) != 1 {
+		t.Errorf("顺序守卫未检出同深度冲突(本应 1 条):%v", violations)
+	}
+	goodPair := []Route{badPair[1], badPair[0]}
+	if violations := staticShadowViolations(goodPair); len(violations) != 0 {
+		t.Errorf("顺序守卫对正确顺序误报:%v", violations)
+	}
+}
+
+// assertStaticBeforeParam 检查"同 method、同段数、某一位是字面段 vs `:param`"的路由对,
+// 字面段必须**先注册**(后注册会被 `:param` 吃掉:Fiber 按注册顺序匹配)。
+func assertStaticBeforeParam(t *testing.T, routes []Route) {
+	t.Helper()
+	for _, v := range staticShadowViolations(routes) {
+		t.Error(v)
+	}
+}
+
+// staticShadowViolations 返回"会被 `:param` 吃掉"的路由对描述(空 = 顺序正确)。
+//
+// 纯函数,便于单测直接构造正/反序路由对验证守卫本身(守卫要能被证伪,见设计 §10.3)。
+func staticShadowViolations(routes []Route) []string {
+	violations := make([]string, 0, 2)
+	for i, param := range routes {
+		if !strings.Contains(param.Path, ":") {
+			continue
+		}
+		for j, literal := range routes {
+			if j <= i || literal.Method != param.Method {
+				continue // 只看"参数段先注册、字面段后注册"这一错误方向
+			}
+			if shadowedBy(param.Path, literal.Path) {
+				violations = append(violations, fmt.Sprintf(
+					"注册顺序错误:端点 %d(%s %s)注册在端点 %d(%s %s)之后,"+
+						"静态段会被 `:param` 吃掉(表现是 2002:id 解析失败)",
+					param.No, param.Method, param.Path, literal.No, literal.Method, literal.Path))
+			}
+		}
+	}
+	return violations
+}
+
+// shadowedBy 判断 paramPath(形如 `/x/:id/y`)是否会吃掉 literalPath(形如 `/x/discounts/y`):
+// 同深度、逐段相同、且 paramPath 在该位是 `:param` 而 literalPath 是字面段。
+func shadowedBy(paramPath, literalPath string) bool {
+	pc := pathSegments(paramPath)
+	lc := pathSegments(literalPath)
+	if len(pc) != len(lc) {
+		return false
+	}
+	wildcard := false
+	for i := range pc {
+		if isParamSegment(pc[i]) {
+			wildcard = true // 该位是参数 → 能匹配 literalPath 的字面段
+			continue
+		}
+		if pc[i] != lc[i] {
+			return false
+		}
+	}
+	return wildcard
+}
+
+// ---------------------------------------------------------------- ② 断言①:method+path 唯一
+
+// TestAssertUniqueMethodPath 断言① 的正例 + 变异反例。
+//
+// 变异反例的意义:Fiber 对重复 `method+path` 不报错、静默丢弃后注册者,
+// 若断言写错(如只比较 path、或只比对本域前 N 条),就抓不到这类缺陷。
+func TestAssertUniqueMethodPath(t *testing.T) {
+	// 正例:完整 40 条 + healthz(模拟全局表里有别的域的路由)
+	db := newHandlerTestDB(t)
+	app, reg := newAcademicRouteTestApp(t, db, RegisterOptions{})
+	app.Get("/healthz", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
+	if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
+		t.Fatalf("40 条路由不该有重复:%v", err)
+	}
+	if len(reg.Routes) != 40 {
+		t.Fatalf("注册条数 = %d,期望 40", len(reg.Routes))
+	}
+
+	// 正例:同 path 不同 method 是**合法**的(端点 12/13 共用 /admin/finance/requests)
+	if err := AssertUniqueMethodPath([]fiber.Route{
+		{Method: "GET", Path: "/admin/finance/requests"},
+		{Method: "POST", Path: "/admin/finance/requests"},
+	}); err != nil {
+		t.Fatalf("同 path 不同 method 不该被判重复:%v", err)
+	}
+
+	// 变异反例 A:纯函数层重复
+	if err := AssertUniqueMethodPath([]fiber.Route{
+		{Method: "GET", Path: "/admin/finance/logs"},
+		{Method: "GET", Path: "/admin/finance/logs"},
+	}); err == nil {
+		t.Fatal("断言① 未检出 method+path 重复(守卫失效)")
+	} else if !strings.Contains(err.Error(), "GET /admin/finance/logs") {
+		t.Errorf("断言① 报错未指出重复项:%v", err)
+	}
+
+	// 变异反例 B:装配层真注册一条重复路由 → Register 必须直接失败
+	// (模拟"第二批端点复制粘贴时把某条重复注册"这一真实事故)。
+	h, hErr := New(Config{DB: db})
+	if hErr != nil {
+		t.Fatalf("handler.New 失败: %v", hErr)
+	}
+	mutated := append([]Route{}, Routes(h)...)
+	mutated = append(mutated, Route{
+		No: 999, Method: fiber.MethodGet, Path: PathFinanceLogs, Name: "重复注册变异",
+		Roles: []string{RoleFinance}, Handler: h.ListLogs,
+	})
+	mutApp := fiber.New(fiber.Config{DisableStartupMessage: true})
+	if _, err := Register(mutApp, h, RegisterOptions{
+		Auth: stubAuth(), RoleGuard: identityhandler.RequireRole, Routes: mutated,
+	}); err == nil {
+		t.Fatal("Register 未拦住重复 method+path(断言① 在装配路径上失效)")
+	}
+}
+
+// ---------------------------------------------------------------- ③ 断言②:一级段白名单
+
+// TestFirstSegmentWhitelist 断言② 的正例 + **变异反例**(证明它不是"恒过")。
+//
+// 危险失败形态说明:若把断言写成"先过滤掉 /auth、/org 再判",则它永远绿——
+// 与本用例的变异反例(`/auth/xxx` 必须变红)正好互为验证。
+func TestFirstSegmentWhitelist(t *testing.T) {
+	// 正例:40 条只占 /admin 与 /student 两个一级段
+	db := newHandlerTestDB(t)
+	_, reg := newAcademicRouteTestApp(t, db, RegisterOptions{})
+	wantPrefixes := []string{"/admin", "/student"}
+	if strings.Join(reg.Prefixes, ",") != strings.Join(wantPrefixes, ",") {
+		t.Fatalf("一级段集合 = %v,期望 %v", reg.Prefixes, wantPrefixes)
+	}
+	if err := AssertFirstSegments(reg.Prefixes); err != nil {
+		t.Fatalf("本域一级段不该越界:%v", err)
+	}
+
+	// 纯函数层:/auth 与 /org 必须被拒(academic 不得重复注册他域路径)
+	for _, bad := range [][]string{
+		{"/admin", "/auth"},
+		{"/org"},
+		{"/admin", "/student", "/pay", "/teaching"},
+	} {
+		if err := AssertFirstSegments(bad); err == nil {
+			t.Errorf("断言② 未拒 %v(守卫失效:这会让 academic 悄悄顶掉 identity/org 的 handler)", bad)
+		}
+	}
+
+	// 变异反例(装配层):在 academic 装配里注册一条 `/auth/xxx` → Register 必须失败。
+	// 这正是团队纪律要求的"能自证失败":删掉注入行即转绿(变异实测输出见交付报告)。
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	mutated := append([]Route{}, Routes(h)...)
+	mutated = append(mutated, Route{
+		No: 998, Method: fiber.MethodGet, Path: "/auth/xxx", Name: "越界一级段变异",
+		Roles: []string{RoleSuperAdmin}, Handler: h.GetAccount,
+	})
+	mutApp := fiber.New(fiber.Config{DisableStartupMessage: true})
+	if _, err := Register(mutApp, h, RegisterOptions{
+		Auth: stubAuth(), RoleGuard: identityhandler.RequireRole, Routes: mutated,
+	}); err == nil {
+		t.Fatal("Register 未拦住 /auth/xxx(断言② 在装配路径上失效 = 恒过)")
+	} else if !strings.Contains(err.Error(), "/auth") {
+		t.Errorf("断言② 报错未指出越界段:%v", err)
+	}
+}
+
+// ---------------------------------------------------------------- ④ 静态段优先(行为)
+
+// TestStaticSegmentBeatsParam `GET /admin/activities/discounts/usable` 必须 **200**。
+//
+// 若它被 `/admin/activities/:id` 吃掉,`:id="discounts"` 解析失败 → **2002**。
+// 这条比"路由表里存在这条"可靠得多:路由表里两条都在,被吃掉时表里照样存在。
+func TestStaticSegmentBeatsParam(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app, _ := newAcademicRouteTestApp(t, db, RegisterOptions{})
+
+	code, msg, _ := doJSON(t, app, fiber.MethodGet,
+		"/admin/activities/discounts/usable?userId=01HSTUDENT00000000000001&institutionId=1&campusId=10",
+		nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("GET /admin/activities/discounts/usable = code %d(%s),期望 200 ——"+
+			"2002 说明被 :id 吃掉;403/1003 说明白名单或装配有问题", code, msg)
+	}
+
+	// 对照:真正走 `:id` 的详情端点也必须通(证明两条路由各自命中自己的 handler)
+	code, msg, _ = doJSON(t, app, fiber.MethodGet, "/admin/activities/1", nil, "X-Test-Token", "t")
+	if code == 2002 {
+		t.Fatalf("GET /admin/activities/1 = 2002(%s):`:id` 路由本身被静态段破坏了", msg)
+	}
+}
+
+// ---------------------------------------------------------------- ⑤ Auth / 角色白名单真的生效
+
+// TestAllRoutesRequireAuth 40 条**全部**需登录:不带 token 一律 1001。
+//
+// 这条防的是"某条端点漏挂 Auth"(公开暴露财务写接口),是最容易在复制粘贴中出错的一处。
+func TestAllRoutesRequireAuth(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app, reg := newAcademicRouteTestApp(t, db, RegisterOptions{})
+	for _, r := range reg.Routes {
+		code, msg, _ := doJSON(t, app, r.Method, requestPath(r.Path), nil) // 无 X-Test-Token
+		if code != 1001 {
+			t.Errorf("端点 %d(%s %s)无 token 时 code = %d(%s),期望 1001(未登录)——该端点漏挂 Auth?",
+				r.No, r.Method, r.Path, code, msg)
+		}
+	}
+}
+
+// TestRoleGuardApplied 白名单真的在路由层生效(非白名单角色 → 1003,白名单角色进 handler)。
+func TestRoleGuardApplied(t *testing.T) {
+	db := newHandlerTestDB(t)
+	app, _ := newAcademicRouteTestApp(t, db, RegisterOptions{})
+
+	// 端点 3(充值)白名单 = super_admin/finance;academic → 1003
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathFinanceRecharges, nil,
+		"X-Test-Token", "t", "X-Test-Scope", "academic"); code != 1003 {
+		t.Errorf("端点3 academic 调充值 = %d(%s),期望 1003(白名单外)", code, msg)
+	}
+	// 同一端点 finance 通过守卫(进入 handler 后因缺 body → 2001,但**不是** 1003)
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathFinanceRecharges, nil,
+		"X-Test-Token", "t"); code == 1003 {
+		t.Errorf("端点3 finance 被 1003 挡住(%s):守卫过度收紧,种子/白名单不一致", msg)
+	}
+	// 端点 17(学生账单)白名单 = student/parent/super_admin;finance → 1003(跨端越权)
+	if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathStudentBill, nil,
+		"X-Test-Token", "t"); code != 1003 {
+		t.Errorf("端点17 finance 调学生账单 = %d(%s),期望 1003", code, msg)
+	}
+	// 端点 26(报名)含 student → 不 1003
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, "/admin/activities/1/sign", nil,
+		"X-Test-Token", "t", "X-Test-Scope", "student"); code == 1003 {
+		t.Errorf("端点26 student 被 1003 挡住(%s):白名单写错", msg)
+	}
+}
+
+// ---------------------------------------------------------------- ⑥ RBAC 种子
+
+// TestRBACSeedMatchesRoutes 种子与路由表 **1:1**(挂什么就种什么,含反向可查)。
+func TestRBACSeedMatchesRoutes(t *testing.T) {
+	db := newHandlerTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	routes := Routes(h)
+	seed, err := BuildRBACSeed(routes)
+	if err != nil {
+		t.Fatalf("BuildRBACSeed 失败: %v", err)
+	}
+
+	// 权限点:一端点一 code,且不重复
+	if len(seed.Permissions) != len(routes) {
+		t.Fatalf("权限点 %d 条,期望 %d(每端点一个)", len(seed.Permissions), len(routes))
+	}
+	codeSet := make(map[string]struct{}, len(seed.Permissions))
+	for _, p := range seed.Permissions {
+		if _, dup := codeSet[p.Code]; dup {
+			t.Errorf("权限点 code 重复:%s", p.Code)
+		}
+		codeSet[p.Code] = struct{}{}
+		if p.Type != "button" {
+			t.Errorf("权限点 %s type = %q,本批只生成 button(菜单树不由路由表推导)", p.Code, p.Type)
+		}
+	}
+
+	// casbin 规则:每条都对应"某角色 + 某路由 path",且 path 必须在路由表里
+	pathSet := make(map[string]struct{}, len(routes))
+	for _, r := range routes {
+		pathSet[r.Path] = struct{}{}
+	}
+	for _, rule := range seed.CasbinRules {
+		if rule.Ptype != "p" || rule.V1 != "*" {
+			t.Errorf("策略行 ptype/dom 非法:%+v(应与既有 /org 种子一致:p + dom=*)", rule)
+		}
+		if _, ok := pathSet[rule.V2]; !ok {
+			t.Errorf("策略路径 %q 不在路由表里(种子漂移)", rule.V2)
+		}
+	}
+
+	// 每个端点 × 每个白名单角色都必须有策略覆盖(合并 method 后:同 path 只留一条)
+	covered := make(map[string]string, len(seed.CasbinRules))
+	for _, rule := range seed.CasbinRules {
+		covered[rule.V0+" "+rule.V2] = rule.V3
+	}
+	for _, r := range routes {
+		for _, role := range r.Roles {
+			methods, ok := covered[role+" "+r.Path]
+			if !ok {
+				t.Errorf("端点 %d(%s %s)缺少角色 %s 的策略", r.No, r.Method, r.Path, role)
+				continue
+			}
+			if !strings.Contains(methods, r.Method) {
+				t.Errorf("端点 %d 策略 %s @ %s 的 method 正则 %q 未覆盖 %s", r.No, role, r.Path, methods, r.Method)
+			}
+		}
+	}
+}
+
+// TestRBACSeedIdempotent 种子**幂等**:连跑两次,行数不变、第二次插入 0 行。
+func TestRBACSeedIdempotent(t *testing.T) {
+	db := newRBACSeedTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	routes := Routes(h)
+
+	first, err := SeedRBAC(t.Context(), db, routes)
+	if err != nil {
+		t.Fatalf("首次 SeedRBAC 失败: %v", err)
+	}
+	if first.InsertedCasbinRules != int64(first.CasbinRules) {
+		t.Errorf("首次插入策略 %d 行,期望全部 %d 行", first.InsertedCasbinRules, first.CasbinRules)
+	}
+	if first.InsertedPermissions != int64(first.Permissions) {
+		t.Errorf("首次插入权限点 %d 行,期望 %d 行", first.InsertedPermissions, first.Permissions)
+	}
+	if first.InsertedRolePermissions != int64(first.RolePermissions) {
+		t.Errorf("首次插入矩阵 %d 行,期望 %d 行", first.InsertedRolePermissions, first.RolePermissions)
+	}
+
+	second, err := SeedRBAC(t.Context(), db, routes)
+	if err != nil {
+		t.Fatalf("二次 SeedRBAC 失败: %v", err)
+	}
+	if second.InsertedCasbinRules != 0 || second.InsertedPermissions != 0 || second.InsertedRolePermissions != 0 {
+		t.Errorf("二次执行仍插入行(非幂等):casbin=%d perm=%d rolePerm=%d",
+			second.InsertedCasbinRules, second.InsertedPermissions, second.InsertedRolePermissions)
+	}
+
+	// 行数与生成数一致(唯一键吸收重复,不产生多余行)
+	var casbinCount, permCount, rolePermCount int64
+	db.Raw("SELECT COUNT(*) FROM casbin_rule").Scan(&casbinCount)
+	db.Raw("SELECT COUNT(*) FROM t_permission").Scan(&permCount)
+	db.Raw("SELECT COUNT(*) FROM t_role_permission").Scan(&rolePermCount)
+	if casbinCount != int64(first.CasbinRules) {
+		t.Errorf("casbin_rule 行数 = %d,期望 %d", casbinCount, first.CasbinRules)
+	}
+	if permCount != int64(first.Permissions) {
+		t.Errorf("t_permission 行数 = %d,期望 %d", permCount, first.Permissions)
+	}
+	if rolePermCount != int64(first.RolePermissions) {
+		t.Errorf("t_role_permission 行数 = %d,期望 %d", rolePermCount, first.RolePermissions)
+	}
+}
+
+// TestRBACSeedEnforcedByCasbin 种子在**真 Casbin 引擎**下可判定(含 `:param` 不过度匹配)。
+//
+// 这是"路径保留 Fiber `:param` 而非照抄 `/org/*` 的 `*`"这一决定的唯一硬证据:
+// `*` 在 keyMatch2 里是跨 `/` 的 `.*`,会把 `POST /admin/activities/1/sign` 也放给 teacher。
+func TestRBACSeedEnforcedByCasbin(t *testing.T) {
+	db := newRBACSeedTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	if _, err := SeedRBAC(t.Context(), db, Routes(h)); err != nil {
+		t.Fatalf("SeedRBAC 失败: %v", err)
+	}
+	enf, err := infrarabc.NewEnforcer(db)
+	if err != nil {
+		t.Fatalf("NewEnforcer 失败: %v", err)
+	}
+	engine := enf.Get()
+
+	cases := []struct {
+		name   string
+		role   string
+		method string
+		path   string
+		want   bool
+	}{
+		{"端点3 充值:finance 放行", "finance", "POST", "/admin/finance/recharges", true},
+		{"端点3 充值:academic 拒绝", "academic", "POST", "/admin/finance/recharges", false},
+		{"端点1 账户快照:academic 放行(:userId 通配)", "academic", "GET", "/admin/finance/accounts/01HUSER", true},
+		{"端点14 工单通过:finance 放行", "finance", "POST", "/admin/finance/requests/9/approve", true},
+		{"端点14 审批:academic 拒绝(审批仅 finance/super_admin)", "academic", "POST", "/admin/finance/requests/9/approve", false},
+		{"端点24 详情:teacher 放行", "teacher", "GET", "/admin/activities/9", true},
+		{"端点26 报名:teacher 拒绝(:param 不跨段匹配)", "teacher", "POST", "/admin/activities/9/sign", false},
+		{"端点31 可用赠送:finance 放行(静态段)", "finance", "GET", "/admin/activities/discounts/usable", true},
+		{"端点17 学生账单:student 放行", "student", "GET", "/student/bill", true},
+		{"端点17 学生账单:finance 拒绝(跨端)", "finance", "GET", "/student/bill", false},
+		{"端点42 删分账流水:DELETE 与 GET 分开授权", "academic", "DELETE", "/admin/divide-into/logs", true},
+		{"端点43 分账流水:GET 也放行", "academic", "GET", "/admin/divide-into/logs", true},
+	}
+	for _, c := range cases {
+		got, eerr := engine.Enforce(c.role, "*", c.path, c.method)
+		if eerr != nil {
+			t.Fatalf("%s:Enforce 出错: %v", c.name, eerr)
+		}
+		if got != c.want {
+			t.Errorf("%s:Enforce(%s, *, %s, %s) = %v,期望 %v",
+				c.name, c.role, c.path, c.method, got, c.want)
+		}
+	}
+}
+
+// TestSeedSQLLooksLikeExistingDDL 渲染出的 SQL 与既有 `core-model-ddl.sql` 写法同构
+// (`INSERT IGNORE` + 反引号列名 + 五列 ptype/v0..v3),便于直接追加进文档而不用改写。
+func TestSeedSQLLooksLikeExistingDDL(t *testing.T) {
+	db := newHandlerTestDB(t)
+	h, err := New(Config{DB: db})
+	if err != nil {
+		t.Fatalf("handler.New 失败: %v", err)
+	}
+	seed, err := BuildRBACSeed(Routes(h))
+	if err != nil {
+		t.Fatalf("BuildRBACSeed 失败: %v", err)
+	}
+	sqlText, err := seed.SQL("mysql")
+	if err != nil {
+		t.Fatalf("SQL 渲染失败: %v", err)
+	}
+	t.Logf("academic RBAC 种子:casbin_rule=%d 条 / t_permission=%d 条 / t_role_permission=%d 条",
+		len(seed.CasbinRules), len(seed.Permissions), len(seed.RolePermissions))
+	for _, want := range []string{
+		"INSERT IGNORE INTO `casbin_rule` (`ptype`, `v0`, `v1`, `v2`, `v3`) VALUES",
+		"INSERT IGNORE INTO `t_permission`",
+		"INSERT IGNORE INTO `t_role_permission`",
+		"'p', 'finance', '*', '/admin/finance/recharges', 'POST'",
+		"'p', 'teacher', '*', '/admin/activities/:id', 'GET'",
+	} {
+		if !strings.Contains(sqlText, want) {
+			t.Errorf("种子 SQL 缺少 %q", want)
+		}
+	}
+	if _, err := seed.SQL("postgres"); err == nil {
+		t.Error("不支持的方言应报错,而不是静默产出非法 SQL")
+	}
+}
+
+// newRBACSeedTestDB 建三张 RBAC 表(与目标态 DDL 的唯一键一致)+ 复用 academic 测试库。
+//
+// 为什么在测试里手写 CREATE TABLE 而不复用 org 的 GORM model:
+// academic 不得 import org 域模型(依赖方向);这里只要**唯一键形态**一致,
+// 幂等的判定依据恰好就是唯一键(uk_policy / PK code / uk_role_permission)。
+func newRBACSeedTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	db := newHandlerTestDB(t)
+	stmts := []string{
+		"CREATE TABLE `casbin_rule` (" +
+			"id INTEGER PRIMARY KEY AUTOINCREMENT, ptype TEXT NOT NULL DEFAULT '', " +
+			"v0 TEXT NOT NULL DEFAULT '', v1 TEXT NOT NULL DEFAULT '', " +
+			"v2 TEXT NOT NULL DEFAULT '', v3 TEXT NOT NULL DEFAULT '', " +
+			"UNIQUE (ptype, v0, v1, v2, v3))",
+		"CREATE TABLE `t_permission` (" +
+			"code TEXT PRIMARY KEY, type TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', " +
+			"parent_code TEXT, path TEXT, icon TEXT, sort INTEGER NOT NULL DEFAULT 0, " +
+			"status TEXT NOT NULL DEFAULT 'active')",
+		"CREATE TABLE `t_role_permission` (" +
+			"id INTEGER PRIMARY KEY AUTOINCREMENT, role_code TEXT NOT NULL, " +
+			"permission_code TEXT NOT NULL, UNIQUE (role_code, permission_code))",
+	}
+	for _, stmt := range stmts {
+		if err := db.Exec(stmt).Error; err != nil {
+			t.Fatalf("建 RBAC 表失败: %v", err)
+		}
+	}
+	return db
+}
+
+// TestFirstSegmentsSorted 一级段的输出顺序稳定(断言② 的报错文案与快照都依赖它)。
+func TestFirstSegmentsSorted(t *testing.T) {
+	got := FirstSegments([]Route{
+		{Path: "/student/bill"},
+		{Path: "/pay/orders"},
+		{Path: "/admin/finance/logs"},
+		{Path: "/admin/activities"},
+	})
+	want := []string{"/admin", "/pay", "/student"}
+	if strings.Join(got, ",") != strings.Join(want, ",") {
+		t.Fatalf("FirstSegments = %v,期望 %v(去重 + 升序)", got, want)
+	}
+	sorted := sort.SliceIsSorted(got, func(i, j int) bool { return got[i] < got[j] })
+	if !sorted {
+		t.Fatalf("FirstSegments 未排序:%v", got)
+	}
+	// /pay 是白名单内的段(端点 18/19/20 将来挂这里),不得报错
+	if err := AssertFirstSegments(got); err != nil {
+		t.Fatalf("/pay 属白名单,不该报错:%v", err)
+	}
+}

+ 499 - 0
internal/academic/handler/seed.go

@@ -0,0 +1,499 @@
+// RBAC 种子生成器(AC-14)——**从路由表生成,不手写清单**。
+//
+// 依据:unified/03-migration/academic-domain-design.md AC-14 行("种子幂等 + 与路由表 1:1 对齐")/
+// `01-architecture/casbin-rbac-design.md` §6(casbin p 规则种子写法:`dom=*`、`keyMatch2` 路径、
+// `regexMatch` 方法、`INSERT IGNORE` 幂等)/ `02-data-model/core-model-ddl.sql`(三张表的既有种子格式)。
+//
+// ---------------------------------------------------------------------------
+// 为什么必须"从路由表生成"
+// ---------------------------------------------------------------------------
+//
+// 本任务只挂**第一批 40 个**端点,后面还有第二批(44~63)。手写种子清单在第二批时**一定会漏**,
+// 而症状极其隐蔽:接口从 **404** 变成 **403(1003)**——404 一眼看出"没挂",
+// 403 看起来像"权限配错",排查者会去查 RBAC 矩阵/角色/token,最后才发现是种子少了一行。
+//
+// 因此三张表全部由 `Route` 派生:
+//
+//	casbin_rule      ← Route.Roles × Route.Path × Route.Method(**1:1** 可反查)
+//	t_permission     ← 每个端点一个 button 权限点(code 由 method+path 推导)
+//	t_role_permission← Route.Roles × 该端点的权限点 code
+//
+// ---------------------------------------------------------------------------
+// 幂等(重复执行不产生重复行)
+// ---------------------------------------------------------------------------
+//
+// 复用既有 DDL 的 `INSERT IGNORE` 写法(MySQL),依赖三张表已有的唯一键:
+//
+//	casbin_rule        UNIQUE uk_policy (ptype,v0,v1,v2,v3)
+//	t_permission       PRIMARY KEY (code)
+//	t_role_permission  UNIQUE uk_role_permission (role_code,permission_code)
+//
+// SQLite(单测库)没有 `INSERT IGNORE`,改写为 `INSERT OR IGNORE`(同语义、同依赖唯一键)。
+// 两种方言都**不做"先查后插"**:先查后插在并发下会插重(两次都查不到),
+// 而唯一键 + IGNORE 是数据库级的原子保证。
+//
+// ---------------------------------------------------------------------------
+// 与既有 `/auth`、`/org` 种子的关系(不另起一套)
+// ---------------------------------------------------------------------------
+//
+// 写法完全同构:`ptype=p` / `v0=角色码` / `v1=*`(不区分机构)/ `v2=path` / `v3=method 正则`,
+// 同一张 `casbin_rule`、同一套 model.conf,不新增表、不新增列、不改 model。
+//
+// **唯一的路径写法差异(有意,且更安全)**:既有种子把 `:id` 段写成 `*`
+// (如 `/org/institutions/*`),本生成器**原样保留 Fiber 的 `:param`** 写法
+// (如 `/admin/activities/:id`)。原因:
+//  1. `keyMatch2` 原生支持 `:param`(转成 `[^/]+`),语义与 Fiber 完全一致,无需翻译;
+//  2. `*` 在 keyMatch2 里是 `.*`(**跨 `/` 匹配**),`/admin/activities/*` 会连带命中
+//     `/admin/activities/1/sign`(POST)等本不该给该角色的端点 —— 那是一条**提权**路径。
+//     保留 `:param` 后规则与路由表逐字符可对齐,`git diff` 就能审出"这条策略给了谁"。
+//  3. 生成器不做"路径翻译"这一步,就不会出现"翻译函数写错、某类路径全放行"的静默缺陷。
+package handler
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"strings"
+
+	"gorm.io/gorm"
+)
+
+// CasbinRule `casbin_rule` 的一行(ptype + v0..v3,对齐 `infra/rbac` 的 `ruleRow`)。
+//
+// 字段语义(casbin model.conf `p = sub, dom, obj, act`):
+//   - Ptype:策略类型(本生成器恒 `p`;`g` 段留空供将来人级授权);
+//   - V0:`sub` = 角色码;
+//   - V1:`dom` = 机构 ID;本批恒 `*`(不区分机构,与既有 /org 种子一致);
+//   - V2:`obj` = 请求 path(keyMatch2,保留下 Fiber 的 `:param` 写法);
+//   - V3:`act` = HTTP method 正则(同 path 多 method 合并为 `GET|POST`)。
+type CasbinRule struct {
+	Ptype string
+	V0    string
+	V1    string
+	V2    string
+	V3    string
+}
+
+// PermissionSeed `t_permission` 的一行(权限点字典;本批只生成 `button` 型)。
+//
+// 为什么只生成 `button` 不生成 `menu`:菜单树是**UI 结构**(`parent_code`/`path`/`icon`/`sort`
+// 描述的是"页面怎么摆",与"后端有哪些接口"不是同一件事,路由表推不出层级与图标。
+// 硬造 menu 会把菜单树写坏(`/auth/issue` 的 menuTree 直接受影响)。菜单项由前端/产品排期单独提。
+type PermissionSeed struct {
+	Code       string
+	Type       string
+	Name       string
+	ParentCode string
+	Path       string
+	Icon       string
+	Sort       int
+	Status     string
+}
+
+// RolePermissionSeed `t_role_permission` 的一行(角色 × 权限点矩阵)。
+type RolePermissionSeed struct {
+	RoleCode       string
+	PermissionCode string
+}
+
+// RBACSeed 一次生成的三张表种子(顺序稳定,便于 diff 与单测快照)。
+type RBACSeed struct {
+	// CasbinRules 接口强制策略(`casbin_rule`,第②层)。
+	CasbinRules []CasbinRule
+	// Permissions 权限点字典(`t_permission`,第①层展示用)。
+	Permissions []PermissionSeed
+	// RolePermissions 角色矩阵(`t_role_permission`)。
+	RolePermissions []RolePermissionSeed
+}
+
+// SeedResult 种子落库结果(幂等判据:第二次执行时 `Inserted*` 全为 0)。
+type SeedResult struct {
+	// CasbinRules 本次生成的策略行数(不是插入行数)。
+	CasbinRules int
+	// Permissions 本次生成的权限点行数。
+	Permissions int
+	// RolePermissions 本次生成的角色矩阵行数。
+	RolePermissions int
+	// InsertedCasbinRules 本次**实际插入**的策略行数(重复行被唯一键忽略,不计入)。
+	InsertedCasbinRules int64
+	// InsertedPermissions 本次实际插入的权限点行数。
+	InsertedPermissions int64
+	// InsertedRolePermissions 本次实际插入的角色矩阵行数。
+	InsertedRolePermissions int64
+}
+
+// BuildRBACSeed 从路由表生成三张表种子。
+//
+// 入参:routes []Route(生产传 `Registration.Routes`,保证"挂什么就种什么")。
+// 出参:
+//   - *RBACSeed:三张表的行(顺序稳定);
+//   - error:权限点 code 冲突(两个端点推导出同一 code)。**冲突必须报错而不是去重**:
+//     去重会让某个端点的权限点在矩阵里"看起来配了、实际没有",是典型的静默失效。
+func BuildRBACSeed(routes []Route) (*RBACSeed, error) {
+	seed := &RBACSeed{
+		CasbinRules:     make([]CasbinRule, 0, len(routes)*2),
+		Permissions:     make([]PermissionSeed, 0, len(routes)),
+		RolePermissions: make([]RolePermissionSeed, 0, len(routes)*2),
+	}
+	codes := make(map[string]int, len(routes))
+	rolePerms := make(map[string]struct{}, len(routes)*2)
+
+	casbin := make(map[casbinKey]map[string]struct{}, len(routes))
+	order := make([]casbinKey, 0, len(routes))
+
+	for _, r := range routes {
+		// ---- t_permission(每个端点一个 button 权限点)
+		code := permissionCodeFor(r.Method, r.Path)
+		if prev, ok := codes[code]; ok {
+			return nil, fmt.Errorf(
+				"academic seed: 端点 %d 与端点 %d 推导出同一权限点 code %q(端点路径 %s %s / %s)——"+
+					"请检查 permissionCodeFor 的推导规则,不要用去重掩盖",
+				prev, r.No, code, r.Method, r.Path, r.Name)
+		}
+		codes[code] = r.No
+		seed.Permissions = append(seed.Permissions, PermissionSeed{
+			Code: code, Type: "button", Name: r.Name,
+			ParentCode: "", Path: "", Icon: "",
+			Sort: r.No, Status: "active",
+		})
+
+		// ---- t_role_permission(角色 × 本端点权限点)
+		for _, role := range r.Roles {
+			key := role + "\x00" + code
+			if _, dup := rolePerms[key]; dup {
+				continue
+			}
+			rolePerms[key] = struct{}{}
+			seed.RolePermissions = append(seed.RolePermissions, RolePermissionSeed{
+				RoleCode: role, PermissionCode: code,
+			})
+		}
+
+		// ---- casbin_rule(角色 × path,多 method 合并)
+		for _, role := range r.Roles {
+			key := casbinKey{role: role, path: r.Path}
+			if _, ok := casbin[key]; !ok {
+				casbin[key] = make(map[string]struct{}, 2)
+				order = append(order, key)
+			}
+			casbin[key][r.Method] = struct{}{}
+		}
+	}
+
+	// 按"首次出现顺序"输出(顺序稳定:注册顺序即契约端点顺序),method 按固定序合并。
+	for _, key := range order {
+		methods := make([]string, 0, len(casbin[key]))
+		for m := range casbin[key] {
+			methods = append(methods, m)
+		}
+		sort.Slice(methods, func(i, j int) bool { return methodRank(methods[i]) < methodRank(methods[j]) })
+		seed.CasbinRules = append(seed.CasbinRules, CasbinRule{
+			Ptype: "p", V0: key.role, V1: "*", V2: key.path, V3: strings.Join(methods, "|"),
+		})
+	}
+	return seed, nil
+}
+
+// casbinKey casbin 规则的去重键(角色 + path;method 合并到 v3)。
+type casbinKey struct {
+	role string
+	path string
+}
+
+// methodRank method 的稳定排序序(让 `GET|POST` 这样的正则可复现,避免 map 遍历顺序抖动)。
+func methodRank(method string) int {
+	switch method {
+	case "GET":
+		return 0
+	case "POST":
+		return 1
+	case "PUT":
+		return 2
+	case "PATCH":
+		return 3
+	case "DELETE":
+		return 4
+	default:
+		return 9
+	}
+}
+
+// permissionCodeFor 从 `method + path` 推导三段式权限点码 `academic.<资源>.<动作>`。
+//
+// 推导规则(**纯函数、无表驱动**:第二批端点接进来时零改动):
+//
+//	资源 = 路径去掉一级段(`/admin`|`/student`)与参数段后,字面段用 `_` 连接;
+//	       若"路径尾是 `/:param/<动词>`",该动词**不计入资源**(它是动作)。
+//	动作 = GET 且路径含参数段 → `get`;GET 无参数段 → `list`;
+//	       非 GET 且尾是 `/:param/<动词>` → 该动词(连字符转下划线);
+//	       其余 → POST `create` / PUT `update` / DELETE `delete`。
+//
+// 样例(可对照交付报告的 40 行注册表逐条核):
+//
+//	GET  /admin/finance/accounts/:userId          → academic.finance_accounts.get
+//	POST /admin/finance/refunds/custom            → academic.finance_refunds_custom.create
+//	POST /admin/finance/requests/:id/approve      → academic.finance_requests.approve
+//	GET  /admin/activities/discounts/usable       → academic.activities_discounts_usable.list
+//	POST /admin/activities/:id/cancel-sign        → academic.activities.cancel_sign
+//	GET  /student/bill                            → academic.bill.list
+//	PUT  /admin/divide-into/members/:userId/quota → academic.divide_into_members.quota
+//
+// ⚠ **本规则是本任务推导出来的,契约里没有 academic 的权限点码词表**(现有字典只有
+// `org.*` / `role.*`)。若产品/前端已有约定词表,只改本函数一处即可全量重算——
+// 这正是"从路由表生成"的价值:不存在"改了 40 行漏了 3 行"。
+func permissionCodeFor(method, path string) string {
+	segs := pathSegments(path)
+	// 去一级段(/admin 或 /student;将来 /pay 同理)。
+	if len(segs) > 0 {
+		segs = segs[1:]
+	}
+	if len(segs) == 0 {
+		// 理论上不会出现(本批所有端点都至少两段),兜底成一个稳定的 code。
+		return "academic.root." + strings.ToLower(method)
+	}
+
+	hasParam := false
+	for _, seg := range segs {
+		if isParamSegment(seg) {
+			hasParam = true
+			break
+		}
+	}
+
+	// 尾形态 `/:param/<动词>`:最后两段为"参数 + 字面段"。
+	tailVerb := ""
+	if n := len(segs); n >= 2 && isParamSegment(segs[n-2]) && !isParamSegment(segs[n-1]) {
+		tailVerb = normalizeSegment(segs[n-1])
+	}
+
+	literals := make([]string, 0, len(segs))
+	for _, seg := range segs {
+		if isParamSegment(seg) {
+			continue
+		}
+		literals = append(literals, normalizeSegment(seg))
+	}
+
+	action := ""
+	switch method {
+	case "GET":
+		if hasParam {
+			action = "get"
+		} else {
+			action = "list"
+		}
+	default:
+		if tailVerb != "" {
+			action = tailVerb
+			// 尾动词是动作,不进资源段(否则会出现 `..._approve.approve`)。
+			literals = literals[:len(literals)-1]
+		} else {
+			switch method {
+			case "POST":
+				action = "create"
+			case "PUT":
+				action = "update"
+			case "DELETE":
+				action = "delete"
+			default:
+				action = strings.ToLower(method)
+			}
+		}
+	}
+	return "academic." + strings.Join(literals, "_") + "." + action
+}
+
+// pathSegments 切分路径为段(丢弃前导空段)。
+func pathSegments(path string) []string {
+	trimmed := strings.Trim(path, "/")
+	if trimmed == "" {
+		return nil
+	}
+	return strings.Split(trimmed, "/")
+}
+
+// isParamSegment 判断是否参数段(Fiber 的 `:name` 与可选 `:name?` 均算)。
+func isParamSegment(seg string) bool { return strings.HasPrefix(seg, ":") }
+
+// normalizeSegment 归一化字面段:连字符转下划线(`refund-by-activity` → `refund_by_activity`)。
+func normalizeSegment(seg string) string { return strings.ReplaceAll(seg, "-", "_") }
+
+// SQL 渲染:与既有 `core-model-ddl.sql` 的种子写法逐字对齐(`INSERT IGNORE INTO ... VALUES`)。
+
+// CasbinRulesSQL 渲染 `casbin_rule` 的 INSERT 语句。
+//
+// 入参:dialect `mysql` / `sqlite`(决定 `INSERT IGNORE` 还是 `INSERT OR IGNORE`)。
+// 出参:单条多值 INSERT(无行时返回空串)。
+func (s *RBACSeed) CasbinRulesSQL(dialect string) (string, error) {
+	if len(s.CasbinRules) == 0 {
+		return "", nil
+	}
+	prefix, err := insertIgnorePrefix("casbin_rule", "`ptype`, `v0`, `v1`, `v2`, `v3`", dialect)
+	if err != nil {
+		return "", err
+	}
+	rows := make([]string, 0, len(s.CasbinRules))
+	for _, r := range s.CasbinRules {
+		rows = append(rows, fmt.Sprintf("(%s)", strings.Join([]string{
+			sqlString(r.Ptype), sqlString(r.V0), sqlString(r.V1), sqlString(r.V2), sqlString(r.V3),
+		}, ", ")))
+	}
+	return prefix + "\n  " + strings.Join(rows, ",\n  ") + ";", nil
+}
+
+// PermissionsSQL 渲染 `t_permission` 的 INSERT 语句。
+func (s *RBACSeed) PermissionsSQL(dialect string) (string, error) {
+	if len(s.Permissions) == 0 {
+		return "", nil
+	}
+	prefix, err := insertIgnorePrefix("t_permission",
+		"`code`, `type`, `name`, `parent_code`, `path`, `icon`, `sort`, `status`", dialect)
+	if err != nil {
+		return "", err
+	}
+	rows := make([]string, 0, len(s.Permissions))
+	for _, p := range s.Permissions {
+		parent := "NULL"
+		if p.ParentCode != "" {
+			parent = sqlString(p.ParentCode)
+		}
+		path := "NULL"
+		if p.Path != "" {
+			path = sqlString(p.Path)
+		}
+		icon := "NULL"
+		if p.Icon != "" {
+			icon = sqlString(p.Icon)
+		}
+		rows = append(rows, fmt.Sprintf("(%s)",
+			strings.Join([]string{
+				sqlString(p.Code), sqlString(p.Type), sqlString(p.Name),
+				parent, path, icon, fmt.Sprintf("%d", p.Sort), sqlString(p.Status),
+			}, ", ")))
+	}
+	return prefix + "\n  " + strings.Join(rows, ",\n  ") + ";", nil
+}
+
+// RolePermissionsSQL 渲染 `t_role_permission` 的 INSERT 语句。
+func (s *RBACSeed) RolePermissionsSQL(dialect string) (string, error) {
+	if len(s.RolePermissions) == 0 {
+		return "", nil
+	}
+	prefix, err := insertIgnorePrefix("t_role_permission", "`role_code`, `permission_code`", dialect)
+	if err != nil {
+		return "", err
+	}
+	rows := make([]string, 0, len(s.RolePermissions))
+	for _, rp := range s.RolePermissions {
+		rows = append(rows, fmt.Sprintf("(%s, %s)", sqlString(rp.RoleCode), sqlString(rp.PermissionCode)))
+	}
+	return prefix + "\n  " + strings.Join(rows, ",\n  ") + ";", nil
+}
+
+// SQL 渲染三张表的完整种子脚本(可直接追加进 `core-model-ddl.sql`,逐行可 diff)。
+func (s *RBACSeed) SQL(dialect string) (string, error) {
+	casbin, err := s.CasbinRulesSQL(dialect)
+	if err != nil {
+		return "", err
+	}
+	perms, err := s.PermissionsSQL(dialect)
+	if err != nil {
+		return "", err
+	}
+	roles, err := s.RolePermissionsSQL(dialect)
+	if err != nil {
+		return "", err
+	}
+	parts := []string{
+		"-- academic 域 RBAC 种子(AC-14,**由路由表生成,勿手改**:改路由后重新生成)",
+		"-- casbin_rule:第②层接口强制(角色 × path × method;dom=* 不区分机构;path 保留 Fiber 的 :param)",
+		casbin,
+		"-- t_permission:button 型权限点(菜单树是 UI 结构,不由路由表推导)",
+		perms,
+		"-- t_role_permission:角色 × 权限点矩阵",
+		roles,
+	}
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		if strings.TrimSpace(p) == "" {
+			continue
+		}
+		out = append(out, p)
+	}
+	return strings.Join(out, "\n") + "\n", nil
+}
+
+// insertIgnorePrefix 生成各方言的 `INSERT IGNORE` 前缀。
+func insertIgnorePrefix(table, columns, dialect string) (string, error) {
+	switch dialect {
+	case "mysql":
+		return "INSERT IGNORE INTO `" + table + "` (" + columns + ") VALUES", nil
+	case "sqlite":
+		return "INSERT OR IGNORE INTO `" + table + "` (" + columns + ") VALUES", nil
+	default:
+		return "", fmt.Errorf("academic seed: 不支持的方言 %q(仅 mysql/sqlite)", dialect)
+	}
+}
+
+// sqlString 把字符串渲染为 SQL 字面量(单引号转义;本生成器的输入来自路由表,仍做转义以防将来注错)。
+func sqlString(s string) string {
+	return "'" + strings.ReplaceAll(s, "'", "''") + "'"
+}
+
+// SeedRBAC 生成并落库 RBAC 种子(**幂等**:重复执行由唯一键 + IGNORE 吸收)。
+//
+// 入参:
+//   - ctx context.Context:执行上下文;
+//   - db *gorm.DB:数据库句柄(方言决定 IGNORE 关键字;仅支持 mysql/sqlite);
+//   - routes []Route:路由表(生产传 `Registration.Routes`——**挂什么就种什么**)。
+//
+// 出参:
+//   - *SeedResult:生成行数 + 实际插入行数(`Inserted*` 全 0 即"已是最新",可直接用于幂等断言);
+//   - error:权限点 code 冲突 / 方言不支持 / 执行失败。
+//
+// 装配位置:`cmd/api` 装配层在 `UNIFIED_AUTO_MIGRATE=1`(dev 建表守卫)时调用;
+// 生产按"DDL 先行"纪律用本函数渲染的 SQL(见 `SQL`)随迁移脚本执行,不在启动时写权限表。
+func SeedRBAC(ctx context.Context, db *gorm.DB, routes []Route) (*SeedResult, error) {
+	if db == nil {
+		return nil, fmt.Errorf("academic seed: DB 未注入")
+	}
+	seed, err := BuildRBACSeed(routes)
+	if err != nil {
+		return nil, err
+	}
+	dialect := db.Dialector.Name()
+	res := &SeedResult{
+		CasbinRules:     len(seed.CasbinRules),
+		Permissions:     len(seed.Permissions),
+		RolePermissions: len(seed.RolePermissions),
+	}
+
+	if sqlText, serr := seed.CasbinRulesSQL(dialect); serr != nil {
+		return nil, serr
+	} else if sqlText != "" {
+		tx := db.WithContext(ctx).Exec(sqlText)
+		if tx.Error != nil {
+			return nil, fmt.Errorf("academic seed: 写 casbin_rule 失败: %w", tx.Error)
+		}
+		res.InsertedCasbinRules = tx.RowsAffected
+	}
+	if sqlText, serr := seed.PermissionsSQL(dialect); serr != nil {
+		return nil, serr
+	} else if sqlText != "" {
+		tx := db.WithContext(ctx).Exec(sqlText)
+		if tx.Error != nil {
+			return nil, fmt.Errorf("academic seed: 写 t_permission 失败: %w", tx.Error)
+		}
+		res.InsertedPermissions = tx.RowsAffected
+	}
+	if sqlText, serr := seed.RolePermissionsSQL(dialect); serr != nil {
+		return nil, serr
+	} else if sqlText != "" {
+		tx := db.WithContext(ctx).Exec(sqlText)
+		if tx.Error != nil {
+			return nil, fmt.Errorf("academic seed: 写 t_role_permission 失败: %w", tx.Error)
+		}
+		res.InsertedRolePermissions = tx.RowsAffected
+	}
+	return res, nil
+}