This commit is contained in:
2026-04-25 19:17:19 +08:00
parent ba463ae38d
commit 18c92584d9
10 changed files with 533 additions and 0 deletions

View File

@@ -269,6 +269,9 @@ func (a *Application) autoMigrate(db *gorm.DB) error {
&subordinateEntities.SubordinateInvitation{},
&subordinateEntities.UserSubordinateLink{},
&subordinateEntities.SubordinateWalletAllocation{},
&subordinateEntities.SubordinateQuotaPurchase{},
&subordinateEntities.UserProductQuotaAccount{},
&subordinateEntities.UserProductQuotaLedger{},
// 任务域
&taskEntities.AsyncTask{},

View File

@@ -53,3 +53,26 @@ type RemoveChildSubscriptionCommand struct {
ChildUserID string `json:"child_user_id" binding:"required"`
SubscriptionID string `json:"subscription_id" binding:"required"`
}
// PurchaseChildQuotaCommand 主账号为子账号购买调用额度
type PurchaseChildQuotaCommand struct {
ParentUserID string
ChildUserID string `json:"child_user_id" binding:"required"`
ProductID string `json:"product_id" binding:"required"`
CallCount int64 `json:"call_count" binding:"required,min=1"`
VerifyCode string `json:"verify_code" binding:"required,len=6"`
}
// ListChildQuotaPurchasesCommand 下属额度购买记录查询
type ListChildQuotaPurchasesCommand struct {
ParentUserID string
ChildUserID string `json:"child_user_id" form:"child_user_id" binding:"required"`
Page int `json:"page" form:"page"`
PageSize int `json:"page_size" form:"page_size"`
}
// ListChildQuotaAccountsCommand 下属额度账户查询
type ListChildQuotaAccountsCommand struct {
ParentUserID string
ChildUserID string `json:"child_user_id" form:"child_user_id" binding:"required"`
}

View File

@@ -55,3 +55,28 @@ type ChildSubscriptionItem struct {
UIComponentPrice string `json:"ui_component_price"`
CreatedAt time.Time `json:"created_at"`
}
// ChildQuotaPurchaseItem 下属额度购买记录
type ChildQuotaPurchaseItem struct {
ID string `json:"id"`
ProductID string `json:"product_id"`
CallCount int64 `json:"call_count"`
UnitPrice string `json:"unit_price"`
TotalAmount string `json:"total_amount"`
BusinessRef string `json:"business_ref"`
CreatedAt time.Time `json:"created_at"`
}
// ChildQuotaPurchaseListResponse 下属额度购买记录列表
type ChildQuotaPurchaseListResponse struct {
Total int64 `json:"total"`
Items []ChildQuotaPurchaseItem `json:"items"`
}
// ChildQuotaAccountItem 下属产品额度账户
type ChildQuotaAccountItem struct {
ProductID string `json:"product_id"`
TotalQuota int64 `json:"total_quota"`
UsedQuota int64 `json:"used_quota"`
AvailableQuota int64 `json:"available_quota"`
}

View File

@@ -17,4 +17,8 @@ type SubordinateApplicationService interface {
AssignChildSubscription(ctx context.Context, cmd *commands.AssignChildSubscriptionCommand) error
ListChildSubscriptions(ctx context.Context, cmd *commands.ListChildSubscriptionsCommand) ([]responses.ChildSubscriptionItem, error)
RemoveChildSubscription(ctx context.Context, cmd *commands.RemoveChildSubscriptionCommand) error
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)
ListMyQuotaAccounts(ctx context.Context, userID string) ([]responses.ChildQuotaAccountItem, error)
}

View File

@@ -391,3 +391,218 @@ func (s *SubordinateApplicationServiceImpl) RemoveChildSubscription(ctx context.
}
return s.productSub.CancelSubscription(ctx, cmd.SubscriptionID)
}
// PurchaseChildQuota 主账号为子账号购买调用额度(按子账号订阅价结算)
func (s *SubordinateApplicationServiceImpl) PurchaseChildQuota(ctx context.Context, cmd *commands.PurchaseChildQuotaCommand) error {
if cmd.CallCount <= 0 {
return fmt.Errorf("购买次数必须大于0")
}
parentUser, err := s.userRepo.GetByID(ctx, cmd.ParentUserID)
if err != nil {
return fmt.Errorf("主账号信息获取失败")
}
if err := s.smsService.VerifyCode(ctx, parentUser.Phone, strings.TrimSpace(cmd.VerifyCode), user_entities.SMSSceneLogin); err != nil {
return fmt.Errorf("验证码错误或已过期")
}
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("该用户不是您的有效下属")
}
parentSub, err := s.productSub.GetUserSubscribedProduct(ctx, cmd.ParentUserID, cmd.ProductID)
if err != nil {
return err
}
if parentSub == nil {
return fmt.Errorf("主账号未订阅该产品,无法购买额度")
}
if !parentSub.Price.GreaterThan(decimal.Zero) {
return fmt.Errorf("主账号订阅价格异常,无法购买额度")
}
callCountDec := decimal.NewFromInt(cmd.CallCount)
totalAmount := parentSub.Price.Mul(callCountDec)
if !totalAmount.GreaterThan(decimal.Zero) {
return fmt.Errorf("购买金额必须大于0")
}
bizRef := uuid.New().String()
return s.txm.ExecuteInTx(ctx, func(txCtx context.Context) error {
// 购买额度前自动确保子账号存在该产品订阅,并统一为主账号订阅价
childSub, err := s.productSub.GetUserSubscribedProduct(txCtx, cmd.ChildUserID, cmd.ProductID)
if err != nil {
return err
}
if childSub == nil {
newSub := &productentities.Subscription{
UserID: cmd.ChildUserID,
ProductID: cmd.ProductID,
Price: parentSub.Price,
UIComponentPrice: parentSub.UIComponentPrice,
}
if err := s.productSub.SaveSubscription(txCtx, newSub); err != nil {
return fmt.Errorf("为下属创建订阅失败: %w", err)
}
} else {
childSub.Price = parentSub.Price
childSub.UIComponentPrice = parentSub.UIComponentPrice
if err := s.productSub.SaveSubscription(txCtx, childSub); err != nil {
return fmt.Errorf("更新下属订阅失败: %w", err)
}
}
ok, err := s.walletRepo.UpdateBalanceByUserID(txCtx, cmd.ParentUserID, totalAmount, "subtract")
if err != nil {
return err
}
if !ok {
return fmt.Errorf("主账号扣款失败,请重试")
}
account, err := s.subRepo.FindQuotaAccount(txCtx, cmd.ChildUserID, cmd.ProductID)
if err != nil {
return err
}
var beforeAvailable int64
if account == nil {
account = &subentities.UserProductQuotaAccount{
UserID: cmd.ChildUserID,
ProductID: cmd.ProductID,
TotalQuota: cmd.CallCount,
UsedQuota: 0,
AvailableQuota: cmd.CallCount,
}
beforeAvailable = 0
if err := s.subRepo.CreateQuotaAccount(txCtx, account); err != nil {
return err
}
} else {
beforeAvailable = account.AvailableQuota
account.TotalQuota += cmd.CallCount
account.AvailableQuota += cmd.CallCount
if err := s.subRepo.UpdateQuotaAccount(txCtx, account); err != nil {
return err
}
}
purchase := &subentities.SubordinateQuotaPurchase{
ParentUserID: cmd.ParentUserID,
ChildUserID: cmd.ChildUserID,
ProductID: cmd.ProductID,
CallCount: cmd.CallCount,
UnitPrice: parentSub.Price,
TotalAmount: totalAmount,
BusinessRef: bizRef,
OperatorUserID: cmd.ParentUserID,
}
if err := s.subRepo.CreateQuotaPurchase(txCtx, purchase); err != nil {
return err
}
ledger := &subentities.UserProductQuotaLedger{
UserID: cmd.ChildUserID,
ProductID: cmd.ProductID,
ChangeType: subentities.QuotaLedgerChangeTypePurchaseForSub,
DeltaQuota: cmd.CallCount,
BeforeQuota: beforeAvailable,
AfterQuota: beforeAvailable + cmd.CallCount,
SourceID: purchase.ID,
OperatorID: cmd.ParentUserID,
Remark: "主账号为子账号购买额度",
}
return s.subRepo.CreateQuotaLedger(txCtx, ledger)
})
}
// ListChildQuotaPurchases 下属额度购买记录
func (s *SubordinateApplicationServiceImpl) ListChildQuotaPurchases(ctx context.Context, cmd *commands.ListChildQuotaPurchasesCommand) (*responses.ChildQuotaPurchaseListResponse, error) {
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 = 20
}
offset := (page - 1) * pageSize
rows, total, err := s.subRepo.ListQuotaPurchasesByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID, pageSize, offset)
if err != nil {
return nil, err
}
items := make([]responses.ChildQuotaPurchaseItem, 0, len(rows))
for _, row := range rows {
items = append(items, responses.ChildQuotaPurchaseItem{
ID: row.ID,
ProductID: row.ProductID,
CallCount: row.CallCount,
UnitPrice: row.UnitPrice.StringFixed(2),
TotalAmount: row.TotalAmount.StringFixed(2),
BusinessRef: row.BusinessRef,
CreatedAt: row.CreatedAt,
})
}
return &responses.ChildQuotaPurchaseListResponse{
Total: total,
Items: items,
}, nil
}
// ListChildQuotaAccounts 下属额度账户
func (s *SubordinateApplicationServiceImpl) ListChildQuotaAccounts(ctx context.Context, cmd *commands.ListChildQuotaAccountsCommand) ([]responses.ChildQuotaAccountItem, error) {
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("该用户不是您的有效下属")
}
accounts, err := s.subRepo.ListQuotaAccountsByUser(ctx, cmd.ChildUserID)
if err != nil {
return nil, err
}
items := make([]responses.ChildQuotaAccountItem, 0, len(accounts))
for _, account := range accounts {
items = append(items, responses.ChildQuotaAccountItem{
ProductID: account.ProductID,
TotalQuota: account.TotalQuota,
UsedQuota: account.UsedQuota,
AvailableQuota: account.AvailableQuota,
})
}
return items, nil
}
// ListMyQuotaAccounts 查询当前用户额度账户(通用能力,适配所有用户)
func (s *SubordinateApplicationServiceImpl) ListMyQuotaAccounts(ctx context.Context, userID string) ([]responses.ChildQuotaAccountItem, error) {
accounts, err := s.subRepo.ListQuotaAccountsByUser(ctx, userID)
if err != nil {
return nil, err
}
items := make([]responses.ChildQuotaAccountItem, 0, len(accounts))
for _, account := range accounts {
items = append(items, responses.ChildQuotaAccountItem{
ProductID: account.ProductID,
TotalQuota: account.TotalQuota,
UsedQuota: account.UsedQuota,
AvailableQuota: account.AvailableQuota,
})
}
return items, nil
}

View File

@@ -0,0 +1,96 @@
package entities
import (
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
const (
// QuotaLedgerChangeTypePurchaseForSub 主账号为子账号购买额度
QuotaLedgerChangeTypePurchaseForSub = "purchase_for_sub"
)
// SubordinateQuotaPurchase 主账号为子账号购买额度记录
type SubordinateQuotaPurchase struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
ParentUserID string `gorm:"type:varchar(36);not null;index" json:"parent_user_id"`
ChildUserID string `gorm:"type:varchar(36);not null;index" json:"child_user_id"`
ProductID string `gorm:"type:varchar(36);not null;index" json:"product_id"`
CallCount int64 `gorm:"type:bigint;not null" json:"call_count"`
UnitPrice decimal.Decimal `gorm:"type:decimal(20,8);not null" json:"unit_price"`
TotalAmount decimal.Decimal `gorm:"type:decimal(20,8);not null" json:"total_amount"`
BusinessRef string `gorm:"type:varchar(64);not null;uniqueIndex" json:"business_ref"`
OperatorUserID string `gorm:"type:varchar(36);not null" json:"operator_user_id"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (SubordinateQuotaPurchase) TableName() string {
return "subordinate_quota_purchases"
}
func (q *SubordinateQuotaPurchase) BeforeCreate(tx *gorm.DB) error {
if q.ID == "" {
q.ID = uuid.New().String()
}
return nil
}
// UserProductQuotaAccount 用户产品额度账户(通用模型,适配所有用户)
type UserProductQuotaAccount struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"type:varchar(36);not null;index:idx_user_product,unique" json:"user_id"`
ProductID string `gorm:"type:varchar(36);not null;index:idx_user_product,unique" json:"product_id"`
TotalQuota int64 `gorm:"type:bigint;not null;default:0" json:"total_quota"`
UsedQuota int64 `gorm:"type:bigint;not null;default:0" json:"used_quota"`
AvailableQuota int64 `gorm:"type:bigint;not null;default:0" json:"available_quota"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (UserProductQuotaAccount) TableName() string {
return "user_product_quota_accounts"
}
func (a *UserProductQuotaAccount) BeforeCreate(tx *gorm.DB) error {
if a.ID == "" {
a.ID = uuid.New().String()
}
return nil
}
// UserProductQuotaLedger 用户产品额度流水(通用模型,适配所有用户)
type UserProductQuotaLedger struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"type:varchar(36);not null;index" json:"user_id"`
ProductID string `gorm:"type:varchar(36);not null;index" json:"product_id"`
ChangeType string `gorm:"type:varchar(50);not null;index" json:"change_type"`
DeltaQuota int64 `gorm:"type:bigint;not null" json:"delta_quota"`
BeforeQuota int64 `gorm:"type:bigint;not null" json:"before_quota"`
AfterQuota int64 `gorm:"type:bigint;not null" json:"after_quota"`
SourceID string `gorm:"type:varchar(36);index" json:"source_id"`
OperatorID string `gorm:"type:varchar(36);not null" json:"operator_id"`
Remark string `gorm:"type:varchar(255)" json:"remark"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (UserProductQuotaLedger) TableName() string {
return "user_product_quota_ledgers"
}
func (l *UserProductQuotaLedger) BeforeCreate(tx *gorm.DB) error {
if l.ID == "" {
l.ID = uuid.New().String()
}
return nil
}

View File

@@ -28,4 +28,15 @@ type SubordinateRepository interface {
// 划拨
CreateWalletAllocation(ctx context.Context, a *entities.SubordinateWalletAllocation) error
ListWalletAllocationsByParentAndChild(ctx context.Context, parentUserID, childUserID string, limit, offset int) ([]*entities.SubordinateWalletAllocation, int64, error)
// 额度购买
CreateQuotaPurchase(ctx context.Context, p *entities.SubordinateQuotaPurchase) error
ListQuotaPurchasesByParentAndChild(ctx context.Context, parentUserID, childUserID string, limit, offset int) ([]*entities.SubordinateQuotaPurchase, int64, error)
// 额度账户
FindQuotaAccount(ctx context.Context, userID, productID string) (*entities.UserProductQuotaAccount, error)
CreateQuotaAccount(ctx context.Context, account *entities.UserProductQuotaAccount) error
UpdateQuotaAccount(ctx context.Context, account *entities.UserProductQuotaAccount) error
ListQuotaAccountsByUser(ctx context.Context, userID string) ([]*entities.UserProductQuotaAccount, error)
CreateQuotaLedger(ctx context.Context, ledger *entities.UserProductQuotaLedger) error
}

View File

@@ -199,3 +199,67 @@ func (r *GormSubordinateRepository) ListWalletAllocationsByParentAndChild(ctx co
}
return out, total, nil
}
// CreateQuotaPurchase 创建额度购买记录
func (r *GormSubordinateRepository) CreateQuotaPurchase(ctx context.Context, p *entities.SubordinateQuotaPurchase) error {
return r.withCtx(ctx).Create(p).Error
}
// ListQuotaPurchasesByParentAndChild 查询主对子额度购买记录
func (r *GormSubordinateRepository) ListQuotaPurchasesByParentAndChild(ctx context.Context, parentUserID, childUserID string, limit, offset int) ([]*entities.SubordinateQuotaPurchase, int64, error) {
var list []entities.SubordinateQuotaPurchase
var total int64
q := r.withCtx(ctx).Model(&entities.SubordinateQuotaPurchase{}).Where("parent_user_id = ? AND child_user_id = ?", parentUserID, childUserID)
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 {
return nil, 0, err
}
out := make([]*entities.SubordinateQuotaPurchase, len(list))
for i := range list {
out[i] = &list[i]
}
return out, total, nil
}
// FindQuotaAccount 查询用户产品额度账户
func (r *GormSubordinateRepository) FindQuotaAccount(ctx context.Context, userID, productID string) (*entities.UserProductQuotaAccount, error) {
var account entities.UserProductQuotaAccount
err := r.withCtx(ctx).Where("user_id = ? AND product_id = ?", userID, productID).First(&account).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &account, nil
}
// CreateQuotaAccount 创建额度账户
func (r *GormSubordinateRepository) CreateQuotaAccount(ctx context.Context, account *entities.UserProductQuotaAccount) error {
return r.withCtx(ctx).Create(account).Error
}
// UpdateQuotaAccount 更新额度账户
func (r *GormSubordinateRepository) UpdateQuotaAccount(ctx context.Context, account *entities.UserProductQuotaAccount) error {
return r.withCtx(ctx).Save(account).Error
}
// ListQuotaAccountsByUser 查询用户全部额度账户
func (r *GormSubordinateRepository) ListQuotaAccountsByUser(ctx context.Context, userID string) ([]*entities.UserProductQuotaAccount, error) {
var list []entities.UserProductQuotaAccount
if err := r.withCtx(ctx).Where("user_id = ?", userID).Order("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
}
// CreateQuotaLedger 创建额度流水
func (r *GormSubordinateRepository) CreateQuotaLedger(ctx context.Context, ledger *entities.UserProductQuotaLedger) error {
return r.withCtx(ctx).Create(ledger).Error
}

View File

@@ -209,3 +209,91 @@ func (h *SubordinateHandler) RemoveChildSubscription(c *gin.Context) {
}
h.response.Success(c, nil, "删除成功")
}
// PurchaseQuota 为下属购买额度
func (h *SubordinateHandler) PurchaseQuota(c *gin.Context) {
parentID := c.GetString("user_id")
if parentID == "" {
h.response.Unauthorized(c, "未登录")
return
}
var cmd commands.PurchaseChildQuotaCommand
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
cmd.ParentUserID = parentID
if err := h.app.PurchaseChildQuota(c.Request.Context(), &cmd); err != nil {
h.logger.Error("为下属购买额度失败", zap.Error(err))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, nil, "购买额度成功")
}
// ListQuotaPurchases 下属额度购买记录
func (h *SubordinateHandler) ListQuotaPurchases(c *gin.Context) {
parentID := c.GetString("user_id")
if parentID == "" {
h.response.Unauthorized(c, "未登录")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
cmd := &commands.ListChildQuotaPurchasesCommand{
ParentUserID: parentID,
ChildUserID: c.Query("child_user_id"),
Page: page,
PageSize: size,
}
if cmd.ChildUserID == "" {
h.response.BadRequest(c, "child_user_id 不能为空")
return
}
res, err := h.app.ListChildQuotaPurchases(c.Request.Context(), cmd)
if err != nil {
h.logger.Error("获取额度购买记录失败", zap.Error(err))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, res, "获取成功")
}
// ListChildQuotaAccounts 下属额度账户
func (h *SubordinateHandler) ListChildQuotaAccounts(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
}
res, err := h.app.ListChildQuotaAccounts(c.Request.Context(), &commands.ListChildQuotaAccountsCommand{
ParentUserID: parentID,
ChildUserID: childID,
})
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")
if userID == "" {
h.response.Unauthorized(c, "未登录")
return
}
res, err := h.app.ListMyQuotaAccounts(c.Request.Context(), userID)
if err != nil {
h.logger.Error("获取我的额度账户失败", zap.Error(err))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, res, "获取成功")
}

View File

@@ -40,6 +40,10 @@ func (r *SubordinateRoutes) Register(router *sharedhttp.GinRouter) {
sub.POST("/assign-subscription", r.handler.AssignSubscription)
sub.GET("/child-subscriptions", r.handler.ListChildSubscriptions)
sub.DELETE("/child-subscriptions/:subscription_id", r.handler.RemoveChildSubscription)
sub.POST("/purchase-quota", r.handler.PurchaseQuota)
sub.GET("/quota-purchases", r.handler.ListQuotaPurchases)
sub.GET("/child-quotas", r.handler.ListChildQuotaAccounts)
sub.GET("/my-quotas", r.handler.ListMyQuotaAccounts)
}
r.logger.Info("下属账号路由注册完成")