feat: automate business upgrades and sync reviewed frontend
This commit is contained in:
@@ -412,6 +412,9 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
|
||||
return err
|
||||
}
|
||||
var users, children int64
|
||||
if err := system.ValidateAuthorityDeletion(&system.Authority{AuthorityID: authority.AuthorityID, BusinessLevel: authority.BusinessLevel}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Associated-user checks only block on users that still
|
||||
// exist (and are not soft-deleted). Counting raw join rows would make an
|
||||
// orphaned association impossible to clean up, which is observably
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The migration resolves historical IDs once. Runtime role assignment uses
|
||||
// business_level and is independent of editable names and migration helpers.
|
||||
func migrateBusinessRoles(db *gorm.DB) error {
|
||||
if !db.Migrator().HasColumn(&authorityPO{}, "BusinessLevel") {
|
||||
if err := db.Migrator().AddColumn(&authorityPO{}, "BusinessLevel"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var roles []authorityPO
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return ensureBusinessRoleIndex(db)
|
||||
}
|
||||
legacy := map[int]string{1: "2083497078545207297", 2: "2087435436573081601", 3: "2087361690139152385"}
|
||||
names := map[int]string{1: "一级商务", 2: "二级商务", 3: "三级商务"}
|
||||
hasMap := db.Migrator().HasTable("sw_kra_id_map")
|
||||
applicable := hasMap
|
||||
for _, role := range roles {
|
||||
if role.BusinessLevel != nil {
|
||||
applicable = true
|
||||
}
|
||||
for _, name := range names {
|
||||
if role.AuthorityName == name {
|
||||
applicable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !applicable {
|
||||
return ensureBusinessRoleIndex(db)
|
||||
}
|
||||
bindings := map[int]uint{}
|
||||
for _, role := range roles {
|
||||
if role.BusinessLevel == nil {
|
||||
continue
|
||||
}
|
||||
level := *role.BusinessLevel
|
||||
if level < 1 || level > 3 || role.AuthorityID == 888 || bindings[level] != 0 {
|
||||
return fmt.Errorf("invalid or duplicate business role level %d", level)
|
||||
}
|
||||
bindings[level] = role.AuthorityID
|
||||
}
|
||||
for level := 1; level <= 3; level++ {
|
||||
if bindings[level] != 0 {
|
||||
continue
|
||||
}
|
||||
var ids []uint
|
||||
if hasMap {
|
||||
if err := db.Table("sw_kra_id_map m").Select("m.new_id").Joins("JOIN sys_authorities a ON a.authority_id=m.new_id AND a.deleted_at IS NULL").Where("m.entity='role' AND m.old_id=? AND m.new_id<>888", legacy[level]).Scan(&ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
for _, role := range roles {
|
||||
if role.AuthorityID != 888 && role.AuthorityName == names[level] {
|
||||
ids = append(ids, role.AuthorityID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
// A generic first installation can have only the administrator.
|
||||
// Migrated SW databases must supply all three mapped roles.
|
||||
if !hasMap && len(bindings) == 0 && len(roles) == 1 && roles[0].AuthorityID == 888 {
|
||||
return ensureBusinessRoleIndex(db)
|
||||
}
|
||||
return fmt.Errorf("business level %d requires exactly one migration role", level)
|
||||
}
|
||||
for _, id := range bindings {
|
||||
if id == ids[0] {
|
||||
return fmt.Errorf("one role cannot represent multiple business levels")
|
||||
}
|
||||
}
|
||||
bindings[level] = ids[0]
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for level, id := range bindings {
|
||||
if err := tx.Model(&authorityPO{}).Where("authority_id=?", id).UpdateColumn("business_level", level).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureBusinessRoleIndex(db)
|
||||
}
|
||||
|
||||
func ensureBusinessRoleIndex(db *gorm.DB) error {
|
||||
const name = "uk_authority_business_level"
|
||||
if db.Migrator().HasIndex(&authorityPO{}, name) {
|
||||
return nil
|
||||
}
|
||||
return db.Migrator().CreateIndex(&authorityPO{}, name)
|
||||
}
|
||||
|
||||
func CheckBusinessRoles(db *gorm.DB) error {
|
||||
var roles []authorityPO
|
||||
if err := db.Where("business_level IS NOT NULL").Find(&roles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, role := range roles {
|
||||
if *role.BusinessLevel < 1 || *role.BusinessLevel > 3 || role.AuthorityID == 888 || seen[*role.BusinessLevel] {
|
||||
return fmt.Errorf("invalid business role binding")
|
||||
}
|
||||
seen[*role.BusinessLevel] = true
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
return fmt.Errorf("three business role bindings are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
biz "kra/internal/biz/system"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBusinessRoleIdentitySurvivesRenameAndMappingCleanup(t *testing.T) {
|
||||
d := newTransactionTestData(t)
|
||||
db := d.DB()
|
||||
for _, statement := range []string{
|
||||
"INSERT INTO sys_authorities(authority_id,authority_name) VALUES(888,'admin'),(11,'一级已改名'),(12,'二级已改名'),(13,'三级已改名')",
|
||||
"CREATE TABLE sw_kra_id_map(entity TEXT,old_id TEXT,new_id INTEGER)",
|
||||
"INSERT INTO sw_kra_id_map VALUES('role','2083497078545207297',11),('role','2087435436573081601',12),('role','2087361690139152385',13)",
|
||||
} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := migrateBusinessRoles(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("DROP TABLE sw_kra_id_map").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateBusinessRoles(db); err != nil {
|
||||
t.Fatalf("migration retry after map removal: %v", err)
|
||||
}
|
||||
repo := &authorityAccessRepo{data: d}
|
||||
if err := repo.UpdateAuthority(context.Background(), &biz.Authority{AuthorityID: 12, AuthorityName: "客服主管"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var role authorityPO
|
||||
if err := db.First(&role, "authority_id=12").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if role.BusinessLevel == nil || *role.BusinessLevel != 2 {
|
||||
t.Fatal("rename changed the stable business role identity")
|
||||
}
|
||||
if err := repo.DeleteAuthority(context.Background(), 12); err != biz.ErrBusinessAuthorityProtected {
|
||||
t.Fatalf("reserved role deletion: %v", err)
|
||||
}
|
||||
if err := repo.CopyAuthority(context.Background(), 12, &biz.Authority{AuthorityID: 14, AuthorityName: "普通副本", DataScope: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role = authorityPO{}
|
||||
if err := db.First(&role, "authority_id=14").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if role.BusinessLevel != nil {
|
||||
t.Fatal("copy inherited the reserved business level")
|
||||
}
|
||||
if err := CheckBusinessRoles(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessRoleMigrationRejectsAmbiguousFallback(t *testing.T) {
|
||||
d := newTransactionTestData(t)
|
||||
if err := d.DB().Exec("INSERT INTO sys_authorities(authority_id,authority_name) VALUES(11,'一级商务'),(12,'一级商务'),(13,'二级商务'),(14,'三级商务')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateBusinessRoles(d.DB()); err == nil {
|
||||
t.Fatal("ambiguous role names were accepted")
|
||||
}
|
||||
var count int64
|
||||
d.DB().Model(&authorityPO{}).Where("business_level IS NOT NULL").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("failed role migration partially changed bindings")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func Migrations() []migration.Step {
|
||||
{ID: "202608210002_communication_surface", Migrate: ensureCommunicationSurface},
|
||||
{ID: "202608210003_communication_test_surface", Migrate: ensureCommunicationTestSurface},
|
||||
{ID: "202611030002_business_user_fields", Migrate: ensureBusinessUserFields},
|
||||
{ID: "202611060024_business_role_identity", Migrate: migrateBusinessRoles},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ type authorityPO struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
|
||||
AuthorityName string
|
||||
BusinessLevel *int `gorm:"type:tinyint unsigned;uniqueIndex:uk_authority_business_level"`
|
||||
ParentID *uint
|
||||
DataScope int `gorm:"default:1"`
|
||||
DefaultRouter string `gorm:"default:dashboard"`
|
||||
|
||||
@@ -97,7 +97,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
|
||||
}
|
||||
|
||||
func toBizAuthority(po authorityPO) system.Authority {
|
||||
return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter}
|
||||
return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, BusinessLevel: po.BusinessLevel, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter}
|
||||
}
|
||||
|
||||
func parseOriginSetting(raw string) (map[string]any, error) {
|
||||
|
||||
Reference in New Issue
Block a user