Files
junhong_cmp_fiber/openspec/specs/addon-package-lifecycle/spec.md
huang c665f32976
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
feat: 套餐系统升级 - Worker 重构、流量重置、文档与规范更新
- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入
- 实现套餐流量重置服务(日/月/年周期重置)
- 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑
- 新增订单创建幂等性防重(Redis 业务键 + 分布式锁)
- 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求
- 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南)
- 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录
- 新增 queue types 抽象和 Redis 常量定义
2026-02-12 14:24:15 +08:00

754 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Spec: 加油包生命周期管理
## 业务背景
### 为什么需要加油包生命周期管理
**现状问题**
- 加油包与主套餐无明确关联,导致主套餐过期后加油包仍可使用(业务逻辑混乱)
- 加油包有效期管理不清晰,无法区分"独立有效期"和"跟随主套餐"两种模式
- 主套餐切换时,旧加油包是否继承到新主套餐无明确规则
- 用户购买加油包时无主套餐检查,可能导致加油包无法使用
**业务目标**
- 加油包必须依附于主套餐才能购买和使用
- 主套餐过期时,其关联的加油包自动失效(级联失效)
- 支持两种有效期模式:独立有效期(固定时长)和跟随主套餐(与主套餐同时到期)
- 主套餐切换时,旧加油包不继承到新主套餐(用户需重新购买)
---
## 业务规则
### 1. 依附规则
加油包必须在有主套餐的情况下才能购买:
```
购买加油包前置检查:
1. 查询载体当前是否有主套餐package_type=formal AND status IN (0待生效, 1生效中)
2. 如果无主套餐 → 返回错误 400"必须有主套餐才能购买加油包"
3. 如果有主套餐 → 允许购买
```
### 2. 关联规则
加油包创建时自动关联到当前生效中的主套餐:
```
确定 master_usage_id 的逻辑:
1. 查询载体当前生效中的主套餐package_type=formal AND status=1
2. 如果有生效中主套餐 → master_usage_id = 该主套餐ID
3. 如果无生效中主套餐但有待生效主套餐status=0→ master_usage_id = priority 最小的待生效主套餐ID
4. 创建 PackageUsage 记录:
- package_type = addon
- master_usage_id = 上述确定的主套餐ID
- status = 0待生效
- has_independent_expiry = 根据套餐配置
```
### 3. 有效期模式
加油包支持两种有效期模式:
| 模式 | has_independent_expiry | 计算规则 | 过期条件 |
|------|------------------------|----------|----------|
| **独立有效期** | true | `expires_at = activated_at + duration_days` | 自身到期时间到达 |
| **跟随主套餐** | false | `expires_at = master套餐.expires_at` | 主套餐到期时间到达 |
**独立有效期加油包**
- 激活时计算自己的 `expires_at`
- 可能在主套餐之前过期
- 到期后 `status=3`(已过期)
**跟随主套餐加油包**
- 激活时 `expires_at = master套餐.expires_at`
- 主套餐 `expires_at` 更新时,同步更新所有跟随的加油包
- 与主套餐同时到期
### 4. 级联失效规则
主套餐过期时,级联失效其所有关联的加油包:
```
主套餐过期触发级联失效:
1. 主套餐 status 变为 3已过期时触发
2. 查询所有 master_usage_id = 主套餐ID 的加油包
3. 批量更新这些加油包 status = 4已失效
4. 不管加油包是否有独立有效期、是否已用完
5. 记录级联失效日志
```
**失效状态说明**
- `status=3`(已过期):自身有效期到达
- `status=4`(已失效):主套餐过期导致的级联失效
### 5. 不继承规则
旧主套餐过期后,其加油包不继承到新主套餐:
```
新主套餐激活时:
1. 不更新旧加油包的 master_usage_id
2. 旧加油包保持 status=4已失效
3. 用户需为新主套餐重新购买加油包
4. 新加油包 master_usage_id = 新主套餐ID
```
### 6. 订单购买限制
**同订单禁止混买正式套餐和加油包**
```
订单创建校验规则:
1. 检查订单项中是否同时包含 package_type=formal 和 package_type=addon
2. 如果混买 → 返回错误 400"同订单不能同时购买正式套餐和加油包"
3. 原因:加油包依赖主套餐激活,订单处理时序无法保证主套餐先激活
4. 解决方案:前端购物车分类展示,提示用户分两单购买
```
**技术实现**
```go
// 订单创建时校验
func (s *OrderService) ValidateOrderItems(items []*OrderItem) error {
hasMainPackage := false
hasAddonPackage := false
for _, item := range items {
pkg, err := s.packageStore.GetByID(item.PackageID)
if err != nil {
return err
}
if pkg.PackageType == constants.PackageTypeFormal {
hasMainPackage = true
} else if pkg.PackageType == constants.PackageTypeAddon {
hasAddonPackage = true
}
}
if hasMainPackage && hasAddonPackage {
return errors.New(errors.CodeInvalidParam, "同订单不能同时购买正式套餐和加油包")
}
return nil
}
```
---
## ADDED Requirements
### Requirement: 加油包必须依附于主套餐
系统 SHALL 禁止在无主套餐(无 package_type=formal status=1 或 status=0 的套餐)时购买加油包。
#### Scenario: 无主套餐时购买加油包失败
- **GIVEN** 载体 ICCID=123456无任何主套餐无 package_type=formal status IN (0,1)
- **WHEN** 用户尝试购买加油包package_type=addon
- **THEN** 系统返回错误 400错误码 `ADDON_REQUIRES_MASTER`,错误消息:"必须有主套餐才能购买加油包"
#### Scenario: 有主套餐时可购买加油包
- **GIVEN** 载体有生效中主套餐ID=123, status=1
- **WHEN** 用户购买加油包package_id=456
- **THEN** 系统创建订单成功PackageUsage master_usage_id=123, package_type=addon, status=0
#### Scenario: 只有待生效主套餐时可购买加油包
- **GIVEN** 载体有待生效主套餐ID=123, status=0, priority=1
- **WHEN** 用户购买加油包
- **THEN** 系统创建订单成功,加油包 master_usage_id=123
### Requirement: 加油包关联主套餐
系统 SHALL 在创建加油包使用记录时,将其 master_usage_id 设置为当前生效中或最高优先级待生效的主套餐ID。
#### Scenario: 加油包关联当前生效中主套餐
- **GIVEN** 载体有生效中主套餐ID=123, status=1
- **WHEN** 用户购买加油包
- **THEN** 系统创建 PackageUsage
- master_usage_id=123
- package_type=addon
- status=0
#### Scenario: 多个主套餐时关联生效中的主套餐
- **GIVEN** 载体有:
- 生效中主套餐ID=123, status=1, priority=1
- 待生效主套餐ID=124, status=0, priority=2
- 待生效主套餐ID=125, status=0, priority=3
- **WHEN** 用户购买加油包
- **THEN** 加油包 master_usage_id=123优先关联生效中的主套餐
#### Scenario: 只有待生效主套餐时关联优先级最高的
- **GIVEN** 载体有:
- 待生效主套餐ID=124, status=0, priority=1
- 待生效主套餐ID=125, status=0, priority=2
- **WHEN** 用户购买加油包
- **THEN** 加油包 master_usage_id=124priority=1 最高)
### Requirement: 支持独立有效期加油包
系统 SHALL 支持加油包配置 has_independent_expiry=true拥有独立的有效期。
#### Scenario: 独立有效期加油包激活时计算过期时间
- **GIVEN** 加油包 has_independent_expiry=trueduration_days=30
- **WHEN** 加油包在 2026-02-01 00:00:00 激活
- **THEN** 系统计算 expires_at=2026-03-02 23:59:59+30天
#### Scenario: 独立有效期加油包过期
- **GIVEN** 加油包 has_independent_expiry=trueexpires_at=2026-02-28 23:59:59data_usage_mb=50未用完
- **WHEN** 系统时间到达 2026-03-01 00:00:00
- **THEN** 定时任务将加油包 status 更新为 3已过期
#### Scenario: 独立有效期加油包在主套餐有效期内过期
- **GIVEN** 主套餐有效期到 2026-12-31 23:59:59
- **AND** 加油包 has_independent_expiry=trueexpires_at=2026-03-31 23:59:59
- **WHEN** 系统时间到达 2026-04-01 00:00:00
- **THEN** 加油包 status=3已过期主套餐仍为 status=1生效中
#### Scenario: 独立有效期加油包在主套餐过期后仍失效
- **GIVEN** 加油包 has_independent_expiry=trueexpires_at=2026-12-31 23:59:59未到期
- **AND** 主套餐 expires_at=2026-11-30 23:59:59
- **WHEN** 主套餐在 2026-12-01 00:00:00 过期status=3
- **THEN** 加油包被级联失效status=4不管自身 expires_at
### Requirement: 支持跟随主套餐的加油包
系统 SHALL 支持加油包配置 has_independent_expiry=false跟随主套餐有效期。
#### Scenario: 跟随主套餐的加油包激活时同步到期时间
- **GIVEN** 加油包 has_independent_expiry=falsemaster 主套餐 expires_at=2026-12-31 23:59:59
- **WHEN** 加油包在 2026-02-01 00:00:00 激活
- **THEN** 系统设置加油包 expires_at=2026-12-31 23:59:59与主套餐相同
#### Scenario: 主套餐更新有效期时同步加油包
- **GIVEN** 主套餐 ID=123expires_at=2026-12-31 23:59:59
- **AND** 有3个加油包 master_usage_id=123has_independent_expiry=false
- **WHEN** 主套餐 expires_at 被更新为 2027-01-31 23:59:59
- **THEN** 系统批量更新这3个加油包 expires_at=2027-01-31 23:59:59
#### Scenario: 主套餐有效期更新时不影响独立有效期加油包
- **GIVEN** 主套餐 ID=123expires_at=2026-12-31 23:59:59
- **AND** 加油包Ahas_independent_expiry=trueexpires_at=2026-06-30 23:59:59
- **AND** 加油包Bhas_independent_expiry=falseexpires_at=2026-12-31 23:59:59
- **WHEN** 主套餐 expires_at 更新为 2027-01-31 23:59:59
- **THEN** 加油包A expires_at 保持 2026-06-30 23:59:59不变
- **AND** 加油包B expires_at 更新为 2027-01-31 23:59:59
#### Scenario: 跟随主套餐的加油包与主套餐同时过期
- **GIVEN** 主套餐 expires_at=2026-12-31 23:59:59
- **AND** 加油包 has_independent_expiry=falseexpires_at=2026-12-31 23:59:59
- **WHEN** 系统时间到达 2027-01-01 00:00:00
- **THEN** 定时任务将主套餐和加油包 status 都更新为 3已过期
### Requirement: 主套餐过期时级联失效加油包
系统 SHALL 在主套餐过期status 变为 3将其所有关联加油包的 status 设置为 4已失效
#### Scenario: 主套餐过期触发加油包失效
- **GIVEN** 主套餐 ID=123expires_at=2026-12-31 23:59:59
- **AND** 有3个加油包 master_usage_id=123
- 加油包Adata_usage_mb=50未用完
- 加油包Bdata_usage_mb=200已用完
- 加油包Chas_independent_expiry=trueexpires_at=2027-06-30未到期
- **WHEN** 系统时间到达 2027-01-01 00:00:00主套餐 status=3
- **THEN** 系统批量更新这3个加油包 status=4已失效
#### Scenario: 独立有效期加油包也会级联失效
- **GIVEN** 主套餐 expires_at=2026-11-30 23:59:59
- **AND** 加油包 has_independent_expiry=trueexpires_at=2026-12-31 23:59:59晚于主套餐
- **WHEN** 主套餐在 2026-12-01 00:00:00 过期
- **THEN** 加油包 status=4已失效不管自身还有30天才到期
#### Scenario: 已过期加油包不重复失效
- **GIVEN** 主套餐 expires_at=2026-12-31 23:59:59
- **AND** 加油包 has_independent_expiry=trueexpires_at=2026-11-30 23:59:59status=3已过期
- **WHEN** 主套餐在 2027-01-01 00:00:00 过期
- **THEN** 加油包 status 保持 3已过期不更新为 4
#### Scenario: 级联失效记录到审计日志
- **GIVEN** 主套餐 ID=123 过期有5个关联加油包
- **WHEN** 系统执行级联失效
- **THEN** 系统记录审计日志:
- operation_type=cascade_invalidate
- operation_desc="主套餐ID=123过期级联失效5个加油包"
- before_data=加油包列表及原状态
- after_data=加油包列表及新状态status=4
### Requirement: 加油包不继承到新主套餐
系统 SHALL 确保旧主套餐过期后,其加油包不会自动关联到新激活的主套餐。
#### Scenario: 新主套餐激活后加油包不关联
- **GIVEN** 主套餐AID=123在 2026-12-31 过期其加油包已失效status=4
- **WHEN** 主套餐BID=124在 2027-01-01 激活priority=2 → status=1
- **THEN** 主套餐A的加油包 master_usage_id 保持 123status 保持 4
- **AND** 主套餐B 无关联加油包
#### Scenario: 用户需为新主套餐重新购买加油包
- **GIVEN** 主套餐BID=124刚激活status=1
- **WHEN** 用户购买新加油包
- **THEN** 新加油包 master_usage_id=124status=0
#### Scenario: 旧加油包不可重新激活
- **GIVEN** 主套餐A的加油包ID=999已失效status=4
- **WHEN** 用户尝试手动激活这个加油包
- **THEN** 系统返回错误 400错误码 `ADDON_MASTER_EXPIRED`,错误消息:"关联的主套餐已过期,无法激活加油包"
---
## 边界条件
### 1. 主套餐失效但加油包未用完
- **场景**主套餐过期时加油包流量只用了10%
- **处理**仍然级联失效status=4剩余流量不可用
- **业务规则**:加油包依附于主套餐,主套餐失效则加油包失效
### 2. 多个主套餐同时存在
- **场景**有1个生效中主套餐 + 2个待生效主套餐
- **购买加油包时**:关联到生效中的主套餐
- **主套餐A过期后**加油包随A失效不继承到主套餐B
### 3. 并发购买加油包
- **场景**:两个请求同时为同一载体购买加油包
- **处理**
- 使用事务 + 行锁:`SELECT * FROM package_usage WHERE carrier_id=? AND package_type=formal AND status IN (0,1) ORDER BY status DESC, priority ASC FOR UPDATE`
- 确保两个加油包关联到同一个主套餐
### 4. 主套餐有效期更新失败
- **场景**:主套餐 expires_at 更新时,同步跟随加油包失败
- **处理**
- 使用事务包裹主套餐更新和加油包批量更新
- 更新失败则回滚,返回错误 500
- 记录错误日志包含主套餐ID和失败原因
### 5. 级联失效失败
- **场景**:主套餐过期时,批量更新加油包失败(数据库连接断开)
- **处理**
- 使用 Asynq 重试机制最多3次
- 每次重试前检查加油包当前状态,避免重复更新
- 3次失败后写入死信队列发送告警
---
## 并发场景
### Scenario: 并发购买加油包
- **GIVEN** 载体有生效中主套餐ID=123
- **WHEN** 两个请求 req1 和 req2 同时购买加油包
- **THEN** 系统使用行锁:
```sql
SELECT * FROM package_usage
WHERE carrier_id=? AND package_type='formal' AND status IN (0,1)
ORDER BY status DESC, priority ASC
FOR UPDATE
```
- **AND** req1 和 req2 创建的加油包 master_usage_id 都为 123
### Scenario: 并发主套餐过期和购买加油包
- **GIVEN** 主套餐AID=123即将过期主套餐BID=124待生效
- **WHEN** 时间到达过期时刻:
- 请求1定时任务将主套餐A status=3触发级联失效
- 请求2用户购买加油包
- **THEN** 使用事务隔离:
- 如果请求2先获取锁 → 加油包 master_usage_id=123然后被级联失效status=4
- 如果请求1先获取锁 → 主套餐A已无生效中加油包 master_usage_id=124
### Scenario: 并发更新主套餐有效期和级联失效
- **GIVEN** 主套餐 ID=123有5个跟随的加油包has_independent_expiry=false
- **WHEN** 同时发生:
- 请求1主套餐 expires_at 更新为 2027-12-31
- 请求2主套餐到期触发级联失效
- **THEN** 使用行锁 `SELECT * FROM package_usage WHERE id=123 FOR UPDATE`
- **AND** 先完成的操作生效,后完成的操作基于新状态执行
---
## 异常处理
### 1. 级联失效失败
- **错误场景**:主套餐过期时,批量更新加油包 SQL 执行失败
- **处理流程**
1. 捕获错误,记录 Error 日志包含主套餐ID、加油包数量、错误信息
2. Asynq 自动重试最多3次间隔 10s/30s/60s
3. 重试前检查加油包当前状态(避免重复更新)
4. 3次失败后写入死信队列发送告警通知
- **返回错误**:不返回给用户(异步任务),仅记录日志
### 2. master_usage_id 不存在
- **错误场景**:加油包的 master_usage_id 指向的主套餐被删除
- **处理流程**
1. 加油包激活时检查 `SELECT id FROM package_usage WHERE id=master_usage_id`
2. 如果不存在 → 返回错误 500错误码 `MASTER_NOT_FOUND`
3. 记录 Error 日志包含加油包ID、master_usage_id、载体信息
- **返回错误**`{"code": "MASTER_NOT_FOUND", "msg": "关联的主套餐不存在,请联系管理员"}`
### 3. 同步有效期失败
- **错误场景**:主套餐 expires_at 更新时,批量更新跟随加油包失败
- **处理流程**
1. 使用事务包裹主套餐更新和加油包批量更新
2. 加油包更新失败 → 事务回滚,主套餐 expires_at 不更新
3. 记录 Error 日志包含主套餐ID、加油包数量、错误信息
4. 返回错误 500错误码 `SYNC_EXPIRY_FAILED`
- **返回错误**`{"code": "SYNC_EXPIRY_FAILED", "msg": "更新套餐有效期失败,请稍后重试"}`
### 4. 购买加油包时无主套餐
- **错误场景**:用户购买加油包时,载体无任何主套餐
- **处理流程**
1. 查询载体主套餐:`SELECT id FROM package_usage WHERE carrier_id=? AND package_type='formal' AND status IN (0,1) LIMIT 1`
2. 如果无结果 → 返回错误 400错误码 `ADDON_REQUIRES_MASTER`
- **返回错误**`{"code": "ADDON_REQUIRES_MASTER", "msg": "必须有主套餐才能购买加油包"}`
---
## 数据一致性保证
### 1. 事务边界
- **主套餐过期 + 级联失效**:使用单个事务,确保原子性
- **主套餐更新有效期 + 同步加油包**:使用单个事务,更新失败则回滚
- **购买加油包 + 关联主套餐**:使用事务,确保 master_usage_id 正确
### 2. 行锁机制
- **查询主套餐时加锁**`SELECT * FROM package_usage WHERE carrier_id=? AND package_type='formal' AND status IN (0,1) FOR UPDATE`
- **更新主套餐有效期时加锁**`SELECT * FROM package_usage WHERE id=? FOR UPDATE`
- **级联失效时加锁**`SELECT * FROM package_usage WHERE master_usage_id=? FOR UPDATE`
### 3. 唯一索引
- 已有索引:`idx_carrier_package_type_priority`carrier_id + package_type + priority
- 已有索引:`idx_master_usage_id`master_usage_id
### 4. 数据校验
- **购买加油包前**:校验 has_independent_expiry 与 duration_days 的一致性
- **激活加油包时**:校验 master_usage_id 是否存在
- **级联失效时**:仅更新 status NOT IN (3, 4) 的加油包(避免重复更新)
---
## 性能指标
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|------|-------------|---------|--------|
| 购买加油包(主套餐检查) | < 50ms | 100 QPS | 单载体查询 |
| 关联主套餐(查询+插入) | < 100ms | 100 QPS | 单载体查询 + 单条插入 |
| 主套餐过期级联失效 | < 500ms | 10 QPS | 批量更新平均10个加油包 |
| 主套餐更新有效期同步 | < 300ms | 50 QPS | 批量更新平均5个加油包 |
---
## 错误码定义
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| `ADDON_REQUIRES_MASTER` | 400 | 必须有主套餐才能购买加油包 | 购买加油包时无主套餐 |
| `MASTER_NOT_FOUND` | 500 | 关联的主套餐不存在,请联系管理员 | master_usage_id 不存在 |
| `ADDON_MASTER_EXPIRED` | 400 | 关联的主套餐已过期,无法激活加油包 | 尝试激活已失效加油包 |
| `SYNC_EXPIRY_FAILED` | 500 | 更新套餐有效期失败,请稍后重试 | 同步加油包有效期失败 |
| `CASCADE_INVALIDATE_FAILED` | 500 | 级联失效加油包失败,请稍后重试 | 级联失效批量更新失败 |
---
## 数据迁移策略
**激进策略**(开发阶段,保证干净性):
### 1. ❌ 要删除的字段
目前 `package_usage` 表中可能存在的冗余字段(需确认后删除):
- 如果有 `parent_usage_id` 字段(旧的父级关联) → **删除**
- 如果有 `linked_usage_ids` 字段(旧的关联列表) → **删除**
- 如果有 `inherit_to_next` 字段(旧的继承标志) → **删除**
### 2. ✅ 新增的字段
在 `package_usage` 表中新增:
```sql
ALTER TABLE package_usage
ADD COLUMN master_usage_id BIGINT DEFAULT NULL COMMENT '主套餐ID加油包专用',
ADD COLUMN has_independent_expiry BOOLEAN DEFAULT false COMMENT '是否有独立有效期(加油包专用)';
CREATE INDEX idx_master_usage_id ON package_usage(master_usage_id);
```
在 `package` 表中新增:
```sql
ALTER TABLE package
ADD COLUMN has_independent_expiry BOOLEAN DEFAULT false COMMENT '加油包是否有独立有效期(仅 package_type=addon 时有效)';
```
### 3. ❌ 要废弃的逻辑
- **废弃旧的加油包关联逻辑**:如果代码中存在通过 `parent_usage_id` 或其他字段关联主套餐的逻辑,全部删除
- **废弃旧的继承逻辑**:如果代码中存在"主套餐切换时加油包继承到新主套餐"的逻辑,全部删除
- **废弃旧的有效期计算逻辑**:如果加油包有效期计算不区分"独立有效期"和"跟随主套餐",全部重构
### 4. ✅ 历史数据强制转换
```sql
-- Step 1: 历史加油包数据强制关联到当前主套餐
UPDATE package_usage pu_addon
SET master_usage_id = (
SELECT pu_master.id
FROM package_usage pu_master
WHERE pu_master.carrier_id = pu_addon.carrier_id
AND pu_master.package_type = 'formal'
AND pu_master.status IN (0, 1)
ORDER BY pu_master.status DESC, pu_master.priority ASC
LIMIT 1
)
WHERE pu_addon.package_type = 'addon'
AND pu_addon.master_usage_id IS NULL;
-- Step 2: 无主套餐的历史加油包强制失效
UPDATE package_usage
SET status = 4,
invalidated_at = NOW()
WHERE package_type = 'addon'
AND master_usage_id IS NULL;
-- Step 3: 历史加油包默认为独立有效期模式
UPDATE package_usage
SET has_independent_expiry = true
WHERE package_type = 'addon'
AND has_independent_expiry IS NULL;
-- Step 4: 已过期主套餐的加油包全部级联失效
UPDATE package_usage pu_addon
SET status = 4,
invalidated_at = NOW()
FROM package_usage pu_master
WHERE pu_addon.master_usage_id = pu_master.id
AND pu_master.package_type = 'formal'
AND pu_master.status = 3 -- 已过期
AND pu_addon.status NOT IN (3, 4);
```
### 5. ❌ 删除遗留表/字段(确认后执行)
```sql
-- 如果存在旧的关联表,删除
-- DROP TABLE IF EXISTS package_usage_relations;
-- 如果存在冗余字段,删除
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS parent_usage_id;
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS linked_usage_ids;
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS inherit_to_next;
```
### 6. 验证步骤
```sql
-- 验证1所有加油包都有 master_usage_id除了已失效的
SELECT COUNT(*)
FROM package_usage
WHERE package_type = 'addon'
AND status NOT IN (3, 4)
AND master_usage_id IS NULL;
-- 预期结果0
-- 验证2所有加油包的 master_usage_id 都指向有效的主套餐
SELECT COUNT(*)
FROM package_usage pu_addon
LEFT JOIN package_usage pu_master ON pu_addon.master_usage_id = pu_master.id
WHERE pu_addon.package_type = 'addon'
AND pu_addon.master_usage_id IS NOT NULL
AND pu_master.id IS NULL;
-- 预期结果0
-- 验证3已过期主套餐的加油包都已失效
SELECT COUNT(*)
FROM package_usage pu_addon
JOIN package_usage pu_master ON pu_addon.master_usage_id = pu_master.id
WHERE pu_master.status = 3
AND pu_addon.status NOT IN (3, 4);
-- 预期结果0
-- 验证4检查是否还有遗留字段需根据实际情况调整
-- SELECT column_name FROM information_schema.columns
-- WHERE table_name = 'package_usage'
-- AND column_name IN ('parent_usage_id', 'linked_usage_ids', 'inherit_to_next');
-- 预期结果0 rows
```
---
## 测试场景矩阵
| 场景分类 | 测试用例 | 预期结果 |
|---------|---------|---------|
| **依附检查** | 无主套餐购买加油包 | 返回错误 400ADDON_REQUIRES_MASTER |
| | 有生效中主套餐购买加油包 | 创建成功master_usage_id=生效中主套餐ID |
| | 只有待生效主套餐购买加油包 | 创建成功master_usage_id=priority最小的待生效主套餐ID |
| **关联逻辑** | 多个主套餐时购买加油包 | 优先关联生效中主套餐 |
| | 并发购买加油包 | 使用行锁,两个加油包关联到同一主套餐 |
| **独立有效期** | 独立有效期加油包激活 | expires_at = activated_at + duration_days |
| | 独立有效期加油包到期 | status=3已过期 |
| | 独立有效期加油包未到期但主套餐过期 | status=4已失效 |
| **跟随主套餐** | 跟随主套餐的加油包激活 | expires_at = master套餐.expires_at |
| | 主套餐更新有效期 | 跟随加油包同步更新 expires_at |
| | 主套餐更新有效期时独立有效期加油包不变 | 独立有效期加油包 expires_at 不变 |
| **级联失效** | 主套餐过期触发级联失效 | 所有关联加油包 status=4 |
| | 独立有效期加油包未到期但主套餐过期 | status=4已失效 |
| | 已过期加油包不重复失效 | status 保持 3 |
| | 级联失效失败重试 | Asynq 重试3次失败后进入死信队列 |
| **不继承** | 新主套餐激活后旧加油包不关联 | 旧加油包 master_usage_id 和 status 保持不变 |
| | 为新主套餐购买新加油包 | 新加油包 master_usage_id=新主套餐ID |
| | 尝试激活已失效加油包 | 返回错误 400ADDON_MASTER_EXPIRED |
| **并发** | 并发购买加油包 | 使用行锁,确保关联到同一主套餐 |
| | 并发主套餐过期和购买加油包 | 事务隔离,先完成的操作生效 |
| **异常** | master_usage_id 不存在 | 返回错误 500MASTER_NOT_FOUND |
| | 同步有效期失败 | 事务回滚,返回错误 500SYNC_EXPIRY_FAILED |
| | 级联失效失败 | Asynq 重试,记录日志,发送告警 |
---
## 实现参考
### 购买加油包时的主套餐检查
```go
// Service 层CheckMasterPackageForAddon
func (s *Service) CheckMasterPackageForAddon(ctx context.Context, carrierID uint) (uint, error) {
// 查询生效中或待生效的主套餐
masterUsage, err := s.store.FindMasterPackage(ctx, carrierID)
if err != nil {
return 0, errors.Wrap(errors.CodeInternalError, err, "查询主套餐失败")
}
if masterUsage == nil {
return 0, errors.New(errors.CodeInvalidParam, "必须有主套餐才能购买加油包")
}
return masterUsage.ID, nil
}
// Store 层FindMasterPackage
func (s *Store) FindMasterPackage(ctx context.Context, carrierID uint) (*model.PackageUsage, error) {
var usage model.PackageUsage
err := s.db.WithContext(ctx).
Where("carrier_id = ? AND package_type = ? AND status IN (?, ?)",
carrierID, constants.PackageTypeFormal,
constants.PackageStatusPending, constants.PackageStatusActive).
Order("status DESC, priority ASC"). // 优先生效中,然后按 priority
First(&usage).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &usage, nil
}
```
### 主套餐过期时级联失效加油包
```go
// Service 层CascadeInvalidateAddons
func (s *Service) CascadeInvalidateAddons(ctx context.Context, masterUsageID uint) error {
tx := s.store.BeginTx(ctx)
defer tx.Rollback()
// 批量更新加油包状态
count, err := s.store.InvalidateAddonsByMaster(ctx, tx, masterUsageID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "级联失效加油包失败")
}
if err := tx.Commit().Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "提交事务失败")
}
// 记录审计日志(异步)
s.auditService.LogOperation(ctx, &model.OperationLog{
OperationType: "cascade_invalidate",
OperationDesc: fmt.Sprintf("主套餐ID=%d过期级联失效%d个加油包", masterUsageID, count),
TargetID: masterUsageID,
})
return nil
}
// Store 层InvalidateAddonsByMaster
func (s *Store) InvalidateAddonsByMaster(ctx context.Context, tx *gorm.DB, masterUsageID uint) (int64, error) {
result := tx.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("master_usage_id = ? AND status NOT IN (?, ?)",
masterUsageID,
constants.PackageStatusExpired,
constants.PackageStatusInvalidated).
Updates(map[string]interface{}{
"status": constants.PackageStatusInvalidated,
"invalidated_at": time.Now(),
})
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
```
### 主套餐更新有效期时同步跟随加油包
```go
// Service 层SyncAddonExpiry
func (s *Service) SyncAddonExpiry(ctx context.Context, masterUsageID uint, newExpiresAt time.Time) error {
tx := s.store.BeginTx(ctx)
defer tx.Rollback()
// 更新主套餐有效期
if err := s.store.UpdateExpiry(ctx, tx, masterUsageID, newExpiresAt); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新主套餐有效期失败")
}
// 批量更新跟随的加油包
count, err := s.store.SyncFollowingAddonExpiry(ctx, tx, masterUsageID, newExpiresAt)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "同步加油包有效期失败")
}
if err := tx.Commit().Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "提交事务失败")
}
s.logger.Info("同步加油包有效期成功",
zap.Uint("master_usage_id", masterUsageID),
zap.Int64("count", count),
zap.Time("new_expires_at", newExpiresAt))
return nil
}
// Store 层SyncFollowingAddonExpiry
func (s *Store) SyncFollowingAddonExpiry(ctx context.Context, tx *gorm.DB, masterUsageID uint, expiresAt time.Time) (int64, error) {
result := tx.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("master_usage_id = ? AND has_independent_expiry = ?", masterUsageID, false).
Update("expires_at", expiresAt)
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
```
---
**本 Spec 完成**,包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(依附、关联、独立有效期、跟随主套餐、级联失效、不继承)
- ✅ 边界条件和并发场景
- ✅ 异常处理和数据一致性保证
- ✅ 性能指标和错误码定义
-**激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
- ✅ 测试场景矩阵和实现参考