From d977000a66c47752129c9ec1d8fee0b54eb92086 Mon Sep 17 00:00:00 2001 From: huang Date: Thu, 29 Jan 2026 14:58:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BD=92=E6=A1=A3=E4=BD=A3=E9=87=91?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E8=A7=A6=E5=8F=91=E5=92=8C=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=EF=BC=8C=E5=90=8C=E6=AD=A5=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 归档 OpenSpec 变更到 archive 目录 - 创建 2 个新的主规范文件:commission-trigger 和 order-commission-snapshot - 实现订单佣金快照字段和支付自动触发 - 确保事务一致性,所有佣金操作在同一事务内完成 - 提取成本价计算为公共工具函数 --- internal/bootstrap/services.go | 2 +- .../service/commission_calculation/service.go | 103 +++++++++------ internal/service/order/service.go | 72 +++++++++- internal/service/order/service_test.go | 2 +- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/commission-trigger/spec.md | 122 +++++++++++++++++ .../specs/order-commission-snapshot/spec.md | 60 +++++++++ .../tasks.md | 24 ++++ .../tasks.md | 24 ---- openspec/specs/commission-trigger/spec.md | 124 ++++++++++++++++++ .../specs/order-commission-snapshot/spec.md | 62 +++++++++ pkg/utils/commission.go | 13 ++ 14 files changed, 542 insertions(+), 66 deletions(-) rename openspec/changes/{fix-commission-calculation-trigger-and-snapshot => archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot}/.openspec.yaml (100%) rename openspec/changes/{fix-commission-calculation-trigger-and-snapshot => archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot}/design.md (100%) rename openspec/changes/{fix-commission-calculation-trigger-and-snapshot => archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot}/proposal.md (100%) create mode 100644 openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/specs/commission-trigger/spec.md create mode 100644 openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/specs/order-commission-snapshot/spec.md create mode 100644 openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/tasks.md delete mode 100644 openspec/changes/fix-commission-calculation-trigger-and-snapshot/tasks.md create mode 100644 openspec/specs/commission-trigger/spec.md create mode 100644 openspec/specs/order-commission-snapshot/spec.md create mode 100644 pkg/utils/commission.go diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index ce0c136..81ed6e1 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -119,6 +119,6 @@ func initServices(s *stores, deps *Dependencies) *services { ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop), CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), 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), } } diff --git a/internal/service/commission_calculation/service.go b/internal/service/commission_calculation/service.go index 274eaf4..70fcc5d 100644 --- a/internal/service/commission_calculation/service.go +++ b/internal/service/commission_calculation/service.go @@ -9,6 +9,7 @@ import ( "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/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/utils" "go.uber.org/zap" "gorm.io/gorm" ) @@ -82,26 +83,25 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error { } 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, "创建成本价差佣金记录失败") } - if err := s.CreditCommission(ctx, record); err != nil { + if err := s.creditCommissionInTx(ctx, tx, record); err != nil { return errors.Wrap(errors.CodeInternalError, err, "佣金入账失败") } } 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, "触发单卡一次性佣金失败") } } 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, "触发设备一次性佣金失败") } } - order.CommissionStatus = model.CommissionStatusCalculated - if err := s.orderStore.Update(ctx, order); err != nil { + if err := tx.Model(&model.Order{}).Where("id = ?", orderID).Update("commission_status", model.CommissionStatusCalculated).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金状态失败") } @@ -116,6 +116,11 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model. 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) if err != nil { 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 { - 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 + return utils.CalculateCostPrice(allocation, 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) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "获取卡信息失败") @@ -253,26 +252,32 @@ func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *mo Remark: "一次性佣金", } - if err := s.commissionRecordStore.Create(ctx, record); err != nil { + if err := tx.Create(record).Error; err != nil { 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, "一次性佣金入账失败") } - card.FirstCommissionPaid = true + updates := map[string]any{"first_commission_paid": true} 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 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) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "获取设备信息失败") @@ -336,25 +341,31 @@ func (s *Service) TriggerOneTimeCommissionForDevice(ctx context.Context, order * Remark: "一次性佣金(设备)", } - if err := s.commissionRecordStore.Create(ctx, record); err != nil { + if err := tx.Create(record).Error; err != nil { 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, "一次性佣金入账失败") } - device.FirstCommissionPaid = true + updates := map[string]any{"first_commission_paid": true} 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 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) { switch allocation.OneTimeCommissionType { case model.OneTimeCommissionTypeFixed: @@ -425,22 +436,32 @@ func (s *Service) calculateTieredCommission(ctx context.Context, allocationID ui return 0, nil } -func (s *Service) CreditCommission(ctx context.Context, record *model.CommissionRecord) error { - wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, "shop", record.ShopID, "commission") - if err != nil { +func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record *model.CommissionRecord) error { + var wallet model.Wallet + 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, "获取店铺钱包失败") } - wallet.Balance += record.Amount - if err := s.db.WithContext(ctx).Model(wallet).Update("balance", wallet.Balance).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新钱包余额失败") + balanceBefore := wallet.Balance + result := tx.Model(&model.Wallet{}). + 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() - record.BalanceAfter = wallet.Balance - record.Status = model.CommissionStatusReleased - record.ReleasedAt = &now - if err := s.db.WithContext(ctx).Model(record).Updates(record).Error; err != nil { + if err := tx.Model(record).Updates(map[string]any{ + "balance_after": balanceBefore + record.Amount, + "status": model.CommissionStatusReleased, + "released_at": now, + }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败") } @@ -450,21 +471,27 @@ func (s *Service) CreditCommission(ctx context.Context, record *model.Commission UserID: record.Creator, TransactionType: "commission", Amount: record.Amount, - BalanceBefore: wallet.Balance - record.Amount, - BalanceAfter: wallet.Balance, + BalanceBefore: balanceBefore, + BalanceAfter: balanceBefore + record.Amount, Status: 1, ReferenceType: stringPtr("commission"), ReferenceID: &record.ID, Remark: &remark, 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 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 { return &s } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 3878ed0..9716f09 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -12,6 +12,10 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "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" ) @@ -22,6 +26,8 @@ type Service struct { walletStore *postgres.WalletStore purchaseValidationService *purchase_validation.Service allocationConfigStore *postgres.ShopSeriesAllocationConfigStore + queueClient *queue.Client + logger *zap.Logger } func New( @@ -31,6 +37,8 @@ func New( walletStore *postgres.WalletStore, purchaseValidationService *purchase_validation.Service, allocationConfigStore *postgres.ShopSeriesAllocationConfigStore, + queueClient *queue.Client, + logger *zap.Logger, ) *Service { return &Service{ db: db, @@ -39,6 +47,8 @@ func New( walletStore: walletStore, purchaseValidationService: purchaseValidationService, 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) 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{ BaseModel: model.BaseModel{ Creator: userID, @@ -82,6 +102,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer PaymentStatus: model.PaymentStatusPending, CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: configVersion, + SeriesID: seriesID, + SellerShopID: sellerShopID, + SellerCostPrice: sellerCostPrice, } var items []*model.OrderItem @@ -264,7 +287,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, } 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{}). Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version). 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) }) + + if err != nil { + return err + } + + s.enqueueCommissionCalculation(ctx, orderID) + return nil } 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() - 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{ "payment_status": model.PaymentStatusPaid, "payment_method": paymentMethod, @@ -319,6 +349,13 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay 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 { @@ -373,6 +410,37 @@ func (s *Service) snapshotCommissionConfig(ctx context.Context, allocationID uin 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 { var itemResponses []*dto.OrderItemResponse for _, item := range items { diff --git a/internal/service/order/service_test.go b/internal/service/order/service_test.go index a332e1d..850c4f0 100644 --- a/internal/service/order/service_test.go +++ b/internal/service/order/service_test.go @@ -124,7 +124,7 @@ func setupOrderTestEnv(t *testing.T) *testEnv { require.NoError(t, tx.Create(wallet).Error) 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{ UserID: 1, diff --git a/openspec/changes/fix-commission-calculation-trigger-and-snapshot/.openspec.yaml b/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/.openspec.yaml similarity index 100% rename from openspec/changes/fix-commission-calculation-trigger-and-snapshot/.openspec.yaml rename to openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/.openspec.yaml diff --git a/openspec/changes/fix-commission-calculation-trigger-and-snapshot/design.md b/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/design.md similarity index 100% rename from openspec/changes/fix-commission-calculation-trigger-and-snapshot/design.md rename to openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/design.md diff --git a/openspec/changes/fix-commission-calculation-trigger-and-snapshot/proposal.md b/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/proposal.md similarity index 100% rename from openspec/changes/fix-commission-calculation-trigger-and-snapshot/proposal.md rename to openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/proposal.md diff --git a/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/specs/commission-trigger/spec.md b/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/specs/commission-trigger/spec.md new file mode 100644 index 0000000..810c34a --- /dev/null +++ b/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/specs/commission-trigger/spec.md @@ -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** 不在服务内部直接创建队列客户端(遵循依赖注入原则) + +--- diff --git a/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/specs/order-commission-snapshot/spec.md b/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/specs/order-commission-snapshot/spec.md new file mode 100644 index 0000000..8af148d --- /dev/null +++ b/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/specs/order-commission-snapshot/spec.md @@ -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** 避免重复推导,确保计算口径一致 + +--- diff --git a/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/tasks.md b/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/tasks.md new file mode 100644 index 0000000..4395e91 --- /dev/null +++ b/openspec/changes/archive/2026-01-29-fix-commission-calculation-trigger-and-snapshot/tasks.md @@ -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 ./...` 确保通过 + diff --git a/openspec/changes/fix-commission-calculation-trigger-and-snapshot/tasks.md b/openspec/changes/fix-commission-calculation-trigger-and-snapshot/tasks.md deleted file mode 100644 index 90a874f..0000000 --- a/openspec/changes/fix-commission-calculation-trigger-and-snapshot/tasks.md +++ /dev/null @@ -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 ./...` 确保通过 - diff --git a/openspec/specs/commission-trigger/spec.md b/openspec/specs/commission-trigger/spec.md new file mode 100644 index 0000000..caa0222 --- /dev/null +++ b/openspec/specs/commission-trigger/spec.md @@ -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** 不在服务内部直接创建队列客户端(遵循依赖注入原则) + +--- + diff --git a/openspec/specs/order-commission-snapshot/spec.md b/openspec/specs/order-commission-snapshot/spec.md new file mode 100644 index 0000000..0db2ed6 --- /dev/null +++ b/openspec/specs/order-commission-snapshot/spec.md @@ -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** 避免重复推导,确保计算口径一致 + +--- + diff --git a/pkg/utils/commission.go b/pkg/utils/commission.go new file mode 100644 index 0000000..c9cb84a --- /dev/null +++ b/pkg/utils/commission.go @@ -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 +}