From 691df820166bdca19b1fac85816cae263a29a01f Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Tue, 8 Sep 2026 19:26:40 +0800 Subject: [PATCH] =?UTF-8?q?feat=E2=9C=A8:=20add=20the=20sys=5Fapp=20regist?= =?UTF-8?q?ry=20and=20the=20casbin=20grant=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sys_app is one row per installed application. It is physically deleted on uninstall rather than following the millisecond soft-delete marker the other sys_ tables use: an installed-app registry has no "deleted by accident, needs recovering" case, and a physical delete is what lets the same code be installed again afterwards. status is installing/installed/failed rather than a boolean, because an install spanning several migration files is not atomic on MySQL - DDL commits implicitly, so a run can stop in the middle. failed_version and last_error are diagnostic snapshots for a person to read; nothing may decide anything from them, and the field comments say so. Where to resume is answered by sys_migration, which cannot drift from what was actually applied. sys_app_casbin_grant records which casbin_rule rows an install created, keyed by casbin_rule's own natural key. That table is not extended instead: gorm-adapter's SavePolicy truncates and reloads it from an in-memory model, which would drop any column added here without a word. Built against SQLite, MySQL 8.0 and PostgreSQL 15. --- app/admin/models/sys_app.go | 89 ++++++++++++ app/admin/models/sys_app_casbin_grant.go | 43 ++++++ .../1786700007000_app_registry_tables.go | 42 ++++++ ...07000_app_registry_tables_postgres_test.go | 62 +++++++++ .../1786700007000_app_registry_tables_test.go | 130 ++++++++++++++++++ 5 files changed, 366 insertions(+) create mode 100644 app/admin/models/sys_app.go create mode 100644 app/admin/models/sys_app_casbin_grant.go create mode 100644 cmd/migrate/migration/version/1786700007000_app_registry_tables.go create mode 100644 cmd/migrate/migration/version/1786700007000_app_registry_tables_postgres_test.go create mode 100644 cmd/migrate/migration/version/1786700007000_app_registry_tables_test.go diff --git a/app/admin/models/sys_app.go b/app/admin/models/sys_app.go new file mode 100644 index 00000000..72008266 --- /dev/null +++ b/app/admin/models/sys_app.go @@ -0,0 +1,89 @@ +package models + +import ( + "time" + + "go-admin/common/models" +) + +// SysApp is the sys_app row model: one row per installed application (PRD +// 008 F2). It deliberately does not embed models.ModelTime - see the design +// doc (docs-prd/008-应用清单与安装器/数据库变更.md) §1.1 for why an +// installed-app registry does not need the millisecond soft-delete marker +// every other sys_* table follows. Uninstalling an app deletes its row +// outright; a later reinstall creates a fresh one. +type SysApp struct { + models.Model // Id int, primary key, autoincrement + + // AppCode is the app.Manifest.Code / migration.ForApp / seed.SeedMenus + // identity, already lower-cased by migration.NormalizeAppCode before + // anything reaches this table. Unique: row existence alone answers G2 + // ("is app X installed"). + AppCode string `json:"appCode" gorm:"type:varchar(64);not null;uniqueIndex:uk_sys_app_app_code;comment:app code"` + + Name string `json:"name" gorm:"size:128;not null;comment:display name, from Manifest.Name"` + // Version is the version this row currently reflects - attempted or + // confirmed, disambiguated by Status. It does not drive which + // migrations run next; sys_migration's per-version rows do that (see + // design doc §1.5's resume flow). This field is descriptive, refreshed + // from the manifest on every install/upgrade/resume attempt. + Version string `json:"version" gorm:"size:32;not null;comment:version this row currently reflects, see Status"` + Description string `json:"description" gorm:"size:255;not null;default:'';comment:from Manifest.Description"` + Author string `json:"author" gorm:"size:128;not null;default:'';comment:from Manifest.Author"` + + // Requires is a comma-separated list of app codes this app declared as + // dependencies (Manifest.Requires). Stored as plain VARCHAR CSV, not + // JSON - see design doc §1.3 for why. F8 (P1) is what validates and + // orders on this; this batch only stores what the manifest declared. + Requires string `json:"requires" gorm:"size:255;not null;default:'';comment:declared dependency app codes, comma separated"` + + // Pricing/License are reserved passthrough fields (PRD 003; PRD 008 + // open question 1). This batch stores whatever the manifest carries and + // does not interpret either one. + Pricing string `json:"pricing" gorm:"size:64;not null;default:'';comment:reserved, not interpreted by this batch"` + License string `json:"license" gorm:"size:64;not null;default:'';comment:reserved, not interpreted by this batch"` + + // Status: 1=installing 2=installed 3=failed. Three states, not a + // single "1=installed", because a partial, stuck install has to be an + // observable row rather than "the row doesn't exist yet" - see design + // doc §1.5 for why cross-migration-file atomicity is not available on + // MySQL (implicit commit on DDL). + Status int `json:"status" gorm:"size:4;not null;default:1;comment:1=installing 2=installed 3=failed"` + + // FailedVersion and LastError are DIAGNOSTIC TEXT ONLY - what a human + // looking at this row is told about the last failure, nothing more. No + // code anywhere may read either one to decide what to do next. + // + // The question "where should a resume pick up" has exactly one + // authoritative answer, and it is not these two columns: subtract + // sys_migration's applied rows for this app_code from what the app's + // own compiled-in code has registered (migration.Snapshot()/ForApp - + // the same set F7's `migrate status` already walks). That answer can + // never go stale, because it is not stored anywhere to go stale - it is + // recomputed from sys_migration every time it is asked. FailedVersion + // is a snapshot of what that computation returned at the moment of + // failure, kept only so an operator does not have to go find the + // process's logs; if it and a fresh recomputation from sys_migration + // ever disagree, sys_migration is right and this column is stale, by + // definition, and nothing should ever notice or care except a human + // reading the row. + FailedVersion string `json:"failedVersion" gorm:"size:64;not null;default:'';comment:diagnostic snapshot only, not a judgment basis; meaningful only when status=3"` + LastError string `json:"lastError" gorm:"size:255;not null;default:'';comment:diagnostic text only, not a judgment basis; meaningful only when status=3"` + + // InstalledAt is when this app first reached status=installed - set + // once, never moved by a later upgrade (see design doc §1.4). Nullable, + // unlike every other column here: a row can exist before it has a + // value (a fresh install starts at status=installing). This is not the + // deleted_at problem 1786700003000_soft_delete_marker.go fixed - that + // column sat inside a unique index, where NULL <> NULL let two live + // rows coexist under the same key. InstalledAt is in no index at all, + // so nullability here opens no such hole. + InstalledAt *time.Time `json:"installedAt" gorm:"comment:first successful install time; null until status first reaches installed"` + UpdatedAt time.Time `json:"updatedAt" gorm:"comment:last updated time"` + + models.ControlBy // CreateBy/UpdateBy: which operator triggered the attempt +} + +func (*SysApp) TableName() string { + return "sys_app" +} diff --git a/app/admin/models/sys_app_casbin_grant.go b/app/admin/models/sys_app_casbin_grant.go new file mode 100644 index 00000000..b375d15f --- /dev/null +++ b/app/admin/models/sys_app_casbin_grant.go @@ -0,0 +1,43 @@ +package models + +import "time" + +// SysAppCasbinGrant is a ledger of casbin_rule rows an app install created, +// keyed by the exact natural key casbin_rule itself is unique on. It exists +// because casbin_rule is not a table this project owns (see design doc +// docs-prd/008-应用清单与安装器/数据库变更.md §2.2): we cannot add an +// app_code column to it without that column being silently zeroed the first +// time anything calls the gorm-adapter's SavePolicy/SavePolicyCtx. Recording +// the natural key here, instead of a foreign key into casbin_rule, is also +// what survives SysRole.Update's RemoveFilteredPolicy+re-add cycle for a +// role's policies (app/admin/service/sys_role.go): that cycle replaces the +// underlying row (a new auto-increment ID) but reproduces the same +// (ptype,v0,v1,v2) tuple from the same sys_menu/sys_api data, so a +// natural-key match here still finds it. What it does not survive is the +// role being renamed, or the tuple being rebuilt from a completely different +// source (a future SavePolicy call from outside this seeder) - in both cases +// the match legitimately fails, and business rule 3 says the uninstaller +// should report and skip, not delete something else that happens to look +// the same. +type SysAppCasbinGrant struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + + AppCode string `json:"appCode" gorm:"type:varchar(64);not null;index:idx_sys_app_casbin_grant_app_code;comment:app code that created this grant"` + + // Column widths mirror gorm-adapter's own CasbinRule struct exactly, so + // a value that fits into casbin_rule always fits here, and the unique + // index below matches the one createTable() puts on casbin_rule itself. + Ptype string `json:"ptype" gorm:"size:100;not null;uniqueIndex:uk_sys_app_casbin_grant_rule;comment:casbin ptype, 'p' today"` + V0 string `json:"v0" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:role_key at grant time"` + V1 string `json:"v1" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:api path"` + V2 string `json:"v2" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:http method"` + V3 string `json:"v3" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:unused today"` + V4 string `json:"v4" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:unused today"` + V5 string `json:"v5" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:unused today"` + + CreatedAt time.Time `json:"createdAt" gorm:"comment:when this grant was recorded"` +} + +func (*SysAppCasbinGrant) TableName() string { + return "sys_app_casbin_grant" +} diff --git a/cmd/migrate/migration/version/1786700007000_app_registry_tables.go b/cmd/migrate/migration/version/1786700007000_app_registry_tables.go new file mode 100644 index 00000000..7faf10be --- /dev/null +++ b/cmd/migrate/migration/version/1786700007000_app_registry_tables.go @@ -0,0 +1,42 @@ +package version + +import ( + "runtime" + + "gorm.io/gorm" + + adminmodels "go-admin/app/admin/models" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" +) + +// Create sys_app (PRD 008 F2) and sys_app_casbin_grant (F4/F6's casbin +// attribution ledger - see the design doc's (docs-prd/008-应用清单与安装器/ +// 数据库变更.md) §2.2/§3 for why casbin_rule itself is not touched: +// gorm-adapter's SavePolicyCtx truncates and reloads that table from its +// in-memory model, and any column this migration added to it would be +// silently zeroed the first time anything calls SavePolicy. +// +// Ordered after 1786700003000 (the soft-delete conversion), so importing +// cmd/migrate/migration/models is banned here - see +// schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels. +// Both new tables are AutoMigrate'd from their runtime model shape under +// app/admin/models directly, which is also why neither one is added to +// 1786700003000's frozen softDeleteTables list: neither embeds +// common.ModelTime in the first place (see design doc §1.1). +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700007000AppRegistryTables) +} + +func _1786700007000AppRegistryTables(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Migrator().AutoMigrate( + new(adminmodels.SysApp), + new(adminmodels.SysAppCasbinGrant), + ); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/cmd/migrate/migration/version/1786700007000_app_registry_tables_postgres_test.go b/cmd/migrate/migration/version/1786700007000_app_registry_tables_postgres_test.go new file mode 100644 index 00000000..7eaf02a5 --- /dev/null +++ b/cmd/migrate/migration/version/1786700007000_app_registry_tables_postgres_test.go @@ -0,0 +1,62 @@ +package version + +import ( + "testing" + + common "go-admin/common/models" + + adminmodels "go-admin/app/admin/models" +) + +// postgresDB is defined in 1786700003000_soft_delete_marker_postgres_test.go +// and shared across this package's PostgreSQL-only tests. +// +// This migration is plain AutoMigrate on two brand-new tables, unlike +// 1786700003000's DROP INDEX (go-admin#919's actual defect), so there is no +// dialect-specific SQL here for AutoMigrate itself to get wrong on +// PostgreSQL specifically. What is worth a real PostgreSQL run is +// 1786700008000's CONCAT()-based duplicate check next door - PostgreSQL has +// had CONCAT() since 9.1, but it was never verified against a real server +// until this file, only inferred from documentation - and the same +// AutoMigrate call this test makes, so a schema/character-set mistake +// AutoMigrate might make silently on a dialect nobody ran it against here +// has somewhere to surface. +func TestAppRegistryTablesAreCreatedOnPostgres(t *testing.T) { + db := postgresDB(t) + const version = "1786700007000-pg" + cleanup := func() { + db.Migrator().DropTable(&adminmodels.SysAppCasbinGrant{}, &adminmodels.SysApp{}) + // Only this test's own row, not the whole shared sys_migration + // table: postgresDB points at a real, persistent database (unlike + // the SQLite tests' fresh in-memory one per run), so a version left + // behind by a previous run of this same binary collides with the + // wrapper's own INSERT the next time this test runs. + db.Exec("DELETE FROM sys_migration WHERE version = ?", version) + } + t.Cleanup(cleanup) + cleanup() + if err := db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatalf("automigrate sys_migration: %v", err) + } + + if err := _1786700007000AppRegistryTables(db, version); err != nil { + t.Fatalf("migrate: %v", err) + } + + if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "Order", Version: "v1"}).Error; err != nil { + t.Fatalf("insert sys_app: %v", err) + } + if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "dup", Version: "v1"}).Error; err == nil { + t.Fatal("a second sys_app row with the same app_code was accepted on PostgreSQL") + } + + grant := adminmodels.SysAppCasbinGrant{AppCode: "order", Ptype: "p", V0: "admin", V1: "/api/v1/order", V2: "GET"} + if err := db.Create(&grant).Error; err != nil { + t.Fatalf("insert sys_app_casbin_grant: %v", err) + } + dup := grant + dup.Id = 0 + if err := db.Create(&dup).Error; err == nil { + t.Fatal("a second sys_app_casbin_grant row with the same natural key was accepted on PostgreSQL") + } +} diff --git a/cmd/migrate/migration/version/1786700007000_app_registry_tables_test.go b/cmd/migrate/migration/version/1786700007000_app_registry_tables_test.go new file mode 100644 index 00000000..05fec59d --- /dev/null +++ b/cmd/migrate/migration/version/1786700007000_app_registry_tables_test.go @@ -0,0 +1,130 @@ +package version + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + adminmodels "go-admin/app/admin/models" + common "go-admin/common/models" +) + +func openAppRegistryDB(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(&common.Migration{}); err != nil { + t.Fatalf("automigrate sys_migration: %v", err) + } + return db +} + +// The migration has to build both tables and record itself as applied - +// F2/F6's acceptance case is a row landing in either one, and neither is +// possible if the table it belongs to was never created. +func TestAppRegistryTablesAreCreated(t *testing.T) { + db := openAppRegistryDB(t) + + if err := _1786700007000AppRegistryTables(db, "1786700007000"); err != nil { + t.Fatalf("migrate: %v", err) + } + + if !db.Migrator().HasTable(&adminmodels.SysApp{}) { + t.Fatal("sys_app was not created") + } + if !db.Migrator().HasTable(&adminmodels.SysAppCasbinGrant{}) { + t.Fatal("sys_app_casbin_grant was not created") + } + + // A row that exercises every column, not just HasTable/HasColumn - + // AutoMigrate can build a column with the wrong type and still report + // that it exists. + if err := db.Create(&adminmodels.SysApp{ + AppCode: "order", Name: "Order", Version: "v1", Description: "d", Author: "a", + Requires: "payment", Pricing: "free", License: "MIT", Status: 1, + }).Error; err != nil { + t.Fatalf("insert sys_app: %v", err) + } + if err := db.Create(&adminmodels.SysAppCasbinGrant{ + AppCode: "order", Ptype: "p", V0: "admin", V1: "/api/v1/order", V2: "GET", + }).Error; err != nil { + t.Fatalf("insert sys_app_casbin_grant: %v", err) + } + + var applied common.Migration + if err := db.Where("version = ?", "1786700007000").First(&applied).Error; err != nil { + t.Fatalf("sys_migration was not recorded: %v", err) + } +} + +// Running it twice must be safe: DDL does not roll back on MySQL, so an +// operator whose first attempt failed partway through has nothing to do but +// run it again. This calls AutoMigrate directly rather than the wrapper, +// which also inserts a sys_migration row that a second call would collide +// on - a collision Migrate.run() itself prevents by never calling a +// function twice for the same recorded version, so it is not this +// migration's job to tolerate. +func TestAppRegistryTablesAutoMigrateIsRepeatable(t *testing.T) { + db := openAppRegistryDB(t) + + for i := 0; i < 3; i++ { + if err := db.Migrator().AutoMigrate( + new(adminmodels.SysApp), + new(adminmodels.SysAppCasbinGrant), + ); err != nil { + t.Fatalf("automigrate %d: %v", i, err) + } + } +} + +// sys_app.app_code is the unique key G2 ("is app X installed") answers with +// - a second row for the same app code must be rejected, not tolerated. +func TestSysAppAppCodeIsUnique(t *testing.T) { + db := openAppRegistryDB(t) + if err := _1786700007000AppRegistryTables(db, "1786700007000"); err != nil { + t.Fatalf("migrate: %v", err) + } + + if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "Order", Version: "v1"}).Error; err != nil { + t.Fatalf("first insert: %v", err) + } + if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "Order dup", Version: "v1"}).Error; err == nil { + t.Fatal("a second sys_app row with the same app_code was accepted") + } +} + +// sys_app_casbin_grant's unique index mirrors casbin_rule's own natural key +// (ptype,v0..v5) exactly - see design doc §3. A duplicate grant for the +// same rule must be rejected the same way gorm-adapter's own unique index +// on casbin_rule would reject it. +func TestSysAppCasbinGrantNaturalKeyIsUnique(t *testing.T) { + db := openAppRegistryDB(t) + if err := _1786700007000AppRegistryTables(db, "1786700007000"); err != nil { + t.Fatalf("migrate: %v", err) + } + + grant := adminmodels.SysAppCasbinGrant{AppCode: "order", Ptype: "p", V0: "admin", V1: "/api/v1/order", V2: "GET"} + if err := db.Create(&grant).Error; err != nil { + t.Fatalf("first insert: %v", err) + } + dup := grant + dup.Id = 0 + if err := db.Create(&dup).Error; err == nil { + t.Fatal("a second sys_app_casbin_grant row with the same natural key was accepted") + } + + // A grant for a different app, but the identical casbin natural key, is + // exactly the collision two applications granting the same api/role + // pair would produce - the natural key has to be the one thing that + // rejects it, app_code is descriptive only and not part of the index. + other := grant + other.Id = 0 + other.AppCode = "another-app" + if err := db.Create(&other).Error; err == nil { + t.Fatal("a duplicate natural key under a different app_code was accepted") + } +}