From b3a740ab2a1a8f7772f8eff744fb975320f90767 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 4 Sep 2026 21:18:03 +0800 Subject: [PATCH] =?UTF-8?q?feat=E2=9C=A8(example):=20register=20the=20orde?= =?UTF-8?q?r=20routes,=20migration=20and=20menu=20seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration goes through core's package-level facades: SetAppRouters for the routes, migration.ForApp for the schema, and seed.MenuSpec/ApiSpec for the menu rows - none of which requires importing the host. The menu component is spelled apps/order/order/index. The frontend tells a packaged view from a built-in one by that first segment alone, and getting it wrong is silent: the page falls back to the not-installed placeholder while the console names a src/views path that was never going to exist. The tests assert that prefix, that every Parent reference closes, and that every ApiCode resolves - the three ways a menu graph is wrong without anything saying so. Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx --- example/app-order/migration/migration.go | 89 ++++++++++ example/app-order/migration/migration_test.go | 153 ++++++++++++++++++ example/app-order/router/router.go | 67 ++++++++ example/app-order/router/router_test.go | 79 +++++++++ 4 files changed, 388 insertions(+) create mode 100644 example/app-order/migration/migration.go create mode 100644 example/app-order/migration/migration_test.go create mode 100644 example/app-order/router/router.go create mode 100644 example/app-order/router/router_test.go diff --git a/example/app-order/migration/migration.go b/example/app-order/migration/migration.go new file mode 100644 index 00000000..dbc09a7b --- /dev/null +++ b/example/app-order/migration/migration.go @@ -0,0 +1,89 @@ +// Package migration registers app-order's one migration: create its two +// tables and seed the menu/API entries the admin UI needs to expose them. +// +// It registers through contract/migration.ForApp - the package-level +// facade, not a private NewRegistry() - because that is the only registry a +// third-party app, which cannot reach into the host process, can register +// against and have any hope of the host's own execution engine picking up. +// Whether it actually does, today, is a different question: see this +// package's test file and the gap list in the accompanying report. +package migration + +import ( + "gorm.io/gorm" + + contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration" + 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" + + "github.com/go-admin-team/example-app-order/models" +) + +// AppCode is app-order's migration.ForApp / seed.SeedMenus identity. +const AppCode = "order" + +// version is this migration's sys_migration key before ForApp namespaces +// it (see contract/migration.ForApp's doc comment: the stored key becomes +// "order-" + version). It follows the framework's own 13-digit millisecond +// timestamp convention purely so a human reading sys_migration.version +// alongside the framework's own rows can still eyeball roughly when it was +// authored; contract/migration.ForApp does not require that shape, just +// uniqueness within this app's own namespace. +const version = "1793800000000" + +func init() { + contractmigration.ForApp(AppCode).SetVersion(version, createOrderSchema) +} + +// createOrderSchema creates app_order/app_order_item and seeds the menu and +// API entries a host's Seeder turns into sys_menu/sys_api/sys_menu_api_rule +// rows (and, once an administrator grants the menu to a role through the +// ordinary admin UI, casbin_rule). See seed.Seeder's security note: this +// call does not sandbox anything, it only saves app-order from needing to +// know go-admin's own schema. +func createOrderSchema(db *gorm.DB, migrationVersion, appCode string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.AutoMigrate(&models.Order{}, &models.OrderItem{}); err != nil { + return err + } + + menus := []seed.MenuSpec{ + { + Code: "dir", Kind: contractmodels.Directory, + Title: "Order Example", Path: "/apps/order", Component: "Layout", + Icon: "shopping", Sort: 200, + }, + { + Code: "list", Parent: "dir", Kind: contractmodels.Menu, + Title: "Orders", Path: "list", + // Component must start with "apps//" - see + // seed.MenuSpec.Component's doc comment. This is the one + // concrete rule the report's gap list has nothing bad to + // say about: it is documented exactly where a caller + // building a MenuSpec would look. + Component: "apps/order/order/index", + Sort: 1, + ApiCodes: []string{"list", "get", "create", "pay"}, + }, + { + Code: "btn-create", Parent: "list", Kind: contractmodels.Button, + Title: "Create", Permission: "order:order:create", Sort: 1, + }, + { + Code: "btn-pay", Parent: "list", Kind: contractmodels.Button, + Title: "Pay", Permission: "order:order:pay", Sort: 2, + }, + } + apis := []seed.ApiSpec{ + {Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"}, + {Code: "get", Title: "Order detail", Path: "/api/v1/order/:id", Method: "GET", Handle: "apis.Order.Get-fm"}, + {Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Create-fm"}, + {Code: "pay", Title: "Pay order", Path: "/api/v1/order/:id/pay", Method: "PUT", Handle: "apis.Order.Pay-fm"}, + } + if err := seed.SeedMenus(tx, appCode, menus, apis); err != nil { + return err + } + + return tx.Create(&contractmodels.Migration{Version: migrationVersion, AppCode: appCode}).Error + }) +} diff --git a/example/app-order/migration/migration_test.go b/example/app-order/migration/migration_test.go new file mode 100644 index 00000000..cfec607b --- /dev/null +++ b/example/app-order/migration/migration_test.go @@ -0,0 +1,153 @@ +package migration + +import ( + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration" + 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" + + "github.com/go-admin-team/example-app-order/models" +) + +// fakeSeeder stands in for a host's real Seeder (the one wt-shim, as of +// this writing, never registers - see the accompanying report's gap list). +// It records what it received instead of writing to any table, which is +// enough to check app-order's own MenuSpec/ApiSpec assembly without +// depending on go-admin's sys_menu/sys_api schema. +type fakeSeeder struct { + appCode string + menus []seed.MenuSpec + apis []seed.ApiSpec +} + +func (f *fakeSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, apis []seed.ApiSpec) error { + f.appCode = appCode + f.menus = menus + f.apis = apis + return nil +} + +// seed.RegisterSeeder panics on a second call in the same process (see its +// doc comment) - by design, there is no public way to unregister one. This +// package's tests share the one registration below rather than each +// registering their own. +var fake = &fakeSeeder{} + +func init() { + seed.RegisterSeeder(fake) +} + +// TestRegistersUnderContractMigrationForApp is this package's core claim: +// that createOrderSchema is reachable through contract/migration's +// package-level Snapshot, the only registry a third-party module can +// register against. It does not confirm any host actually calls Snapshot +// today - see the report. +func TestRegistersUnderContractMigrationForApp(t *testing.T) { + entries := contractmigration.Snapshot() + entry, ok := entries[AppCode+"-"+version] + if !ok { + t.Fatalf("no entry for %s-%s; registered: %v", AppCode, version, keysOf(entries)) + } + if entry.AppCode != AppCode { + t.Errorf("Entry.AppCode = %q, want %q", entry.AppCode, AppCode) + } +} + +func keysOf(m map[string]contractmigration.Entry) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// TestMigrationCreatesTablesSeedsMenusAndRecordsItself runs the registered +// migration function directly against a fresh sqlite database - standing in +// for the host's execution engine, which (see the report) does not exist +// yet for an externally-registered app. It is the closest thing to an +// end-to-end run this example can do without wt-shim's cooperation. +func TestMigrationCreatesTablesSeedsMenusAndRecordsItself(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + // sys_migration itself is created by the framework's own first + // migration (go-admin's cmd/migrate/migration/version/*_tables.go), + // which by the time any app's migration runs has always already run - + // simulate that precondition rather than app-order's own migration + // creating a table it does not own. + if err := db.AutoMigrate(&contractmodels.Migration{}); err != nil { + t.Fatalf("automigrate sys_migration: %v", err) + } + + entries := contractmigration.Snapshot() + entry, ok := entries[AppCode+"-"+version] + if !ok { + t.Fatalf("no entry for %s-%s", AppCode, version) + } + if err := entry.Fn(db, AppCode+"-"+version); err != nil { + t.Fatalf("running the registered migration: %v", err) + } + + if !db.Migrator().HasTable(&models.Order{}) { + t.Error("app_order was not created") + } + if !db.Migrator().HasTable(&models.OrderItem{}) { + t.Error("app_order_item was not created") + } + + var migrationRow contractmodels.Migration + if err := db.Where("version = ?", AppCode+"-"+version).First(&migrationRow).Error; err != nil { + t.Fatalf("sys_migration row: %v", err) + } + if migrationRow.AppCode != AppCode { + t.Errorf("sys_migration.app_code = %q, want %q", migrationRow.AppCode, AppCode) + } + + if fake.appCode != AppCode { + t.Errorf("Seeder saw appCode %q, want %q", fake.appCode, AppCode) + } + assertMenuGraphIsConsistent(t, fake.menus, fake.apis) +} + +// assertMenuGraphIsConsistent checks the two rules that would otherwise +// only surface as a broken admin UI at install time: every Parent +// reference resolves to a Code in the same batch, and the frontend's +// apps// convention for a packaged page's Component (documented on +// MenuSpec.Component, enforced by nothing - see the report) is actually +// followed. +func assertMenuGraphIsConsistent(t *testing.T, menus []seed.MenuSpec, apis []seed.ApiSpec) { + t.Helper() + + codes := make(map[string]seed.MenuSpec, len(menus)) + for _, m := range menus { + codes[m.Code] = m + } + apiCodes := make(map[string]bool, len(apis)) + for _, a := range apis { + apiCodes[a.Code] = true + } + + for _, m := range menus { + if m.Parent != "" { + if _, ok := codes[m.Parent]; !ok { + t.Errorf("menu %q has Parent %q, which is not a Code in this batch", m.Code, m.Parent) + } + } + for _, ac := range m.ApiCodes { + if !apiCodes[ac] { + t.Errorf("menu %q references ApiCode %q, which is not in this batch's apis", m.Code, ac) + } + } + if m.Kind == contractmodels.Menu && m.Component != "" { + if !strings.HasPrefix(m.Component, "apps/"+AppCode+"/") { + t.Errorf("menu %q has Component %q, want it to start with apps/%s/", m.Code, m.Component, AppCode) + } + } + } +} diff --git a/example/app-order/router/router.go b/example/app-order/router/router.go new file mode 100644 index 00000000..d61dc51b --- /dev/null +++ b/example/app-order/router/router.go @@ -0,0 +1,67 @@ +// Package router wires app-order's four routes onto a host's gin engine. +package router + +import ( + "github.com/gin-gonic/gin" + + jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth" + "github.com/go-admin-team/go-admin-core/v2/sdk" + "github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions" + coreruntime "github.com/go-admin-team/go-admin-core/v2/sdk/runtime" + + "github.com/go-admin-team/example-app-order/apis" +) + +// RegisterRouter mounts app-order's routes under v1. +// +// Its signature - (v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) +// - is not app-order's own invention: it is the exact shape every in-tree +// go-admin app router package already registers into its own routerCheckRole +// slice (see app/demo/router/demo_product.go), so a host installs this +// exactly where it installs its own app/*/router packages: one file under +// cmd/api/ that imports this package and appends RegisterRouter (adjusted to +// the host's own registration slice's calling convention) - see +// cmd/api/demo.go for the pattern. +// +// authMiddleware is taken as an explicit parameter rather than fetched +// through sdk.Runtime.GetHandlerFunc(coreruntime.JwtTokenCheck). As of this +// writing the reference host (go-admin's common/middleware/init.go) registers +// that key with an unbound method expression - +// sdk.Runtime.SetMiddleware(JwtTokenCheck, (*jwt.GinJWTMiddleware).MiddlewareFunc) +// - which is exactly the shape GetHandlerFunc's own doc comment warns +// against: the stored value's type is func(*jwt.GinJWTMiddleware) +// gin.HandlerFunc, not gin.HandlerFunc, so GetHandlerFunc's type assertion +// fails and it reports ok=false every time, for every caller, not just this +// one. Taking authMiddleware directly sidesteps that live bug and matches +// what every in-tree app already does. +func RegisterRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { + roleCheck, ok := sdk.Runtime.GetHandlerFunc(coreruntime.RoleCheck) + if !ok { + // A host that has not wired up RoleCheck has not wired up Casbin + // authorization at all. Registering these routes without it would + // silently serve every order to every authenticated caller + // regardless of role - fail loud at startup instead, the same way + // PermissionAction fails loud (Abort, not c.Next) when its own + // database lookup errors. See contract/actions.PermissionAction's + // doc comment for the same reasoning applied to data-scope instead + // of role. + panic("app-order: host has not registered core's " + coreruntime.RoleCheck + + " middleware (sdk.Runtime.SetMiddleware); refusing to mount unauthorized order routes") + } + + e := apis.Order{} + r := v1.Group("/order"). + Use(authMiddleware.MiddlewareFunc()). + Use(roleCheck) + { + // actions.PermissionAction is imported directly from core - a plain + // function, not something fetched through sdk.Runtime - because + // unlike RoleCheck's Casbin policy tables (host-owned; see + // contract/actions's package doc), the data-scope machinery it + // installs has no host-specific state at all. + r.GET("", actions.PermissionAction(), e.GetPage) + r.GET("/:id", actions.PermissionAction(), e.Get) + r.POST("", e.Create) + r.PUT("/:id/pay", actions.PermissionAction(), e.Pay) + } +} diff --git a/example/app-order/router/router_test.go b/example/app-order/router/router_test.go new file mode 100644 index 00000000..133b7244 --- /dev/null +++ b/example/app-order/router/router_test.go @@ -0,0 +1,79 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth" + "github.com/go-admin-team/go-admin-core/v2/sdk" + coreruntime "github.com/go-admin-team/go-admin-core/v2/sdk/runtime" +) + +func testAuthMiddleware(t *testing.T) *jwt.GinJWTMiddleware { + t.Helper() + mw, err := jwt.New(&jwt.GinJWTMiddleware{ + Realm: "test", + Key: []byte("test-signing-key"), + SigningAlgorithm: "HS256", + Timeout: 0, + TokenLookup: "header: Authorization", + TokenHeadName: "Bearer", + }) + if err != nil { + t.Fatalf("building a test JWT middleware: %v", err) + } + return mw +} + +// This test must run before anything in this package calls +// sdk.Runtime.SetMiddleware(coreruntime.RoleCheck, ...) - sdk.Runtime is a +// single process-wide instance (see sdk.Runtime's doc comment) with no way +// to unregister a key, and Go runs a package's tests in source order by +// default. It is declared first in the file for that reason. +func TestRegisterRouterPanicsWithoutHostRoleCheck(t *testing.T) { + if _, ok := sdk.Runtime.GetHandlerFunc(coreruntime.RoleCheck); ok { + t.Fatal("RoleCheck is already registered; this test must run before any test that registers it") + } + + defer func() { + if recover() == nil { + t.Fatal("RegisterRouter did not panic with no host RoleCheck middleware registered") + } + }() + + gin.SetMode(gin.TestMode) + r := gin.New() + v1 := r.Group("/api/v1") + RegisterRouter(v1, testAuthMiddleware(t)) +} + +func TestRegisterRouterMountsRoutesOnceRoleCheckIsRegistered(t *testing.T) { + sdk.Runtime.SetMiddleware(coreruntime.RoleCheck, gin.HandlerFunc(func(c *gin.Context) { c.Next() })) + // sdk.Runtime has no way to unregister a middleware key (SetMiddleware + // only ever adds or overwrites - see its doc comment), so restore the + // "as far as GetHandlerFunc is concerned, unregistered" state other + // tests in this package depend on: a nil interface{} fails + // GetHandlerFunc's gin.HandlerFunc type assertion the same way a never- + // set key does. Needed for `go test -count=2` and similar re-runs + // within one process, not for a single run. + t.Cleanup(func() { sdk.Runtime.SetMiddleware(coreruntime.RoleCheck, nil) }) + + gin.SetMode(gin.TestMode) + r := gin.New() + v1 := r.Group("/api/v1") + RegisterRouter(v1, testAuthMiddleware(t)) + + // A route that exists returns something other than 404, even if the + // JWT/Casbin/PermissionAction chain in front of it then rejects the + // unauthenticated test request - proving RegisterRouter actually wired + // the route up is the point, not exercising the auth chain itself. + req := httptest.NewRequest(http.MethodGet, "/api/v1/order", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code == http.StatusNotFound { + t.Errorf("GET /api/v1/order was not registered (404)") + } +}