feat: 迁移商务账号层级和列表管理

This commit is contained in:
Yvan
2026-11-03 17:56:33 +08:00
parent ce4b54a2e2
commit 794001dfbe
24 changed files with 1229 additions and 12 deletions
+257
View File
@@ -0,0 +1,257 @@
package business
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"kra/internal/biz/business"
)
// accountPO 是 sys_users 的商务投影,表结构仍由 system 模块维护。
type accountPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt, UpdatedAt time.Time
DeletedAt gorm.DeletedAt
UUID, Username, Password, NickName, RealName, Phone, Email string
QQNumber string `gorm:"column:qq_number"`
Level int
ParentID uint
IDPath string `gorm:"column:id_path"`
BusinessStatus int
RejectReason string
AuthorityID uint
Enable int
OriginSetting string
PasswordUpdatedAt *time.Time
ParentUserName string `gorm:"->;-:migration"`
}
func (accountPO) TableName() string { return "sys_users" }
func newAccount(a *business.Account) accountPO {
now := time.Now()
enable := 0
if a.Status == 0 {
enable = 1
}
return accountPO{ID: a.ID, UUID: uuid.NewString(), Username: a.Username, NickName: a.NickName, Password: a.Password,
RealName: a.RealName, Phone: a.Phone, Email: a.Email, QQNumber: a.QQNumber, Level: a.Level, ParentID: a.ParentID,
IDPath: a.IDPath, BusinessStatus: a.Status, RejectReason: a.RejectReason, AuthorityID: a.AuthorityID, Enable: enable,
OriginSetting: "{}", PasswordUpdatedAt: &now}
}
func toBizAccount(po accountPO) *business.Account {
return &business.Account{ID: po.ID, Username: po.Username, NickName: po.NickName, RealName: po.RealName, Phone: po.Phone, Email: po.Email,
QQNumber: po.QQNumber, Level: po.Level, ParentID: po.ParentID, IDPath: po.IDPath, Status: po.BusinessStatus, RejectReason: po.RejectReason,
AuthorityID: po.AuthorityID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, ParentUserName: po.ParentUserName}
}
type accountRepo struct{ data Provider }
func NewAccountRepo(data Provider) business.AccountRepo { return &accountRepo{data: data} }
func (r *accountRepo) Find(ctx context.Context, id uint) (*business.Account, error) {
var po accountPO
if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, business.ErrAccountNotFound
}
return nil, business.ErrStorage
}
return toBizAccount(po), nil
}
func applyUserScope(db *gorm.DB, scope business.UserScope, columnPrefix string) *gorm.DB {
if scope.All {
return db
}
if scope.Empty || scope.SelfID == 0 {
return db.Where("1=0")
}
id, path, parent := columnPrefix+"id", columnPrefix+"id_path", columnPrefix+"parent_id"
query := "(" + path + " = ? OR " + path + " LIKE ? OR " + parent + " = ?)"
args := []any{scope.DescendantPath, scope.DescendantPath + ",%", scope.SelfID}
if scope.IncludeSelf {
query = "(" + query + " OR " + id + " = ?)"
args = append(args, scope.SelfID)
} else {
db = db.Where(id+" <> ?", scope.SelfID)
}
return db.Where(query, args...)
}
func (r *accountRepo) List(ctx context.Context, scope business.UserScope, options ...business.ListOption) ([]*business.Account, int64, error) {
o := business.NewListOptions(options...)
filter := o.Filter
db := r.data.DB().WithContext(ctx).Model(&accountPO{}).Table("sys_users AS u").Where("u.deleted_at IS NULL")
db = applyUserScope(db, scope, "u.")
if filter.UserID != 0 {
db = db.Where("u.id = ?", filter.UserID)
}
for _, item := range []struct{ column, value string }{{"username", filter.Username}, {"nick_name", filter.NickName}, {"real_name", filter.RealName}, {"phone", filter.Phone}} {
if item.value != "" {
db = db.Where("u."+item.column+" LIKE ?", "%"+item.value+"%")
}
}
if filter.Level != nil {
db = db.Where("u.level = ?", *filter.Level)
}
if filter.Status != nil {
db = db.Where("u.business_status = ?", *filter.Status)
}
if filter.BeginTime != nil && filter.EndTime != nil {
db = db.Where("u.created_at BETWEEN ? AND ?", filter.BeginTime, filter.EndTime)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, business.ErrStorage
}
columns := map[string]string{"userId": "id", "userName": "username", "realName": "real_name", "level": "level", "status": "business_status", "createTime": "created_at"}
column := columns[o.OrderBy]
if column == "" {
column = "id"
}
order := "u." + column
if o.Desc {
order += " DESC"
} else {
order += " ASC"
}
var pos []accountPO
if err := db.Select("u.*, p.username AS parent_user_name").Joins("LEFT JOIN sys_users p ON p.id=u.parent_id AND p.deleted_at IS NULL").Order(order).Order("u.id ASC").Offset(o.Offset).Limit(o.Limit).Find(&pos).Error; err != nil {
return nil, 0, business.ErrStorage
}
rows := make([]*business.Account, 0, len(pos))
for _, po := range pos {
rows = append(rows, toBizAccount(po))
}
return rows, total, nil
}
func checkAccountUnique(tx *gorm.DB, a *business.Account) error {
for _, item := range []struct {
column, value string
err error
}{{"username", a.Username, business.ErrAccountExists}, {"phone", a.Phone, business.ErrPhoneExists}, {"email", a.Email, business.ErrEmailExists}} {
if item.value == "" {
continue
}
var count int64
if err := tx.Model(&accountPO{}).Where(item.column+" = ? AND id <> ?", item.value, a.ID).Count(&count).Error; err != nil {
return business.ErrStorage
}
if count > 0 {
return item.err
}
}
return nil
}
func (r *accountRepo) Create(ctx context.Context, a *business.Account) error {
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := checkAccountUnique(tx, a); err != nil {
return err
}
po := newAccount(a)
if err := tx.Create(&po).Error; err != nil {
return business.ErrStorage
}
if err := tx.Table("sys_user_authority").Create(map[string]any{"sys_user_id": po.ID, "sys_authority_authority_id": po.AuthorityID}).Error; err != nil {
return business.ErrStorage
}
on := 1
if err := tx.Create(&preferencePO{UserID: po.ID, IncludeSubPromote: &on, IDCardDedup: &on, UpdateTime: time.Now()}).Error; err != nil {
return business.ErrStorage
}
a.ID, a.CreatedAt, a.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
return nil
})
}
func (r *accountRepo) Update(ctx context.Context, a *business.Account) error {
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := checkAccountUnique(tx, a); err != nil {
return err
}
enable := 0
if a.Status == 0 {
enable = 1
}
result := tx.Model(&accountPO{}).Where("id = ?", a.ID).Updates(map[string]any{
"username": a.Username, "nick_name": a.NickName, "real_name": a.RealName, "phone": a.Phone, "email": a.Email, "qq_number": a.QQNumber,
"business_status": a.Status, "enable": enable, "reject_reason": a.RejectReason,
})
if result.Error != nil {
return business.ErrStorage
}
if result.RowsAffected == 0 {
return business.ErrAccountNotFound
}
return nil
})
}
func (r *accountRepo) AuthorityForLevel(ctx context.Context, level int) (uint, error) {
// 原角色 ID 仅用于查迁移映射,不作为 Kra 角色 ID 使用。
legacy := map[int]string{1: "2083497078545207297", 2: "2087435436573081601", 3: "2087361690139152385"}
oldID, ok := legacy[level]
if !ok {
return 0, business.ErrBusinessRole
}
db := r.data.DB().WithContext(ctx)
var id uint
if db.Migrator().HasTable("sw_kra_id_map") {
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 = ? AND m.old_id = ?", "role", oldID).Scan(&id).Error; err != nil {
return 0, business.ErrStorage
}
if id != 0 && id != 888 {
return id, nil
}
}
// 新安装可使用对应中文角色;匹配不唯一时停止创建,避免错授权限。
names := map[int]string{1: "一级商务", 2: "二级商务", 3: "三级商务"}
var ids []uint
if err := db.Table("sys_authorities").Where("authority_name = ? AND deleted_at IS NULL AND authority_id <> ?", names[level], 888).Pluck("authority_id", &ids).Error; err != nil {
return 0, business.ErrStorage
}
if len(ids) != 1 {
return 0, business.ErrBusinessRole
}
return ids[0], nil
}
func (r *accountRepo) DirectCounts(ctx context.Context, ids []uint) (map[uint][2]int64, error) {
result := map[uint][2]int64{}
if len(ids) == 0 {
return result, nil
}
db := r.data.DB().WithContext(ctx)
var binds []struct {
UserID uint
Total int64
}
if err := db.Table("sys_user_bind_platform").Select("user_id, COUNT(*) AS total").Where("user_id IN ? AND status = ?", ids, "0").Group("user_id").Scan(&binds).Error; err != nil {
return nil, business.ErrStorage
}
if len(binds) == 0 {
return result, nil
}
for _, b := range binds {
result[b.UserID] = [2]int64{0, b.Total}
}
var counts []struct {
UserID uint
Total int64
}
if err := db.Table("sys_user_bind_platform b").Select("b.user_id, COUNT(*) AS total").Joins("JOIN tab_promote p ON p.source_db=b.source_db AND p.busier_id=b.platform_busier_id AND p.promote_level=1").Where("b.user_id IN ? AND b.status = ?", ids, "0").Group("b.user_id").Scan(&counts).Error; err != nil {
return nil, business.ErrStorage
}
for _, c := range counts {
value := result[c.UserID]
value[0] = c.Total
result[c.UserID] = value
}
return result, nil
}
+105
View File
@@ -0,0 +1,105 @@
package business
import (
"context"
"gorm.io/gorm"
"kra/internal/biz/business"
"testing"
)
func prepareAccounts(t *testing.T) *gorm.DB {
t.Helper()
db := openTestDB(t)
if err := db.AutoMigrate(&accountPO{}); err != nil {
t.Fatal(err)
}
for _, sql := range []string{
"CREATE TABLE sys_user_authority (sys_user_id INTEGER,sys_authority_authority_id INTEGER)",
"CREATE TABLE sys_authorities (authority_id INTEGER,authority_name TEXT,deleted_at DATETIME)",
"CREATE TABLE sw_kra_id_map (entity TEXT,old_id TEXT,new_id INTEGER)",
"CREATE TABLE tab_promote (id INTEGER,source_db TEXT,busier_id INTEGER,promote_level INTEGER,status INTEGER)",
"INSERT INTO sys_authorities VALUES (901,'一级商务',NULL),(902,'已改名二级角色',NULL),(903,'三级商务',NULL)",
"INSERT INTO sw_kra_id_map VALUES ('role','2087435436573081601',902)",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func TestAccountCreateIsAtomicAndPreferencesDefaultOn(t *testing.T) {
db := prepareAccounts(t)
r := NewAccountRepo(testData{db})
ctx := context.Background()
role, err := r.AuthorityForLevel(ctx, 2)
if err != nil || role != 902 {
t.Fatalf("角色映射不正确: %d %v", role, err)
}
a := &business.Account{Username: "child", RealName: "子商务", Password: "hash", Level: 2, ParentID: 10, IDPath: "0,10", AuthorityID: role, Status: 3}
if err := r.Create(ctx, a); err != nil {
t.Fatal(err)
}
var po accountPO
if err := db.First(&po, a.ID).Error; err != nil {
t.Fatal(err)
}
if po.Password != "hash" || po.Enable != 0 || po.BusinessStatus != 3 || po.UUID == "" {
t.Fatalf("账号字段丢失: %+v", po)
}
pref, err := NewUserPreferenceRepo(testData{db}).Find(ctx, a.ID)
if err != nil || !pref.IncludeSubPromote || !pref.IDCardDedup {
t.Fatalf("默认偏好缺失: %+v %v", pref, err)
}
duplicate := &business.Account{Username: "child"}
if err := r.Create(ctx, duplicate); err != business.ErrAccountExists {
t.Fatalf("应拒绝重复账号: %v", err)
}
if err := db.Exec("DROP TABLE sys_user_authority").Error; err != nil {
t.Fatal(err)
}
failed := &business.Account{Username: "rollback"}
if err := r.Create(ctx, failed); err == nil {
t.Fatal("角色关联失败后必须回滚")
}
var count int64
db.Model(&accountPO{}).Where("username = ?", "rollback").Count(&count)
if count != 0 {
t.Fatal("留下无角色的部分账号")
}
}
func TestAccountListScopesAndCrossPlatformCounts(t *testing.T) {
db := prepareAccounts(t)
pos := []accountPO{{ID: 12, Username: "parent", Level: 1, IDPath: "0"}, {ID: 13, Username: "child", Level: 2, ParentID: 12, IDPath: "0,12"}, {ID: 14, Username: "grandchild", Level: 3, ParentID: 13, IDPath: "0,12,13"}, {ID: 99, Username: "outsider", Level: 2, IDPath: "0,123"}}
if err := db.Create(&pos).Error; err != nil {
t.Fatal(err)
}
for _, sql := range []string{
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(13,'a',7,'0'),(13,'b',7,'0'),(13,'c',8,'3'),(14,'a',8,'0')",
"INSERT INTO tab_promote VALUES(1,'a',7,1,1),(2,'a',7,1,-2),(3,'a',7,2,1),(1,'b',7,1,1),(4,'c',7,1,1),(5,'a',8,1,1)",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
r := NewAccountRepo(testData{db})
rows, total, err := r.List(context.Background(), business.UserScope{SelfID: 12, DescendantPath: "0,12"}, business.ListFilter(business.AccountFilter{}), business.ListLimit(20))
if err != nil || total != 2 || len(rows) != 2 {
t.Fatalf("下级范围错误: %+v %d %v", rows, total, err)
}
if rows[0].ParentUserName != "parent" || rows[1].ParentUserName != "child" {
t.Fatal("上级账号未关联")
}
counts, err := r.DirectCounts(context.Background(), []uint{13, 14, 99})
if err != nil {
t.Fatal(err)
}
if counts[13] != [2]int64{3, 2} || counts[14] != [2]int64{1, 1} || counts[99] != [2]int64{} {
t.Fatalf("渠道数或绑定数口径不一致: %+v", counts)
}
rows, total, err = r.List(context.Background(), business.UserScope{}, business.ListFilter(business.AccountFilter{}))
if err != nil || total != 0 || len(rows) != 0 {
t.Fatal("空范围不能放行全量")
}
}
+17
View File
@@ -0,0 +1,17 @@
package business
import "time"
type bindingPO struct {
UserID uint `gorm:"primaryKey;autoIncrement:false"`
SourceDB string `gorm:"column:source_db;primaryKey;size:50"`
PlatformBusierID uint64 `gorm:"primaryKey;autoIncrement:false"`
PlatformBusierAccount string `gorm:"size:100"`
Status string `gorm:"size:1;index"`
RejectReason *string `gorm:"size:500"`
CreateTime time.Time
ReviewTime *time.Time
ReviewAccount *string `gorm:"size:100"`
}
func (bindingPO) TableName() string { return "sys_user_bind_platform" }
+3 -1
View File
@@ -6,7 +6,9 @@ import (
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202611030001_business_preference", Migrate: func(db *gorm.DB) error {
return []migration.Step{{ID: "202611030003_business_bindings", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &bindingPO{})
}}, {ID: "202611030001_business_preference", Migrate: func(db *gorm.DB) error {
// 旧表沿用;已迁移数据库不重建或重置用户偏好。
if err := migration.CreateMissingTables(db, &preferencePO{}); err != nil {
return err
+1
View File
@@ -28,6 +28,7 @@ var ProviderSet = wire.NewSet(
NewData,
wire.Bind(new(databusiness.Provider), new(*Data)),
databusiness.NewUserPreferenceRepo,
databusiness.NewAccountRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 16 {
t.Fatalf("migration versions = %d, want 16", versions)
if versions != 17 {
t.Fatalf("migration versions = %d, want 17", versions)
}
var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {