修复套餐接续停机竞态与轮询兜底
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m13s

This commit is contained in:
2026-08-29 16:27:48 +08:00
parent 62f3d25e81
commit 395e5fb47c
11 changed files with 293 additions and 22 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-29

View File

@@ -0,0 +1,66 @@
## Context
见 proposal.md。当前到期处理在事务提交后投递异步套餐激活任务同时以 goroutine 发起停机检查;两条路径没有共同顺序或共享状态结论。生产中已观察到新套餐激活后 1.9 至 15.7 秒仍写入 `no_package` 停机。
现有 `ActivationService` 已负责待生效主套餐激活及激活后的复机回调;`StopResumeService` 已集中停复机条件。保留这两个职责边界,不增加新的状态表或外部依赖。
## Goals / Non-Goals
**Goals:**
- 同一载体到期接续期间不以过时权益快照停机。
- 没有后续可生效套餐时保留现有停机语义。
- 激活投递或执行暂时失败时宁可交由既有恢复/轮询兜底,不误判为无套餐。
- 给维护者提供基于当前事实的只读候选筛选与受控补偿流程。
**Non-Goals:**
- 不新增自动批量复机、管理接口或数据库迁移。
- 不把 Redis/Asynq 运行排障改造成业务状态机。
- 不改变手动停机、运营商风险停机或实名策略语义。
## Decisions
### 1. 将停机决策后移到套餐激活任务的最终重新评估
到期处理找到后续待生效主套餐时,只投递激活任务,不并发启动停机检查。激活任务完成后以该载体的最新套餐状态调用现有停机评估:成功激活时不触发无套餐停机;队首套餐因实名等业务条件不能激活时,才按当前事实评估停机。
这样复用现有激活锁、套餐条件和停复机服务,不让两个异步流程各自读取一次状态。
备选方案:在到期处理内同步完成套餐激活。未采用,因为到期扫描单轮最多处理大量套餐,同步串行激活会放大调度 Worker 的数据库和 Redis 锁持有时间。
### 2. 激活投递失败不是无套餐结论
仅在明确不存在后续主套餐时,允许到期处理立即发起停机评估。存在后续套餐但投递失败时记录错误并保留当前网络状态,由既有孤儿套餐恢复扫描和套餐轮询重试;不得回退到并发停机。
备选方案:投递失败立即停机。未采用,因为它重新引入“接续结果未知被当作无套餐”的错误路径。
### 3. 存量恢复与代码修复分离
代码发布后,维护者用只读查询筛选“有效主套餐、未耗尽、实名满足、`no_package` 停机”的候选卡;每张或受控小批次先查询运营商实时状态,再调用既有复机能力。补偿不直接更新数据库,也不自动对全部候选执行。
备选方案:发布时自动批量复机全部候选。未采用,因为本地状态不足以代替运营商风险、销户和人工业务判断。
### 4. 轮询只作兜底并单独核验运行覆盖
停机卡配置的 `polling:package` 仍是后备恢复机制,不能作为避免竞态的主路径。维护者分别核验调度 Worker 心跳、Redis 分片队列深度、Asynq 队列积压与消费者吞吐;这些运行指标只用于定位未补偿原因。
### 5. 卡状态轮询仅补齐缺失的套餐任务
生产日志确认存在卡状态和流量任务仍持续执行、但同一卡的 `polling:package` 队列项缺失的情形。卡状态轮询成功且未命中风险停机后,按数据库最新卡状态重新匹配轮询配置;若该卡应参与套餐检查,则以 Redis `ZADD NX` 仅在对应分片 Sorted Set 不存在该卡时立即补入套餐任务。已存在任务不得覆盖其 score也不新增重复任务。
该做法不将卡状态轮询变为停复机决策路径:它只修复缺失的套餐调度项,最终仍由既有 `PollingPackageHandler → EvaluateAndAct` 判断权益、实名、停机原因和运营商结果。
## Risks / Trade-offs
- [激活任务投递失败时卡暂不因套餐到期自动停机] → 保留孤儿恢复扫描和套餐轮询;记录失败并在运行排障中核验任务恢复。
- [激活任务内增加最终停机评估] → 保持幂等条件和现有 Redis 锁;同一载体状态变化必须重读数据库。
- [存量候选包含运营商侧不可复机卡] → 补偿前逐卡或受控小批查询运营商状态,拒绝风险停机、销户或其他非本地套餐原因。
- [轮询吞吐不足延迟兜底] → 发布前后分别记录队列深度、消费速率和最近检查时间分布,不以单次数据库字段判断 Worker 健康。
- [卡状态轮询补齐套餐任务] → 仅使用 `ZADD NX` 补入缺失成员,不改写已有任务的执行时间;最终复机仍受既有权益、实名、停机原因和运营商校验约束。
## Migration Plan
1. 在隔离环境验证“旧套餐到期 + 后续套餐可激活”不会产生停机调用,及“无后续/不可激活”仍进入停机评估。
2. 按生产运行说明发布 Worker不需要数据库迁移。
3. 维护者观察一轮套餐到期处理、激活任务与轮询队列,确认没有新的“有效套餐 + `no_package` 停机”记录。
4. 使用只读筛选得到存量候选,按受控批次核验运营商状态后调用既有复机;失败项保留失败原因并人工处理。
5. 若发布后出现意外停机语义,回滚 Worker 二进制;存量补偿不得直接写库。

View File

@@ -0,0 +1,28 @@
## Why
生产诊断发现套餐接续期间会并发投递下一套餐激活任务和无套餐停机检查。停机检查可在新套餐生效后才完成并回写停机,造成卡已具备有效套餐、实名和未耗尽流量却仍以 `no_package` 停机;当前已检出 117 张此类卡,且 13 张在新套餐生效后 120 秒内被停机。
## What Changes
- 将主套餐到期后的“接续下一套餐”和“无有效套餐停机”收敛为同一条按载体串行的决策流程,禁止待生效套餐接续期间以旧快照发起停机。
- 只有在确认不存在可生效的后续主套餐,或后续套餐经业务校验确定不能生效后,才执行无套餐停机检查。
- 套餐接续完成后重新依据当前卡与套餐事实判断停复机,确保有效套餐卡不遗留 `no_package` 停机状态。
- 为既有异常卡提供只读筛选条件和受控人工补偿步骤;补偿前必须重新核验套餐、流量、实名和运营商实时状态,不引入自动批量复机。
- 排查并记录轮询调度、入队与消费覆盖情况;当卡状态轮询仍正常但 `polling:package` 队列项缺失时,仅补入缺失的套餐任务,确保套餐轮询仍可作为即时接续失败后的兜底,不将 Redis/Asynq 运行状态误判为业务终态。
## Capabilities
### New Capabilities
- 无。
### Modified Capabilities
- `package-lifecycle`: 主套餐到期、后续套餐接续及无套餐停机必须基于同一载体的最新权益事实,避免接续成功后错误停机。
## Impact
- 受影响代码:`internal/polling/package_activation_handler.go``internal/polling/queue_manager.go``internal/service/package/activation_service.go``internal/service/iot_card/stop_resume_service.go` 及相关轮询任务装配。
- 受影响运行单元:调度 Worker、Asynq 套餐激活任务、套餐轮询任务。
- 不新增 HTTP API、数据库 Schema 或第三方依赖。
- 生产补偿仅由维护者执行受控外部复机Agent 仅提供只读诊断与候选清单。

View File

@@ -0,0 +1,35 @@
## MODIFIED Requirements
### Requirement: 套餐状态流转
系统 SHALL 按当前套餐和套餐使用状态控制上架、订购、激活、失效与到期处理。主套餐到期时,系统 MUST 先确定同一载体是否存在待生效的后续主套餐:存在时,后续套餐激活与停复机重新评估 MUST 由同一条顺序流程完成;系统 MUST NOT 依据后续套餐激活前的无套餐快照发起停机。后续套餐成功生效后,系统 MUST 依据最新套餐、流量和实名事实重新判断卡网络状态,且不得遗留 `no_package` 停机。不存在后续套餐或后续套餐经业务校验不能生效时,系统 SHALL 按现有停机规则评估卡状态。后续套餐激活结果未知或任务投递失败不得被当作无后续套餐处理并据此停机,系统 SHALL 保留既有激活恢复与轮询兜底路径。
#### Scenario: 套餐状态流转
- **GIVEN** 套餐或使用记录处于允许的前置状态
- **WHEN** 执行状态操作
- **THEN** 仅发生一次允许的状态变化;不满足前置状态时返回业务错误
#### Scenario: 到期主套餐接续后续套餐
- **GIVEN** 某载体的当前主套餐到期,且存在满足激活条件的待生效后续主套餐
- **WHEN** 系统处理该主套餐到期
- **THEN** 系统先完成后续套餐激活并按最新权益事实重新评估停复机,且不得因到期前的无套餐快照对该载体发起 `no_package` 停机
#### Scenario: 到期主套餐无后续可生效套餐
- **GIVEN** 某载体的当前主套餐到期,且不存在后续主套餐或队首后续主套餐不满足激活条件
- **WHEN** 系统完成该套餐到期处理
- **THEN** 系统按当前套餐、流量和实名事实执行既有停机评估
#### Scenario: 后续套餐激活结果未知
- **GIVEN** 某载体的当前主套餐到期,存在待生效后续主套餐,但激活任务投递或执行结果暂时未知
- **WHEN** 系统处理该套餐到期
- **THEN** 系统不得将该未知结果视为不存在后续套餐而依据旧快照发起停机,并保留既有激活恢复与套餐轮询兜底
#### Scenario: 卡状态轮询发现缺失的套餐任务
- **GIVEN** 启用轮询的卡匹配套餐检查配置,且其 `polling:package` 分片队列项因异常缺失
- **WHEN** 卡状态轮询成功完成且未命中风险停机
- **THEN** 系统基于最新卡状态仅补入缺失的套餐任务,不改写已存在套餐任务的执行时间;后续套餐任务仍按既有停复机条件评估该卡

View File

@@ -0,0 +1,21 @@
## 1. 到期接续顺序
- [x] 1.1 调整主套餐到期处理:明确区分“无后续主套餐”“已提交后续激活”和“后续激活投递失败”,仅无后续主套餐可立即进入停机评估。
- [x] 1.2 调整套餐激活任务:在后续套餐激活完成或确认暂不能激活后,重读载体当前权益并调用既有停机评估;移除与激活任务并发的停机 goroutine。
- [x] 1.3 保持激活后的既有复机回调、Redis 锁、审计与 Integration Log 语义,不新增状态表、接口或自动批量复机。
## 2. 运行兜底与可观测性
- [x] 2.1 核对停机卡匹配的 `polling:package` 配置及生命周期回调入队路径,修正本次改动涉及的入队或重排遗漏,确保其仅作为接续异常后的兜底。
- [x] 2.2 为激活任务的最终停机评估保留可按载体、套餐使用记录和结果关联的中文结构化日志;不得记录敏感外部载荷。
## 3. 验证
- [ ] 3.1 在隔离环境人工演练“旧套餐到期且后续套餐可激活”“无后续套餐”“后续套餐暂不能激活”“激活投递失败”四种路径,核对停复机调用与套餐最终状态。
- [x] 3.2 执行 `gofmt -w <changed-go-files>``go build ./cmd/api ./cmd/worker``openspec validate fix-package-expiry-stop-race --strict`
## 4. 生产受控恢复
- [x] 4.1 维护者通过只读查询重新筛选“有效主套餐、流量未耗尽、实名满足且 `no_package` 停机”的候选卡,并记录筛选时点与数量。
- [ ] 4.2 维护者按受控小批次查询运营商实时状态,仅对可复机卡调用既有复机能力;不得直接更新卡网络状态或停机原因。
- [ ] 4.3 维护者核验调度 Worker 心跳、Redis 分片队列、Asynq 队列和消费者吞吐,并在发布后观察是否仍产生“有效套餐 + `no_package` 停机”记录。