核心变更: 1. Model层时间字段改为*time.Time并设为nullable - PackageUsage.ActivatedAt/ExpiresAt - PersonalCustomerDevice/ICCID/Phone.LastUsedAt/VerifiedAt 2. 数据库迁移: - activated_at/expires_at列移除NOT NULL约束 - 清洗零值记录(status=0且activated_at<'2000-01-01') 3. 新增ActivateSpecificPackage方法:精准激活指定套餐, 修复HandlePackageQueueActivation从"查找过期包"改为直接激活payload指定套餐 4. 新增孤儿套餐恢复扫描:Worker启动或每次套餐检查时, 自动发现并恢复无status=1主套餐的孤儿载体 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.2 KiB
Context
当前系统存在两类根本性缺陷,且已在生产数据中造成可见损坏。
时间零值问题根因:GORM 对非指针 time.Time 字段的处理规则是"任何值(包括零值)都写入数据库",而 *time.Time 为 nil 时写 NULL。PackageUsage.ActivatedAt/ExpiresAt 定义为 time.Time(NOT NULL),当套餐处于待生效状态(status=0)时,代码主动赋值 time.Time{} 规避 GORM 的零值跳过逻辑,导致 0001-01-01 00:00:00 UTC 被持久化,在中国历史时区下显示为 0001-01-01 00:00:43。其余三个字段(LastUsedAt × 2、VerifiedAt)的 DB 列已是 nullable,但 Go 类型仍是非指针,同样会写零值。
激活逻辑根因:系统有两套激活路径但职责边界混乱:
HandlePackageActivationCheck(轮询,每 10 秒):负责检测过期、提交激活任务HandlePackageQueueActivation(Asynq 任务):负责执行具体激活
问题在于 HandlePackageQueueActivation 调用了 ActivateQueuedPackage(carrierType, carrierID),该函数的设计意图是"扫描过期包→标记过期→激活下一个"的完整循环,但轮询处理器已经完成了"标记过期"步骤。Asynq 任务运行时,过期触发条件已消失,函数空转返回,payload 中携带的目标 PackageUsageID 被完全浪费。
另外,系统无任何机制感知"载体无生效套餐但有待生效套餐"的孤儿状态,重启后 Asynq 任务丢失或 MaxRetry 耗尽后状态永久卡死。
Goals / Non-Goals
Goals:
- 彻底消除时间零值污染:5 个字段全部改为
*time.Time,DB 列对齐,历史数据清洗 - 修复激活链断链:Asynq 任务直接激活 payload 指定的目标套餐,不再重跑发现流程
- 建立孤儿套餐自愈机制:10 秒周期内兜底检测无主套餐但有待激活套餐的载体,覆盖重启恢复和任务丢失两种场景
- 不影响已生效(status=1)和已过期(status=3)套餐的任何行为
Non-Goals:
- 不重构 Asynq 任务队列架构
- 不修改套餐购买、定价、佣金等其他流程
- 不处理
pending_realname_activation=true的套餐(实名激活路径单独维护,无此问题) - 不引入新的外部依赖
Decisions
决策 1:ActivateSpecificPackage 独立方法,而非修改 ActivateQueuedPackage
选择:在 ActivationService 新增 ActivateSpecificPackage(ctx, packageUsageID uint) 方法,HandlePackageQueueActivation 改为调用该方法。
理由:ActivateQueuedPackage 是"发现+执行"的组合操作,修改它会影响所有调用方;新方法职责单一——"激活一个已知 ID 的套餐"——符合最小改动原则,不破坏其他调用路径。
ActivateSpecificPackage 逻辑:
1. 加载 PackageUsage(根据 ID)
2. 幂等检查:status != Pending → 直接返回
3. 加载关联 Package(获取 CalendarType, DurationMonths/Days, DataResetCycle)
4. activatedAt = now,计算 expiresAt 和 nextResetAt
5. 事务内更新:status=1, activated_at, expires_at, [next_reset_at]
6. 调用 syncCarrierStatusActivated
7. 异步调用 resumeCallback.ResumeCardIfStopped(如已注入)
备选方案:直接修改 ActivateQueuedPackage 接受 packageUsageID 参数 → 拒绝,会改变该函数对其他调用方的契约。
决策 2:孤儿扫描合并到 HandlePackageActivationCheck 现有周期
选择:在 HandlePackageActivationCheck 末尾追加孤儿扫描逻辑(独立函数 findAndActivateOrphanPackages),与现有过期检测复用同一 10 秒 ticker。
孤儿定义(伪代码 SQL,仅描述查询语义;实现时需使用相关子查询将外层表别名化,outer 为示意,不可直接执行):
-- 查找"无生效套餐但有待激活套餐"的载体
-- 注意:实现时外层表需显式指定别名(如 p1),子查询中通过 p1.iot_card_id 关联
SELECT DISTINCT carrier_type, carrier_id
FROM tb_package_usage AS p1
WHERE p1.status = 0
AND p1.master_usage_id IS NULL
AND p1.pending_realname_activation = false
AND p1.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM tb_package_usage p2
WHERE p2.status = 1
AND p2.master_usage_id IS NULL
AND p2.deleted_at IS NULL
AND (
(p1.iot_card_id > 0 AND p2.iot_card_id = p1.iot_card_id)
OR (p1.device_id > 0 AND p2.device_id = p1.device_id)
)
)
理由:孤儿状态与过期状态在时间维度上相关,合并到同一检查周期减少代码分散;10 秒间隔满足"重启后 10 秒内自动恢复"的 SLA。
备选方案:独立 goroutine 或独立 Asynq Scheduler 任务 → 拒绝,增加组件复杂度,10 秒已足够。
决策 3:*time.Time 指针方案,不使用 sql.NullTime
选择:所有受影响字段改为 *time.Time。
理由:项目已有大量 *time.Time 用法(Order.PaidAt、Device.ActivatedAt 等),风格一致;sql.NullTime 引入 Valid 字段读写噪音,不符合项目现有模式。
决策 4:数据迁移策略
tb_package_usage.activated_at/expires_at 的处理分两步:
- 数据清洗(先执行):
UPDATE tb_package_usage SET activated_at = NULL, expires_at = NULL WHERE status = 0 AND activated_at < '2000-01-01'(仅清洗零值,不影响已激活记录) - 结构变更(后执行):
ALTER TABLE tb_package_usage ALTER COLUMN activated_at DROP NOT NULL
顺序原因:先清洗确保 NOT NULL 约束变更后不遗留脏数据;两步均可独立回滚。
Risks / Trade-offs
[风险] 孤儿扫描的 N+1 性能问题 → 缓解:限制单次扫描最多处理 100 个孤儿载体;孤儿状态在正常业务中极少发生(仅重启恢复和任务失败时),不会成为热路径。
[风险] ActivateSpecificPackage 并发激活同一套餐 → 缓解:复用已有 RedisPackageActivationLockKey(carrierType, carrierID) 分布式锁 + 幂等检查(status != Pending 直接返回)。
[风险] *time.Time 改动引发下游编译错误 → 缓解:修改后运行 go build ./... 全量编译,统一修复所有 nil 判断。
[权衡] 历史零值数据一旦清为 NULL,无法区分"未激活"和"数据损坏" → 可接受:所有 status=0 且 activated_at 为零值的记录语义上就是"未激活",NULL 是正确表达。
Migration Plan
- 部署前:执行数据清洗 SQL(修复 9 条零值记录 → NULL)
- 部署:滚动发布(DB 列仍 NOT NULL,旧代码已用
Omit跳过写入,新代码写 NULL 不兼容)→ 必须作为单次停机升级,先跑迁移再升级服务 - 迁移顺序:
- Step 1: 执行数据清洗 SQL
- Step 2: 执行 ALTER TABLE(DROP NOT NULL)
- Step 3: 部署新代码
- 回滚:若部署失败,先回滚代码(旧代码写零值,DB 已允许 NULL,业务可继续),再评估是否需回滚 DB 结构(通常不需要)
Open Questions
- 孤儿扫描触发激活后,是否需要记录审计日志?(建议:记录 Info 日志即可,不进审计表)
ActivateSpecificPackage失败时(如套餐配置找不到),是否需要告警?(建议:Error 日志 + 现有 Asynq MaxRetry(3) 已覆盖)