feat: 迁移商务账号层级和列表管理
This commit is contained in:
Generated
+4
-1
@@ -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)
|
||||
|
||||
@@ -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 检查及前端生产构建通过。
|
||||
- 当前没有执行本轮真实数据库迁移验证。
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("空范围不能放行全量")
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="kra-search-box">
|
||||
<el-form :inline="true" :model="query" @submit.prevent="search">
|
||||
<el-form-item label="商务账号"><el-input v-model="query.userName" clearable placeholder="请输入账号" /></el-form-item>
|
||||
<el-form-item label="姓名"><el-input v-model="query.realName" clearable placeholder="请输入姓名" /></el-form-item>
|
||||
<el-form-item label="商务等级">
|
||||
<el-select v-model="query.level" clearable placeholder="全部" style="width: 130px">
|
||||
<el-option v-for="(label, value) in levels" :key="value" :label="label" :value="Number(value)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" clearable placeholder="全部" style="width: 130px">
|
||||
<el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">查询</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="kra-table-box">
|
||||
<div class="kra-btn-list">
|
||||
<el-button v-if="canAdd" type="primary" icon="plus" @click="openCreate">添加子商务</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" row-key="userId" @sort-change="sort">
|
||||
<el-table-column prop="userName" label="商务账号" min-width="140" />
|
||||
<el-table-column label="商务等级" width="110"><template #default="{ row }">{{ levels[row.level] || '系统账号' }}</template></el-table-column>
|
||||
<el-table-column prop="parentUserName" label="上级商务" min-width="140" />
|
||||
<el-table-column prop="realName" label="姓名" min-width="100" />
|
||||
<el-table-column prop="phoneNumber" label="手机号" min-width="130" />
|
||||
<el-table-column prop="bindCount" label="SDK 商务数" width="120" />
|
||||
<el-table-column prop="promoteCount" label="直属渠道数" width="120" />
|
||||
<el-table-column prop="createTime" label="注册时间" sortable="custom" min-width="180" />
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statuses[row.status] || row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column v-if="canEdit" label="操作" fixed="right" width="90"><template #default="{ row }"><el-button v-if="row.authorityId !== 888" type="primary" link @click="openEdit(row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination">
|
||||
<el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" />
|
||||
</div>
|
||||
</div>
|
||||
<el-drawer v-model="drawer" :title="editing ? '编辑商务' : '添加子商务'" :size="480" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="商务账号" prop="userName"><el-input v-model="form.userName" maxlength="30" /></el-form-item>
|
||||
<el-form-item v-if="!editing" label="登录密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="new-password" /></el-form-item>
|
||||
<el-form-item label="姓名" prop="realName"><el-input v-model="form.realName" maxlength="30" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phoneNumber" maxlength="11" /></el-form-item>
|
||||
<el-form-item label="QQ"><el-input v-model="form.qqNumber" maxlength="20" /></el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="form.email" maxlength="50" /></el-form-item>
|
||||
<el-form-item v-if="editing" label="状态">
|
||||
<el-select v-model="form.status"><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="value" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.status === '2'" label="驳回原因"><el-input v-model="form.rejectReason" type="textarea" maxlength="200" /></el-form-item>
|
||||
<el-form-item v-if="!editing" label="商务等级"><span>{{ childLevel }}</span></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">确定</el-button></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { useBtnAuth } from '@/utils/btnAuth'
|
||||
import { listBusiness, getBusiness, createBusiness, updateBusiness } from '@/api/business'
|
||||
|
||||
defineOptions({ name: 'SwBusinessList' })
|
||||
const user = useUserStore()
|
||||
const buttons = useBtnAuth()
|
||||
const levels = { 1: '一级商务', 2: '二级商务', 3: '三级商务' }
|
||||
const statuses = { 0: '正常', 1: '停用', 2: '已驳回', 3: '待审核' }
|
||||
const isAdmin = computed(() => Number(user.userInfo.authorityId) === 888)
|
||||
const canAdd = computed(() => (isAdmin.value || buttons['sw:business:add']) && (isAdmin.value || Number(user.userInfo.level) < 3))
|
||||
const canEdit = computed(() => isAdmin.value || buttons['sw:business:edit'])
|
||||
const childLevel = computed(() => levels[isAdmin.value ? 1 : Number(user.userInfo.level || 1) + 1])
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, userName: '', realName: '', level: undefined, status: undefined, orderByColumn: '', isAsc: '' })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const drawer = ref(false)
|
||||
const editing = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref()
|
||||
const form = ref({})
|
||||
const rules = {
|
||||
userName: [{ required: true, message: '请输入商务账号', trigger: 'blur' }, { min: 2, max: 30, message: '账号长度为 2 到 30 个字符', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 5, max: 50, message: '密码长度为 5 到 50 个字符', trigger: 'blur' }],
|
||||
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }]
|
||||
}
|
||||
const statusType = (status) => ({ 0: 'success', 1: 'info', 2: 'danger', 3: 'warning' })[status] || 'info'
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listBusiness(query)
|
||||
if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total }
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
const search = () => { query.pageNum = 1; return load() }
|
||||
const reset = () => { Object.assign(query, { userName: '', realName: '', level: undefined, status: undefined }); return search() }
|
||||
const sort = ({ prop, order }) => { query.orderByColumn = prop || ''; query.isAsc = order === 'descending' ? 'desc' : 'asc'; return search() }
|
||||
const openCreate = () => {
|
||||
editing.value = false
|
||||
form.value = { userName: '', password: '', realName: '', phoneNumber: '', qqNumber: '', email: '', status: '0', rejectReason: '' }
|
||||
drawer.value = true
|
||||
}
|
||||
const openEdit = async (row) => {
|
||||
const res = await getBusiness(row.userId)
|
||||
if (res.code !== 0) return
|
||||
editing.value = true
|
||||
form.value = { ...res.data }
|
||||
drawer.value = true
|
||||
}
|
||||
const save = async () => {
|
||||
if (!await formRef.value.validate().catch(() => false)) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await (editing.value ? updateBusiness : createBusiness)({ ...form.value, nickName: form.value.realName })
|
||||
if (res.code === 0) { ElMessage.success(editing.value ? '修改成功' : '添加成功'); drawer.value = false; await load() }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user