feat: 迁移商务账号层级和列表管理
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user