| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499 |
- // 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
- }
|