feat: 接入商务用户资料和全局统计偏好

This commit is contained in:
Yvan
2026-11-03 17:44:25 +08:00
parent 89cc7761b5
commit ce4b54a2e2
39 changed files with 2562 additions and 17 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
package biz
import (
"kra/internal/biz/business"
"kra/internal/biz/integration"
"kra/internal/biz/payment"
"kra/internal/biz/system"
@@ -10,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase)
+61
View File
@@ -0,0 +1,61 @@
package business
import (
"context"
"github.com/go-kratos/kratos/v3/errors"
"kra/internal/biz/system"
)
var (
ErrUnauthenticated = errors.Unauthorized("SW_UNAUTHENTICATED", "请先登录")
ErrPreferenceRequired = errors.BadRequest("SW_PREFERENCE_REQUIRED", "偏好参数不能为空")
ErrStorage = errors.InternalServer("SW_STORAGE_ERROR", "商务数据访问失败")
)
// UserPreference 使用新用户 ID,保留旧商务两个全局开关的含义。
type UserPreference struct {
IncludeSubPromote bool
IDCardDedup bool
}
type UserPreferenceRepo interface {
Find(context.Context, uint) (*UserPreference, error)
Save(context.Context, uint, UserPreference) error
}
type UserPreferenceUsecase struct{ repo UserPreferenceRepo }
func NewUserPreferenceUsecase(repo UserPreferenceRepo) *UserPreferenceUsecase {
return &UserPreferenceUsecase{repo: repo}
}
func (uc *UserPreferenceUsecase) Current(ctx context.Context) (*UserPreference, error) {
actor, ok := system.ActorFromContext(ctx)
if !ok || actor.UserID == 0 {
return nil, ErrUnauthenticated
}
return uc.ForUser(ctx, actor.UserID)
}
func (uc *UserPreferenceUsecase) ForUser(ctx context.Context, id uint) (*UserPreference, error) {
if id == 0 {
return nil, ErrUnauthenticated
}
pref, err := uc.repo.Find(ctx, id)
if err != nil {
return nil, err
}
if pref == nil {
return &UserPreference{IncludeSubPromote: true, IDCardDedup: true}, nil
}
return pref, nil
}
func (uc *UserPreferenceUsecase) UpdateCurrent(ctx context.Context, pref UserPreference) error {
actor, ok := system.ActorFromContext(ctx)
if !ok || actor.UserID == 0 {
return ErrUnauthenticated
}
return uc.repo.Save(ctx, actor.UserID, pref)
}
+43
View File
@@ -0,0 +1,43 @@
package business
import (
"context"
"kra/internal/biz/system"
"testing"
)
type preferenceRepoFake struct {
value *UserPreference
userID uint
calls int
}
func (r *preferenceRepoFake) Find(_ context.Context, id uint) (*UserPreference, error) {
r.calls++
r.userID = id
return r.value, nil
}
func (r *preferenceRepoFake) Save(_ context.Context, id uint, pref UserPreference) error {
r.calls++
r.userID, r.value = id, &pref
return nil
}
func TestPreferenceDefaultsAndCurrentIdentity(t *testing.T) {
r := &preferenceRepoFake{}
uc := NewUserPreferenceUsecase(r)
if _, err := uc.Current(context.Background()); err != ErrUnauthenticated || r.calls != 0 {
t.Fatalf("匿名请求访问了偏好: %v", err)
}
ctx := system.NewActorContext(context.Background(), system.Actor{UserID: 7})
pref, err := uc.Current(ctx)
if err != nil || !pref.IncludeSubPromote || !pref.IDCardDedup {
t.Fatalf("缺省开关应均为开启: %+v %v", pref, err)
}
if err = uc.UpdateCurrent(ctx, UserPreference{}); err != nil {
t.Fatal(err)
}
if r.userID != 7 || r.value.IncludeSubPromote || r.value.IDCardDedup {
t.Fatalf("未保存当前用户的关闭值: %+v", r)
}
}
+2 -2
View File
@@ -122,7 +122,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
uc.recordLogin(ctx, attempt, false, "用户名不存在或者密码错误", 0)
return nil, ErrInvalidCredentials
}
if user.Enable != 1 {
if user.Enable != 1 || user.BusinessStatus != 0 {
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
uc.recordLogin(ctx, attempt, false, "用户被禁止登录", user.ID)
return nil, &UserDisabledError{UserID: user.ID}
@@ -183,7 +183,7 @@ func (uc *AuthenticationUsecase) currentTokenUser(ctx context.Context, claims *A
if err != nil || user == nil || user.ID != claims.ID || user.Username != claims.Username {
return nil, ErrTokenDisabled
}
if user.Enable != 1 {
if user.Enable != 1 || user.BusinessStatus != 0 {
return nil, &UserDisabledError{UserID: user.ID}
}
if !userHasAuthority(user, claims.AuthorityID) {
@@ -0,0 +1,27 @@
package system
import (
"context"
"errors"
"golang.org/x/crypto/bcrypt"
"testing"
"time"
)
func TestBusinessReviewStateBlocksPasswordAndExistingSession(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
for _, status := range []int{1, 2, 3} {
user := &User{ID: 7, Username: "business", Password: string(hash), Enable: 1, BusinessStatus: status}
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{}, &authenticationSettings{}, nil)
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{user: user}), security, &authenticationIssuer{}, nil)
if _, err := uc.Login(context.Background(), &LoginAttempt{Username: "business", Password: "secret", IP: "127.0.0.1"}); !errors.Is(err, ErrUserDisabled) {
t.Fatalf("审核状态 %d 的登录应被拒绝: %v", status, err)
}
if _, err := uc.currentTokenUser(context.Background(), &AuthClaims{ID: 7, Username: "business", UUID: "uuid", IssuedAt: time.Now()}); !errors.Is(err, ErrUserDisabled) {
t.Fatalf("审核状态 %d 的现有会话应被拒绝: %v", status, err)
}
}
}
+8
View File
@@ -29,6 +29,14 @@ type User struct {
OriginSetting map[string]any
MustChangePassword bool
PasswordUpdatedAt *time.Time
// 商务资料与系统身份共用主键;平台商务 ID 另由绑定关系持有。
Level int
ParentID uint
IDPath string
RealName string
QQNumber string
BusinessStatus int
RejectReason string
}
type UserListFilter struct {
Username, NickName, Phone, Email, OrderKey string
+31
View File
@@ -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
})
}}}
}
+62
View File
@@ -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
}
+72
View File
@@ -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)
}
}
+3
View File
@@ -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)),
+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 != 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 {
+1 -1
View File
@@ -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)
}
}
+16
View File
@@ -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) }
+7
View File
@@ -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" }
+2 -2
View File
@@ -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) {
+13
View File
@@ -0,0 +1,13 @@
package business
import (
databusiness "kra/internal/data/business"
"kra/internal/modules/surface"
"kra/pkg/module"
)
func Definition() module.Definition {
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
APIs: surface.APIsForPrefix("/sw/", 0),
}}
}
+2
View File
@@ -4,6 +4,7 @@
package modules
import (
businessmodule "kra/internal/modules/business"
integrationmodule "kra/internal/modules/integration"
paymentmodule "kra/internal/modules/payment"
systemmodule "kra/internal/modules/system"
@@ -20,5 +21,6 @@ func Catalog() module.Catalog {
integrationmodule.Definition(),
taskmodule.Definition(),
paymentmodule.Definition(),
businessmodule.Definition(),
}}
}
+7 -7
View File
@@ -16,25 +16,25 @@ func TestCatalogMigrationIDsAreUnique(t *testing.T) {
func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
catalog := Catalog()
if len(catalog.Definitions) != 4 {
t.Fatalf("definitions = %d, want 4", len(catalog.Definitions))
if len(catalog.Definitions) != 5 {
t.Fatalf("definitions = %d, want 5", len(catalog.Definitions))
}
want := []string{"system", "integration", "task", "payment"}
want := []string{"system", "integration", "task", "payment", "business"}
for index, name := range want {
if got := catalog.Definitions[index].Name; got != name {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
if got := catalog.MigrationSteps(); len(got) != 14 {
t.Fatalf("module migrations = %d, want 14", len(got))
if got := catalog.MigrationSteps(); len(got) != 16 {
t.Fatalf("module migrations = %d, want 16", len(got))
}
var menus, apis int
for _, definition := range catalog.Definitions {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 4 || apis != 18 {
t.Fatalf("admin surface = %d menus/%d APIs, want 4/18", menus, apis)
if menus != 4 || apis != 20 {
t.Fatalf("admin surface = %d menus/%d APIs, want 4/20", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
+2
View File
@@ -41,6 +41,8 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
"GET /sw/user/preference": {group: "商务偏好", description: "获取当前用户商务偏好"},
"PUT /sw/user/preference": {group: "商务偏好", description: "更新当前用户商务偏好", audit: true},
"DELETE /api/deleteApisByIds": {group: "api", description: "批量删除api", audit: true},
"DELETE /dataAccessLog/deleteDataAccessLogByIds": {group: "数据权限审计", description: "批量删除数据权限审计日志", audit: true},
"DELETE /department/deleteDepartment": {group: "部门", description: "删除部门", audit: true},
+2 -1
View File
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
func emptyHandlers() *handler.Set {
return &handler.Set{
Business: &handler.Business{},
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
Permission: &handler.Permission{}, Organization: &handler.Organization{},
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Payment: &handler.Payment{}, Task: &handler.Task{},
@@ -97,7 +98,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":197`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":199`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
+38
View File
@@ -0,0 +1,38 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/go-kratos/kratos/v3/errors"
"kra/internal/service/business"
"kra/internal/service/dto"
)
type Business struct {
preference *business.UserPreferenceService
}
func NewBusiness(preference *business.UserPreferenceService) *Business {
return &Business{preference: preference}
}
func (h *Business) UserPreference(c *gin.Context) {
value, err := h.preference.Current(c.Request.Context())
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, value)
}
func (h *Business) UpdateUserPreference(c *gin.Context) {
var req dto.UserPreferenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, "偏好参数格式错误")
return
}
if err := h.preference.UpdateCurrent(c.Request.Context(), &req); err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OK(c)
}
+1
View File
@@ -5,6 +5,7 @@ import "github.com/google/wire"
// ProviderSet wires the system HTTP handlers. Set itself is filled field by
// field so adding a handler only means adding its constructor here.
var ProviderSet = wire.NewSet(
NewBusiness,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
+1
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
Business *Business
SMS *SMS
Authority *Authority
Menu *Menu
+11
View File
@@ -0,0 +1,11 @@
package router
import (
"github.com/gin-gonic/gin"
"kra/internal/server/handler"
)
func RegisterBusiness(private *gin.RouterGroup, h *handler.Business) {
private.GET("/sw/user/preference", h.UserPreference)
private.PUT("/sw/user/preference", h.UpdateUserPreference)
}
+1
View File
@@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
func() { RegisterBusiness(private, r.handlers.Business) },
func() { RegisterPublic(public, engine, r.handlers.Public) },
func() { RegisterUser(private, r.handlers.User) },
func() { RegisterNavigation(private, r.handlers.Navigation) },
+33
View File
@@ -0,0 +1,33 @@
package business
import (
"context"
"kra/internal/biz/business"
"kra/internal/service/dto"
)
type PreferenceUsecase interface {
Current(context.Context) (*business.UserPreference, error)
UpdateCurrent(context.Context, business.UserPreference) error
}
type UserPreferenceService struct{ uc PreferenceUsecase }
func NewUserPreferenceService(uc *business.UserPreferenceUsecase) *UserPreferenceService {
return &UserPreferenceService{uc: uc}
}
func (s *UserPreferenceService) Current(ctx context.Context) (*dto.UserPreferenceResponse, error) {
pref, err := s.uc.Current(ctx)
if err != nil {
return nil, err
}
return &dto.UserPreferenceResponse{IncludeSubPromote: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, nil
}
func (s *UserPreferenceService) UpdateCurrent(ctx context.Context, req *dto.UserPreferenceRequest) error {
if req == nil || req.IncludeSubPromote == nil || req.IDCardDedup == nil {
return business.ErrPreferenceRequired
}
return s.uc.UpdateCurrent(ctx, business.UserPreference{IncludeSubPromote: *req.IncludeSubPromote, IDCardDedup: *req.IDCardDedup})
}
@@ -0,0 +1,42 @@
package business
import (
"context"
"kra/internal/biz/business"
"kra/internal/service/dto"
"testing"
)
type preferenceFake struct {
calls int
value business.UserPreference
}
func (f *preferenceFake) Current(context.Context) (*business.UserPreference, error) {
return &f.value, nil
}
func (f *preferenceFake) UpdateCurrent(_ context.Context, p business.UserPreference) error {
f.calls++
f.value = p
return nil
}
func TestPreferenceRequiresBothFieldsButAcceptsFalse(t *testing.T) {
f := &preferenceFake{}
s := &UserPreferenceService{uc: f}
off := false
for _, req := range []*dto.UserPreferenceRequest{nil, {}, {IncludeSubPromote: &off}, {IDCardDedup: &off}} {
if err := s.UpdateCurrent(context.Background(), req); err != business.ErrPreferenceRequired {
t.Fatalf("缺字段应被拒绝: %v", err)
}
}
if f.calls != 0 {
t.Fatal("校验失败后不应调用用例")
}
if err := s.UpdateCurrent(context.Background(), &dto.UserPreferenceRequest{IncludeSubPromote: &off, IDCardDedup: &off}); err != nil {
t.Fatal(err)
}
if f.calls != 1 || f.value.IncludeSubPromote || f.value.IDCardDedup {
t.Fatalf("false 值未完整传递: %+v", f)
}
}
+11
View File
@@ -0,0 +1,11 @@
package dto
type UserPreferenceRequest struct {
IncludeSubPromote *bool `json:"includeSubPromote"`
IDCardDedup *bool `json:"idcardDedup"`
}
type UserPreferenceResponse struct {
IncludeSubPromote bool `json:"includeSubPromote"`
IDCardDedup bool `json:"idcardDedup"`
}
+7
View File
@@ -73,6 +73,13 @@ type SwitchAuthorityRequest struct {
}
type UserResponse struct {
Level int `json:"level"`
ParentID uint `json:"parentId"`
IDPath string `json:"idPath"`
RealName string `json:"realName"`
QQNumber string `json:"qqNumber"`
BusinessStatus int `json:"businessStatus"`
RejectReason string `json:"rejectReason"`
ID uint `json:"ID"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
+2
View File
@@ -1,6 +1,7 @@
package service
import (
businessservice "kra/internal/service/business"
integrationservice "kra/internal/service/integration"
paymentservice "kra/internal/service/payment"
systemservice "kra/internal/service/system"
@@ -11,6 +12,7 @@ import (
// ProviderSet aggregates all service modules for Wire.
var ProviderSet = wire.NewSet(
businessservice.NewUserPreferenceService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
+1 -1
View File
@@ -40,5 +40,5 @@ func convertUser(user *system.User) *dto.UserResponse {
if user.Department != nil {
department = departmentResponse(user.Department)
}
return &dto.UserResponse{ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, UUID: user.UUID, Username: user.Username, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Authority: convertAuthority(user.Authority), Authorities: authorities, DeptID: user.DeptID, Department: department, Departments: departments, Positions: positions, Phone: user.Phone, Email: user.Email, Enable: user.Enable, OriginSetting: user.OriginSetting, PasswordUpdatedAt: user.PasswordUpdatedAt}
return &dto.UserResponse{ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, UUID: user.UUID, Username: user.Username, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Authority: convertAuthority(user.Authority), Authorities: authorities, DeptID: user.DeptID, Department: department, Departments: departments, Positions: positions, Phone: user.Phone, Email: user.Email, Enable: user.Enable, OriginSetting: user.OriginSetting, PasswordUpdatedAt: user.PasswordUpdatedAt, Level: user.Level, ParentID: user.ParentID, IDPath: user.IDPath, RealName: user.RealName, QQNumber: user.QQNumber, BusinessStatus: user.BusinessStatus, RejectReason: user.RejectReason}
}