# 技术设计文档: 套餐系统升级 ## 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 个字段 ```go 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_type` 和 `data_reset_cycle` 是两个独立维度(套餐类型 vs 流量重置周期) - `calendar_type=natural_month` 时必须提供 `duration_months`,`by_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 个字段 ```go 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` 表新增幂等字段 ```go 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=已处理 **使用方式**: ```sql -- 首次实名触发时 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` 表新增计费日配置字段 ```go type Carrier struct { // ... 现有字段 ... // 新增字段 BillingDay *int `gorm:"column:billing_day;comment:计费日(1-31),NULL=默认1号,27=联通" json:"billing_day"` } ``` **决策理由**: - 可配置化,无需硬编码联通27号规则 - 支持运营商策略变更 - 便于新运营商接入 - 便于测试(可模拟不同计费日) **数据初始化**: ```sql UPDATE tb_carrier SET billing_day = 27 WHERE name = '中国联通'; UPDATE tb_carrier SET billing_day = 1 WHERE name IN ('中国移动', '中国电信'); ``` #### 1.5 新增卡日流量详单表 **方案**: 创建卡维度日流量统计表 ```go 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 表 **方案**: 创建套餐流量日记录表 ```go 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) ``` **替代方案**: - ~~复用现有 `DataUsageRecord` 表~~:`DataUsageRecord` 按卡维度记录,无法区分主套餐和加油包,需要独立表 --- ### 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_type` 和 `duration_*` 计算 - `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** ```http 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 } ``` **响应**: ```json { "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_type`、`data_reset_cycle`、`enable_realname_activation` - `package_code` 不可修改 --- #### 4.2 客户视图流量查询 API(新增) ```http GET /api/h5/packages/my-usage Authorization: Bearer ``` **响应**: ```json { "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_id` 或 `device_id`) 2. 查询生效中或已用完的套餐(`WHERE status IN (1,2)`) 3. 区分主套餐(`master_usage_id IS NULL`)和加油包(`master_usage_id IS NOT NULL`) 4. 计算总计流量(主套餐 + 所有加油包) --- #### 4.3 套餐流量详单 API(新增) ```http GET /api/admin/package-usage/:id/daily-records?start_date=2026-02-01&end_date=2026-02-10 Authorization: Bearer ``` **响应**: ```json { "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.CanManageShop` 或 `middleware.CanManageEnterprise`) 2. 查询日记录表(`WHERE package_usage_id=? AND date BETWEEN ? AND ?`) 3. 按 `date ASC` 排序 --- ### 5. 常量管理 在 `pkg/constants/constants.go` 新增以下常量: ```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 层 ```go // 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 层 ```go // 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 层 ```go // 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 主套餐排队激活事务 ```go 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 加油包级联失效事务 ```go 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号重置) **修改方案**: ```go // 旧代码(需删除或重构) 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.go` 或 `internal/service/iot_card/traffic_deduction.go` **问题描述**: - 当前按套餐激活顺序扣减 - 不支持"加油包优先"规则 - 未区分主套餐和加油包 **修改方案**: ```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` **问题描述**: - 当前轮询仅处理卡状态同步 - 不处理待激活套餐队列 - 不处理主套餐过期检测 **修改方案**: ```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`(新建或扩展) **核心逻辑**: ```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` 或类似文件 **问题描述**: - 旧逻辑:单一套餐流量用完即停机 - 新逻辑:主套餐 + 所有加油包流量全部用完才停机 **修改方案**: ```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` 数据没有 `priority`、`master_usage_id` 等新字段 - 现有套餐没有 `calendar_type`、`data_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. **创建迁移脚本**: ```bash 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 层**: - 扩展 `Package` 和 `PackageUsage` 模型 - 创建 `PackageUsageDailyRecord` 模型 2. **Store 层**: - 扩展 `PackageStore` 和 `PackageUsageStore` 查询方法 - 创建 `PackageUsageDailyRecordStore` 3. **Service 层**: - 扩展 `package.Service` 支持新字段 - 改造 `order.Service` 的 `activatePackage` 函数(主套餐排队、加油包限制) - 创建套餐激活 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`,是否需要批量更新? - **当前决策**: 不批量更新,仅对新套餐生效 - **原因**: 避免影响历史套餐的流量统计(用户预期不重置)