From 7c18159d709d4e6fea4a657671123ab684fa5d8e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 29 Aug 2026 16:30:23 +0800 Subject: [PATCH] fix(#156): reconcile purchaser permissions at startup --- docs/02-architecture-and-code-map.md | 12 +- server/app/goauto/access/reconcile.go | 89 ++++++++++++++ server/app/goauto/access/reconcile_test.go | 128 +++++++++++++++++++++ server/cmd/api/server.go | 6 + 4 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 server/app/goauto/access/reconcile.go create mode 100644 server/app/goauto/access/reconcile_test.go diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index c3f0ffa..9786b29 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: fdc3e2871913ea7c64fa9729296ce41273866c2b -synchronized_at: 2026-08-29T08:00:07Z +wiki_revision: d0222d4337992bedcf2b973bc3bb9d43922c0ea3 +synchronized_at: 2026-08-29T08:29:05Z @@ -294,3 +294,11 @@ PddProductDetailCollector - 迁移保留页面菜单 ID、路由、组件和 API 关联,移除旧的 11 个一级模块根菜单;导航固定为“分组 → 页面”两级,不增加第三级。 - Admin 角色继续通过角色菜单查询取得全部 GoAuto 页面;采购员获得两个父组和除 AI 规格匹配外的 10 个页面,AI 页面及入口均不可见。 - Web 继续通过 `/api/v1/menurole` 动态生成路由;直接访问组内页面时展开对应父组并高亮当前页面。 + + +## GoAuto 采购员 API 权限启动对账(#156) + +- `server/app/goauto/access/purchaser.go` 的 `AdminAPIs` 是 GoAuto 管理接口与采购员授权矩阵的唯一代码事实源;`PurchaserAPIs()` 只筛选其中明确标记为采购员可用的条目。 +- API 服务在注册路由和监听端口之前调用 `access.ReconcilePurchaserPermissions`:补齐 `sys_api` 缺失项,并在单一事务内只删除、重建 `casbin_rule` 中 `ptype=p, v0=purchaser` 的策略。其他角色、自建策略和菜单绑定不在对账范围。 +- 对账每次服务启动执行且幂等;代码新增采购员接口后无需补丁迁移,重启即可补齐;代码减权后旧采购员策略会被清除。 +- 任一数据库对账失败时,API 启动直接返回错误,不注册路由、不监听端口,避免权限矩阵未对齐时继续提供服务。既有版本化迁移保留其历史语义,但不再是运行时权限同步的唯一入口。 diff --git a/server/app/goauto/access/reconcile.go b/server/app/goauto/access/reconcile.go new file mode 100644 index 0000000..92c6d7a --- /dev/null +++ b/server/app/goauto/access/reconcile.go @@ -0,0 +1,89 @@ +package access + +import "gorm.io/gorm" + +// ReconcilePurchaserPermissions makes the reviewed GoAuto API matrix the +// runtime source of truth for the purchaser role. The transaction is +// deliberately fail-closed: callers must not start serving requests when the +// role, API catalogue and Casbin policies cannot be aligned atomically. +func ReconcilePurchaserPermissions(db *gorm.DB) error { + return reconcilePurchaserPermissions(db, AdminAPIs) +} + +func reconcilePurchaserPermissions(db *gorm.DB, permissions []APIPermission) error { + return db.Transaction(func(tx *gorm.DB) error { + role := purchaserRole{} + if err := tx.Where("role_key = ?", RolePurchaser). + Assign(purchaserRole{ + RoleName: "采购员", Status: "2", RoleSort: 20, Admin: false, + DataScope: "1", Remark: "GoAuto 采购业务角色(系统维护)", + }).FirstOrCreate(&role, purchaserRole{RoleKey: RolePurchaser}).Error; err != nil { + return err + } + + for _, permission := range permissions { + api := purchaserAPI{} + if err := tx.Where(purchaserAPI{Path: permission.Path, Action: permission.Method}). + Attrs(purchaserAPI{Title: permission.Title, Type: "BUS"}). + FirstOrCreate(&api).Error; err != nil { + return err + } + } + + if err := tx.Where("ptype = ? AND v0 = ?", "p", RolePurchaser). + Delete(&purchaserPolicy{}).Error; err != nil { + return err + } + for _, permission := range permissions { + if !permission.Purchaser { + continue + } + policy := purchaserPolicy{ + Ptype: "p", V0: RolePurchaser, V1: permission.Path, V2: permission.Method, + } + if err := tx.Create(&policy).Error; err != nil { + return err + } + } + return nil + }) +} + +// These private models intentionally cover only columns needed by the +// reconciler. Keeping them here avoids coupling runtime startup to historical +// migration packages while targeting the same production tables. +type purchaserRole struct { + RoleID int `gorm:"column:role_id;primaryKey;autoIncrement"` + RoleName string `gorm:"column:role_name;size:128"` + Status string `gorm:"column:status;size:4"` + RoleKey string `gorm:"column:role_key;size:128"` + RoleSort int `gorm:"column:role_sort"` + Remark string `gorm:"column:remark;size:255"` + Admin bool `gorm:"column:admin"` + DataScope string `gorm:"column:data_scope;size:128"` +} + +func (purchaserRole) TableName() string { return "sys_role" } + +type purchaserAPI struct { + ID int `gorm:"column:id;primaryKey;autoIncrement"` + Title string `gorm:"column:title;size:128"` + Path string `gorm:"column:path;size:128"` + Type string `gorm:"column:type;size:16"` + Action string `gorm:"column:action;size:16"` +} + +func (purchaserAPI) TableName() string { return "sys_api" } + +type purchaserPolicy struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement"` + Ptype string `gorm:"column:ptype;size:100"` + V0 string `gorm:"column:v0;size:100"` + V1 string `gorm:"column:v1;size:100"` + V2 string `gorm:"column:v2;size:100"` + V3 string `gorm:"column:v3;size:100"` + V4 string `gorm:"column:v4;size:100"` + V5 string `gorm:"column:v5;size:100"` +} + +func (purchaserPolicy) TableName() string { return "casbin_rule" } diff --git a/server/app/goauto/access/reconcile_test.go b/server/app/goauto/access/reconcile_test.go new file mode 100644 index 0000000..7fffecd --- /dev/null +++ b/server/app/goauto/access/reconcile_test.go @@ -0,0 +1,128 @@ +package access + +import ( + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestReconcilePurchaserPermissions(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&purchaserRole{}, &purchaserAPI{}, &purchaserPolicy{}); err != nil { + t.Fatal(err) + } + + custom := purchaserPolicy{Ptype: "p", V0: "custom-role", V1: "/custom", V2: "GET"} + if err = db.Create(&custom).Error; err != nil { + t.Fatal(err) + } + + if err = ReconcilePurchaserPermissions(db); err != nil { + t.Fatal(err) + } + if err = ReconcilePurchaserPermissions(db); err != nil { + t.Fatalf("reconcile must be repeatable: %v", err) + } + + var apiCount int64 + if err = db.Model(&purchaserAPI{}).Count(&apiCount).Error; err != nil { + t.Fatal(err) + } + if apiCount != int64(len(AdminAPIs)) { + t.Fatalf("sys_api count=%d, want %d", apiCount, len(AdminAPIs)) + } + for _, permission := range AdminAPIs { + var count int64 + if err = db.Model(&purchaserAPI{}). + Where("path = ? AND action = ?", permission.Path, permission.Method). + Count(&count).Error; err != nil || count != 1 { + t.Fatalf("API %s %s count=%d err=%v", permission.Method, permission.Path, count, err) + } + } + + var purchaserCount int64 + if err = db.Model(&purchaserPolicy{}). + Where("ptype = ? AND v0 = ?", "p", RolePurchaser). + Count(&purchaserCount).Error; err != nil { + t.Fatal(err) + } + if purchaserCount != int64(len(PurchaserAPIs())) { + t.Fatalf("purchaser policy count=%d, want %d", purchaserCount, len(PurchaserAPIs())) + } + + assertPolicyCount(t, db, "custom-role", "/custom", "GET", 1) + assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 0) +} + +func TestReconcilePurchaserPermissionsRemovesOnlyStalePurchaserGrant(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&purchaserRole{}, &purchaserAPI{}, &purchaserPolicy{}); err != nil { + t.Fatal(err) + } + + permissions := []APIPermission{ + {Title: "保留", Path: "/kept", Method: "GET", Purchaser: true}, + {Title: "移除", Path: "/removed", Method: "POST", Purchaser: true}, + {Title: "管理员", Path: "/admin-only", Method: "DELETE", Purchaser: false}, + } + if err = reconcilePurchaserPermissions(db, permissions); err != nil { + t.Fatal(err) + } + other := purchaserPolicy{Ptype: "p", V0: "other-role", V1: "/removed", V2: "POST"} + if err = db.Create(&other).Error; err != nil { + t.Fatal(err) + } + + if err = reconcilePurchaserPermissions(db, permissions[:1]); err != nil { + t.Fatal(err) + } + assertPolicyCount(t, db, RolePurchaser, "/kept", "GET", 1) + assertPolicyCount(t, db, RolePurchaser, "/removed", "POST", 0) + assertPolicyCount(t, db, RolePurchaser, "/admin-only", "DELETE", 0) + assertPolicyCount(t, db, "other-role", "/removed", "POST", 1) +} + +func TestReconcilePurchaserPermissionsRollsBackOnCatalogueFailure(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + // Deliberately omit sys_api so reconciliation fails after attempting to + // create the purchaser role. The surrounding transaction must undo it. + if err = db.AutoMigrate(&purchaserRole{}, &purchaserPolicy{}); err != nil { + t.Fatal(err) + } + other := purchaserPolicy{Ptype: "p", V0: "other-role", V1: "/custom", V2: "GET"} + if err = db.Create(&other).Error; err != nil { + t.Fatal(err) + } + + if err = ReconcilePurchaserPermissions(db); err == nil { + t.Fatal("reconcile unexpectedly succeeded without sys_api") + } + var roleCount int64 + if err = db.Model(&purchaserRole{}).Where("role_key = ?", RolePurchaser).Count(&roleCount).Error; err != nil { + t.Fatal(err) + } + if roleCount != 0 { + t.Fatalf("failed reconcile left purchaser role behind: %d", roleCount) + } + assertPolicyCount(t, db, "other-role", "/custom", "GET", 1) +} + +func assertPolicyCount(t *testing.T, db *gorm.DB, role, path, method string, want int64) { + t.Helper() + var count int64 + if err := db.Model(&purchaserPolicy{}). + Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", role, path, method). + Count(&count).Error; err != nil || count != want { + t.Fatalf("policy %s %s %s count=%d want=%d err=%v", role, method, path, count, want, err) + } +} diff --git a/server/cmd/api/server.go b/server/cmd/api/server.go index 8268938..ecc99f1 100644 --- a/server/cmd/api/server.go +++ b/server/cmd/api/server.go @@ -20,6 +20,7 @@ import ( "go-admin/app/admin/models" "go-admin/app/admin/router" + goautoaccess "go-admin/app/goauto/access" goautodevice "go-admin/app/goauto/device" goautopurchase "go-admin/app/goauto/purchase" goautoreplacement "go-admin/app/goauto/replacement" @@ -89,6 +90,11 @@ func run() error { if config.ApplicationConfig.Mode == pkg.ModeProd.String() { gin.SetMode(gin.ReleaseMode) } + for name, db := range sdk.Runtime.GetDb() { + if err := goautoaccess.ReconcilePurchaserPermissions(db); err != nil { + return fmt.Errorf("reconcile GoAuto purchaser permissions for database %q: %w", name, err) + } + } initRouter() for _, f := range AppRouters {