## Context

当前套餐分配和佣金系统（add-shop-package-allocation）的实现存在多个架构和业务逻辑问题：

**当前系统问题**：
1. **接口设计违背系统原则**：创建了独立的 `/api/admin/my-packages` 接口来查询代理可售套餐，但系统已有完善的 GORM Callback 数据权限自动过滤机制，应该通过扩展 `/api/admin/packages` 接口实现
2. **加价模式设计不合理**：通过 `PricingMode` 和 `PricingValue` 实现自动加价计算（固定金额或百分比），但实际业务中代理需要手动调整成本价，自动计算反而增加复杂度
3. **梯度佣金逻辑错误**：当前实现将梯度佣金理解为"销量达标后额外奖励 N 元"，正确的业务逻辑应该是"销量达标后提升返佣比例（从 20% 提升到 30%）"
4. **返佣模式不完整**：只实现了一次性佣金触发，缺少基础返佣配置；应支持固定金额和百分比两种模式
5. **缺少数据一致性保障**：
   - 配置变更后无法追溯历史订单使用的配置
   - 成本价调整无历史记录，无法审计
   - 梯度统计实时计算，高频充值场景下性能问题
   - 缺少软删除和级联状态管理

**技术债务**：
- 代码冗余：独立的 MyPackageService、MyPackageHandler、MyPackageStore
- API 冗余：3个独立接口（my-packages、my-packages/:id、my-series-allocations）
- 数据模型不一致：ShopSeriesAllocation 字段命名和用途不明确

**约束条件**：
- 系统处于开发阶段，可以大改，无需数据迁移
- 必须遵循项目规范：禁止外键约束，关联通过 ID 手动维护
- 必须使用 GORM Callback 实现数据权限自动过滤
- 使用 Redis + Asynq 支持异步任务

## Goals / Non-Goals

**Goals:**
- 统一接口设计，删除独立的 my-packages 接口，通过数据权限自动过滤实现代理可售套餐查询
- 简化分配流程，删除自动加价计算，改为批量分配时可选加价 + 后续手动调整
- 修正梯度佣金逻辑，实现"销量达标提升返佣比例"而非"额外奖励"
- 完善基础返佣配置，支持固定金额和百分比两种模式
- 增强数据一致性，新增配置版本表、成本价历史表、统计缓存表
- 优化性能，梯度统计改为异步更新 + Redis 缓存

**Non-Goals:**
- 不实现梯度返佣追溯功能（达标后不补差历史订单，简化实现）
- 不支持跨周期的滚动窗口统计（如"任意连续 30 天"，只支持固定周期）
- 不实现复杂的返佣策略（如阶梯定价、组合返佣等，使用策略模式预留扩展性）
- 不实现手动审批流程（所有返佣自动发放）
- 本期不实现长期分佣和冻结解冻机制（流量卡业务暂不需要）

## Decisions

### 决策 1：分阶段实施，阶段 1 实现完整功能

**决策**：一次性实现阶段 1（MVP）和阶段 2（增强版）的所有功能

**理由**：
- 系统处于开发阶段，可以一次性调整到位
- 配置版本管理和历史记录是核心功能，不应作为"增强"而应作为"必需"
- 一次性实现避免二次重构

**数据模型（完整版）**：

```go
// 1. ShopSeriesAllocation - 重构
type ShopSeriesAllocation struct {
    gorm.Model
    BaseModel
    ShopID                  uint
    SeriesID                uint
    AllocatorShopID         uint
    
    // ❌ 删除字段
    // PricingMode             string
    // PricingValue            int64
    // OneTimeCommissionTrigger   string
    // OneTimeCommissionThreshold int64
    // OneTimeCommissionAmount    int64
    
    // ✅ 新增字段
    BaseCommissionMode      string // "fixed" 或 "percent"
    BaseCommissionValue     int64  // 固定金额(分) 或 百分比(千分比)
    EnableTierCommission    bool   // 是否启用梯度返佣
    
    Status                  int
}

// 2. ShopSeriesCommissionTier - 重构
type ShopSeriesCommissionTier struct {
    gorm.Model
    BaseModel
    AllocationID        uint
    
    // 统计周期
    PeriodType          string     // monthly/quarterly/yearly
    
    // 梯度类型和阈值
    TierType            string     // sales_count/sales_amount
    ThresholdValue      int64
    
    // ✅ 新增字段：达标后的返佣配置
    CommissionMode      string     // "fixed" 或 "percent"
    CommissionValue     int64
}

// 3. ShopPackageAllocation - 保持不变
type ShopPackageAllocation struct {
    gorm.Model
    BaseModel
    ShopID              uint
    PackageID           uint
    SeriesAllocationID  uint
    CostPrice           int64
    Status              int
}

// 4. 🆕 ShopSeriesAllocationConfig - 配置版本表
type ShopSeriesAllocationConfig struct {
    gorm.Model
    AllocationID        uint
    Version             int
    
    // 配置快照
    BaseCommissionMode  string
    BaseCommissionValue int64
    EnableTierCommission bool
    
    EffectiveFrom       time.Time
    EffectiveTo         *time.Time
}

// 5. 🆕 ShopPackageAllocationPriceHistory - 成本价历史
type ShopPackageAllocationPriceHistory struct {
    gorm.Model
    AllocationID        uint
    OldCostPrice        int64
    NewCostPrice        int64
    ChangeReason        string
    ChangedBy           uint
    EffectiveFrom       time.Time
}

// 6. 🆕 ShopSeriesCommissionStats - 统计缓存
type ShopSeriesCommissionStats struct {
    gorm.Model
    AllocationID        uint
    PeriodType          string
    PeriodStart         time.Time
    PeriodEnd           time.Time
    
    // 统计数据
    TotalSalesCount     int64
    TotalSalesAmount    int64
    CurrentTierID       *uint
    
    // 性能优化
    LastUpdatedAt       time.Time
    Version             int       // 乐观锁
    Status              string    // active/completed/cancelled
}
```

---

### 决策 2：删除独立的 my-packages 接口，通过数据权限自动过滤

**决策**：删除所有 my-packages 相关代码，扩展 `/api/admin/packages` 接口

**实现方式**：

```go
// PackageStore 增加代理权限过滤
func (s *PackageStore) List(ctx context.Context, filters PackageListFilters) ([]model.Package, int64, error) {
    db := s.db.WithContext(ctx)
    
    // 1. GORM Callback 自动应用基础数据权限
    
    // 2. 代理用户额外过滤：JOIN ShopPackageAllocation
    userInfo := gormx.GetUserInfoFromContext(ctx)
    if userInfo != nil && userInfo.UserType == constants.UserTypeAgent {
        db = db.Joins("INNER JOIN tb_shop_package_allocation ON tb_shop_package_allocation.package_id = tb_package.id").
            Where("tb_shop_package_allocation.shop_id = ? AND tb_shop_package_allocation.status = ?", 
                userInfo.ShopID, constants.StatusEnabled)
    }
    
    // 3. 应用其他筛选条件
    // ...
}

// PackageService 补充代理字段
func (s *Service) toPackageResponse(ctx context.Context, pkg *model.Package) dto.PackageResponse {
    resp := dto.PackageResponse{
        // ... 基础字段
    }
    
    // 代理用户：补充成本价和返佣信息
    userInfo := gormx.GetUserInfoFromContext(ctx)
    if userInfo != nil && userInfo.UserType == constants.UserTypeAgent {
        allocation, _ := s.packageAllocationStore.GetByShopAndPackage(ctx, userInfo.ShopID, pkg.ID)
        if allocation != nil {
            resp.CostPrice = &allocation.CostPrice
            profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
            resp.ProfitMargin = &profitMargin
            
            // 查询当前返佣信息
            commissionInfo := s.getCommissionInfo(ctx, allocation.SeriesAllocationID)
            resp.CurrentCommissionRate = commissionInfo.CurrentRate
            resp.TierInfo = commissionInfo.TierInfo
        }
    }
    
    return resp
}
```

**删除的代码**：
- `internal/handler/admin/my_package.go`
- `internal/service/my_package/service.go`
- `internal/model/dto/my_package_dto.go`
- `internal/routes/my_package.go`
- Bootstrap 和路由注册中的相关代码

---

### 决策 3：批量分配支持可选加价，后续手动调整

**决策**：删除自动加价计算，改为批量分配时一次性计算 + 后续手动调整

**新增接口**：

```
POST /api/admin/shop-package-allocations/batch

Request:
{
  "shop_id": 10,
  "series_id": 5,
  "price_adjustment": {              // 可选：批量加价
    "type": "percent",               // "fixed" 或 "percent"
    "value": 100                     // 10% 或固定金额(分)
  },
  "base_commission": {               // 必填：基础返佣配置
    "mode": "percent",               // "fixed" 或 "percent"
    "value": 200                     // 20% 或固定金额(分)
  },
  "enable_tier_commission": true,    // 可选：启用梯度返佣
  "tier_config": {                   // 可选：梯度配置
    "period_type": "monthly",
    "tier_type": "sales_count",
    "tiers": [
      { "threshold": 100, "mode": "percent", "value": 300 },
      { "threshold": 200, "mode": "percent", "value": 400 },
      { "threshold": 500, "mode": "percent", "value": 500 }
    ]
  }
}

系统行为：
1. 验证权限（只能分配给直属下级）
2. 验证系列已被分配给自己
3. 获取系列下所有启用的套餐
4. 批量计算成本价（如果提供了 price_adjustment）
5. 创建 ShopSeriesAllocation（存储返佣配置）
6. 创建配置版本（ShopSeriesAllocationConfig）
7. 批量创建 ShopPackageAllocation（使用 CreateInBatches）
8. 如启用梯度，批量创建 ShopSeriesCommissionTier
9. 初始化统计记录（ShopSeriesCommissionStats）
```

**批量调价接口**：

```
PATCH /api/admin/shop-package-allocations/batch-update

Request:
{
  "shop_id": 10,
  "series_id": 5,                    // 可选：不填则调整所有
  "price_adjustment": {
    "type": "percent",
    "value": 50                      // 再加价 5%
  }
}

系统行为：
1. 查询符合条件的 ShopPackageAllocation
2. 批量计算新成本价
3. 批量更新（使用事务）
4. 批量创建历史记录（ShopPackageAllocationPriceHistory）
```

---

### 决策 4：配置变更时创建新版本，订单锁定配置版本

**决策**：配置变更不直接修改 ShopSeriesAllocation，而是创建新的配置版本

**实现方式**：

```go
// 更新配置时
func (s *Service) UpdateAllocation(ctx context.Context, id uint, req dto.UpdateRequest) error {
    return s.db.Transaction(func(tx *gorm.DB) error {
        // 1. 查询当前配置
        allocation, _ := s.allocationStore.GetByID(ctx, id)
        
        // 2. 检查配置是否变化
        configChanged := (allocation.BaseCommissionMode != req.BaseCommissionMode ||
                         allocation.BaseCommissionValue != req.BaseCommissionValue ||
                         allocation.EnableTierCommission != req.EnableTierCommission)
        
        if configChanged {
            // 3. 失效当前配置版本
            s.configStore.InvalidateCurrent(ctx, id, time.Now())
            
            // 4. 创建新配置版本
            newVersion := model.ShopSeriesAllocationConfig{
                AllocationID:         id,
                Version:              currentVersion + 1,
                BaseCommissionMode:   req.BaseCommissionMode,
                BaseCommissionValue:  req.BaseCommissionValue,
                EnableTierCommission: req.EnableTierCommission,
                EffectiveFrom:        time.Now(),
            }
            s.configStore.Create(ctx, &newVersion)
        }
        
        // 5. 更新 ShopSeriesAllocation 主表
        s.allocationStore.Update(ctx, allocation)
        
        return nil
    })
}

// 订单创建时锁定配置
func CreateRechargeOrder(ctx context.Context, req CreateOrderRequest) {
    // 1. 查询当前生效的配置版本
    config := s.configStore.GetEffective(ctx, allocationID, time.Now())
    
    // 2. 锁定配置到订单
    order := RechargeOrder{
        AllocationConfigID:    config.ID,
        LockedCommissionMode:  config.BaseCommissionMode,
        LockedCommissionValue: config.BaseCommissionValue,
        // ... 其他字段
    }
    
    s.orderStore.Create(ctx, &order)
}
```

---

### 决策 5：梯度统计异步更新 + Redis 缓存

**决策**：梯度统计不实时计算，改为异步更新 + Redis 缓存

**实现方式**：

```go
// 充值成功后：异步更新统计
func OnRechargeSuccess(ctx context.Context, order RechargeOrder) {
    // 1. 立即返回（不阻塞用户）
    
    // 2. 发送消息到队列
    task := asynq.NewTask("commission:stats:update", map[string]interface{}{
        "allocation_id": order.AllocationID,
        "sales_count":   1,
        "sales_amount":  order.Amount,
    })
    s.queueClient.Enqueue(task)
}

// 异步任务：更新统计
func UpdateCommissionStats(ctx context.Context, payload map[string]interface{}) error {
    allocationID := payload["allocation_id"]
    salesCount := payload["sales_count"]
    salesAmount := payload["sales_amount"]
    
    // 1. 获取当前周期
    period := getCurrentPeriod(time.Now(), periodType)
    
    // 2. Redis 原子递增
    key := fmt.Sprintf("commission:stats:%d:%s", allocationID, period)
    s.redis.HIncrBy(key, "total_count", salesCount)
    s.redis.HIncrBy(key, "total_amount", salesAmount)
    s.redis.ExpireAt(key, period.End.Add(7*24*time.Hour))
    
    // 3. 定时任务（每小时）同步到数据库
    // 4. 判断档位变化，更新 current_tier_id
    
    return nil
}

// 查询当前返佣信息
func GetCommissionInfo(ctx context.Context, allocationID uint) CommissionInfo {
    // 1. 优先从 Redis 获取统计数据
    key := fmt.Sprintf("commission:stats:%d:%s", allocationID, getCurrentPeriod())
    stats := s.redis.HGetAll(key)
    
    // 2. 如果 Redis 不存在，从数据库获取
    if len(stats) == 0 {
        dbStats := s.statsStore.GetCurrent(ctx, allocationID)
        // ...
    }
    
    // 3. 查询梯度配置，判断当前档位
    tiers := s.tierStore.ListByAllocationID(ctx, allocationID)
    currentTier := findMatchingTier(stats["total_count"], tiers)
    
    // 4. 返回当前返佣信息
    return CommissionInfo{
        CurrentRate:   currentTier.CommissionValue,
        CurrentTierID: currentTier.ID,
        NextThreshold: findNextTier(tiers, currentTier).ThresholdValue,
        // ...
    }
}
```

---

### 决策 6：不追溯历史订单（简化实现）

**决策**：梯度返佣达标后，只对后续充值按新比例计算，不补差历史订单

**理由**：
- 简化实现，避免复杂的追溯计算和补差逻辑
- 代理容易理解："从达标开始，后续充值按新比例"
- 减少纠纷：不需要解释"哪些订单补差，哪些不补差"

**业务逻辑**：

```
1月1日-15日: 销量50，返佣20%
  └─ 订单1: 充值100元 → 返佣20元

1月16日: 销量达到100，提升到30%
  └─ 订单2: 充值100元 → 返佣30元 ✅

1月1日-15日的订单: 保持20%（不补差）
```

---

### 决策 7：成本价调整记录历史

**决策**：每次调整成本价时，记录历史到 ShopPackageAllocationPriceHistory

**实现方式**：

```go
func (s *Service) UpdateCostPrice(ctx context.Context, id uint, newCostPrice int64, reason string) error {
    return s.db.Transaction(func(tx *gorm.DB) error {
        // 1. 查询当前成本价
        allocation, _ := s.allocationStore.GetByID(ctx, id)
        oldCostPrice := allocation.CostPrice
        
        // 2. 创建历史记录
        history := model.ShopPackageAllocationPriceHistory{
            AllocationID:  id,
            OldCostPrice:  oldCostPrice,
            NewCostPrice:  newCostPrice,
            ChangeReason:  reason,
            ChangedBy:     getUserIDFromContext(ctx),
            EffectiveFrom: time.Now(),
        }
        s.historyStore.Create(ctx, &history)
        
        // 3. 更新主表
        allocation.CostPrice = newCostPrice
        s.allocationStore.Update(ctx, allocation)
        
        return nil
    })
}
```

## Risks / Trade-offs

### 风险 1：配置版本表增加存储空间

**风险**：每次配置变更都创建新版本，长期运营后可能产生大量历史数据

**缓解**：
- 定期归档（如保留2年内的版本，超过2年的归档到冷存储）
- 索引优化（在 allocation_id + effective_from 上建立索引）
- 查询优化（查询当前生效版本时使用 `WHERE effective_to IS NULL`）

### 风险 2：Redis 缓存失效导致统计不准确

**风险**：Redis 故障或数据丢失，导致统计数据不准确

**缓解**：
- Redis 持久化（AOF + RDB）
- 定时同步到数据库（每小时一次）
- 数据库作为兜底（Redis 不存在时从数据库重建）
- 每个周期结束后，统计数据归档到数据库并清除 Redis

### 风险 3：批量操作事务超时

**风险**：批量分配大量套餐（如1000个套餐给100个代理）时，事务可能超时

**缓解**：
- 使用 `CreateInBatches`（每批500条）
- 分批处理：超过1000条时，拆分为多个事务
- 设置合理的事务超时时间（60秒）

### 风险 4：数据权限过滤性能问题

**风险**：JOIN ShopPackageAllocation 可能影响查询性能

**缓解**：
- 在 `tb_shop_package_allocation.shop_id` 和 `package_id` 上建立复合索引
- 使用 `EXPLAIN` 分析查询计划
- 如有性能问题，考虑使用子查询或物化视图

### Trade-off 1：不追溯 vs 代理期望

**权衡**：不追溯历史订单可能不符合部分代理的期望（他们可能希望达标后补差）

**选择**：简化实现优先
- 明确告知代理："达标后，后续充值按新比例"
- 如有强烈需求，可在阶段2增加追溯功能

### Trade-off 2：配置版本复杂度 vs 数据一致性

**权衡**：配置版本增加了实现复杂度，但保证了数据一致性

**选择**：数据一致性优先
- 历史订单必须可追溯，避免纠纷
- 审计需求（财务、运营）要求完整历史

## Migration Plan

### 阶段 1：数据库迁移

```sql
-- 1. 修改 tb_shop_series_allocation
ALTER TABLE tb_shop_series_allocation
DROP COLUMN pricing_mode,
DROP COLUMN pricing_value,
DROP COLUMN one_time_commission_trigger,
DROP COLUMN one_time_commission_threshold,
DROP COLUMN one_time_commission_amount,
ADD COLUMN base_commission_mode VARCHAR(20) NOT NULL DEFAULT 'percent',
ADD COLUMN base_commission_value BIGINT NOT NULL DEFAULT 0,
ADD COLUMN enable_tier_commission BOOLEAN NOT NULL DEFAULT FALSE;

-- 2. 修改 tb_shop_series_commission_tier
ALTER TABLE tb_shop_series_commission_tier
ADD COLUMN commission_mode VARCHAR(20) NOT NULL DEFAULT 'percent';

-- 3. 创建新表
CREATE TABLE tb_shop_series_allocation_config (...);
CREATE TABLE tb_shop_package_allocation_price_history (...);
CREATE TABLE tb_shop_series_commission_stats (...);

-- 4. 创建索引
CREATE INDEX idx_allocation_config_effective ON tb_shop_series_allocation_config(allocation_id, effective_to);
CREATE INDEX idx_price_history_allocation ON tb_shop_package_allocation_price_history(allocation_id, effective_from);
CREATE INDEX idx_commission_stats_period ON tb_shop_series_commission_stats(allocation_id, period_start, period_end);
CREATE INDEX idx_package_allocation_shop_pkg ON tb_shop_package_allocation(shop_id, package_id, status);
```

### 阶段 2：代码部署

1. 部署新代码（包含新接口和删除的接口）
2. 前端同步更新（切换到新接口）
3. 验证功能
4. 监控性能和错误日志

### 阶段 3：清理

1. 确认前端已完全切换到新接口
2. 删除旧接口的路由注册（如有保留）
3. 清理未使用的代码和依赖

### Rollback 策略

**数据库 Rollback**：
- 保留旧字段数据（迁移前备份）
- 如需回滚，执行反向迁移 SQL

**代码 Rollback**：
- 回退到上一个稳定版本
- 前端回退到旧接口

## Open Questions

无待解决问题。所有核心设计决策已在探索阶段确定。
