# Spec: 套餐周期类型管理

## 业务背景

现有套餐系统仅支持简单的按月计算模式（通过 `duration_months` 字段），无法区分"自然月套餐"和"按天套餐"的业务需求。本规范引入 `calendar_type` 字段，支持两种套餐类型：

1. **自然月套餐（natural_month）**：按月边界计算有效期，适合"月卡"、"季卡"、"年卡"等场景
2. **按天套餐（by_day）**：按天数精确计算有效期，适合"7天卡"、"30天卡"、"90天卡"等场景

两种类型的核心差异在于**有效期计算方式**：
- 自然月套餐：激活后到当前月份 + N 个月的**月末 23:59:59**
- 按天套餐：激活后 + N 天的 **23:59:59**

## 业务规则

1. **calendar_type 是必填字段**，默认值为 `by_day`（向后兼容）
2. **duration_months 和 duration_days 互斥但至少提供一个**：
   - `calendar_type=natural_month` 时，必须提供 `duration_months`
   - `calendar_type=by_day` 时，必须提供 `duration_days`（如缺失，可从 `duration_months * 30` 转换）
3. **有效期计算时区统一使用服务器时区**（Asia/Shanghai）
4. **套餐激活时才计算 expires_at**，创建订单时不计算
5. **自然月套餐的月末处理**：
   - 2月 → 28/29日（闰年判断）
   - 其他小月（4/6/9/11月）→ 30日
   - 大月（1/3/5/7/8/10/12月）→ 31日

## ADDED Requirements

### Requirement: 支持自然月套餐类型
系统 SHALL 支持自然月套餐（calendar_type=natural_month），套餐有效期按自然月边界计算。

**业务价值**：满足运营商月卡业务需求，例如"联通月卡"在当月任意时间激活，均在月末过期，避免用户困惑。

**技术约束**：
- 有效期必须精确到秒（23:59:59）
- 闰年判断必须准确（2月29日处理）
- 跨年处理必须正确（12月 + 1个月 = 次年1月）

#### Scenario: 月中购买自然月套餐
- **GIVEN** 系统时间为 2026-01-15 10:00:00
- **WHEN** 用户购买自然月套餐（calendar_type=natural_month, duration_months=1）并激活
- **THEN** 套餐 activated_at=2026-01-15 10:00:00，expires_at=2026-01-31 23:59:59

#### Scenario: 月末购买自然月套餐（边界条件）
- **GIVEN** 系统时间为 2026-01-30 23:00:00
- **WHEN** 用户购买自然月套餐（calendar_type=natural_month, duration_months=1）并激活
- **THEN** 套餐 activated_at=2026-01-30 23:00:00，expires_at=2026-01-31 23:59:59
- **AND** 实际有效期仅剩约 25 小时（业务允许，用户自行承担）

#### Scenario: 自然月年套餐
- **GIVEN** 系统时间为 2026-02-15 10:00:00
- **WHEN** 用户购买自然月年套餐（calendar_type=natural_month, duration_months=12）并激活
- **THEN** 套餐 activated_at=2026-02-15 10:00:00，expires_at=2027-02-28 23:59:59
- **AND** 因为 2027 年不是闰年，2月为 28 日

#### Scenario: 闰年自然月套餐（边界条件）
- **GIVEN** 系统时间为 2028-02-15 10:00:00（2028 年是闰年）
- **WHEN** 用户购买自然月年套餐（calendar_type=natural_month, duration_months=12）并激活
- **THEN** 套餐 expires_at=2029-02-28 23:59:59
- **AND** 因为 2029 年不是闰年，2月为 28 日

#### Scenario: 闰年2月购买1个月套餐（边界条件）
- **GIVEN** 系统时间为 2028-02-15 10:00:00（2028 年是闰年）
- **WHEN** 用户购买自然月套餐（calendar_type=natural_month, duration_months=1）并激活
- **THEN** 套餐 expires_at=2028-02-29 23:59:59
- **AND** 因为 2028 年是闰年，2月为 29 日

#### Scenario: 跨年自然月套餐（边界条件）
- **GIVEN** 系统时间为 2026-12-15 10:00:00
- **WHEN** 用户购买自然月套餐（calendar_type=natural_month, duration_months=2）并激活
- **THEN** 套餐 expires_at=2027-02-28 23:59:59
- **AND** 正确跨年计算（12月 + 2个月 = 次年2月）

#### Scenario: 自然月季卡（90天 vs 3个月差异）
- **GIVEN** 系统时间为 2026-01-31 10:00:00
- **WHEN** 用户购买自然月季卡（calendar_type=natural_month, duration_months=3）并激活
- **THEN** 套餐 expires_at=2026-04-30 23:59:59
- **AND** 实际天数 = 31（1月剩余）+ 28（2月）+ 31（3月）+ 30（4月）= 120 天
- **AND** 比按天套餐（90天）多 30 天，体现自然月优势

### Requirement: 支持按天套餐类型
系统 SHALL 支持按天套餐（calendar_type=by_day），套餐有效期按天数精确计算。

**业务价值**：满足灵活天数套餐需求，例如"7天卡"、"30天卡"、"90天卡"，用户在任意时间激活，都获得完整的天数。

**技术约束**：
- 有效期计算公式：`expires_at = activated_at + duration_days 天 - 1秒`（例如：10:00:00 激活 + 1天 = 次日 09:59:59，但为了用户体验，统一为 23:59:59）
- 实际实现：`expires_at = (activated_at 日期 + duration_days 天) 的 23:59:59`
- 自动处理闰年、大小月、跨年

#### Scenario: 购买30天套餐
- **GIVEN** 系统时间为 2026-01-15 10:00:00
- **WHEN** 用户购买30天套餐（calendar_type=by_day, duration_days=30）并激活
- **THEN** 套餐 activated_at=2026-01-15 10:00:00，expires_at=2026-02-13 23:59:59
- **AND** 实际天数 = 30 天（含激活当天）

#### Scenario: 购买90天套餐
- **GIVEN** 系统时间为 2026-12-01 10:00:00
- **WHEN** 用户购买90天套餐（calendar_type=by_day, duration_days=90）并激活
- **THEN** 套餐 activated_at=2026-12-01 10:00:00，expires_at=2027-02-28 23:59:59
- **AND** 正确跨年计算

#### Scenario: 跨年购买按天套餐（边界条件）
- **GIVEN** 系统时间为 2026-12-20 10:00:00
- **WHEN** 用户购买20天套餐（calendar_type=by_day, duration_days=20）并激活
- **THEN** 套餐 activated_at=2026-12-20 10:00:00，expires_at=2027-01-08 23:59:59
- **AND** 正确跨年计算（12月20日 + 20天 = 1月8日）

#### Scenario: 闰年按天套餐（边界条件）
- **GIVEN** 系统时间为 2028-02-15 10:00:00（2028 年是闰年）
- **WHEN** 用户购买30天套餐（calendar_type=by_day, duration_days=30）并激活
- **THEN** 套餐 expires_at=2028-03-15 23:59:59
- **AND** 正确处理闰年 2月有 29 天

#### Scenario: 按天套餐与自然月套餐对比（业务理解）
- **GIVEN** 系统时间为 2026-01-31 10:00:00
- **WHEN** 用户购买30天套餐（calendar_type=by_day, duration_days=30）并激活
- **THEN** 套餐 expires_at=2026-03-01 23:59:59
- **AND** 如果购买自然月套餐（duration_months=1），expires_at=2026-01-31 23:59:59
- **AND** 按天套餐用户获得完整 30 天，更公平

#### Scenario: 1天套餐（边界条件）
- **GIVEN** 系统时间为 2026-01-15 23:30:00
- **WHEN** 用户购买1天套餐（calendar_type=by_day, duration_days=1）并激活
- **THEN** 套餐 activated_at=2026-01-15 23:30:00，expires_at=2026-01-15 23:59:59
- **AND** 实际有效期仅剩 29 分钟（业务允许，用户自行承担）

#### Scenario: 365天套餐（年卡）
- **GIVEN** 系统时间为 2026-01-01 00:00:00
- **WHEN** 用户购买365天套餐（calendar_type=by_day, duration_days=365）并激活
- **THEN** 套餐 expires_at=2026-12-31 23:59:59
- **AND** 精确一年有效期

### Requirement: 套餐周期类型可配置
系统 SHALL 允许管理员在创建套餐时指定 calendar_type，可选值为 natural_month 或 by_day。

**业务规则**：
- calendar_type 必填，默认值为 `by_day`（向后兼容）
- natural_month 时必须提供 duration_months（1-120）
- by_day 时必须提供 duration_days（1-3650）
- 不允许同时指定 duration_months 和 duration_days（冗余）

**数据验证**：
- calendar_type ∈ {natural_month, by_day}
- duration_months ∈ [1, 120]（最长10年）
- duration_days ∈ [1, 3650]（最长10年）

#### Scenario: 创建自然月套餐（成功）
- **GIVEN** 管理员已登录后台系统
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
- **AND** 请求体包含 calendar_type=natural_month, duration_months=3, package_name="联通季卡"
- **THEN** 系统返回 200，响应数据包含 calendar_type=natural_month, duration_months=3
- **AND** 数据库 tb_package 表新增一条记录，calendar_type=natural_month

#### Scenario: 创建按天套餐（成功）
- **GIVEN** 管理员已登录后台系统
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
- **AND** 请求体包含 calendar_type=by_day, duration_days=60, package_name="60天卡"
- **THEN** 系统返回 200，响应数据包含 calendar_type=by_day, duration_days=60
- **AND** 数据库 tb_package 表新增一条记录，calendar_type=by_day, duration_days=60

#### Scenario: 自然月套餐缺少 duration_months（参数验证失败）
- **GIVEN** 管理员已登录后台系统
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
- **AND** 请求体包含 calendar_type=natural_month 但未提供 duration_months
- **THEN** 系统返回错误 400，错误消息："自然月套餐必须指定 duration_months"
- **AND** 数据库无新增记录

#### Scenario: 按天套餐缺少 duration_days（参数验证失败）
- **GIVEN** 管理员已登录后台系统
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
- **AND** 请求体包含 calendar_type=by_day 但未提供 duration_days
- **THEN** 系统返回错误 400，错误消息："按天套餐必须指定 duration_days"
- **AND** 数据库无新增记录

#### Scenario: calendar_type 非法值（参数验证失败）
- **GIVEN** 管理员已登录后台系统
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
- **AND** 请求体包含 calendar_type=weekly（非法值）
- **THEN** 系统返回错误 400，错误消息："calendar_type 只能为 natural_month 或 by_day"
- **AND** 数据库无新增记录

#### Scenario: duration_months 超出范围（参数验证失败）
- **GIVEN** 管理员已登录后台系统
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
- **AND** 请求体包含 calendar_type=natural_month, duration_months=150（超出范围）
- **THEN** 系统返回错误 400，错误消息："duration_months 必须在 1-120 之间"
- **AND** 数据库无新增记录

#### Scenario: duration_days 超出范围（参数验证失败）
- **GIVEN** 管理员已登录后台系统
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
- **AND** 请求体包含 calendar_type=by_day, duration_days=5000（超出范围）
- **THEN** 系统返回错误 400，错误消息："duration_days 必须在 1-3650 之间"
- **AND** 数据库无新增记录

#### Scenario: 同时提供 duration_months 和 duration_days（参数冗余）
- **GIVEN** 管理员已登录后台系统
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
- **AND** 请求体包含 calendar_type=natural_month, duration_months=3, duration_days=90
- **THEN** 系统返回错误 400，错误消息："不允许同时指定 duration_months 和 duration_days"
- **AND** 数据库无新增记录

### Requirement: 套餐激活时根据类型计算到期时间
系统 SHALL 在套餐激活时，根据 calendar_type 自动计算并设置 expires_at。

**计算时机**：
- 订单创建时不计算（activated_at 和 expires_at 均为 NULL）
- 套餐激活时才计算（首次实名激活、主套餐排队激活、立即激活）

**计算公式**：
- 自然月：`expires_at = (activated_at 月份 + duration_months) 的月末 23:59:59`
- 按天：`expires_at = (activated_at 日期 + duration_days) 的 23:59:59`

**幂等性保证**：
- 同一套餐多次调用激活接口，expires_at 不变（使用已有的 activated_at 计算）

#### Scenario: 激活自然月套餐
- **GIVEN** PackageUsage 记录 status=0, package.calendar_type=natural_month, package.duration_months=1
- **WHEN** 套餐激活，activated_at 设置为 2026-02-15 10:00:00
- **THEN** 系统计算 expires_at=2026-02-28 23:59:59
- **AND** PackageUsage.status 更新为 1（生效中）

#### Scenario: 激活按天套餐
- **GIVEN** PackageUsage 记录 status=0, package.calendar_type=by_day, package.duration_days=30
- **WHEN** 套餐激活，activated_at 设置为 2026-02-15 10:00:00
- **THEN** 系统计算 expires_at=2026-03-16 23:59:59
- **AND** PackageUsage.status 更新为 1（生效中）

#### Scenario: 激活时处理闰年（自然月）
- **GIVEN** PackageUsage 记录 status=0, package.calendar_type=natural_month, package.duration_months=1
- **WHEN** 套餐激活，activated_at 设置为 2028-02-15 10:00:00（闰年）
- **THEN** 系统计算 expires_at=2028-02-29 23:59:59
- **AND** 正确识别闰年，2月为 29 日

#### Scenario: 激活时处理跨年（自然月）
- **GIVEN** PackageUsage 记录 status=0, package.calendar_type=natural_month, package.duration_months=3
- **WHEN** 套餐激活，activated_at 设置为 2026-11-15 10:00:00
- **THEN** 系统计算 expires_at=2027-02-28 23:59:59
- **AND** 正确跨年计算（11月 + 3个月 = 次年2月）

#### Scenario: 重复激活请求（幂等性保证）
- **GIVEN** PackageUsage 记录 status=1, activated_at=2026-02-15 10:00:00, expires_at=2026-02-28 23:59:59
- **WHEN** 再次调用激活接口（重试或并发请求）
- **THEN** 系统检测到 status=1，直接返回成功，不重新计算 expires_at
- **AND** expires_at 保持不变

#### Scenario: 激活失败回滚（异常处理）
- **GIVEN** PackageUsage 记录 status=0
- **WHEN** 套餐激活过程中数据库更新失败（例如网络中断）
- **THEN** 系统事务回滚，PackageUsage.status 保持为 0
- **AND** activated_at 和 expires_at 均为 NULL
- **AND** 返回错误消息："套餐激活失败，请重试"

### Requirement: 套餐类型信息可查询
系统 SHALL 在套餐详情和列表 API 中返回 calendar_type 和对应的 duration 字段。

**API 响应格式**：
- 自然月套餐：返回 `calendar_type`, `duration_months`, `duration_days=null`
- 按天套餐：返回 `calendar_type`, `duration_days`, `duration_months=null`

**性能要求**：
- 套餐详情查询 P95 < 50ms
- 套餐列表查询 P95 < 200ms（分页，每页最多 100 条）

#### Scenario: 查询自然月套餐详情
- **GIVEN** 数据库存在套餐 ID=123，calendar_type=natural_month, duration_months=12
- **WHEN** 用户通过 GET /api/admin/packages/123 查询套餐
- **THEN** 系统返回 200，响应 JSON 包含：
  ```json
  {
    "id": 123,
    "calendar_type": "natural_month",
    "duration_months": 12,
    "duration_days": null
  }
  ```

#### Scenario: 查询按天套餐详情
- **GIVEN** 数据库存在套餐 ID=456，calendar_type=by_day, duration_days=90
- **WHEN** 用户通过 GET /api/admin/packages/456 查询套餐
- **THEN** 系统返回 200，响应 JSON 包含：
  ```json
  {
    "id": 456,
    "calendar_type": "by_day",
    "duration_days": 90,
    "duration_months": null
  }
  ```

#### Scenario: 套餐列表显示类型
- **GIVEN** 数据库存在 50 个套餐，包含自然月和按天两种类型
- **WHEN** 管理员通过 GET /api/admin/packages?page=1&page_size=20 获取套餐列表
- **THEN** 系统返回 200，响应包含 20 个套餐数据
- **AND** 每个套餐数据包含 calendar_type 字段
- **AND** 响应时间 < 200ms（P95）

#### Scenario: 查询不存在的套餐（错误处理）
- **GIVEN** 数据库不存在套餐 ID=999
- **WHEN** 用户通过 GET /api/admin/packages/999 查询套餐
- **THEN** 系统返回 404，错误消息："套餐不存在"

### Requirement: 套餐类型可更新
系统 SHALL 允许管理员更新套餐的 calendar_type 和 duration 字段（仅限未生效的套餐）。

**更新限制**：
- 已有生效中 PackageUsage 记录的套餐，禁止修改 calendar_type 和 duration
- 只允许修改处于"下架"状态（shelf_status=2）且无生效中使用记录的套餐

#### Scenario: 更新下架套餐的类型（成功）
- **GIVEN** 套餐 ID=123, shelf_status=2（下架），无生效中 PackageUsage 记录
- **WHEN** 管理员通过 PUT /api/admin/packages/123 更新套餐
- **AND** 请求体包含 calendar_type=by_day, duration_days=60（从自然月改为按天）
- **THEN** 系统返回 200，套餐更新成功
- **AND** 数据库 calendar_type 更新为 by_day, duration_days=60, duration_months=null

#### Scenario: 更新已上架套餐（禁止）
- **GIVEN** 套餐 ID=123, shelf_status=1（上架），有生效中 PackageUsage 记录
- **WHEN** 管理员通过 PUT /api/admin/packages/123 更新套餐
- **AND** 请求体包含 calendar_type=by_day
- **THEN** 系统返回错误 400，错误消息："该套餐有生效中的使用记录，禁止修改类型"
- **AND** 数据库不更新

#### Scenario: 更新套餐其他字段（允许）
- **GIVEN** 套餐 ID=123, shelf_status=1（上架），有生效中 PackageUsage 记录
- **WHEN** 管理员通过 PUT /api/admin/packages/123 更新套餐
- **AND** 请求体仅包含 suggested_retail_price=5000（修改价格，不修改类型）
- **THEN** 系统返回 200，价格更新成功
- **AND** calendar_type 和 duration 保持不变

## 数据一致性保证

1. **套餐激活时的并发控制**：使用 Redis 分布式锁（key: `package:activation:lock:{usage_id}`），TTL=30s
2. **expires_at 精度要求**：数据库字段类型为 `timestamp`，精确到秒
3. **时区统一**：所有时间计算使用服务器时区（Asia/Shanghai）
4. **闰年判断准确性**：使用 Go 标准库 `time.Date()` 自动处理闰年

## 性能指标

| 操作 | 性能要求 | 监控指标 |
|------|---------|---------|
| 套餐创建 API | P95 < 100ms | API 响应时间 |
| 套餐查询 API | P95 < 50ms | 数据库查询时间 |
| 套餐激活计算 | < 10ms | 有效期计算耗时 |
| 套餐列表 API | P95 < 200ms | API 响应时间 |

## 错误码定义

| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| CodeInvalidParam | 400 | 自然月套餐必须指定 duration_months | 参数验证失败 |
| CodeInvalidParam | 400 | 按天套餐必须指定 duration_days | 参数验证失败 |
| CodeInvalidParam | 400 | calendar_type 只能为 natural_month 或 by_day | 参数验证失败 |
| CodeInvalidParam | 400 | duration_months 必须在 1-120 之间 | 参数验证失败 |
| CodeInvalidParam | 400 | duration_days 必须在 1-3650 之间 | 参数验证失败 |
| CodeForbidden | 403 | 该套餐有生效中的使用记录，禁止修改类型 | 业务规则限制 |
| CodeNotFound | 404 | 套餐不存在 | 资源不存在 |

## 数据迁移策略

**激进策略**（开发阶段）：
1. **历史套餐数据强制转换**：
   - 现有套餐统一设置 `calendar_type=by_day`
   - 根据 `duration_months` 计算 `duration_days = duration_months * 30`
   - 数据迁移后，所有套餐都有明确的 `calendar_type` 和对应的 `duration` 字段

2. **历史 PackageUsage 数据处理**：
   - 保留 `activated_at` 和 `expires_at`（不重新计算）
   - 新增 `calendar_type`, `data_reset_cycle` 字段，从关联的 Package 复制

3. **API 破坏性变更**：
   - `calendar_type` 字段**必填**，无默认值
   - 创建套餐时必须明确指定 `calendar_type` 和对应的 `duration` 字段
   - 不支持只提供 `duration_months` 而不指定 `calendar_type` 的旧请求
