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

This commit is contained in:
Yvan
2026-11-03 17:56:33 +08:00
parent ce4b54a2e2
commit 794001dfbe
24 changed files with 1229 additions and 12 deletions
+1 -1
View File
@@ -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)
+230
View File
@@ -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)
}
+112
View File
@@ -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 &copy, 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 = &copy
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)
}
}