This commit is contained in:
2026-06-06 14:45:22 +08:00
parent e4eb41ce10
commit 59c09d6a33
10 changed files with 315 additions and 24 deletions

View File

@@ -9,6 +9,36 @@ type SubPortalRegisterCommand struct {
InviteToken string `json:"invite_token" binding:"required"`
}
// ListChildApiCallsCommand 下属 API 调用记录查询
type ListChildApiCallsCommand struct {
ParentUserID string
ChildUserID string `form:"child_user_id" binding:"required"`
Page int `form:"page"`
PageSize int `form:"page_size"`
TransactionID string `form:"transaction_id"`
ProductName string `form:"product_name"`
Status string `form:"status"`
StartTime string `form:"start_time"`
EndTime string `form:"end_time"`
}
// ListSubordinatesCommand 下属列表查询
type ListSubordinatesCommand struct {
ParentUserID string
Page int `form:"page"`
PageSize int `form:"page_size"`
Remark string `form:"remark"`
Phone string `form:"phone"`
CompanyName string `form:"company_name"`
}
// UpdateSubordinateRemarkCommand 更新下属备注
type UpdateSubordinateRemarkCommand struct {
ParentUserID string
ChildUserID string `json:"child_user_id" binding:"required"`
Remark string `json:"remark"`
}
// CreateInvitationCommand 主账号创建邀请
type CreateInvitationCommand struct {
ParentUserID string

View File

@@ -10,15 +10,24 @@ type CreateInvitationResponse struct {
InvitationID string `json:"invitation_id"`
}
// SubordinateProductQuotaItem 下属产品额度
type SubordinateProductQuotaItem struct {
ProductID string `json:"product_id"`
ProductName string `json:"product_name"`
AvailableQuota int64 `json:"available_quota"`
}
// SubordinateListItem 下属一条
type SubordinateListItem struct {
ChildUserID string `json:"child_user_id"`
Phone string `json:"phone,omitempty"`
LinkID string `json:"link_id"`
Remark string `json:"remark"`
RegisteredAt time.Time `json:"registered_at"`
CompanyName string `json:"company_name"`
IsCertified bool `json:"is_certified"`
Balance string `json:"balance"`
ProductQuotas []SubordinateProductQuotaItem `json:"product_quotas"`
}
// SubordinateListResponse 列表

View File

@@ -3,6 +3,7 @@ package subordinate
import (
"context"
api_dto "tyapi-server/internal/application/api/dto"
"tyapi-server/internal/application/subordinate/dto/commands"
"tyapi-server/internal/application/subordinate/dto/responses"
)
@@ -11,7 +12,8 @@ import (
type SubordinateApplicationService interface {
RegisterSubPortal(ctx context.Context, cmd *commands.SubPortalRegisterCommand) (*responses.SubPortalRegisterResponse, error)
CreateInvitation(ctx context.Context, cmd *commands.CreateInvitationCommand) (*responses.CreateInvitationResponse, error)
ListMySubordinates(ctx context.Context, parentUserID string, page, pageSize int) (*responses.SubordinateListResponse, error)
ListMySubordinates(ctx context.Context, cmd *commands.ListSubordinatesCommand) (*responses.SubordinateListResponse, error)
UpdateSubordinateRemark(ctx context.Context, cmd *commands.UpdateSubordinateRemarkCommand) error
AllocateToChild(ctx context.Context, cmd *commands.AllocateToChildCommand) error
ListChildAllocations(ctx context.Context, cmd *commands.ListChildAllocationsCommand) (*responses.ChildAllocationListResponse, error)
AssignChildSubscription(ctx context.Context, cmd *commands.AssignChildSubscriptionCommand) error
@@ -20,5 +22,6 @@ type SubordinateApplicationService interface {
PurchaseChildQuota(ctx context.Context, cmd *commands.PurchaseChildQuotaCommand) error
ListChildQuotaPurchases(ctx context.Context, cmd *commands.ListChildQuotaPurchasesCommand) (*responses.ChildQuotaPurchaseListResponse, error)
ListChildQuotaAccounts(ctx context.Context, cmd *commands.ListChildQuotaAccountsCommand) ([]responses.ChildQuotaAccountItem, error)
ListChildApiCalls(ctx context.Context, cmd *commands.ListChildApiCallsCommand) (*api_dto.ApiCallListResponse, error)
ListMyQuotaAccounts(ctx context.Context, userID string) ([]responses.ChildQuotaAccountItem, error)
}

View File

@@ -11,18 +11,22 @@ import (
"github.com/shopspring/decimal"
"go.uber.org/zap"
api_app "tyapi-server/internal/application/api"
api_dto "tyapi-server/internal/application/api/dto"
"tyapi-server/internal/application/subordinate/dto/commands"
"tyapi-server/internal/application/subordinate/dto/responses"
"tyapi-server/internal/config"
"tyapi-server/internal/domains/finance/repositories"
productentities "tyapi-server/internal/domains/product/entities"
product_service "tyapi-server/internal/domains/product/services"
product_repositories "tyapi-server/internal/domains/product/repositories"
subentities "tyapi-server/internal/domains/subordinate/entities"
subrepositories "tyapi-server/internal/domains/subordinate/repositories"
user_entities "tyapi-server/internal/domains/user/entities"
user_repositories "tyapi-server/internal/domains/user/repositories"
domain_user_services "tyapi-server/internal/domains/user/services"
"tyapi-server/internal/shared/database"
shared_interfaces "tyapi-server/internal/shared/interfaces"
)
// SubordinateApplicationServiceImpl 实现
@@ -31,10 +35,12 @@ type SubordinateApplicationServiceImpl struct {
userAgg domain_user_services.UserAggregateService
smsService *domain_user_services.SMSCodeService
productSub *product_service.ProductSubscriptionService
productRepo product_repositories.ProductRepository
cfg *config.Config
txm *database.TransactionManager
walletRepo repositories.WalletRepository
userRepo user_repositories.UserRepository
apiApp api_app.ApiApplicationService
logger *zap.Logger
}
@@ -44,10 +50,12 @@ func NewSubordinateApplicationService(
userAgg domain_user_services.UserAggregateService,
smsService *domain_user_services.SMSCodeService,
productSub *product_service.ProductSubscriptionService,
productRepo product_repositories.ProductRepository,
cfg *config.Config,
txm *database.TransactionManager,
walletRepo repositories.WalletRepository,
userRepo user_repositories.UserRepository,
apiApp api_app.ApiApplicationService,
logger *zap.Logger,
) SubordinateApplicationService {
return &SubordinateApplicationServiceImpl{
@@ -55,10 +63,12 @@ func NewSubordinateApplicationService(
userAgg: userAgg,
smsService: smsService,
productSub: productSub,
productRepo: productRepo,
cfg: cfg,
txm: txm,
walletRepo: walletRepo,
userRepo: userRepo,
apiApp: apiApp,
logger: logger,
}
}
@@ -176,7 +186,9 @@ func (s *SubordinateApplicationServiceImpl) buildInvitationResponse(inv *subenti
}
// ListMySubordinates 主账号的下属
func (s *SubordinateApplicationServiceImpl) ListMySubordinates(ctx context.Context, parentUserID string, page, pageSize int) (*responses.SubordinateListResponse, error) {
func (s *SubordinateApplicationServiceImpl) ListMySubordinates(ctx context.Context, cmd *commands.ListSubordinatesCommand) (*responses.SubordinateListResponse, error) {
page := cmd.Page
pageSize := cmd.PageSize
if page < 1 {
page = 1
}
@@ -184,10 +196,58 @@ func (s *SubordinateApplicationServiceImpl) ListMySubordinates(ctx context.Conte
pageSize = 20
}
offset := (page - 1) * pageSize
links, total, err := s.subRepo.ListChildrenByParent(ctx, parentUserID, pageSize, offset)
filter := subrepositories.SubordinateListFilter{
Remark: strings.TrimSpace(cmd.Remark),
Phone: strings.TrimSpace(cmd.Phone),
CompanyName: strings.TrimSpace(cmd.CompanyName),
}
links, total, err := s.subRepo.ListChildrenByParent(ctx, cmd.ParentUserID, filter, pageSize, offset)
if err != nil {
return nil, err
}
childIDs := make([]string, 0, len(links))
for _, ln := range links {
childIDs = append(childIDs, ln.ChildUserID)
}
quotaByUser := make(map[string][]*subentities.UserProductQuotaAccount)
if len(childIDs) > 0 {
accounts, quotaErr := s.subRepo.ListQuotaAccountsByUserIDs(ctx, childIDs)
if quotaErr != nil {
return nil, quotaErr
}
for _, account := range accounts {
quotaByUser[account.UserID] = append(quotaByUser[account.UserID], account)
}
}
productNameMap := make(map[string]string)
if s.productRepo != nil {
productIDSet := make(map[string]struct{})
for _, accounts := range quotaByUser {
for _, account := range accounts {
productIDSet[account.ProductID] = struct{}{}
}
}
if len(productIDSet) > 0 {
productIDs := make([]string, 0, len(productIDSet))
for id := range productIDSet {
productIDs = append(productIDs, id)
}
products, productErr := s.productRepo.FindProductsByIDs(ctx, productIDs)
if productErr != nil {
s.logger.Warn("批量获取产品名称失败", zap.Error(productErr))
} else {
for _, product := range products {
if product != nil {
productNameMap[product.ID] = product.Name
}
}
}
}
}
items := make([]responses.SubordinateListItem, 0, len(links))
for _, ln := range links {
phone := ""
@@ -209,19 +269,52 @@ func (s *SubordinateApplicationServiceImpl) ListMySubordinates(ctx context.Conte
if w, e := s.walletRepo.GetByUserID(ctx, ln.ChildUserID); e == nil && w != nil {
balance = w.Balance.StringFixed(2)
}
productQuotas := make([]responses.SubordinateProductQuotaItem, 0)
for _, account := range quotaByUser[ln.ChildUserID] {
name := productNameMap[account.ProductID]
if name == "" {
name = account.ProductID
}
productQuotas = append(productQuotas, responses.SubordinateProductQuotaItem{
ProductID: account.ProductID,
ProductName: name,
AvailableQuota: account.AvailableQuota,
})
}
items = append(items, responses.SubordinateListItem{
ChildUserID: ln.ChildUserID,
Phone: phone,
LinkID: ln.ID,
Remark: ln.Remark,
RegisteredAt: registeredAt,
CompanyName: companyName,
IsCertified: isCertified,
Balance: balance,
ProductQuotas: productQuotas,
})
}
return &responses.SubordinateListResponse{Total: total, Items: items}, nil
}
// UpdateSubordinateRemark 更新下属备注
func (s *SubordinateApplicationServiceImpl) UpdateSubordinateRemark(ctx context.Context, cmd *commands.UpdateSubordinateRemarkCommand) error {
lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID)
if err != nil {
return err
}
if lnk == nil || lnk.Status != subentities.LinkStatusActive {
return fmt.Errorf("该用户不是您的有效下属")
}
remark := strings.TrimSpace(cmd.Remark)
if len([]rune(remark)) > 255 {
return fmt.Errorf("备注不能超过255个字符")
}
lnk.Remark = remark
return s.subRepo.UpdateLink(ctx, lnk)
}
// AllocateToChild 划款
func (s *SubordinateApplicationServiceImpl) AllocateToChild(ctx context.Context, cmd *commands.AllocateToChildCommand) error {
amount, err := decimal.NewFromString(strings.TrimSpace(cmd.Amount))
@@ -604,6 +697,57 @@ func (s *SubordinateApplicationServiceImpl) ListChildQuotaAccounts(ctx context.C
return items, nil
}
// ListChildApiCalls 主账号查看下属 API 调用记录
func (s *SubordinateApplicationServiceImpl) ListChildApiCalls(ctx context.Context, cmd *commands.ListChildApiCallsCommand) (*api_dto.ApiCallListResponse, error) {
if s.apiApp == nil {
return nil, fmt.Errorf("API 服务未初始化")
}
lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID)
if err != nil {
return nil, err
}
if lnk == nil || lnk.Status != subentities.LinkStatusActive {
return nil, fmt.Errorf("该用户不是您的有效下属")
}
page := cmd.Page
pageSize := cmd.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
filters := make(map[string]interface{})
if strings.TrimSpace(cmd.TransactionID) != "" {
filters["transaction_id"] = strings.TrimSpace(cmd.TransactionID)
}
if strings.TrimSpace(cmd.ProductName) != "" {
filters["product_name"] = strings.TrimSpace(cmd.ProductName)
}
if strings.TrimSpace(cmd.Status) != "" {
filters["status"] = strings.TrimSpace(cmd.Status)
}
if strings.TrimSpace(cmd.StartTime) != "" {
if t, parseErr := time.Parse("2006-01-02 15:04:05", strings.TrimSpace(cmd.StartTime)); parseErr == nil {
filters["start_time"] = t
}
}
if strings.TrimSpace(cmd.EndTime) != "" {
if t, parseErr := time.Parse("2006-01-02 15:04:05", strings.TrimSpace(cmd.EndTime)); parseErr == nil {
filters["end_time"] = t
}
}
return s.apiApp.GetUserApiCalls(ctx, cmd.ChildUserID, filters, shared_interfaces.ListOptions{
Page: page,
PageSize: pageSize,
Sort: "created_at",
Order: "desc",
})
}
// ListMyQuotaAccounts 查询当前用户额度账户(通用能力,适配所有用户)
func (s *SubordinateApplicationServiceImpl) ListMyQuotaAccounts(ctx context.Context, userID string) ([]responses.ChildQuotaAccountItem, error) {
accounts, err := s.subRepo.ListQuotaAccountsByUser(ctx, userID)

View File

@@ -21,6 +21,7 @@ type UserSubordinateLink struct {
ParentUserID string `gorm:"type:varchar(36);not null;index:idx_parent,priority:1" json:"parent_user_id" comment:"主账号用户ID"`
ChildUserID string `gorm:"type:varchar(36);not null;uniqueIndex" json:"child_user_id" comment:"子账号用户ID(唯一)"`
InvitationID *string `gorm:"type:varchar(36);index" json:"invitation_id,omitempty" comment:"关联的邀请ID"`
Remark string `gorm:"type:varchar(255)" json:"remark" comment:"主账号备注"`
Status LinkStatus `gorm:"type:varchar(20);not null;default:active" json:"status" comment:"状态"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`

View File

@@ -0,0 +1,8 @@
package repositories
// SubordinateListFilter 下属列表筛选
type SubordinateListFilter struct {
Remark string
Phone string
CompanyName string
}

View File

@@ -21,7 +21,8 @@ type SubordinateRepository interface {
CreateLink(ctx context.Context, link *entities.UserSubordinateLink) error
FindLinkByChildUserID(ctx context.Context, childUserID string) (*entities.UserSubordinateLink, error)
FindLinkByParentAndChild(ctx context.Context, parentUserID, childUserID string) (*entities.UserSubordinateLink, error)
ListChildrenByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.UserSubordinateLink, int64, error)
ListChildrenByParent(ctx context.Context, parentUserID string, filter SubordinateListFilter, limit, offset int) ([]*entities.UserSubordinateLink, int64, error)
ListQuotaAccountsByUserIDs(ctx context.Context, userIDs []string) ([]*entities.UserProductQuotaAccount, error)
UpdateLink(ctx context.Context, link *entities.UserSubordinateLink) error
// 是否存在子账号关系(任意子账号)
IsUserSubordinate(ctx context.Context, userID string) (bool, error)

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
@@ -152,15 +153,31 @@ func (r *GormSubordinateRepository) FindLinkByParentAndChild(ctx context.Context
return &l, nil
}
// ListChildrenByParent 列出下属
func (r *GormSubordinateRepository) ListChildrenByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.UserSubordinateLink, int64, error) {
// ListChildrenByParent 列出下属(支持备注/手机号/公司名模糊筛选)
func (r *GormSubordinateRepository) ListChildrenByParent(ctx context.Context, parentUserID string, filter repositories.SubordinateListFilter, limit, offset int) ([]*entities.UserSubordinateLink, int64, error) {
var list []entities.UserSubordinateLink
var total int64
q := r.withCtx(ctx).Model(&entities.UserSubordinateLink{}).Where("parent_user_id = ? AND status = ?", parentUserID, entities.LinkStatusActive)
q := r.withCtx(ctx).Model(&entities.UserSubordinateLink{}).
Where("user_subordinate_links.parent_user_id = ? AND user_subordinate_links.status = ?", parentUserID, entities.LinkStatusActive)
if strings.TrimSpace(filter.Remark) != "" {
q = q.Where("user_subordinate_links.remark LIKE ?", "%"+strings.TrimSpace(filter.Remark)+"%")
}
if strings.TrimSpace(filter.Phone) != "" || strings.TrimSpace(filter.CompanyName) != "" {
q = q.Joins("JOIN users ON users.id = user_subordinate_links.child_user_id AND users.deleted_at IS NULL")
if strings.TrimSpace(filter.Phone) != "" {
q = q.Where("users.phone LIKE ?", "%"+strings.TrimSpace(filter.Phone)+"%")
}
if strings.TrimSpace(filter.CompanyName) != "" {
q = q.Joins("LEFT JOIN enterprise_infos ON enterprise_infos.user_id = users.id AND enterprise_infos.deleted_at IS NULL").
Where("enterprise_infos.company_name LIKE ?", "%"+strings.TrimSpace(filter.CompanyName)+"%")
}
}
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil {
if err := q.Order("user_subordinate_links.created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil {
return nil, 0, err
}
out := make([]*entities.UserSubordinateLink, len(list))
@@ -185,6 +202,22 @@ func (r *GormSubordinateRepository) IsUserSubordinate(ctx context.Context, userI
return n > 0, nil
}
// ListQuotaAccountsByUserIDs 批量查询用户产品额度账户
func (r *GormSubordinateRepository) ListQuotaAccountsByUserIDs(ctx context.Context, userIDs []string) ([]*entities.UserProductQuotaAccount, error) {
if len(userIDs) == 0 {
return nil, nil
}
var list []entities.UserProductQuotaAccount
if err := r.withCtx(ctx).Where("user_id IN ?", userIDs).Order("user_id ASC, updated_at DESC").Find(&list).Error; err != nil {
return nil, err
}
out := make([]*entities.UserProductQuotaAccount, len(list))
for i := range list {
out[i] = &list[i]
}
return out, nil
}
// CreateWalletAllocation 记划拨
func (r *GormSubordinateRepository) CreateWalletAllocation(ctx context.Context, a *entities.SubordinateWalletAllocation) error {
// 幂等:同 business_ref 不重复

View File

@@ -76,7 +76,14 @@ func (h *SubordinateHandler) ListSubordinates(c *gin.Context) {
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
res, err := h.app.ListMySubordinates(c.Request.Context(), parentID, page, size)
res, err := h.app.ListMySubordinates(c.Request.Context(), &commands.ListSubordinatesCommand{
ParentUserID: parentID,
Page: page,
PageSize: size,
Remark: c.Query("remark"),
Phone: c.Query("phone"),
CompanyName: c.Query("company_name"),
})
if err != nil {
h.logger.Error("获取下属列表失败", zap.Error(err))
h.response.InternalError(c, "获取下属列表失败")
@@ -85,6 +92,26 @@ func (h *SubordinateHandler) ListSubordinates(c *gin.Context) {
h.response.Success(c, res, "获取成功")
}
// UpdateSubordinateRemark 更新下属备注
func (h *SubordinateHandler) UpdateSubordinateRemark(c *gin.Context) {
parentID := c.GetString("user_id")
if parentID == "" {
h.response.Unauthorized(c, "未登录")
return
}
var cmd commands.UpdateSubordinateRemarkCommand
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
cmd.ParentUserID = parentID
if err := h.app.UpdateSubordinateRemark(c.Request.Context(), &cmd); err != nil {
h.logger.Error("更新下属备注失败", zap.Error(err))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, nil, "备注已更新")
}
// Allocate 划款
func (h *SubordinateHandler) Allocate(c *gin.Context) {
parentID := c.GetString("user_id")
@@ -282,6 +309,39 @@ func (h *SubordinateHandler) ListChildQuotaAccounts(c *gin.Context) {
h.response.Success(c, res, "获取成功")
}
// ListChildApiCalls 下属 API 调用记录
func (h *SubordinateHandler) ListChildApiCalls(c *gin.Context) {
parentID := c.GetString("user_id")
if parentID == "" {
h.response.Unauthorized(c, "未登录")
return
}
childID := c.Query("child_user_id")
if childID == "" {
h.response.BadRequest(c, "child_user_id 不能为空")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
res, err := h.app.ListChildApiCalls(c.Request.Context(), &commands.ListChildApiCallsCommand{
ParentUserID: parentID,
ChildUserID: childID,
Page: page,
PageSize: size,
TransactionID: c.Query("transaction_id"),
ProductName: c.Query("product_name"),
Status: c.Query("status"),
StartTime: c.Query("start_time"),
EndTime: c.Query("end_time"),
})
if err != nil {
h.logger.Error("获取下属调用记录失败", zap.Error(err))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, res, "获取成功")
}
// ListMyQuotaAccounts 当前登录用户额度账户
func (h *SubordinateHandler) ListMyQuotaAccounts(c *gin.Context) {
userID := c.GetString("user_id")

View File

@@ -35,6 +35,7 @@ func (r *SubordinateRoutes) Register(router *sharedhttp.GinRouter) {
{
sub.POST("/invitations", r.handler.CreateInvitation)
sub.GET("/subordinates", r.handler.ListSubordinates)
sub.PATCH("/subordinates/remark", r.handler.UpdateSubordinateRemark)
sub.POST("/allocate", r.handler.Allocate)
sub.GET("/allocations", r.handler.ListAllocations)
sub.POST("/assign-subscription", r.handler.AssignSubscription)
@@ -43,6 +44,7 @@ func (r *SubordinateRoutes) Register(router *sharedhttp.GinRouter) {
sub.POST("/purchase-quota", r.handler.PurchaseQuota)
sub.GET("/quota-purchases", r.handler.ListQuotaPurchases)
sub.GET("/child-quotas", r.handler.ListChildQuotaAccounts)
sub.GET("/child-api-calls", r.handler.ListChildApiCalls)
sub.GET("/my-quotas", r.handler.ListMyQuotaAccounts)
}