feat: 实现代理钱包订单创建和订单角色追踪功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m0s
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user