# 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=124(priority=1 最高) ### Requirement: 支持独立有效期加油包 系统 SHALL 支持加油包配置 has_independent_expiry=true,拥有独立的有效期。 #### Scenario: 独立有效期加油包激活时计算过期时间 - **GIVEN** 加油包 has_independent_expiry=true,duration_days=30 - **WHEN** 加油包在 2026-02-01 00:00:00 激活 - **THEN** 系统计算 expires_at=2026-03-02 23:59:59(+30天) #### Scenario: 独立有效期加油包过期 - **GIVEN** 加油包 has_independent_expiry=true,expires_at=2026-02-28 23:59:59,data_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=true,expires_at=2026-03-31 23:59:59 - **WHEN** 系统时间到达 2026-04-01 00:00:00 - **THEN** 加油包 status=3(已过期),主套餐仍为 status=1(生效中) #### Scenario: 独立有效期加油包在主套餐过期后仍失效 - **GIVEN** 加油包 has_independent_expiry=true,expires_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=false,master 主套餐 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=123,expires_at=2026-12-31 23:59:59 - **AND** 有3个加油包 master_usage_id=123,has_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=123,expires_at=2026-12-31 23:59:59 - **AND** 加油包A:has_independent_expiry=true,expires_at=2026-06-30 23:59:59 - **AND** 加油包B:has_independent_expiry=false,expires_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=false,expires_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=123,expires_at=2026-12-31 23:59:59 - **AND** 有3个加油包 master_usage_id=123: - 加油包A:data_usage_mb=50(未用完) - 加油包B:data_usage_mb=200(已用完) - 加油包C:has_independent_expiry=true,expires_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=true,expires_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=true,expires_at=2026-11-30 23:59:59,status=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** 主套餐A(ID=123)在 2026-12-31 过期,其加油包已失效(status=4) - **WHEN** 主套餐B(ID=124)在 2027-01-01 激活(priority=2 → status=1) - **THEN** 主套餐A的加油包 master_usage_id 保持 123,status 保持 4 - **AND** 主套餐B 无关联加油包 #### Scenario: 用户需为新主套餐重新购买加油包 - **GIVEN** 主套餐B(ID=124)刚激活(status=1) - **WHEN** 用户购买新加油包 - **THEN** 新加油包 master_usage_id=124,status=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** 主套餐A(ID=123)即将过期,主套餐B(ID=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 ``` --- ## 测试场景矩阵 | 场景分类 | 测试用例 | 预期结果 | |---------|---------|---------| | **依附检查** | 无主套餐购买加油包 | 返回错误 400:ADDON_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 | | | 尝试激活已失效加油包 | 返回错误 400:ADDON_MASTER_EXPIRED | | **并发** | 并发购买加油包 | 使用行锁,确保关联到同一主套餐 | | | 并发主套餐过期和购买加油包 | 事务隔离,先完成的操作生效 | | **异常** | master_usage_id 不存在 | 返回错误 500:MASTER_NOT_FOUND | | | 同步有效期失败 | 事务回滚,返回错误 500:SYNC_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 完成**,包含: - ✅ 业务背景和业务规则 - ✅ 详细场景(依附、关联、独立有效期、跟随主套餐、级联失效、不继承) - ✅ 边界条件和并发场景 - ✅ 异常处理和数据一致性保证 - ✅ 性能指标和错误码定义 - ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换) - ✅ 测试场景矩阵和实现参考