Files
junhong_cmp_fiber/openspec/changes/package-system-upgrade/design.md
huang 353621d923
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
移除所有测试代码和测试要求
**变更说明**:
- 删除所有 *_test.go 文件(单元测试、集成测试、验收测试、流程测试)
- 删除整个 tests/ 目录
- 更新 CLAUDE.md:用"测试禁令"章节替换所有测试要求
- 删除测试生成 Skill (openspec-generate-acceptance-tests)
- 删除测试生成命令 (opsx:gen-tests)
- 更新 tasks.md:删除所有测试相关任务

**新规范**:
-  禁止编写任何形式的自动化测试
-  禁止创建 *_test.go 文件
-  禁止在任务中包含测试相关工作
-  仅当用户明确要求时才编写测试

**原因**:
业务系统的正确性通过人工验证和生产环境监控保证,测试代码维护成本高于价值。

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 17:13:42 +08:00

57 KiB
Raw Blame History

技术设计文档: 套餐系统升级

Context

背景

当前套餐系统仅支持简单的按月计算模式,所有套餐立即生效,流量管理粗糙。新需求引入了代理商囤货场景(后台为未实名设备预购套餐,等待首次实名时自动激活)、灵活的套餐类型(自然月套餐、按天套餐)、多套餐管理(主套餐排队、加油包生命周期)、精细化流量统计(客户视图、套餐维度详单)等复杂业务逻辑。

当前状态

已有实现:

  • 套餐模型 (Package, PackageUsage):支持基础流量限额和使用统计
  • 轮询系统 (Scheduler):支持千万级卡规模的实名检查、流量检查、套餐检查
  • 订单服务:支持套餐购买并立即激活(activatePackage

现有限制:

  • Package.DurationMonths 只支持按月计算,无法区分自然月和按天
  • PackageUsage.Status 只有 1-生效中、2-已用完、3-已过期,无"待生效"和"已失效"状态
  • 无流量重置周期概念联通27号重置、日重置/月重置需求无法支持)
  • 无主套餐排队机制,同时可存在多个生效中的主套餐
  • 无加油包生命周期管理,加油包与主套餐无关联关系
  • 流量扣减无优先级,停机条件只检查单一套餐
  • 流量统计只有卡维度详单,无套餐维度详单和客户视图

约束条件

  • 必须遵循 Handler → Service → Store → Model 分层架构
  • 禁止外键约束和 GORM 关联关系foreignKey, hasMany, belongsTo
  • 所有常量定义在 pkg/constants/,禁止硬编码
  • 性能要求:套餐激活延迟 < 1分钟、API P95 < 200ms、千万级卡规模支持不退化
  • 异步任务使用 Asynq必须支持重试和幂等性

涉众

  • 代理商:需要囤货功能(提前为未实名设备低价采购套餐)
  • 客户(企业/个人):需要区分主套餐和加油包用量、查看流量详单
  • 运营团队:需要精细化流量统计、套餐生命周期管理
  • 开发团队:负责实施和维护升级后的套餐系统

Goals / Non-Goals

Goals

  1. 支持灵活的套餐类型:自然月套餐(按月边界)、按天套餐(精确天数)
  2. 支持流量重置周期日重置、月重置联通27号/其他1号、年重置、不重置
  3. 支持首次实名激活:代理商囤货场景,后台可为未实名设备购买套餐,等待实名时触发激活
  4. 支持主套餐排队:同时只能有一个生效中主套餐,后续购买自动排队,当前过期后自动激活下一个
  5. 支持加油包生命周期:加油包依附于主套餐,主套餐过期时级联失效
  6. 支持流量扣减优先级:优先扣减加油包(按 priority再扣主套餐全部用完才停机
  7. 支持套餐流量详单:按套餐维度记录每日流量增量,支持按日期查询
  8. 支持客户视图流量查询:区分主套餐和加油包用量,显示总计流量

Non-Goals

  1. 不支持加油包继承主套餐过期时加油包统一失效status=4不转移到下一个主套餐
  2. 不支持加油包跨主套餐共享:加油包只为当前主套餐服务
  3. 不支持套餐暂停/恢复:套餐生命周期是单向的(待生效 → 生效中 → 已用完/已过期/已失效)
  4. 不支持套餐流量转移:套餐间流量不可转移或合并
  5. 不修改现有卡流量详单逻辑:卡维度详单 (DataUsageRecord) 保持不变
  6. 不修改订单服务的分佣逻辑:分佣计算逻辑不在本次改造范围内

Decisions

1. 数据库 Schema 设计

1.1 Package 表扩展

方案: 在 tb_package 表新增 3 个字段

type Package struct {
    // ... 现有字段 ...

    // 新增字段
    CalendarType            string `gorm:"column:calendar_type;type:varchar(20);not null;default:'by_day';comment:周期类型 natural_month-自然月 by_day-按天" json:"calendar_type"`
    DataResetCycle          string `gorm:"column:data_reset_cycle;type:varchar(20);not null;default:'none';comment:流量重置周期 daily-每日 monthly-每月 yearly-每年 none-不重置" json:"data_reset_cycle"`
    EnableRealnameActivation bool  `gorm:"column:enable_realname_activation;type:boolean;default:false;comment:是否需要首次实名激活(后台囤货场景)" json:"enable_realname_activation"`
}

决策理由:

  • calendar_typedata_reset_cycle 是两个独立维度(套餐类型 vs 流量重置周期)
  • calendar_type=natural_month 时必须提供 duration_monthsby_day 时可提供 duration_days(如缺失则从 duration_months 转换)
  • enable_realname_activation=true 的套餐,后台购买时创建 PackageUsage(status=0, pending_realname_activation=true)

替代方案:

  • 使用 JSONB 字段存储扩展配置:不利于 SQL 查询和索引,违背"优先使用结构化字段"原则

1.2 PackageUsage 表扩展

方案: 扩展 tb_package_usage 表状态和新增 7 个字段

type PackageUsage struct {
    // ... 现有字段 ...

    // status 扩展0-待生效, 1-生效中, 2-已用完, 3-已过期, 4-已失效
    Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"`

    // 新增字段
    Priority                  int       `gorm:"column:priority;type:int;index;comment:优先级(主套餐排队顺序,数字越小优先级越高)" json:"priority"`
    MasterUsageID             *uint     `gorm:"column:master_usage_id;index;comment:主套餐ID加油包关联主套餐" json:"master_usage_id"`
    HasIndependentExpiry      bool      `gorm:"column:has_independent_expiry;type:boolean;default:false;comment:加油包是否有独立有效期" json:"has_independent_expiry"`
    PendingRealnameActivation bool      `gorm:"column:pending_realname_activation;type:boolean;default:false;comment:是否等待首次实名激活" json:"pending_realname_activation"`
    DataResetCycle            string    `gorm:"column:data_reset_cycle;type:varchar(20);not null;default:'none';comment:流量重置周期(从 Package 复制)" json:"data_reset_cycle"`
    LastResetAt               *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"`
    NextResetAt               *time.Time `gorm:"column:next_reset_at;comment:下次流量重置时间" json:"next_reset_at"`
}

决策理由:

  • priority: 主套餐排队顺序数字越小优先级越高1 > 2 > 3
  • master_usage_id: 加油包关联主套餐 ID实现生命周期管理主套餐过期时级联失效
  • has_independent_expiry: 加油包有效期模式true=独立有效期false=跟随主套餐)
  • pending_realname_activation: 标识是否等待首次实名激活(后台囤货场景)
  • data_reset_cycle: 从 Package.DataResetCycle 复制,避免 JOIN 查询
  • last_reset_at / next_reset_at: 支持流量重置调度

索引策略:

  • priority 索引:支持主套餐排队查询(WHERE status=0 AND priority=MIN(priority)
  • master_usage_id 索引:支持加油包级联失效查询(WHERE master_usage_id=?

替代方案:

  • 使用单独的 PackageQueue 表管理主套餐排队:增加复杂度,状态分散在两个表中,不利于一致性保证

1.3 IoT卡表扩展

方案: 在 tb_iot_card 表新增幂等字段

type IotCard struct {
    // ... 现有字段 ...

    // 新增字段
    FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间NULL=未实名非NULL=已实名(幂等标记)" json:"first_realname_at"`
}

决策理由

  • realname_status 字段更可靠(状态可能被重置,时间戳不可逆)
  • 可追溯首次实名时间
  • 数据库层面保证唯一更新(UPDATE SET first_realname_at=NOW() WHERE id=? AND first_realname_at IS NULL
  • 支持幂等性NULL=未实名非NULL=已处理

使用方式

-- 首次实名触发时
UPDATE tb_iot_card
SET first_realname_at = NOW()
WHERE id = ? AND first_realname_at IS NULL;

-- 判断是否首次实名
SELECT first_realname_at FROM tb_iot_card WHERE id = ?;
-- NULL = 首次,执行激活逻辑
-- 非NULL = 已处理,跳过

1.4 运营商表扩展

方案: 在 tb_carrier 表新增计费日配置字段

type Carrier struct {
    // ... 现有字段 ...

    // 新增字段
    BillingDay *int `gorm:"column:billing_day;comment:计费日1-31NULL=默认1号27=联通" json:"billing_day"`
}

决策理由

  • 可配置化无需硬编码联通27号规则
  • 支持运营商策略变更
  • 便于新运营商接入
  • 便于测试(可模拟不同计费日)

数据初始化

UPDATE tb_carrier SET billing_day = 27 WHERE name = '中国联通';
UPDATE tb_carrier SET billing_day = 1 WHERE name IN ('中国移动', '中国电信');

1.5 新增卡日流量详单表

方案: 创建卡维度日流量统计表

type CardDailyUsage struct {
    gorm.Model
    CardID          uint      `gorm:"column:card_id;index:idx_card_date;not null;comment:卡ID" json:"card_id"`
    UsageDate       time.Time `gorm:"column:usage_date;type:date;index:idx_card_date;not null;comment:使用日期" json:"usage_date"`
    TotalDataUsage  int64     `gorm:"column:total_data_usage;type:bigint;not null;comment:总流量使用(字节),聚合该卡当日所有套餐的用量" json:"total_data_usage"`
    CarrierID       int       `gorm:"column:carrier_id;comment:运营商ID冗余便于查询" json:"carrier_id"`
}

// 唯一索引
CREATE UNIQUE INDEX idx_card_date ON tb_card_daily_usage(card_id, usage_date) WHERE deleted_at IS NULL;

// 日期索引(便于按日期范围查询)
CREATE INDEX idx_usage_date ON tb_card_daily_usage(usage_date);

决策理由

  • 支持卡维度流量统计查询:不需要聚合多个套餐记录
  • 简化账单生成直接查询卡日流量无需JOIN套餐表
  • 提升查询性能避免复杂的GROUP BY和SUM操作
  • 流量告警触发:快速查询卡当日总流量

数据来源

卡日总流量 = SUM(该卡所有生效套餐当日增量)

与 PackageUsageDailyRecord 的关系

  • PackageUsageDailyRecord:套餐维度详单(区分主套餐和加油包)
  • CardDailyUsage:卡维度汇总(所有套餐总和)
  • 两者互补,不重复

1.6 新增 PackageUsageDailyRecord 表

方案: 创建套餐流量日记录表

type PackageUsageDailyRecord struct {
    gorm.Model
    BaseModel           `gorm:"embedded"`
    PackageUsageID      uint      `gorm:"column:package_usage_id;index:idx_usage_date;not null;comment:套餐使用记录ID" json:"package_usage_id"`
    Date                time.Time `gorm:"column:date;type:date;index:idx_usage_date;not null;comment:日期" json:"date"`
    DailyUsageMB        int64     `gorm:"column:daily_usage_mb;type:bigint;not null;comment:当日流量增量(MB)" json:"daily_usage_mb"`
    CumulativeUsageMB   int64     `gorm:"column:cumulative_usage_mb;type:bigint;not null;comment:累计流量(MB)" json:"cumulative_usage_mb"`
}

// 唯一索引
CREATE UNIQUE INDEX idx_package_usage_date ON tb_package_usage_daily_record(package_usage_id, date) WHERE deleted_at IS NULL;

决策理由:

  • 按套餐维度记录:每个 PackageUsage 每天一条记录
  • daily_usage_mb: 当天流量增量(基于上游累计流量计算)
  • cumulative_usage_mb: 截至当天的累计流量
  • 联合唯一索引:确保同一套餐同一天只有一条记录

数据来源:

今日增量 = max(上游返回累计流量 - 昨日 cumulative_usage_mb, 0)

替代方案:

  • 复用现有 DataUsageRecordDataUsageRecord 按卡维度记录,无法区分主套餐和加油包,需要独立表

2. 业务流程设计

2.1 首次实名激活流程

┌─────────────────────────────────────────────────────────────────┐
│  后台订单服务Order Service                                  │
├─────────────────────────────────────────────────────────────────┤
│  1. 代理商为未实名设备购买套餐enable_realname_activation=true│
│  2. 创建 PackageUsage(status=0, pending_realname_activation=true)│
│     activated_at=NULL, expires_at=NULL                          │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 实名检查任务Realname Handler                    │
├─────────────────────────────────────────────────────────────────┤
│  3. 检测到首次实名realname_status: 0/1 → 2                  │
│  4. 查询该卡/设备是否有待激活套餐                               │
│     WHERE pending_realname_activation=true AND status=0         │
│  5. 提交 Asynq 任务: TaskTypePackageFirstActivation            │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  Asynq Handler - 套餐首次实名激活任务                           │
├─────────────────────────────────────────────────────────────────┤
│  6. 根据 calendar_type 计算 activated_at 和 expires_at          │
│     - natural_month: 激活时间=当前时间,过期时间=月末23:59:59  │
│     - by_day: 激活时间=当前时间,过期时间=激活时间+N天         │
│  7. 更新 PackageUsage                                           │
│     status=1, pending_realname_activation=false,                │
│     activated_at=计算值, expires_at=计算值                       │
│  8. 记录操作日志                                                 │
└─────────────────────────────────────────────────────────────────┘

幂等性保证:

  • 任务处理前检查 pending_realname_activation=false,已激活则直接返回成功
  • 使用数据库事务更新 PackageUsage

重试策略:

  • 最大重试 3 次Asynq MaxRetry(3)
  • 超时时间 30 秒Asynq Timeout(30s)

性能目标:

  • 实名检测到激活延迟 < 30 秒(取决于轮询间隔 + 队列延迟)

2.2 主套餐排队生效流程

┌─────────────────────────────────────────────────────────────────┐
│  订单服务Order Service                                       │
├─────────────────────────────────────────────────────────────────┤
│  1. 用户购买主套餐package_type=formal                        │
│  2. 查询该载体当前生效中主套餐                                   │
│     WHERE usage_type=? AND (iot_card_id/device_id)=? AND        │
│           status=1 AND master_usage_id IS NULL                  │
│  3. 如果有生效中主套餐:                                           │
│     - 新套餐 status=0, priority=MAX(priority)+1                 │
│     - activated_at=NULL, expires_at=NULL                        │
│     如果无生效中主套餐:                                           │
│     - 新套餐 status=1, priority=1, 立即激活                      │
│     - 根据 calendar_type 计算 activated_at 和 expires_at        │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 套餐激活检查任务(每 10 秒调度一次)                 │
├─────────────────────────────────────────────────────────────────┤
│  4. 查询已过期主套餐status=1 AND expires_at <= NOW         │
│  5. 更新过期主套餐 status=3                                      │
│  6. 查询该载体下一个待生效主套餐                                 │
│     WHERE usage_type=? AND (iot_card_id/device_id)=? AND        │
│           status=0 AND master_usage_id IS NULL                  │
│     ORDER BY priority ASC LIMIT 1                               │
│  7. 提交 Asynq 任务: TaskTypePackageQueueActivation            │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  Asynq Handler - 主套餐排队激活任务                             │
├─────────────────────────────────────────────────────────────────┤
│  8. 根据 calendar_type 计算 activated_at 和 expires_at          │
│  9. 更新 PackageUsage status=1, activated_at, expires_at       │
│  10. 记录操作日志                                                │
└─────────────────────────────────────────────────────────────────┘

激活延迟保证:

  • 目标: 主套餐过期后 1 分钟内激活下一个
  • 调度间隔: 10 秒(Scheduler.scheduleLoop 每 10 秒执行一次)
  • 性能分析:
    • 过期检查: 10 秒(最差情况,刚好错过本次调度)
    • 队列延迟: < 1 秒Asynq 队列延迟)
    • 激活处理: < 5 秒(数据库更新 + 日志记录)
    • 总延迟 < 20 秒(满足 < 1 分钟要求)

幂等性保证:

  • 任务处理前检查 status=1,已激活则直接返回成功
  • 使用数据库事务 + 乐观锁(WHERE status=0)防止重复激活

2.3 加油包生命周期管理

┌─────────────────────────────────────────────────────────────────┐
│  订单服务Order Service                                       │
├─────────────────────────────────────────────────────────────────┤
│  1. 用户购买加油包package_type=addon                         │
│  2. 检查该载体是否有生效中或待生效主套餐                         │
│     WHERE usage_type=? AND (iot_card_id/device_id)=? AND        │
│           status IN (0,1) AND master_usage_id IS NULL           │
│  3. 如果无主套餐: 返回错误 "必须有主套餐才能购买加油包"          │
│  4. 创建 PackageUsage:                                           │
│     - master_usage_id=主套餐ID                                   │
│     - status=1, priority=MAX(priority)+1 (同一主套餐下)         │
│     - has_independent_expiry=套餐配置的独立有效期模式            │
│     - 根据 has_independent_expiry 计算 expires_at               │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 套餐激活检查任务                                     │
├─────────────────────────────────────────────────────────────────┤
│  5. 查询已过期主套餐status=1 AND expires_at <= NOW         │
│  6. 更新主套餐 status=3                                          │
│  7. 查询该主套餐下的所有加油包                                   │
│     WHERE master_usage_id=主套餐ID                              │
│  8. 批量更新加油包 status=4已失效                           │
│  9. 记录操作日志                                                 │
└─────────────────────────────────────────────────────────────────┘

有效期计算:

  • has_independent_expiry=true: 根据加油包套餐的 calendar_typeduration_* 计算
  • has_independent_expiry=false: expires_at=主套餐.expires_at

级联失效:

  • 主套餐过期时(status=3),批量更新关联加油包 status=4
  • 不支持加油包转移到下一个主套餐

2.4 流量扣减优先级流程

┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 流量检查任务Carddata Handler                    │
├─────────────────────────────────────────────────────────────────┤
│  1. 查询上游流量ICCID → 累计流量)                            │
│  2. 查询该卡当前生效套餐status=1 的主套餐和加油包)           │
│  3. 按优先级排序:加油包(按 priority ASC→ 主套餐             │
│  4. 依次扣减流量:                                                │
│     FOR EACH 套餐 IN 优先级列表:                                 │
│       剩余额度 = data_limit_mb - data_usage_mb                  │
│       IF 剩余额度 > 0:                                           │
│         扣减量 = MIN(本次增量, 剩余额度)                         │
│         UPDATE data_usage_mb += 扣减量                          │
│         本次增量 -= 扣减量                                       │
│         记录到 PackageUsageDailyRecord                          │
│       IF data_usage_mb >= data_limit_mb:                        │
│         UPDATE status=2 (已用完)                                │
│       IF 本次增量 == 0:                                          │
│         BREAK                                                   │
│  5. 检查停机条件:                                                │
│     IF 所有套餐 data_usage_mb >= data_limit_mb:                 │
│       触发停机操作                                               │
└─────────────────────────────────────────────────────────────────┘

停机条件:

  • 旧逻辑: 单一套餐流量用完即停机
  • 新逻辑: 主套餐 + 所有加油包流量全部用完才停机

性能优化:

  • 批量查询套餐(一次 SQL 获取主套餐和所有加油包)
  • 批量更新套餐(使用事务提交)

2.5 流量重置调度流程

┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 流量重置调度任务(每 10 秒调度一次)                 │
├─────────────────────────────────────────────────────────────────┤
│  1. 每日 0 点触发日重置:                                         │
│     WHERE data_reset_cycle='daily' AND next_reset_at <= NOW    │
│     UPDATE data_usage_mb=0, last_reset_at=NOW,                  │
│            next_reset_at=明天 00:00:00                          │
│                                                                 │
│  2. 每月 1 号(非联通)或 27 号(联通)触发月重置:               │
│     WHERE data_reset_cycle='monthly' AND next_reset_at <= NOW  │
│     UPDATE data_usage_mb=0, last_reset_at=NOW,                  │
│            next_reset_at=下月 1号/27号 00:00:00                │
│                                                                 │
│  3. 每年 1 月 1 日触发年重置:                                    │
│     WHERE data_reset_cycle='yearly' AND next_reset_at <= NOW   │
│     UPDATE data_usage_mb=0, last_reset_at=NOW,                  │
│            next_reset_at=明年 1月 1日 00:00:00                  │
└─────────────────────────────────────────────────────────────────┘

重置时间计算:

  • daily: 每天 00:00:00
  • monthly:
    • 联通卡(carrier_id=CUCC: 每月 27 号 00:00:00
    • 其他运营商: 每月 1 号 00:00:00
  • yearly: 每年 1 月 1 日 00:00:00
  • none: 不重置(next_reset_at=NULL

分批处理:

  • 每次最多处理 10000 条记录(避免长事务)
  • 使用游标分批查询(WHERE id > last_id

幂等性保证:

  • 使用 next_reset_at <= NOW 条件查询,已重置的记录 next_reset_at 已更新到未来时间

3. 状态机设计

3.1 PackageUsage 状态转换图

                     ┌────────────────┐
                     │   0-待生效     │
                     │  (Pending)     │
                     └────────────────┘
                            │
              ┌─────────────┼─────────────┐
              │                           │
    首次实名激活 / 主套餐排队激活       过期前删除订单
              │                           │
              ↓                           ↓
      ┌────────────────┐           ┌────────────────┐
      │   1-生效中     │           │   已删除       │
      │  (Active)      │           │  (Deleted)     │
      └────────────────┘           └────────────────┘
              │
      ┌───────┴───────┐
      │               │
  流量用完       有效期过期
      │               │
      ↓               ↓
┌────────────────┐ ┌────────────────┐
│   2-已用完     │ │   3-已过期     │
│  (Depleted)    │ │  (Expired)     │
└────────────────┘ └────────────────┘
      │               │
      └───────┬───────┘
              │
      (仅加油包) 主套餐过期
              │
              ↓
      ┌────────────────┐
      │   4-已失效     │
      │  (Invalidated) │
      └────────────────┘

状态说明:

  • 0-待生效: 套餐已购买但未激活(等待首次实名或主套餐排队)
  • 1-生效中: 套餐正在生效,流量可用
  • 2-已用完: 流量已耗尽但未过期(可续费加油包)
  • 3-已过期: 有效期已过(主套餐过期触发下一个激活)
  • 4-已失效: 加油包跟随主套餐失效(仅加油包)

不可逆性: 状态转换是单向的,不支持反向转换(如已过期 → 生效中)


4. API 设计

4.1 套餐管理 API 改造

创建套餐 API

POST /api/admin/packages
Content-Type: application/json

{
  "package_code": "PACKAGE-001",
  "package_name": "联通月卡30GB",
  "series_id": 1,
  "package_type": "formal",

  // 新增字段
  "calendar_type": "natural_month",     // 必填: natural_month | by_day
  "duration_months": 1,                 // calendar_type=natural_month 时必填
  "duration_days": null,                // calendar_type=by_day 时必填
  "data_reset_cycle": "monthly",        // 必填: daily | monthly | yearly | none
  "enable_realname_activation": true,   // 可选,默认 false

  "real_data_mb": 30720,
  "virtual_data_mb": 0,
  "enable_virtual_data": false,
  "cost_price": 1500,
  "suggested_retail_price": 3000
}

响应:

{
  "code": 200,
  "msg": "success",
  "data": {
    "id": 123,
    "package_code": "PACKAGE-001",
    "calendar_type": "natural_month",
    "data_reset_cycle": "monthly",
    "enable_realname_activation": true,
    // ... 其他字段
  }
}

更新套餐 API:

  • 支持更新 calendar_typedata_reset_cycleenable_realname_activation
  • package_code 不可修改

4.2 客户视图流量查询 API新增

GET /api/h5/packages/my-usage
Authorization: Bearer <token>

响应:

{
  "code": 200,
  "msg": "success",
  "data": {
    "main_package": {
      "package_usage_id": 1,
      "package_name": "联通月卡30GB",
      "used_mb": 8192,
      "total_mb": 30720,
      "status": 1,
      "expires_at": "2026-02-28T23:59:59Z"
    },
    "addon_packages": [
      {
        "package_usage_id": 2,
        "package_name": "流量加油包10GB",
        "used_mb": 3072,
        "total_mb": 10240,
        "status": 1,
        "expires_at": "2026-02-28T23:59:59Z"
      },
      {
        "package_usage_id": 3,
        "package_name": "流量加油包5GB",
        "used_mb": 1024,
        "total_mb": 5120,
        "status": 1,
        "expires_at": "2026-03-15T23:59:59Z"
      }
    ],
    "total": {
      "used_mb": 12288,
      "total_mb": 46080
    }
  }
}

性能要求: P95 < 200ms

查询逻辑:

  1. 根据 token 获取 user_id 和载体信息(iot_card_iddevice_id
  2. 查询生效中或已用完的套餐(WHERE status IN (1,2)
  3. 区分主套餐(master_usage_id IS NULL)和加油包(master_usage_id IS NOT NULL
  4. 计算总计流量(主套餐 + 所有加油包)

4.3 套餐流量详单 API新增

GET /api/admin/package-usage/:id/daily-records?start_date=2026-02-01&end_date=2026-02-10
Authorization: Bearer <token>

响应:

{
  "code": 200,
  "msg": "success",
  "data": {
    "package_usage_id": 1,
    "package_name": "联通月卡30GB",
    "records": [
      {
        "date": "2026-02-01",
        "daily_usage_mb": 1024,
        "cumulative_usage_mb": 1024
      },
      {
        "date": "2026-02-02",
        "daily_usage_mb": 2048,
        "cumulative_usage_mb": 3072
      }
      // ...
    ],
    "total_usage_mb": 3072
  }
}

查询逻辑:

  1. 验证越权(使用 middleware.CanManageShopmiddleware.CanManageEnterprise
  2. 查询日记录表(WHERE package_usage_id=? AND date BETWEEN ? AND ?
  3. date ASC 排序

5. 常量管理

pkg/constants/constants.go 新增以下常量:

// 套餐周期类型
const (
    PackageCalendarTypeNaturalMonth = "natural_month" // 自然月
    PackageCalendarTypeByDay        = "by_day"        // 按天
)

// 套餐流量重置周期
const (
    PackageDataResetDaily   = "daily"   // 每日
    PackageDataResetMonthly = "monthly" // 每月
    PackageDataResetYearly  = "yearly"  // 每年
    PackageDataResetNone    = "none"    // 不重置
)

// 套餐使用状态
const (
    PackageUsageStatusPending     = 0 // 待生效
    PackageUsageStatusActive      = 1 // 生效中
    PackageUsageStatusDepleted    = 2 // 已用完
    PackageUsageStatusExpired     = 3 // 已过期
    PackageUsageStatusInvalidated = 4 // 已失效(加油包跟随主套餐)
)

// 任务类型
const (
    TaskTypePackageFirstActivation = "package:first_activation" // 首次实名激活
    TaskTypePackageQueueActivation = "package:queue_activation" // 主套餐排队激活
    TaskTypePackageDataReset       = "package:data_reset"       // 流量重置
)

// Redis 键函数
func RedisPackageActivationLockKey(usageID uint) string {
    return fmt.Sprintf("package:activation:lock:%d", usageID)
}

6. 依赖注入设计

6.1 Store 层

// internal/store/postgres/package.go
type PackageStore struct {
    db    *gorm.DB
    redis *redis.Client
}

func NewPackageStore(db *gorm.DB, redis *redis.Client) *PackageStore {
    return &PackageStore{db: db, redis: redis}
}

// internal/store/postgres/package_usage.go
type PackageUsageStore struct {
    db    *gorm.DB
    redis *redis.Client
}

func NewPackageUsageStore(db *gorm.DB, redis *redis.Client) *PackageUsageStore {
    return &PackageUsageStore{db: db, redis: redis}
}

// internal/store/postgres/package_usage_daily_record.go
type PackageUsageDailyRecordStore struct {
    db *gorm.DB
}

func NewPackageUsageDailyRecordStore(db *gorm.DB) *PackageUsageDailyRecordStore {
    return &PackageUsageDailyRecordStore{db: db}
}

6.2 Service 层

// internal/service/package/service.go
type Service struct {
    packageStore         *postgres.PackageStore
    packageUsageStore    *postgres.PackageUsageStore
    packageSeriesStore   *postgres.PackageSeriesStore
    logger               *zap.Logger
}

func NewService(
    packageStore *postgres.PackageStore,
    packageUsageStore *postgres.PackageUsageStore,
    packageSeriesStore *postgres.PackageSeriesStore,
    logger *zap.Logger,
) *Service {
    return &Service{
        packageStore:       packageStore,
        packageUsageStore:  packageUsageStore,
        packageSeriesStore: packageSeriesStore,
        logger:             logger,
    }
}

// internal/service/order/service.go
type Service struct {
    // ... 现有依赖 ...
    packageUsageStore *postgres.PackageUsageStore
    queueClient       *asynq.Client
}

6.3 Handler 层

// internal/handler/admin/package.go
type PackageHandler struct {
    packageService *package_service.Service
    logger         *zap.Logger
}

func NewPackageHandler(
    packageService *package_service.Service,
    logger *zap.Logger,
) *PackageHandler {
    return &PackageHandler{
        packageService: packageService,
        logger:         logger,
    }
}

// internal/handler/h5/package_usage.go新增
type PackageUsageHandler struct {
    packageUsageService *package_service.Service
    logger              *zap.Logger
}

func NewPackageUsageHandler(
    packageUsageService *package_service.Service,
    logger *zap.Logger,
) *PackageUsageHandler {
    return &PackageUsageHandler{
        packageUsageService: packageUsageService,
        logger:              logger,
    }
}

7. 事务处理设计

7.1 主套餐排队激活事务

func (s *Service) ActivateQueuedPackage(ctx context.Context, usageID uint) error {
    // 使用 Redis 分布式锁避免并发激活
    lockKey := constants.RedisPackageActivationLockKey(usageID)
    lock := s.redis.SetNX(ctx, lockKey, 1, 30*time.Second)
    if !lock.Val() {
        return errors.New(errors.CodeConflict, "套餐正在激活中,请稍后重试")
    }
    defer s.redis.Del(ctx, lockKey)

    // 开启事务
    return s.db.Transaction(func(tx *gorm.DB) error {
        // 1. 查询待激活套餐(加行锁)
        var usage model.PackageUsage
        err := tx.Where("id = ? AND status = ?", usageID, 0).
            Clauses(clause.Locking{Strength: "UPDATE"}).
            First(&usage).Error
        if err != nil {
            return errors.Wrap(errors.CodeNotFound, err, "套餐不存在或已激活")
        }

        // 2. 查询套餐配置
        var pkg model.Package
        if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
            return errors.Wrap(errors.CodeInternal, err, "查询套餐配置失败")
        }

        // 3. 计算激活时间和过期时间
        activatedAt := time.Now()
        expiresAt := s.calculateExpiryTime(activatedAt, pkg.CalendarType, pkg.DurationMonths, pkg.DurationDays)

        // 4. 更新 PackageUsage
        err = tx.Model(&usage).Updates(map[string]interface{}{
            "status":       1,
            "activated_at": activatedAt,
            "expires_at":   expiresAt,
        }).Error
        if err != nil {
            return errors.Wrap(errors.CodeInternal, err, "更新套餐状态失败")
        }

        // 5. 记录操作日志
        s.logger.Info("主套餐排队激活成功",
            zap.Uint("usage_id", usageID),
            zap.Time("activated_at", activatedAt),
            zap.Time("expires_at", expiresAt))

        return nil
    })
}

7.2 加油包级联失效事务

func (s *Service) CascadeInvalidateAddons(ctx context.Context, masterUsageID uint) error {
    return s.db.Transaction(func(tx *gorm.DB) error {
        // 1. 查询主套餐下的所有加油包
        var addons []*model.PackageUsage
        err := tx.Where("master_usage_id = ? AND status IN (1,2)", masterUsageID).
            Find(&addons).Error
        if err != nil {
            return errors.Wrap(errors.CodeInternal, err, "查询加油包失败")
        }

        if len(addons) == 0 {
            return nil // 无加油包,直接返回
        }

        // 2. 批量更新加油包状态为"已失效"
        addonIDs := make([]uint, len(addons))
        for i, addon := range addons {
            addonIDs[i] = addon.ID
        }

        err = tx.Model(&model.PackageUsage{}).
            Where("id IN ?", addonIDs).
            Update("status", 4).Error
        if err != nil {
            return errors.Wrap(errors.CodeInternal, err, "更新加油包状态失败")
        }

        // 3. 记录操作日志
        s.logger.Info("加油包级联失效完成",
            zap.Uint("master_usage_id", masterUsageID),
            zap.Int("invalidated_count", len(addons)))

        return nil
    })
}

现有代码修正清单

本章节列出套餐系统升级中需要修改的现有代码缺陷和不兼容逻辑。

1. 数据重置日期计算逻辑修正

文件位置: internal/service/iot_card/traffic_utils.go 或类似文件(需确认实际位置)

问题描述:

  • 当前代码硬编码按自然月重置每月1号
  • 不支持 by_day 类型(购买日周期)
  • 不支持联通卡特殊规则27号重置

修改方案:

// 旧代码(需删除或重构)
func calculateResetDate(activatedAt time.Time) time.Time {
    return time.Date(activatedAt.Year(), activatedAt.Month()+1, 1, 0, 0, 0, 0, time.UTC)
}

// 新代码(支持多种重置周期)
func calculateResetDate(pkg *model.Package, activatedAt time.Time, carrierID int) time.Time {
    switch pkg.DataResetCycle {
    case constants.PackageDataResetDaily:
        // 每日:明天 00:00:00
        return time.Date(activatedAt.Year(), activatedAt.Month(), activatedAt.Day()+1, 0, 0, 0, 0, time.UTC)

    case constants.PackageDataResetMonthly:
        // 每月:根据运营商确定计费日
        billingDay := 1
        if carrierID == constants.CarrierCUCC { // 联通
            billingDay = 27
        }
        // 计算下个计费日
        year, month := activatedAt.Year(), activatedAt.Month()+1
        if month > 12 {
            year++
            month = 1
        }
        // 处理月末边界如31号在2月不存在
        lastDayOfMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
        if billingDay > lastDayOfMonth {
            billingDay = lastDayOfMonth
        }
        return time.Date(year, month, billingDay, 0, 0, 0, 0, time.UTC)

    case constants.PackageDataResetYearly:
        // 每年:明年 1月1日 00:00:00
        return time.Date(activatedAt.Year()+1, 1, 1, 0, 0, 0, 0, time.UTC)

    case constants.PackageDataResetNone:
        // 不重置
        return time.Time{}

    default:
        // 默认按月
        return time.Date(activatedAt.Year(), activatedAt.Month()+1, 1, 0, 0, 0, 0, time.UTC)
    }
}

影响范围:

  • 订单服务创建套餐时计算 next_reset_at
  • 轮询系统流量重置调度
  • 单元测试需新增联通卡、按天套餐测试用例

2. 流量扣减逻辑重构

文件位置: internal/handler/worker/iot_card_traffic.gointernal/service/iot_card/traffic_deduction.go

问题描述:

  • 当前按套餐激活顺序扣减
  • 不支持"加油包优先"规则
  • 未区分主套餐和加油包

修改方案:

// 旧代码(需删除)
func (s *Service) DeductTraffic(ctx context.Context, cardID uint, increment int64) error {
    // 查询生效套餐,按 activated_at ASC 排序
    packages := s.store.GetActivePackages(cardID) // 问题:不区分主套餐和加油包

    for _, pkg := range packages {
        // 按激活时间顺序扣减(错误逻辑)
        ...
    }
}

// 新代码(支持优先级扣减)
func (s *Service) DeductTraffic(ctx context.Context, cardID uint, increment int64) error {
    // 1. 查询卡的所有生效套餐
    packages, err := s.store.GetActivePackagesByPriority(ctx, cardID)
    if err != nil {
        return errors.Wrap(errors.CodeInternal, err, "查询生效套餐失败")
    }

    // 2. 按优先级排序(加油包优先,再按 priority ASC, expires_at ASC, activated_at ASC
    // Store 层已排序,此处直接使用

    // 3. 依次扣减
    remainingIncrement := increment
    for _, pkg := range packages {
        if remainingIncrement <= 0 {
            break
        }

        availableData := pkg.DataLimitMB - pkg.DataUsageMB
        if availableData <= 0 {
            continue // 已用完,跳过
        }

        deductAmount := min(remainingIncrement, availableData)

        // 更新套餐用量
        err := s.store.UpdateDataUsage(ctx, pkg.ID, deductAmount)
        if err != nil {
            return errors.Wrap(errors.CodeInternal, err, "更新套餐用量失败")
        }

        // 记录日记录
        err = s.recordDailyUsage(ctx, pkg.ID, deductAmount)
        if err != nil {
            // 日记录失败不影响扣减(仅记录日志)
            s.logger.Error("记录日用量失败", zap.Error(err))
        }

        remainingIncrement -= deductAmount

        // 检查套餐是否用完
        if pkg.DataUsageMB+deductAmount >= pkg.DataLimitMB {
            err := s.store.UpdatePackageStatus(ctx, pkg.ID, constants.PackageUsageStatusDepleted)
            if err != nil {
                s.logger.Error("更新套餐状态失败", zap.Error(err))
            }
        }
    }

    // 4. 检查停机条件
    if remainingIncrement > 0 || s.shouldStopCard(ctx, cardID) {
        return s.stopCard(ctx, cardID)
    }

    return nil
}

// Store 层查询方法(新增)
func (s *Store) GetActivePackagesByPriority(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
    var packages []*model.PackageUsage
    err := s.db.WithContext(ctx).
        Where("iot_card_id = ? AND status = ?", cardID, constants.PackageUsageStatusActive).
        Order("(master_usage_id IS NOT NULL) DESC, priority ASC, expires_at ASC, activated_at ASC").
        Find(&packages).Error
    return packages, err
}

影响范围:

  • 轮询系统流量检查任务
  • Store 层新增 GetActivePackagesByPriority 方法
  • 单元测试需覆盖多加油包扣减场景

3. 轮询系统套餐激活入口

文件位置: internal/handler/worker/iot_card_polling.go

问题描述:

  • 当前轮询仅处理卡状态同步
  • 不处理待激活套餐队列
  • 不处理主套餐过期检测

修改方案:

// 旧代码
func (h *Handler) HandleIotCardPolling(ctx context.Context, task *asynq.Task) error {
    // 解析任务参数
    var payload IotCardPollingPayload
    if err := json.Unmarshal(task.Payload(), &payload); err != nil {
        return err
    }

    // 1. 同步卡状态
    err := h.syncCardStatus(ctx, payload.CardID)
    if err != nil {
        return err
    }

    // 2. 同步流量
    err = h.syncCardTraffic(ctx, payload.CardID)
    if err != nil {
        return err
    }

    // 3. 同步费用
    err = h.syncCardBalance(ctx, payload.CardID)
    if err != nil {
        return err
    }

    return nil // 缺少套餐激活检查
}

// 新代码(增加套餐激活检查)
func (h *Handler) HandleIotCardPolling(ctx context.Context, task *asynq.Task) error {
    // ... 现有逻辑:同步卡状态、流量、费用

    // 4. 新增:检查并激活排队套餐
    card, err := h.iotCardStore.GetByID(ctx, payload.CardID)
    if err != nil {
        return err
    }

    if card.Status == constants.IotCardStatusActive {
        // 检查是否有待激活套餐
        err := h.packageActivationService.CheckAndActivateQueuedPackages(ctx, card.ID)
        if err != nil {
            // 不中断轮询,记录日志继续
            h.logger.Error("激活排队套餐失败",
                zap.Uint("card_id", card.ID),
                zap.Error(err))
        }
    }

    // 5. 新增:检查主套餐是否过期
    err = h.packageActivationService.CheckExpiredPackages(ctx, card.ID)
    if err != nil {
        h.logger.Error("检查过期套餐失败",
            zap.Uint("card_id", card.ID),
            zap.Error(err))
    }

    return nil
}

影响范围:

  • 轮询系统 Handler 层
  • 新增 PackageActivationService 依赖注入
  • 集成测试需验证完整轮询流程

4. 加油包过期级联处理

位置: 新增功能,无现有代码需修改

说明:

  • 当前系统无加油包概念,这是全新功能
  • 需在套餐过期处理流程中增加级联失效逻辑
  • 详见 addon-package-lifecycle/spec.md

新增代码位置:

  • internal/service/package/lifecycle_service.go(新建)
  • internal/handler/worker/package_expiry.go(新建或扩展)

核心逻辑:

func (s *Service) HandleMainPackageExpiry(ctx context.Context, mainPackageID uint) error {
    tx := s.db.BeginTx(ctx)
    defer tx.Rollback()

    // 1. 更新主套餐状态为已过期
    err := s.store.UpdatePackageStatus(ctx, tx, mainPackageID, constants.PackageUsageStatusExpired)
    if err != nil {
        return err
    }

    // 2. 查询关联的加油包
    addons, err := s.store.GetAddonsByMasterID(ctx, mainPackageID)
    if err != nil {
        return err
    }

    // 3. 批量级联失效加油包
    if len(addons) > 0 {
        addonIDs := extractIDs(addons)
        err = s.store.BatchUpdateStatus(ctx, tx, addonIDs, constants.PackageUsageStatusInvalidated)
        if err != nil {
            return err
        }
    }

    // 4. 提交事务
    if err := tx.Commit().Error; err != nil {
        return err
    }

    // 5. 记录审计日志
    s.auditService.LogOperation(ctx, &model.OperationLog{
        OperationType: "cascade_invalidate",
        OperationDesc: fmt.Sprintf("主套餐ID=%d过期级联失效%d个加油包", mainPackageID, len(addons)),
    })

    return nil
}

5. 停机条件更新

文件位置: internal/service/iot_card/stop_service.go 或类似文件

问题描述:

  • 旧逻辑:单一套餐流量用完即停机
  • 新逻辑:主套餐 + 所有加油包流量全部用完才停机

修改方案:

// 旧代码(需删除)
func (s *Service) CheckStopCondition(ctx context.Context, cardID uint) (bool, error) {
    // 查询生效中套餐
    pkg, err := s.store.GetActiveMainPackage(ctx, cardID)
    if err != nil {
        return false, err
    }

    // 旧逻辑:主套餐用完即停机(错误)
    if pkg.DataUsageMB >= pkg.DataLimitMB {
        return true, nil
    }

    return false, nil
}

// 新代码(检查所有套餐)
func (s *Service) CheckStopCondition(ctx context.Context, cardID uint) (bool, error) {
    // 查询所有生效中的套餐(包括主套餐和加油包)
    count, err := s.store.CountAvailablePackages(ctx, cardID)
    if err != nil {
        return false, err
    }

    // 新逻辑:所有套餐都用完才停机
    return count == 0, nil
}

// Store 层查询方法(新增)
func (s *Store) CountAvailablePackages(ctx context.Context, cardID uint) (int64, error) {
    var count int64
    err := s.db.WithContext(ctx).
        Model(&model.PackageUsage{}).
        Where("iot_card_id = ? AND status = ? AND data_usage_mb < data_limit_mb",
            cardID, constants.PackageUsageStatusActive).
        Count(&count).Error
    return count, err
}

影响范围:

  • 轮询系统流量检查后的停机判断
  • 单元测试需覆盖"加油包剩余流量不停机"场景

Risks / Trade-offs

1. [性能] 套餐激活调度频率 vs 激活延迟

Risk:

  • 调度间隔 10 秒,最差情况下主套餐过期后 20 秒内激活下一个
  • 如果同时有大量套餐过期,可能出现队列堆积

Mitigation:

  • 调度间隔可配置(默认 10 秒),支持运行时调整
  • 套餐激活任务使用独立队列(优先级高于其他任务)
  • 监控 Asynq 队列长度和处理延迟,告警阈值设置为 1 分钟

2. [复杂度] 流量扣减优先级逻辑的边界条件

Risk:

  • 多个加油包 + 主套餐的流量扣减逻辑复杂,容易出现边界问题(如负数流量、扣减不完全)
  • 并发场景下可能出现流量扣减不一致

Mitigation:

  • 流量扣减使用数据库事务 + 行锁(SELECT FOR UPDATE
  • 单元测试覆盖所有边界条件:
    • 流量刚好用完
    • 流量超出剩余额度
    • 多个加油包同时用完
    • 主套餐和加油包同时用完
  • 代码层面强制约束 data_usage_mb >= 0

3. [迁移] 历史数据兼容性

Risk:

  • 现有 PackageUsage 数据没有 prioritymaster_usage_id 等新字段
  • 现有套餐没有 calendar_typedata_reset_cycle 字段

Mitigation:

  • 数据库迁移脚本为新字段设置默认值:
    • Package.calendar_type 默认 by_day
    • Package.data_reset_cycle 默认 none
    • PackageUsage.priority 默认 1(历史主套餐)
    • PackageUsage.master_usage_id 默认 NULL(历史主套餐)
  • 迁移后运行数据校验脚本,确保历史数据一致性

4. [兼容性] 客户端未实名购买限制

Risk:

  • 现有 H5 端允许未实名客户购买套餐,新限制可能导致用户体验下降

Mitigation:

  • 前端在购买按钮前置提示"请先完成实名认证"
  • API 返回清晰的错误消息:"设备/卡必须先完成实名认证才能购买套餐"
  • 后台管理端不受限制,代理商可为未实名设备囤货

5. [数据一致性] 流量重置可能丢失部分流量记录

Risk:

  • 流量重置时 data_usage_mb=0,如果在重置前有流量增量未记录到日记录表,会导致数据丢失

Mitigation:

  • 流量重置前先触发一次流量检查,确保最新流量已记录
  • 流量重置和流量检查不在同一事务中,避免长事务
  • 监控流量重置任务的执行日志,告警异常情况

Migration Plan

阶段 1: 数据库迁移

  1. 创建迁移脚本:

    make create-migration name=package_system_upgrade
    
  2. 迁移内容:

    • 修改 tb_package 表(新增 3 个字段)
    • 修改 tb_package_usage 表(扩展 status 枚举,新增 7 个字段)
    • 创建 tb_package_usage_daily_record
    • 创建索引priority, master_usage_id, package_usage_id+date
  3. 回滚策略:

    • 删除新增表 tb_package_usage_daily_record
    • 删除新增字段(使用 ALTER TABLE DROP COLUMN
    • 注意:状态枚举扩展无法回滚(已写入的 status=0/4 数据会保留)

阶段 2: 代码实施

  1. Model 层:

    • 扩展 PackagePackageUsage 模型
    • 创建 PackageUsageDailyRecord 模型
  2. Store 层:

    • 扩展 PackageStorePackageUsageStore 查询方法
    • 创建 PackageUsageDailyRecordStore
  3. Service 层:

    • 扩展 package.Service 支持新字段
    • 改造 order.ServiceactivatePackage 函数(主套餐排队、加油包限制)
    • 创建套餐激活 Service首次实名激活、排队激活
  4. Handler 层:

    • 改造 admin.PackageHandler 支持新字段
    • 创建 h5.PackageUsageHandler 提供客户视图 API
  5. 轮询系统:

    • 扩展 HandleCarddataCheck 支持流量扣减优先级和停机条件
    • 创建 HandlePackageActivation 套餐激活检查任务
    • 创建 HandleDataReset 流量重置调度任务
  6. Asynq Handler:

    • 创建 HandlePackageFirstActivation 首次实名激活任务
    • 创建 HandlePackageQueueActivation 主套餐排队激活任务

阶段 3: 测试和验证

  1. 单元测试:

    • 套餐有效期计算逻辑(自然月 vs 按天)
    • 流量扣减优先级逻辑(边界条件)
    • 流量重置时间计算联通27号 vs 其他1号
  2. 集成测试:

    • 首次实名激活流程(囤货 → 实名 → 激活)
    • 主套餐排队流程(购买 → 排队 → 过期 → 激活)
    • 加油包生命周期(购买 → 主套餐过期 → 加油包失效)
    • 流量扣减和停机(加油包优先 → 主套餐 → 停机)
  3. 性能测试:

    • 套餐激活延迟(目标 < 1 分钟)
    • 客户视图 API 性能P95 < 200ms
    • 轮询系统千万级卡规模支持不退化

阶段 4: 部署和回滚策略

  1. 灰度发布:

    • 先在测试环境部署,完整验证所有流程
    • 生产环境先部署代码(特性开关关闭)
    • 执行数据库迁移
    • 开启特性开关,观察日志和监控
  2. 回滚策略:

    • 代码回滚: 关闭特性开关 → 回滚代码
    • 数据库回滚: 执行回滚迁移脚本(注意状态枚举扩展无法回滚)
    • 数据修复: 如有脏数据status=0/4手动修正或保留不影响现有功能
  3. 监控指标:

    • Asynq 任务队列长度和处理延迟
    • 套餐激活延迟(从过期到激活的时间)
    • API 响应时间(客户视图 API P95
    • 流量扣减错误率(日志错误数)

Open Questions

  1. 加油包优先级分配策略是否需要支持手动调整加油包的扣减顺序还是严格按购买顺序priority

    • 当前决策: 严格按 priority购买顺序不支持手动调整
    • 未来扩展: 可考虑在 API 中支持更新 priority需要验证业务必要性
  2. 流量重置时的日记录处理:流量重置后,是否需要在日记录表中新增一条"重置记录"daily_usage_mb=0

    • 当前决策: 不新增重置记录,日记录只记录实际流量增量
    • 原因: 避免日记录表膨胀,重置信息可从 PackageUsage.last_reset_at 获取
  3. 客户端实名校验的异常场景:如果用户在支付成功后、订单完成前完成实名,是否允许购买?

    • 当前决策: 订单创建时检查实名状态,支付完成后不再检查
    • 原因: 简化逻辑,避免状态不一致(支付成功但订单失败)
  4. 套餐激活失败的重试策略:如果 Asynq 任务重试 3 次后仍失败,套餐是否永久停留在"待生效"状态?

    • 当前决策: 是,需要人工介入修复
    • 监控: 告警通知 + 日志记录,运营团队定期检查
  5. 历史套餐的 data_reset_cycle 默认值:现有套餐迁移后 data_reset_cycle=none,如需调整为 monthly,是否需要批量更新?

    • 当前决策: 不批量更新,仅对新套餐生效
    • 原因: 避免影响历史套餐的流量统计(用户预期不重置)