## 1. 数据库迁移与历史数据清洗

- [x] 1.1 创建迁移文件，清洗 `tb_package_usage` 中 `status=0` 且 `activated_at < '2000-01-01'` 的零值记录（将 `activated_at` 和 `expires_at` 更新为 NULL）
- [x] 1.2 创建迁移文件，对 `tb_package_usage.activated_at` 执行 `ALTER COLUMN ... DROP NOT NULL`
- [x] 1.3 创建迁移文件，对 `tb_package_usage.expires_at` 执行 `ALTER COLUMN ... DROP NOT NULL`
- [x] 1.4 在本地执行迁移，用 PostgreSQL MCP 验证：查询 `tb_package_usage` 确认列已 nullable、零值记录已清洗（应为 9 条）

## 2. Model 层修改

- [x] 2.1 修改 `internal/model/package.go`：`PackageUsage.ActivatedAt` 改为 `*time.Time`，更新 gorm tag 去除 `not null`
- [x] 2.2 修改 `internal/model/package.go`：`PackageUsage.ExpiresAt` 改为 `*time.Time`，更新 gorm tag 去除 `not null`
- [x] 2.3 修改 `internal/model/personal_customer_device.go`：`LastUsedAt` 改为 `*time.Time`
- [x] 2.4 修改 `internal/model/personal_customer_iccid.go`：`LastUsedAt` 改为 `*time.Time`
- [x] 2.5 修改 `internal/model/personal_customer_phone.go`：`VerifiedAt` 改为 `*time.Time`
- [x] 2.6 执行 `go build ./...`，修复所有因指针类型变更导致的编译错误（nil 判断、解引用、比较等）
- [x] 2.7 检查并修复受影响的 DTO 文件中对 `ActivatedAt/ExpiresAt/LastUsedAt/VerifiedAt` 的引用：`internal/model/dto/package_dto.go`（`PackageUsageItemResponse.ExpiresAt/ActivatedAt` 为 `string` 类型，其映射赋值代码需增加 nil 判断，nil 时输出空字符串或 `"-"`）；`asset_dto.go`、`client_asset_dto.go`、`device_dto.go`、`iot_card_dto.go` 中对应字段已是 `*time.Time`，确认无需改动

## 3. 赋值逻辑修正

- [x] 3.1 修改 `internal/service/order/service.go`（`activateMainPackage` 函数）：待生效路径（status=0）中，将 `activatedAt = time.Time{}` 和 `expiresAt = time.Time{}` 改为不赋值（保持 nil），去除后续对 `usage.ActivatedAt/ExpiresAt` 的条件赋值中对零值的依赖
- [x] 3.2 修改 `internal/task/auto_purchase.go`（`activateMainPackage` 函数）：同上，确保待生效套餐的 `ActivatedAt/ExpiresAt` 在创建时为 nil
- [x] 3.3 检查 `internal/service/package/activation_service.go` 中所有对 `ExpiresAt` 的读取（如 `mainPackage.ExpiresAt` 传给加油包），确认指针解引用安全
- [x] 3.4 修复 `internal/service/package/activation_service.go` 中 `activateNextMainPackage` 函数的 ExpiryBase 处理：当前该函数直接使用 `now` 作为 `activatedAt`，与 `ActivateByRealname` 的 `from_purchase` 逻辑不一致；改为参照 `ActivateByRealname` 的实现，根据 `pkg.ExpiryBase` 判断激活时间基准（`"from_purchase"` 时用 `usage.CreatedAt`，否则用 `time.Now()`）

## 4. 激活服务新增 ActivateSpecificPackage

- [x] 4.1 在 `internal/service/package/activation_service.go` 新增 `ActivateSpecificPackage(ctx context.Context, packageUsageID uint) error` 方法
- [x] 4.2 实现：加载 PackageUsage → 幂等检查（status != Pending 直接返回 nil）→ 获取分布式锁（`RedisPackageActivationLockKey`）→ 加载关联 Package → 根据 `pkg.ExpiryBase` 决定激活时间基准（`"from_purchase"` 时用 `usage.CreatedAt`，否则用 `time.Now()`；与 `ActivateByRealname` 保持一致）→ 计算 activatedAt/expiresAt/nextResetAt → 事务内 Updates → 调用 `syncCarrierStatusActivated` → 异步 `resumeCallback.ResumeCardIfStopped`
- [x] 4.3 确认分布式锁在函数退出时被 `defer` 释放

## 5. 修复 HandlePackageQueueActivation

- [x] 5.1 修改 `internal/polling/package_activation_handler.go` 中的 `HandlePackageQueueActivation`：将调用 `h.activationService.ActivateQueuedPackage(ctx, payload.CarrierType, payload.CarrierID)` 替换为调用 `h.activationService.ActivateSpecificPackage(ctx, payload.PackageUsageID)`
- [x] 5.2 修改 `HandlePackageQueueActivation` 中 `activationService == nil` 的降级分支：**不删除该分支**，改为记录 Error 日志（`"激活服务未注入，无法执行套餐激活"`）并返回错误，防止静默失败（原降级分支直接 Updates 缺少 expires_at 计算，逻辑不完整，改为报错更安全）
- [ ] 5.3 用 PostgreSQL MCP 手动验证：触发一次过期检测（或手动将某套餐 expires_at 设为过去），确认排队套餐被成功激活（status: 0 → 1）

## 6. 新增孤儿套餐恢复扫描

- [x] 6.1 在 `internal/polling/package_activation_handler.go` 新增 `findAndActivateOrphanPackages(ctx context.Context) error` 函数：查询无 status=1 主套餐但有 status=0（且 `pending_realname_activation=false`）主套餐的载体，单次上限 100 个
- [x] 6.2 对每个孤儿载体，查询其 priority 最小的 status=0 主套餐，调用 `h.enqueueActivationTask` 提交激活任务
- [x] 6.3 在 `HandlePackageActivationCheck` 末尾追加调用 `h.findAndActivateOrphanPackages(ctx)`，日志记录触发的孤儿数量
- [ ] 6.4 用 PostgreSQL MCP 验证当前 TEST5G 设备（device_id=1）的 3 条待生效套餐（id=2,3,6）：确认孤儿扫描触发后它们被逐步激活

## 7. 收尾验证

- [x] 7.1 执行 `go build ./...` 确认全量编译零错误
- [x] 7.2 用 PostgreSQL MCP 查询 `tb_package_usage` 验证：无 `activated_at < '2000-01-01'` 的记录
- [x] 7.3 用 PostgreSQL MCP 查询验证：`personal_customer_device.last_used_at`、`personal_customer_iccid.last_used_at`、`tb_personal_customer_phone.verified_at` 中不存在 `< '2000-01-01'` 的值（如有，补一条清洗 SQL）
- [x] 7.4 检查并更新 DTO 中所有展示 `ActivatedAt/ExpiresAt` 的字段，确认 nil 时的 JSON 输出为 `null`（而非 `"0001-01-01T00:00:00Z"`）
