feat: 接入商务用户资料和全局统计偏好
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202611030001_business_preference", Migrate: func(db *gorm.DB) error {
|
||||
// 旧表沿用;已迁移数据库不重建或重置用户偏好。
|
||||
if err := migration.CreateMissingTables(db, &preferencePO{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasTable("sys_authorities") || !db.Migrator().HasTable("casbin_rule") {
|
||||
return nil
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// 两个接口仅操作当前身份,旧系统对所有已登录用户开放。
|
||||
for _, method := range []string{"GET", "PUT"} {
|
||||
if err := tx.Exec(`INSERT INTO casbin_rule (ptype,v0,v1,v2,v3,v4,v5)
|
||||
SELECT 'p', a.authority_id, ?, ?, '', '', '' FROM sys_authorities a
|
||||
WHERE a.deleted_at IS NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM casbin_rule p WHERE p.ptype='p' AND p.v0=a.authority_id AND p.v1=? AND p.v2=?)`,
|
||||
"/sw/user/preference", method, "/sw/user/preference", method).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
// Provider 复用 Data 持有的连接,不在仓储中创建数据库客户端。
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
|
||||
type preferencePO struct {
|
||||
UserID uint `gorm:"primaryKey;autoIncrement:false"`
|
||||
IncludeSubPromote *int
|
||||
IDCardDedup *int `gorm:"column:idcard_dedup"`
|
||||
UpdateTime time.Time
|
||||
}
|
||||
|
||||
func (preferencePO) TableName() string { return "sys_user_preference" }
|
||||
|
||||
type userPreferenceRepo struct{ data Provider }
|
||||
|
||||
func NewUserPreferenceRepo(data Provider) business.UserPreferenceRepo {
|
||||
return &userPreferenceRepo{data: data}
|
||||
}
|
||||
|
||||
func (r *userPreferenceRepo) Find(ctx context.Context, id uint) (*business.UserPreference, error) {
|
||||
var po preferencePO
|
||||
err := r.data.DB().WithContext(ctx).First(&po, "user_id = ?", id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
return &business.UserPreference{
|
||||
IncludeSubPromote: po.IncludeSubPromote == nil || *po.IncludeSubPromote != 0,
|
||||
IDCardDedup: po.IDCardDedup == nil || *po.IDCardDedup != 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *userPreferenceRepo) Save(ctx context.Context, id uint, pref business.UserPreference) error {
|
||||
include, dedup := 0, 0
|
||||
if pref.IncludeSubPromote {
|
||||
include = 1
|
||||
}
|
||||
if pref.IDCardDedup {
|
||||
dedup = 1
|
||||
}
|
||||
po := preferencePO{UserID: id, IncludeSubPromote: &include, IDCardDedup: &dedup, UpdateTime: time.Now()}
|
||||
if err := r.data.DB().WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"include_sub_promote", "idcard_dedup", "update_time"}),
|
||||
}).Create(&po).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testData struct{ db *gorm.DB }
|
||||
|
||||
func (d testData) DB() *gorm.DB { return d.db }
|
||||
|
||||
func openTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "business.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
for _, step := range Migrations() {
|
||||
if err := step.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestPreferencePreservesLegacyValuesAndUpserts(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
r := NewUserPreferenceRepo(testData{db})
|
||||
ctx := context.Background()
|
||||
if pref, err := r.Find(ctx, 5); err != nil || pref != nil {
|
||||
t.Fatalf("缺省记录错误: %+v %v", pref, err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_user_preference(user_id,include_sub_promote,idcard_dedup) VALUES (5,NULL,2),(6,1,1)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pref, err := r.Find(ctx, 5)
|
||||
if err != nil || !pref.IncludeSubPromote || !pref.IDCardDedup {
|
||||
t.Fatalf("旧 NULL 和非零值应为开启: %+v %v", pref, err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := r.Save(ctx, 5, business.UserPreference{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, step := range Migrations() {
|
||||
if err := step.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
pref, err = r.Find(ctx, 5)
|
||||
if err != nil || pref.IncludeSubPromote || pref.IDCardDedup {
|
||||
t.Fatalf("重复迁移不能重置偏好: %+v %v", pref, err)
|
||||
}
|
||||
other, err := r.Find(ctx, 6)
|
||||
if err != nil || !other.IncludeSubPromote || !other.IDCardDedup {
|
||||
t.Fatalf("影响其他用户: %+v %v", other, err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&preferencePO{}).Where("user_id = ?", 5).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("重复写入产生多条记录: %d", count)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/config"
|
||||
databusiness "kra/internal/data/business"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
datapayment "kra/internal/data/payment"
|
||||
datasystem "kra/internal/data/system"
|
||||
@@ -25,6 +26,8 @@ import (
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewData,
|
||||
wire.Bind(new(databusiness.Provider), new(*Data)),
|
||||
databusiness.NewUserPreferenceRepo,
|
||||
NewIntegrationRuntime,
|
||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||
|
||||
@@ -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 != 14 {
|
||||
t.Fatalf("migration versions = %d, want 14", versions)
|
||||
if versions != 16 {
|
||||
t.Fatalf("migration versions = %d, want 16", versions)
|
||||
}
|
||||
var communicationRows []dataintegration.ConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
|
||||
@@ -178,7 +178,7 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *system
|
||||
value.Menus = nil
|
||||
}
|
||||
}
|
||||
defaults := []struct{ path, method string }{{"/menu/getMenu", "POST"}, {"/jwt/jsonInBlacklist", "POST"}, {"/base/login", "POST"}, {"/user/changePassword", "POST"}, {"/user/setUserAuthority", "POST"}, {"/user/getUserInfo", "GET"}, {"/user/setSelfInfo", "PUT"}, {"/fileUploadAndDownload/upload", "POST"}, {"/sysDictionary/findSysDictionary", "GET"}}
|
||||
defaults := []struct{ path, method string }{{"/sw/user/preference", "GET"}, {"/sw/user/preference", "PUT"}, {"/menu/getMenu", "POST"}, {"/jwt/jsonInBlacklist", "POST"}, {"/base/login", "POST"}, {"/user/changePassword", "POST"}, {"/user/setUserAuthority", "POST"}, {"/user/getUserInfo", "GET"}, {"/user/setSelfInfo", "PUT"}, {"/fileUploadAndDownload/upload", "POST"}, {"/sysDictionary/findSysDictionary", "GET"}}
|
||||
rules := make([]casbinRulePO, 0, len(defaults))
|
||||
for _, item := range defaults {
|
||||
if strict {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBusinessFieldsSurviveMigrationAndIdentityLoad(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migrateAll(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
po := userPO{Username: "business", UUID: "business-uuid", Password: "original-hash", Enable: 1, Level: 2, ParentID: 11, IDPath: "0,11", RealName: "商务姓名", QQNumber: "12345", BusinessStatus: 3, RejectReason: "审核资料"}
|
||||
if err = db.Create(&po).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ensureBusinessUserFields(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewUserRepo(&Data{gormDB: newReloadableDB(db, nil)})
|
||||
user, err := r.FindUserByUUID(context.Background(), "business-uuid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Level != 2 || user.ParentID != 11 || user.IDPath != "0,11" || user.RealName != "商务姓名" || user.QQNumber != "12345" || user.BusinessStatus != 3 || user.RejectReason != "审核资料" || user.Password != "original-hash" {
|
||||
t.Fatalf("迁移或身份加载丢失商务字段: %+v", user)
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,25 @@ func Migrations() []migration.Step {
|
||||
},
|
||||
{ID: "202608210002_communication_surface", Migrate: ensureCommunicationSurface},
|
||||
{ID: "202608210003_communication_test_surface", Migrate: ensureCommunicationTestSurface},
|
||||
{ID: "202611030002_business_user_fields", Migrate: ensureBusinessUserFields},
|
||||
}
|
||||
}
|
||||
|
||||
func ensureBusinessUserFields(db *gorm.DB) error {
|
||||
// 与已交付迁移 SQL 的字段保持一致,仅补缺列,保留导入值和密码。
|
||||
for _, field := range []string{"Level", "ParentID", "IDPath", "RealName", "QQNumber", "BusinessStatus", "RejectReason"} {
|
||||
if !db.Migrator().HasColumn(&userPO{}, field) {
|
||||
if err := db.Migrator().AddColumn(&userPO{}, field); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasIndex(&userPO{}, "idx_sys_users_business_parent") {
|
||||
return db.Migrator().CreateIndex(&userPO{}, "idx_sys_users_business_parent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kept as a migration version for existing installations; the canonical
|
||||
// communication surface seeder is idempotent and now owns the test endpoint.
|
||||
func ensureCommunicationTestSurface(db *gorm.DB) error { return ensureCommunicationSurface(db) }
|
||||
|
||||
@@ -25,6 +25,13 @@ type userPO struct {
|
||||
OriginSetting string `gorm:"type:text"`
|
||||
PasswordUpdatedAt *time.Time
|
||||
MustChangePassword bool
|
||||
Level int `gorm:"type:tinyint unsigned;not null;default:0"`
|
||||
ParentID uint `gorm:"not null;default:0;index:idx_sys_users_business_parent"`
|
||||
IDPath string `gorm:"column:id_path;size:255;not null;default:0"`
|
||||
RealName string `gorm:"size:30;not null;default:''"`
|
||||
QQNumber string `gorm:"column:qq_number;size:20;not null;default:''"`
|
||||
BusinessStatus int `gorm:"type:tinyint unsigned;not null;default:0"`
|
||||
RejectReason string `gorm:"size:200;not null;default:''"`
|
||||
}
|
||||
|
||||
func (userPO) TableName() string { return "sys_users" }
|
||||
|
||||
@@ -93,7 +93,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
|
||||
for _, item := range positionPOs {
|
||||
positions = append(positions, *posFromPO(item))
|
||||
}
|
||||
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, Authority: toBizAuthority(authority), Authorities: authorities, DeptID: po.DeptID, Department: primary, Departments: departments, Positions: positions, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}, nil
|
||||
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, Authority: toBizAuthority(authority), Authorities: authorities, DeptID: po.DeptID, Department: primary, Departments: departments, Positions: positions, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt, Level: po.Level, ParentID: po.ParentID, IDPath: po.IDPath, RealName: po.RealName, QQNumber: po.QQNumber, BusinessStatus: po.BusinessStatus, RejectReason: po.RejectReason}, nil
|
||||
}
|
||||
|
||||
func toBizAuthority(po authorityPO) system.Authority {
|
||||
@@ -116,7 +116,7 @@ func baseBizUser(po *userPO) (*system.User, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}, nil
|
||||
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt, Level: po.Level, ParentID: po.ParentID, IDPath: po.IDPath, RealName: po.RealName, QQNumber: po.QQNumber, BusinessStatus: po.BusinessStatus, RejectReason: po.RejectReason}, nil
|
||||
}
|
||||
|
||||
func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*system.User, error) {
|
||||
|
||||
Reference in New Issue
Block a user