feat: 实现代理钱包订单创建和订单角色追踪功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m0s

新增功能:
- 代理在后台使用 wallet 支付时,订单直接完成(扣款 + 激活套餐)
- 支持代理自购和代理代购场景
- 新增订单角色追踪字段(operator_id、operator_type、actual_paid_amount、purchase_role)
- 订单查询支持 OR 逻辑(buyer_id 或 operator_id)
- 钱包流水记录交易子类型和关联店铺
- 佣金逻辑调整:代理代购不产生佣金

数据库变更:
- 订单表新增 4 个字段和 2 个索引
- 钱包流水表新增 2 个字段
- 包含迁移脚本和回滚脚本

文档:
- 功能总结文档
- 部署指南
- OpenAPI 文档更新
- Specs 同步(新增 agent-order-role-tracking capability)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 14:11:42 +08:00
parent c5bf85c8de
commit 8ed3d9da93
24 changed files with 3346 additions and 52 deletions

View File

@@ -131,35 +131,37 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
}
userID := middleware.GetUserIDFromContext(ctx)
// 提取资源所属店铺ID
var resourceShopID *uint
var seriesID *uint
if validationResult.Card != nil {
resourceShopID = validationResult.Card.ShopID
seriesID = validationResult.Card.SeriesID
} else if validationResult.Device != nil {
resourceShopID = validationResult.Device.ShopID
seriesID = validationResult.Device.SeriesID
}
// 初始化订单字段
orderBuyerType := buyerType
orderBuyerID := buyerID
totalAmount := validationResult.TotalPrice
paymentMethod := req.PaymentMethod
paymentStatus := model.PaymentStatusPending
var paidAt *time.Time
now := time.Now()
isPurchaseOnBehalf := false
var seriesID *uint
var sellerShopID *uint
var operatorID *uint
operatorType := ""
var actualPaidAmount *int64
purchaseRole := ""
var sellerShopID *uint = resourceShopID
var sellerCostPrice int64
if validationResult.Card != nil {
seriesID = validationResult.Card.SeriesID
sellerShopID = validationResult.Card.ShopID
} else if validationResult.Device != nil {
seriesID = validationResult.Device.SeriesID
sellerShopID = validationResult.Device.ShopID
}
if sellerShopID != nil && len(validationResult.Packages) > 0 {
firstPackageID := validationResult.Packages[0].ID
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, *sellerShopID, firstPackageID)
if err == nil && allocation != nil {
sellerCostPrice = allocation.CostPrice
}
}
// 场景判断offline平台代购、wallet代理钱包支付、其他待支付
if req.PaymentMethod == model.PaymentMethodOffline {
// ==== 场景 1平台代购offline====
purchaseBuyerID, buyerCostPrice, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult)
if err != nil {
return nil, err
@@ -172,6 +174,82 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
paidAt = purchasePaidAt
isPurchaseOnBehalf = true
sellerCostPrice = buyerCostPrice
// 设置操作者信息(平台代购)
operatorID = nil
operatorType = constants.OwnerTypePlatform
purchaseRole = model.PurchaseRolePurchasedByPlatform
actualPaidAmount = nil
} else if req.PaymentMethod == model.PaymentMethodWallet {
// ==== 场景 2代理钱包支付wallet====
// 只有代理账号可以使用钱包支付
if buyerType != model.BuyerTypeAgent {
return nil, errors.New(errors.CodeInvalidParam, "只有代理账号可以使用钱包支付")
}
operatorShopID := buyerID
// 判断资源是否属于操作者
if resourceShopID == nil {
return nil, errors.New(errors.CodeInternalError, "资源店铺ID为空")
}
// 获取第一个套餐ID用于查询成本价
if len(validationResult.Packages) == 0 {
return nil, errors.New(errors.CodeInternalError, "套餐列表为空")
}
firstPackageID := validationResult.Packages[0].ID
if *resourceShopID == operatorShopID {
// ==== 子场景 2.1:代理自购 ====
costPrice, err := s.getCostPrice(ctx, operatorShopID, firstPackageID)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = operatorShopID
totalAmount = costPrice
paymentMethod = model.PaymentMethodWallet
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = false
operatorID = &operatorShopID
operatorType = "agent"
actualPaidAmountVal := costPrice
actualPaidAmount = &actualPaidAmountVal
purchaseRole = model.PurchaseRoleSelfPurchase
sellerCostPrice = costPrice
} else {
// ==== 子场景 2.2:代理代购(给下级购买)====
// 获取买家成本价
buyerCostPrice, err := s.getCostPrice(ctx, *resourceShopID, firstPackageID)
if err != nil {
return nil, err
}
// 获取操作者成本价
operatorCostPrice, err := s.getCostPrice(ctx, operatorShopID, firstPackageID)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = *resourceShopID
totalAmount = buyerCostPrice
paymentMethod = model.PaymentMethodWallet
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = true
operatorID = &operatorShopID
operatorType = "agent"
actualPaidAmount = &operatorCostPrice
purchaseRole = model.PurchaseRolePurchaseForSubordinate
sellerCostPrice = buyerCostPrice
}
}
order := &model.Order{
@@ -195,27 +273,48 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
SellerShopID: sellerShopID,
SellerCostPrice: sellerCostPrice,
IsPurchaseOnBehalf: isPurchaseOnBehalf,
OperatorID: operatorID,
OperatorType: operatorType,
ActualPaidAmount: actualPaidAmount,
PurchaseRole: purchaseRole,
}
items := s.buildOrderItems(userID, validationResult.Packages)
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
// 根据支付方式选择创建订单的方式
if req.PaymentMethod == model.PaymentMethodOffline {
// 平台代购:创建订单并立即激活套餐
if err := s.createOrderWithActivation(ctx, order, items); err != nil {
return nil, err
}
s.enqueueCommissionCalculation(ctx, order.ID)
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(order, items), nil
}
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, err
}
} else if req.PaymentMethod == model.PaymentMethodWallet {
// 钱包支付:创建订单、扣款、激活套餐(在事务中完成)
if operatorID == nil {
return nil, errors.New(errors.CodeInternalError, "钱包支付场景下 operatorID 不能为空")
}
operatorShopID := *operatorID
buyerShopID := orderBuyerID
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(order, items), nil
if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil {
return nil, err
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(order, items), nil
} else {
// 其他支付方式:创建待支付订单
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, err
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(order, items), nil
}
}
func (s *Service) resolvePurchaseOnBehalfInfo(ctx context.Context, result *purchase_validation.PurchaseValidationResult) (uint, int64, *time.Time, error) {
@@ -272,6 +371,184 @@ func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package) []
return items
}
// getCostPrice 查询店铺对套餐的成本价
// shopID: 店铺ID
// packageID: 套餐ID
// 返回成本价(分),如果查询失败返回错误
func (s *Service) getCostPrice(ctx context.Context, shopID uint, packageID uint) (int64, error) {
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeInvalidParam, "店铺没有该套餐的分配配置")
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐成本价失败")
}
return allocation.CostPrice, nil
}
// createWalletTransaction 创建钱包流水记录
// ctx: 上下文
// tx: 事务对象
// walletID: 钱包ID
// orderID: 订单ID
// amount: 扣款金额(正数)
// purchaseRole: 订单角色
// relatedShopID: 关联店铺ID代购场景填充下级店铺ID
func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, walletID uint, orderID uint, amount int64, purchaseRole string, relatedShopID *uint) error {
var subtype *string
remark := "购买套餐"
// 根据订单角色确定交易子类型和备注
switch purchaseRole {
case model.PurchaseRoleSelfPurchase:
subtypeVal := constants.WalletTransactionSubtypeSelfPurchase
subtype = &subtypeVal
case model.PurchaseRolePurchaseForSubordinate:
subtypeVal := constants.WalletTransactionSubtypePurchaseForSubordinate
subtype = &subtypeVal
// 查询下级店铺名称,填充到备注
if relatedShopID != nil {
var shop model.Shop
if err := tx.Where("id = ?", *relatedShopID).First(&shop).Error; err == nil {
remark = fmt.Sprintf("为下级代理【%s】购买套餐", shop.ShopName)
} else {
remark = "为下级代理购买套餐"
}
}
}
userID := middleware.GetUserIDFromContext(ctx)
// 创建钱包流水记录
transaction := &model.AgentWalletTransaction{
AgentWalletID: walletID,
ShopID: 0, // 将在下面从钱包记录获取
UserID: userID,
TransactionType: constants.AgentTransactionTypeDeduct,
TransactionSubtype: subtype,
Amount: -amount, // 扣款为负数
BalanceBefore: 0, // 将在下面填充
BalanceAfter: 0, // 将在下面填充
Status: constants.TransactionStatusSuccess,
ReferenceType: strPtr(constants.ReferenceTypeOrder),
ReferenceID: &orderID,
RelatedShopID: relatedShopID,
Remark: &remark,
Creator: userID,
ShopIDTag: 0, // 将在下面填充
EnterpriseIDTag: nil,
}
// 查询钱包记录,获取 shop_id 和余额信息
var wallet model.AgentWallet
if err := tx.Where("id = ?", walletID).First(&wallet).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包信息失败")
}
transaction.ShopID = wallet.ShopID
transaction.ShopIDTag = wallet.ShopIDTag
transaction.EnterpriseIDTag = wallet.EnterpriseIDTag
transaction.BalanceBefore = wallet.Balance
transaction.BalanceAfter = wallet.Balance - amount
if err := tx.Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包流水失败")
}
return nil
}
// strPtr 字符串指针辅助函数
func strPtr(s string) *string {
return &s
}
// createOrderWithWalletPayment 使用钱包支付创建订单并完成支付
// 包含余额检查、扣款、创建流水、激活套餐等操作,在事务中执行
// ctx: 上下文
// order: 订单对象
// items: 订单明细列表
// operatorShopID: 操作者店铺ID扣款的店铺
// buyerShopID: 买家店铺ID代购场景下级店铺ID
func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model.Order, items []*model.OrderItem, operatorShopID uint, buyerShopID uint) error {
if order.ActualPaidAmount == nil {
return errors.New(errors.CodeInternalError, "实际支付金额不能为空")
}
actualAmount := *order.ActualPaidAmount
// 1. 事务外:检查钱包余额(快速失败)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, operatorShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
if wallet.Balance < actualAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
// 2. 事务内:创建订单 + 扣款 + 创建流水 + 激活套餐
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 2.1 创建订单
if err := tx.Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
// 2.2 创建订单明细
for _, item := range items {
item.OrderID = order.ID
}
if err := tx.CreateInBatches(items, 100).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
}
// 2.3 扣减钱包余额(乐观锁)
result := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, actualAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", actualAmount),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减钱包余额失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
// 2.4 创建钱包流水
var relatedShopID *uint
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
relatedShopID = &buyerShopID
}
if err := s.createWalletTransaction(ctx, tx, wallet.ID, order.ID, actualAmount, order.PurchaseRole, relatedShopID); err != nil {
return err
}
// 2.5 激活套餐
if err := s.activatePackage(ctx, tx, order); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
// 3. 事务外:佣金计算(异步)
// 只有平台代购才入队佣金计算operator_id == nil
if order.OperatorID == nil {
s.enqueueCommissionCalculation(ctx, order.ID)
}
return nil
}
func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
@@ -329,6 +606,9 @@ func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType
if req.OrderNo != "" {
filters["order_no"] = req.OrderNo
}
if req.PurchaseRole != "" {
filters["purchase_role"] = req.PurchaseRole
}
if req.StartTime != nil {
filters["start_time"] = req.StartTime
}
@@ -1024,6 +1304,33 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
statusText = "已退款"
}
// 查询操作者名称
operatorName := ""
if order.OperatorType == "agent" && order.OperatorID != nil {
var shop model.Shop
if err := s.db.Where("id = ?", *order.OperatorID).First(&shop).Error; err == nil {
operatorName = shop.ShopName
}
}
// 生成派生字段
isPurchasedByParent := order.PurchaseRole == model.PurchaseRolePurchasedByParent
purchaseRemark := ""
switch order.PurchaseRole {
case model.PurchaseRolePurchasedByParent:
if operatorName != "" {
purchaseRemark = fmt.Sprintf("由上级代理【%s】购买", operatorName)
} else {
purchaseRemark = "由上级代理购买"
}
case model.PurchaseRolePurchasedByPlatform:
purchaseRemark = "由平台代购"
case model.PurchaseRolePurchaseForSubordinate:
if operatorName != "" {
purchaseRemark = fmt.Sprintf("由【%s】为下级购买", operatorName)
}
}
return &dto.OrderResponse{
ID: order.ID,
OrderNo: order.OrderNo,
@@ -1040,9 +1347,21 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
IsPurchaseOnBehalf: order.IsPurchaseOnBehalf,
CommissionStatus: order.CommissionStatus,
CommissionConfigVersion: order.CommissionConfigVersion,
Items: itemResponses,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
// 操作者信息
OperatorID: order.OperatorID,
OperatorType: order.OperatorType,
OperatorName: operatorName,
ActualPaidAmount: order.ActualPaidAmount,
// 订单角色
PurchaseRole: order.PurchaseRole,
IsPurchasedByParent: isPurchasedByParent,
PurchaseRemark: purchaseRemark,
Items: itemResponses,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
}
}