归档: add-polling-card-status-with-verbose-log,同步主规范
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m24s

change 目录归档至 openspec/changes/archive/2026-04-15-add-polling-card-status-with-verbose-log/
新建主规范 openspec/specs/polling-card-status-task/spec.md(6个需求)
追加 verbose log 需求至 openspec/specs/polling-task-handlers/spec.md(4个新需求)
protect handler 规范更新字段名:network_status → network_status_at_check + action_taken

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-15 12:23:05 +08:00
parent 854330a4b4
commit 8bf1282378
8 changed files with 188 additions and 31 deletions

View File

@@ -1,57 +1,57 @@
## 1. 数据库迁移
- [ ] 1.1 创建迁移文件 `000120_add_polling_card_status.up.sql``tb_polling_config` 新增 `card_status_check_interval INT NULL``tb_iot_card` 新增 `last_card_status_check_at TIMESTAMPTZ NULL`
- [ ] 1.2 创建对应回滚文件 `000120_add_polling_card_status.down.sql`DROP COLUMN 两列
- [ ] 1.3 执行 `make migrate-up` 并验证:`make migrate-version` 确认 dirty=false使用 PostgreSQL MCP 验证两列已创建
- [x] 1.1 创建迁移文件 `000120_add_polling_card_status.up.sql``tb_polling_config` 新增 `card_status_check_interval INT NULL``tb_iot_card` 新增 `last_card_status_check_at TIMESTAMPTZ NULL`
- [x] 1.2 创建对应回滚文件 `000120_add_polling_card_status.down.sql`DROP COLUMN 两列
- [x] 1.3 执行 `make migrate-up` 并验证:`make migrate-version` 确认 dirty=false使用 PostgreSQL MCP 验证两列已创建
## 2. 常量与配置
- [ ] 2.1 `pkg/constants/constants.go`:轮询任务类型常量块新增 `TaskTypePollingCardStatus = "polling:card_status"`
- [ ] 2.2 `pkg/config/config.go`:新增 `PollingConfig struct { VerboseLog bool }`,在 `Config` 中加入 `Polling PollingConfig` 字段
- [ ] 2.3 `pkg/config/defaults/config.yaml`:在 polling 配置区新增 `verbose_log: false`
- [ ] 2.4 验证:`LspDiagnostics` 检查 `pkg/constants/constants.go``pkg/config/config.go` 无报错
- [x] 2.1 `pkg/constants/constants.go`:轮询任务类型常量块新增 `TaskTypePollingCardStatus = "polling:card_status"`
- [x] 2.2 `pkg/config/config.go`:新增 `PollingConfig struct { VerboseLog bool }`,在 `Config` 中加入 `Polling PollingConfig` 字段
- [x] 2.3 `pkg/config/defaults/config.yaml`:在 polling 配置区新增 `verbose_log: false`
- [x] 2.4 验证:`LspDiagnostics` 检查 `pkg/constants/constants.go``pkg/config/config.go` 无报错
## 3. 数据模型更新
- [ ] 3.1 `internal/model/polling.go``PollingConfig` 结构体新增 `CardStatusCheckInterval *int`GORM column: `card_status_check_interval`
- [ ] 3.2 `internal/model/iot_card.go``IotCard` 结构体新增 `LastCardStatusCheckAt *time.Time`GORM column: `last_card_status_check_at`
- [ ] 3.3 验证:`LspDiagnostics` 检查两个 model 文件无报错
- [x] 3.1 `internal/model/polling.go``PollingConfig` 结构体新增 `CardStatusCheckInterval *int`GORM column: `card_status_check_interval`
- [x] 3.2 `internal/model/iot_card.go``IotCard` 结构体新增 `LastCardStatusCheckAt *time.Time`GORM column: `last_card_status_check_at`
- [x] 3.3 验证:`LspDiagnostics` 检查两个 model 文件无报错
## 4. PollingBase 新增 verboseLog 支持
- [ ] 4.1 `internal/task/polling_base.go``PollingBase` 结构体新增 `verboseLog bool` 字段;`NewPollingBase` 函数签名新增 `verboseLog bool` 参数并赋值
- [ ] 4.2 `cmd/worker/main.go``task.NewPollingBase(...)` 调用新增 `cfg.Polling.VerboseLog` 参数
- [ ] 4.3 验证:`LspDiagnostics` 检查 `polling_base.go``main.go` 无报错
- [x] 4.1 `internal/task/polling_base.go``PollingBase` 结构体新增 `verboseLog bool` 字段;`NewPollingBase` 函数签名新增 `verboseLog bool` 参数并赋值
- [x] 4.2 `cmd/worker/main.go``task.NewPollingBase(...)` 调用新增 `cfg.Polling.VerboseLog` 参数
- [x] 4.3 验证:`LspDiagnostics` 检查 `polling_base.go``main.go` 无报错
## 5. 现有 Handler 添加 verbose 日志
- [ ] 5.1 `internal/task/polling_realname_handler.go`:在 `QueryRealnameStatus` 成功返回后,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、real_status、new_status、changed
- [ ] 5.2 `internal/task/polling_carddata_handler.go`:在 `QueryFlow` 成功返回后,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、gateway_flow_mb、increment_mb、is_cross_month
- [ ] 5.3 `internal/task/polling_package_handler.go`:在 `freshCard` 加载成功后、调用 `EvaluateAndAct` 之前,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、network_status、stop_reason
- [ ] 5.4 `internal/task/polling_protect_handler.go`:在 switch 块执行完毕、`UpdateFields(last_protect_check_at)` 之前,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、stop_protect、start_protect、network_status
- [ ] 5.5 验证:`LspDiagnostics` 检查上述 4 个 handler 文件无报错
- [x] 5.1 `internal/task/polling_realname_handler.go`:在 `QueryRealnameStatus` 成功返回后,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、real_status、new_status、changed
- [x] 5.2 `internal/task/polling_carddata_handler.go`:在 `QueryFlow` 成功返回后,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、gateway_flow_mb、increment_mb、is_cross_month
- [x] 5.3 `internal/task/polling_package_handler.go`:在 `freshCard` 加载成功后、调用 `EvaluateAndAct` 之前,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、network_status、stop_reason
- [x] 5.4 `internal/task/polling_protect_handler.go`:在 switch 块执行完毕、`UpdateFields(last_protect_check_at)` 之前,判断 `h.base.verboseLog` 为 true 时输出 Info 日志card_id、iccid、stop_protect、start_protect、network_status
- [x] 5.5 验证:`LspDiagnostics` 检查上述 4 个 handler 文件无报错
## 6. 卡状态轮询 Handler 实现
- [ ] 6.1 新建 `internal/task/polling_cardstatus_handler.go`:实现 `PollingCardStatusHandler`字段base、gateway、iotCardStore、stopResumeSvc完整实现 `Handle` 方法
- [ ] 6.2 `Handle` 方法逻辑:获取并发信号量 → 获取卡信息(带缓存)→ 调用 `gateway.QueryCardStatus` → 映射状态("停机"→0其他→1→ verbose log 判断 → 比较状态是否变化 → 写 DB`last_card_status_check_at` + 可选 `network_status`)→ 状态变化时更新缓存并调用 `EvaluateAndAct` → 更新统计 → 重入队
- [ ] 6.3 验证:`LspDiagnostics` 检查新文件无报错
- [x] 6.1 新建 `internal/task/polling_cardstatus_handler.go`:实现 `PollingCardStatusHandler`字段base、gateway、iotCardStore、stopResumeSvc完整实现 `Handle` 方法
- [x] 6.2 `Handle` 方法逻辑:获取并发信号量 → 获取卡信息(带缓存)→ 调用 `gateway.QueryCardStatus` → 映射状态("停机"→0其他→1→ verbose log 判断 → 比较状态是否变化 → 写 DB`last_card_status_check_at` + 可选 `network_status`)→ 状态变化时更新缓存并调用 `EvaluateAndAct` → 更新统计 → 重入队
- [x] 6.3 验证:`LspDiagnostics` 检查新文件无报错
## 7. 轮询系统基础设施集成
- [ ] 7.1 `internal/task/polling_utils.go``getIntervalByTaskType` 新增 `case constants.TaskTypePollingCardStatus`,返回 `cfg.CardStatusCheckInterval`
- [ ] 7.2 `internal/polling/queue_manager.go``allTaskTypes` slice 追加 `constants.TaskTypePollingCardStatus`
- [ ] 7.3 `internal/polling/lifecycle_service.go``getEnabledTaskTypes` 新增 card_status 条件(`CardStatusCheckInterval != nil && *CardStatusCheckInterval > 0``calcInitialDelay` 新增对应 case
- [ ] 7.4 `internal/polling/initializer.go`:在 `initBatch()` 的 if-block 链中新增一段,当 `cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0` 时,以 `card.LastCardStatusCheckAt`(新增字段)为基准调用 `calculateNextCheckTime`,向 `TaskTypePollingCardStatus` 分片队列写入 ZAdd 条目;位置紧跟 ProtectCheckInterval 块之后,格式与其他 4 种类型一致
- [ ] 7.5 验证:`LspDiagnostics` 检查上述 4 个文件无报错
- [x] 7.1 `internal/task/polling_utils.go``getIntervalByTaskType` 新增 `case constants.TaskTypePollingCardStatus`,返回 `cfg.CardStatusCheckInterval`
- [x] 7.2 `internal/polling/queue_manager.go``allTaskTypes` slice 追加 `constants.TaskTypePollingCardStatus`
- [x] 7.3 `internal/polling/lifecycle_service.go``getEnabledTaskTypes` 新增 card_status 条件(`CardStatusCheckInterval != nil && *CardStatusCheckInterval > 0``calcInitialDelay` 新增对应 case
- [x] 7.4 `internal/polling/initializer.go`:在 `initBatch()` 的 if-block 链中新增一段,当 `cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0` 时,以 `card.LastCardStatusCheckAt`(新增字段)为基准调用 `calculateNextCheckTime`,向 `TaskTypePollingCardStatus` 分片队列写入 ZAdd 条目;位置紧跟 ProtectCheckInterval 块之后,格式与其他 4 种类型一致
- [x] 7.5 验证:`LspDiagnostics` 检查上述 4 个文件无报错
## 8. Worker Handler 注册
- [ ] 8.1 `pkg/queue/handler.go`:在 `RegisterHandlers` 中创建 `cardStatusHandler := task.NewPollingCardStatusHandler(...)` 并注册 `h.mux.HandleFunc(constants.TaskTypePollingCardStatus, cardStatusHandler.Handle)`
- [ ] 8.2 验证:`LspDiagnostics` 检查 `handler.go` 无报错;`go build ./cmd/worker/...` 成功编译
- [x] 8.1 `pkg/queue/handler.go`:在 `RegisterHandlers` 中创建 `cardStatusHandler := task.NewPollingCardStatusHandler(...)` 并注册 `h.mux.HandleFunc(constants.TaskTypePollingCardStatus, cardStatusHandler.Handle)`
- [x] 8.2 验证:`LspDiagnostics` 检查 `handler.go` 无报错;`go build ./cmd/worker/...` 成功编译
## 9. 整体验证
- [ ] 9.1 执行 `go build ./...` 确认整个项目编译通过
- [ ] 9.2 使用 PostgreSQL MCP 验证迁移列已正确创建类型、NULL 约束)
- [ ] 9.3 检查 `make migrate-down && make migrate-up` 回滚和重新迁移均成功
- [x] 9.1 执行 `go build ./...` 确认整个项目编译通过
- [x] 9.2 使用 PostgreSQL MCP 验证迁移列已正确创建类型、NULL 约束)
- [x] 9.3 检查 `make migrate-down && make migrate-up` 回滚和重新迁移均成功

View File

@@ -0,0 +1,75 @@
### Requirement: 注册卡状态轮询任务类型常量
系统 SHALL 在 `pkg/constants/constants.go` 中定义常量 `TaskTypePollingCardStatus = "polling:card_status"`,与其他轮询类型常量在同一常量块内。
#### Scenario: 常量可被引用
- **WHEN** 其他包引用 `constants.TaskTypePollingCardStatus`
- **THEN** 编译通过,值为 `"polling:card_status"`
---
### Requirement: 轮询配置支持卡状态检查间隔
`PollingConfig` model`tb_polling_config`SHALL 包含 `CardStatusCheckInterval *int` 字段GORM column: `card_status_check_interval`NULL 表示该配置不启用卡状态轮询。
#### Scenario: 配置间隔为正整数时卡状态轮询启用
- **WHEN** `PollingConfig.CardStatusCheckInterval` 不为 NULL 且值 > 0
- **THEN** `getEnabledTaskTypes` 返回的列表中包含 `TaskTypePollingCardStatus`
#### Scenario: 配置间隔为 NULL 时卡状态轮询不启用
- **WHEN** `PollingConfig.CardStatusCheckInterval` 为 NULL
- **THEN** `getEnabledTaskTypes` 返回的列表中不包含 `TaskTypePollingCardStatus`
---
### Requirement: IotCard 记录最后一次卡状态检查时间
`IotCard` model`tb_iot_card`SHALL 包含 `LastCardStatusCheckAt *time.Time` 字段GORM column: `last_card_status_check_at`),每次卡状态轮询成功执行后更新。
#### Scenario: 每次执行卡状态轮询后更新时间戳
- **WHEN** `PollingCardStatusHandler.Handle` 成功执行(无论状态是否变化)
- **THEN** `tb_iot_card.last_card_status_check_at` 更新为当前时间
---
### Requirement: PollingCardStatusHandler 查询 Gateway 卡状态并同步 DB
系统 SHALL 实现 `PollingCardStatusHandler`,遵循与 `PollingRealnameHandler` 相同的结构:获取并发信号量 → 从缓存/DB 获取卡信息 → 调用 `gateway.QueryCardStatus` → 解析状态 → 更新 DB → 触发停复机评估 → 更新统计 → 重入队。
#### Scenario: Gateway 返回"正常"时卡 network_status 为 1
- **WHEN** `gateway.QueryCardStatus` 返回 `CardStatus = "正常"``"准备"`
- **THEN** `newNetworkStatus = 1``NetworkStatusOnline`
#### Scenario: Gateway 返回"停机"时卡 network_status 为 0
- **WHEN** `gateway.QueryCardStatus` 返回 `CardStatus = "停机"`
- **THEN** `newNetworkStatus = 0``NetworkStatusOffline`
#### Scenario: 状态未变化时不触发停复机评估
- **WHEN** Gateway 返回的 `newNetworkStatus``card.NetworkStatus` 相同
- **THEN** 不调用 `stopResumeSvc.EvaluateAndAct`
#### Scenario: 状态发生变化时更新 DB + 缓存 + 触发评估
- **WHEN** Gateway 返回的 `newNetworkStatus``card.NetworkStatus` 不同
- **THEN** `tb_iot_card.network_status` 更新为新值Redis 缓存同步更新,调用 `stopResumeSvc.EvaluateAndAct`
#### Scenario: Gateway 查询失败时重入队不更新状态
- **WHEN** `gateway.QueryCardStatus` 返回 error
- **THEN** 记录 Warn 日志,更新失败统计,重入队后返回,不修改 DB
#### Scenario: 并发已满时重新入队
- **WHEN** `acquireConcurrency` 返回 false
- **THEN** 调用 `requeueCard` 后返回,不执行 Gateway 查询
---
### Requirement: 卡状态轮询类型纳入队列管理
`allTaskTypes``queue_manager.go`SHALL 包含 `TaskTypePollingCardStatus`,使得 `RemoveFromAllQueues`、调度器出队、初始化器均覆盖卡状态队列。
#### Scenario: 卡删除时清理卡状态队列
- **WHEN** 调用 `RemoveFromAllQueues(ctx, cardID)`
- **THEN** 卡 ID 从 `polling:card_status` 的所有分片队列中移除
---
### Requirement: 初始化器写入卡状态队列
`PollingInitializer` SHALL 在批量初始化时,为每张卡的 `polling:card_status` 分片 Sorted Set 写入初始条目(与 realname/carddata/package/protect 保持一致)。
#### Scenario: Worker 启动时卡状态队列被初始化
- **WHEN** `PollingInitializer.StartBackground` 完成批量加载
- **THEN** 每张启用轮询且匹配配置的卡在 `polling:card_status` 对应分片队列中有条目

View File

@@ -265,3 +265,85 @@ func NewPollingProtectHandler(
- **GIVEN** 卡 `is_standalone=true`(未绑定设备)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到独立卡,直接 requeue设备保护期与独立卡无关
---
### Requirement: PollingBase 支持 verboseLog 标志
`PollingBase` 结构体 SHALL 新增 `verboseLog bool` 字段,`NewPollingBase` 函数 SHALL 接受 `verboseLog bool` 参数并赋值。`cmd/worker/main.go` SHALL 从 `cfg.Polling.VerboseLog` 读取并传入。
#### Scenario: verboseLog 为 true 时详细日志启用
- **WHEN** `cfg.Polling.VerboseLog = true` 且 Worker 启动
- **THEN** `PollingBase.verboseLog = true`,所有 handler 在成功查询上游时输出 Info 日志
#### Scenario: verboseLog 为 false 时详细日志不输出
- **WHEN** `cfg.Polling.VerboseLog = false`(默认)
- **THEN** 成功查询上游时不输出额外日志,行为与修改前相同
---
### Requirement: 配置文件包含 polling.verbose_log 字段
`pkg/config/config.go` SHALL 新增 `PollingConfig struct``VerboseLog bool`,并加入 `Config` 结构体。`pkg/config/defaults/config.yaml` SHALL 包含 `polling.verbose_log: false`
#### Scenario: 默认配置 verbose_log 为 false
- **WHEN** 未设置 `JUNHONG_POLLING_VERBOSE_LOG` 环境变量
- **THEN** `cfg.Polling.VerboseLog = false`
#### Scenario: 环境变量覆盖开启 verbose_log
- **WHEN** 设置 `JUNHONG_POLLING_VERBOSE_LOG=true`
- **THEN** `cfg.Polling.VerboseLog = true`
---
### Requirement: realname handler 成功查询后输出 verbose 日志
`PollingRealnameHandler.Handle` SHALL 在 `gateway.QueryRealnameStatus` 成功返回后,当 `h.base.verboseLog` 为 true 时,以 `Info` 级别记录日志,包含:`card_id``iccid``real_status`bool`new_status`int`changed`bool
#### Scenario: verboseLog 开启时实名查询成功输出日志
- **WHEN** `verboseLog = true``QueryRealnameStatus` 成功返回
- **THEN** 输出包含 iccid + real_status + new_status + changed 的 Info 日志
#### Scenario: verboseLog 关闭时实名查询成功不输出额外日志
- **WHEN** `verboseLog = false``QueryRealnameStatus` 成功返回(状态未变)
- **THEN** 不输出 verbose 日志
---
### Requirement: carddata handler 成功查询后输出 verbose 日志
`PollingCarddataHandler.Handle` SHALL 在 `gateway.QueryFlow` 成功返回后,当 `h.base.verboseLog` 为 true 时,以 `Info` 级别记录日志,包含:`card_id``iccid``gateway_flow_mb`float64`increment_mb`float64`is_cross_month`bool
#### Scenario: verboseLog 开启时流量查询成功输出日志
- **WHEN** `verboseLog = true``QueryFlow` 成功返回
- **THEN** 输出包含 iccid + gateway_flow_mb + increment_mb + is_cross_month 的 Info 日志
#### Scenario: verboseLog 关闭时流量查询成功不输出额外日志
- **WHEN** `verboseLog = false``QueryFlow` 成功返回(流量无增量)
- **THEN** 不输出 verbose 日志
---
### Requirement: package handler 成功加载卡信息后输出 verbose 日志
`PollingPackageHandler.Handle` SHALL 在 `GetByID` 成功返回 `freshCard` 后、调用 `EvaluateAndAct` 之前,当 `h.base.verboseLog` 为 true 时,以 `Info` 级别记录日志,包含:`card_id``iccid``network_status`int`stop_reason`string
> package handler 不查询 Gatewayverbose log 记录的是本次评估时读取到的本地卡状态,便于排查"停复机评估为何结果不符预期"类问题。
#### Scenario: verboseLog 开启时套餐检查加载卡成功输出日志
- **WHEN** `verboseLog = true``iotCardStore.GetByID` 成功返回 `freshCard`
- **THEN** 输出包含 iccid + network_status + stop_reason 的 Info 日志
#### Scenario: verboseLog 关闭时套餐检查不输出额外日志
- **WHEN** `verboseLog = false`
- **THEN** 不输出 verbose 日志(现有 "套餐检查:执行停复机评估" Info 日志保持不变)
---
### Requirement: protect handler 成功执行保护期检查后输出 verbose 日志
`PollingProtectHandler.Handle` SHALL 在 switch 块执行完毕、调用 `UpdateFields(last_protect_check_at)` 之前,当 `h.base.verboseLog` 为 true 时,以 `Info` 级别记录日志,包含:`card_id``iccid``stop_protect`bool`start_protect`bool`network_status_at_check`int`action_taken`string
> 注:该日志在 switch 的所有分支强制停机、强制复机、EvaluateAndAct、无操作均成功执行后输出若某分支因 Gateway 失败提前 return则不输出已有 Error 日志覆盖)。`network_status_at_check` 反映检查时的卡状态(触发动作的原因),`action_taken` 反映实际执行了什么forced_stop / forced_resume / evaluate / no_action
#### Scenario: verboseLog 开启时保护期检查成功输出日志
- **WHEN** `verboseLog = true` 且 switch 块各分支均执行成功(未提前 return
- **THEN** 输出包含 iccid + stop_protect + start_protect + network_status_at_check + action_taken 的 Info 日志
#### Scenario: verboseLog 关闭时保护期检查不输出额外日志
- **WHEN** `verboseLog = false`
- **THEN** 不输出 verbose 日志