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
+4 -1
View File
@@ -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)
+3
View File
@@ -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
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)
}
}
+257
View File
@@ -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
}
+105
View File
@@ -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("空范围不能放行全量")
}
}
+17
View File
@@ -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" }
+3 -1
View File
@@ -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
+1
View File
@@ -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)),
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 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 {
+4
View File
@@ -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),
}}
}
+4 -4
View File
@@ -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))
+4
View File
@@ -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},
+1 -1
View File
@@ -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)
}
}
+59 -2
View File
@@ -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) {
+4
View File
@@ -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)
}
+165
View File
@@ -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})
}
+69
View File
@@ -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)
}
}
+56
View File
@@ -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"`
}
+1
View File
@@ -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,
+5
View File
@@ -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 })
+1
View File
@@ -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",
+121
View File
@@ -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>