# 一次性佣金修复 - 设计

## 目标

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 ./...` 通过。

