**变更说明**: - 删除所有 *_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>
57 KiB
技术设计文档: 套餐系统升级
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
- 支持灵活的套餐类型:自然月套餐(按月边界)、按天套餐(精确天数)
- 支持流量重置周期:日重置、月重置(联通27号/其他1号)、年重置、不重置
- 支持首次实名激活:代理商囤货场景,后台可为未实名设备购买套餐,等待实名时触发激活
- 支持主套餐排队:同时只能有一个生效中主套餐,后续购买自动排队,当前过期后自动激活下一个
- 支持加油包生命周期:加油包依附于主套餐,主套餐过期时级联失效
- 支持流量扣减优先级:优先扣减加油包(按 priority),再扣主套餐;全部用完才停机
- 支持套餐流量详单:按套餐维度记录每日流量增量,支持按日期查询
- 支持客户视图流量查询:区分主套餐和加油包用量,显示总计流量
Non-Goals
- 不支持加油包继承:主套餐过期时,加油包统一失效(status=4),不转移到下一个主套餐
- 不支持加油包跨主套餐共享:加油包只为当前主套餐服务
- 不支持套餐暂停/恢复:套餐生命周期是单向的(待生效 → 生效中 → 已用完/已过期/已失效)
- 不支持套餐流量转移:套餐间流量不可转移或合并
- 不修改现有卡流量详单逻辑:卡维度详单 (
DataUsageRecord) 保持不变 - 不修改订单服务的分佣逻辑:分佣计算逻辑不在本次改造范围内
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_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)
替代方案:
使用:不利于 SQL 查询和索引,违背"优先使用结构化字段"原则JSONB字段存储扩展配置
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-31),NULL=默认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)
替代方案:
复用现有: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:00monthly:- 联通卡(
carrier_id=CUCC): 每月 27 号 00:00:00 - 其他运营商: 每月 1 号 00:00:00
- 联通卡(
yearly: 每年 1 月 1 日 00:00:00none: 不重置(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_type、data_reset_cycle、enable_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
查询逻辑:
- 根据 token 获取
user_id和载体信息(iot_card_id或device_id) - 查询生效中或已用完的套餐(
WHERE status IN (1,2)) - 区分主套餐(
master_usage_id IS NULL)和加油包(master_usage_id IS NOT NULL) - 计算总计流量(主套餐 + 所有加油包)
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
}
}
查询逻辑:
- 验证越权(使用
middleware.CanManageShop或middleware.CanManageEnterprise) - 查询日记录表(
WHERE package_usage_id=? AND date BETWEEN ? AND ?) - 按
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.go 或 internal/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数据没有priority、master_usage_id等新字段 - 现有套餐没有
calendar_type、data_reset_cycle字段
Mitigation:
- 数据库迁移脚本为新字段设置默认值:
Package.calendar_type默认by_dayPackage.data_reset_cycle默认nonePackageUsage.priority默认1(历史主套餐)PackageUsage.master_usage_id默认NULL(历史主套餐)
- 迁移后运行数据校验脚本,确保历史数据一致性
4. [兼容性] 客户端未实名购买限制
Risk:
- 现有 H5 端允许未实名客户购买套餐,新限制可能导致用户体验下降
Mitigation:
- 前端在购买按钮前置提示"请先完成实名认证"
- API 返回清晰的错误消息:"设备/卡必须先完成实名认证才能购买套餐"
- 后台管理端不受限制,代理商可为未实名设备囤货
5. [数据一致性] 流量重置可能丢失部分流量记录
Risk:
- 流量重置时
data_usage_mb=0,如果在重置前有流量增量未记录到日记录表,会导致数据丢失
Mitigation:
- 流量重置前先触发一次流量检查,确保最新流量已记录
- 流量重置和流量检查不在同一事务中,避免长事务
- 监控流量重置任务的执行日志,告警异常情况
Migration Plan
阶段 1: 数据库迁移
-
创建迁移脚本:
make create-migration name=package_system_upgrade -
迁移内容:
- 修改
tb_package表(新增 3 个字段) - 修改
tb_package_usage表(扩展 status 枚举,新增 7 个字段) - 创建
tb_package_usage_daily_record表 - 创建索引(priority, master_usage_id, package_usage_id+date)
- 修改
-
回滚策略:
- 删除新增表
tb_package_usage_daily_record - 删除新增字段(使用
ALTER TABLE DROP COLUMN) - 注意:状态枚举扩展无法回滚(已写入的 status=0/4 数据会保留)
- 删除新增表
阶段 2: 代码实施
-
Model 层:
- 扩展
Package和PackageUsage模型 - 创建
PackageUsageDailyRecord模型
- 扩展
-
Store 层:
- 扩展
PackageStore和PackageUsageStore查询方法 - 创建
PackageUsageDailyRecordStore
- 扩展
-
Service 层:
- 扩展
package.Service支持新字段 - 改造
order.Service的activatePackage函数(主套餐排队、加油包限制) - 创建套餐激活 Service(首次实名激活、排队激活)
- 扩展
-
Handler 层:
- 改造
admin.PackageHandler支持新字段 - 创建
h5.PackageUsageHandler提供客户视图 API
- 改造
-
轮询系统:
- 扩展
HandleCarddataCheck支持流量扣减优先级和停机条件 - 创建
HandlePackageActivation套餐激活检查任务 - 创建
HandleDataReset流量重置调度任务
- 扩展
-
Asynq Handler:
- 创建
HandlePackageFirstActivation首次实名激活任务 - 创建
HandlePackageQueueActivation主套餐排队激活任务
- 创建
阶段 3: 测试和验证
-
单元测试:
- 套餐有效期计算逻辑(自然月 vs 按天)
- 流量扣减优先级逻辑(边界条件)
- 流量重置时间计算(联通27号 vs 其他1号)
-
集成测试:
- 首次实名激活流程(囤货 → 实名 → 激活)
- 主套餐排队流程(购买 → 排队 → 过期 → 激活)
- 加油包生命周期(购买 → 主套餐过期 → 加油包失效)
- 流量扣减和停机(加油包优先 → 主套餐 → 停机)
-
性能测试:
- 套餐激活延迟(目标 < 1 分钟)
- 客户视图 API 性能(P95 < 200ms)
- 轮询系统千万级卡规模支持不退化
阶段 4: 部署和回滚策略
-
灰度发布:
- 先在测试环境部署,完整验证所有流程
- 生产环境先部署代码(特性开关关闭)
- 执行数据库迁移
- 开启特性开关,观察日志和监控
-
回滚策略:
- 代码回滚: 关闭特性开关 → 回滚代码
- 数据库回滚: 执行回滚迁移脚本(注意状态枚举扩展无法回滚)
- 数据修复: 如有脏数据(status=0/4),手动修正或保留(不影响现有功能)
-
监控指标:
- Asynq 任务队列长度和处理延迟
- 套餐激活延迟(从过期到激活的时间)
- API 响应时间(客户视图 API P95)
- 流量扣减错误率(日志错误数)
Open Questions
-
加油包优先级分配策略:是否需要支持手动调整加油包的扣减顺序?还是严格按购买顺序(priority)?
- 当前决策: 严格按 priority(购买顺序),不支持手动调整
- 未来扩展: 可考虑在 API 中支持更新 priority(需要验证业务必要性)
-
流量重置时的日记录处理:流量重置后,是否需要在日记录表中新增一条"重置记录"(daily_usage_mb=0)?
- 当前决策: 不新增重置记录,日记录只记录实际流量增量
- 原因: 避免日记录表膨胀,重置信息可从
PackageUsage.last_reset_at获取
-
客户端实名校验的异常场景:如果用户在支付成功后、订单完成前完成实名,是否允许购买?
- 当前决策: 订单创建时检查实名状态,支付完成后不再检查
- 原因: 简化逻辑,避免状态不一致(支付成功但订单失败)
-
套餐激活失败的重试策略:如果 Asynq 任务重试 3 次后仍失败,套餐是否永久停留在"待生效"状态?
- 当前决策: 是,需要人工介入修复
- 监控: 告警通知 + 日志记录,运营团队定期检查
-
历史套餐的 data_reset_cycle 默认值:现有套餐迁移后
data_reset_cycle=none,如需调整为monthly,是否需要批量更新?- 当前决策: 不批量更新,仅对新套餐生效
- 原因: 避免影响历史套餐的流量统计(用户预期不重置)