All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入 - 实现套餐流量重置服务(日/月/年周期重置) - 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑 - 新增订单创建幂等性防重(Redis 业务键 + 分布式锁) - 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求 - 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南) - 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录 - 新增 queue types 抽象和 Redis 常量定义
1469 lines
57 KiB
Markdown
1469 lines
57 KiB
Markdown
# 技术设计文档: 套餐系统升级
|
||
|
||
## 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 <token>
|
||
```
|
||
|
||
**响应**:
|
||
```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 <token>
|
||
```
|
||
|
||
**响应**:
|
||
```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`,是否需要批量更新?
|
||
- **当前决策**: 不批量更新,仅对新套餐生效
|
||
- **原因**: 避免影响历史套餐的流量统计(用户预期不重置)
|