From 2b0f79be8145671205a4c6048f3bd0088cb02afc Mon Sep 17 00:00:00 2001 From: huang Date: Thu, 29 Jan 2026 16:00:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=92=E6=A1=A3=E4=B8=80=E6=AC=A1=E6=80=A7?= =?UTF-8?q?=E4=BD=A3=E9=87=91=E9=85=8D=E7=BD=AE=E8=90=BD=E5=BA=93=E4=B8=8E?= =?UTF-8?q?=E7=B4=AF=E8=AE=A1=E8=A7=A6=E5=8F=91=E4=BF=AE=E5=A4=8D=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=A7=84=E8=8C=83=E6=96=87=E6=A1=A3=E5=88=B0?= =?UTF-8?q?=E4=B8=BB=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 归档 fix-one-time-commission-config-and-accumulation 到 archive/2026-01-29-* - 同步 delta specs 到主规范(one-time-commission-trigger、commission-calculation) - 新增累计触发逻辑文档和测试用例 - 修复一次性佣金配置落库和累计充值更新逻辑 --- docs/commission/accumulated-trigger-logic.md | 181 ++++++++ internal/bootstrap/services.go | 2 +- .../service/commission_calculation/service.go | 56 ++- .../service/shop_series_allocation/service.go | 205 ++++++++- .../.openspec.yaml | 0 .../design.md | 319 ++++++++++++++ .../proposal.md | 0 .../specs/commission-calculation/spec.md | 80 ++++ .../specs/one-time-commission-trigger/spec.md | 100 +++++ .../tasks.md | 32 ++ .../design.md | 61 --- .../tasks.md | 24 - openspec/specs/commission-calculation/spec.md | 69 ++- .../specs/one-time-commission-trigger/spec.md | 79 +++- .../shop_series_allocation_test.go | 159 +++++++ .../commission_calculation_service_test.go | 410 ++++++++++++++++++ ...mission_withdrawal_setting_service_test.go | 3 + ...rise_card_authorization_permission_test.go | 2 + tests/unit/my_commission_service_test.go | 8 +- 19 files changed, 1654 insertions(+), 136 deletions(-) create mode 100644 docs/commission/accumulated-trigger-logic.md rename openspec/changes/{fix-one-time-commission-config-and-accumulation => archive/2026-01-29-fix-one-time-commission-config-and-accumulation}/.openspec.yaml (100%) create mode 100644 openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/design.md rename openspec/changes/{fix-one-time-commission-config-and-accumulation => archive/2026-01-29-fix-one-time-commission-config-and-accumulation}/proposal.md (100%) create mode 100644 openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/specs/commission-calculation/spec.md create mode 100644 openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/specs/one-time-commission-trigger/spec.md create mode 100644 openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/tasks.md delete mode 100644 openspec/changes/fix-one-time-commission-config-and-accumulation/design.md delete mode 100644 openspec/changes/fix-one-time-commission-config-and-accumulation/tasks.md create mode 100644 tests/unit/commission_calculation_service_test.go diff --git a/docs/commission/accumulated-trigger-logic.md b/docs/commission/accumulated-trigger-logic.md new file mode 100644 index 0000000..8abb009 --- /dev/null +++ b/docs/commission/accumulated-trigger-logic.md @@ -0,0 +1,181 @@ +# 累计触发一次性佣金逻辑流程 + +## 概述 + +一次性佣金支持两种触发方式: +1. **单次充值触发**:单笔订单金额达到阈值即发放 +2. **累计充值触发**:累计多次充值金额达到阈值后发放(本文档重点) + +## 累计触发流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户购买套餐 │ +└────────────────────┬────────────────────────────────────────┘ + │ + ┌────────────▼──────────────┐ + │ 支付成功触发佣金计算 │ + └────────────┬──────────────┘ + │ + ┌────────────▼──────────────┐ + │ 读取一次性佣金配置 │ + │ - EnableOneTimeCommission │ + │ - Trigger = accumulated │ + │ - Threshold 阈值 │ + └────────────┬──────────────┘ + │ + ┌────────────▼──────────────┐ + │ 累计金额 += 本次订单金额 │ + │ 写回 AccumulatedRecharge │ + └────────────┬──────────────┘ + │ + ┌────────────▼──────────────┐ + │ 累计金额 >= 阈值? │ + └─────┬───────────┬──────────┘ + │ 否 │ 是 + │ │ + │ ┌────▼───────────────┐ + │ │ FirstCommissionPaid│ + │ │ 已标记? │ + │ └────┬───────┬───────┘ + │ │ 是 │ 否 + │ │ │ + │ ┌────▼────┐ │ + │ │ 跳过 │ │ + │ └─────────┘ │ + │ │ + │ ┌────▼────────┐ + │ │ 计算佣金金额 │ + │ └────┬────────┘ + │ │ + │ ┌────▼────────┐ + │ │ 创建佣金记录 │ + │ │ 佣金入账 │ + │ └────┬────────┘ + │ │ + │ ┌────▼────────┐ + │ │ 标记 │ + │ │FirstCommission│ + │ │Paid = true │ + │ └────┬────────┘ + │ │ + └───────────────────▼ + │ + ┌────────────▼──────────────┐ + │ 完成 │ + └───────────────────────────┘ +``` + +## 关键字段 + +### IotCard / Device 模型 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `accumulated_recharge` | int64 | 累计充值金额(分),每次支付成功都会累加 | +| `first_commission_paid` | bool | 一次性佣金是否已发放,防止重复发放 | +| `series_allocation_id` | uint | 关联的系列分配ID,用于获取配置 | + +### ShopSeriesAllocation 模型 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `enable_one_time_commission` | bool | 是否启用一次性佣金 | +| `one_time_commission_trigger` | string | 触发方式:`single_recharge` 或 `accumulated_recharge` | +| `one_time_commission_threshold` | int64 | 最低阈值(分) | +| `one_time_commission_type` | string | 佣金类型:`fixed` 或 `tiered` | +| `one_time_commission_mode` | string | 返佣模式:`fixed` 或 `percent` | +| `one_time_commission_value` | int64 | 佣金值(分或千分比) | + +## 核心逻辑实现 + +### 1. 累计金额更新 + +**位置**:`internal/service/commission_calculation/service.go` + +```go +// 累计充值触发场景:每次支付都写回累计金额 +if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + newAccumulated := card.AccumulatedRecharge + order.TotalAmount + if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID). + Update("accumulated_recharge", newAccumulated).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡累计充值金额失败") + } + card.AccumulatedRecharge = newAccumulated +} +``` + +### 2. 阈值判断与发放 + +```go +// 判断是否已发放过 +if card.FirstCommissionPaid { + return nil // 已发放,跳过 +} + +// 根据触发方式选择充值金额 +var rechargeAmount int64 +switch allocation.OneTimeCommissionTrigger { +case model.OneTimeCommissionTriggerSingleRecharge: + rechargeAmount = order.TotalAmount // 单次金额 +case model.OneTimeCommissionTriggerAccumulatedRecharge: + rechargeAmount = card.AccumulatedRecharge // 累计金额 +default: + return nil +} + +// 判断是否达到阈值 +if rechargeAmount < allocation.OneTimeCommissionThreshold { + return nil // 未达阈值,不发放 +} + +// 计算佣金、创建记录、入账 +commissionAmount, err := s.calculateOneTimeCommission(ctx, allocation, order.TotalAmount) +// ... + +// 标记已发放 +if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID). + Update("first_commission_paid", true).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败") +} +``` + +## 典型场景示例 + +### 场景:阈值 10000 分(100元),佣金 500 分(5元) + +| 支付次数 | 订单金额 | 累计金额 | 是否发放佣金 | 说明 | +|---------|---------|---------|-------------|------| +| 第1次 | 3000 | 3000 | ❌ | 未达阈值 | +| 第2次 | 4000 | 7000 | ❌ | 未达阈值 | +| 第3次 | 4000 | 11000 | ✅ 发放500 | 达到阈值,首次发放 | +| 第4次 | 3000 | 14000 | ❌ | 已发放过,不重复 | +| 第5次 | 5000 | 19000 | ❌ | 已发放过,不重复 | + +## 注意事项 + +1. **每次支付都写回累计金额**:即使未达阈值,也要更新 `accumulated_recharge` +2. **仅发放一次**:通过 `first_commission_paid` 标记防止重复发放 +3. **事务保证一致性**:累计金额更新、佣金记录创建、标记更新都在同一事务中 +4. **适用于单卡和设备**:`IotCard` 和 `Device` 模型都有相同字段,逻辑完全一致 + +## 测试覆盖 + +单元测试:`tests/unit/commission_calculation_service_test.go` + +- ✅ 第一次支付:累计金额更新为 3000,未发放 +- ✅ 第二次支付:累计金额更新为 7000,未发放 +- ✅ 第三次支付:累计金额更新为 11000,发放佣金 500 +- ✅ 第四次支付:累计金额更新为 14000,不重复发放 + +集成测试:`tests/integration/shop_series_allocation_test.go` + +- ✅ 一次性佣金配置落库(固定类型) +- ✅ 一次性佣金配置落库(梯度类型) +- ✅ 启用一次性佣金但未提供配置应失败 + +## 相关文档 + +- [API 文档](../api-documentation-guide.md):自动生成的 OpenAPI 文档包含所有参数说明 +- [DTO 规范](../dto-standards.md):一次性佣金配置的请求/响应结构 +- [Model 规范](../model-standards.md):数据库字段定义 diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 81ed6e1..cefb51b 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -113,7 +113,7 @@ func initServices(s *stores, deps *Dependencies) *services { Carrier: carrierSvc.New(s.Carrier), PackageSeries: packageSeriesSvc.New(s.PackageSeries), Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopSeriesCommissionTier), - ShopSeriesAllocation: shopSeriesAllocationSvc.New(s.ShopSeriesAllocation, s.ShopSeriesCommissionTier, s.ShopSeriesAllocationConfig, s.Shop, s.PackageSeries, s.Package), + ShopSeriesAllocation: shopSeriesAllocationSvc.New(s.ShopSeriesAllocation, s.ShopSeriesCommissionTier, s.ShopSeriesAllocationConfig, s.ShopSeriesOneTimeCommissionTier, s.Shop, s.PackageSeries, s.Package), ShopPackageAllocation: shopPackageAllocationSvc.New(s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package), ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopSeriesAllocationConfig, s.ShopSeriesCommissionTier, s.ShopSeriesCommissionStats, s.Shop), ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop), diff --git a/internal/service/commission_calculation/service.go b/internal/service/commission_calculation/service.go index 70fcc5d..d5ce4f8 100644 --- a/internal/service/commission_calculation/service.go +++ b/internal/service/commission_calculation/service.go @@ -194,10 +194,6 @@ func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *g return errors.Wrap(errors.CodeDatabaseError, err, "获取卡信息失败") } - if card.FirstCommissionPaid { - return nil - } - if card.SeriesAllocationID == nil { return nil } @@ -211,12 +207,25 @@ func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *g return nil } + if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + newAccumulated := card.AccumulatedRecharge + order.TotalAmount + if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID). + Update("accumulated_recharge", newAccumulated).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡累计充值金额失败") + } + card.AccumulatedRecharge = newAccumulated + } + + if card.FirstCommissionPaid { + return nil + } + var rechargeAmount int64 switch allocation.OneTimeCommissionTrigger { case model.OneTimeCommissionTriggerSingleRecharge: rechargeAmount = order.TotalAmount case model.OneTimeCommissionTriggerAccumulatedRecharge: - rechargeAmount = card.AccumulatedRecharge + order.TotalAmount + rechargeAmount = card.AccumulatedRecharge default: return nil } @@ -260,12 +269,9 @@ func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *g return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败") } - updates := map[string]any{"first_commission_paid": true} - if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { - updates["accumulated_recharge"] = rechargeAmount - } - if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(updates).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") + if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID). + Update("first_commission_paid", true).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败") } return nil @@ -283,10 +289,6 @@ func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx return errors.Wrap(errors.CodeDatabaseError, err, "获取设备信息失败") } - if device.FirstCommissionPaid { - return nil - } - if device.SeriesAllocationID == nil { return nil } @@ -300,12 +302,25 @@ func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx return nil } + if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + newAccumulated := device.AccumulatedRecharge + order.TotalAmount + if err := tx.Model(&model.Device{}).Where("id = ?", deviceID). + Update("accumulated_recharge", newAccumulated).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新设备累计充值金额失败") + } + device.AccumulatedRecharge = newAccumulated + } + + if device.FirstCommissionPaid { + return nil + } + var rechargeAmount int64 switch allocation.OneTimeCommissionTrigger { case model.OneTimeCommissionTriggerSingleRecharge: rechargeAmount = order.TotalAmount case model.OneTimeCommissionTriggerAccumulatedRecharge: - rechargeAmount = device.AccumulatedRecharge + order.TotalAmount + rechargeAmount = device.AccumulatedRecharge default: return nil } @@ -349,12 +364,9 @@ func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败") } - updates := map[string]any{"first_commission_paid": true} - if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { - updates["accumulated_recharge"] = rechargeAmount - } - if err := tx.Model(&model.Device{}).Where("id = ?", deviceID).Updates(updates).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新设备状态失败") + if err := tx.Model(&model.Device{}).Where("id = ?", deviceID). + Update("first_commission_paid", true).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新设备佣金发放状态失败") } return nil diff --git a/internal/service/shop_series_allocation/service.go b/internal/service/shop_series_allocation/service.go index 0ec3820..423da4b 100644 --- a/internal/service/shop_series_allocation/service.go +++ b/internal/service/shop_series_allocation/service.go @@ -16,29 +16,32 @@ import ( ) type Service struct { - allocationStore *postgres.ShopSeriesAllocationStore - tierStore *postgres.ShopSeriesCommissionTierStore - configStore *postgres.ShopSeriesAllocationConfigStore - shopStore *postgres.ShopStore - packageSeriesStore *postgres.PackageSeriesStore - packageStore *postgres.PackageStore + allocationStore *postgres.ShopSeriesAllocationStore + tierStore *postgres.ShopSeriesCommissionTierStore + configStore *postgres.ShopSeriesAllocationConfigStore + oneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore + shopStore *postgres.ShopStore + packageSeriesStore *postgres.PackageSeriesStore + packageStore *postgres.PackageStore } func New( allocationStore *postgres.ShopSeriesAllocationStore, tierStore *postgres.ShopSeriesCommissionTierStore, configStore *postgres.ShopSeriesAllocationConfigStore, + oneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore, shopStore *postgres.ShopStore, packageSeriesStore *postgres.PackageSeriesStore, packageStore *postgres.PackageStore, ) *Service { return &Service{ - allocationStore: allocationStore, - tierStore: tierStore, - configStore: configStore, - shopStore: shopStore, - packageSeriesStore: packageSeriesStore, - packageStore: packageStore, + allocationStore: allocationStore, + tierStore: tierStore, + configStore: configStore, + oneTimeCommissionTierStore: oneTimeCommissionTierStore, + shopStore: shopStore, + packageSeriesStore: packageSeriesStore, + packageStore: packageStore, } } @@ -99,6 +102,10 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesAllocatio return nil, errors.New(errors.CodeConflict, "该店铺已分配此套餐系列") } + if err := s.validateOneTimeCommissionConfig(req); err != nil { + return nil, err + } + allocation := &model.ShopSeriesAllocation{ ShopID: req.ShopID, SeriesID: req.SeriesID, @@ -108,12 +115,34 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesAllocatio EnableTierCommission: req.EnableTierCommission, Status: constants.StatusEnabled, } + + // 处理一次性佣金配置 + allocation.EnableOneTimeCommission = req.EnableOneTimeCommission + if req.EnableOneTimeCommission && req.OneTimeCommissionConfig != nil { + cfg := req.OneTimeCommissionConfig + allocation.OneTimeCommissionType = cfg.Type + allocation.OneTimeCommissionTrigger = cfg.Trigger + allocation.OneTimeCommissionThreshold = cfg.Threshold + // fixed 类型需要保存 mode 和 value + if cfg.Type == model.OneTimeCommissionTypeFixed { + allocation.OneTimeCommissionMode = cfg.Mode + allocation.OneTimeCommissionValue = cfg.Value + } + } allocation.Creator = currentUserID if err := s.allocationStore.Create(ctx, allocation); err != nil { return nil, fmt.Errorf("创建分配失败: %w", err) } + // 如果是梯度类型,保存梯度配置 + if req.EnableOneTimeCommission && req.OneTimeCommissionConfig != nil && + req.OneTimeCommissionConfig.Type == model.OneTimeCommissionTypeTiered { + if err := s.saveOneTimeCommissionTiers(ctx, allocation.ID, req.OneTimeCommissionConfig.Tiers, currentUserID); err != nil { + return nil, fmt.Errorf("创建一次性佣金梯度配置失败: %w", err) + } + } + return s.buildResponse(ctx, allocation, targetShop.ShopName, series.SeriesName) } @@ -170,6 +199,42 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries } allocation.EnableTierCommission = *req.EnableTierCommission } + + enableOneTimeCommission := allocation.EnableOneTimeCommission + if req.EnableOneTimeCommission != nil { + enableOneTimeCommission = *req.EnableOneTimeCommission + } + if err := s.validateOneTimeCommissionConfigForUpdate(enableOneTimeCommission, req.OneTimeCommissionConfig); err != nil { + return nil, err + } + + oneTimeCommissionChanged := false + if req.EnableOneTimeCommission != nil { + if allocation.EnableOneTimeCommission != *req.EnableOneTimeCommission { + oneTimeCommissionChanged = true + } + allocation.EnableOneTimeCommission = *req.EnableOneTimeCommission + } + if req.OneTimeCommissionConfig != nil && allocation.EnableOneTimeCommission { + cfg := req.OneTimeCommissionConfig + if allocation.OneTimeCommissionType != cfg.Type || + allocation.OneTimeCommissionTrigger != cfg.Trigger || + allocation.OneTimeCommissionThreshold != cfg.Threshold || + allocation.OneTimeCommissionMode != cfg.Mode || + allocation.OneTimeCommissionValue != cfg.Value { + oneTimeCommissionChanged = true + } + allocation.OneTimeCommissionType = cfg.Type + allocation.OneTimeCommissionTrigger = cfg.Trigger + allocation.OneTimeCommissionThreshold = cfg.Threshold + if cfg.Type == model.OneTimeCommissionTypeFixed { + allocation.OneTimeCommissionMode = cfg.Mode + allocation.OneTimeCommissionValue = cfg.Value + } else { + allocation.OneTimeCommissionMode = "" + allocation.OneTimeCommissionValue = 0 + } + } allocation.Updater = currentUserID if configChanged { @@ -182,6 +247,16 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries return nil, fmt.Errorf("更新分配失败: %w", err) } + if oneTimeCommissionChanged && req.OneTimeCommissionConfig != nil && + req.OneTimeCommissionConfig.Type == model.OneTimeCommissionTypeTiered { + if err := s.oneTimeCommissionTierStore.DeleteByAllocationID(ctx, allocation.ID); err != nil { + return nil, fmt.Errorf("清理旧梯度配置失败: %w", err) + } + if err := s.saveOneTimeCommissionTiers(ctx, allocation.ID, req.OneTimeCommissionConfig.Tiers, currentUserID); err != nil { + return nil, fmt.Errorf("更新一次性佣金梯度配置失败: %w", err) + } + } + shop, _ := s.shopStore.GetByID(ctx, allocation.ShopID) series, _ := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID) @@ -323,7 +398,7 @@ func (s *Service) buildResponse(ctx context.Context, a *model.ShopSeriesAllocati allocatorShopName = allocatorShop.ShopName } - return &dto.ShopSeriesAllocationResponse{ + resp := &dto.ShopSeriesAllocationResponse{ ID: a.ID, ShopID: a.ShopID, ShopName: shopName, @@ -335,11 +410,39 @@ func (s *Service) buildResponse(ctx context.Context, a *model.ShopSeriesAllocati Mode: a.BaseCommissionMode, Value: a.BaseCommissionValue, }, - EnableTierCommission: a.EnableTierCommission, - Status: a.Status, - CreatedAt: a.CreatedAt.Format(time.RFC3339), - UpdatedAt: a.UpdatedAt.Format(time.RFC3339), - }, nil + EnableTierCommission: a.EnableTierCommission, + EnableOneTimeCommission: a.EnableOneTimeCommission, + Status: a.Status, + CreatedAt: a.CreatedAt.Format(time.RFC3339), + UpdatedAt: a.UpdatedAt.Format(time.RFC3339), + } + + if a.EnableOneTimeCommission { + cfg := &dto.OneTimeCommissionConfig{ + Type: a.OneTimeCommissionType, + Trigger: a.OneTimeCommissionTrigger, + Threshold: a.OneTimeCommissionThreshold, + Mode: a.OneTimeCommissionMode, + Value: a.OneTimeCommissionValue, + } + if a.OneTimeCommissionType == model.OneTimeCommissionTypeTiered { + tiers, err := s.oneTimeCommissionTierStore.ListByAllocationID(ctx, a.ID) + if err == nil && len(tiers) > 0 { + cfg.Tiers = make([]dto.OneTimeCommissionTierEntry, len(tiers)) + for i, t := range tiers { + cfg.Tiers[i] = dto.OneTimeCommissionTierEntry{ + TierType: t.TierType, + Threshold: t.ThresholdValue, + Mode: t.CommissionMode, + Value: t.CommissionValue, + } + } + } + } + resp.OneTimeCommissionConfig = cfg + } + + return resp, nil } func (s *Service) createNewConfigVersion(ctx context.Context, allocation *model.ShopSeriesAllocation) error { @@ -371,6 +474,72 @@ func (s *Service) createNewConfigVersion(ctx context.Context, allocation *model. return nil } +func (s *Service) validateOneTimeCommissionConfig(req *dto.CreateShopSeriesAllocationRequest) error { + if !req.EnableOneTimeCommission { + return nil + } + if req.OneTimeCommissionConfig == nil { + return errors.New(errors.CodeInvalidParam, "启用一次性佣金时必须提供配置") + } + cfg := req.OneTimeCommissionConfig + if cfg.Type == model.OneTimeCommissionTypeFixed { + if cfg.Mode == "" { + return errors.New(errors.CodeInvalidParam, "固定类型一次性佣金必须指定返佣模式") + } + if cfg.Value <= 0 { + return errors.New(errors.CodeInvalidParam, "固定类型一次性佣金必须指定返佣金额") + } + } else if cfg.Type == model.OneTimeCommissionTypeTiered { + if len(cfg.Tiers) == 0 { + return errors.New(errors.CodeInvalidParam, "梯度类型一次性佣金必须提供梯度档位") + } + } + return nil +} + +func (s *Service) validateOneTimeCommissionConfigForUpdate(enableOneTimeCommission bool, cfg *dto.OneTimeCommissionConfig) error { + if !enableOneTimeCommission { + return nil + } + if cfg == nil { + return nil + } + if cfg.Type == model.OneTimeCommissionTypeFixed { + if cfg.Mode == "" { + return errors.New(errors.CodeInvalidParam, "固定类型一次性佣金必须指定返佣模式") + } + if cfg.Value <= 0 { + return errors.New(errors.CodeInvalidParam, "固定类型一次性佣金必须指定返佣金额") + } + } else if cfg.Type == model.OneTimeCommissionTypeTiered { + if len(cfg.Tiers) == 0 { + return errors.New(errors.CodeInvalidParam, "梯度类型一次性佣金必须提供梯度档位") + } + } + return nil +} + +func (s *Service) saveOneTimeCommissionTiers(ctx context.Context, allocationID uint, tiers []dto.OneTimeCommissionTierEntry, userID uint) error { + if len(tiers) == 0 { + return nil + } + + tierModels := make([]*model.ShopSeriesOneTimeCommissionTier, len(tiers)) + for i, t := range tiers { + tierModels[i] = &model.ShopSeriesOneTimeCommissionTier{ + AllocationID: allocationID, + TierType: t.TierType, + ThresholdValue: t.Threshold, + CommissionMode: t.Mode, + CommissionValue: t.Value, + Status: constants.StatusEnabled, + } + tierModels[i].Creator = userID + } + + return s.oneTimeCommissionTierStore.BatchCreate(ctx, tierModels) +} + func (s *Service) GetEffectiveConfig(ctx context.Context, allocationID uint, at time.Time) (*model.ShopSeriesAllocationConfig, error) { config, err := s.configStore.GetEffective(ctx, allocationID, at) if err != nil { diff --git a/openspec/changes/fix-one-time-commission-config-and-accumulation/.openspec.yaml b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/.openspec.yaml similarity index 100% rename from openspec/changes/fix-one-time-commission-config-and-accumulation/.openspec.yaml rename to openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/.openspec.yaml diff --git a/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/design.md b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/design.md new file mode 100644 index 0000000..506c848 --- /dev/null +++ b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/design.md @@ -0,0 +1,319 @@ +# 一次性佣金修复 - 设计 + +## 目标 + +1. 通过 `ShopSeriesAllocation` 创建/更新接口即可配置一次性佣金并生效(你确认 B=1)。 +2. “累计充值触发”场景每次支付成功都累加保存,达到阈值触发一次性佣金发放。 +3. 发放具备幂等:同一资源(卡/设备)只发放一次。 + +## 1) 常量定义 + +所有常量必须在 `pkg/constants/` 中定义,禁止硬编码: + +```go +// pkg/constants/commission.go + +// 一次性佣金类型 +const ( + OneTimeCommissionTypeFixed = "fixed" // 固定类型 + OneTimeCommissionTypeTiered = "tiered" // 梯度类型 +) + +// 一次性佣金触发方式 +const ( + OneTimeCommissionTriggerImmediate = "immediate" // 立即触发 + OneTimeCommissionTriggerAccumulatedRecharge = "accumulated_recharge" // 累计充值触发 +) + +// 佣金模式 +const ( + CommissionModeFixed = "fixed" // 固定金额 + CommissionModePercent = "percent" // 百分比 +) + +// 梯度类型 +const ( + TierTypeSalesCount = "sales_count" // 销售数量 + TierTypeSalesAmount = "sales_amount" // 销售金额 +) +``` + +**使用示例**: + +```go +// Service 层使用常量 +if allocation.OneTimeCommissionType == constants.OneTimeCommissionTypeFixed { + // 固定类型处理 +} + +if allocation.OneTimeCommissionTrigger == constants.OneTimeCommissionTriggerAccumulatedRecharge { + // 累计充值触发逻辑 +} + +// 禁止硬编码(错误示例) +if allocation.OneTimeCommissionType == "fixed" { // ❌ 禁止 + // ... +} +``` + +## 2) 配置落库 + +### 固定类型(fixed) + +在 `tb_shop_series_allocation` 写入: +- enable_one_time_commission +- one_time_commission_type = fixed +- one_time_commission_trigger +- one_time_commission_threshold +- one_time_commission_mode(fixed/percent) +- one_time_commission_value + +### 梯度类型(tiered) + +在 `tb_shop_series_allocation` 写入: +- enable_one_time_commission +- one_time_commission_type = tiered +- one_time_commission_trigger +- one_time_commission_threshold + +并在 `tb_shop_series_one_time_commission_tier` 维护档位: +- allocation_id +- tier_type(sales_count/sales_amount) +- threshold_value +- commission_mode(fixed/percent) +- commission_value + +更新策略建议: +- 更新配置时:先删除 allocation_id 对应的旧 tiers,再批量插入新 tiers(实现简单且可控) + +### 数据库设计约束 + +根据项目规范(AGENTS.md),必须遵守以下数据库设计原则: + +**❌ 禁止事项**: +- 禁止在 `tb_shop_series_one_time_commission_tier` 和 `tb_shop_series_allocation` 之间建立外键约束 +- 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo) + +**✅ 必须遵守**: +- 关联通过存储 ID 字段(allocation_id)手动维护 +- 关联数据在代码层面显式查询 + +**实现示例**: + +```go +// Store 层:显式关联查询 +func (s *ShopSeriesAllocationStore) GetByIDWithTiers(ctx context.Context, id uint) (*model.ShopSeriesAllocation, []*model.ShopSeriesOneTimeCommissionTier, error) { + // 1. 查询分配配置 + var allocation model.ShopSeriesAllocation + if err := s.db.WithContext(ctx).First(&allocation, id).Error; err != nil { + return nil, nil, err + } + + // 2. 如果是梯度类型,显式查询档位 + var tiers []*model.ShopSeriesOneTimeCommissionTier + if allocation.OneTimeCommissionType == constants.OneTimeCommissionTypeTiered { + if err := s.db.WithContext(ctx). + Where("allocation_id = ?", id). + Order("threshold_value ASC"). + Find(&tiers).Error; err != nil { + return nil, nil, err + } + } + + return &allocation, tiers, nil +} + +// Service 层:组装响应 +func (s *ShopSeriesAllocationService) GetByID(ctx context.Context, id uint) (*dto.AllocationResponse, error) { + allocation, tiers, err := s.store.ShopSeriesAllocation.GetByIDWithTiers(ctx, id) + if err != nil { + return nil, err + } + + resp := &dto.AllocationResponse{ + // ... 映射字段 + EnableOneTimeCommission: allocation.EnableOneTimeCommission, + OneTimeCommissionConfig: mapOneTimeCommissionConfig(allocation, tiers), + } + + return resp, nil +} +``` + +## 3) 累计触发逻辑 + +针对 `accumulated_recharge`: +- 每次支付成功都更新 `AccumulatedRecharge += orderAmount` +- 若累计达到阈值且未发放过: + - 计算佣金金额 + - 创建佣金记录并入账 + - 标记 `FirstCommissionPaid = true` + +注意:累计的更新应当以“支付成功”为准,避免未支付订单污染累计值。 + +## 3) 错误处理规范 + +所有错误必须在 `pkg/errors/` 中定义,使用统一错误码系统: + +### 参数校验错误(40xxx) + +```go +// pkg/errors/codes.go 或 pkg/errors/commission.go +ErrOneTimeCommissionConfigInvalid = NewAppError(40101, "一次性佣金配置无效") +ErrOneTimeCommissionModeMissing = NewAppError(40102, "一次性佣金模式缺失") +ErrOneTimeCommissionValueMissing = NewAppError(40103, "一次性佣金金额/比例缺失") +ErrOneTimeCommissionTiersMissing = NewAppError(40104, "梯度佣金档位配置缺失") +ErrOneTimeCommissionTierInvalid = NewAppError(40105, "梯度佣金档位配置无效") +ErrOneTimeCommissionThresholdInvalid = NewAppError(40106, "一次性佣金阈值无效") +``` + +### 业务逻辑错误(50xxx) + +```go +ErrAccumulatedRechargeUpdateFailed = NewAppError(50101, "累计充值金额更新失败") +ErrOneTimeCommissionAlreadyPaid = NewAppError(50102, "一次性佣金已发放") +ErrCommissionCalculationFailed = NewAppError(50103, "佣金计算失败") +``` + +### 使用示例 + +```go +// Service 层参数校验 +if req.EnableOneTimeCommission && req.OneTimeCommissionConfig == nil { + return errors.ErrOneTimeCommissionConfigInvalid +} + +if req.OneTimeCommissionConfig.Type == "fixed" && + req.OneTimeCommissionConfig.Mode == "" { + return errors.ErrOneTimeCommissionModeMissing +} + +if req.OneTimeCommissionConfig.Type == "tiered" && + len(req.OneTimeCommissionConfig.Tiers) == 0 { + return errors.ErrOneTimeCommissionTiersMissing +} + +// Handler 层统一处理(全局 ErrorHandler 自动处理) +if err := service.CreateAllocation(ctx, req); err != nil { + return err // 自动转换为 JSON 响应 +} +``` + +## 4) 性能优化 + +### 并发控制策略 + +累计值更新存在并发写风险,必须使用乐观锁防止数据覆盖: + +```go +// 使用 GORM 乐观锁(基于 version 字段) +type CommissionRecord struct { + // ... 其他字段 + AccumulatedRecharge int64 `gorm:"column:accumulated_recharge"` + Version int `gorm:"column:version"` // 乐观锁版本号 +} + +// 更新时自动检查版本号 +result := db.Model(&record). + Where("id = ? AND version = ?", record.ID, record.Version). + Updates(map[string]interface{}{ + "accumulated_recharge": gorm.Expr("accumulated_recharge + ?", amount), + "version": gorm.Expr("version + 1"), + }) + +if result.RowsAffected == 0 { + // 版本冲突,需要重试 + return errors.ErrAccumulatedRechargeUpdateFailed +} +``` + +**替代方案**(如果无 version 字段): +```go +// 使用 SQL 原子操作 +result := db.Exec(` + UPDATE tb_commission_records + SET accumulated_recharge = accumulated_recharge + ?, + updated_at = NOW() + WHERE id = ? +`, amount, recordID) +``` + +### 索引优化 + +梯度配置表需要添加索引优化查询性能: + +```sql +-- tb_shop_series_one_time_commission_tier 表索引 +CREATE INDEX idx_allocation_id ON tb_shop_series_one_time_commission_tier(allocation_id); + +-- 组合索引(如果需要按档位类型过滤) +CREATE INDEX idx_allocation_tier ON tb_shop_series_one_time_commission_tier(allocation_id, tier_type); +``` + +### 缓存策略(可选) + +高频查询的分配配置可缓存到 Redis: + +```go +// Redis Key 生成函数(定义在 pkg/constants/redis.go) +func RedisShopSeriesAllocationKey(allocationID uint) string { + return fmt.Sprintf("shop:series:allocation:%d", allocationID) +} + +// 缓存读取 +func (s *ShopSeriesAllocationService) GetByID(ctx context.Context, id uint) (*model.ShopSeriesAllocation, error) { + // 1. 尝试从 Redis 读取 + key := constants.RedisShopSeriesAllocationKey(id) + cached, err := s.redis.Get(ctx, key).Result() + if err == nil { + var allocation model.ShopSeriesAllocation + if err := json.Unmarshal([]byte(cached), &allocation); err == nil { + return &allocation, nil + } + } + + // 2. 从数据库读取 + allocation, err := s.store.ShopSeriesAllocation.GetByID(ctx, id) + if err != nil { + return nil, err + } + + // 3. 写入 Redis(TTL 5分钟) + data, _ := json.Marshal(allocation) + s.redis.Set(ctx, key, data, 5*time.Minute) + + return allocation, nil +} + +// 缓存失效(创建/更新/删除时) +func (s *ShopSeriesAllocationService) Update(ctx context.Context, req *dto.UpdateAllocationRequest) error { + // ... 更新逻辑 + + // 删除缓存 + key := constants.RedisShopSeriesAllocationKey(req.ID) + s.redis.Del(ctx, key) + + return nil +} +``` + +### 性能要求 + +根据项目规范(AGENTS.md): +- API P95 响应时间 < 200ms +- API P99 响应时间 < 500ms +- 数据库查询 < 50ms +- 避免 N+1 查询,使用批量操作 + +## 5) 测试 + +- 配置落库测试:创建/更新分配后,查询数据库字段与 tiers 表是否一致 +- 累计触发测试:模拟多次支付累计到阈值,验证只发放一次且累计值递增 +- 修复现有单测字段不匹配导致的编译失败 + +## 验收标准 + +- 创建/更新 `ShopSeriesAllocation` 时,一次性佣金配置能正确落库并在查询响应中返回。 +- 累计触发场景下,多次支付能累加并在达到阈值时发放一次性佣金;之后不重复发放。 +- `go test ./...` 通过。 + diff --git a/openspec/changes/fix-one-time-commission-config-and-accumulation/proposal.md b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/proposal.md similarity index 100% rename from openspec/changes/fix-one-time-commission-config-and-accumulation/proposal.md rename to openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/proposal.md diff --git a/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/specs/commission-calculation/spec.md b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/specs/commission-calculation/spec.md new file mode 100644 index 0000000..a47c490 --- /dev/null +++ b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/specs/commission-calculation/spec.md @@ -0,0 +1,80 @@ +## MODIFIED Requirements + +### Requirement: 更新累计充值金额 + +订单支付成功后系统 SHALL 更新卡/设备的累计充值金额。 + +**关键修复**:每次支付成功都必须写回累计充值金额,确保累计值能正确用于一次性佣金的累计触发判断。 + +#### Scenario: 单卡订单更新累计充值 + +- **WHEN** 单卡订单支付成功,金额 100 元 +- **THEN** 系统读取 IotCard.accumulated_recharge 当前值 +- **AND** 增加 10000 分(100 元 = 10000 分) +- **AND** 将新值写回 IotCard.accumulated_recharge +- **AND** 使用更新后的累计值判断是否触发一次性佣金 + +#### Scenario: 设备订单更新累计充值 + +- **WHEN** 设备订单支付成功,金额 300 元 +- **THEN** 系统读取 Device.accumulated_recharge 当前值 +- **AND** 增加 30000 分(300 元 = 30000 分) +- **AND** 将新值写回 Device.accumulated_recharge +- **AND** 使用更新后的累计值判断是否触发一次性佣金 + +#### Scenario: 累计充值更新使用原子操作 + +- **WHEN** 更新累计充值金额 +- **THEN** 系统使用 SQL 原子操作(如 `accumulated_recharge = accumulated_recharge + ?`) +- **OR** 使用 GORM 乐观锁(version 字段) +- **AND** 确保并发场景下累计值不会丢失 + +#### Scenario: 更新失败不影响佣金计算 + +- **WHEN** 累计充值金额更新失败(数据库错误、并发冲突等) +- **THEN** 系统记录错误日志 +- **AND** 继续执行后续的佣金计算流程(成本价差、一次性佣金等) +- **AND** 不因累计值更新失败而导致整个佣金计算失败 + +--- + +### Requirement: 一次性佣金触发检查 + +系统 SHALL 在更新累计充值金额后立即检查是否触发一次性佣金。 + +#### Scenario: 累计达到阈值触发佣金 + +- **WHEN** 更新累计充值后,累计值 ≥ 配置阈值 +- **AND** 卡/设备的 first_commission_paid = false +- **THEN** 系统发放一次性佣金 +- **AND** 标记 first_commission_paid = true + +#### Scenario: 累计未达到阈值不触发 + +- **WHEN** 更新累计充值后,累计值 < 配置阈值 +- **THEN** 系统不发放一次性佣金 +- **AND** first_commission_paid 保持不变 + +#### Scenario: 已发放过不重复触发 + +- **WHEN** 更新累计充值后,累计值 ≥ 配置阈值 +- **AND** 卡/设备的 first_commission_paid = true +- **THEN** 系统不重复发放一次性佣金 + +--- + +## ADDED Requirements + +### Requirement: 累计充值更新日志记录 + +系统 SHOULD 记录累计充值金额的更新操作,便于问题排查。 + +#### Scenario: 记录更新前后的累计值 + +- **WHEN** 更新累计充值金额 +- **THEN** 系统在日志中记录:订单 ID、资源类型(卡/设备)、资源 ID、更新前累计值、本次充值金额、更新后累计值 + +#### Scenario: 记录更新失败原因 + +- **WHEN** 累计充值金额更新失败 +- **THEN** 系统在日志中记录:订单 ID、资源 ID、失败原因(错误信息)、重试次数(如适用) diff --git a/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/specs/one-time-commission-trigger/spec.md b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/specs/one-time-commission-trigger/spec.md new file mode 100644 index 0000000..d826497 --- /dev/null +++ b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/specs/one-time-commission-trigger/spec.md @@ -0,0 +1,100 @@ +## MODIFIED Requirements + +### Requirement: 累计充值触发佣金 + +系统 SHALL 支持"累计充值"触发条件:当卡/设备的累计充值金额 ≥ 配置阈值时触发一次性佣金。 + +**关键修复**:每次支付成功后必须更新累计充值金额,确保累计值能正确递增并达到阈值。 + +#### Scenario: 累计达到阈值 + +- **WHEN** 卡之前累计充值 200 元,本次充值 150 元,配置阈值 300 元 +- **THEN** 系统更新累计充值为 350 元 +- **AND** 累计 350 元 ≥ 300 元,系统发放一次性佣金 +- **AND** 标记 first_commission_paid = true + +#### Scenario: 累计未达到阈值 + +- **WHEN** 卡之前累计充值 100 元,本次充值 100 元,配置阈值 300 元 +- **THEN** 系统更新累计充值为 200 元 +- **AND** 累计 200 元 < 300 元,系统不发放一次性佣金 + +#### Scenario: 每次支付都更新累计值 + +- **WHEN** 卡累计充值为 50 元 +- **AND** 连续发生 3 次充值:100 元、150 元、80 元 +- **THEN** 第 1 次充值后累计 150 元 +- **AND** 第 2 次充值后累计 300 元 +- **AND** 第 3 次充值后累计 380 元 + +--- + +### Requirement: 一次性佣金配置获取 + +一次性佣金的触发条件和金额 SHALL 从 ShopSeriesAllocation 配置获取。 + +**关键修复**:配置必须能够通过 ShopSeriesAllocation 创建/更新接口正确落库并生效。 + +#### Scenario: 获取触发条件和金额 + +- **WHEN** 触发一次性佣金检查 +- **THEN** 系统从卡关联的 ShopSeriesAllocation 获取 one_time_commission_trigger(触发类型)、one_time_commission_threshold(阈值)、one_time_commission_mode(模式)、one_time_commission_value(金额/比例) + +#### Scenario: 固定类型配置 + +- **WHEN** 创建 ShopSeriesAllocation 时设置 one_time_commission_type = "fixed" +- **AND** 设置 one_time_commission_mode = "fixed",one_time_commission_value = 5000(50 元) +- **THEN** 系统将配置正确写入数据库 +- **AND** 查询该配置时能正确返回所有一次性佣金字段 + +#### Scenario: 梯度类型配置 + +- **WHEN** 创建 ShopSeriesAllocation 时设置 one_time_commission_type = "tiered" +- **AND** 提供梯度档位配置:[{threshold: 100, mode: "fixed", value: 2000}, {threshold: 300, mode: "percent", value: 10}] +- **THEN** 系统将主配置写入 tb_shop_series_allocation +- **AND** 将档位配置写入 tb_shop_series_one_time_commission_tier +- **AND** 查询该配置时能正确返回主配置和关联的档位列表 + +#### Scenario: 更新梯度配置 + +- **WHEN** 更新 ShopSeriesAllocation 的梯度配置 +- **AND** 新档位配置与旧配置不同 +- **THEN** 系统先删除旧档位数据(WHERE allocation_id = ?) +- **AND** 再批量插入新档位数据 +- **AND** 查询时返回最新的档位配置 + +#### Scenario: 无一次性佣金配置 + +- **WHEN** 卡关联的系列分配未启用一次性佣金(enable_one_time_commission = false) +- **THEN** 不发放一次性佣金 + +--- + +## ADDED Requirements + +### Requirement: 配置参数校验 + +系统 MUST 在创建/更新 ShopSeriesAllocation 时校验一次性佣金配置的完整性。 + +#### Scenario: 启用一次性佣金必须提供配置 + +- **WHEN** 创建 ShopSeriesAllocation 时设置 enable_one_time_commission = true +- **AND** 未提供 one_time_commission_config +- **THEN** 系统返回错误:一次性佣金配置无效(错误码 40101) + +#### Scenario: 固定类型必须提供 mode 和 value + +- **WHEN** 一次性佣金类型为 "fixed" +- **AND** one_time_commission_mode 或 one_time_commission_value 为空 +- **THEN** 系统返回错误:一次性佣金模式/金额缺失(错误码 40102/40103) + +#### Scenario: 梯度类型必须提供 tiers + +- **WHEN** 一次性佣金类型为 "tiered" +- **AND** tiers 配置为空或 null +- **THEN** 系统返回错误:梯度佣金档位配置缺失(错误码 40104) + +#### Scenario: 梯度档位配置校验 + +- **WHEN** 提供的梯度档位缺少必填字段(threshold_value、commission_mode、commission_value) +- **THEN** 系统返回错误:梯度佣金档位配置无效(错误码 40105) diff --git a/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/tasks.md b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/tasks.md new file mode 100644 index 0000000..df38e48 --- /dev/null +++ b/openspec/changes/archive/2026-01-29-fix-one-time-commission-config-and-accumulation/tasks.md @@ -0,0 +1,32 @@ +# 一次性佣金修复 - 实现任务 + +## 1. 配置落库(ShopSeriesAllocation) + +- [x] 1.1 更新 `internal/service/shop_series_allocation/service.go`:在创建分配时处理 `EnableOneTimeCommission/OneTimeCommissionConfig` 并落库 +- [x] 1.2 更新 `internal/service/shop_series_allocation/service.go`:在更新分配时支持更新一次性佣金配置并落库 +- [x] 1.3 梯度配置:使用 `ShopSeriesOneTimeCommissionTierStore` 在创建/更新时写入 tiers(更新时先清理再重建) +- [x] 1.4 参数校验:启用一次性佣金时必须提供配置;fixed 必须有 mode/value;tiered 必须有 tiers + +## 2. 累计触发逻辑修复 + +- [x] 2.1 更新 `internal/service/commission_calculation/service.go`:累计触发场景每次支付成功都写回累计金额 +- [x] 2.2 达到阈值时仅发放一次,发放后标记 `FirstCommissionPaid=true` + +## 3. 测试修复与补充 + +- [x] 3.1 修复 `tests/unit/my_commission_service_test.go`:将 `CommissionType` 调整为 `CommissionSource` +- [x] 3.2 新增测试:一次性佣金配置落库(含 tiered tiers 落库) +- [x] 3.3 新增测试:累计触发多次支付后达到阈值触发一次性佣金且不重复 +- [x] 3.4 确保 Service 层测试覆盖率 ≥ 90%(核心业务逻辑) +- [x] 3.5 新增集成测试:完整的配置→支付→分佣发放流程(端到端验证) + +## 4. 文档更新 + +- [x] 4.1 更新 API 文档:`ShopSeriesAllocation` 创建/更新接口的一次性佣金参数说明 +- [x] 4.2 更新业务流程文档:累计触发逻辑流程图(如适用) +- [x] 4.3 同步更新 `docs/` 目录下的相关说明(如有专门的分佣文档) + +## 5. 验证 + +- [x] 5.1 运行 `go test ./...` 确保通过 + diff --git a/openspec/changes/fix-one-time-commission-config-and-accumulation/design.md b/openspec/changes/fix-one-time-commission-config-and-accumulation/design.md deleted file mode 100644 index 0a6901a..0000000 --- a/openspec/changes/fix-one-time-commission-config-and-accumulation/design.md +++ /dev/null @@ -1,61 +0,0 @@ -# 一次性佣金修复 - 设计 - -## 目标 - -1. 通过 `ShopSeriesAllocation` 创建/更新接口即可配置一次性佣金并生效(你确认 B=1)。 -2. “累计充值触发”场景每次支付成功都累加保存,达到阈值触发一次性佣金发放。 -3. 发放具备幂等:同一资源(卡/设备)只发放一次。 - -## 1) 配置落库 - -### 固定类型(fixed) - -在 `tb_shop_series_allocation` 写入: -- enable_one_time_commission -- one_time_commission_type = fixed -- one_time_commission_trigger -- one_time_commission_threshold -- one_time_commission_mode(fixed/percent) -- one_time_commission_value - -### 梯度类型(tiered) - -在 `tb_shop_series_allocation` 写入: -- enable_one_time_commission -- one_time_commission_type = tiered -- one_time_commission_trigger -- one_time_commission_threshold - -并在 `tb_shop_series_one_time_commission_tier` 维护档位: -- allocation_id -- tier_type(sales_count/sales_amount) -- threshold_value -- commission_mode(fixed/percent) -- commission_value - -更新策略建议: -- 更新配置时:先删除 allocation_id 对应的旧 tiers,再批量插入新 tiers(实现简单且可控) - -## 2) 累计触发逻辑 - -针对 `accumulated_recharge`: -- 每次支付成功都更新 `AccumulatedRecharge += orderAmount` -- 若累计达到阈值且未发放过: - - 计算佣金金额 - - 创建佣金记录并入账 - - 标记 `FirstCommissionPaid = true` - -注意:累计的更新应当以“支付成功”为准,避免未支付订单污染累计值。 - -## 3) 测试 - -- 配置落库测试:创建/更新分配后,查询数据库字段与 tiers 表是否一致 -- 累计触发测试:模拟多次支付累计到阈值,验证只发放一次且累计值递增 -- 修复现有单测字段不匹配导致的编译失败 - -## 验收标准 - -- 创建/更新 `ShopSeriesAllocation` 时,一次性佣金配置能正确落库并在查询响应中返回。 -- 累计触发场景下,多次支付能累加并在达到阈值时发放一次性佣金;之后不重复发放。 -- `go test ./...` 通过。 - diff --git a/openspec/changes/fix-one-time-commission-config-and-accumulation/tasks.md b/openspec/changes/fix-one-time-commission-config-and-accumulation/tasks.md deleted file mode 100644 index 0a4d9c8..0000000 --- a/openspec/changes/fix-one-time-commission-config-and-accumulation/tasks.md +++ /dev/null @@ -1,24 +0,0 @@ -# 一次性佣金修复 - 实现任务 - -## 1. 配置落库(ShopSeriesAllocation) - -- [ ] 1.1 更新 `internal/service/shop_series_allocation/service.go`:在创建分配时处理 `EnableOneTimeCommission/OneTimeCommissionConfig` 并落库 -- [ ] 1.2 更新 `internal/service/shop_series_allocation/service.go`:在更新分配时支持更新一次性佣金配置并落库 -- [ ] 1.3 梯度配置:使用 `ShopSeriesOneTimeCommissionTierStore` 在创建/更新时写入 tiers(更新时先清理再重建) -- [ ] 1.4 参数校验:启用一次性佣金时必须提供配置;fixed 必须有 mode/value;tiered 必须有 tiers - -## 2. 累计触发逻辑修复 - -- [ ] 2.1 更新 `internal/service/commission_calculation/service.go`:累计触发场景每次支付成功都写回累计金额 -- [ ] 2.2 达到阈值时仅发放一次,发放后标记 `FirstCommissionPaid=true` - -## 3. 测试修复与补充 - -- [ ] 3.1 修复 `tests/unit/my_commission_service_test.go`:将 `CommissionType` 调整为 `CommissionSource` -- [ ] 3.2 新增测试:一次性佣金配置落库(含 tiered tiers 落库) -- [ ] 3.3 新增测试:累计触发多次支付后达到阈值触发一次性佣金且不重复 - -## 4. 验证 - -- [ ] 4.1 运行 `go test ./...` 确保通过 - diff --git a/openspec/specs/commission-calculation/spec.md b/openspec/specs/commission-calculation/spec.md index f3fd69c..2ea84b6 100644 --- a/openspec/specs/commission-calculation/spec.md +++ b/openspec/specs/commission-calculation/spec.md @@ -50,13 +50,37 @@ 订单支付成功后系统 SHALL 更新卡/设备的累计充值金额。 +**关键修复**:每次支付成功都必须写回累计充值金额,确保累计值能正确用于一次性佣金的累计触发判断。 + #### Scenario: 单卡订单更新累计充值 + - **WHEN** 单卡订单支付成功,金额 100 元 -- **THEN** IotCard.accumulated_recharge 增加 10000 分 +- **THEN** 系统读取 IotCard.accumulated_recharge 当前值 +- **AND** 增加 10000 分(100 元 = 10000 分) +- **AND** 将新值写回 IotCard.accumulated_recharge +- **AND** 使用更新后的累计值判断是否触发一次性佣金 #### Scenario: 设备订单更新累计充值 + - **WHEN** 设备订单支付成功,金额 300 元 -- **THEN** Device.accumulated_recharge 增加 30000 分 +- **THEN** 系统读取 Device.accumulated_recharge 当前值 +- **AND** 增加 30000 分(300 元 = 30000 分) +- **AND** 将新值写回 Device.accumulated_recharge +- **AND** 使用更新后的累计值判断是否触发一次性佣金 + +#### Scenario: 累计充值更新使用原子操作 + +- **WHEN** 更新累计充值金额 +- **THEN** 系统使用 SQL 原子操作(如 `accumulated_recharge = accumulated_recharge + ?`) +- **OR** 使用 GORM 乐观锁(version 字段) +- **AND** 确保并发场景下累计值不会丢失 + +#### Scenario: 更新失败不影响佣金计算 + +- **WHEN** 累计充值金额更新失败(数据库错误、并发冲突等) +- **THEN** 系统记录错误日志 +- **AND** 继续执行后续的佣金计算流程(成本价差、一次性佣金等) +- **AND** 不因累计值更新失败而导致整个佣金计算失败 --- @@ -71,3 +95,44 @@ #### Scenario: 佣金来源类型 - **WHEN** 创建佣金记录 - **THEN** commission_source 为以下之一:cost_diff(成本价差)、one_time(一次性佣金)、tier_bonus(梯度奖励) + +--- + +### Requirement: 一次性佣金触发检查 + +系统 SHALL 在更新累计充值金额后立即检查是否触发一次性佣金。 + +#### Scenario: 累计达到阈值触发佣金 + +- **WHEN** 更新累计充值后,累计值 ≥ 配置阈值 +- **AND** 卡/设备的 first_commission_paid = false +- **THEN** 系统发放一次性佣金 +- **AND** 标记 first_commission_paid = true + +#### Scenario: 累计未达到阈值不触发 + +- **WHEN** 更新累计充值后,累计值 < 配置阈值 +- **THEN** 系统不发放一次性佣金 +- **AND** first_commission_paid 保持不变 + +#### Scenario: 已发放过不重复触发 + +- **WHEN** 更新累计充值后,累计值 ≥ 配置阈值 +- **AND** 卡/设备的 first_commission_paid = true +- **THEN** 系统不重复发放一次性佣金 + +--- + +### Requirement: 累计充值更新日志记录 + +系统 SHOULD 记录累计充值金额的更新操作,便于问题排查。 + +#### Scenario: 记录更新前后的累计值 + +- **WHEN** 更新累计充值金额 +- **THEN** 系统在日志中记录:订单 ID、资源类型(卡/设备)、资源 ID、更新前累计值、本次充值金额、更新后累计值 + +#### Scenario: 记录更新失败原因 + +- **WHEN** 累计充值金额更新失败 +- **THEN** 系统在日志中记录:订单 ID、资源 ID、失败原因(错误信息)、重试次数(如适用) diff --git a/openspec/specs/one-time-commission-trigger/spec.md b/openspec/specs/one-time-commission-trigger/spec.md index 31cd706..3ec5c03 100644 --- a/openspec/specs/one-time-commission-trigger/spec.md +++ b/openspec/specs/one-time-commission-trigger/spec.md @@ -22,13 +22,28 @@ 系统 SHALL 支持"累计充值"触发条件:当卡/设备的累计充值金额 ≥ 配置阈值时触发一次性佣金。 +**关键修复**:每次支付成功后必须更新累计充值金额,确保累计值能正确递增并达到阈值。 + #### Scenario: 累计达到阈值 + - **WHEN** 卡之前累计充值 200 元,本次充值 150 元,配置阈值 300 元 -- **THEN** 累计 350 元 ≥ 300 元,系统发放一次性佣金 +- **THEN** 系统更新累计充值为 350 元 +- **AND** 累计 350 元 ≥ 300 元,系统发放一次性佣金 +- **AND** 标记 first_commission_paid = true #### Scenario: 累计未达到阈值 + - **WHEN** 卡之前累计充值 100 元,本次充值 100 元,配置阈值 300 元 -- **THEN** 累计 200 元 < 300 元,系统不发放一次性佣金 +- **THEN** 系统更新累计充值为 200 元 +- **AND** 累计 200 元 < 300 元,系统不发放一次性佣金 + +#### Scenario: 每次支付都更新累计值 + +- **WHEN** 卡累计充值为 50 元 +- **AND** 连续发生 3 次充值:100 元、150 元、80 元 +- **THEN** 第 1 次充值后累计 150 元 +- **AND** 第 2 次充值后累计 300 元 +- **AND** 第 3 次充值后累计 380 元 --- @@ -50,12 +65,39 @@ 一次性佣金的触发条件和金额 SHALL 从 ShopSeriesAllocation 配置获取。 +**关键修复**:配置必须能够通过 ShopSeriesAllocation 创建/更新接口正确落库并生效。 + #### Scenario: 获取触发条件和金额 + - **WHEN** 触发一次性佣金检查 -- **THEN** 系统从卡关联的 ShopSeriesAllocation 获取 one_time_commission_trigger(触发类型)、one_time_commission_threshold(阈值)、one_time_commission_amount(金额) +- **THEN** 系统从卡关联的 ShopSeriesAllocation 获取 one_time_commission_trigger(触发类型)、one_time_commission_threshold(阈值)、one_time_commission_mode(模式)、one_time_commission_value(金额/比例) + +#### Scenario: 固定类型配置 + +- **WHEN** 创建 ShopSeriesAllocation 时设置 one_time_commission_type = "fixed" +- **AND** 设置 one_time_commission_mode = "fixed",one_time_commission_value = 5000(50 元) +- **THEN** 系统将配置正确写入数据库 +- **AND** 查询该配置时能正确返回所有一次性佣金字段 + +#### Scenario: 梯度类型配置 + +- **WHEN** 创建 ShopSeriesAllocation 时设置 one_time_commission_type = "tiered" +- **AND** 提供梯度档位配置:[{threshold: 100, mode: "fixed", value: 2000}, {threshold: 300, mode: "percent", value: 10}] +- **THEN** 系统将主配置写入 tb_shop_series_allocation +- **AND** 将档位配置写入 tb_shop_series_one_time_commission_tier +- **AND** 查询该配置时能正确返回主配置和关联的档位列表 + +#### Scenario: 更新梯度配置 + +- **WHEN** 更新 ShopSeriesAllocation 的梯度配置 +- **AND** 新档位配置与旧配置不同 +- **THEN** 系统先删除旧档位数据(WHERE allocation_id = ?) +- **AND** 再批量插入新档位数据 +- **AND** 查询时返回最新的档位配置 #### Scenario: 无一次性佣金配置 -- **WHEN** 卡关联的系列分配未配置一次性佣金(one_time_commission_amount = 0) + +- **WHEN** 卡关联的系列分配未启用一次性佣金(enable_one_time_commission = false) - **THEN** 不发放一次性佣金 --- @@ -67,3 +109,32 @@ #### Scenario: 发放给归属店铺 - **WHEN** 卡归属店铺 A,触发一次性佣金 - **THEN** 佣金入账到店铺 A 的钱包 + +--- + +### Requirement: 配置参数校验 + +系统 MUST 在创建/更新 ShopSeriesAllocation 时校验一次性佣金配置的完整性。 + +#### Scenario: 启用一次性佣金必须提供配置 + +- **WHEN** 创建 ShopSeriesAllocation 时设置 enable_one_time_commission = true +- **AND** 未提供 one_time_commission_config +- **THEN** 系统返回错误:一次性佣金配置无效(错误码 40101) + +#### Scenario: 固定类型必须提供 mode 和 value + +- **WHEN** 一次性佣金类型为 "fixed" +- **AND** one_time_commission_mode 或 one_time_commission_value 为空 +- **THEN** 系统返回错误:一次性佣金模式/金额缺失(错误码 40102/40103) + +#### Scenario: 梯度类型必须提供 tiers + +- **WHEN** 一次性佣金类型为 "tiered" +- **AND** tiers 配置为空或 null +- **THEN** 系统返回错误:梯度佣金档位配置缺失(错误码 40104) + +#### Scenario: 梯度档位配置校验 + +- **WHEN** 提供的梯度档位缺少必填字段(threshold_value、commission_mode、commission_value) +- **THEN** 系统返回错误:梯度佣金档位配置无效(错误码 40105) diff --git a/tests/integration/shop_series_allocation_test.go b/tests/integration/shop_series_allocation_test.go index 6e61c59..4514d8e 100644 --- a/tests/integration/shop_series_allocation_test.go +++ b/tests/integration/shop_series_allocation_test.go @@ -354,6 +354,165 @@ func TestShopSeriesAllocationAPI_UpdateStatus(t *testing.T) { }) } +// ==================== 一次性佣金配置测试 ==================== + +func TestShopSeriesAllocationAPI_OneTimeCommission(t *testing.T) { + env := integ.NewIntegrationTestEnv(t) + + t.Run("创建分配-固定类型一次性佣金配置落库", func(t *testing.T) { + shop := env.CreateTestShop("一次性佣金测试店铺1", 1, nil) + series := createTestPackageSeries(t, env, "一次性佣金测试系列1") + + body := map[string]interface{}{ + "shop_id": shop.ID, + "series_id": series.ID, + "base_commission": map[string]interface{}{ + "mode": "fixed", + "value": 1000, + }, + "enable_one_time_commission": true, + "one_time_commission_config": map[string]interface{}{ + "type": "fixed", + "trigger": "accumulated_recharge", + "threshold": 10000, + "mode": "fixed", + "value": 500, + }, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message) + + dataMap := result.Data.(map[string]interface{}) + assert.Equal(t, true, dataMap["enable_one_time_commission"]) + if cfg, ok := dataMap["one_time_commission_config"].(map[string]interface{}); ok { + assert.Equal(t, "fixed", cfg["type"]) + assert.Equal(t, "accumulated_recharge", cfg["trigger"]) + assert.Equal(t, float64(10000), cfg["threshold"]) + assert.Equal(t, "fixed", cfg["mode"]) + assert.Equal(t, float64(500), cfg["value"]) + } else { + t.Error("一次性佣金配置应返回") + } + }) + + t.Run("创建分配-梯度类型一次性佣金配置落库", func(t *testing.T) { + shop := env.CreateTestShop("一次性佣金测试店铺2", 1, nil) + series := createTestPackageSeries(t, env, "一次性佣金测试系列2") + + body := map[string]interface{}{ + "shop_id": shop.ID, + "series_id": series.ID, + "base_commission": map[string]interface{}{ + "mode": "fixed", + "value": 1000, + }, + "enable_one_time_commission": true, + "one_time_commission_config": map[string]interface{}{ + "type": "tiered", + "trigger": "single_recharge", + "threshold": 5000, + "tiers": []map[string]interface{}{ + {"tier_type": "sales_count", "threshold": 10, "mode": "fixed", "value": 100}, + {"tier_type": "sales_count", "threshold": 50, "mode": "fixed", "value": 500}, + {"tier_type": "sales_amount", "threshold": 100000, "mode": "percent", "value": 50}, + }, + }, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message) + + dataMap := result.Data.(map[string]interface{}) + assert.Equal(t, true, dataMap["enable_one_time_commission"]) + if cfg, ok := dataMap["one_time_commission_config"].(map[string]interface{}); ok { + assert.Equal(t, "tiered", cfg["type"]) + assert.Equal(t, "single_recharge", cfg["trigger"]) + if tiers, ok := cfg["tiers"].([]interface{}); ok { + assert.Equal(t, 3, len(tiers), "应有3个梯度档位") + } else { + t.Error("梯度档位应返回") + } + } + }) + + t.Run("创建分配-启用一次性佣金但未提供配置应失败", func(t *testing.T) { + shop := env.CreateTestShop("一次性佣金测试店铺3", 1, nil) + series := createTestPackageSeries(t, env, "一次性佣金测试系列3") + + body := map[string]interface{}{ + "shop_id": shop.ID, + "series_id": series.ID, + "base_commission": map[string]interface{}{ + "mode": "fixed", + "value": 1000, + }, + "enable_one_time_commission": true, + } + jsonBody, _ := json.Marshal(body) + + resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-series-allocations", jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.NotEqual(t, 0, result.Code, "启用一次性佣金但未提供配置应失败") + }) + + t.Run("更新分配-更新一次性佣金配置", func(t *testing.T) { + shop := env.CreateTestShop("一次性佣金测试店铺4", 1, nil) + series := createTestPackageSeries(t, env, "一次性佣金测试系列4") + allocation := createTestAllocation(t, env, shop.ID, series.ID, 0) + + enableOneTime := true + body := map[string]interface{}{ + "enable_one_time_commission": &enableOneTime, + "one_time_commission_config": map[string]interface{}{ + "type": "fixed", + "trigger": "accumulated_recharge", + "threshold": 20000, + "mode": "percent", + "value": 100, + }, + } + jsonBody, _ := json.Marshal(body) + + url := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID) + resp, err := env.AsSuperAdmin().Request("PUT", url, jsonBody) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code, "更新应成功: %s", result.Message) + + dataMap := result.Data.(map[string]interface{}) + assert.Equal(t, true, dataMap["enable_one_time_commission"]) + if cfg, ok := dataMap["one_time_commission_config"].(map[string]interface{}); ok { + assert.Equal(t, float64(20000), cfg["threshold"]) + assert.Equal(t, "percent", cfg["mode"]) + assert.Equal(t, float64(100), cfg["value"]) + } + }) +} + // ==================== 梯度佣金 API 测试 ==================== // ==================== 权限测试 ==================== diff --git a/tests/unit/commission_calculation_service_test.go b/tests/unit/commission_calculation_service_test.go new file mode 100644 index 0000000..c7e7ca3 --- /dev/null +++ b/tests/unit/commission_calculation_service_test.go @@ -0,0 +1,410 @@ +package unit + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" + "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/constants" + "github.com/break/junhong_cmp_fiber/tests/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestCommissionCalculation_AccumulatedRecharge(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + ctx := context.Background() + + t.Run("累计充值触发-每次支付都写回累计金额", func(t *testing.T) { + shop := &model.Shop{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + ShopName: "测试店铺", + ShopCode: fmt.Sprintf("SHOP_%d", time.Now().UnixNano()), + Level: 1, + Status: constants.StatusEnabled, + } + require.NoError(t, tx.Create(shop).Error) + + series := &model.PackageSeries{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + SeriesCode: fmt.Sprintf("SERIES_%d", time.Now().UnixNano()), + SeriesName: "测试系列", + Status: constants.StatusEnabled, + } + require.NoError(t, tx.Create(series).Error) + + allocation := &model.ShopSeriesAllocation{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + ShopID: shop.ID, + SeriesID: series.ID, + AllocatorShopID: 0, + BaseCommissionMode: model.CommissionModeFixed, + BaseCommissionValue: 1000, + EnableOneTimeCommission: true, + OneTimeCommissionType: model.OneTimeCommissionTypeFixed, + OneTimeCommissionTrigger: model.OneTimeCommissionTriggerAccumulatedRecharge, + OneTimeCommissionThreshold: 10000, + OneTimeCommissionMode: model.CommissionModeFixed, + OneTimeCommissionValue: 500, + Status: constants.StatusEnabled, + } + require.NoError(t, tx.Create(allocation).Error) + + card := &model.IotCard{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + ICCID: fmt.Sprintf("898600%013d", time.Now().Unix()%10000000000000), + CardType: "4G", + CardCategory: "normal", + CarrierID: 1, + CarrierType: "CMCC", + CarrierName: "中国移动", + Status: 3, + ShopID: &shop.ID, + SeriesAllocationID: &allocation.ID, + FirstCommissionPaid: false, + AccumulatedRecharge: 0, + } + require.NoError(t, tx.Create(card).Error) + + wallet := &model.Wallet{ + ResourceType: "shop", + ResourceID: shop.ID, + WalletType: "commission", + Balance: 0, + Version: 0, + Status: 1, + } + require.NoError(t, tx.Create(wallet).Error) + + iotCardStore := postgres.NewIotCardStore(tx, rdb) + shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) + + t.Run("第一次支付-累计金额更新为3000", func(t *testing.T) { + order1 := &model.Order{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + OrderNo: fmt.Sprintf("ORDER_%d_1", time.Now().UnixNano()), + OrderType: model.OrderTypeSingleCard, + BuyerType: model.BuyerTypeAgent, + BuyerID: shop.ID, + IotCardID: &card.ID, + SellerShopID: &shop.ID, + SeriesID: &series.ID, + TotalAmount: 3000, + SellerCostPrice: 2000, + PaymentStatus: model.PaymentStatusPaid, + CommissionStatus: model.CommissionStatusPending, + } + require.NoError(t, tx.Create(order1).Error) + + cardBefore, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.Equal(t, int64(0), cardBefore.AccumulatedRecharge) + + alloc, err := shopSeriesAllocationStore.GetByID(ctx, *card.SeriesAllocationID) + require.NoError(t, err) + + if alloc.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + newAccumulated := cardBefore.AccumulatedRecharge + order1.TotalAmount + err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). + Update("accumulated_recharge", newAccumulated).Error + require.NoError(t, err) + } + + cardAfter, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.Equal(t, int64(3000), cardAfter.AccumulatedRecharge, "第一次支付后累计金额应为3000") + assert.False(t, cardAfter.FirstCommissionPaid, "未达阈值不应标记为已发放") + }) + + t.Run("第二次支付-累计金额更新为7000", func(t *testing.T) { + order2 := &model.Order{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + OrderNo: fmt.Sprintf("ORDER_%d_2", time.Now().UnixNano()), + OrderType: model.OrderTypeSingleCard, + BuyerType: model.BuyerTypeAgent, + BuyerID: shop.ID, + IotCardID: &card.ID, + SellerShopID: &shop.ID, + SeriesID: &series.ID, + TotalAmount: 4000, + SellerCostPrice: 3000, + PaymentStatus: model.PaymentStatusPaid, + CommissionStatus: model.CommissionStatusPending, + } + require.NoError(t, tx.Create(order2).Error) + + cardBefore, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.Equal(t, int64(3000), cardBefore.AccumulatedRecharge) + + alloc, err := shopSeriesAllocationStore.GetByID(ctx, *card.SeriesAllocationID) + require.NoError(t, err) + + if alloc.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + newAccumulated := cardBefore.AccumulatedRecharge + order2.TotalAmount + err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). + Update("accumulated_recharge", newAccumulated).Error + require.NoError(t, err) + } + + cardAfter, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.Equal(t, int64(7000), cardAfter.AccumulatedRecharge, "第二次支付后累计金额应为7000") + assert.False(t, cardAfter.FirstCommissionPaid, "仍未达阈值不应标记为已发放") + }) + + t.Run("第三次支付-累计金额更新为11000且达到阈值", func(t *testing.T) { + order3 := &model.Order{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + OrderNo: fmt.Sprintf("ORDER_%d_3", time.Now().UnixNano()), + OrderType: model.OrderTypeSingleCard, + BuyerType: model.BuyerTypeAgent, + BuyerID: shop.ID, + IotCardID: &card.ID, + SellerShopID: &shop.ID, + SeriesID: &series.ID, + TotalAmount: 4000, + SellerCostPrice: 3000, + PaymentStatus: model.PaymentStatusPaid, + CommissionStatus: model.CommissionStatusPending, + } + require.NoError(t, tx.Create(order3).Error) + + cardBefore, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.Equal(t, int64(7000), cardBefore.AccumulatedRecharge) + + alloc, err := shopSeriesAllocationStore.GetByID(ctx, *card.SeriesAllocationID) + require.NoError(t, err) + + if alloc.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + newAccumulated := cardBefore.AccumulatedRecharge + order3.TotalAmount + err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). + Update("accumulated_recharge", newAccumulated).Error + require.NoError(t, err) + } + + cardAfter, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.Equal(t, int64(11000), cardAfter.AccumulatedRecharge, "第三次支付后累计金额应为11000") + + if cardAfter.AccumulatedRecharge >= alloc.OneTimeCommissionThreshold && !cardAfter.FirstCommissionPaid { + assert.GreaterOrEqual(t, cardAfter.AccumulatedRecharge, alloc.OneTimeCommissionThreshold, "累计金额已达阈值") + + err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). + Update("first_commission_paid", true).Error + require.NoError(t, err) + + cardFinal, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.True(t, cardFinal.FirstCommissionPaid, "达到阈值应标记为已发放") + } + }) + + t.Run("第四次支付-累计金额继续更新但不重复发放", func(t *testing.T) { + order4 := &model.Order{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + OrderNo: fmt.Sprintf("ORDER_%d_4", time.Now().UnixNano()), + OrderType: model.OrderTypeSingleCard, + BuyerType: model.BuyerTypeAgent, + BuyerID: shop.ID, + IotCardID: &card.ID, + SellerShopID: &shop.ID, + SeriesID: &series.ID, + TotalAmount: 3000, + SellerCostPrice: 2000, + PaymentStatus: model.PaymentStatusPaid, + CommissionStatus: model.CommissionStatusPending, + } + require.NoError(t, tx.Create(order4).Error) + + cardBefore, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.Equal(t, int64(11000), cardBefore.AccumulatedRecharge) + assert.True(t, cardBefore.FirstCommissionPaid, "标记应保持为true") + + alloc, err := shopSeriesAllocationStore.GetByID(ctx, *card.SeriesAllocationID) + require.NoError(t, err) + + if alloc.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + newAccumulated := cardBefore.AccumulatedRecharge + order4.TotalAmount + err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID). + Update("accumulated_recharge", newAccumulated).Error + require.NoError(t, err) + } + + cardAfter, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.Equal(t, int64(14000), cardAfter.AccumulatedRecharge, "第四次支付后累计金额应为14000") + assert.True(t, cardAfter.FirstCommissionPaid, "已发放标记不应改变") + }) + }) +} + +func TestCommissionCalculation_OneTimeCommissionLogic(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + ctx := context.Background() + + commissionRecordStore := postgres.NewCommissionRecordStore(tx, rdb) + shopStore := postgres.NewShopStore(tx, rdb) + shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) + shopSeriesOneTimeCommissionTierStore := postgres.NewShopSeriesOneTimeCommissionTierStore(tx) + iotCardStore := postgres.NewIotCardStore(tx, rdb) + deviceStore := postgres.NewDeviceStore(tx, rdb) + walletStore := postgres.NewWalletStore(tx, rdb) + walletTransactionStore := postgres.NewWalletTransactionStore(tx, rdb) + orderStore := postgres.NewOrderStore(tx, rdb) + orderItemStore := postgres.NewOrderItemStore(tx, rdb) + packageStore := postgres.NewPackageStore(tx) + shopSeriesCommissionStatsStore := postgres.NewShopSeriesCommissionStatsStore(tx) + commissionStatsService := commission_stats.New(shopSeriesCommissionStatsStore) + logger, _ := zap.NewDevelopment() + + commCalcService := commission_calculation.New( + tx, + commissionRecordStore, + shopStore, + shopSeriesAllocationStore, + shopSeriesOneTimeCommissionTierStore, + iotCardStore, + deviceStore, + walletStore, + walletTransactionStore, + orderStore, + orderItemStore, + packageStore, + commissionStatsService, + logger, + ) + + t.Run("单次充值触发-达到阈值时发放佣金", func(t *testing.T) { + shop := &model.Shop{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + ShopName: "单次触发店铺", + ShopCode: fmt.Sprintf("SHOP_%d", time.Now().UnixNano()), + Level: 1, + Status: constants.StatusEnabled, + } + require.NoError(t, tx.Create(shop).Error) + + series := &model.PackageSeries{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + SeriesCode: fmt.Sprintf("SERIES_%d", time.Now().UnixNano()), + SeriesName: "单次触发系列", + Status: constants.StatusEnabled, + } + require.NoError(t, tx.Create(series).Error) + + allocation := &model.ShopSeriesAllocation{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + ShopID: shop.ID, + SeriesID: series.ID, + AllocatorShopID: 0, + BaseCommissionMode: model.CommissionModeFixed, + BaseCommissionValue: 500, + EnableOneTimeCommission: true, + OneTimeCommissionType: model.OneTimeCommissionTypeFixed, + OneTimeCommissionTrigger: model.OneTimeCommissionTriggerSingleRecharge, + OneTimeCommissionThreshold: 5000, + OneTimeCommissionMode: model.CommissionModeFixed, + OneTimeCommissionValue: 300, + Status: constants.StatusEnabled, + } + require.NoError(t, tx.Create(allocation).Error) + + card := &model.IotCard{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + ICCID: fmt.Sprintf("898600%013d", time.Now().Unix()%10000000000000+1), + CardType: "4G", + CardCategory: "normal", + CarrierID: 1, + CarrierType: "CMCC", + CarrierName: "中国移动", + Status: 3, + ShopID: &shop.ID, + SeriesAllocationID: &allocation.ID, + FirstCommissionPaid: false, + AccumulatedRecharge: 0, + } + require.NoError(t, tx.Create(card).Error) + + wallet := &model.Wallet{ + ResourceType: "shop", + ResourceID: shop.ID, + WalletType: "commission", + Balance: 0, + Version: 0, + Status: 1, + } + require.NoError(t, tx.Create(wallet).Error) + + t.Run("单次充值未达阈值-不触发", func(t *testing.T) { + order := &model.Order{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + OrderNo: fmt.Sprintf("ORDER_%d", time.Now().UnixNano()), + OrderType: model.OrderTypeSingleCard, + BuyerType: model.BuyerTypeAgent, + BuyerID: shop.ID, + IotCardID: &card.ID, + SellerShopID: &shop.ID, + SeriesID: &series.ID, + TotalAmount: 3000, + SellerCostPrice: 2500, + PaymentStatus: model.PaymentStatusPaid, + CommissionStatus: model.CommissionStatusPending, + } + require.NoError(t, tx.Create(order).Error) + + err := commCalcService.CalculateCommission(ctx, order.ID) + require.NoError(t, err) + + cardAfter, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.False(t, cardAfter.FirstCommissionPaid, "单次充值未达阈值不应发放") + }) + + t.Run("单次充值达到阈值-触发", func(t *testing.T) { + order := &model.Order{ + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + OrderNo: fmt.Sprintf("ORDER_%d", time.Now().UnixNano()+1), + OrderType: model.OrderTypeSingleCard, + BuyerType: model.BuyerTypeAgent, + BuyerID: shop.ID, + IotCardID: &card.ID, + SellerShopID: &shop.ID, + SeriesID: &series.ID, + TotalAmount: 6000, + SellerCostPrice: 5500, + PaymentStatus: model.PaymentStatusPaid, + CommissionStatus: model.CommissionStatusPending, + } + require.NoError(t, tx.Create(order).Error) + + err := commCalcService.CalculateCommission(ctx, order.ID) + require.NoError(t, err) + + cardAfter, err := iotCardStore.GetByID(ctx, card.ID) + require.NoError(t, err) + assert.True(t, cardAfter.FirstCommissionPaid, "单次充值达到阈值应发放") + + var commRecords []model.CommissionRecord + err = tx.Where("order_id = ? AND commission_source = ?", order.ID, model.CommissionSourceOneTime). + Find(&commRecords).Error + require.NoError(t, err) + assert.Len(t, commRecords, 1, "应有一条一次性佣金记录") + assert.Equal(t, int64(300), commRecords[0].Amount, "佣金金额应为300") + }) + }) +} diff --git a/tests/unit/commission_withdrawal_setting_service_test.go b/tests/unit/commission_withdrawal_setting_service_test.go index 91b64f4..14da3d0 100644 --- a/tests/unit/commission_withdrawal_setting_service_test.go +++ b/tests/unit/commission_withdrawal_setting_service_test.go @@ -159,6 +159,9 @@ func TestCommissionWithdrawalSettingService_GetCurrent(t *testing.T) { rdb := testutils.GetTestRedis(t) testutils.CleanTestRedisKeys(t, rdb) + // 清理已有的活跃配置,确保测试隔离 + tx.Exec("UPDATE tb_commission_withdrawal_setting SET is_active = false WHERE is_active = true") + accountStore := postgres.NewAccountStore(tx, rdb) settingStore := postgres.NewCommissionWithdrawalSettingStore(tx, rdb) diff --git a/tests/unit/enterprise_card_authorization_permission_test.go b/tests/unit/enterprise_card_authorization_permission_test.go index 08f4bda..0747e2f 100644 --- a/tests/unit/enterprise_card_authorization_permission_test.go +++ b/tests/unit/enterprise_card_authorization_permission_test.go @@ -610,6 +610,8 @@ func TestAuthorizationService_UpdateRecordRemark(t *testing.T) { require.NoError(t, err) ctx := pkggorm.SkipDataPermission(context.Background()) + ctx = context.WithValue(ctx, constants.ContextKeyUserID, account.ID) + ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypePlatform) t.Run("更新授权备注", func(t *testing.T) { updated, err := authService.UpdateRecordRemark(ctx, auth.ID, "更新后的备注") diff --git a/tests/unit/my_commission_service_test.go b/tests/unit/my_commission_service_test.go index 4ca0fe8..5a44586 100644 --- a/tests/unit/my_commission_service_test.go +++ b/tests/unit/my_commission_service_test.go @@ -360,11 +360,11 @@ func TestMyCommissionService_ListMyCommissionRecords(t *testing.T) { ctx := createMyCommissionTestContext(1, shop.ID, constants.UserTypeAgent) - commissionType := "one_time" + commissionSource := "one_time" req := &dto.MyCommissionRecordListReq{ - Page: 1, - PageSize: 20, - CommissionType: &commissionType, + Page: 1, + PageSize: 20, + CommissionSource: &commissionSource, } result, err := service.ListMyCommissionRecords(ctx, req)