diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 0f5daaa..2e4a094 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -84,7 +84,10 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg userPreferenceRepo := business.NewUserPreferenceRepo(dataData) userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo) userPreferenceService := business3.NewUserPreferenceService(userPreferenceUsecase) - handlerBusiness := handler.NewBusiness(userPreferenceService) + accountRepo := business.NewAccountRepo(dataData) + accountUsecase := business2.NewAccountUsecase(accountRepo) + accountService := business3.NewAccountService(accountUsecase) + handlerBusiness := handler.NewBusiness(userPreferenceService, accountService) runtimeconfigStore := data.NewIntegrationRuntime(dataData) smsSender := sms.NewSender(runtimeconfigStore) smsUsecase := integration.NewSMSUsecase(smsSender) diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index 2235ed2..39f4f0d 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -42,6 +42,9 @@ - 已落地商务用户七个扩展字段的存取及增量补列;登录和既有会话同时检查商务审核状态。 - 已落地全局偏好 GET/PUT、当前用户隔离、缺省开启、显式关闭及幂等写入;Kra 顶栏增加统计口径入口。 +- 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。 +- 商务列表的绑定、画像、导出等功能仍在后续范围内;当前不将整个商务菜单标为完成。 - 已生成 `source-inventory.json`:现有 SQL 的 63 个接口契约、279 个变更源文件及额外找回密码控制器。此清单是范围索引,不代表已完成实现。 - 已执行 `make generate`、相关 Go 测试、变更前端 ESLint 和生产构建。构建存在原 `arcdash` 依赖的 BigInt 浏览器目标警告。 +- 第一批提交 `ce4b54a` 后 `go test ./...` 全量通过;第二批账号功能的 biz/data/service/modules/server/cmd 检查及前端生产构建通过。 - 当前没有执行本轮真实数据库迁移验证。 diff --git a/internal/biz/biz.go b/internal/biz/biz.go index 3ef61c4..ffc7fdb 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -11,4 +11,4 @@ import ( ) // ProviderSet is biz providers. -var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase) +var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase) diff --git a/internal/biz/business/account.go b/internal/biz/business/account.go new file mode 100644 index 0000000..8d2357e --- /dev/null +++ b/internal/biz/business/account.go @@ -0,0 +1,230 @@ +package business + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/go-kratos/kratos/v3/errors" + "golang.org/x/crypto/bcrypt" + "kra/internal/biz/system" +) + +var ( + ErrAccountNotFound = errors.NotFound("SW_ACCOUNT_NOT_FOUND", "商务账号不存在") + ErrAccountScope = errors.Forbidden("SW_ACCOUNT_SCOPE", "没有权限访问用户数据") + ErrChildLevel = errors.BadRequest("SW_CHILD_LEVEL", "三级商务不可再创建下级,请使用绑定游戏商务") + ErrAccountInput = errors.BadRequest("SW_ACCOUNT_INPUT", "商务账号参数不正确") + ErrAccountExists = errors.BadRequest("SW_ACCOUNT_EXISTS", "登录账号已存在") + ErrPhoneExists = errors.BadRequest("SW_PHONE_EXISTS", "手机号码已存在") + ErrEmailExists = errors.BadRequest("SW_EMAIL_EXISTS", "邮箱账号已存在") + ErrBusinessRole = errors.BadRequest("SW_BUSINESS_ROLE", "未找到对应层级的商务角色,请核对角色迁移映射") +) + +type Account struct { + ID uint + Username, NickName, Password, RealName, Phone, Email, QQNumber string + Level int + ParentID uint + IDPath, ParentUserName string + Status int + RejectReason string + AuthorityID uint + CreatedAt, UpdatedAt time.Time + PromoteCount, BindCount int64 +} + +// UserScope 表示商务树可见范围,与框架部门权限相互独立。 +type UserScope struct { + All, Empty, IncludeSelf bool + SelfID uint + DescendantPath string +} + +type AccountFilter struct { + UserID uint + Username, NickName, RealName, Phone string + Level *int + Status *int + BeginTime, EndTime *time.Time +} + +type AccountUpdate struct { + ID uint + Username, NickName, RealName, Phone, Email, QQNumber, RejectReason *string + Status *int +} + +type ListOptions struct { + Filter AccountFilter + Offset, Limit int + OrderBy string + Desc bool +} +type ListOption func(*ListOptions) + +func ListFilter(filter AccountFilter) ListOption { return func(o *ListOptions) { o.Filter = filter } } +func ListOffset(n int) ListOption { return func(o *ListOptions) { o.Offset = n } } +func ListLimit(n int) ListOption { return func(o *ListOptions) { o.Limit = n } } +func ListOrderBy(key string, desc bool) ListOption { + return func(o *ListOptions) { o.OrderBy, o.Desc = key, desc } +} +func NewListOptions(options ...ListOption) ListOptions { + o := ListOptions{Limit: 10, OrderBy: "userId"} + for _, option := range options { + option(&o) + } + return o +} + +type AccountRepo interface { + Find(context.Context, uint) (*Account, error) + List(context.Context, UserScope, ...ListOption) ([]*Account, int64, error) + Create(context.Context, *Account) error + Update(context.Context, *Account) error + AuthorityForLevel(context.Context, int) (uint, error) + DirectCounts(context.Context, []uint) (map[uint][2]int64, error) +} + +type AccountUsecase struct{ repo AccountRepo } + +func NewAccountUsecase(repo AccountRepo) *AccountUsecase { return &AccountUsecase{repo: repo} } + +func (uc *AccountUsecase) actor(ctx context.Context) (system.Actor, *Account, error) { + actor, ok := system.ActorFromContext(ctx) + if !ok || actor.UserID == 0 { + return actor, nil, ErrUnauthenticated + } + account, err := uc.repo.Find(ctx, actor.UserID) + return actor, account, err +} + +func descendantPath(account *Account) string { + path := strings.TrimSpace(account.IDPath) + if path == "" { + path = "0" + } + return path + "," + strconv.FormatUint(uint64(account.ID), 10) +} + +func descendantOf(owner, target *Account) bool { + if owner == nil || target == nil || owner.ID == target.ID { + return false + } + path := descendantPath(owner) + return target.ParentID == owner.ID || target.IDPath == path || strings.HasPrefix(target.IDPath, path+",") +} + +func (uc *AccountUsecase) List(ctx context.Context, filter AccountFilter, options ...ListOption) ([]*Account, int64, error) { + actor, current, err := uc.actor(ctx) + if err != nil { + return nil, 0, err + } + scope := UserScope{All: actor.AuthorityID == 888} + if !scope.All { + if current.Level >= 3 { + return []*Account{}, 0, nil + } + scope.SelfID, scope.DescendantPath = current.ID, descendantPath(current) + } + rows, total, err := uc.repo.List(ctx, scope, append(options, ListFilter(filter))...) + if err != nil || len(rows) == 0 { + return rows, total, err + } + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + counts, err := uc.repo.DirectCounts(ctx, ids) + if err != nil { + return nil, 0, err + } + for _, row := range rows { + row.PromoteCount, row.BindCount = counts[row.ID][0], counts[row.ID][1] + } + return rows, total, nil +} + +func (uc *AccountUsecase) Find(ctx context.Context, id uint) (*Account, error) { + actor, current, err := uc.actor(ctx) + if err != nil { + return nil, err + } + account, err := uc.repo.Find(ctx, id) + if err != nil { + return nil, err + } + if actor.AuthorityID != 888 && id != current.ID && !descendantOf(current, account) { + return nil, ErrAccountScope + } + return account, nil +} + +func (uc *AccountUsecase) CreateChild(ctx context.Context, input *Account) (*Account, error) { + if input == nil { + return nil, ErrAccountInput + } + actor, parent, err := uc.actor(ctx) + if err != nil { + return nil, err + } + account := *input + account.ID, account.ParentID, account.IDPath, account.Level = 0, 0, "0", 1 + if actor.AuthorityID != 888 { + if parent.Level >= 3 { + return nil, ErrChildLevel + } + level := parent.Level + if level < 1 { + level = 1 + } + account.Level, account.ParentID, account.IDPath = level+1, parent.ID, descendantPath(parent) + } + account.AuthorityID, err = uc.repo.AuthorityForLevel(ctx, account.Level) + if err != nil { + return nil, err + } + if account.NickName == "" { + account.NickName = account.RealName + } + hash, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost) + if err != nil { + return nil, ErrAccountInput + } + account.Password = string(hash) + if err = uc.repo.Create(ctx, &account); err != nil { + return nil, err + } + account.Password = "" + return &account, nil +} + +func (uc *AccountUsecase) Update(ctx context.Context, input *AccountUpdate) error { + if input == nil || input.ID == 0 { + return ErrAccountInput + } + account, err := uc.Find(ctx, input.ID) + if err != nil { + return err + } + if account.AuthorityID == 888 { + return ErrAccountScope + } + // 编辑资料和审核不会改变商务层级、归属、角色或密码。 + for _, field := range []struct { + dst *string + src *string + }{ + {&account.Username, input.Username}, {&account.NickName, input.NickName}, {&account.RealName, input.RealName}, + {&account.Phone, input.Phone}, {&account.Email, input.Email}, {&account.QQNumber, input.QQNumber}, {&account.RejectReason, input.RejectReason}, + } { + if field.src != nil { + *field.dst = *field.src + } + } + if input.Status != nil { + account.Status = *input.Status + } + return uc.repo.Update(ctx, account) +} diff --git a/internal/biz/business/account_test.go b/internal/biz/business/account_test.go new file mode 100644 index 0000000..94498a7 --- /dev/null +++ b/internal/biz/business/account_test.go @@ -0,0 +1,112 @@ +package business + +import ( + "context" + "golang.org/x/crypto/bcrypt" + "kra/internal/biz/system" + "testing" +) + +type accountRepoFake struct { + AccountRepo + accounts map[uint]*Account + created *Account + updated *Account + listCalls int + scope UserScope +} + +func (r *accountRepoFake) Find(_ context.Context, id uint) (*Account, error) { + if a := r.accounts[id]; a != nil { + copy := *a + return ©, nil + } + return nil, ErrAccountNotFound +} +func (r *accountRepoFake) AuthorityForLevel(_ context.Context, level int) (uint, error) { + return uint(900 + level), nil +} +func (r *accountRepoFake) Create(_ context.Context, a *Account) error { + copy := *a + r.created = © + a.ID = 30 + return nil +} +func (r *accountRepoFake) Update(_ context.Context, a *Account) error { r.updated = a; return nil } +func (r *accountRepoFake) List(_ context.Context, scope UserScope, _ ...ListOption) ([]*Account, int64, error) { + r.listCalls++ + r.scope = scope + return []*Account{}, 0, nil +} + +func accountContext(id, aid uint) context.Context { + return system.NewActorContext(context.Background(), system.Actor{UserID: id, AuthorityID: aid}) +} + +func TestChildBusinessUsesServerHierarchyAndMappedRole(t *testing.T) { + for _, level := range []int{0, 1, 2, 3} { + r := &accountRepoFake{accounts: map[uint]*Account{12: {ID: 12, Level: level, IDPath: "0,5"}}} + uc := NewAccountUsecase(r) + aid := uint(901) + if level == 0 { + aid = 888 + } + a, err := uc.CreateChild(accountContext(12, aid), &Account{ID: 999, Level: 1, ParentID: 999, IDPath: "0,999", AuthorityID: 888, Username: "child", Password: "password", RealName: "姓名"}) + if level == 3 { + if err != ErrChildLevel || r.created != nil { + t.Fatalf("三级不应创建子商务: %v", err) + } + continue + } + if err != nil { + t.Fatal(err) + } + if r.created.Level != level+1 || r.created.AuthorityID != uint(901+level) || r.created.Status != 0 || r.created.NickName != "姓名" { + t.Fatalf("层级或默认信息不正确: %+v", r.created) + } + if level == 0 { + if r.created.ParentID != 0 || r.created.IDPath != "0" { + t.Fatal("超管创建的一级挂错归属") + } + } else if r.created.ParentID != 12 || r.created.IDPath != "0,5,12" { + t.Fatal("子商务祖先链不正确") + } + if a.Password != "" || bcrypt.CompareHashAndPassword([]byte(r.created.Password), []byte("password")) != nil { + t.Fatal("密码未安全写入或泄露到返回值") + } + } +} + +func TestBusinessScopeSeparatesDescendantsAndSiblings(t *testing.T) { + r := &accountRepoFake{accounts: map[uint]*Account{ + 12: {ID: 12, Level: 1, IDPath: "0"}, 13: {ID: 13, Level: 2, ParentID: 12, IDPath: "0,12"}, 14: {ID: 14, Level: 3, IDPath: "0,12,13"}, + 99: {ID: 99, Level: 2, IDPath: "0,123"}, + }} + uc := NewAccountUsecase(r) + ctx := accountContext(12, 901) + for _, id := range []uint{12, 13, 14} { + if _, err := uc.Find(ctx, id); err != nil { + t.Fatalf("本人及下级应可查: %v", err) + } + } + if _, err := uc.Find(ctx, 99); err != ErrAccountScope { + t.Fatalf("ID 前缀相似的其他树不应可见: %v", err) + } + if _, _, err := uc.List(accountContext(14, 903), AccountFilter{}); err != nil || r.listCalls != 0 { + t.Fatal("三级商务列表应直接返回空") + } + if _, _, err := uc.List(ctx, AccountFilter{}); err != nil || r.scope.IncludeSelf || r.scope.All || r.scope.DescendantPath != "0,12" { + t.Fatalf("商务列表应仅下级: %+v %v", r.scope, err) + } +} + +func TestAccountPartialEditKeepsHierarchyAndReviewState(t *testing.T) { + r := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 2: {ID: 2, Level: 2, ParentID: 8, IDPath: "0,8", Status: 3, AuthorityID: 902, Username: "child"}}} + name := "新姓名" + if err := NewAccountUsecase(r).Update(accountContext(1, 888), &AccountUpdate{ID: 2, RealName: &name}); err != nil { + t.Fatal(err) + } + if a := r.updated; a.Status != 3 || a.Level != 2 || a.ParentID != 8 || a.AuthorityID != 902 || a.Username != "child" || a.RealName != name { + t.Fatalf("部分更新改变了其他字段: %+v", a) + } +} diff --git a/internal/data/business/account.go b/internal/data/business/account.go new file mode 100644 index 0000000..f755110 --- /dev/null +++ b/internal/data/business/account.go @@ -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 +} diff --git a/internal/data/business/account_test.go b/internal/data/business/account_test.go new file mode 100644 index 0000000..bd28893 --- /dev/null +++ b/internal/data/business/account_test.go @@ -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("空范围不能放行全量") + } +} diff --git a/internal/data/business/binding.go b/internal/data/business/binding.go new file mode 100644 index 0000000..a161783 --- /dev/null +++ b/internal/data/business/binding.go @@ -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" } diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index f1df307..64318b3 100644 --- a/internal/data/business/migrations.go +++ b/internal/data/business/migrations.go @@ -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 diff --git a/internal/data/data.go b/internal/data/data.go index aeeb797..06b32a0 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -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)), diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index bf3ea42..74e1cfe 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -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 { diff --git a/internal/modules/business/definition.go b/internal/modules/business/definition.go index 19a8caf..20a08fe 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -8,6 +8,10 @@ import ( func Definition() module.Definition { return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{ + Menus: []module.Menu{ + {Name: "sw_BusMgmt", Path: "sw-BusMgmt", Component: "view/routerHolder.vue", Title: "商务管理", Icon: "user", Sort: 2}, + {Name: "sw_BusinessList", Path: "BusinessList", ParentName: "sw_BusMgmt", Component: "view/sw/BusinessList.vue", Title: "商务列表", Icon: "user", Sort: 1}, + }, APIs: surface.APIsForPrefix("/sw/", 0), }} } diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index f9c4d73..6939497 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { t.Fatalf("definition[%d] = %q, want %q", index, got, name) } } - if got := catalog.MigrationSteps(); len(got) != 16 { - t.Fatalf("module migrations = %d, want 16", len(got)) + if got := catalog.MigrationSteps(); len(got) != 17 { + t.Fatalf("module migrations = %d, want 17", 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 != 20 { - t.Fatalf("admin surface = %d menus/%d APIs, want 4/20", menus, apis) + if menus != 6 || apis != 24 { + t.Fatalf("admin surface = %d menus/%d APIs, want 6/24", menus, apis) } if got := catalog.DefaultTimedTasks(); len(got) != 2 { t.Fatalf("default timed tasks = %d, want 2", len(got)) diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index db82a9c..221d7fb 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -41,6 +41,10 @@ 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/business/list": {group: "商务管理", description: "查询下级商务列表"}, + "GET /sw/business/:userId": {group: "商务管理", description: "获取商务资料"}, + "POST /sw/business": {group: "商务管理", description: "添加子商务", audit: true}, + "PUT /sw/business": {group: "商务管理", description: "编辑商务资料和审核状态", audit: true}, "GET /sw/user/preference": {group: "商务偏好", description: "获取当前用户商务偏好"}, "PUT /sw/user/preference": {group: "商务偏好", description: "更新当前用户商务偏好", audit: true}, "DELETE /api/deleteApisByIds": {group: "api", description: "批量删除api", audit: true}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 5277b96..b41b81f 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -98,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":199`) { + if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":203`) { t.Fatalf("startup route summary is missing: %s", text) } } diff --git a/internal/server/handler/business.go b/internal/server/handler/business.go index f2e63b8..e669524 100644 --- a/internal/server/handler/business.go +++ b/internal/server/handler/business.go @@ -5,14 +5,71 @@ import ( "github.com/go-kratos/kratos/v3/errors" "kra/internal/service/business" "kra/internal/service/dto" + "strconv" ) type Business struct { preference *business.UserPreferenceService + accounts *business.AccountService } -func NewBusiness(preference *business.UserPreferenceService) *Business { - return &Business{preference: preference} +func NewBusiness(preference *business.UserPreferenceService, accounts *business.AccountService) *Business { + return &Business{preference: preference, accounts: accounts} +} + +func (h *Business) AccountList(c *gin.Context) { + var req dto.BusinessAccountQuery + if err := c.ShouldBindQuery(&req); err != nil { + Fail(c, "查询参数格式错误") + return + } + value, err := h.accounts.List(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, value) +} + +func (h *Business) Account(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("userId"), 10, 64) + if err != nil || id == 0 { + Fail(c, "商务用户 ID 不正确") + return + } + value, err := h.accounts.Find(c.Request.Context(), uint(id)) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, value) +} + +func (h *Business) CreateAccount(c *gin.Context) { + var req dto.BusinessAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + Fail(c, "商务账号参数格式错误") + return + } + value, err := h.accounts.Create(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, value) +} + +func (h *Business) UpdateAccount(c *gin.Context) { + var req dto.BusinessAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + Fail(c, "商务账号参数格式错误") + return + } + if err := h.accounts.Update(c.Request.Context(), &req); err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OK(c) } func (h *Business) UserPreference(c *gin.Context) { diff --git a/internal/server/router/business.go b/internal/server/router/business.go index 254c08f..4649a9a 100644 --- a/internal/server/router/business.go +++ b/internal/server/router/business.go @@ -6,6 +6,10 @@ import ( ) func RegisterBusiness(private *gin.RouterGroup, h *handler.Business) { + private.GET("/sw/business/list", h.AccountList) + private.GET("/sw/business/:userId", h.Account) + private.POST("/sw/business", h.CreateAccount) + private.PUT("/sw/business", h.UpdateAccount) private.GET("/sw/user/preference", h.UserPreference) private.PUT("/sw/user/preference", h.UpdateUserPreference) } diff --git a/internal/service/business/account.go b/internal/service/business/account.go new file mode 100644 index 0000000..666eb44 --- /dev/null +++ b/internal/service/business/account.go @@ -0,0 +1,165 @@ +package business + +import ( + "context" + "net/mail" + "strconv" + "strings" + "time" + "unicode/utf8" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type AccountUsecase interface { + List(context.Context, business.AccountFilter, ...business.ListOption) ([]*business.Account, int64, error) + Find(context.Context, uint) (*business.Account, error) + CreateChild(context.Context, *business.Account) (*business.Account, error) + Update(context.Context, *business.AccountUpdate) error +} +type AccountService struct{ uc AccountUsecase } + +func NewAccountService(uc *business.AccountUsecase) *AccountService { return &AccountService{uc: uc} } + +func accountResponse(a *business.Account) *dto.BusinessAccountResponse { + return &dto.BusinessAccountResponse{UserID: a.ID, Username: a.Username, NickName: a.NickName, RealName: a.RealName, Phone: a.Phone, Email: a.Email, QQNumber: a.QQNumber, + Level: a.Level, ParentID: a.ParentID, IDPath: a.IDPath, ParentUserName: a.ParentUserName, Status: strconv.Itoa(a.Status), RejectReason: a.RejectReason, AuthorityID: a.AuthorityID, + CreateTime: a.CreatedAt.Format("2006-01-02 15:04:05"), UpdateTime: a.UpdatedAt.Format("2006-01-02 15:04:05"), PromoteCount: a.PromoteCount, BindCount: a.BindCount} +} + +func (s *AccountService) List(ctx context.Context, req *dto.BusinessAccountQuery) (*dto.BusinessAccountPage, error) { + if req == nil { + return nil, business.ErrAccountInput + } + page, size := req.Page, req.PageSize + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + if size > 500 { + size = 500 + } + if page > int(^uint(0)>>1)/size { + return nil, business.ErrAccountInput + } + filter := business.AccountFilter{UserID: req.UserID, Username: req.Username, NickName: req.NickName, RealName: req.RealName, Phone: req.Phone} + for _, field := range []struct { + raw string + target **int + }{{req.Level, &filter.Level}, {req.Status, &filter.Status}} { + if field.raw == "" { + continue + } + n, err := strconv.Atoi(field.raw) + if err != nil || n < 0 || n > 3 { + return nil, business.ErrAccountInput + } + *field.target = &n + } + if req.BeginTime != "" && req.EndTime != "" { + begin, err := time.ParseInLocation("2006-01-02 15:04:05", req.BeginTime, time.Local) + if err != nil { + return nil, business.ErrAccountInput + } + end, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local) + if err != nil || end.Before(begin) { + return nil, business.ErrAccountInput + } + filter.BeginTime, filter.EndTime = &begin, &end + } + rows, total, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size), business.ListOrderBy(req.OrderByColumn, strings.EqualFold(req.IsAsc, "desc") || req.IsAsc == "descending")) + if err != nil { + return nil, err + } + out := &dto.BusinessAccountPage{Rows: make([]*dto.BusinessAccountResponse, 0, len(rows)), Total: total} + for _, a := range rows { + out.Rows = append(out.Rows, accountResponse(a)) + } + return out, nil +} +func (s *AccountService) Find(ctx context.Context, id uint) (*dto.BusinessAccountResponse, error) { + if id == 0 { + return nil, business.ErrAccountInput + } + a, err := s.uc.Find(ctx, id) + if err != nil { + return nil, err + } + return accountResponse(a), nil +} + +func validateAccount(req *dto.BusinessAccountRequest, create bool) (*int, error) { + if req == nil { + return nil, business.ErrAccountInput + } + if create && (req.Username == nil || req.RealName == nil || strings.TrimSpace(*req.RealName) == "" || req.Password == "") { + return nil, business.ErrAccountInput + } + if req.Username != nil && (utf8.RuneCountInString(*req.Username) < 2 || utf8.RuneCountInString(*req.Username) > 30 || strings.TrimSpace(*req.Username) == "") { + return nil, business.ErrAccountInput + } + for _, field := range []struct { + s *string + max int + }{{req.NickName, 30}, {req.RealName, 30}, {req.Phone, 11}, {req.Email, 50}, {req.QQNumber, 20}, {req.RejectReason, 200}} { + if field.s != nil && (utf8.RuneCountInString(*field.s) > field.max || strings.ContainsAny(*field.s, "<>")) { + return nil, business.ErrAccountInput + } + } + if req.Username != nil && strings.ContainsAny(*req.Username, "<>") { + return nil, business.ErrAccountInput + } + if create && (utf8.RuneCountInString(req.Password) < 5 || utf8.RuneCountInString(req.Password) > 50 || len(req.Password) > 72) { + return nil, business.ErrAccountInput + } + if req.Email != nil && *req.Email != "" { + if _, err := mail.ParseAddress(*req.Email); err != nil { + return nil, business.ErrAccountInput + } + } + if req.Status == nil { + return nil, nil + } + status, err := strconv.Atoi(*req.Status) + if err != nil || status < 0 || status > 3 { + return nil, business.ErrAccountInput + } + return &status, nil +} + +func stringValue(s *string) string { + if s == nil { + return "" + } + return *s +} + +func (s *AccountService) Create(ctx context.Context, req *dto.BusinessAccountRequest) (*dto.BusinessAccountResponse, error) { + status, err := validateAccount(req, true) + if err != nil { + return nil, err + } + a := &business.Account{Username: stringValue(req.Username), NickName: stringValue(req.NickName), RealName: stringValue(req.RealName), Phone: stringValue(req.Phone), + Email: stringValue(req.Email), QQNumber: stringValue(req.QQNumber), Password: req.Password, RejectReason: stringValue(req.RejectReason)} + if status != nil { + a.Status = *status + } + created, err := s.uc.CreateChild(ctx, a) + if err != nil { + return nil, err + } + return accountResponse(created), nil +} +func (s *AccountService) Update(ctx context.Context, req *dto.BusinessAccountRequest) error { + status, err := validateAccount(req, false) + if err != nil { + return err + } + if req.UserID == 0 { + return business.ErrAccountInput + } + return s.uc.Update(ctx, &business.AccountUpdate{ID: req.UserID, Username: req.Username, NickName: req.NickName, RealName: req.RealName, Phone: req.Phone, Email: req.Email, QQNumber: req.QQNumber, Status: status, RejectReason: req.RejectReason}) +} diff --git a/internal/service/business/account_test.go b/internal/service/business/account_test.go new file mode 100644 index 0000000..b9d241c --- /dev/null +++ b/internal/service/business/account_test.go @@ -0,0 +1,69 @@ +package business + +import ( + "context" + "encoding/json" + "kra/internal/biz/business" + "kra/internal/service/dto" + "testing" +) + +type accountUsecaseFake struct { + AccountUsecase + created *business.Account + filter business.AccountFilter + options business.ListOptions +} + +func (f *accountUsecaseFake) CreateChild(_ context.Context, a *business.Account) (*business.Account, error) { + f.created = a + return a, nil +} +func (f *accountUsecaseFake) List(_ context.Context, filter business.AccountFilter, opts ...business.ListOption) ([]*business.Account, int64, error) { + f.filter, f.options = filter, business.NewListOptions(opts...) + return []*business.Account{}, 0, nil +} + +func TestAccountTransportIgnoresForgedOwnershipAndPreservesDefaultStatus(t *testing.T) { + var req dto.BusinessAccountRequest + if err := json.Unmarshal([]byte(`{"userId":999,"userName":"child","realName":"姓名","password":"abc123","level":1,"parentId":999,"roleIds":[888]}`), &req); err != nil { + t.Fatal(err) + } + f := &accountUsecaseFake{} + s := &AccountService{uc: f} + value, err := s.Create(context.Background(), &req) + if err != nil { + t.Fatal(err) + } + if f.created.ID != 0 || f.created.ParentID != 0 || f.created.AuthorityID != 0 || f.created.Level != 0 || f.created.Status != 0 { + t.Fatalf("传输层接受了身份注入: %+v", f.created) + } + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + if _, ok := payload["password"]; ok { + t.Fatal("响应不应包含密码") + } +} + +func TestAccountQueryEmptyFiltersAndPageBounds(t *testing.T) { + f := &accountUsecaseFake{} + s := &AccountService{uc: f} + if _, err := s.List(context.Background(), &dto.BusinessAccountQuery{PageSize: 99999}); err != nil { + t.Fatal(err) + } + if f.filter.Level != nil || f.filter.Status != nil || f.options.Limit != 500 || f.options.Offset != 0 { + t.Fatalf("缺省过滤或分页不正确: %+v %+v", f.filter, f.options) + } + if _, err := s.List(context.Background(), &dto.BusinessAccountQuery{Status: "0"}); err != nil || f.filter.Status == nil || *f.filter.Status != 0 { + t.Fatalf("正常状态筛选未保留: %v", err) + } + if _, err := s.List(context.Background(), &dto.BusinessAccountQuery{Page: int(^uint(0) >> 1), PageSize: 500}); err != business.ErrAccountInput { + t.Fatalf("溢出分页应拒绝: %v", err) + } +} diff --git a/internal/service/dto/business_account.go b/internal/service/dto/business_account.go new file mode 100644 index 0000000..0d19849 --- /dev/null +++ b/internal/service/dto/business_account.go @@ -0,0 +1,56 @@ +package dto + +type BusinessAccountQuery struct { + Page int `form:"pageNum"` + PageSize int `form:"pageSize"` + UserID uint `form:"userId"` + Username string `form:"userName"` + NickName string `form:"nickName"` + RealName string `form:"realName"` + Phone string `form:"phoneNumber"` + Level string `form:"level"` + Status string `form:"status"` + BeginTime string `form:"params[beginTime]"` + EndTime string `form:"params[endTime]"` + OrderByColumn string `form:"orderByColumn"` + IsAsc string `form:"isAsc"` +} + +type BusinessAccountRequest struct { + UserID uint `json:"userId"` + Username *string `json:"userName"` + NickName *string `json:"nickName"` + Password string `json:"password"` + RealName *string `json:"realName"` + Phone *string `json:"phoneNumber"` + Email *string `json:"email"` + QQNumber *string `json:"qqNumber"` + Status *string `json:"status"` + RejectReason *string `json:"rejectReason"` +} + +type BusinessAccountResponse struct { + UserID uint `json:"userId"` + Username string `json:"userName"` + NickName string `json:"nickName"` + RealName string `json:"realName"` + Phone string `json:"phoneNumber"` + Email string `json:"email"` + QQNumber string `json:"qqNumber"` + Level int `json:"level"` + ParentID uint `json:"parentId"` + IDPath string `json:"idPath"` + ParentUserName string `json:"parentUserName"` + Status string `json:"status"` + RejectReason string `json:"rejectReason"` + AuthorityID uint `json:"authorityId"` + CreateTime string `json:"createTime"` + UpdateTime string `json:"updateTime"` + PromoteCount int64 `json:"promoteCount"` + BindCount int64 `json:"bindCount"` +} + +type BusinessAccountPage struct { + Rows []*BusinessAccountResponse `json:"rows"` + Total int64 `json:"total"` +} diff --git a/internal/service/service.go b/internal/service/service.go index 04d46b0..99f6d50 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -13,6 +13,7 @@ import ( // ProviderSet aggregates all service modules for Wire. var ProviderSet = wire.NewSet( businessservice.NewUserPreferenceService, + businessservice.NewAccountService, systemservice.ProviderSet, paymentservice.ProviderSet, integrationservice.ProviderSet, diff --git a/web/src/api/business.js b/web/src/api/business.js index fabc142..0aae0cf 100644 --- a/web/src/api/business.js +++ b/web/src/api/business.js @@ -2,3 +2,8 @@ import service from '@/utils/request' export const getUserPreference = () => service({ url: '/sw/user/preference', method: 'get' }) export const updateUserPreference = (data) => service({ url: '/sw/user/preference', method: 'put', data }) + +export const listBusiness = (params) => service({ url: '/sw/business/list', method: 'get', params }) +export const getBusiness = (id) => service({ url: `/sw/business/${id}`, method: 'get' }) +export const createBusiness = (data) => service({ url: '/sw/business', method: 'post', data }) +export const updateBusiness = (data) => service({ url: '/sw/business', method: 'put', data }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 6582f87..5509588 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -52,6 +52,7 @@ "/src/view/superAdmin/params/sysParams.vue": "SysParams", "/src/view/superAdmin/position/position.vue": "Position", "/src/view/superAdmin/user/user.vue": "User", + "/src/view/sw/BusinessList.vue": "SwBusinessList", "/src/view/system/security/forceChangePassword.vue": "ForceChangePassword", "/src/view/system/security/index.vue": "SecurityConfig", "/src/view/system/state.vue": "State", diff --git a/web/src/view/sw/BusinessList.vue b/web/src/view/sw/BusinessList.vue new file mode 100644 index 0000000..a5dd033 --- /dev/null +++ b/web/src/view/sw/BusinessList.vue @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + 查询重置 + + + + + 添加子商务 + + + + {{ levels[row.level] || '系统账号' }} + + + + + + + {{ statuses[row.status] || row.status }} + 编辑 + + + + + + + + + + + + + + + + + + {{ childLevel }} + + 取消确定 + + + + +