# Spec Delta: 套餐管理能力扩展

## 业务背景

### 为什么需要扩展套餐管理字段

**现状问题**：
- 现有套餐管理缺少周期类型（自然月 vs 按天）配置
- 流量重置周期（每日/每月/每年/不重置）无法配置
- 实名激活机制无法按套餐级别控制
- 旧字段（duration）无法区分自然月和按天套餐

**业务目标**：
- 在套餐创建/更新时支持新字段配置
- 确保 calendar_type 与 duration_months/duration_days 的一致性
- 支持 data_reset_cycle 的灵活配置
- 支持 enable_realname_activation 的开关控制

---

## 业务规则

### 1. 周期类型与时长字段的关联规则

```
IF calendar_type = natural_month:
    THEN duration_months 必填，duration_days 可选
ELSE IF calendar_type = by_day:
    THEN duration_days 必填，duration_months 可选

验证规则：
- natural_month 套餐：必须提供 duration_months
- by_day 套餐：必须提供 duration_days
```

### 2. 流量重置周期的取值范围

```
data_reset_cycle ∈ {daily, monthly, yearly, none}

默认值规则：
- 主套餐：默认 monthly
- 加油包：默认 none
```

### 3. 实名激活开关规则

```
enable_realname_activation: boolean

- true：套餐激活前必须实名认证
- false：套餐激活不需要实名认证

默认值：
- 主套餐：默认 true
- 加油包：默认 false
```

---

## MODIFIED Requirements

### Requirement: 创建套餐

系统 SHALL 允许平台管理员创建套餐，包含套餐编码、套餐名称、所属系列、套餐类型、时长、**周期类型（calendar_type）、流量重置周期（data_reset_cycle）、是否需要实名激活（enable_realname_activation）**、流量配置、价格和建议价格。套餐编码 MUST 全局唯一（排除已删除记录）。新创建的套餐默认为启用状态(1)和下架状态(2)。

#### Scenario: 成功创建自然月套餐
- **GIVEN** 管理员提供套餐信息，calendar_type=natural_month，duration_months=1
- **WHEN** 提交创建请求
- **THEN** 系统创建套餐，状态=1，上架状态=2，calendar_type=natural_month

#### Scenario: 成功创建按天套餐
- **GIVEN** 管理员提供套餐信息，calendar_type=by_day，duration_days=30
- **WHEN** 提交创建请求
- **THEN** 系统创建套餐，calendar_type=by_day，duration_days=30

#### Scenario: 套餐编码重复
- **GIVEN** 数据库中存在套餐编码为 "PKG001" 的套餐（未删除）
- **WHEN** 管理员创建套餐，编码为 "PKG001"
- **THEN** 系统返回错误 "套餐编码已存在"

#### Scenario: 关联不存在的套餐系列
- **GIVEN** 管理员指定 series_id=999，但系列不存在
- **WHEN** 提交创建请求
- **THEN** 系统返回错误 "套餐系列不存在"

#### Scenario: 缺少必填字段
- **GIVEN** 管理员未提供套餐编码
- **WHEN** 提交创建请求
- **THEN** 系统返回参数验证错误 "套餐编码为必填项"

#### Scenario: 创建自然月套餐时必须提供 duration_months
- **GIVEN** 管理员创建套餐，calendar_type=natural_month，但未提供 duration_months
- **WHEN** 提交创建请求
- **THEN** 系统返回错误 "自然月套餐必须指定 duration_months"

#### Scenario: 创建按天套餐时必须提供 duration_days
- **GIVEN** 管理员创建套餐，calendar_type=by_day，但未提供 duration_days
- **WHEN** 提交创建请求
- **THEN** 系统返回错误 "按天套餐必须指定 duration_days"

#### Scenario: 默认 data_reset_cycle 为 monthly
- **GIVEN** 管理员创建主套餐，未指定 data_reset_cycle
- **WHEN** 提交创建请求
- **THEN** 系统自动设置 data_reset_cycle=monthly

#### Scenario: 默认 enable_realname_activation 为 true
- **GIVEN** 管理员创建主套餐，未指定 enable_realname_activation
- **WHEN** 提交创建请求
- **THEN** 系统自动设置 enable_realname_activation=true

### Requirement: 更新套餐

系统 SHALL 允许管理员更新套餐的基本信息，**包括周期类型、流量重置周期、实名激活配置等新增字段**。套餐编码创建后 MUST NOT 允许修改。

#### Scenario: 成功更新套餐基本信息
- **GIVEN** 管理员更新套餐名称和价格
- **WHEN** 提交更新请求
- **THEN** 系统更新套餐记录，返回更新后的详情

#### Scenario: 尝试修改套餐编码
- **GIVEN** 管理员尝试修改套餐编码
- **WHEN** 提交更新请求
- **THEN** 系统忽略套餐编码字段，不进行修改

#### Scenario: 更新不存在的套餐
- **GIVEN** 管理员更新套餐 ID=999，但套餐不存在
- **WHEN** 提交更新请求
- **THEN** 系统返回 "套餐不存在" 错误

#### Scenario: 关联不存在的套餐系列
- **GIVEN** 管理员将套餐的 series_id 改为 999，但系列不存在
- **WHEN** 提交更新请求
- **THEN** 系统返回错误 "套餐系列不存在"

#### Scenario: 更新套餐周期类型（从自然月改为按天）
- **GIVEN** 套餐当前 calendar_type=natural_month，duration_months=1
- **WHEN** 管理员更新 calendar_type=by_day，duration_days=30
- **THEN** 系统更新成功，calendar_type=by_day，duration_days=30

#### Scenario: 更新套餐周期类型（从按天改为自然月）
- **GIVEN** 套餐当前 calendar_type=by_day，duration_days=30
- **WHEN** 管理员更新 calendar_type=natural_month，duration_months=1
- **THEN** 系统更新成功，calendar_type=natural_month，duration_months=1

#### Scenario: 更新周期类型但未提供对应时长字段
- **GIVEN** 套餐当前 calendar_type=by_day
- **WHEN** 管理员更新 calendar_type=natural_month，但未提供 duration_months
- **THEN** 系统返回错误 "自然月套餐必须指定 duration_months"

#### Scenario: 更新 data_reset_cycle
- **GIVEN** 套餐当前 data_reset_cycle=monthly
- **WHEN** 管理员更新 data_reset_cycle=daily
- **THEN** 系统更新成功，data_reset_cycle=daily

#### Scenario: 更新 enable_realname_activation
- **GIVEN** 套餐当前 enable_realname_activation=true
- **WHEN** 管理员更新 enable_realname_activation=false
- **THEN** 系统更新成功，enable_realname_activation=false

### Requirement: 查询套餐详情

系统 SHALL 允许管理员查询单个套餐的详细信息，**响应包含新增字段（calendar_type, data_reset_cycle, enable_realname_activation）**。

#### Scenario: 查询存在的套餐
- **GIVEN** 数据库中存在套餐 ID=1
- **WHEN** 管理员请求套餐详情
- **THEN** 系统返回该套餐的完整信息，包含所有新增字段

#### Scenario: 查询不存在的套餐
- **GIVEN** 管理员请求套餐 ID=999，但套餐不存在
- **WHEN** 提交查询请求
- **THEN** 系统返回 "套餐不存在" 错误

#### Scenario: 响应包含周期类型信息
- **GIVEN** 套餐 calendar_type=natural_month，duration_months=1
- **WHEN** 管理员查询套餐详情
- **THEN** 响应包含 calendar_type=natural_month，duration_months=1

#### Scenario: 响应包含流量重置周期信息
- **GIVEN** 套餐 data_reset_cycle=monthly
- **WHEN** 管理员查询套餐详情
- **THEN** 响应包含 data_reset_cycle=monthly

#### Scenario: 响应包含实名激活配置
- **GIVEN** 套餐 enable_realname_activation=true
- **WHEN** 管理员查询套餐详情
- **THEN** 响应包含 enable_realname_activation=true

---

## 边界条件

### 1. 套餐编码唯一性

- **场景**：套餐编码已存在（未删除）
- **处理**：返回错误 "套餐编码已存在"

### 2. 套餐系列不存在

- **场景**：创建/更新套餐时，指定的系列 ID 不存在
- **处理**：返回错误 "套餐系列不存在"

### 3. 周期类型与时长字段不匹配

- **场景**：calendar_type=natural_month 但未提供 duration_months
- **处理**：返回错误 "自然月套餐必须指定 duration_months"

---

## 并发场景

### 1. 并发创建相同编码的套餐

- **场景**：两个管理员同时创建编码为 "PKG001" 的套餐
- **处理**：数据库唯一索引（code + deleted_at）保证只有一个创建成功，另一个返回错误

### 2. 并发更新套餐信息

- **场景**：两个管理员同时更新同一个套餐
- **处理**：使用乐观锁（updated_at），后提交的更新成功，前提交的更新被覆盖

---

## 数据一致性保证

### 1. 套餐编码唯一性

- **机制**：数据库唯一索引（code + deleted_at）

### 2. 套餐系列外键校验

- **机制**：在创建/更新前，查询系列是否存在

### 3. 周期类型与时长字段一致性

- **机制**：在 Service 层校验 calendar_type 与 duration_months/duration_days 的匹配

---

## 性能指标

| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|------|-------------|---------|--------|
| 创建套餐 | < 100ms (P95) | 50 QPS | 单条插入 |
| 更新套餐 | < 100ms (P95) | 50 QPS | 单条更新 |
| 查询套餐详情 | < 50ms (P95) | 500 QPS | 单条查询 |
| 列表查询 | < 200ms (P95) | 200 QPS | 分页查询（默认 20 条） |

---

## 错误码定义

| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| `PACKAGE_CODE_EXISTS` | 400 | 套餐编码已存在 | 创建套餐时编码重复 |
| `SERIES_NOT_FOUND` | 404 | 套餐系列不存在 | 创建/更新套餐时系列不存在 |
| `PACKAGE_NOT_FOUND` | 404 | 套餐不存在 | 查询/更新/删除不存在的套餐 |
| `INVALID_CALENDAR_TYPE` | 400 | 无效的周期类型 | calendar_type 不在 {natural_month, by_day} |
| `MISSING_DURATION_MONTHS` | 400 | 自然月套餐必须指定 duration_months | 创建自然月套餐未提供 duration_months |
| `MISSING_DURATION_DAYS` | 400 | 按天套餐必须指定 duration_days | 创建按天套餐未提供 duration_days |
| `INVALID_DATA_RESET_CYCLE` | 400 | 无效的流量重置周期 | data_reset_cycle 不在 {daily, monthly, yearly, none} |

---

## 数据迁移策略

**激进策略**（开发阶段，保证干净性）：

### 1. ❌ 要删除的字段

```sql
-- 删除旧的 duration 字段（已被 duration_months/duration_days 替代）
ALTER TABLE package DROP COLUMN IF EXISTS duration;

-- 删除旧的 reset_interval, reset_day 字段（已被 data_reset_cycle 替代）
ALTER TABLE package DROP COLUMN IF EXISTS reset_interval;
ALTER TABLE package DROP COLUMN IF EXISTS reset_day;
```

### 2. ✅ 新增的字段

```sql
-- 新增 calendar_type 字段（必填）
ALTER TABLE package
ADD COLUMN calendar_type VARCHAR(20) NOT NULL DEFAULT 'by_day';

-- 新增 data_reset_cycle 字段（必填）
ALTER TABLE package
ADD COLUMN data_reset_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly';

-- 新增 enable_realname_activation 字段（必填）
ALTER TABLE package
ADD COLUMN enable_realname_activation BOOLEAN NOT NULL DEFAULT true;

-- 新增 duration_months 字段（可选，自然月套餐必填）
ALTER TABLE package
ADD COLUMN duration_months INT;

-- 新增 duration_days 字段（可选，按天套餐必填）
ALTER TABLE package
ADD COLUMN duration_days INT;
```

### 3. ✅ 历史数据转换

```sql
-- 将现有套餐统一设置为按天套餐
UPDATE package
SET calendar_type = 'by_day',
    duration_days = COALESCE(duration_months, 1) * 30
WHERE deleted_at IS NULL;

-- 将现有套餐设置默认流量重置周期
UPDATE package
SET data_reset_cycle = 'monthly'
WHERE deleted_at IS NULL;

-- 将现有套餐设置默认实名激活开关
UPDATE package
SET enable_realname_activation = true
WHERE deleted_at IS NULL;
```

### 4. ✅ 索引优化

```sql
-- 确保套餐编码唯一索引存在
CREATE UNIQUE INDEX IF NOT EXISTS idx_package_code
ON package(code)
WHERE deleted_at IS NULL;

-- 添加周期类型索引（用于按类型查询）
CREATE INDEX IF NOT EXISTS idx_package_calendar_type
ON package(calendar_type);
```

---

## 测试场景矩阵

| 场景分类 | 测试用例 | 预期结果 |
|---------|---------|---------|
| **创建套餐** | 成功创建自然月套餐 | calendar_type=natural_month，duration_months=1 |
| | 成功创建按天套餐 | calendar_type=by_day，duration_days=30 |
| | 套餐编码重复 | 返回 "套餐编码已存在" |
| | 关联不存在的系列 | 返回 "套餐系列不存在" |
| | 缺少必填字段 | 返回参数验证错误 |
| | 自然月套餐未提供 duration_months | 返回 "自然月套餐必须指定 duration_months" |
| | 按天套餐未提供 duration_days | 返回 "按天套餐必须指定 duration_days" |
| | 默认 data_reset_cycle | data_reset_cycle=monthly |
| | 默认 enable_realname_activation | enable_realname_activation=true |
| **更新套餐** | 成功更新基本信息 | 套餐信息已更新 |
| | 尝试修改套餐编码 | 编码不变 |
| | 更新不存在的套餐 | 返回 "套餐不存在" |
| | 更新周期类型（从自然月改为按天） | calendar_type=by_day，duration_days=30 |
| | 更新周期类型但未提供对应时长 | 返回错误 |
| | 更新 data_reset_cycle | data_reset_cycle 已更新 |
| | 更新 enable_realname_activation | enable_realname_activation 已更新 |
| **查询套餐** | 查询存在的套餐 | 返回完整信息，包含新增字段 |
| | 查询不存在的套餐 | 返回 "套餐不存在" |
| | 响应包含周期类型信息 | 包含 calendar_type, duration_months/duration_days |
| | 响应包含流量重置周期 | 包含 data_reset_cycle |
| | 响应包含实名激活配置 | 包含 enable_realname_activation |
| **并发** | 并发创建相同编码套餐 | 只有一个成功 |
| | 并发更新套餐 | 后提交的更新成功 |

---

## 实现参考

### Handler: CreatePackage

```go
// Handler: CreatePackage
func (h *Handler) CreatePackage(c *fiber.Ctx) error {
    var req dto.CreatePackageRequest
    if err := c.BodyParser(&req); err != nil {
        return errors.New(errors.CodeInvalidParam)
    }

    // 调用 Service 层
    pkg, err := h.service.CreatePackage(c.UserContext(), &req)
    if err != nil {
        return err
    }

    return response.Success(c, pkg)
}

// Service 层：CreatePackage
func (s *Service) CreatePackage(ctx context.Context, req *dto.CreatePackageRequest) (*model.Package, error) {
    // 1. 校验套餐编码唯一性
    exists, err := s.store.ExistsByCode(ctx, req.Code)
    if err != nil {
        return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐编码失败")
    }
    if exists {
        return nil, errors.New(errors.CodePackageCodeExists, "套餐编码已存在")
    }

    // 2. 校验套餐系列是否存在
    if req.SeriesID != nil {
        exists, err := s.seriesStore.ExistsByID(ctx, *req.SeriesID)
        if err != nil {
            return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐系列失败")
        }
        if !exists {
            return nil, errors.New(errors.CodeSeriesNotFound, "套餐系列不存在")
        }
    }

    // 3. 校验周期类型与时长字段一致性
    if err := s.validateCalendarType(req.CalendarType, req.DurationMonths, req.DurationDays); err != nil {
        return nil, err
    }

    // 4. 设置默认值
    if req.DataResetCycle == "" {
        req.DataResetCycle = constants.DataResetCycleMonthly
    }
    if req.EnableRealnameActivation == nil {
        defaultValue := true
        req.EnableRealnameActivation = &defaultValue
    }

    // 5. 创建套餐
    pkg := &model.Package{
        Code:                      req.Code,
        Name:                      req.Name,
        SeriesID:                  req.SeriesID,
        PackageType:               req.PackageType,
        CalendarType:              req.CalendarType,
        DurationMonths:            req.DurationMonths,
        DurationDays:              req.DurationDays,
        DataResetCycle:            req.DataResetCycle,
        EnableRealnameActivation:  *req.EnableRealnameActivation,
        TotalDataMB:               req.TotalDataMB,
        Price:                     req.Price,
        SuggestedPrice:            req.SuggestedPrice,
        Status:                    constants.PackageStatusEnabled,
        ListingStatus:             constants.ListingStatusOffShelf,
    }

    if err := s.store.Create(ctx, pkg); err != nil {
        return nil, errors.Wrap(errors.CodeInternalError, err, "创建套餐失败")
    }

    return pkg, nil
}

// 校验周期类型与时长字段一致性
func (s *Service) validateCalendarType(calendarType string, durationMonths, durationDays *int) error {
    if calendarType == constants.CalendarTypeNaturalMonth {
        if durationMonths == nil || *durationMonths <= 0 {
            return errors.New(errors.CodeMissingDurationMonths, "自然月套餐必须指定 duration_months")
        }
    } else if calendarType == constants.CalendarTypeByDay {
        if durationDays == nil || *durationDays <= 0 {
            return errors.New(errors.CodeMissingDurationDays, "按天套餐必须指定 duration_days")
        }
    } else {
        return errors.New(errors.CodeInvalidCalendarType, "无效的周期类型")
    }
    return nil
}

// Store 层：ExistsByCode
func (s *Store) ExistsByCode(ctx context.Context, code string) (bool, error) {
    var count int64
    err := s.db.WithContext(ctx).
        Model(&model.Package{}).
        Where("code = ? AND deleted_at IS NULL", code).
        Count(&count).Error
    return count > 0, err
}
```

### Handler: UpdatePackage

```go
// Handler: UpdatePackage
func (h *Handler) UpdatePackage(c *fiber.Ctx) error {
    id, err := c.ParamsInt("id")
    if err != nil {
        return errors.New(errors.CodeInvalidParam)
    }

    var req dto.UpdatePackageRequest
    if err := c.BodyParser(&req); err != nil {
        return errors.New(errors.CodeInvalidParam)
    }

    // 调用 Service 层
    pkg, err := h.service.UpdatePackage(c.UserContext(), uint(id), &req)
    if err != nil {
        return err
    }

    return response.Success(c, pkg)
}

// Service 层：UpdatePackage
func (s *Service) UpdatePackage(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (*model.Package, error) {
    // 1. 查询套餐是否存在
    pkg, err := s.store.GetByID(ctx, id)
    if err != nil {
        return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐失败")
    }
    if pkg == nil {
        return nil, errors.New(errors.CodePackageNotFound, "套餐不存在")
    }

    // 2. 校验套餐系列是否存在
    if req.SeriesID != nil {
        exists, err := s.seriesStore.ExistsByID(ctx, *req.SeriesID)
        if err != nil {
            return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐系列失败")
        }
        if !exists {
            return nil, errors.New(errors.CodeSeriesNotFound, "套餐系列不存在")
        }
        pkg.SeriesID = req.SeriesID
    }

    // 3. 校验周期类型与时长字段一致性（如果更新了周期类型）
    if req.CalendarType != "" {
        if err := s.validateCalendarType(req.CalendarType, req.DurationMonths, req.DurationDays); err != nil {
            return nil, err
        }
        pkg.CalendarType = req.CalendarType
        if req.DurationMonths != nil {
            pkg.DurationMonths = req.DurationMonths
        }
        if req.DurationDays != nil {
            pkg.DurationDays = req.DurationDays
        }
    }

    // 4. 更新其他字段
    if req.Name != "" {
        pkg.Name = req.Name
    }
    if req.DataResetCycle != "" {
        pkg.DataResetCycle = req.DataResetCycle
    }
    if req.EnableRealnameActivation != nil {
        pkg.EnableRealnameActivation = *req.EnableRealnameActivation
    }
    if req.Price != nil {
        pkg.Price = *req.Price
    }
    if req.SuggestedPrice != nil {
        pkg.SuggestedPrice = *req.SuggestedPrice
    }

    // 5. 更新套餐
    if err := s.store.Update(ctx, pkg); err != nil {
        return nil, errors.Wrap(errors.CodeInternalError, err, "更新套餐失败")
    }

    return pkg, nil
}
```

---

**本 Spec Delta 完成**（扩展版），包含：
- ✅ 业务背景和业务规则
- ✅ 详细场景（创建、更新、查询）
- ✅ 边界条件和并发场景
- ✅ 数据一致性保证和性能指标
- ✅ 错误码定义
- ✅ **激进的数据迁移策略**（明确标注 ❌ 删除和 ✅ 新增）
- ✅ 测试场景矩阵和实现参考
