feat: 归档佣金计算触发和快照变更,同步规范文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m40s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m40s
- 归档 OpenSpec 变更到 archive 目录 - 创建 2 个新的主规范文件:commission-trigger 和 order-commission-snapshot - 实现订单佣金快照字段和支付自动触发 - 确保事务一致性,所有佣金操作在同一事务内完成 - 提取成本价计算为公共工具函数
This commit is contained in:
@@ -119,6 +119,6 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
|
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
|
||||||
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||||
PurchaseValidation: purchaseValidation,
|
PurchaseValidation: purchaseValidation,
|
||||||
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig),
|
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig, deps.QueueClient, deps.Logger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -82,26 +83,25 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, record := range costDiffRecords {
|
for _, record := range costDiffRecords {
|
||||||
if err := s.commissionRecordStore.Create(ctx, record); err != nil {
|
if err := tx.Create(record).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建成本价差佣金记录失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建成本价差佣金记录失败")
|
||||||
}
|
}
|
||||||
if err := s.CreditCommission(ctx, record); err != nil {
|
if err := s.creditCommissionInTx(ctx, tx, record); err != nil {
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "佣金入账失败")
|
return errors.Wrap(errors.CodeInternalError, err, "佣金入账失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||||
if err := s.TriggerOneTimeCommissionForCard(ctx, order, *order.IotCardID); err != nil {
|
if err := s.triggerOneTimeCommissionForCardInTx(ctx, tx, order, *order.IotCardID); err != nil {
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "触发单卡一次性佣金失败")
|
return errors.Wrap(errors.CodeInternalError, err, "触发单卡一次性佣金失败")
|
||||||
}
|
}
|
||||||
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||||
if err := s.TriggerOneTimeCommissionForDevice(ctx, order, *order.DeviceID); err != nil {
|
if err := s.triggerOneTimeCommissionForDeviceInTx(ctx, tx, order, *order.DeviceID); err != nil {
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "触发设备一次性佣金失败")
|
return errors.Wrap(errors.CodeInternalError, err, "触发设备一次性佣金失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
order.CommissionStatus = model.CommissionStatusCalculated
|
if err := tx.Model(&model.Order{}).Where("id = ?", orderID).Update("commission_status", model.CommissionStatusCalculated).Error; err != nil {
|
||||||
if err := s.orderStore.Update(ctx, order); err != nil {
|
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金状态失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金状态失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +116,11 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
|
|||||||
return records, nil
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if order.SeriesID == nil {
|
||||||
|
s.logger.Warn("订单缺少系列ID,跳过成本价差佣金计算", zap.Uint("order_id", order.ID))
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
sellerShop, err := s.shopStore.GetByID(ctx, *order.SellerShopID)
|
sellerShop, err := s.shopStore.GetByID(ctx, *order.SellerShopID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取销售店铺失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取销售店铺失败")
|
||||||
@@ -180,16 +185,10 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) calculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
|
func (s *Service) calculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
|
||||||
if allocation.BaseCommissionMode == model.CommissionModeFixed {
|
return utils.CalculateCostPrice(allocation, orderAmount)
|
||||||
return orderAmount - allocation.BaseCommissionValue
|
|
||||||
} else if allocation.BaseCommissionMode == model.CommissionModePercent {
|
|
||||||
commission := orderAmount * allocation.BaseCommissionValue / 1000
|
|
||||||
return orderAmount - commission
|
|
||||||
}
|
|
||||||
return orderAmount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *model.Order, cardID uint) error {
|
func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *gorm.DB, order *model.Order, cardID uint) error {
|
||||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "获取卡信息失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "获取卡信息失败")
|
||||||
@@ -253,26 +252,32 @@ func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *mo
|
|||||||
Remark: "一次性佣金",
|
Remark: "一次性佣金",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.commissionRecordStore.Create(ctx, record); err != nil {
|
if err := tx.Create(record).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.CreditCommission(ctx, record); err != nil {
|
if err := s.creditCommissionInTx(ctx, tx, record); err != nil {
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
|
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
card.FirstCommissionPaid = true
|
updates := map[string]any{"first_commission_paid": true}
|
||||||
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
|
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
|
||||||
card.AccumulatedRecharge = rechargeAmount
|
updates["accumulated_recharge"] = rechargeAmount
|
||||||
}
|
}
|
||||||
if err := s.iotCardStore.Update(ctx, card); err != nil {
|
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(updates).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) TriggerOneTimeCommissionForDevice(ctx context.Context, order *model.Order, deviceID uint) error {
|
func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *model.Order, cardID uint) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return s.triggerOneTimeCommissionForCardInTx(ctx, tx, order, cardID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx *gorm.DB, order *model.Order, deviceID uint) error {
|
||||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "获取设备信息失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "获取设备信息失败")
|
||||||
@@ -336,25 +341,31 @@ func (s *Service) TriggerOneTimeCommissionForDevice(ctx context.Context, order *
|
|||||||
Remark: "一次性佣金(设备)",
|
Remark: "一次性佣金(设备)",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.commissionRecordStore.Create(ctx, record); err != nil {
|
if err := tx.Create(record).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.CreditCommission(ctx, record); err != nil {
|
if err := s.creditCommissionInTx(ctx, tx, record); err != nil {
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
|
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
device.FirstCommissionPaid = true
|
updates := map[string]any{"first_commission_paid": true}
|
||||||
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
|
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
|
||||||
device.AccumulatedRecharge = rechargeAmount
|
updates["accumulated_recharge"] = rechargeAmount
|
||||||
}
|
}
|
||||||
if err := s.deviceStore.Update(ctx, device); err != nil {
|
if err := tx.Model(&model.Device{}).Where("id = ?", deviceID).Updates(updates).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备状态失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备状态失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) TriggerOneTimeCommissionForDevice(ctx context.Context, order *model.Order, deviceID uint) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return s.triggerOneTimeCommissionForDeviceInTx(ctx, tx, order, deviceID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) calculateOneTimeCommission(ctx context.Context, allocation *model.ShopSeriesAllocation, orderAmount int64) (int64, error) {
|
func (s *Service) calculateOneTimeCommission(ctx context.Context, allocation *model.ShopSeriesAllocation, orderAmount int64) (int64, error) {
|
||||||
switch allocation.OneTimeCommissionType {
|
switch allocation.OneTimeCommissionType {
|
||||||
case model.OneTimeCommissionTypeFixed:
|
case model.OneTimeCommissionTypeFixed:
|
||||||
@@ -425,22 +436,32 @@ func (s *Service) calculateTieredCommission(ctx context.Context, allocationID ui
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CreditCommission(ctx context.Context, record *model.CommissionRecord) error {
|
func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record *model.CommissionRecord) error {
|
||||||
wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, "shop", record.ShopID, "commission")
|
var wallet model.Wallet
|
||||||
if err != nil {
|
if err := tx.Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", "shop", record.ShopID, "commission").First(&wallet).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺钱包失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺钱包失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
wallet.Balance += record.Amount
|
balanceBefore := wallet.Balance
|
||||||
if err := s.db.WithContext(ctx).Model(wallet).Update("balance", wallet.Balance).Error; err != nil {
|
result := tx.Model(&model.Wallet{}).
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新钱包余额失败")
|
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"balance": gorm.Expr("balance + ?", record.Amount),
|
||||||
|
"version": gorm.Expr("version + 1"),
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新钱包余额失败")
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New(errors.CodeInternalError, "钱包版本冲突,请重试")
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
record.BalanceAfter = wallet.Balance
|
if err := tx.Model(record).Updates(map[string]any{
|
||||||
record.Status = model.CommissionStatusReleased
|
"balance_after": balanceBefore + record.Amount,
|
||||||
record.ReleasedAt = &now
|
"status": model.CommissionStatusReleased,
|
||||||
if err := s.db.WithContext(ctx).Model(record).Updates(record).Error; err != nil {
|
"released_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,21 +471,27 @@ func (s *Service) CreditCommission(ctx context.Context, record *model.Commission
|
|||||||
UserID: record.Creator,
|
UserID: record.Creator,
|
||||||
TransactionType: "commission",
|
TransactionType: "commission",
|
||||||
Amount: record.Amount,
|
Amount: record.Amount,
|
||||||
BalanceBefore: wallet.Balance - record.Amount,
|
BalanceBefore: balanceBefore,
|
||||||
BalanceAfter: wallet.Balance,
|
BalanceAfter: balanceBefore + record.Amount,
|
||||||
Status: 1,
|
Status: 1,
|
||||||
ReferenceType: stringPtr("commission"),
|
ReferenceType: stringPtr("commission"),
|
||||||
ReferenceID: &record.ID,
|
ReferenceID: &record.ID,
|
||||||
Remark: &remark,
|
Remark: &remark,
|
||||||
Creator: record.Creator,
|
Creator: record.Creator,
|
||||||
}
|
}
|
||||||
if err := s.walletTransactionStore.Create(ctx, transaction); err != nil {
|
if err := tx.Create(transaction).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreditCommission(ctx context.Context, record *model.CommissionRecord) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return s.creditCommissionInTx(ctx, tx, record)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func stringPtr(s string) *string {
|
func stringPtr(s string) *string {
|
||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +26,8 @@ type Service struct {
|
|||||||
walletStore *postgres.WalletStore
|
walletStore *postgres.WalletStore
|
||||||
purchaseValidationService *purchase_validation.Service
|
purchaseValidationService *purchase_validation.Service
|
||||||
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore
|
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore
|
||||||
|
queueClient *queue.Client
|
||||||
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
@@ -31,6 +37,8 @@ func New(
|
|||||||
walletStore *postgres.WalletStore,
|
walletStore *postgres.WalletStore,
|
||||||
purchaseValidationService *purchase_validation.Service,
|
purchaseValidationService *purchase_validation.Service,
|
||||||
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore,
|
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore,
|
||||||
|
queueClient *queue.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: db,
|
db: db,
|
||||||
@@ -39,6 +47,8 @@ func New(
|
|||||||
walletStore: walletStore,
|
walletStore: walletStore,
|
||||||
purchaseValidationService: purchaseValidationService,
|
purchaseValidationService: purchaseValidationService,
|
||||||
allocationConfigStore: allocationConfigStore,
|
allocationConfigStore: allocationConfigStore,
|
||||||
|
queueClient: queueClient,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +77,16 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
|||||||
userID := middleware.GetUserIDFromContext(ctx)
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID)
|
configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID)
|
||||||
|
|
||||||
|
var seriesID *uint
|
||||||
|
var sellerShopID *uint
|
||||||
|
var sellerCostPrice int64
|
||||||
|
|
||||||
|
if validationResult.Allocation != nil {
|
||||||
|
seriesID = &validationResult.Allocation.SeriesID
|
||||||
|
sellerShopID = &validationResult.Allocation.ShopID
|
||||||
|
sellerCostPrice = utils.CalculateCostPrice(validationResult.Allocation, validationResult.TotalPrice)
|
||||||
|
}
|
||||||
|
|
||||||
order := &model.Order{
|
order := &model.Order{
|
||||||
BaseModel: model.BaseModel{
|
BaseModel: model.BaseModel{
|
||||||
Creator: userID,
|
Creator: userID,
|
||||||
@@ -82,6 +102,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
|||||||
PaymentStatus: model.PaymentStatusPending,
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
CommissionStatus: model.CommissionStatusPending,
|
CommissionStatus: model.CommissionStatusPending,
|
||||||
CommissionConfigVersion: configVersion,
|
CommissionConfigVersion: configVersion,
|
||||||
|
SeriesID: seriesID,
|
||||||
|
SellerShopID: sellerShopID,
|
||||||
|
SellerCostPrice: sellerCostPrice,
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []*model.OrderItem
|
var items []*model.OrderItem
|
||||||
@@ -264,7 +287,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
result := tx.Model(&model.Wallet{}).
|
result := tx.Model(&model.Wallet{}).
|
||||||
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
|
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
@@ -288,6 +311,13 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
|||||||
|
|
||||||
return s.activatePackage(ctx, tx, order)
|
return s.activatePackage(ctx, tx, order)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.enqueueCommissionCalculation(ctx, orderID)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, paymentMethod string) error {
|
func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, paymentMethod string) error {
|
||||||
@@ -308,7 +338,7 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).Updates(map[string]any{
|
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).Updates(map[string]any{
|
||||||
"payment_status": model.PaymentStatusPaid,
|
"payment_status": model.PaymentStatusPaid,
|
||||||
"payment_method": paymentMethod,
|
"payment_method": paymentMethod,
|
||||||
@@ -319,6 +349,13 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
|
|||||||
|
|
||||||
return s.activatePackage(ctx, tx, order)
|
return s.activatePackage(ctx, tx, order)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.enqueueCommissionCalculation(ctx, order.ID)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {
|
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {
|
||||||
@@ -373,6 +410,37 @@ func (s *Service) snapshotCommissionConfig(ctx context.Context, allocationID uin
|
|||||||
return config.Version
|
return config.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint) {
|
||||||
|
if s.queueClient == nil {
|
||||||
|
s.logger.Warn("队列客户端未初始化,跳过佣金计算任务入队", zap.Uint("order_id", orderID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"order_id": orderID,
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadBytes, err := sonic.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("佣金计算任务载荷序列化失败",
|
||||||
|
zap.Uint("order_id", orderID),
|
||||||
|
zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeCommission, payloadBytes); err != nil {
|
||||||
|
s.logger.Error("佣金计算任务入队失败",
|
||||||
|
zap.Uint("order_id", orderID),
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("task_type", constants.TaskTypeCommission))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("佣金计算任务已入队",
|
||||||
|
zap.Uint("order_id", orderID),
|
||||||
|
zap.String("task_type", constants.TaskTypeCommission))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderItem) *dto.OrderResponse {
|
func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderItem) *dto.OrderResponse {
|
||||||
var itemResponses []*dto.OrderItemResponse
|
var itemResponses []*dto.OrderItemResponse
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ func setupOrderTestEnv(t *testing.T) *testEnv {
|
|||||||
require.NoError(t, tx.Create(wallet).Error)
|
require.NoError(t, tx.Create(wallet).Error)
|
||||||
|
|
||||||
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
|
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
|
||||||
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil)
|
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, nil)
|
||||||
|
|
||||||
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
|
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
|
||||||
UserID: 1,
|
UserID: 1,
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# 支付后自动触发佣金计算
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 支付成功后自动入队佣金计算任务
|
||||||
|
|
||||||
|
系统 SHALL 在订单首次支付成功时自动 enqueue 佣金计算异步任务(`commission:calculate`),确保佣金及时发放。
|
||||||
|
|
||||||
|
**触发条件**:
|
||||||
|
- 订单从"待支付"变为"已支付"(首次成功支付)
|
||||||
|
- 订单 `commission_status` 为 `pending`(未计算)
|
||||||
|
|
||||||
|
**任务参数**:
|
||||||
|
- 任务类型:`commission:calculate`
|
||||||
|
- Payload:`{"order_id": <订单ID>}`
|
||||||
|
|
||||||
|
#### Scenario: 首次支付成功触发计算
|
||||||
|
|
||||||
|
- **WHEN** 订单支付成功,订单状态从"待支付"变为"已支付"
|
||||||
|
- **THEN** 系统自动 enqueue `commission:calculate` 任务,payload 包含订单 ID
|
||||||
|
- **AND** 订单 `commission_status` 保持为 `pending`(任务执行后才更新为 `calculated`)
|
||||||
|
|
||||||
|
#### Scenario: 重复支付不重复触发
|
||||||
|
|
||||||
|
- **WHEN** 订单已经是"已支付"状态,再次收到支付成功通知(幂等场景)
|
||||||
|
- **THEN** 系统不重复 enqueue 佣金计算任务
|
||||||
|
- **AND** 日志记录"订单已支付,跳过重复入队"
|
||||||
|
|
||||||
|
#### Scenario: 已计算佣金的订单不触发
|
||||||
|
|
||||||
|
- **WHEN** 订单 `commission_status` 为 `calculated`(已计算)
|
||||||
|
- **THEN** 系统跳过入队操作
|
||||||
|
- **AND** 日志记录"订单佣金已计算,跳过入队"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 入队失败不影响支付主链路
|
||||||
|
|
||||||
|
系统 SHALL 确保佣金任务入队失败时不回滚订单支付成功状态,保障主业务链路稳定。
|
||||||
|
|
||||||
|
**失败处理策略**:
|
||||||
|
- 入队失败时记录 ERROR 级别日志(包含订单 ID、失败原因)
|
||||||
|
- 订单状态保持为"已支付",不回滚
|
||||||
|
- 订单 `commission_status` 保持为 `pending`,允许后续补偿
|
||||||
|
|
||||||
|
**补偿机制**:
|
||||||
|
- 后台补偿任务扫描 `commission_status=pending` 且已支付的订单
|
||||||
|
- 人工触发佣金计算(后台接口)
|
||||||
|
- 定时任务重试入队(可选)
|
||||||
|
|
||||||
|
#### Scenario: 入队失败记录日志
|
||||||
|
|
||||||
|
- **WHEN** 佣金任务入队失败(队列服务不可用或网络超时)
|
||||||
|
- **THEN** 系统记录 ERROR 日志,包含订单 ID、失败原因、队列配置信息
|
||||||
|
- **AND** 订单支付状态保持为"已支付",不回滚
|
||||||
|
|
||||||
|
#### Scenario: 失败后允许补偿
|
||||||
|
|
||||||
|
- **WHEN** 后台补偿任务扫描到 `commission_status=pending` 且 `payment_status=paid` 的订单
|
||||||
|
- **THEN** 系统可重新 enqueue 佣金计算任务或直接执行计算
|
||||||
|
- **AND** 避免佣金永久丢失
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 佣金计算任务幂等性
|
||||||
|
|
||||||
|
系统 SHALL 确保佣金计算任务可重复执行,不重复发放佣金。
|
||||||
|
|
||||||
|
**幂等检查**:
|
||||||
|
- 任务执行前检查订单 `commission_status`
|
||||||
|
- 如果已为 `calculated`,跳过计算并返回成功
|
||||||
|
|
||||||
|
**状态更新**:
|
||||||
|
- 计算完成后将订单 `commission_status` 更新为 `calculated`
|
||||||
|
- 状态更新与佣金记录创建在同一事务中
|
||||||
|
|
||||||
|
#### Scenario: 任务重复执行跳过计算
|
||||||
|
|
||||||
|
- **WHEN** 佣金计算任务执行时,订单 `commission_status` 已为 `calculated`
|
||||||
|
- **THEN** 系统跳过佣金计算和钱包入账操作
|
||||||
|
- **AND** 任务返回成功(避免 Asynq 重试)
|
||||||
|
- **AND** 日志记录"订单佣金已计算,跳过执行"
|
||||||
|
|
||||||
|
#### Scenario: 并发任务只有一个成功
|
||||||
|
|
||||||
|
- **WHEN** 同一订单的佣金计算任务被重复入队,两个 worker 并发执行
|
||||||
|
- **THEN** 第一个任务成功完成计算并更新状态为 `calculated`
|
||||||
|
- **AND** 第二个任务检查到状态已为 `calculated`,跳过计算
|
||||||
|
|
||||||
|
#### Scenario: 任务失败可安全重试
|
||||||
|
|
||||||
|
- **WHEN** 佣金计算任务执行失败(数据库异常、钱包服务不可用)
|
||||||
|
- **THEN** Asynq 自动重试任务
|
||||||
|
- **AND** 重试时幂等检查确保不重复发放佣金
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 队列客户端依赖注入
|
||||||
|
|
||||||
|
系统 SHALL 通过依赖注入方式将队列客户端注入到订单服务,遵循现有 bootstrap 架构。
|
||||||
|
|
||||||
|
**注入位置**:
|
||||||
|
- `internal/service/order/service.go` 的 `Service` 结构体
|
||||||
|
- 添加 `queueClient *asynq.Client` 字段
|
||||||
|
|
||||||
|
**注入方式**:
|
||||||
|
- 在 `internal/bootstrap/services.go` 中初始化订单服务时传入队列客户端
|
||||||
|
- 队列客户端在 `bootstrap.Bootstrap()` 中统一创建
|
||||||
|
|
||||||
|
#### Scenario: 订单服务接收队列客户端
|
||||||
|
|
||||||
|
- **WHEN** 系统启动时执行 `bootstrap.Bootstrap()`
|
||||||
|
- **THEN** 订单服务(`order.Service`)通过构造函数接收队列客户端实例
|
||||||
|
- **AND** 队列客户端可在服务内部调用 `Enqueue()` 方法
|
||||||
|
|
||||||
|
#### Scenario: 支付成功时调用队列客户端
|
||||||
|
|
||||||
|
- **WHEN** 订单支付成功,订单服务执行入队操作
|
||||||
|
- **THEN** 系统通过注入的队列客户端调用 `Enqueue("commission:calculate", payload)`
|
||||||
|
- **AND** 不在服务内部直接创建队列客户端(遵循依赖注入原则)
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 订单佣金快照字段
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 订单创建时填充佣金快照字段
|
||||||
|
|
||||||
|
系统 SHALL 在订单创建时填充佣金计算所需的关键字段快照,确保后续佣金计算不受配置变更影响。
|
||||||
|
|
||||||
|
**快照字段**:
|
||||||
|
- `series_id`:套餐系列 ID
|
||||||
|
- `seller_shop_id`:售卖/收益归属店铺 ID
|
||||||
|
- `seller_cost_price`:卖家成本价(用于成本价差佣金计算)
|
||||||
|
|
||||||
|
**字段来源**:基于购买校验结果(`PurchaseValidationResult`)
|
||||||
|
- `series_id` ← `allocation.SeriesID`
|
||||||
|
- `seller_shop_id` ← `allocation.ShopID`
|
||||||
|
- `seller_cost_price` ← 根据 allocation 的基础返佣规则从订单金额推导
|
||||||
|
|
||||||
|
#### Scenario: 订单创建时写入佣金快照
|
||||||
|
|
||||||
|
- **WHEN** 用户购买套餐,订单创建成功,购买校验返回 `allocation` 数据
|
||||||
|
- **THEN** 订单表中 `series_id`、`seller_shop_id`、`seller_cost_price` 字段已正确填充
|
||||||
|
- **AND** 字段值来源于购买校验结果,而非订单提交参数
|
||||||
|
|
||||||
|
#### Scenario: 缺少 allocation 数据时的处理
|
||||||
|
|
||||||
|
- **WHEN** 订单创建时购买校验结果中缺少 `allocation` 数据
|
||||||
|
- **THEN** 系统记录警告日志,订单佣金快照字段保持 NULL 或默认值
|
||||||
|
- **AND** 订单 `commission_status` 标记为 `pending`(待计算),允许后续补偿
|
||||||
|
|
||||||
|
#### Scenario: 后续佣金计算使用快照字段
|
||||||
|
|
||||||
|
- **WHEN** 佣金计算任务执行时读取订单数据
|
||||||
|
- **THEN** 系统使用订单表中的快照字段(`series_id`、`seller_shop_id`、`seller_cost_price`)
|
||||||
|
- **AND** 不再实时查询套餐配置或返佣规则,避免配置变更影响历史订单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 成本价推导方法复用
|
||||||
|
|
||||||
|
系统 SHALL 提供统一的成本价推导方法,确保订单创建和佣金计算使用相同的计算口径。
|
||||||
|
|
||||||
|
**方法职责**:
|
||||||
|
- 输入:订单金额、allocation 数据(包含返佣规则)
|
||||||
|
- 输出:卖家成本价(seller_cost_price)
|
||||||
|
- 逻辑:与"成本价差佣金"计算保持一致
|
||||||
|
|
||||||
|
#### Scenario: 订单创建时调用成本价推导
|
||||||
|
|
||||||
|
- **WHEN** 订单创建服务填充 `seller_cost_price` 字段
|
||||||
|
- **THEN** 系统调用统一的成本价推导方法,基于订单金额和 allocation 数据计算
|
||||||
|
- **AND** 推导结果写入订单表 `seller_cost_price` 字段
|
||||||
|
|
||||||
|
#### Scenario: 佣金计算时复用相同逻辑
|
||||||
|
|
||||||
|
- **WHEN** 佣金计算服务执行成本价差计算
|
||||||
|
- **THEN** 系统使用订单快照中的 `seller_cost_price`(已在创建时推导)
|
||||||
|
- **AND** 避免重复推导,确保计算口径一致
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 佣金计算链路修复 - 实现任务
|
||||||
|
|
||||||
|
## 1. 订单佣金字段快照
|
||||||
|
|
||||||
|
- [x] 1.1 在 `internal/service/order/service.go` 创建订单时,从购买校验结果填充 `SeriesID/SellerShopID/SellerCostPrice`
|
||||||
|
- [x] 1.2 补充/复用"成本价推导"工具方法,确保口径与佣金计算一致
|
||||||
|
|
||||||
|
## 2. 支付成功后自动入队
|
||||||
|
|
||||||
|
- [x] 2.1 在首次支付成功路径 enqueue `commission:calculate`(payload: order_id)
|
||||||
|
- [x] 2.2 注入队列客户端到订单服务(遵循现有 bootstrap 依赖注入方式)
|
||||||
|
- [x] 2.3 明确入队失败策略:记录日志,订单保持 `commission_status=pending` 可重试
|
||||||
|
|
||||||
|
## 3. 佣金计算一致性与健壮性(可选但推荐)
|
||||||
|
|
||||||
|
- [x] 3.1 调整 `internal/service/commission_calculation/service.go`,确保事务内对佣金记录/钱包/订单状态更新使用同一 `tx`
|
||||||
|
- [x] 3.2 增加必要的空值保护:缺少关键字段时返回业务错误而非 panic
|
||||||
|
|
||||||
|
## 4. 测试与验证
|
||||||
|
|
||||||
|
- [x] 4.1 新增单元测试:订单创建后佣金快照字段写入正确
|
||||||
|
- [x] 4.2 新增单元/集成测试:支付成功后会 enqueue 佣金计算任务(可通过可注入的队列 client 验证)
|
||||||
|
- [x] 4.3 运行 `go test ./...` 确保通过
|
||||||
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# 佣金计算链路修复 - 实现任务
|
|
||||||
|
|
||||||
## 1. 订单佣金字段快照
|
|
||||||
|
|
||||||
- [ ] 1.1 在 `internal/service/order/service.go` 创建订单时,从购买校验结果填充 `SeriesID/SellerShopID/SellerCostPrice`
|
|
||||||
- [ ] 1.2 补充/复用“成本价推导”工具方法,确保口径与佣金计算一致
|
|
||||||
|
|
||||||
## 2. 支付成功后自动入队
|
|
||||||
|
|
||||||
- [ ] 2.1 在首次支付成功路径 enqueue `commission:calculate`(payload: order_id)
|
|
||||||
- [ ] 2.2 注入队列客户端到订单服务(遵循现有 bootstrap 依赖注入方式)
|
|
||||||
- [ ] 2.3 明确入队失败策略:记录日志,订单保持 `commission_status=pending` 可重试
|
|
||||||
|
|
||||||
## 3. 佣金计算一致性与健壮性(可选但推荐)
|
|
||||||
|
|
||||||
- [ ] 3.1 调整 `internal/service/commission_calculation/service.go`,确保事务内对佣金记录/钱包/订单状态更新使用同一 `tx`
|
|
||||||
- [ ] 3.2 增加必要的空值保护:缺少关键字段时返回业务错误而非 panic
|
|
||||||
|
|
||||||
## 4. 测试与验证
|
|
||||||
|
|
||||||
- [ ] 4.1 新增单元测试:订单创建后佣金快照字段写入正确
|
|
||||||
- [ ] 4.2 新增单元/集成测试:支付成功后会 enqueue 佣金计算任务(可通过可注入的队列 client 验证)
|
|
||||||
- [ ] 4.3 运行 `go test ./...` 确保通过
|
|
||||||
|
|
||||||
124
openspec/specs/commission-trigger/spec.md
Normal file
124
openspec/specs/commission-trigger/spec.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# commission-trigger Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TBD - created by archiving change fix-commission-calculation-trigger-and-snapshot. Update Purpose after archive.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: 支付成功后自动入队佣金计算任务
|
||||||
|
|
||||||
|
系统 SHALL 在订单首次支付成功时自动 enqueue 佣金计算异步任务(`commission:calculate`),确保佣金及时发放。
|
||||||
|
|
||||||
|
**触发条件**:
|
||||||
|
- 订单从"待支付"变为"已支付"(首次成功支付)
|
||||||
|
- 订单 `commission_status` 为 `pending`(未计算)
|
||||||
|
|
||||||
|
**任务参数**:
|
||||||
|
- 任务类型:`commission:calculate`
|
||||||
|
- Payload:`{"order_id": <订单ID>}`
|
||||||
|
|
||||||
|
#### Scenario: 首次支付成功触发计算
|
||||||
|
|
||||||
|
- **WHEN** 订单支付成功,订单状态从"待支付"变为"已支付"
|
||||||
|
- **THEN** 系统自动 enqueue `commission:calculate` 任务,payload 包含订单 ID
|
||||||
|
- **AND** 订单 `commission_status` 保持为 `pending`(任务执行后才更新为 `calculated`)
|
||||||
|
|
||||||
|
#### Scenario: 重复支付不重复触发
|
||||||
|
|
||||||
|
- **WHEN** 订单已经是"已支付"状态,再次收到支付成功通知(幂等场景)
|
||||||
|
- **THEN** 系统不重复 enqueue 佣金计算任务
|
||||||
|
- **AND** 日志记录"订单已支付,跳过重复入队"
|
||||||
|
|
||||||
|
#### Scenario: 已计算佣金的订单不触发
|
||||||
|
|
||||||
|
- **WHEN** 订单 `commission_status` 为 `calculated`(已计算)
|
||||||
|
- **THEN** 系统跳过入队操作
|
||||||
|
- **AND** 日志记录"订单佣金已计算,跳过入队"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 入队失败不影响支付主链路
|
||||||
|
|
||||||
|
系统 SHALL 确保佣金任务入队失败时不回滚订单支付成功状态,保障主业务链路稳定。
|
||||||
|
|
||||||
|
**失败处理策略**:
|
||||||
|
- 入队失败时记录 ERROR 级别日志(包含订单 ID、失败原因)
|
||||||
|
- 订单状态保持为"已支付",不回滚
|
||||||
|
- 订单 `commission_status` 保持为 `pending`,允许后续补偿
|
||||||
|
|
||||||
|
**补偿机制**:
|
||||||
|
- 后台补偿任务扫描 `commission_status=pending` 且已支付的订单
|
||||||
|
- 人工触发佣金计算(后台接口)
|
||||||
|
- 定时任务重试入队(可选)
|
||||||
|
|
||||||
|
#### Scenario: 入队失败记录日志
|
||||||
|
|
||||||
|
- **WHEN** 佣金任务入队失败(队列服务不可用或网络超时)
|
||||||
|
- **THEN** 系统记录 ERROR 日志,包含订单 ID、失败原因、队列配置信息
|
||||||
|
- **AND** 订单支付状态保持为"已支付",不回滚
|
||||||
|
|
||||||
|
#### Scenario: 失败后允许补偿
|
||||||
|
|
||||||
|
- **WHEN** 后台补偿任务扫描到 `commission_status=pending` 且 `payment_status=paid` 的订单
|
||||||
|
- **THEN** 系统可重新 enqueue 佣金计算任务或直接执行计算
|
||||||
|
- **AND** 避免佣金永久丢失
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 佣金计算任务幂等性
|
||||||
|
|
||||||
|
系统 SHALL 确保佣金计算任务可重复执行,不重复发放佣金。
|
||||||
|
|
||||||
|
**幂等检查**:
|
||||||
|
- 任务执行前检查订单 `commission_status`
|
||||||
|
- 如果已为 `calculated`,跳过计算并返回成功
|
||||||
|
|
||||||
|
**状态更新**:
|
||||||
|
- 计算完成后将订单 `commission_status` 更新为 `calculated`
|
||||||
|
- 状态更新与佣金记录创建在同一事务中
|
||||||
|
|
||||||
|
#### Scenario: 任务重复执行跳过计算
|
||||||
|
|
||||||
|
- **WHEN** 佣金计算任务执行时,订单 `commission_status` 已为 `calculated`
|
||||||
|
- **THEN** 系统跳过佣金计算和钱包入账操作
|
||||||
|
- **AND** 任务返回成功(避免 Asynq 重试)
|
||||||
|
- **AND** 日志记录"订单佣金已计算,跳过执行"
|
||||||
|
|
||||||
|
#### Scenario: 并发任务只有一个成功
|
||||||
|
|
||||||
|
- **WHEN** 同一订单的佣金计算任务被重复入队,两个 worker 并发执行
|
||||||
|
- **THEN** 第一个任务成功完成计算并更新状态为 `calculated`
|
||||||
|
- **AND** 第二个任务检查到状态已为 `calculated`,跳过计算
|
||||||
|
|
||||||
|
#### Scenario: 任务失败可安全重试
|
||||||
|
|
||||||
|
- **WHEN** 佣金计算任务执行失败(数据库异常、钱包服务不可用)
|
||||||
|
- **THEN** Asynq 自动重试任务
|
||||||
|
- **AND** 重试时幂等检查确保不重复发放佣金
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 队列客户端依赖注入
|
||||||
|
|
||||||
|
系统 SHALL 通过依赖注入方式将队列客户端注入到订单服务,遵循现有 bootstrap 架构。
|
||||||
|
|
||||||
|
**注入位置**:
|
||||||
|
- `internal/service/order/service.go` 的 `Service` 结构体
|
||||||
|
- 添加 `queueClient *asynq.Client` 字段
|
||||||
|
|
||||||
|
**注入方式**:
|
||||||
|
- 在 `internal/bootstrap/services.go` 中初始化订单服务时传入队列客户端
|
||||||
|
- 队列客户端在 `bootstrap.Bootstrap()` 中统一创建
|
||||||
|
|
||||||
|
#### Scenario: 订单服务接收队列客户端
|
||||||
|
|
||||||
|
- **WHEN** 系统启动时执行 `bootstrap.Bootstrap()`
|
||||||
|
- **THEN** 订单服务(`order.Service`)通过构造函数接收队列客户端实例
|
||||||
|
- **AND** 队列客户端可在服务内部调用 `Enqueue()` 方法
|
||||||
|
|
||||||
|
#### Scenario: 支付成功时调用队列客户端
|
||||||
|
|
||||||
|
- **WHEN** 订单支付成功,订单服务执行入队操作
|
||||||
|
- **THEN** 系统通过注入的队列客户端调用 `Enqueue("commission:calculate", payload)`
|
||||||
|
- **AND** 不在服务内部直接创建队列客户端(遵循依赖注入原则)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
62
openspec/specs/order-commission-snapshot/spec.md
Normal file
62
openspec/specs/order-commission-snapshot/spec.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# order-commission-snapshot Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TBD - created by archiving change fix-commission-calculation-trigger-and-snapshot. Update Purpose after archive.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: 订单创建时填充佣金快照字段
|
||||||
|
|
||||||
|
系统 SHALL 在订单创建时填充佣金计算所需的关键字段快照,确保后续佣金计算不受配置变更影响。
|
||||||
|
|
||||||
|
**快照字段**:
|
||||||
|
- `series_id`:套餐系列 ID
|
||||||
|
- `seller_shop_id`:售卖/收益归属店铺 ID
|
||||||
|
- `seller_cost_price`:卖家成本价(用于成本价差佣金计算)
|
||||||
|
|
||||||
|
**字段来源**:基于购买校验结果(`PurchaseValidationResult`)
|
||||||
|
- `series_id` ← `allocation.SeriesID`
|
||||||
|
- `seller_shop_id` ← `allocation.ShopID`
|
||||||
|
- `seller_cost_price` ← 根据 allocation 的基础返佣规则从订单金额推导
|
||||||
|
|
||||||
|
#### Scenario: 订单创建时写入佣金快照
|
||||||
|
|
||||||
|
- **WHEN** 用户购买套餐,订单创建成功,购买校验返回 `allocation` 数据
|
||||||
|
- **THEN** 订单表中 `series_id`、`seller_shop_id`、`seller_cost_price` 字段已正确填充
|
||||||
|
- **AND** 字段值来源于购买校验结果,而非订单提交参数
|
||||||
|
|
||||||
|
#### Scenario: 缺少 allocation 数据时的处理
|
||||||
|
|
||||||
|
- **WHEN** 订单创建时购买校验结果中缺少 `allocation` 数据
|
||||||
|
- **THEN** 系统记录警告日志,订单佣金快照字段保持 NULL 或默认值
|
||||||
|
- **AND** 订单 `commission_status` 标记为 `pending`(待计算),允许后续补偿
|
||||||
|
|
||||||
|
#### Scenario: 后续佣金计算使用快照字段
|
||||||
|
|
||||||
|
- **WHEN** 佣金计算任务执行时读取订单数据
|
||||||
|
- **THEN** 系统使用订单表中的快照字段(`series_id`、`seller_shop_id`、`seller_cost_price`)
|
||||||
|
- **AND** 不再实时查询套餐配置或返佣规则,避免配置变更影响历史订单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 成本价推导方法复用
|
||||||
|
|
||||||
|
系统 SHALL 提供统一的成本价推导方法,确保订单创建和佣金计算使用相同的计算口径。
|
||||||
|
|
||||||
|
**方法职责**:
|
||||||
|
- 输入:订单金额、allocation 数据(包含返佣规则)
|
||||||
|
- 输出:卖家成本价(seller_cost_price)
|
||||||
|
- 逻辑:与"成本价差佣金"计算保持一致
|
||||||
|
|
||||||
|
#### Scenario: 订单创建时调用成本价推导
|
||||||
|
|
||||||
|
- **WHEN** 订单创建服务填充 `seller_cost_price` 字段
|
||||||
|
- **THEN** 系统调用统一的成本价推导方法,基于订单金额和 allocation 数据计算
|
||||||
|
- **AND** 推导结果写入订单表 `seller_cost_price` 字段
|
||||||
|
|
||||||
|
#### Scenario: 佣金计算时复用相同逻辑
|
||||||
|
|
||||||
|
- **WHEN** 佣金计算服务执行成本价差计算
|
||||||
|
- **THEN** 系统使用订单快照中的 `seller_cost_price`(已在创建时推导)
|
||||||
|
- **AND** 避免重复推导,确保计算口径一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
13
pkg/utils/commission.go
Normal file
13
pkg/utils/commission.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
|
||||||
|
func CalculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
|
||||||
|
if allocation.BaseCommissionMode == model.CommissionModeFixed {
|
||||||
|
return orderAmount - allocation.BaseCommissionValue
|
||||||
|
} else if allocation.BaseCommissionMode == model.CommissionModePercent {
|
||||||
|
commission := orderAmount * allocation.BaseCommissionValue / 1000
|
||||||
|
return orderAmount - commission
|
||||||
|
}
|
||||||
|
return orderAmount
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user