From 4a8f97b1eea6f14399708625953a3ae671c0f483 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 5 Sep 2026 00:06:17 +0800 Subject: [PATCH] =?UTF-8?q?feat=E2=9C=A8:=20implement=20the=20menu=20seede?= =?UTF-8?q?r=20an=20application=20registers=20against?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core's seed package defines what an application may ask for and leaves the writing to the host, which is the only side that knows its own tables. No host implemented it, so SeedMenus returned ErrNoSeeder and an application's menus never appeared. adminSeeder writes all four kinds of row, not the two an obvious reading would stop at: without sys_menu_api_rule and the sys_role_menu / casbin_rule grants, the menu exists and no role can reach it. Ids are always autoincrement, never caller-assigned - checksilent's menu-id-collision check reads literals in this repository's tree and cannot see an application in the module cache, so the collision is removed by construction instead of guarded. The runtime validation covers what a static scan cannot reach for a third-party spec: duplicate codes, unresolved parents and api references, an unknown kind, and a sort outside sys_menu.sort's tinyint range. MenuSpec carries no menu name, so one is synthesised from the app code and the spec code - two applications both choosing "list" would otherwise collide on the frontend's keep-alive key. It lives in app/admin/service because cmd links both subcommands into one binary, so its init runs whichever one is invoked, and cmd/migrate never has to import app/admin to reach it. Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx --- app/admin/service/seed.go | 302 +++++++++++++++++++++++++++++++++ app/admin/service/seed_test.go | 239 ++++++++++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 app/admin/service/seed.go create mode 100644 app/admin/service/seed_test.go diff --git a/app/admin/service/seed.go b/app/admin/service/seed.go new file mode 100644 index 00000000..e61453c4 --- /dev/null +++ b/app/admin/service/seed.go @@ -0,0 +1,302 @@ +package service + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "gorm.io/gorm" + + contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models" + "github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed" + + "go-admin/app/admin/models" +) + +// adminSeeder is go-admin's own implementation of seed.Seeder: it turns the +// MenuSpec/ApiSpec values a third-party application asks for into rows +// across the four tables a visible, working menu entry needs - sys_api, +// sys_menu, sys_menu_api_rule, and sys_role_menu/casbin_rule - following the +// same shape cmd/migrate/migration/version/1786700001000_demo_menu.go +// already hand-writes for the host's own demo module. +// +// See go-admin-core's docs/contract.md, "Application-supplied menu and API +// entries", for the requirements this satisfies, and the security note on +// seed.Seeder for what this boundary does and does not protect against: an +// application already holds the same *gorm.DB this receives and could write +// sys_menu/sys_api/casbin_rule directly, bypassing this entirely. +type adminSeeder struct{} + +func init() { + seed.RegisterSeeder(adminSeeder{}) +} + +// adminRoleKey is the role every seeded menu is granted to. This mirrors +// 1786700001000_demo_menu.go's own convention rather than inventing a +// second one: MenuSpec carries no "which roles should see this" field for a +// Seeder to consult instead, and admin is the one role guaranteed to exist +// once the framework's own seed data has run. +const adminRoleKey = "admin" + +// menuSortRange is what sys_menu.sort's column type actually holds. +// +// sort is `gorm:"size:4"`, which MySQL builds as a tinyint (-128..127); +// sqlite ignores the width and accepts anything, so this only ever surfaces +// on a real install, mid-migration, as Error 1264 - by which point the +// migration has already run other, non-transactional DDL that will not be +// retried. tools/checksilent's menu-sort-overflow check catches this for +// every MenuSpec-shaped literal committed to this repository, but it walks +// the repository's own source tree: a third-party application living in the +// module cache is invisible to it. This is the equivalent check for that +// application, run when its migration actually calls SeedMenus rather than +// never. +const ( + menuSortMin = -128 + menuSortMax = 127 +) + +func (adminSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, apis []seed.ApiSpec) error { + apiRows, err := seedApis(tx, appCode, apis) + if err != nil { + return fmt.Errorf("seed: app %q: apis: %w", appCode, err) + } + + menuIDs, err := seedMenuTree(tx, appCode, menus, apiRows) + if err != nil { + return fmt.Errorf("seed: app %q: menus: %w", appCode, err) + } + + if len(menuIDs) == 0 { + return nil + } + return grantToAdminRole(tx, menuIDs, apiRows) +} + +// seedApis writes one sys_api row per ApiSpec and returns them keyed by +// ApiSpec.Code, so seedMenuTree can resolve a MenuSpec's ApiCodes into the +// rows sys_menu_api_rule needs to reference. +// +// sys_api.id is left to autoincrement rather than assigned by the caller, +// unlike 1786700001000_demo_menu.go's hand-picked ids: that migration is +// the one file tools/checksilent's menu-id-collision check can see, because +// it lives in this repository; nothing plays that role for a third-party +// application's ids in the module cache. Never accepting a caller-chosen id +// here removes the collision this Seeder has no way to detect instead of +// trying to detect it after the fact. +func seedApis(tx *gorm.DB, appCode string, apis []seed.ApiSpec) (map[string]models.SysApi, error) { + seen := make(map[string]bool, len(apis)) + rows := make(map[string]models.SysApi, len(apis)) + for _, a := range apis { + if a.Code == "" { + return nil, errors.New("ApiSpec.Code must not be empty") + } + if seen[a.Code] { + return nil, fmt.Errorf("duplicate ApiSpec.Code %q", a.Code) + } + seen[a.Code] = true + + row := models.SysApi{ + Handle: a.Handle, + Title: a.Title, + Path: a.Path, + Action: a.Method, + Type: "SYS", + AppCode: appCode, + } + if err := tx.Create(&row).Error; err != nil { + return nil, fmt.Errorf("api %q: %w", a.Code, err) + } + rows[a.Code] = row + } + return rows, nil +} + +// seedMenuTree writes one sys_menu row per MenuSpec, resolving Parent/Code +// references into parent_id/paths, and returns every menu id created so the +// caller can grant them to a role. +// +// Specs do not have to be given in parent-before-child order: this makes +// repeated passes over the remaining specs, creating whichever ones have +// their Parent (if any) already created, until every spec is placed. A +// spec whose Parent never resolves - naming a Code missing from this call, +// or only reachable through a cycle - stops making progress and is reported +// rather than looping forever. +func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows map[string]models.SysApi) ([]int, error) { + byCode := make(map[string]seed.MenuSpec, len(specs)) + for _, s := range specs { + if s.Code == "" { + return nil, errors.New("MenuSpec.Code must not be empty") + } + if _, dup := byCode[s.Code]; dup { + return nil, fmt.Errorf("duplicate MenuSpec.Code %q", s.Code) + } + if err := validateMenuSpec(s); err != nil { + return nil, fmt.Errorf("%q: %w", s.Code, err) + } + byCode[s.Code] = s + } + + created := make(map[string]models.SysMenu, len(specs)) + ids := make([]int, 0, len(specs)) + + for len(created) < len(specs) { + progressed := false + for _, s := range specs { + if _, done := created[s.Code]; done { + continue + } + + var parentRow models.SysMenu + if s.Parent != "" { + parent, ok := created[s.Parent] + if !ok { + if _, exists := byCode[s.Parent]; !exists { + return nil, fmt.Errorf("%q: Parent %q is not a Code in this call", s.Code, s.Parent) + } + continue // s.Parent exists but has not been created yet; retry next pass + } + parentRow = parent + } + + row := models.SysMenu{ + MenuName: menuName(appCode, s.Code), + Title: s.Title, + Icon: s.Icon, + Path: s.Path, + MenuType: s.Kind, + Permission: s.Permission, + ParentId: parentRow.MenuId, + Component: s.Component, + Sort: s.Sort, + // Hidden by default and marked as an external frame, the + // same defaults 1786700001000_demo_menu.go seeds its own + // menu with: a freshly installed application's menu should + // not need an administrator to first find and unhide it. + Visible: "0", + IsFrame: "1", + AppCode: appCode, + } + for _, code := range s.ApiCodes { + api, ok := apiRows[code] + if !ok { + return nil, fmt.Errorf("%q: ApiCodes references %q, which is not an ApiSpec.Code in this call", s.Code, code) + } + // The full row, not just {Id: api.Id}: gorm's many2many + // association save upserts an associated row whose primary + // key is already set, so a stub carrying only Id would + // overwrite every other column of an sys_api row this same + // call just wrote with zero values. + row.SysApi = append(row.SysApi, api) + } + + if err := tx.Create(&row).Error; err != nil { + return nil, fmt.Errorf("%q: %w", s.Code, err) + } + + // paths is a materialized path from the root ("/0"), built from + // ids that only exist once the row above is created - the same + // two-step create-then-update 1786700001000_demo_menu.go's + // hand-assigned ids let it do in one literal, sequenced here + // instead. + if s.Parent == "" { + row.Paths = "/0/" + strconv.Itoa(row.MenuId) + } else { + row.Paths = parentRow.Paths + "/" + strconv.Itoa(row.MenuId) + } + if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId). + Update("paths", row.Paths).Error; err != nil { + return nil, fmt.Errorf("%q: writing paths: %w", s.Code, err) + } + + created[s.Code] = row + ids = append(ids, row.MenuId) + progressed = true + } + if !progressed { + return nil, fmt.Errorf("unresolved Parent reference(s) among %d remaining spec(s); check for a cycle", len(specs)-len(created)) + } + } + return ids, nil +} + +// validateMenuSpec rejects the malformed input tools/checksilent's +// menu-sort-overflow and Kind-adjacent checks would catch for an in-tree +// seed but cannot for a third-party application's - see menuSortRange's doc +// comment. +func validateMenuSpec(s seed.MenuSpec) error { + switch s.Kind { + case contractmodels.Directory, contractmodels.Menu, contractmodels.Button: + default: + return fmt.Errorf("Kind %q is not one of Directory/Menu/Button", s.Kind) + } + if s.Sort < menuSortMin || s.Sort > menuSortMax { + return fmt.Errorf("Sort %d does not fit sys_menu.sort's tinyint column (%d..%d)", s.Sort, menuSortMin, menuSortMax) + } + return nil +} + +// menuName synthesizes sys_menu.menu_name from appCode and the spec's Code, +// since MenuSpec carries no field of its own for it - contract/seed's +// package doc says a MenuSpec is what rendering a menu and checking a +// button permission need, not a mirror of sys_menu's columns. +// +// PascalCasing both and concatenating them, rather than using Code alone, +// is what keeps two applications that both picked the plain word "list" as +// a Code from producing the identical menu_name: the frontend's keep-alive +// cache matches a route by this exact string, not by (appCode, Code), so a +// collision there is a UI bug, not a database error, and nothing else here +// would ever surface it. +func menuName(appCode, code string) string { + return pascalCase(appCode) + pascalCase(code) +} + +func pascalCase(s string) string { + var b strings.Builder + for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' }) { + b.WriteString(strings.ToUpper(part[:1])) + b.WriteString(part[1:]) + } + return b.String() +} + +// grantToAdminRole is sys_role_menu and casbin_rule: the two tables +// go-admin-core's contract.md requires alongside sys_menu/sys_api, without +// which a seeded menu is invisible to every role and its apis are +// authorized for no one. +// +// It follows 1786700001000_demo_menu.go's exact pattern, including +// tolerating a missing admin role: a database that has not yet run the +// framework's own seed data (config/db.sql, inside 1599190683659_tables.go) +// has nothing to grant to yet, and namespacedKey's ordering guarantee - every +// framework migration sorts before every app-prefixed one - means that +// should not happen in practice, but failing this call over it would be +// worse than a menu with no grant yet. +func grantToAdminRole(tx *gorm.DB, menuIDs []int, apiRows map[string]models.SysApi) error { + var role models.SysRole + if err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + + for _, id := range menuIDs { + if err := tx.Exec( + "INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = ? AND menu_id = ?)", + role.RoleId, id, role.RoleId, id, + ).Error; err != nil { + return err + } + } + + for _, a := range apiRows { + if err := tx.Exec( + "INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)", + role.RoleKey, a.Path, a.Action, role.RoleKey, a.Path, a.Action, + ).Error; err != nil { + return err + } + } + return nil +} diff --git a/app/admin/service/seed_test.go b/app/admin/service/seed_test.go new file mode 100644 index 00000000..0e5558aa --- /dev/null +++ b/app/admin/service/seed_test.go @@ -0,0 +1,239 @@ +package service + +import ( + "errors" + "strconv" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models" + "github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed" + + "go-admin/app/admin/models" +) + +// newSeedTestDB builds the tables adminSeeder.SeedMenus writes to. sys_menu, +// sys_api, sys_role and sys_role_menu (GORM's own join table for +// SysRole.SysMenu) come from AutoMigrate; casbin_rule does not have a GORM +// model anywhere in this codebase - see 1786700001000_demo_menu.go's own +// comment on why models.CasbinRule (-> sys_casbin_rule) is the wrong table - +// so it is created directly, matching the columns grantToAdminRole's INSERT +// addresses. +func newSeedTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&models.SysMenu{}, &models.SysApi{}, &models.SysRole{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + if err := db.Exec(`CREATE TABLE casbin_rule ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ptype TEXT, v0 TEXT, v1 TEXT, v2 TEXT, v3 TEXT, v4 TEXT, v5 TEXT + )`).Error; err != nil { + t.Fatalf("create casbin_rule: %v", err) + } + return db +} + +func seedAdminRole(t *testing.T, db *gorm.DB) models.SysRole { + t.Helper() + role := models.SysRole{RoleName: "Administrator", RoleKey: adminRoleKey} + if err := db.Create(&role).Error; err != nil { + t.Fatalf("seed admin role: %v", err) + } + return role +} + +// This is the acceptance case go-admin-core's docs/contract.md requires: one +// SeedMenus call populates all four tables a visible, working menu entry +// needs, every row tagged with the appCode it was called with, and the +// parent/child tree resolved into sys_menu's parent_id/paths. +func TestSeedMenusPopulatesAllFourTables(t *testing.T) { + db := newSeedTestDB(t) + seedAdminRole(t, db) + + menus := []seed.MenuSpec{ + {Code: "dir", Kind: contractmodels.Directory, Title: "Order Example", Path: "/apps/order", Component: "Layout", Sort: 10}, + {Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: "Orders", Path: "list", Component: "apps/order/order/index", Sort: 1, ApiCodes: []string{"list"}}, + {Code: "btn-create", Parent: "list", Kind: contractmodels.Button, Title: "Create", Permission: "order:order:create", Sort: 1}, + } + apis := []seed.ApiSpec{ + {Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"}, + } + + err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", menus, apis) + }) + if err != nil { + t.Fatalf("SeedMenus: %v", err) + } + + var apiRows []models.SysApi + if err := db.Find(&apiRows).Error; err != nil { + t.Fatal(err) + } + if len(apiRows) != 1 || apiRows[0].AppCode != "order" || apiRows[0].Path != "/api/v1/order" { + t.Fatalf("sys_api = %+v", apiRows) + } + + var menuRows []models.SysMenu + if err := db.Order("sort").Find(&menuRows).Error; err != nil { + t.Fatal(err) + } + if len(menuRows) != 3 { + t.Fatalf("sys_menu has %d rows, want 3: %+v", len(menuRows), menuRows) + } + byName := map[string]models.SysMenu{} + for _, m := range menuRows { + if m.AppCode != "order" { + t.Errorf("menu %q app_code = %q, want order", m.MenuName, m.AppCode) + } + byName[m.MenuName] = m + } + dir, ok := byName[menuName("order", "dir")] + if !ok || dir.ParentId != 0 || dir.Paths != "/0/"+strconv.Itoa(dir.MenuId) { + t.Fatalf("dir menu = %+v", dir) + } + list, ok := byName[menuName("order", "list")] + if !ok || list.ParentId != dir.MenuId || list.Paths != dir.Paths+"/"+strconv.Itoa(list.MenuId) { + t.Fatalf("list menu = %+v (dir=%+v)", list, dir) + } + btn, ok := byName[menuName("order", "btn-create")] + if !ok || btn.ParentId != list.MenuId { + t.Fatalf("btn menu = %+v (list=%+v)", btn, list) + } + + // sys_menu_api_rule: gorm's own many2many join table for SysMenu.SysApi. + var joinCount int64 + if err := db.Table("sys_menu_api_rule"). + Where("sys_menu_menu_id = ? AND sys_api_id = ?", list.MenuId, apiRows[0].Id). + Count(&joinCount).Error; err != nil { + t.Fatal(err) + } + if joinCount != 1 { + t.Errorf("sys_menu_api_rule has %d row(s) linking list to its api, want 1", joinCount) + } + + // sys_role_menu: every seeded menu granted to the admin role. + var roleMenuCount int64 + if err := db.Table("sys_role_menu").Count(&roleMenuCount).Error; err != nil { + t.Fatal(err) + } + if roleMenuCount != 3 { + t.Errorf("sys_role_menu has %d row(s), want 3 (one per seeded menu)", roleMenuCount) + } + + // casbin_rule: the api's path/method granted to the admin role. + var casbinCount int64 + if err := db.Table("casbin_rule"). + Where("ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?", adminRoleKey, "/api/v1/order", "GET"). + Count(&casbinCount).Error; err != nil { + t.Fatal(err) + } + if casbinCount != 1 { + t.Errorf("casbin_rule has %d matching row(s), want 1", casbinCount) + } +} + +// A database that has not run the framework's own seed data yet (no admin +// role) must not fail SeedMenus - 1786700001000_demo_menu.go tolerates +// exactly the same condition for the host's own demo module. +func TestSeedMenusToleratesMissingAdminRole(t *testing.T) { + db := newSeedTestDB(t) + + err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", []seed.MenuSpec{ + {Code: "dir", Kind: contractmodels.Directory, Title: "Order"}, + }, nil) + }) + if err != nil { + t.Fatalf("SeedMenus: %v", err) + } + + var roleMenuCount int64 + if err := db.Table("sys_role_menu").Count(&roleMenuCount).Error; err != nil { + t.Fatal(err) + } + if roleMenuCount != 0 { + t.Errorf("sys_role_menu has %d row(s) with no role to grant to", roleMenuCount) + } +} + +func TestSeedMenusRejectsMalformedSpecs(t *testing.T) { + cases := []struct { + name string + menus []seed.MenuSpec + apis []seed.ApiSpec + want string + }{ + { + name: "duplicate menu code", + menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Directory}, {Code: "a", Kind: contractmodels.Directory}}, + want: `duplicate MenuSpec.Code "a"`, + }, + { + name: "unresolved parent", + menus: []seed.MenuSpec{{Code: "a", Parent: "missing", Kind: contractmodels.Menu}}, + want: `Parent "missing" is not a Code in this call`, + }, + { + name: "unresolved api code", + menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Menu, ApiCodes: []string{"missing"}}}, + want: `ApiCodes references "missing"`, + }, + { + name: "unknown kind", + menus: []seed.MenuSpec{{Code: "a", Kind: "X"}}, + want: `Kind "X" is not one of Directory/Menu/Button`, + }, + { + name: "sort overflows a tinyint", + menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Directory, Sort: 900}}, + want: `Sort 900 does not fit sys_menu.sort's tinyint column`, + }, + { + name: "duplicate api code", + apis: []seed.ApiSpec{{Code: "x"}, {Code: "x"}}, + want: `duplicate ApiSpec.Code "x"`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db := newSeedTestDB(t) + err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", tc.menus, tc.apis) + }) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want it to contain %q", err, tc.want) + } + }) + } +} + +// TestSeederIsRegistered pins the registration itself, not the behaviour. +// +// Every other test here calls adminSeeder{}.SeedMenus directly, which proves +// the implementation is right and proves nothing about whether anything ever +// reaches it: delete the RegisterSeeder call in init() and they all stay +// green, while a real migrate fails with ErrNoSeeder and no menu is written. +// Going through the package-level SeedMenus is what closes that gap - it is +// the door an application actually knocks on. +func TestSeederIsRegistered(t *testing.T) { + db := newSeedTestDB(t) + err := db.Transaction(func(tx *gorm.DB) error { + return seed.SeedMenus(tx, "probe", []seed.MenuSpec{{ + Code: "root", Kind: contractmodels.Directory, Title: "Probe", Sort: 1, + }}, nil) + }) + if errors.Is(err, seed.ErrNoSeeder) { + t.Fatal("no Seeder is registered: an application's SeedMenus would write no menu at all") + } + if err != nil { + t.Fatalf("SeedMenus through the package-level entry point: %v", err) + } +}