## Context 线上只读数据得到旧孤儿扫描窗口 `100/100/0`:固定取出的 100 条待生效记录全部仍有 `status IN (1,2)` 占位主套餐,而真正无占位套餐的记录位于窗口之外。当前实现先 `LIMIT 100`,再逐条查询占位状态,因此同一批无效候选会永久挡住真实孤儿。 过期路径还在数据库事务内调用 `enqueueActivationTask`。Asynq 消费者可能早于事务提交读取旧主套餐,随后 `ActivateSpecificPackage` 判断“已有生效主套餐”并返回 `nil`;Handler 继续记录“套餐激活成功”,任务不再重试。Redis 锁冲突也返回 `nil`,存在另一条假成功路径。 本热修保持当前线上 `Polling Handler → GORM transaction → Asynq → Package Activation Service` 架构,不引入新的可靠投递设施。 ## Goals / Non-Goals **Goals:** - 每轮 100 个恢复名额只用于真实孤儿载体,同一载体只选择队首套餐。 - 旧套餐过期事实提交后才投递 Asynq,消除事务可见性竞态。 - Redis 锁冲突返回错误,由现有 Asynq 重试。 - 成功日志只对应实际激活或明确的已完成幂等事实。 **Non-Goals:** - 不新增 Outbox、迁移、索引、依赖、队列或任务类型。 - 不重构套餐购买、实名激活、流量扣减、退款和停复机。 - 不修改 API、DTO、路由或前端。 - 不批量修复历史数据。 - 按用户要求,不新增、修改或运行自动化测试。 ## Decisions ### 决策 1:数据库先筛选真实孤儿队首,再执行 LIMIT 使用 GORM `Raw` 执行 PostgreSQL CTE/窗口查询: 1. 从有效 `status=0` 主套餐按卡/设备载体分组。 2. 每组按 `priority ASC, created_at ASC, id ASC` 选 `ROW_NUMBER()=1`。 3. 使用相关 `NOT EXISTS` 排除同载体 `status IN (1,2)` 主套餐。 4. 稳定排序后 `LIMIT 100`。 删除现有逐条 `Count` 和 Go map 分组。查询仍返回完整 `PackageUsage`,沿用现有投递循环。 **拒绝:扩大 LIMIT。** 只会推迟复现并增加 N+1。 **拒绝:分页遍历所有 pending。** 需要游标状态,复杂度高于一次正确查询。 ### 决策 2:过期事务提交后再投递现有 Asynq `processExpiredPackage` 事务只更新旧主套餐和关联加油包。提交成功后,使用普通数据库句柄调用现有 `activateNextPackage` 查询队首并入队。 提交后入队失败时返回错误并记录上下文;同一轮末尾及后续轮询的真实孤儿扫描会再次发现该载体,提供持久状态驱动的补偿。 **拒绝:任务增加固定延迟。** 固定延迟不能证明事务已提交。 **拒绝:引入 Outbox。** 当前线上没有该基础设施,热修不扩张架构。 ### 决策 3:锁冲突必须触发 Asynq 重试 `ActivateSpecificPackage` 未取得 `RedisPackageActivationLockKey` 时返回现有 `CodePackageActivationConflict`。Handler 原样返回错误,由任务已有 `MaxRetry(3)` 处理。 Redis Key 继续使用 `pkg/constants/redis.go` 的生成函数,不新增硬编码 Key。 ### 决策 4:显式返回本次是否激活 `ActivateSpecificPackage` 返回 `(bool, error)`: - `true,nil`:本次把待生效套餐推进为生效中; - `false,nil`:记录已非待生效、存在占位套餐或条件暂不满足; - `false,error`:数据库、Redis 或锁冲突,应由任务重试或记录失败。 Handler 仅在 `true,nil` 时记录“套餐激活成功”。Handler 已在调用前识别 `status=1` 的重复任务并记录幂等跳过。 ### 决策 5:依赖注入和事务边界保持不变 `PackageActivationHandler` 继续通过结构体字段持有 `*gorm.DB`、`*redis.Client`、`*asynq.Client`、`*ActivationService` 和 Zap Logger;不新增单实现接口或工厂。套餐激活仍由 Service 自己开启 GORM 事务,Handler 不直接更新新套餐状态。 ### 决策 6:公共能力与验证 - Audit Event:N/A,系统自动生命周期推进。 - Domain Ledger:N/A,`tb_package_usage` 是权威事实。 - Integration Log:N/A,无新增外部调用。 - Outbox:N/A,保持当前 Asynq + 周期自愈。 按用户要求不写或运行自动化测试。验证使用 `gofmt`、`git diff --check`、`go build ./...`、只读 SQL、查询计划和日志检查。 ## Risks / Trade-offs - **[风险] CTE 扫描大量 pending** → 用 `EXPLAIN (ANALYZE, BUFFERS)` 验证;无证据不新增索引。 - **[风险] 提交后、入队前进程退出** → 下一轮真实孤儿扫描恢复,最长增加一个轮询周期。 - **[风险] 多实例重复入队** → 载体 Redis 锁和套餐状态幂等保证只实际激活一次。 - **[风险] 方法签名变化遗漏调用点** → 使用 `rg` 检查全部调用方并以全量构建证明编译契约。 - **[权衡] Asynq 最终失败后仍依赖轮询重新入队** → 这是当前线上架构的既有补偿边界,本热修不扩建基础设施。 - **[权衡] 不新增自动化测试** → 遵循用户边界,以构建、SQL 和日志证据替代。 ## Migration Plan 1. 实施真实孤儿查询、提交后入队、锁冲突重试和准确日志。 2. 执行格式化、静态检查、`go build ./...` 和只读 SQL语义检查。 3. 形成独立中文 Lore 热修提交。 4. 部署 Worker,观察至少两个轮询周期内真实孤儿收敛和任务日志。 ### 回滚 - 无数据库迁移,revert 热修提交并重新部署 Worker。 - 已正确激活的套餐保持业务事实,不执行反向 SQL。 - 回滚后新增孤儿继续使用带状态保护的单卡 SQL逐条恢复。 ## Open Questions 无。