mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-25 03:21:46 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0132db1b9 | ||
|
|
7c3f55a873 | ||
|
|
f7c0247394 | ||
|
|
f64115e03a | ||
|
|
a2524c31bf | ||
|
|
71d6211c61 | ||
|
|
c67760bc39 | ||
|
|
d3e7f46a46 | ||
|
|
55866682ae | ||
|
|
ac23556029 | ||
|
|
550e95ff43 | ||
|
|
060b6cfd64 | ||
|
|
89a4738394 | ||
|
|
d8529289cf | ||
|
|
4a8f97b1ee | ||
|
|
d54ac844ef | ||
|
|
379fba515f | ||
|
|
7d29c9953a | ||
|
|
be3c4452e3 | ||
|
|
5b01c9ada8 |
@@ -125,9 +125,12 @@ func (SysPost) TableName() string { return "sys_post" }
|
||||
两条与主仓贡献者直接相关的:
|
||||
|
||||
- **`common/`、`core/` 不得 import `app/`** —— `make checksilent` 在 CI 里守着,违反即红。
|
||||
- **从 core 契约包声明出来的类型必须写成别名**(`type X = pkg.Y`,不是 `type X pkg.Y`)
|
||||
—— `contract-shim-alias` 检查守着。defined type 会丢掉整个方法集,
|
||||
而且**不一定在本仓编译失败**,理由见 `docs/contract.md` 末节。
|
||||
- **注册类 API(`AppRouters` / `sdk.Runtime.SetAppRouters` / `migration.ForApp`)
|
||||
只允许在 `init()` 中调用** —— 注册期靠 Go 的包初始化顺序保证无并发写,
|
||||
core 侧的 setter 没有加锁。
|
||||
必须在 `runStartupHooks()` 之前调用完** —— `init()` 是最省事的位置,
|
||||
但约束的是**顺序**,不是写在哪个函数里;晚到的注册会被丢弃并只记一条 ERROR。
|
||||
|
||||
## 路由注册
|
||||
|
||||
@@ -191,7 +194,8 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
文件名前 13 位为时间戳版本号。**已执行过的迁移文件不可修改** ——
|
||||
文件名前 13 位为毫秒时间戳版本号,不合规的名字会在启动时 panic 并报出该文件名。
|
||||
**已执行过的迁移文件不可修改** ——
|
||||
`sys_migration` 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
|
||||
|
||||
放哪个目录取决于身份:
|
||||
@@ -223,7 +227,7 @@ go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
|
||||
## 静默失败校验
|
||||
|
||||
`make checksilent` 检查六类**不报错、不记日志、行为悄悄变得不对**的问题,
|
||||
`make checksilent` 检查七类**不报错、不记日志、行为悄悄变得不对**的问题,
|
||||
CI 会跑,命中 ERROR 即失败:
|
||||
|
||||
| 检查 | 级别 | 静默后果 |
|
||||
@@ -233,6 +237,7 @@ CI 会跑,命中 ERROR 即失败:
|
||||
| `config-value-truncation` | ERROR | `sys_config.config_value` 超 255 字符被静默截断 |
|
||||
| `menu-id-collision` | ERROR | 两个模块硬编码同一菜单 ID,互相覆盖 |
|
||||
| `contract-import-boundary` | ERROR | 契约包 import `app/`,应用无法独立编译 |
|
||||
| `contract-shim-alias` | ERROR | 契约薄壳写成 defined type 而非别名,方法集丢失,本仓可能照常编译、第三方应用编译不过 |
|
||||
| `menu-name-mismatch` | WARN | 菜单名与前端组件 `name` 不一致,keep-alive 缓存静默失效 |
|
||||
|
||||
最后一条要跨仓库比对,只能做正则启发式,因此是 WARN,**不影响退出码**,
|
||||
|
||||
@@ -23,6 +23,10 @@ type SysApi struct {
|
||||
Path string `json:"path" gorm:"size:128;comment:地址"`
|
||||
Action string `json:"action" gorm:"size:16;comment:请求类型"`
|
||||
Type string `json:"type" gorm:"size:16;comment:接口类型"`
|
||||
// AppCode identifies which application's seed.SeedMenus call wrote this
|
||||
// row; empty for the host's own built-in APIs. Same NOT NULL DEFAULT ''
|
||||
// reasoning as SysMenu.AppCode.
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;default:'';index:idx_sys_api_app_code;comment:AppCode"`
|
||||
models.ModelTime
|
||||
models.ControlBy
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ type SysMenu struct {
|
||||
RoleId int `gorm:"-"`
|
||||
Children []SysMenu `json:"children,omitempty" gorm:"-"`
|
||||
IsSelect bool `json:"is_select" gorm:"-"`
|
||||
// AppCode identifies which application's seed.SeedMenus call wrote this
|
||||
// row; empty for the host's own built-in menus. NOT NULL DEFAULT '' for
|
||||
// the same reason sys_migration.app_code is (see contract/models.Migration):
|
||||
// AutoMigrate adding this column to an existing table leaves every
|
||||
// pre-existing row reading back as "" rather than NULL.
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;default:'';index:idx_sys_menu_app_code;comment:AppCode"`
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
}
|
||||
|
||||
@@ -25,11 +25,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册系统路由
|
||||
InitSysRouter(r, authMiddleware)
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Not `len(menuIDs) == 0`: grantToAdminRole grants two independent
|
||||
// things, and an application is free to register apis without menus -
|
||||
// endpoints another service calls, or a UI mounted somewhere else.
|
||||
// Skipping the whole call on an empty menu list wrote the sys_api rows
|
||||
// and then no casbin rule for them, so those endpoints were denied to
|
||||
// everyone, admin included, with a migration that reported success.
|
||||
if len(menuIDs) == 0 && len(apiRows) == 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,
|
||||
// Visible "0" is shown, not hidden - 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
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// An application is free to register apis with no menus at all - endpoints
|
||||
// another service calls, or a UI mounted somewhere else. Skipping
|
||||
// grantToAdminRole on an empty menu list wrote the sys_api rows and then no
|
||||
// casbin rule for them, so every one of those endpoints was denied to
|
||||
// everyone including admin, from a migration that reported success.
|
||||
func TestSeedMenusGrantsApisWhenThereAreNoMenus(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
role := seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "hook", Title: "Inbound hook", Path: "/api/v1/hook", Method: "POST", Handle: "hook.Receive"},
|
||||
{Code: "sync", Title: "Sync", Path: "/api/v1/sync", Method: "GET", Handle: "hook.Sync"},
|
||||
}
|
||||
if err := (adminSeeder{}).SeedMenus(db, "hooks", nil, apis); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
var apiCount int64
|
||||
db.Model(&models.SysApi{}).Where("app_code = ?", "hooks").Count(&apiCount)
|
||||
if apiCount != int64(len(apis)) {
|
||||
t.Fatalf("sys_api rows = %d, want %d", apiCount, len(apis))
|
||||
}
|
||||
|
||||
for _, a := range apis {
|
||||
var n int64
|
||||
db.Table("casbin_rule").
|
||||
Where("ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?", role.RoleKey, a.Path, a.Method).
|
||||
Count(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("casbin_rule for %s %s = %d rows, want 1: the endpoint is denied to admin", a.Method, a.Path, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same guard: nothing registered at all must stay a
|
||||
// no-op rather than start touching sys_role_menu or casbin_rule.
|
||||
func TestSeedMenusWithNothingRegisteredWritesNothing(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
if err := (adminSeeder{}).SeedMenus(db, "empty", nil, nil); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
for _, table := range []string{"casbin_rule", "sys_role_menu"} {
|
||||
var n int64
|
||||
db.Table(table).Count(&n)
|
||||
if n != 0 {
|
||||
t.Errorf("%s has %d rows, want 0", table, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册业务路由
|
||||
InitBusinessRouter(r, authMiddleware)
|
||||
|
||||
@@ -26,10 +26,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册业务路由
|
||||
initRouter(r, authMiddleware)
|
||||
|
||||
@@ -25,10 +25,9 @@ func InitRouter() {
|
||||
os.Exit(-1)
|
||||
}
|
||||
// the jwt middleware
|
||||
authMiddleware, err := common.AuthInit()
|
||||
if err != nil {
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
// the jwt middleware: shared instance InitMiddleware built at startup,
|
||||
// not one built here per module (see common/middleware.GetAuthMiddleware).
|
||||
authMiddleware := common.GetAuthMiddleware()
|
||||
|
||||
// 注册业务路由
|
||||
// TODO: 这里可存放业务路由,里边并无实际路由只有演示代码
|
||||
|
||||
@@ -3,7 +3,6 @@ package migration
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -11,11 +10,21 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
|
||||
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
var Migrate = newMigration()
|
||||
|
||||
// contractSnapshot is contractmigration.Snapshot, indirected through a
|
||||
// package-level variable so tests can substitute an isolated
|
||||
// *contractmigration.Registry's Snapshot instead of reaching into
|
||||
// go-admin-core's single process-wide registry, which every *Migration in
|
||||
// this process - test-local or the package-level Migrate - reads through the
|
||||
// same call. See mergedEntries.
|
||||
var contractSnapshot = contractmigration.Snapshot
|
||||
|
||||
func newMigration() *Migration {
|
||||
return &Migration{version: make(map[string]versionEntry)}
|
||||
}
|
||||
@@ -135,6 +144,47 @@ func namespacedKey(appCode, k string) string {
|
||||
return appCode + "-" + k
|
||||
}
|
||||
|
||||
// mergedEntries returns every migration this process knows about: the
|
||||
// host's own registry (e.version, filled by version/*.go and
|
||||
// version-local/*.go through SetVersion/ForApp) plus whatever a third-party
|
||||
// application registered through go-admin-core's sdk/contract/migration
|
||||
// package (PRD 006, F9's host wiring).
|
||||
//
|
||||
// That package keeps its own process-wide registry, entirely separate from
|
||||
// e.version, because a third-party application cannot reach into this
|
||||
// process to call an unexported method on *Migration - contract/migration's
|
||||
// package-level ForApp/Snapshot are the only door open to it. Without this
|
||||
// merge, migrate/status/--dry-run would only ever see the host's own
|
||||
// migrations: an application's ForApp("crm").SetVersion(...) would compile,
|
||||
// register successfully into contract/migration's registry, and then never
|
||||
// run, with no error anywhere - the exact silent gap this method closes.
|
||||
//
|
||||
// Entry and versionEntry are structurally identical (an app code plus a
|
||||
// func(db, version) error); the conversion below exists only because they
|
||||
// are two distinct named types, one per package, not because the data
|
||||
// differs.
|
||||
func (e *Migration) mergedEntries() map[string]versionEntry {
|
||||
e.mutex.Lock()
|
||||
out := make(map[string]versionEntry, len(e.version))
|
||||
for k, v := range e.version {
|
||||
out[k] = v
|
||||
}
|
||||
e.mutex.Unlock()
|
||||
|
||||
for k, entry := range contractSnapshot() {
|
||||
if _, exists := out[k]; exists {
|
||||
// contract/migration.ForApp namespaces every app-owned key as
|
||||
// appCode + "-" + k, and appCode is reserved from ""/"core", so
|
||||
// this should never collide with a host-registered key. If it
|
||||
// somehow does, the host's own registration wins rather than
|
||||
// silently overwriting it.
|
||||
continue
|
||||
}
|
||||
out[k] = versionEntry{appCode: entry.AppCode, fn: entry.Fn}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StatusEntry is one row of migrate status.
|
||||
type StatusEntry struct {
|
||||
AppCode string
|
||||
@@ -156,12 +206,11 @@ func (e *Migration) Status() ([]StatusEntry, error) {
|
||||
return nil, fmt.Errorf("migration: no database configured")
|
||||
}
|
||||
|
||||
e.mutex.Lock()
|
||||
registered := make(map[string]string, len(e.version))
|
||||
for k, v := range e.version {
|
||||
all := e.mergedEntries()
|
||||
registered := make(map[string]string, len(all))
|
||||
for k, v := range all {
|
||||
registered[k] = v.appCode
|
||||
}
|
||||
e.mutex.Unlock()
|
||||
|
||||
applied := make(map[string]common.Migration)
|
||||
// A database that has never been migrated has no sys_migration table.
|
||||
@@ -247,12 +296,11 @@ func DisplayAppCode(code string) string {
|
||||
// AppCodes lists the app codes with at least one registered migration, framework
|
||||
// included under its display name, sorted.
|
||||
func (e *Migration) AppCodes() []string {
|
||||
e.mutex.Lock()
|
||||
all := e.mergedEntries()
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range e.version {
|
||||
for _, v := range all {
|
||||
seen[DisplayAppCode(v.appCode)] = struct{}{}
|
||||
}
|
||||
e.mutex.Unlock()
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
for code := range seen {
|
||||
@@ -263,17 +311,16 @@ func (e *Migration) AppCodes() []string {
|
||||
}
|
||||
|
||||
func (e *Migration) run(appCode string) {
|
||||
e.mutex.Lock()
|
||||
versions := make([]string, 0, len(e.version))
|
||||
entries := make(map[string]versionEntry, len(e.version))
|
||||
for k, v := range e.version {
|
||||
all := e.mergedEntries()
|
||||
versions := make([]string, 0, len(all))
|
||||
entries := make(map[string]versionEntry, len(all))
|
||||
for k, v := range all {
|
||||
if appCode != allApps && v.appCode != appCode {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, k)
|
||||
entries[k] = v
|
||||
}
|
||||
e.mutex.Unlock()
|
||||
sort.Strings(versions)
|
||||
|
||||
// A mistyped --app would otherwise select nothing and report "no
|
||||
@@ -315,7 +362,10 @@ func (e *Migration) run(appCode string) {
|
||||
// from the empty app code, which selects the framework's own migrations.
|
||||
const allApps = "\x00all"
|
||||
|
||||
// GetFilename derives a migration's version from its file name. The rule
|
||||
// lives in contract/migration, because an application registering through
|
||||
// that package names its files by the same convention and must land on the
|
||||
// same version string; a second copy here is a second thing to keep in step.
|
||||
func GetFilename(s string) string {
|
||||
s = filepath.Base(s)
|
||||
return s[:13]
|
||||
return contractmigration.GetFilename(s)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,26 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
|
||||
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// withContractRegistry points contractSnapshot at an isolated
|
||||
// *contractmigration.Registry for the duration of one test, instead of
|
||||
// go-admin-core's single process-wide one - see contractSnapshot's doc
|
||||
// comment for why that indirection exists. Restored on cleanup so other
|
||||
// tests in this package keep seeing an empty contract registry regardless of
|
||||
// run order.
|
||||
func withContractRegistry(t *testing.T) *contractmigration.Registry {
|
||||
t.Helper()
|
||||
reg := contractmigration.NewRegistry()
|
||||
orig := contractSnapshot
|
||||
contractSnapshot = reg.Snapshot
|
||||
t.Cleanup(func() { contractSnapshot = orig })
|
||||
return reg
|
||||
}
|
||||
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
@@ -388,3 +405,178 @@ func TestMigrateAppOnAnUnknownCodeSaysSo(t *testing.T) {
|
||||
t.Errorf("a typo ran %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the acceptance test for PRD 006's host-wiring gap: a migration
|
||||
// registered through contract/migration.ForApp - the only door open to a
|
||||
// third-party application - must actually run, be recorded under its app
|
||||
// code, and show up in AppCodes/Status/--app the same as one registered
|
||||
// through the host's own m.ForApp. Before mergedEntries existed, m.Migrate()
|
||||
// never looked at contract/migration's registry at all, so this compiled,
|
||||
// registered, and silently never ran.
|
||||
func TestMergedEntriesRunsAContractRegisteredAppMigration(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := false
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.Migrate()
|
||||
|
||||
if !ran {
|
||||
t.Fatal("contract-registered migration did not run")
|
||||
}
|
||||
rows := rowsByVersion(t, db)
|
||||
row, ok := rows["order-1793800000000"]
|
||||
if !ok {
|
||||
t.Fatalf("no row for order-1793800000000; got %v", rows)
|
||||
}
|
||||
if row.AppCode != "order" {
|
||||
t.Errorf("app_code = %q, want %q", row.AppCode, "order")
|
||||
}
|
||||
}
|
||||
|
||||
// migrate status and --dry-run both read Status; a contract-registered
|
||||
// migration has to appear there under its app code exactly like a
|
||||
// host-registered one, both before and after it is applied.
|
||||
func TestMergedEntriesStatusIncludesContractRegisteredMigrations(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
entries, err := m.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byVersion := map[string]StatusEntry{}
|
||||
for _, e := range entries {
|
||||
byVersion[e.Version] = e
|
||||
}
|
||||
e, ok := byVersion["order-1793800000000"]
|
||||
if !ok || !e.Registered || e.Applied || e.AppCode != "order" {
|
||||
t.Fatalf("pending contract entry = %+v (ok=%v)", e, ok)
|
||||
}
|
||||
|
||||
m.Migrate()
|
||||
|
||||
entries, err = m.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byVersion = map[string]StatusEntry{}
|
||||
for _, e := range entries {
|
||||
byVersion[e.Version] = e
|
||||
}
|
||||
if e := byVersion["order-1793800000000"]; !e.Applied {
|
||||
t.Errorf("applied contract entry = %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// AppCodes feeds both --app's typo detection (appRegistrationError) and the
|
||||
// group headings status prints; a contract-registered app has to appear
|
||||
// there or a real "go-admin migrate --app order" would be told the app does
|
||||
// not exist.
|
||||
func TestMergedEntriesAppCodesIncludesContractRegisteredApps(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
m := newMigration()
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error { return nil })
|
||||
|
||||
got := m.AppCodes()
|
||||
want := []string{"core", "order"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("AppCodes = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("AppCodes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --app order has to actually run only order's migrations - the same
|
||||
// per-app isolation MigrateApp already gives host-registered apps - even
|
||||
// though order is registered in a different registry entirely.
|
||||
func TestMergedEntriesMigrateAppRunsOnlyThatContractApp(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
ran := map[string]bool{}
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
ran["core"] = true
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
|
||||
ran["order"] = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.MigrateApp("order")
|
||||
|
||||
if !ran["order"] {
|
||||
t.Error("order did not run")
|
||||
}
|
||||
if ran["core"] {
|
||||
t.Errorf("MigrateApp(order) also ran %v", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// A host-registered key is not supposed to collide with a namespaced
|
||||
// contract key (see mergedEntries' doc comment), but if it somehow did, the
|
||||
// host's own registration must win rather than a third-party application
|
||||
// silently overwriting a framework migration under the same key.
|
||||
func TestMergedEntriesHostRegistrationWinsOnKeyCollision(t *testing.T) {
|
||||
reg := withContractRegistry(t)
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
hostRan, contractRan := false, false
|
||||
m.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
hostRan = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
reg.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
contractRan = true
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.Migrate()
|
||||
|
||||
if !hostRan {
|
||||
t.Error("host registration did not run")
|
||||
}
|
||||
if contractRan {
|
||||
t.Error("contract registration ran; host registration should have won the collision")
|
||||
}
|
||||
}
|
||||
|
||||
// GetFilename must stay the same rule the contract package applies, since an
|
||||
// application registering through contract/migration names its files by that
|
||||
// convention and has to land on the same version string. Pinning the reject
|
||||
// case is what catches a re-divergence: a local copy that only sliced would
|
||||
// return "add_orders.go" here and register a migration under a key that never
|
||||
// matches anything.
|
||||
func TestGetFilenameDelegatesToTheContractRule(t *testing.T) {
|
||||
if got := GetFilename("version/1786700001000_demo_menu.go"); got != "1786700001000" {
|
||||
t.Fatalf("GetFilename = %q, want %q", got, "1786700001000")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("a file name carrying no version did not panic")
|
||||
}
|
||||
}()
|
||||
GetFilename("version/add_orders.go")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Add sys_menu.app_code and sys_api.app_code ahead of PRD 006 F9's Seeder.
|
||||
//
|
||||
// Every row a third-party application's migration writes through
|
||||
// seed.SeedMenus must be attributable to the app that wrote it, so
|
||||
// installing, auditing, or removing one application does not require
|
||||
// guessing which rows belong to it - see go-admin-core's docs/contract.md,
|
||||
// "Application-supplied menu and API entries", for the requirement this
|
||||
// satisfies.
|
||||
//
|
||||
// Ordered after 1786700003000, so importing cmd/migrate/migration/models is
|
||||
// banned here (see schema_coverage_test.go's
|
||||
// TestPostConversionMigrationsAvoidFrozenSeedModels): AddColumn instead
|
||||
// reads the runtime models' own gorm tags directly, which is also what
|
||||
// makes the column this adds match the one the admin Seeder writes through
|
||||
// those same structs.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700006000AppCodeColumns)
|
||||
}
|
||||
|
||||
func _1786700006000AppCodeColumns(db *gorm.DB, version string) error {
|
||||
m := db.Migrator()
|
||||
if !m.HasColumn(&adminmodels.SysMenu{}, "AppCode") {
|
||||
if err := m.AddColumn(&adminmodels.SysMenu{}, "AppCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&adminmodels.SysApi{}, "AppCode") {
|
||||
if err := m.AddColumn(&adminmodels.SysApi{}, "AppCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
@@ -3,11 +3,19 @@ package middleware
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"go-admin/common/middleware/handler"
|
||||
)
|
||||
|
||||
// authMiddleware is the single JWT middleware instance the whole process
|
||||
// shares. InitMiddleware builds it once, before any module registers its
|
||||
// routes; GetAuthMiddleware is how a module gets it back instead of calling
|
||||
// AuthInit itself and building another, functionally-equivalent-but-distinct
|
||||
// instance.
|
||||
var authMiddleware *jwt.GinJWTMiddleware
|
||||
|
||||
// AuthInit jwt验证new
|
||||
func AuthInit() (*jwt.GinJWTMiddleware, error) {
|
||||
timeout := time.Hour
|
||||
@@ -33,4 +41,23 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
|
||||
TimeFunc: time.Now,
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthMiddleware returns the shared JWT middleware instance InitMiddleware
|
||||
// built at startup. Application modules (app/admin, app/jobs, app/other,
|
||||
// app/demo) call this instead of AuthInit so their router chains - which
|
||||
// still need the instance itself for authMiddleware.MiddlewareFunc() and
|
||||
// authMiddleware.LoginHandler, not just the bound closure registered under
|
||||
// sdk.Runtime's JwtTokenCheck key - end up using the same instance the host
|
||||
// registered, rather than one each.
|
||||
//
|
||||
// It fails loudly instead of returning nil: an InitRouter that runs before
|
||||
// InitMiddleware has a real startup-ordering bug, not a case to paper over
|
||||
// with a nil *jwt.GinJWTMiddleware that would panic much further down the
|
||||
// call chain with a far less useful stack trace.
|
||||
func GetAuthMiddleware() *jwt.GinJWTMiddleware {
|
||||
if authMiddleware == nil {
|
||||
log.Fatal("JWT middleware not initialized; InitMiddleware must run before any module's InitRouter")
|
||||
}
|
||||
return authMiddleware
|
||||
}
|
||||
|
||||
@@ -2,15 +2,20 @@ package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/v2/jwtauth"
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
// These alias core's own constants (see sdk/runtime.GetHandlerFunc's contract
|
||||
// doc, section 9) rather than redeclaring the same three strings, so a typo
|
||||
// here can no longer split registration and lookup into two different keys
|
||||
// that both happen to compile.
|
||||
const (
|
||||
JwtTokenCheck string = "JwtToken"
|
||||
RoleCheck string = "AuthCheckRole"
|
||||
PermissionCheck string = "PermissionAction"
|
||||
JwtTokenCheck = runtime.JwtTokenCheck
|
||||
RoleCheck = runtime.RoleCheck
|
||||
PermissionCheck = runtime.PermissionCheck
|
||||
)
|
||||
|
||||
func InitMiddleware(r *gin.Engine) {
|
||||
@@ -29,7 +34,26 @@ func InitMiddleware(r *gin.Engine) {
|
||||
r.Use(Secure)
|
||||
// 链路追踪
|
||||
//r.Use(middleware.Trace())
|
||||
sdk.Runtime.SetMiddleware(JwtTokenCheck, (*jwt.GinJWTMiddleware).MiddlewareFunc)
|
||||
|
||||
// Build the shared JWT middleware instance here, before any module
|
||||
// registers routes (initRouter runs ahead of runStartupHooks, which is
|
||||
// what invokes each module's InitRouter - see cmd/api/server.go). Doing
|
||||
// it once here, instead of once per module via AuthInit, is what makes
|
||||
// GetAuthMiddleware and sdk.Runtime.GetHandlerFunc(JwtTokenCheck) both
|
||||
// resolve to a single, meaningful instance instead of "whichever module
|
||||
// happened to initialize last".
|
||||
//
|
||||
// SetMiddleware must be given a bound closure (authMiddleware.MiddlewareFunc()),
|
||||
// not the unbound method expression (*jwt.GinJWTMiddleware).MiddlewareFunc:
|
||||
// the latter has no receiver bound to it, so GetHandlerFunc's type
|
||||
// assertion to gin.HandlerFunc always fails for it.
|
||||
var err error
|
||||
authMiddleware, err = AuthInit()
|
||||
if err != nil {
|
||||
// A process with no JWT middleware must not start serving requests.
|
||||
log.Fatalf("JWT Init Error, %s", err.Error())
|
||||
}
|
||||
sdk.Runtime.SetMiddleware(JwtTokenCheck, authMiddleware.MiddlewareFunc())
|
||||
sdk.Runtime.SetMiddleware(RoleCheck, AuthCheckRole())
|
||||
sdk.Runtime.SetMiddleware(PermissionCheck, actions.PermissionAction())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
||||
)
|
||||
|
||||
// freshRuntime hands the test its own Runtime and puts the old one back, the
|
||||
// same pattern cmd/api/server_test.go uses: sdk.Runtime is a process-wide
|
||||
// singleton, and a test that registers into it would otherwise leak state
|
||||
// into every other test in the binary.
|
||||
func freshRuntime(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := sdk.Runtime
|
||||
t.Cleanup(func() { sdk.Runtime = previous })
|
||||
sdk.Runtime = runtime.NewConfig()
|
||||
}
|
||||
|
||||
// TestInitMiddlewareRegistersUsableJwtHandlerFunc is the reverse proof for
|
||||
// hoisting the JWT instance's construction into InitMiddleware:
|
||||
// sdk.Runtime.GetHandlerFunc(JwtTokenCheck) must hand back ok=true and a
|
||||
// non-nil gin.HandlerFunc, not just something GetMiddleware can return as an
|
||||
// untyped interface{}.
|
||||
//
|
||||
// Before this change, InitMiddleware registered the unbound method
|
||||
// expression (*jwt.GinJWTMiddleware).MiddlewareFunc under this key - a value
|
||||
// with no receiver bound to it, which is not a gin.HandlerFunc no matter how
|
||||
// a caller asserts its type. Reverting the registration below to that
|
||||
// expression makes GetHandlerFunc report ok=false; it does not fail to
|
||||
// compile, because (*jwt.GinJWTMiddleware).MiddlewareFunc has a well-formed,
|
||||
// unrelated method-expression type that SetMiddleware's interface{} param
|
||||
// happily accepts.
|
||||
func TestInitMiddlewareRegistersUsableJwtHandlerFunc(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
previousSecret := config.JwtConfig.Secret
|
||||
config.JwtConfig.Secret = "test-secret-key"
|
||||
t.Cleanup(func() { config.JwtConfig.Secret = previousSecret })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
InitMiddleware(gin.New())
|
||||
|
||||
h, ok := sdk.Runtime.GetHandlerFunc(JwtTokenCheck)
|
||||
if !ok {
|
||||
t.Fatal("GetHandlerFunc(JwtTokenCheck) reported ok=false after InitMiddleware ran")
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatal("GetHandlerFunc(JwtTokenCheck) reported ok=true but returned a nil handler")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitMiddlewareBuildsOneSharedJwtInstance locks down the fix for the
|
||||
// four-instances problem: GetAuthMiddleware must return the very instance
|
||||
// InitMiddleware built and handed to sdk.Runtime, not a lookalike built
|
||||
// separately by whichever caller asks first.
|
||||
func TestInitMiddlewareBuildsOneSharedJwtInstance(t *testing.T) {
|
||||
freshRuntime(t)
|
||||
|
||||
previousSecret := config.JwtConfig.Secret
|
||||
config.JwtConfig.Secret = "test-secret-key"
|
||||
t.Cleanup(func() { config.JwtConfig.Secret = previousSecret })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
InitMiddleware(gin.New())
|
||||
|
||||
shared := GetAuthMiddleware()
|
||||
if shared == nil {
|
||||
t.Fatal("GetAuthMiddleware returned nil after InitMiddleware ran")
|
||||
}
|
||||
if shared != authMiddleware {
|
||||
t.Error("GetAuthMiddleware did not return the package-level instance InitMiddleware built")
|
||||
}
|
||||
}
|
||||
+599
-76
@@ -1,64 +1,453 @@
|
||||
# 公共契约面
|
||||
|
||||
> 本文写给**第三方应用作者**:你写一个装进 go-admin 的业务模块,可以依赖什么、
|
||||
> 怎么注册进来、哪些东西随时可能变。
|
||||
> 怎么接进来、哪些约定不遵守会**不报错地出错**。
|
||||
>
|
||||
> 主仓贡献者的编码约定见根目录 `AGENTS.md`,设计取舍见 `docs/architecture.md`。
|
||||
|
||||
---
|
||||
|
||||
## 承诺稳定的包
|
||||
## 契约面在 core,不在 go-admin
|
||||
|
||||
| 包 | 用途 |
|
||||
|---|---|
|
||||
| `common/actions` | 通用 CRUD Action(Index / View / Create / Update / Delete / Permission) |
|
||||
| `common/dto` | 分页、`search` tag 解析、`Control` / `Index` 接口 |
|
||||
| `common/models` | `ActiveRecord`、`ControlBy`、`ModelTime`、`Model` |
|
||||
| `common/middleware` | `AuthCheckRole`、`InitMiddleware` 等 |
|
||||
这份文档以前列的是 go-admin 自己的四个包(`common/actions` 等),依据写的是
|
||||
「把 `app/demo` 的 import 去重之后恰好就是这四个」。
|
||||
|
||||
**依据不是拍脑袋列的**:`app/demo` 是一个可编译、有测试、CI 会跑的标准 CRUD 模块,
|
||||
把它的 `go-admin/` 前缀 import 全部去重之后,恰好就是这四个包 —— 它代表
|
||||
"写一个标准模块所需要的最小依赖面"。你的模块如果需要第五个包,先在 issue 里说一声,
|
||||
那多半意味着契约面缺了什么。
|
||||
**那个依据是错的,而且错的方向是把人引向依赖宿主。**
|
||||
|
||||
"稳定"的含义:**在 `2.x` 内不做破坏性变更**。新增导出符号不算破坏;改签名、
|
||||
改语义、删除导出符号算,会走 major 版本并在 release note 里单列。
|
||||
go-admin 的使用方式是 clone / fork:每个使用者拿到的是一整份代码,然后**改它**。
|
||||
应用如果依赖 `go-admin/common/actions`,它依赖的是一个**每个使用者都不一样、
|
||||
而且随时在变**的东西——你没有办法测试自己的应用在别人改过的 fork 上能不能编译。
|
||||
|
||||
### 没有已知例外
|
||||
还有一条更硬的:`go-admin` 这个 module path 没有点号,
|
||||
按 Go 的规则**不是合法的可解析模块路径**:
|
||||
|
||||
这四个包**不 import `app/` 下的任何东西**,2026-08-31 起由 CI 强制
|
||||
(见下方「边界由 CI 守着」)。在此之前有两处反向依赖,都已根治:
|
||||
```
|
||||
$ go get go-admin/common/models
|
||||
go: malformed module path "go-admin/common/models": missing dot in first path element
|
||||
```
|
||||
|
||||
| 原位置 | 反向依赖 | 处理 |
|
||||
|---|---|---|
|
||||
| `common/middleware/logger.go` | `app/admin/service/dto` 的两个操作日志状态常量 | 常量下沉到 `common/global`,`dto` 侧保留同名常量作为 deprecated 别名,fork 不受影响 |
|
||||
| `common/middleware/handler/auth.go` | `app/admin/models` 的 `SysUser` / `SysRole` | 该段断言恒失败、设的是零值且开源版无人读取,属死代码,已删除 |
|
||||
想 import 它就必须写 `replace`,而**非主模块的 `replace` 会被忽略**——
|
||||
你在自己应用里写的 replace 对使用者不生效。所以「应用 require go-admin」
|
||||
这条路不是不优雅,是走不通。
|
||||
|
||||
之所以不把它们记成"已知例外":这份文档的作用就是告诉你哪些包可以依赖,
|
||||
如果第一条下面就挂着例外脚注,后来人会照着例外抄,边界从第一天起就是脏的。
|
||||
契约面因此落在 **go-admin-core**:那是唯一一个大家都一样、有版本号、
|
||||
不会被使用者随手改的东西。
|
||||
|
||||
---
|
||||
|
||||
## 其余包不保证稳定
|
||||
## 承诺稳定的包
|
||||
|
||||
`common/` 下没有出现在上表里的包(`common/global`、`common/storage`、
|
||||
`common/database`、`common/file_store`、`common/response`、`common/service`、
|
||||
`common/apis`、`common/middleware/handler`、根 `common` 包……)以及
|
||||
`app/admin` 的内部实现,**均不承诺稳定**。
|
||||
全部在 `github.com/go-admin-team/go-admin-core/v2` 下:
|
||||
|
||||
其中 `common/global`、`common/middleware/handler`、根 `common` 包是
|
||||
`common/middleware` 的编译期依赖 —— 它们会被一起拉进你的依赖图,但这不代表
|
||||
它们的 API 稳定。**不要因为"都在 `common/` 目录下"就认为是契约面。**
|
||||
| 包 | 用途 |
|
||||
|---|---|
|
||||
| `sdk/contract/models` | `Model` / `ControlBy` / `ModelTime` / `ActiveRecord` / `BaseUser` / `Migration`、`sys_menu.menu_type` 的三个枚举值 |
|
||||
| `sdk/contract/dto` | `Pagination` / `MakeCondition` / `Paginate` / `OrderDest` / `ObjectById`、`Index` 与 `Control` 接口 |
|
||||
| `sdk/contract/actions` | 数据权限设施:`DataPermission` / `Permission` / `PermissionAction` / `GetPermissionFromContext`、五个 `DataScope*` 常量与 `IsValidDataScope` |
|
||||
| `sdk/contract/migration` | `Registry` / `AppRegistrar` / `ForApp` / `SetVersion` / `GetFilename` |
|
||||
| `sdk/contract/seed` | `MenuSpec` / `ApiSpec` / `Seeder` / `SeedMenus`——往侧边栏和接口表里登记自己 |
|
||||
| `sdk/pkg` | `GetOrm(c)`:从请求上下文取本租户的数据库连接 |
|
||||
| `sdk/api`、`sdk/service` | 可选的 Api / Service 基类 |
|
||||
| `response` | `OK` / `Error` / `PageOK`:响应格式 |
|
||||
| `jwtauth/user` | 从 token 取当前用户身份 |
|
||||
| `sdk/runtime` | 中间件 key 常量与 `GetHandlerFunc`:复用宿主已注册的鉴权链 |
|
||||
|
||||
规划中的 001(模块路径改名)会把非契约包移进 `internal/`,由编译器强制这条边界。
|
||||
届时上表之外的包对外部模块直接不可见 —— 现在就照上表写,那次改动对你零成本。
|
||||
`sdk/contract/` 这个前缀的含义就是「**承诺对应用稳定**的那一面」。core 里
|
||||
`sdk/` 下的其他包是框架基础设施,语义不同——上表逐个列了名字,
|
||||
**不要因为「都在 core 里」就认为是契约面**。
|
||||
|
||||
"稳定"的含义:**在 core 的 `v2.x` 内不做破坏性变更**。新增导出符号不算破坏;
|
||||
改签名、改语义、删除导出符号算,会走 major 版本并在 release note 里单列。
|
||||
|
||||
准确的语义以 core 那份文档为准:
|
||||
[go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)。
|
||||
本文写的是宿主这一侧——它管不着的那些。
|
||||
|
||||
### go-admin 自己的包
|
||||
|
||||
`go-admin/common/models`、`common/dto`、`common/actions` 里的契约类型现在是
|
||||
**指向 core 的类型别名**(`type X = corepkg.X`),主仓和所有 fork 的存量代码
|
||||
一行不用改。别名在编译期就是同一个类型,不是"兼容层"。
|
||||
|
||||
但**新写的应用不要 import 它们**——那样就又依赖上宿主了。
|
||||
|
||||
---
|
||||
|
||||
## 契约面是三层,不是一层
|
||||
|
||||
划分依据不是"应用会 import 哪些包",而是**"哪一条不遵守会静默出错"**:
|
||||
|
||||
| 层 | 内容 | 判据 |
|
||||
|---|---|---|
|
||||
| **一 · 必须遵守** | 路由注册、从 context 取库、响应 shape、`ControlBy`/`ModelTime`、鉴权、数据权限、事务范式 | 不遵守 → **不报错,行为悄悄不对** |
|
||||
| **二 · 可选便利** | `api.Api`、`service.Service`、CRUD Action、`MakeCondition` | 用不用都对 |
|
||||
| **三 · 今天空白** | 应用间调用、领域事件、缓存租户隔离 | **没有。别自己发明** |
|
||||
|
||||
**框架不强制任何一层抽象。** 一个不用任何便利层的 handler 完全合法:
|
||||
|
||||
```go
|
||||
func handler(c *gin.Context) {
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
response.Error(c, 500, err, "")
|
||||
return
|
||||
}
|
||||
var list []MyModel
|
||||
if err := db.Find(&list).Error; err != nil {
|
||||
response.Error(c, 500, err, "")
|
||||
return
|
||||
}
|
||||
response.OK(c, list, "")
|
||||
}
|
||||
```
|
||||
|
||||
第一层则是不管你用不用便利层都要遵守的,逐条写在下面,每条都附**不遵守会怎样**。
|
||||
|
||||
---
|
||||
|
||||
## 第一层:不遵守就静默出错
|
||||
|
||||
### 1. 路由注册
|
||||
|
||||
见下方「注册路由」一节。
|
||||
|
||||
**不遵守会怎样**:注册表在 `RunAppRouters()` 之后就封闭了,晚到的注册被丢弃,
|
||||
只记一条 ERROR 日志。包级 `AppRouters` 连这个都没有——它就是一个普通 slice,
|
||||
什么时候 append 都"成功",启动钩子之后 append 的那些永远不会执行,**且不出声**。
|
||||
|
||||
### 2. 数据库连接从 context 取,不用全局变量
|
||||
|
||||
```go
|
||||
db, err := pkg.GetOrm(c) // 唯一正确的取法
|
||||
```
|
||||
|
||||
`common/middleware/db.go` 在每个请求上按 `c.Request.Host` 挑出本租户的连接
|
||||
放进 context:
|
||||
|
||||
```go
|
||||
c.Set("db", sdk.Runtime.GetDbByTenant(c.Request.Host).WithContext(c))
|
||||
```
|
||||
|
||||
**不遵守会怎样**:连接是**按租户注册**的(`SetDbByTenant(host, db)`),
|
||||
`GetOrm(c)` 按 `c.Request.Host` 挑。你要是在启动时把某个连接存进包级变量再一直用,
|
||||
多租户部署下所有租户的读写就都落到那一个库上——不报错、不告警,数据串了才发现。
|
||||
|
||||
这个坑在本仓库真踩过:`common/global.Driver` 取的是启动循环
|
||||
**迭代到的第一个**库的驱动(`common/database/initialize.go`),
|
||||
而 Go 的 map 迭代顺序是随机的——两个库用不同驱动时,那个值每次启动都可能不一样。
|
||||
所以「一个进程一个库」这个假设不要写进任何一行代码。
|
||||
|
||||
### 3. 响应 shape
|
||||
|
||||
一律用 `response.OK` / `response.Error` / `response.PageOK`,不要自己
|
||||
`c.JSON`。它们发出去的形状是:
|
||||
|
||||
```jsonc
|
||||
// 成功
|
||||
{"requestId": "...", "code": 200, "data": {...}}
|
||||
// 分页:data 里再套一层
|
||||
{"requestId": "...", "code": 200, "data": {"count": 42, "pageIndex": 1, "pageSize": 10, "list": [...]}}
|
||||
// 失败
|
||||
{"requestId": "...", "code": 500, "msg": "...", "status": "error"}
|
||||
```
|
||||
|
||||
**HTTP 状态码永远是 200**,业务码在 body 的 `code` 里——这是既定行为,
|
||||
`response.Error` 走的是 `c.AbortWithStatusJSON(http.StatusOK, res)`。
|
||||
|
||||
**不遵守会怎样**:前端 `src/utils/request.ts` 的响应拦截器只读 body 的 `code`,
|
||||
`code !== 200` 就弹一条 `msg` 内容的 error toast 并 reject。你自己
|
||||
`c.JSON(200, myThing)` 的话 `code` 是 `undefined`,界面上弹出来的是**一条空的
|
||||
错误提示**,数据到不了页面。列表更安静:`useTable.ts` 读的是
|
||||
`page?.list ?? []` 和 `page?.count ?? 0`,形状对不上就是**一张空表,零报错**。
|
||||
|
||||
### 4. `ControlBy` 与 `ModelTime`
|
||||
|
||||
每张业务表的 model 都嵌这三个:
|
||||
|
||||
```go
|
||||
type Order struct {
|
||||
models.Model // Id
|
||||
// ... 你的字段 ...
|
||||
models.ControlBy // CreateBy / UpdateBy
|
||||
models.ModelTime // CreatedAt / UpdatedAt / DeletedAt
|
||||
}
|
||||
|
||||
func (Order) TableName() string { return "app_order" } // 必须显式声明
|
||||
```
|
||||
|
||||
`ControlBy` 提供 `create_by` 列,**数据权限的每一条 SQL 都 join 在它上面**。
|
||||
`ModelTime` 的 `DeletedAt` 是 `soft_delete.DeletedAt`(毫秒时间戳,活行为 0,
|
||||
永不为 NULL),不是 `gorm.DeletedAt`。
|
||||
|
||||
**不遵守会怎样**:
|
||||
|
||||
- 嵌了 `ControlBy` 但写入时忘了 `SetCreateBy(user.GetUserId(c))`,
|
||||
`create_by` 就是 0。除「全部数据权限」外的每一档都**查不到任何数据**,
|
||||
而且不报错——看起来像"这个用户还没建过数据"。
|
||||
- 用错 `ModelTime` 版本(可空的 `gorm.DeletedAt`):gorm 按
|
||||
`deleted_at IS NULL` 过滤,而活行里存的是 0,于是**整张表一行都查不出来**。
|
||||
主仓的 `sys_columns` / `sys_tables` 真在这个状态下待过——代码生成器
|
||||
一张表都列不出来,没有任何报错。`make checksilent` 的 `modeltime-mix`
|
||||
就是为这条加的。
|
||||
- `TableName()` 忘了写:GORM 配了 `SingularTable`,不会推导复数,表名会是
|
||||
你没预料的那个。
|
||||
|
||||
### 5. 鉴权:用宿主已注册的中间件,不要自己造
|
||||
|
||||
```go
|
||||
jwtCheck, ok := sdk.Runtime.GetHandlerFunc(runtime.JwtTokenCheck)
|
||||
if !ok {
|
||||
log.Fatal("JwtTokenCheck is not registered; is the host started via cmd/api?")
|
||||
}
|
||||
roleCheck, _ := sdk.Runtime.GetHandlerFunc(runtime.RoleCheck)
|
||||
permCheck, _ := sdk.Runtime.GetHandlerFunc(runtime.PermissionCheck)
|
||||
|
||||
g := v1.Group("/order").Use(jwtCheck).Use(roleCheck).Use(permCheck)
|
||||
```
|
||||
|
||||
三个 key 的常量在 `sdk/runtime`,宿主启动时把三个中间件注册进去。
|
||||
|
||||
**不遵守会怎样**:`GetHandlerFunc` 在"没注册"和"注册成了别的类型"两种情况下
|
||||
都返回 `ok=false` 而不是 panic——**因为路由注册跑在 core 的 panic 护栏里面,
|
||||
裸类型断言 panic 之后日志报的是"这个模块一条路由都没注册上",跟真实原因对不上**。
|
||||
所以 `ok` 必须自己判,判出来要**大声失败**:一个跳过鉴权继续注册的路由,
|
||||
就是一条静默的匿名可访问接口。
|
||||
|
||||
**宿主必须注册绑定过的闭包。** 三个 key 存的都得是 `gin.HandlerFunc`——
|
||||
比如 `authMiddleware.MiddlewareFunc()`,**不是** `(*jwt.GinJWTMiddleware).MiddlewareFunc`。
|
||||
后者是方法表达式,没有接收者绑在上面,取回来断言不成 `gin.HandlerFunc`,
|
||||
怎么断言都做不成一个能用的 handler。
|
||||
|
||||
> **当前状态**:`common/middleware/init.go` 里 `RoleCheck` 与 `PermissionCheck`
|
||||
> 注册的是 `AuthCheckRole()` 和 `actions.PermissionAction()`,都是绑定过的闭包,
|
||||
> 取回来就能用;**`JwtTokenCheck` 注册的还是那个方法表达式**,所以今天对它
|
||||
> `GetHandlerFunc` 拿到的是 `ok=false`。上面那段 `log.Fatal` 会在启动时打出来——
|
||||
> 这是有意的,宁可起不来也不要一条没鉴权的路由。主仓这一处的修复见 F10,
|
||||
> 修完之后本段可以删掉。
|
||||
|
||||
还有一条**不影响行为但影响理解**的:主仓今天四个模块各自调一次 `AuthInit()`
|
||||
(`app/admin`、`app/jobs`、`app/other`、`app/demo`),也就是有四个 JWT 实例。
|
||||
这不产生行为差异——配置同源(`config.JwtConfig`),JWT 校验是无状态的,
|
||||
不看实例身份。但它意味着 `GetHandlerFunc(runtime.JwtTokenCheck)` 取回来的是
|
||||
**最后注册进去的那一个**。要让应用拿到一个有意义的共享实例,宿主应当在注册路由
|
||||
之前构造一次,而不是每个模块构造一次。
|
||||
|
||||
**测的时候别用 `admin` 账号。** `AuthCheckRole` 里 `rolekey == "admin"` 直接
|
||||
`c.Next()`,**完全跳过 Casbin**。拿 admin 压任何鉴权路径都测不到东西。
|
||||
|
||||
### 6. 数据权限
|
||||
|
||||
两件事都要做:
|
||||
|
||||
```go
|
||||
// 路由上挂中间件(上一节的 permCheck 就是它)
|
||||
g := v1.Group("/order").Use(permCheck)
|
||||
|
||||
// 查询里组合 scope
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
db.Scopes(actions.Permission(Order{}.TableName(), p)).Find(&list)
|
||||
```
|
||||
|
||||
`sys_role.data_scope` 有五档,`Permission()` 按它拼 WHERE 条件:
|
||||
|
||||
| 值 | 常量 | 含义 | 条件 |
|
||||
|---|---|---|---|
|
||||
| `1` | `DataScopeAll` | 全部数据权限 | 不加条件 |
|
||||
| `2` | `DataScopeCustom` | 自定义数据权限 | `create_by` 属于 `sys_role_dept` 关联到的部门 |
|
||||
| `3` | `DataScopeDept` | 本部门 | `create_by` 属于本部门 |
|
||||
| `4` | `DataScopeDeptTree` | 本部门及以下 | `create_by` 属于 `dept_path` 匹配的子树 |
|
||||
| `5` | `DataScopeSelf` | 仅本人 | `create_by = 当前用户` |
|
||||
|
||||
自己往 `sys_role.data_scope` 写值的话先过一遍 `IsValidDataScope`——
|
||||
写进去的非法值不会在写入时报错,只会在**每一次查询**里静默地什么都查不到。
|
||||
|
||||
**不遵守会怎样**,两种漏法的方向相反,值得分清:
|
||||
|
||||
- **查询里忘了组合 `Permission()`** —— 就是**全量可见**,每个角色都看得到所有人
|
||||
的数据,不报错、不记日志。**这是本框架里最贵的一类静默失败**,所以那一行
|
||||
`db.Scopes(...)` 不是"最佳实践",是契约。
|
||||
- **组合了 `Permission()` 但路由上漏挂中间件** —— 上下文里没有 `PermissionKey`,
|
||||
拿到的是零值,`DataScope` 是空串,落进下面那个 fail-closed 的 default,
|
||||
结果是**一行都查不到**。方向反了,至少还看得见。
|
||||
|
||||
五档之外的值(空串、拼错的、还没迁移的老数据)落到 `default` 分支,
|
||||
那里是 **fail closed**:加一条 `1 = 0`,什么都不返回。注意 `1`(全部数据权限)
|
||||
是**显式列出的一个 case**,不是"落到 default"——两者曾经是同一条路,
|
||||
于是"没配置"和"配置成看全部"产出的 SQL 一个字都不差。
|
||||
|
||||
`3` / `4` 两档在 `DeptId <= 0` 时同样 fail closed。原因是
|
||||
`sys_dept.dept_path` 一律以 `/0/` 开头,`dept_id=0` 会把 LIKE 模式变成
|
||||
`'%/0/%'`,**命中全表**——本来想表达"没有部门",实际表达的是"全部部门"。
|
||||
|
||||
数据权限还有一个**全局开关** `application.enabledp`,默认是 `false`。
|
||||
关掉时 `Permission()` 原样返回查询、`PermissionAction()` 直接放行——
|
||||
**你的应用在默认配置下测不出数据权限的任何行为**,要验证得先把它打开。
|
||||
|
||||
**不要自己重写这段 SQL。** 那 20 行里埋着 8 项内部知识:JWT claims 的私有键名
|
||||
(`datascope` / `deptid`)、`sys_user`↔`sys_role` 的 join、`sys_role_dept`
|
||||
关联表、`sys_dept.dept_path` 的 `/0/1/2/` 编码、`create_by` 的归属约定、
|
||||
`enabledp` 开关、老 token 的回落逻辑。**而且写错的方向是越权。**
|
||||
仓库里有过一份第二实现,`dept_path` 的匹配写成 `"%"+id+"%"` 少了两个斜杠,
|
||||
`dept_id=1` 会匹配上 `/11/`、`/21/`、`/100/`——写它的人比第三方更懂这套约定,
|
||||
仍然写错了。那份实现已经删掉了。
|
||||
|
||||
### 7. 事务范式
|
||||
|
||||
**业务层的事务一律用 `Transaction()` 闭包形式**:
|
||||
|
||||
```go
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err // rolled back
|
||||
}
|
||||
return tx.Model(&stock).Where("qty >= ?", n).
|
||||
UpdateColumn("qty", gorm.Expr("qty - ?", n)).Error
|
||||
})
|
||||
```
|
||||
|
||||
GORM 自己处理提交、回滚,以及 **panic 时的回滚**。
|
||||
|
||||
**不要照抄 `app/admin/service/sys_role.go`。** 那里有 5 处手写的
|
||||
`Begin` / `defer` 写法,三个缺陷都是静默的:
|
||||
|
||||
```go
|
||||
tx := e.Orm
|
||||
if config.DatabaseConfig.Driver != "sqlite3" { // 缺陷 2
|
||||
tx = e.Orm.Begin()
|
||||
defer func() {
|
||||
if err != nil { tx.Rollback() } else { tx.Commit() } // 缺陷 1
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
1. **panic 时提交半截事务**——defer 只看 `err`,panic 时 `err` 仍是 nil,走的是
|
||||
`Commit()`
|
||||
2. **sqlite 下根本不开事务**——那一整个特判让 `tx` 就是 `e.Orm` 本身,
|
||||
写一半失败留一半
|
||||
3. **读 `config.DatabaseConfig.Driver`**——那是全局单库配置,多租户下不是
|
||||
当前租户的驱动
|
||||
|
||||
缺陷 1 不止那一处:`app/admin/service/sys_dept.go`、`sys_menu.go`、
|
||||
`app/other/models/tools/sys_tables.go` 用的是同一个 `defer` 写法
|
||||
(没有 sqlite 特判,所以只有缺陷 1)。**整个 `Begin`/`defer` 家族都别照抄。**
|
||||
|
||||
同一个仓库里就有正确的参照:`cmd/migrate/migration/version/` 下 7 个迁移里
|
||||
5 个用的是闭包形式(另外两个是纯 DDL 标记,DDL 在 MySQL 下本来就不进事务),
|
||||
且这条路在 sqlite 下实测跑得通(`make build-sqlite`)。
|
||||
主仓那些写法本批次不改,单独跟。
|
||||
|
||||
**并发保护用条件更新 + `RowsAffected`**,不要"先查后改":
|
||||
|
||||
```go
|
||||
res := tx.Model(&Order{}).Where("id = ? AND status = ?", id, StatusPending).
|
||||
Update("status", StatusPaid)
|
||||
if res.Error != nil { return res.Error }
|
||||
if res.RowsAffected == 0 { return ErrAlreadyPaid } // 别人先改了
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二层:可选便利
|
||||
|
||||
用不用都对,**不用不会出任何问题**:
|
||||
|
||||
| 东西 | 在哪 | 是什么 |
|
||||
|---|---|---|
|
||||
| `api.Api` | core `sdk/api` | 一条链式糖:`MakeContext` / `Bind` / `MakeOrm` / `OK` / `PageOK` / `Error` |
|
||||
| `service.Service` | core `sdk/service` | 一个装 `Orm` / `Log` / `Cache` / `Error` 的结构体加一个 `AddError` |
|
||||
| `MakeCondition` / `search` tag | core `sdk/contract/dto` | 把 DTO 上的 `search:"type:exact;column:name;table:xx"` 翻成 WHERE |
|
||||
| 通用 CRUD Action | go-admin `common/actions` | `IndexAction` 等五个。**留在 go-admin,没有下沉** |
|
||||
|
||||
最后一行是有意的:CRUD Action 是最需要演进的一类东西(分页参数、批量操作、
|
||||
软删语义、字段级权限),而 core 的每一个导出都是永久承诺——放进去容易,
|
||||
拿出来不可能。想用就把那 294 行抄走,抄走的那份还能按你自己的需要改。
|
||||
主仓唯一的真实业务模块 `app/admin` **一个 CRUD Action 都没用**,全是手写 Service。
|
||||
|
||||
`MakeCondition` 返回的是 `func(db *gorm.DB) *gorm.DB` 闭包,方言从闭包里那个
|
||||
`db.Dialector.Name()` 读,**必然是本租户那个库的驱动**,不需要你设置任何东西。
|
||||
|
||||
---
|
||||
|
||||
## 第三层:今天没有的
|
||||
|
||||
**明说没有,别自己发明**:
|
||||
|
||||
| 能力 | 现状 |
|
||||
|---|---|
|
||||
| 应用间调用 | 零定义。A 应用要调 B 应用只能直接 import 对方的包,循环依赖就回来了 |
|
||||
| 领域事件 / EventBus | 无 |
|
||||
| 缓存的租户隔离 | `service.Service` 有 `Cache` 字段,**是否按租户隔离未验证**。当作没隔离来写 |
|
||||
| 异步任务 | 有队列,但热更新后消费者会丢(issue #892) |
|
||||
|
||||
这几条留给后续批次,按真实需求补——现在凭空设计只会设计错。
|
||||
如果你的应用卡在这里,在 issue 里说一声,那正是我们要的输入。
|
||||
|
||||
---
|
||||
|
||||
## 装一个应用要接两处线
|
||||
|
||||
后端**两处**,漏掉第二处是**静默失败**:
|
||||
|
||||
```go
|
||||
// 1. 路由:cmd/api/<name>.go
|
||||
import _ "github.com/acme/go-admin-app-order/router"
|
||||
|
||||
// 2. 迁移:cmd/migrate/server.go 的 import 块里
|
||||
import _ "github.com/acme/go-admin-app-order/migration"
|
||||
```
|
||||
|
||||
两个都是空导入,作用只是让那个包的 `init()` 跑起来。
|
||||
|
||||
**漏了第二处会怎样**:不报错。`migrate` 命令照常跑完、照常打印成功,
|
||||
你的建表和种子数据**就是不执行**。等到第一个请求打过来才会看到
|
||||
"表不存在",而那时排查方向已经跑偏了。
|
||||
|
||||
`migrate --dry-run` 是确认接线成功的最快方式——它只读,可以直接对生产库跑:
|
||||
|
||||
```bash
|
||||
go-admin migrate --dry-run -c config/settings.yml # 你的迁移应该出现在列表里
|
||||
```
|
||||
|
||||
带界面的应用还有第三处,在前端仓库,见下一节。
|
||||
|
||||
---
|
||||
|
||||
## 前端:菜单 `component` 必须以 `apps/` 开头
|
||||
|
||||
前端那一处接线是 `go-admin-ui` 的 `apps.config.mjs`——加一条
|
||||
`{ code: 'order', source: '...' }`,`source` 指到你的页面目录
|
||||
(兄弟目录的相对路径,或 `./node_modules/@scope/app-order/views/order`)。
|
||||
`scripts/sync-apps.mjs` 会在 `pnpm dev` 与 `pnpm build` 之前把它复制进
|
||||
`src/apps/<code>/`,不需要手工跑。
|
||||
|
||||
`src/stores/permission.ts` 的 `appPath()` **只认路径第一段是 `apps`**,
|
||||
其余一律当成主仓内置视图去 `src/views/` 下找。
|
||||
|
||||
所以你的菜单种子里 `Component` 必须写成:
|
||||
|
||||
```
|
||||
apps/<code>/<该应用内的相对路径>/index
|
||||
```
|
||||
|
||||
比如 `code` 是 `order` 的应用写 `apps/order/index`(开头带不带 `/` 都行,
|
||||
只看第一段)。**不能**写成 `/order/index`。
|
||||
|
||||
**写错会怎样**:第一段是 `order` 而不是 `apps`,前端会去找一个不存在的
|
||||
`src/views/order/index.vue`,页面摔到 `AppNotInstalled` 占位组件。
|
||||
但控制台打印的是 `no component at src/views/order/index.vue`——
|
||||
**跟真实原因(漏了 `apps/` 前缀)对不上**,排查时很容易被这条日志带偏。
|
||||
|
||||
对应的前端约定写在 go-admin-ui 的 `AGENTS.md`。另外一条:`source` 目录的内容
|
||||
**原样**搬进 `src/apps/<code>/`,不会在 `code` 之外再自动插一层——想要
|
||||
`apps/order/index` 这种最短形式,`source` 就要直接指到该应用**这一个页面模块**
|
||||
的目录,而不是应用仓库的 `views` 根目录。
|
||||
|
||||
---
|
||||
|
||||
## 注册路由
|
||||
|
||||
一个应用模块要注册自己的路由,写一个 `func()` 签名的 `InitRouter`
|
||||
(照抄 `app/demo/router/router.go`),然后二选一接进来:
|
||||
写一个 `func()` 签名的 `InitRouter`(照抄 `app/demo/router/router.go`),
|
||||
然后二选一接进来:
|
||||
|
||||
```go
|
||||
// 方式一(历史写法,仍然有效):在主仓 cmd/api/<name>.go 里
|
||||
@@ -68,36 +457,30 @@ AppRouters = append(AppRouters, router.InitRouter)
|
||||
sdk.Runtime.SetAppRouters(router.InitRouter)
|
||||
```
|
||||
|
||||
方式二是本次新接上的。差别只有一个但很关键:方式一要求你的模块
|
||||
`import "go-admin/cmd/api"` —— 那是主程序的命令包,让业务模块依赖它很别扭,
|
||||
也正是"主仓要为每个模块加一个七行文件"的根源。
|
||||
**第三方应用只能走方式二**——方式一要求 `import "go-admin/cmd/api"`,
|
||||
那就又依赖上宿主了。
|
||||
|
||||
**执行顺序**:先跑完包级 `AppRouters`,再由 core 的 `sdk.Runtime.RunAppRouters()`
|
||||
跑它自己的注册表,各自内部保持注册顺序。别依赖跨来源的相对顺序,各模块的
|
||||
`RouterGroup` 前缀互不相同,本来就不该有顺序依赖。
|
||||
走方式二还多拿到两样东西,都在 core 那边实现:
|
||||
|
||||
走方式二还多拿到两样东西,都在 core 那边实现(见
|
||||
[core 的 `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)):
|
||||
**panic 护栏**——你的 `InitRouter` panic 了,其余模块照常注册、进程不退出,日志里会写明
|
||||
是哪一行注册的;**失败分级**——`sdk.Runtime.SetAppRoutersWith(f, runtime.WithFatal())`
|
||||
声明「我起不来就别启动」。方式一(包级 `AppRouters`)没有护栏,panic 直接掀桌。
|
||||
- **panic 护栏**——你的 `InitRouter` panic 了,其余模块照常注册、进程不退出,
|
||||
日志里会写明是哪一行注册的
|
||||
- **失败分级**——`sdk.Runtime.SetAppRoutersWith(f, runtime.WithFatal())`
|
||||
声明「我起不来就别启动」
|
||||
|
||||
`InitRouter()` 内部的约定:自己拿 `sdk.Runtime.GetEngine()`,按需建
|
||||
方式一(包级 `AppRouters`)没有护栏,panic 直接掀桌。
|
||||
|
||||
**执行顺序**:先跑完包级 `AppRouters`,再由 `sdk.Runtime.RunAppRouters()`
|
||||
跑 core 自己的注册表,各自内部保持注册顺序。别依赖跨来源的相对顺序。
|
||||
|
||||
`InitRouter()` 内部:自己拿 `sdk.Runtime.GetEngine()`,按需建
|
||||
`gin.RouterGroup`,通过 `init()` 自注册到你自己包内的
|
||||
`routerCheckRole` / `routerNoCheckRole` 列表,不在任何中心文件手工列举
|
||||
(与 `AGENTS.md`「路由注册」一节一致)。
|
||||
`routerCheckRole` / `routerNoCheckRole` 列表,不在任何中心文件手工列举。
|
||||
|
||||
---
|
||||
|
||||
## 注册数据库迁移
|
||||
|
||||
框架自身的迁移不变:
|
||||
|
||||
```go
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700001000DemoMenu)
|
||||
```
|
||||
|
||||
应用的迁移走 `ForApp`:
|
||||
框架自身的迁移用 `SetVersion`;应用的迁移走 `ForApp`:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
@@ -108,12 +491,20 @@ func init() {
|
||||
func initCrmTables(db *gorm.DB, version, appCode string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// ... schema / data changes ...
|
||||
return tx.Create(&common.Migration{Version: version, AppCode: appCode}).Error
|
||||
return tx.Create(&models.Migration{Version: version, AppCode: appCode}).Error
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
四条必须知道的规则:
|
||||
注册面(`ForApp` / `SetVersion` / `GetFilename`)在 core 的
|
||||
`sdk/contract/migration`,是一个**进程级的包级注册表**——`ForApp` 直接当包级函数
|
||||
调,不需要从宿主手里接过什么句柄。**执行面**——读 `sys_migration`、排序、跑事务、
|
||||
`migrate` 与 `migrate status` 两个命令——留在宿主,它通过 `Snapshot()` 读那张表。
|
||||
|
||||
仓库内的模块继续经 `go-admin/cmd/migrate/migration` 走,那个包现在是薄壳,
|
||||
导入路径不变;外置应用直接 import core 的那个包,**两边写法一模一样**。
|
||||
|
||||
五条必须知道的规则:
|
||||
|
||||
1. **完成记录由迁移函数自己写**,而且要写在自己的事务里。框架的调度循环只做
|
||||
"这个 version 在 `sys_migration` 里有没有" 的判断,从不代你插入 —— 这样
|
||||
@@ -122,12 +513,17 @@ func initCrmTables(db *gorm.DB, version, appCode string) error {
|
||||
schema 上那一列等于白加,你的迁移会被记成框架的。
|
||||
3. **落库的 `version` 是加了前缀的**。`ForApp("crm")` 注册 `1786800001000`,
|
||||
实际写进 `sys_migration.version` 的是 `crm-1786800001000`,函数收到的
|
||||
`version` 参数已经是这个带前缀的值,照抄进 `common.Migration{Version: version}`
|
||||
`version` 参数已经是这个带前缀的值,照抄进 `models.Migration{Version: version}`
|
||||
即可。前缀的意义是:两个来源不同的应用哪怕碰巧生成同一个毫秒时间戳,也不会撞主键、
|
||||
不会有一方被误判为"已应用"。
|
||||
4. **应用 code 一律小写**,`ForApp` 会自己 `strings.ToLower` 一遍。`core` 是保留字
|
||||
(`migrate status` 用它表示框架自身,`--app core` 选中框架),`ForApp("core")`
|
||||
会 panic。
|
||||
5. **文件名前 13 位必须是毫秒时间戳**,`GetFilename` 就是从这里取版本号的。
|
||||
不合规的名字会 panic,并把违规文件名报出来 —— 这是**故意的**:调用点全在
|
||||
`init()` 里,没有 error 可返回,而另一条路是把文件名本身注册成"版本号"
|
||||
(`add_orders.go` 恰好 13 个字符,只查长度是拦不住的),那样这条迁移
|
||||
永远不会被执行,且不会有任何提示。宁可启动失败。
|
||||
|
||||
顺序保证:**同一应用内按版本号严格有序**。跨应用顺序不做承诺 —— 由于前缀的存在,
|
||||
今天的实际顺序是"先跑完全部框架迁移,再按 appCode 字母序逐个应用跑完",
|
||||
@@ -146,6 +542,98 @@ go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
|
||||
|
||||
---
|
||||
|
||||
## 菜单与接口种子
|
||||
|
||||
一个带界面的应用要在侧边栏里出现,需要往四类数据里写东西:`sys_api`、
|
||||
`sys_menu`、`sys_menu_api_rule`(菜单与接口的关联)、以及角色授权与 Casbin
|
||||
策略(`sys_role_menu` / `casbin_rule`)。
|
||||
|
||||
**你不需要知道这些表长什么样。** `sdk/contract/seed` 让你只描述"我要什么",
|
||||
由宿主决定"怎么写进它自己的表":
|
||||
|
||||
```go
|
||||
// 在你自己的迁移里,用它自己的那个事务
|
||||
err := seed.SeedMenus(tx, "order", []seed.MenuSpec{
|
||||
{Code: "root", Kind: models.Directory, Title: "订单"},
|
||||
{Code: "list", Parent: "root", Kind: models.Menu, Title: "订单列表",
|
||||
Path: "/order", Component: "apps/order/index", ApiCodes: []string{"list"}},
|
||||
}, []seed.ApiSpec{
|
||||
{Code: "list", Title: "订单列表", Path: "/api/v1/order", Method: "GET"},
|
||||
})
|
||||
```
|
||||
|
||||
`Kind` 用的就是 `sdk/contract/models` 里 `sys_menu.menu_type` 的那三个值
|
||||
(`Directory` / `Menu` / `Button`),不是另一套同值的常量。
|
||||
|
||||
`Component` 的写法见上面「前端」一节——**这里是最容易写错的一个字段**。
|
||||
|
||||
core 里**没有** `SysMenu`、没有 `SysApi`、没有任何表名。这是刻意划的边界:
|
||||
这个框架的宿主里本来就已经有两份 `SysMenu`(一份冻结在迁移期、一份运行期),
|
||||
两者在软删语义上不一致,害过人,为此专门建了一个仓库内的工具来守。
|
||||
往 core 里再放第三份表结构,就等于在**唯一没有工具守着**的地方重造同一类 bug。
|
||||
|
||||
### `Sort` 有上界,越界会中断整场迁移
|
||||
|
||||
`sys_menu.sort` 声明为 `gorm:"size:4"`,MySQL 据此建成 **tinyint,取值 -128..127**。
|
||||
sqlite 忽略宽度,所以越界值在本地测试里一路绿灯,到真实安装时是 Error 1264 ——
|
||||
而且发生在一次迁移的**中途**,后面的迁移全部不再执行。
|
||||
|
||||
`make checksilent` 的 `menu-sort-overflow` 会扫出仓库树里的越界字面量,
|
||||
**但它扫不到 module cache 里的应用**。外置应用只有宿主 Seeder 的运行期校验兜底。
|
||||
|
||||
### `MenuSpec` 没有菜单名字段,名字由宿主合成
|
||||
|
||||
前端用菜单名做 keep-alive 的缓存键。两个应用如果都取 `Code: "list"`,
|
||||
缓存键就会撞在一起 —— 后打开的那个页面会拿到前一个的缓存实例。
|
||||
|
||||
所以宿主的 Seeder 不直接用 `Code` 当菜单名,而是用
|
||||
**PascalCase(appCode) + PascalCase(Code)** 合成(`order` + `list` → `OrderList`)。
|
||||
你不需要做什么,但要知道两件事:
|
||||
|
||||
- 菜单名不是你能指定的,也不必与 `Title` 一致 —— `Title` 才是界面上显示的文字
|
||||
- 前端组件的 `name` 若要与菜单名对齐(`checksilent` 的 `menu-name-mismatch` 会比对),
|
||||
按合成后的名字写,不是按 `Code`
|
||||
|
||||
---
|
||||
|
||||
## 应用配置节
|
||||
|
||||
不要改宿主的源码去加配置。`sdk/config.RegisterExtend` 让你认领
|
||||
`extend:` 下自己那一节:
|
||||
|
||||
```go
|
||||
type orderConfig struct {
|
||||
PaymentEndpoint string
|
||||
Timeout int
|
||||
}
|
||||
|
||||
// 在 init() 里调,与 SetAppRouters / ForApp 同一约定
|
||||
var getOrderConfig = config.RegisterExtend[orderConfig]("order")
|
||||
|
||||
func handler(c *gin.Context) {
|
||||
cfg := getOrderConfig()
|
||||
_ = cfg.PaymentEndpoint
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
extend:
|
||||
order:
|
||||
PaymentEndpoint: https://payment.internal
|
||||
Timeout: 30
|
||||
```
|
||||
|
||||
每个 key 各自解码,互不覆盖。**同一个 key 注册两次会立刻 panic**——
|
||||
注册期没有"封闭时刻"可以用来拒绝迟到的注册,所以重复只能在注册的那一刻
|
||||
大声报出来,而不是让第二个人静默顶掉第一个人的配置节。
|
||||
|
||||
配置文件是被监听的,改动会触发重载。`RegisterExtend` 每次重载解码进一个全新的
|
||||
`T` 再原子换指针,所以访问器拿到的永远是一个自洽的快照,请求路径上读它不需要加锁。
|
||||
唯一要注意的:**不要跨两次调用拼一个视图**——从同一个返回值上读两个字段是一致的,
|
||||
调两次访问器各读一个字段,中间夹一次重载就不是了。
|
||||
|
||||
---
|
||||
|
||||
## 硬约束:注册要赶在启动钩子之前
|
||||
|
||||
三个注册入口——`AppRouters`、`sdk.Runtime.SetAppRouters`、`migration.ForApp`——
|
||||
@@ -153,18 +641,16 @@ go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
|
||||
|
||||
`init()` 是最省事的位置:Go 规范保证包级变量初始化与 `init()` 在 `main()` 之前
|
||||
**单 goroutine 顺序执行**,注册期天然没有并发写。但它不是唯一合法位置——
|
||||
在 `run()` 之类早于启动钩子的地方注册同样成立。这条规则约束的是**顺序**,
|
||||
不是你写在哪个函数里。
|
||||
在 `run()` 之类早于启动钩子的地方注册同样成立。**这条规则约束的是顺序,
|
||||
不是你写在哪个函数里。**
|
||||
|
||||
`sdk.Runtime.SetAppRouters` 的准确语义以 core 为准:
|
||||
想在代码里判断注册窗口是否还开着:
|
||||
|
||||
> [go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)
|
||||
```go
|
||||
if sdk.Runtime.AppRoutersSealed() { /* RunAppRouters 已经跑过了 */ }
|
||||
```
|
||||
|
||||
那份文档写明了注册类与资源类的划分、封闭时刻、护栏边界(**只覆盖同步 panic,
|
||||
你自己 `go func()` 出去的 panic 框架够不着**)、以及配置热更新会在运行期
|
||||
重新执行 setup 回调这件事。
|
||||
|
||||
主仓这边只补三条它管不着的:
|
||||
主仓这边补三条 core 那份文档管不着的:
|
||||
|
||||
1. **`RunAppRouters()` 跑过之后,core 的注册表就封闭了**,再调
|
||||
`sdk.Runtime.SetAppRouters` 会被丢弃并记一条 ERROR 日志。包级 `AppRouters`
|
||||
@@ -181,19 +667,56 @@ go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
|
||||
```
|
||||
|
||||
`cmd/api/server_test.go` 里的 `freshRuntime` 就是这个。
|
||||
3. **`migration.ForApp` 是主仓的东西**,core 不认识它,上面那份文档不覆盖它。
|
||||
它的约束仍然是"注册要在迁移调度循环跑起来之前",实践上就是 `init()`。
|
||||
3. **迁移的调度循环是主仓的东西**,core 只有注册面。迁移注册的约束仍然是
|
||||
"赶在调度循环跑起来之前",实践上就是 `init()`。
|
||||
|
||||
---
|
||||
|
||||
## 安全边界:装一个应用等于信任它
|
||||
|
||||
**这一层划不出安全边界,本文不假装划得出。**
|
||||
|
||||
第三方应用的代码在**宿主进程内**运行,与宿主**同权限**。它持有的是裸的
|
||||
`*gorm.DB`——`seed.SeedMenus` 用的就是你自己迁移里那个 `tx`,绕开 `Seeder`
|
||||
直写 `sys_menu`、`sys_api`、甚至 `casbin_rule` 一直都做得到,Go 的类型系统
|
||||
拦不住,本框架的任何一层也拦不住。
|
||||
|
||||
还有一条**不碰 `casbin_rule` 也能走通**的间接路径:把自己的菜单通过
|
||||
`ApiCodes` 关联到别人的接口,然后等管理员在后台把这个菜单授权给某个角色——
|
||||
策略是后台自己生成的,记在管理员头上。
|
||||
|
||||
所以:
|
||||
|
||||
> **装一个应用,等于信任它。** 这和 `import _` 一个 Go 库是同一量级的信任。
|
||||
> `Seeder` 这类设计的目的是让**守规矩的应用不必知道宿主的表结构**,
|
||||
> 不是把不守规矩的应用关起来。
|
||||
|
||||
给使用者的实际建议只有一条:**按信任 Go 依赖的标准来审应用**——看源码、
|
||||
钉版本、认作者。不要因为它叫"应用"就以为它跑在沙箱里。
|
||||
|
||||
---
|
||||
|
||||
## 边界由 CI 守着
|
||||
|
||||
`common/`、`core/` 不得 import `app/`,这条由 `tools/checksilent` 的
|
||||
`contract-import-boundary` 检查固化,`make checksilent` 在 CI 里跑,违反即失败
|
||||
(测试文件同样算 —— 一个删掉 `app/admin` 的 fork 也应该能跑 `go test ./...`)。
|
||||
`tools/checksilent` 里有两条盯契约面的检查,`make checksilent` 在 CI 里跑,
|
||||
命中 ERROR 即失败:
|
||||
|
||||
靠人工评审列契约面会漏。上面那两处反向依赖里,第二处就是评审没发现、
|
||||
靠机器全量扫描才找出来的。
|
||||
| 检查 | 盯的是 |
|
||||
|---|---|
|
||||
| `contract-import-boundary` | `common/`、`core/` 不得 import `app/`——否则一个删掉 `app/admin` 的 fork 就编译不了它被告知可以依赖的那一面 |
|
||||
| `contract-shim-alias` | 从 core 契约包声明出来的类型必须是**别名**(`type X = pkg.Y`),不能是 defined type。判据是右手边,不是一份包名清单,所以谁在哪加的都算 |
|
||||
|
||||
`tools/checksilent` 还检查另外五类"不出声的失败",写模块时值得先看一眼
|
||||
第二条守的是一条一个字符的差别。`type X = pkg.Y` 和 `type X pkg.Y`
|
||||
看着几乎一样,但后者只拿走底层结构、**丢掉整个方法集**,于是嵌了它的 model
|
||||
不再满足 `ActiveRecord`。麻烦在于这**不一定在本仓编译失败**——本仓只用接口
|
||||
使唤其中一部分类型,没被使唤到的那些在这里编译得好好的,
|
||||
**到第三方应用或某个 fork 里才炸**,而那里没人看着。
|
||||
|
||||
测试文件同样算——一个删掉 `app/admin` 的 fork 也应该能跑 `go test ./...`。
|
||||
|
||||
**这两条工具都只扫仓库树。** 装在 module cache 里的第三方应用,
|
||||
`checksilent` 一个文件都看不到。所以它保的是**这个仓库和它的 fork**,
|
||||
不是你的应用——你的应用要自己跑自己的检查。
|
||||
|
||||
`checksilent` 还检查另外五类"不出声的失败",写模块时值得先看一眼
|
||||
`go run ./tools/checksilent -h`。
|
||||
|
||||
+160
-2
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"path"
|
||||
"sort"
|
||||
@@ -18,6 +19,7 @@ const (
|
||||
checkConfigValue = "config-value-truncation"
|
||||
checkMenuIDConflict = "menu-id-collision"
|
||||
checkImportBoundary = "contract-import-boundary"
|
||||
checkShimAlias = "contract-shim-alias"
|
||||
)
|
||||
|
||||
// Package paths, relative to the module. Spelled once so a module rename
|
||||
@@ -44,6 +46,7 @@ func runChecks(s *snapshot, opt options) ([]Finding, error) {
|
||||
out = append(out, checkConfigValueLength(s)...)
|
||||
out = append(out, checkMenuIDCollisions(s)...)
|
||||
out = append(out, checkContractImportBoundary(s)...)
|
||||
out = append(out, checkContractShimAlias(s)...)
|
||||
|
||||
if opt.UIDir != "" {
|
||||
fs, err := checkMenuNames(s, opt.UIDir)
|
||||
@@ -109,6 +112,9 @@ func checkModelTimeMixing(s *snapshot) []Finding {
|
||||
frozen := s.pkg(pkgFrozenModels)
|
||||
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(sf.Path, "app/") && sf.Imports(frozen) {
|
||||
tables := tableNames(sf)
|
||||
for name, st := range structTypes(sf) {
|
||||
@@ -166,6 +172,9 @@ func checkMenuSortOverflow(s *snapshot) []Finding {
|
||||
)
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
forEachStructLiteral(sf, func(lit structLiteral) {
|
||||
if !s.isMenuModel(lit) {
|
||||
return
|
||||
@@ -204,6 +213,9 @@ func checkConfigValueLength(s *snapshot) []Finding {
|
||||
const limit = 255
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
forEachStructLiteral(sf, func(lit structLiteral) {
|
||||
if lit.Name != "SysConfig" || !s.isModelPackage(lit.PkgPath) {
|
||||
return
|
||||
@@ -251,6 +263,9 @@ func checkMenuIDCollisions(s *snapshot) []Finding {
|
||||
sites := map[int64][]site{}
|
||||
|
||||
for _, sf := range s.Files {
|
||||
if sf.isTest() {
|
||||
continue
|
||||
}
|
||||
forEachStructLiteral(sf, func(lit structLiteral) {
|
||||
if !s.isMenuModel(lit) {
|
||||
return
|
||||
@@ -379,6 +394,135 @@ func checkContractImportBoundary(s *snapshot) []Finding {
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check 7: a shim of a core contract type must be an alias
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// coreModulePrefix and coreContractSegment together identify a package under
|
||||
// core's contract namespace. Matched as prefix plus segment rather than as one
|
||||
// literal path so that a major-version bump of core - which rewrites the
|
||||
// /v2 in every import - does not quietly turn this check off.
|
||||
const (
|
||||
coreModulePrefix = "github.com/go-admin-team/go-admin-core/"
|
||||
coreContractSegment = "/sdk/contract/"
|
||||
)
|
||||
|
||||
// isCoreContractPkg reports whether an import path names one of core's
|
||||
// contract packages.
|
||||
func isCoreContractPkg(path string) bool {
|
||||
return strings.HasPrefix(path, coreModulePrefix) && strings.Contains(path, coreContractSegment)
|
||||
}
|
||||
|
||||
// checkContractShimAlias reports a shim of a core contract type that was
|
||||
// written as a defined type instead of an alias.
|
||||
//
|
||||
// type ControlBy = models.ControlBy // alias: same type, same method set
|
||||
// type ControlBy models.ControlBy // defined type: methods are gone
|
||||
//
|
||||
// The two lines differ by one character and by everything else. A defined type
|
||||
// takes the underlying struct and none of the methods declared on it, so a
|
||||
// model embedding the second one no longer has SetCreateBy or SetUpdateBy and
|
||||
// no longer satisfies ActiveRecord - which is not a warning, it is a compile
|
||||
// error, but only in code that actually uses the method set.
|
||||
//
|
||||
// That is why the compiler is not enough on its own. This repository exercises
|
||||
// some of the contract types through interfaces and some not at all; the ones
|
||||
// it does not exercise compile perfectly well as defined types here and break
|
||||
// in a third-party application, or in a fork's own module, which is where
|
||||
// nobody is looking. The check costs one field of the AST - a type alias
|
||||
// records the position of its '=' - and covers the surface uniformly rather
|
||||
// than covering whatever app/demo happens to touch this month.
|
||||
//
|
||||
// The trigger is the right-hand side, not a list of names: any type declared
|
||||
// from a core contract package is one of these, whoever wrote it and whenever
|
||||
// it was added. A type declared from a local struct literal is not caught by
|
||||
// this - see ScannedShimAliases, which is what stops a run over a tree with no
|
||||
// shims in it from reading as a clean bill of health.
|
||||
func checkContractShimAlias(s *snapshot) []Finding {
|
||||
var out []Finding
|
||||
for _, sf := range s.Files {
|
||||
forEachTypeSpec(sf, func(ts *ast.TypeSpec) {
|
||||
qualifier, pkg, name, ok := qualifiedType(sf, ts.Type)
|
||||
if !ok || !isCoreContractPkg(pkg) {
|
||||
return
|
||||
}
|
||||
if ts.Assign.IsValid() {
|
||||
return // "type X = pkg.Y", which is what it must be
|
||||
}
|
||||
out = append(out, s.finding(Error, checkShimAlias, sf, ts,
|
||||
"%s is declared from %s.%s as a defined type, not an alias;\n"+
|
||||
" a defined type keeps the fields and drops the method set, so anything embedding it stops satisfying\n"+
|
||||
" the interfaces it satisfied before - here it may still compile, in a fork or a third-party app it does not.\n"+
|
||||
" Write it as: type %s = %s.%s",
|
||||
ts.Name.Name, qualifier, name, ts.Name.Name, qualifier, name))
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ScannedShimAliases counts the type aliases into core's contract packages the
|
||||
// snapshot holds, so the summary can say whether checkContractShimAlias found
|
||||
// anything to guard at all.
|
||||
//
|
||||
// Reported for the same reason ScannedContractRoots is: before the contract
|
||||
// packages are lowered into core there are no shims here, the check has
|
||||
// nothing to look at, and a run that printed nothing would look exactly like a
|
||||
// run over a tree that passed.
|
||||
func ScannedShimAliases(s *snapshot) int {
|
||||
n := 0
|
||||
for _, sf := range s.Files {
|
||||
forEachTypeSpec(sf, func(ts *ast.TypeSpec) {
|
||||
_, pkg, _, ok := qualifiedType(sf, ts.Type)
|
||||
if ok && isCoreContractPkg(pkg) && ts.Assign.IsValid() {
|
||||
n++
|
||||
}
|
||||
})
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// forEachTypeSpec visits every type declaration in the file, including the
|
||||
// ones inside a parenthesised type block.
|
||||
func forEachTypeSpec(sf *sourceFile, fn func(*ast.TypeSpec)) {
|
||||
for _, decl := range sf.Syntax.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
if ts, ok := spec.(*ast.TypeSpec); ok {
|
||||
fn(ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// qualifiedType resolves a type expression that names a type in another
|
||||
// package, returning that package's import path and the type name. A bare
|
||||
// identifier, a struct literal or anything else reports false: this asks
|
||||
// specifically "is the right-hand side pkg.Name", which is the shape both a
|
||||
// correct shim and the mistake it guards against are written in.
|
||||
// The qualifier returned is the one written in this file, which is not
|
||||
// path.Base of the import path whenever the import is aliased - and the shim
|
||||
// files alias every one of them (contractmodels, contractdto). A message that
|
||||
// suggests a fix has to spell it the way the file already does, or the line it
|
||||
// tells the author to write does not compile.
|
||||
func qualifiedType(sf *sourceFile, typ ast.Expr) (qualifier, pkgPath, name string, ok bool) {
|
||||
sel, isSel := typ.(*ast.SelectorExpr)
|
||||
if !isSel {
|
||||
return "", "", "", false
|
||||
}
|
||||
ident, isIdent := sel.X.(*ast.Ident)
|
||||
if !isIdent {
|
||||
return "", "", "", false
|
||||
}
|
||||
p, found := sf.imports[ident.Name]
|
||||
if !found {
|
||||
return "", "", "", false
|
||||
}
|
||||
return ident.Name, p, sel.Sel.Name, true
|
||||
}
|
||||
|
||||
// migrationVersion reads the 13-digit timestamp a migration file name starts
|
||||
// with. Files outside the two migration directories are not migrations, however
|
||||
// they are named.
|
||||
@@ -398,9 +542,23 @@ func migrationVersion(rel string) (int64, bool) {
|
||||
return v, true
|
||||
}
|
||||
|
||||
// isMenuModel reports whether a literal is one of the SysMenu models rather
|
||||
// than, say, the SysMenu service struct that shares the name.
|
||||
// isMenuModel reports whether a literal describes a menu row, whichever of
|
||||
// the two shapes it is written in.
|
||||
//
|
||||
// A host module seeds a menu by building the SysMenu model directly. An
|
||||
// application installed from outside this repository cannot reach that type,
|
||||
// so it describes the same row as a seed.MenuSpec and hands it to the host's
|
||||
// Seeder. Both end up in sys_menu and both are subject to its column widths,
|
||||
// so a check that knew only the first shape would go quiet exactly when the
|
||||
// author is furthest from the schema it protects.
|
||||
//
|
||||
// That is not hypothetical: this repository's own reference application was
|
||||
// written with a Sort of 200 - past the tinyint sys_menu.sort is built as -
|
||||
// and this check passed it, because a MenuSpec is not a SysMenu.
|
||||
func (s *snapshot) isMenuModel(lit structLiteral) bool {
|
||||
if lit.Name == "MenuSpec" && isCoreContractPkg(lit.PkgPath) {
|
||||
return true
|
||||
}
|
||||
return lit.Name == "SysMenu" && s.isModelPackage(lit.PkgPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -452,3 +452,219 @@ func TestComponentNameParsesBothVueStyles(t *testing.T) {
|
||||
t.Error("a component with no declared name must not be compared")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const coreContractModels = "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
|
||||
// shimFixture writes one shim file declaring ControlBy from core's contract
|
||||
// package, in whichever of the two forms the caller asks for.
|
||||
func shimFixture(t *testing.T, decl string) string {
|
||||
t.Helper()
|
||||
return fixture(t, map[string]string{
|
||||
"common/models/by.go": "package models\n\nimport \"" + coreContractModels + "\"\n\n" + decl + "\n",
|
||||
})
|
||||
}
|
||||
|
||||
func TestShimAliasDetectsADefinedType(t *testing.T) {
|
||||
root := shimFixture(t, "type ControlBy models.ControlBy")
|
||||
|
||||
f := requireOne(t, check(t, root, options{}), checkShimAlias)
|
||||
if f.Severity != "ERROR" {
|
||||
t.Errorf("severity = %s", f.Severity)
|
||||
}
|
||||
if !strings.Contains(f.Message, "type ControlBy = models.ControlBy") {
|
||||
t.Errorf("the message must spell out the fix; got %s", f.Message)
|
||||
}
|
||||
if f.File != "common/models/by.go" || f.Line != 5 {
|
||||
t.Errorf("position = %s:%d", f.File, f.Line)
|
||||
}
|
||||
}
|
||||
|
||||
// The counterproof for the check above: the same fixture with the one
|
||||
// character that makes it correct must produce nothing. Without this the check
|
||||
// could be reporting every type declaration it sees and the test above would
|
||||
// still pass.
|
||||
func TestShimAliasAcceptsAnAlias(t *testing.T) {
|
||||
root := shimFixture(t, "type ControlBy = models.ControlBy")
|
||||
if got := only(t, check(t, root, options{}), checkShimAlias); len(got) != 0 {
|
||||
t.Errorf("reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A parenthesised type block is how a shim package with more than one type
|
||||
// tends to get written, and a walker that only looked at single-spec
|
||||
// declarations would skip all but the first.
|
||||
func TestShimAliasReadsAParenthesisedBlock(t *testing.T) {
|
||||
root := shimFixture(t, `type (
|
||||
Model = models.Model
|
||||
ControlBy models.ControlBy
|
||||
ModelTime = models.ModelTime
|
||||
)`)
|
||||
f := requireOne(t, check(t, root, options{}), checkShimAlias)
|
||||
if !strings.Contains(f.Message, "ControlBy") {
|
||||
t.Errorf("message = %s", f.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// A defined type over a package that is not core's contract namespace is
|
||||
// somebody's ordinary code. The check exists for the surface core promises to
|
||||
// keep stable, and reporting anything else would make it a style rule.
|
||||
func TestShimAliasIgnoresOtherPackages(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"app/demo/models/product.go": `package models
|
||||
|
||||
import "go-admin/common/models"
|
||||
|
||||
type Product models.Model
|
||||
`,
|
||||
})
|
||||
if got := only(t, check(t, root, options{}), checkShimAlias); len(got) != 0 {
|
||||
t.Errorf("reported %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The version is part of core's import path and changes on every major bump.
|
||||
// Matching the whole path literally would turn the check off on that day and
|
||||
// say nothing about it.
|
||||
func TestShimAliasSurvivesACoreMajorVersionBump(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"common/models/by.go": `package models
|
||||
|
||||
import "github.com/go-admin-team/go-admin-core/v9/sdk/contract/models"
|
||||
|
||||
type ControlBy models.ControlBy
|
||||
`,
|
||||
})
|
||||
if got := only(t, check(t, root, options{}), checkShimAlias); len(got) != 1 {
|
||||
t.Errorf("findings = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A tree with no shims in it is the state of this repository until the
|
||||
// contract packages are lowered, and the check saying nothing there must not
|
||||
// be reported as a boundary being guarded.
|
||||
func TestShimAliasCoverageIsReportedAsZeroWhenThereAreNoShims(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"common/models/by.go": "package models\n\ntype ControlBy struct{}\n",
|
||||
})
|
||||
s, err := load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if n := ScannedShimAliases(s); n != 0 {
|
||||
t.Errorf("ScannedShimAliases = %d, want 0", n)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if _, err := run(&buf, root, options{}, false); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "guarded nothing") {
|
||||
t.Errorf("the summary must say the check covered nothing; got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShimAliasCoverageCountsTheAliasesItGuards(t *testing.T) {
|
||||
root := shimFixture(t, `type (
|
||||
Model = models.Model
|
||||
ControlBy = models.ControlBy
|
||||
)`)
|
||||
s, err := load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if n := ScannedShimAliases(s); n != 2 {
|
||||
t.Errorf("ScannedShimAliases = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A menu written as a seed.MenuSpec lands in the same sys_menu.sort column as
|
||||
// one written as a SysMenu, so the same tinyint bound applies. Until this was
|
||||
// covered, an application - the one author furthest from the schema - was the
|
||||
// one the check went quiet for.
|
||||
func TestMenuSortOverflowIsDetectedInAContractMenuSpec(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"example/app-order/migration/migration.go": `package migration
|
||||
|
||||
import "github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
|
||||
|
||||
func menus() []seed.MenuSpec {
|
||||
return []seed.MenuSpec{
|
||||
{Code: "dir", Sort: 200},
|
||||
{Code: "ok", Sort: 20},
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
f := requireOne(t, check(t, root, options{}), checkMenuSort)
|
||||
if !strings.Contains(f.Message, "200") {
|
||||
t.Errorf("finding should name the offending value, got: %s", f.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Every guard against a bad seeded value needs a test that writes the very
|
||||
// value it rejects. Scanning _test.go made each of those guards report its
|
||||
// own test - the check firing on the proof that it works.
|
||||
func TestSeededValueChecksSkipTestFiles(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
|
||||
"app/admin/service/seed_test.go": `package service
|
||||
|
||||
import "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
func fixtureMenus() []models.SysMenu {
|
||||
return []models.SysMenu{
|
||||
{MenuId: 9000, Sort: 900},
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
if got := only(t, check(t, root, options{}), checkMenuSort); len(got) != 0 {
|
||||
t.Fatalf("%s fired on a test fixture: %v", checkMenuSort, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The other direction: the exemption must not turn the check off. A real
|
||||
// seed - the thing that actually reaches MySQL - is still reported.
|
||||
func TestSeededValueChecksStillCoverNonTestFiles(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"cmd/migrate/migration/models/models.go": frozenModelsPkg,
|
||||
"cmd/migrate/migration/version/1786700001000_seed.go": `package version
|
||||
|
||||
import "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
func seed() []models.SysMenu {
|
||||
return []models.SysMenu{
|
||||
{MenuId: 9000, Sort: 900},
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
f := requireOne(t, check(t, root, options{}), checkMenuSort)
|
||||
if !strings.Contains(f.Message, "900") {
|
||||
t.Errorf("finding = %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// The suggested fix has to use the qualifier the file actually writes. Every
|
||||
// shim in this repository aliases its import (contractmodels, contractdto),
|
||||
// so building the message from path.Base of the import path told the author
|
||||
// to write a line that does not compile.
|
||||
func TestShimAliasSuggestionUsesTheInSourceQualifier(t *testing.T) {
|
||||
root := fixture(t, map[string]string{
|
||||
"common/models/by.go": "package models\n\nimport contractmodels \"" + coreContractModels +
|
||||
"\"\n\ntype ControlBy contractmodels.ControlBy\n",
|
||||
})
|
||||
|
||||
f := requireOne(t, check(t, root, options{}), checkShimAlias)
|
||||
if !strings.Contains(f.Message, "type ControlBy = contractmodels.ControlBy") {
|
||||
t.Errorf("the fix must name the import as this file spells it; got %s", f.Message)
|
||||
}
|
||||
if strings.Contains(f.Message, "= models.ControlBy") {
|
||||
t.Errorf("the fix names a qualifier this file does not bind; got %s", f.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Command checksilent reports the failures in this repository that do not
|
||||
// announce themselves: no error, no log line, behaviour quietly wrong.
|
||||
//
|
||||
// Six checks, five of them ERROR and one WARN. An ERROR fails the run; a WARN
|
||||
// Seven checks, six of them ERROR and one WARN. An ERROR fails the run; a WARN
|
||||
// prints and does not. The split is not about how bad the consequence is - all
|
||||
// six are bad - but about how certain the detection is. Everything reported as
|
||||
// seven are bad - but about how certain the detection is. Everything reported as
|
||||
// an ERROR is decided from this repository's own syntax. The one WARN compares
|
||||
// against a second repository through a regular expression, and a check that
|
||||
// can be wrong must not be able to stop a build, or the first response to it
|
||||
@@ -102,4 +102,16 @@ func printSummary(w io.Writer, findings []Finding, opt options, s *snapshot) {
|
||||
fmt.Fprintf(w, "The %s check covered %s; %s does not exist here and was not scanned.\n",
|
||||
checkImportBoundary, strings.Join(scanned, ", "), strings.Join(absent, ", "))
|
||||
}
|
||||
// Same reason: a tree with no alias into core's contract packages gives
|
||||
// this check nothing to look at, and its silence must not be read as a
|
||||
// pass. That is now the interesting case rather than the expected one -
|
||||
// the shims exist, so a count of zero means they stopped being aliases,
|
||||
// or stopped being here.
|
||||
if n := ScannedShimAliases(s); n == 0 {
|
||||
fmt.Fprintf(w, "The %s check found no type alias into core's contract packages and guarded nothing.\n",
|
||||
checkShimAlias)
|
||||
} else {
|
||||
fmt.Fprintf(w, "The %s check covered %d type alias(es) into core's contract packages.\n",
|
||||
checkShimAlias, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,19 @@ type sourceFile struct {
|
||||
consts map[string]int64 // package-level integer constants, filled per package
|
||||
}
|
||||
|
||||
// isTest reports whether this file is a _test.go.
|
||||
//
|
||||
// The checks about a seeded value - a menu sort, a config value, a menu id, a
|
||||
// soft-delete shape - are all about what reaches a real database through a
|
||||
// migration, and a test fixture reaches none. Worse, each of those guards
|
||||
// needs a test that writes the very value it rejects, so scanning test files
|
||||
// makes every such guard report its own test. The import and alias checks do
|
||||
// not skip tests: those are about the dependency graph, where a test file's
|
||||
// import is as real as any other.
|
||||
func (f *sourceFile) isTest() bool {
|
||||
return strings.HasSuffix(f.Path, "_test.go")
|
||||
}
|
||||
|
||||
// snapshot is every Go file under the root, parsed once and shared by all the
|
||||
// checks.
|
||||
type snapshot struct {
|
||||
|
||||
Reference in New Issue
Block a user