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
+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,