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

30 KiB
Raw Blame History

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. 解决方案:前端购物车分类展示,提示用户分两单购买

技术实现

// 订单创建时校验
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 系统使用行锁:
    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_prioritycarrier_id + package_type + priority
  • 已有索引:idx_master_usage_idmaster_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 表中新增:

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 表中新增:

ALTER TABLE package
ADD COLUMN has_independent_expiry BOOLEAN DEFAULT false COMMENT '加油包是否有独立有效期(仅 package_type=addon 时有效)';

3. 要废弃的逻辑

  • 废弃旧的加油包关联逻辑:如果代码中存在通过 parent_usage_id 或其他字段关联主套餐的逻辑,全部删除
  • 废弃旧的继承逻辑:如果代码中存在"主套餐切换时加油包继承到新主套餐"的逻辑,全部删除
  • 废弃旧的有效期计算逻辑:如果加油包有效期计算不区分"独立有效期"和"跟随主套餐",全部重构

4. 历史数据强制转换

-- 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. 删除遗留表/字段(确认后执行)

-- 如果存在旧的关联表,删除
-- 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. 验证步骤

-- 验证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 重试,记录日志,发送告警

实现参考

购买加油包时的主套餐检查

// 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
}

主套餐过期时级联失效加油包

// 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
}

主套餐更新有效期时同步跟随加油包

// 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 完成,包含:

  • 业务背景和业务规则
  • 详细场景(依附、关联、独立有效期、跟随主套餐、级联失效、不继承)
  • 边界条件和并发场景
  • 异常处理和数据一致性保证
  • 性能指标和错误码定义
  • 激进的数据迁移策略(明确删除字段、废弃逻辑、强制转换)
  • 测试场景矩阵和实现参考