## 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` 为示意,不可直接执行): ```sql -- 查找"无生效套餐但有待激活套餐"的载体 -- 注意:实现时外层表需显式指定别名(如 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` 的处理分两步: 1. **数据清洗**(先执行):`UPDATE tb_package_usage SET activated_at = NULL, expires_at = NULL WHERE status = 0 AND activated_at < '2000-01-01'`(仅清洗零值,不影响已激活记录) 2. **结构变更**(后执行):`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 1. **部署前**:执行数据清洗 SQL(修复 9 条零值记录 → NULL) 2. **部署**:滚动发布(DB 列仍 NOT NULL,旧代码已用 `Omit` 跳过写入,新代码写 NULL 不兼容)→ **必须作为单次停机升级**,先跑迁移再升级服务 3. **迁移顺序**: - Step 1: 执行数据清洗 SQL - Step 2: 执行 ALTER TABLE(DROP NOT NULL) - Step 3: 部署新代码 4. **回滚**:若部署失败,先回滚代码(旧代码写零值,DB 已允许 NULL,业务可继续),再评估是否需回滚 DB 结构(通常不需要) ## Open Questions - 孤儿扫描触发激活后,是否需要记录审计日志?(建议:记录 Info 日志即可,不进审计表) - `ActivateSpecificPackage` 失败时(如套餐配置找不到),是否需要告警?(建议:Error 日志 + 现有 Asynq MaxRetry(3) 已覆盖)