Files
huang 5065d925ad
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
fix: 修正套餐激活和时间字段nullable问题
核心变更:
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>
2026-04-16 11:14:39 +08:00

124 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 TABLEDROP NOT NULL
- Step 3: 部署新代码
4. **回滚**若部署失败先回滚代码旧代码写零值DB 已允许 NULL业务可继续再评估是否需回滚 DB 结构(通常不需要)
## Open Questions
- 孤儿扫描触发激活后,是否需要记录审计日志?(建议:记录 Info 日志即可,不进审计表)
- `ActivateSpecificPackage` 失败时如套餐配置找不到是否需要告警建议Error 日志 + 现有 Asynq MaxRetry(3) 已覆盖)