# 一次性佣金修复 - 设计 ## 目标 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 ./...` 通过。