Files
huang 434a8b0349
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
【核心变更】

1. 停复机逻辑统一(StopResumeService)
   - 新增 EvaluateAndAct 统一入口,封装三条件停复机判断
   - 停机条件:无套餐(no_package) / 流量耗尽(traffic_exhausted) / 未实名(not_realname)
   - 复机条件:stop_reason 合规 + 有套餐且未耗尽 + 已实名或行业卡
   - 修复设备套餐 Bug:hasValidPackage 按 device_id 查套餐,而非仅 iot_card_id
   - 设备维度停复机加幂等锁(Redis SetNX,TTL 30s),防止多卡并发重复调 Gateway

2. Redis 分片队列(PollingQueueManager)
   - 新建 queue_manager.go,封装所有轮询 Redis 操作
   - 16 分片 Sorted Set,Key 格式:polling:shard:{shardID}:queue:{taskType}
   - Lua 脚本原子出队(ZRANGEBYSCORE + 分批 ZREM),消除竞态窗口
   - 新增背压检测:队列深度超 50 万时 Scheduler 跳过该分片
   - RemoveFromAllQueues 覆盖 4 种任务类型(含 protect)

3. Handler 拆分(polling_handler.go 1360行 → 5个专注文件)
   - polling_base.go:共享基类(并发控制/卡缓存/重入队)
   - polling_realname_handler.go:实名采集,实名 0→1 时立即触发复机
   - polling_carddata_handler.go:流量采集,保留跨月边界检测逻辑
   - polling_package_handler.go:套餐采集,委托 EvaluateAndAct 决策
   - polling_protect_handler.go:保护期一致性检查,保护期内强制修正

4. 配置管理(PollingConfigManager)
   - 新建 config_manager.go,从 scheduler.go 提取配置职责
   - 内存缓存 + 5 分钟定时刷新,刷新失败保留原缓存
   - 修复 getCardCondition:停机卡返回 suspended,不再错配 activated 配置

5. 渐进式初始化(CardInitializer)
   - 新建 initializer.go,分批加载(每批 10 万),批次间 sleep 500ms
   - 过滤 enable_polling=false 的卡,初始化完成前 Scheduler 不出队

6. 卡生命周期服务(PollingLifecycleService)
   - 新建 lifecycle_service.go,替代已删除的 callbacks.go 和 api_callback.go
   - OnCardCreated/OnCardEnabled/OnCardStatusChanged 入队前检查 enable_polling

7. Scheduler 精简(1000+行 → 227行)
   - 保留纯调度循环:scheduleLoop + processShardSchedule + enqueueBatch
   - 保留每 10 秒触发套餐过期检测和流量重置
   - 移除所有 DB 操作、配置加载、卡初始化逻辑

8. 轮询管控 API(enable_polling)
   - 新增 PUT /api/admin/assets/:id/polling-status 接口
   - 支持对设备/卡维度开关轮询,关闭后从所有分片队列移除

9. 数据库迁移
   - 000103:tb_device 新增 enable_polling 字段(boolean, NOT NULL, DEFAULT true)
   - 000104:新增 suspended 轮询配置,为 activated 配置补全 protect_check_interval

【文件统计】
- 新增:19 个文件(handler × 5、polling 组件 × 4、迁移 × 3 等)
- 修改:20 个文件(bootstrap 注入、store 接口、monitoring 适配分片等)
- 删除:3 个文件(polling_handler.go、callbacks.go、api_callback.go)

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-07 12:27:04 +08:00

59 KiB
Raw Permalink Blame History

任务清单openspec 跟踪用)

  • 1.1 创建 DB 迁移文件tb_device 新增 enable_polling 字段)
  • 1.2 model/device.go 新增 EnablePolling 字段
  • 1.3 pkg/constants/iot.go 新增停机原因常量
  • 1.4 pkg/constants/polling.go 新增轮询系统常量
  • 1.5 pkg/constants/redis.go 新增分片队列 Key 函数和设备锁 Key 函数
  • 1.6 执行迁移并验证DB 操作)
  • 1.7 新增 "suspended" 轮询配置DB 操作)
  • 1.8 为现有活跃配置添加 protect_check_intervalDB 操作)
  • 1.9 清理 real_name 存量配置条件DB 操作)
  • 2.0 前置评估not_realname 停机迁移影响(门控任务)
  • 2.1 新增 hasValidPackage 方法(修复设备套餐 Bug
  • 2.2 新增 isTrafficExhausted 方法
  • 2.3 新增 isRealnameOK 方法
  • 2.4 新增 checkStopReasons 方法
  • 2.5 新增 EvaluateAndAct 统一入口方法
  • 2.6 修改 stopCardWithRetry 接收 stopReason 参数
  • 2.7 修改 resumeSingleCard复机条件使用新逻辑
  • 2.8 修改 stopDeviceCards 和 resumeDeviceCards加设备维度幂等锁
  • 2.9 定义 StopResumeServiceInterface 接口
  • 2.10 删除旧 hasAvailablePackage 方法
  • 2.11 PostgreSQL MCP 验证设备套餐停复机正确性
  • 3.1 新建 queue_manager.go统一 Redis 队列操作)
  • 3.2 新建 config_manager.go配置管理
  • 4.1 新建 polling_realname_handler.go
  • 4.2 新建 polling_carddata_handler.go
  • 4.3 新建 polling_package_handler.go
  • 4.4 新建 polling_protect_handler.go
  • 4.5 消除所有直接 DB 操作13处
  • 4.6 提取 PollingBase 共享基类
  • 4.7 更新 pkg/queue/handler.go 注册 4 个新 Handler
  • 4.8 删除旧 polling_handler.goPhase 4+5 原子部署完成)
  • 4.9 验证 4 种轮询任务均能正常触发和执行
  • 5.1 新建 initializer.go分片渐进式初始化
  • 5.2 精简 scheduler.go目标 < 250行实际 227行
  • 5.3 删除 callbacks.go
  • 5.4 删除 api_callback.go
  • 5.5 新建 lifecycle_service.go替代 callbacks.go
  • 5.6 更新 MonitoringService 适配分片队列
  • 5.7 更新 cmd/worker/main.go 启动流程(接入新轮询组件)
  • 5.8 更新 PollingCallback 注入点bootstrap/services.go
  • 6.1 新增 DTOUpdateAssetPollingStatusRequest/Response
  • 6.2 新增 Store 方法DeviceStore.UpdatePollingStatus
  • 6.3 新增 Service 方法AssetPollingService
  • 6.4 新增 Handler 方法AssetHandler.UpdatePollingStatus
  • 6.5 注册路由
  • 6.6 更新文档生成器
  • 7.1 DB 验证:设备套餐停复机正确性
  • 7.2 DB 验证:非行业卡未实名有套餐触发停机
  • 7.3 DB 验证:行业卡未实名不停机
  • 7.4 Redis 验证protect 队列 Bug 修复
  • 7.5 接口验证enable_polling 管控接口
  • 7.6 代码行数验证
  • 7.7 Redis 原子性验证
  • 7.8 兼容性验证36 个已有接口零改动
  • 7.9 not_realname 停机迁移影响评估(已前移至 2.0

Phase 1基础准备DB 迁移 + 常量 + Redis Key

1.1 创建 DB 迁移文件tb_device 新增 enable_polling 字段)

文件migrations/YYYYMMDDHHMMSS_add_enable_polling_to_device.sql

内容

-- 上行迁移
ALTER TABLE tb_device ADD COLUMN IF NOT EXISTS enable_polling BOOLEAN NOT NULL DEFAULT TRUE;
COMMENT ON COLUMN tb_device.enable_polling IS '是否参与轮询false 时不加入轮询队列';

-- 下行迁移(回滚)
-- ALTER TABLE tb_device DROP COLUMN IF EXISTS enable_polling;

验证条件

  • \d tb_device 输出中包含 enable_polling boolean not null default true
  • 存量数据全部为 TRUESELECT COUNT(*) FROM tb_device WHERE enable_polling IS NULL = 0

1.2 model/device.go 新增 EnablePolling 字段

文件internal/model/device.go

变更:在 Device 结构体中新增字段,完整 GORM 标签:

// EnablePolling 是否参与轮询false 时不加入轮询队列
EnablePolling bool `json:"enable_polling" gorm:"column:enable_polling;not null;default:true"`

验证条件

  • 字段标签与数据库列名一致(enable_polling
  • 有中文注释说明用途
  • not nulldefault:true 标签齐全

1.3 pkg/constants/iot.go 新增停机原因常量

文件pkg/constants/iot.go

新增常量(在现有 StopReasonTrafficExhausted 等常量附近):

// StopReasonNoPackage 停机原因:无有效套餐
StopReasonNoPackage = "no_package"

// StopReasonNotRealname 停机原因:非行业卡且未完成实名认证
StopReasonNotRealname = "not_realname"

验证条件

  • 两个常量均有中文注释
  • 与现有 StopReason* 常量在同一分组下
  • 常量值全小写下划线格式

1.4 pkg/constants/polling.go 新增轮询系统常量

文件pkg/constants/polling.go(若不存在则新建,否则追加)

新增常量

// PollingShardCount 轮询队列默认分片数
// 千万级规模下16 分片可将单队列深度控制在 ~60 万以内
const PollingShardCount = 16

// PollingBackpressureThreshold 单分片队列背压阈值
// 超过此深度时Scheduler 跳过该分片本轮出队,防止 Asynq 积压过载
// 默认 50 万,可根据 Asynq Worker 处理速度调整
const PollingBackpressureThreshold = 500_000

// PollingDequeueMaxBatchSize Lua 脚本单次出队上限
// 受 Lua unpack() 的 LUAI_MAXCSTACK 约 8000 限制,保守取 7000
const PollingDequeueMaxBatchSize = 7000

验证条件

  • 三个常量均有中文注释说明含义和取值依据
  • PollingShardCountPollingBackpressureThresholdPollingQueueManagerScheduler 中引用(而非硬编码 magic number
  • go build ./... 编译通过

1.5 pkg/constants/redis.go 新增分片队列 Key 函数和设备锁 Key 函数

文件pkg/constants/redis.go

新增函数

// RedisPollingShardQueueKey 轮询分片队列 Key
// shardID: 分片编号0 到 N-1
// taskType: 任务类型realname/carddata/package/protect
// 格式polling:shard:{shardID}:queue:{taskType}
func RedisPollingShardQueueKey(shardID int, taskType string) string {
    return fmt.Sprintf("polling:shard:%d:queue:%s", shardID, taskType)
}

// RedisPollingDeviceOpLockKey 设备维度停复机操作锁 Key
// 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 调用
// TTL 建议 30 秒(覆盖 stopDeviceCards/resumeDeviceCards 最长执行时间)
// 格式polling:device:op_lock:{deviceID}
func RedisPollingDeviceOpLockKey(deviceID uint) string {
    return fmt.Sprintf("polling:device:op_lock:%d", deviceID)
}

验证条件

  • 两个函数均有中文 doc 注释,说明参数含义和 Key 格式
  • 通过单元验证:RedisPollingShardQueueKey(3, "carddata") = "polling:shard:3:queue:carddata"
  • RedisPollingDeviceOpLockKey(10) = "polling:device:op_lock:10"

1.6 执行迁移并验证

操作

  1. 运行迁移命令(参考 db-migration Skill 中的迁移规范)
  2. 用 PostgreSQL MCP 验证列已创建

验证 SQLPostgreSQL MCP 执行)

-- 验证列存在
SELECT column_name, data_type, column_default, is_nullable
FROM information_schema.columns
WHERE table_name = 'tb_device' AND column_name = 'enable_polling';

-- 验证存量数据
SELECT COUNT(*) as total,
       COUNT(*) FILTER (WHERE enable_polling = true) as enabled,
       COUNT(*) FILTER (WHERE enable_polling = false) as disabled
FROM tb_device;

通过条件:查询返回 enable_polling | boolean | true | NO,存量数据 enabled = total


1.7 新增 "suspended" 轮询配置M1 修复:停机卡持续轮询)

背景getCardCondition 修复后(决策 14停机卡返回 "suspended" 条件。若数据库中无对应配置,停机卡将无法被入队,也就无法自动复机。

操作:执行以下 SQL新增"停机卡轮询"配置:

-- 停机卡轮询配置:每小时检查一次实名/流量/套餐,以便检测复机条件
-- priority=25 介于 'not_real_name'(10) 和 'real_name'(20) 之间
-- ⚠️ realname_check_interval 必须设置:
--   停机且未实名的卡(因 not_realname 停机)需要 realname 检查,
--   实名完成后 realname Handler 触发 EvaluateAndAct → 自动复机;
--   若 realname_check_interval=NULL此类卡永远不会被实名检查无法复机
INSERT INTO tb_polling_config (
    config_name, card_condition, card_category, carrier_id,
    priority, realname_check_interval, carddata_check_interval, package_check_interval, protect_check_interval,
    status, description, created_at, updated_at
) VALUES (
    '停机卡轮询', 'suspended', NULL, NULL,
    25, 3600, 3600, 3600, NULL,
    1, '停机卡每小时检查实名/流量/套餐,满足条件时自动复机', NOW(), NOW()
)
ON CONFLICT DO NOTHING;

验证PostgreSQL MCP

SELECT id, config_name, card_condition,
       realname_check_interval, carddata_check_interval, package_check_interval, status
FROM tb_polling_config
WHERE card_condition = 'suspended';
-- 预期返回1行realname_check_interval=3600carddata_check_interval=3600status=1

通过条件:查询返回停机卡配置,且 realname_check_interval IS NOT NULL(停机未实名卡必须能被实名检查,否则无法复机)。


1.8 为现有活跃配置添加 protect_check_intervalM2 修复)

背景protect_check_interval 字段由迁移 000102 添加,但现有所有配置均为 NULL导致 protect 任务从未入队,polling_protect_handler.go 无法生效。

操作:为 "已激活在线卡" 和 "默认轮询配置" 添加 protect 检查间隔(与 carddata 保持一致):

-- 为已激活卡添加保护期检查(每小时)
UPDATE tb_polling_config
SET protect_check_interval = 3600, updated_at = NOW()
WHERE card_condition = 'activated' AND protect_check_interval IS NULL;

-- 为停机卡配置也添加(新增配置天然包含,此处保险更新)
UPDATE tb_polling_config
SET protect_check_interval = 3600, updated_at = NOW()
WHERE card_condition = 'suspended' AND protect_check_interval IS NULL;

验证PostgreSQL MCP

SELECT config_name, card_condition, protect_check_interval
FROM tb_polling_config
WHERE status = 1
ORDER BY priority;
-- 预期:至少 activated 和 suspended 的 protect_check_interval 非 NULL

通过条件:至少存在一条 protect_check_interval IS NOT NULL 的启用配置。


1.9 清理 real_name 存量配置条件getCardCondition 兼容性)

背景:决策 14 修复后,getCardCondition 不再返回 "real_name",只返回 not_real_name / activated / suspended
若数据库中存在 card_condition='real_name' 的配置,这些配置将永远无法匹配任何卡,但也不会报错——原本依赖此配置轮询的卡会悄悄退出轮询队列。

操作

第一步:评估影响范围(执行 PostgreSQL MCP

-- 查看现有 real_name 配置
SELECT id, config_name, card_condition, priority, status,
       realname_check_interval, carddata_check_interval, package_check_interval
FROM tb_polling_config
WHERE card_condition = 'real_name'
ORDER BY priority;

决策逻辑

  • 若返回 0 行:无需操作,继续下一任务。
  • 若返回 ≥ 1 行:分析其轮询间隔含义——
    • 若这些配置是"已实名在线卡"的配置 → 语义等同于 activated,执行下面的更新 SQL
    • 若有其他特殊含义 → 与业务方确认后再操作

第二步:迁移 real_name 配置(若存在)

-- 将 card_condition='real_name' 迁移到 'activated'
-- 注意:若 activated 配置已存在,需检查优先级是否冲突
UPDATE tb_polling_config
SET card_condition = 'activated',
    description   = COALESCE(description, '') || '(原 real_name 条件,已迁移至 activated',
    updated_at    = NOW()
WHERE card_condition = 'real_name';

验证PostgreSQL MCP

-- 确认无残留 real_name 配置
SELECT COUNT(*) FROM tb_polling_config WHERE card_condition = 'real_name';
-- 预期0

-- 确认 activated 配置存在且启用
SELECT id, config_name, card_condition, priority, status
FROM tb_polling_config
WHERE card_condition = 'activated' AND status = 1;
-- 预期:至少 1 行

通过条件card_condition='real_name' 的行数为 0。


Phase 2StopResumeService 重写(停复机逻辑统一)

⚠️ Phase 2 前置门控(必须在 2.1 之前完成)Phase 2 新增 not_realname 停机条件,部署后所有"在线+未实名+普通卡"将在下一轮询周期被批量停机。必须先执行影响评估并与业务方确认,再继续后续任务。

2.0 前置评估not_realname 停机迁移影响(门控任务)

⚠️ 此任务必须在 Phase 2 实施前完成,结果决定是否继续 Phase 2 或调整策略。

评估 SQLPostgreSQL MCP 执行)

-- 统计"在线 + 未实名 + 普通卡"(部署后将被停机的卡)
SELECT
    COUNT(*) AS total_at_risk,
    COUNT(DISTINCT carrier_id) AS affected_carriers
FROM tb_iot_card
WHERE network_status = 1          -- 当前在线
  AND real_name_status = 0        -- 未实名
  AND (card_category IS NULL OR card_category != 'industry')  -- 非行业卡
  AND deleted_at IS NULL;

-- 按运营商分布查看,判断是否适合分批上线
SELECT carrier_id, COUNT(*) AS count
FROM tb_iot_card
WHERE network_status = 1
  AND real_name_status = 0
  AND (card_category IS NULL OR card_category != 'industry')
  AND deleted_at IS NULL
GROUP BY carrier_id
ORDER BY count DESC;

评估结果2026-04-03

  • total_at_risk = 1,仅 1 张卡受影响carrier_id=1566
  • 影响极小,满足 < 100 门控条件,可直接继续 Phase 2

决策门控

  • total_at_risk = 0:可直接继续 Phase 2无迁移影响
  • total_at_risk < 100:与业务方确认后可直接继续,记录受影响卡列表
  • total_at_risk ≥ 100必须与业务方讨论,可选方案: a. 灰度上线:先仅对特定 carrier_id 生效 b. 宽限期:新增配置字段"not_realname 停机启用日期",未到日期不执行条件 C c. 直接接受:通知受影响用户,批量停机作为业务策略
  • 评估结果及业务方决策必须在此任务中记录,才能标记任务完成

2.1 新增 hasValidPackage 方法(修复设备套餐 Bug

文件internal/service/iot_card/stop_resume_service.go

方法签名

// hasValidPackage 检查卡是否有有效套餐status IN 0,1
// 修复Bug绑定设备的卡查 device_id独立卡查 iot_card_id
func (s *Service) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error)

实现要点

  • card.DeviceID != nil:查 tb_package_usage WHERE device_id=? AND status IN(0,1)
  • 否则:查 tb_package_usage WHERE iot_card_id=? AND status IN(0,1)
  • status=0待激活和 status=1激活中均视为有效

验证条件PostgreSQL MCP

-- 场景1为设备购买套餐后hasValidPackage 应返回 true
SELECT COUNT(*) FROM tb_package_usage
WHERE device_id = (SELECT device_id FROM tb_iot_card WHERE id = :cardID)
  AND status IN (0, 1);
-- 预期结果 > 0

-- 场景2仅有 iot_card_id 套餐的独立卡
SELECT COUNT(*) FROM tb_package_usage
WHERE iot_card_id = :cardID AND status IN (0, 1);

2.2 新增 isTrafficExhausted 方法

文件internal/service/iot_card/stop_resume_service.go

方法签名

// isTrafficExhausted 检查卡的流量是否已耗尽
// 条件:活跃套餐 status=2或 data_usage_mb >= data_limit_mbdata_limit_mb > 0
func (s *Service) isTrafficExhausted(ctx context.Context, card *model.IotCard) (bool, error)

实现要点

  • 查询活跃套餐status=1同样区分 device_id 和 iot_card_id
  • status=2 直接返回 true
  • data_limit_mb > 0 && data_usage_mb >= data_limit_mb 返回 true
  • data_limit_mb == 0(无限流量)不判断用量,返回 false
  • 无活跃套餐时返回 false

验证条件PostgreSQL MCP

-- 验证流量耗尽场景
SELECT id, data_usage_mb, data_limit_mb, status
FROM tb_package_usage
WHERE iot_card_id = :cardID AND status IN(1,2)
ORDER BY created_at DESC LIMIT 1;
-- status=2 或 data_usage_mb >= data_limit_mb 时应停机

2.3 新增 isRealnameOK 方法

文件internal/service/iot_card/stop_resume_service.go

方法签名

// isRealnameOK 检查卡是否满足实名要求
// 行业卡card_category='industry')无需实名;其他卡需 real_name_status=1
func (s *Service) isRealnameOK(card *model.IotCard) bool

实现要点

  • card.CardCategory == "industry" 返回 true行业卡豁免
  • card.RealNameStatus == 1 返回 true
  • 其他情况返回 false

验证条件PostgreSQL MCP

-- 验证行业卡数据
SELECT id, card_category, real_name_status
FROM tb_iot_card
WHERE card_category = 'industry' AND real_name_status = 0
LIMIT 5;
-- 这些卡不应被停机(行业卡豁免)

2.4 新增 checkStopReasons 方法

文件internal/service/iot_card/stop_resume_service.go

方法签名

// checkStopReasons 检查卡的停机原因列表,按优先级排序
// 优先级no_package > traffic_exhausted > not_realname
func (s *Service) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error)

实现要点

  • 依次检查三个条件,满足则追加到 reasons 切片
  • 已按优先级顺序检查(第一个即为最高优先级)
  • 返回空切片表示无停机原因

验证条件

  • 同时满足三个条件时,返回 ["no_package", "traffic_exhausted", "not_realname"]
  • 仅满足 traffic_exhausted 时,返回 ["traffic_exhausted"]

2.5 新增 EvaluateAndAct 统一入口方法

文件internal/service/iot_card/stop_resume_service.go

方法签名Mi2 修复:删除冗余的 carrierType/carrierID 参数)

// EvaluateAndAct 停复机统一入口
// 根据卡的当前网络状态,自动判断并执行停机或复机操作
// 设备/单卡维度通过 card.DeviceID 推导(非 nil 则为设备维度,覆盖设备下所有卡)
func (s *Service) EvaluateAndAct(ctx context.Context, card *model.IotCard) error

实现要点

  • 在线(network_status=1):调 checkStopReasons,有原因则停机(优先级最高的原因)
  • 停机(network_status=0):检查 stop_reason IN(...) + shouldResume,满足则复机
  • 设备维度:card.DeviceID != nil 时调 stopDeviceCards/resumeDeviceCards
  • 其他网络状态:直接返回 nil不处理
  • 日志中通过 zap.Uint("card_id", card.ID)zap.Any("device_id", card.DeviceID) 记录足够的上下文,无需外部传入 carrierType/carrierID

验证条件PostgreSQL MCP

-- 验证停机后字段更新
SELECT id, network_status, stop_reason
FROM tb_iot_card
WHERE id = :cardID;
-- 停机后 network_status=0, stop_reason 为对应原因

-- 验证复机后字段清除
SELECT id, network_status, stop_reason
FROM tb_iot_card
WHERE id = :cardID;
-- 复机后 network_status=1, stop_reason=''

2.6 修改 stopCardWithRetry 接收 stopReason 参数

文件internal/service/iot_card/stop_resume_service.go

变更

  • 方法签名增加 stopReason string 参数
  • 停机成功后将 stop_reason 写入 DB通过 Store 方法)

验证条件PostgreSQL MCP

-- 验证停机原因写入
SELECT id, stop_reason, network_status, updated_at
FROM tb_iot_card
WHERE id = :cardID;
-- stop_reason 应为 'no_package' 或 'traffic_exhausted' 或 'not_realname'

2.7 修改 resumeSingleCard复机条件使用新逻辑

文件internal/service/iot_card/stop_resume_service.go

变更

  • 复机前检查 stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')
  • 调用 shouldResume(ctx, card) = hasValidPackage && !isTrafficExhausted && isRealnameOK
  • 复机成功后清空 stop_reason(写入 ''

验证条件PostgreSQL MCP

-- 验证 stop_reason='manual' 的卡不被自动复机
SELECT COUNT(*) FROM tb_iot_card
WHERE stop_reason = 'manual' AND network_status = 1;
-- 预期 = 0手动停机不应被自动复机

2.8 修改 stopDeviceCards 和 resumeDeviceCards加设备维度幂等锁

文件internal/service/iot_card/stop_resume_service.go

背景:设备下多张卡可能同时被轮询系统触发 EvaluateAndAct(不同分片同一调度周期),导致多次并发调用 stopDeviceCards / resumeDeviceCards,同一张卡被重复 Gateway 停/复机。

变更stopDeviceCards

  • 进入函数后,先通过 Redis SetNX 获取设备操作锁(constants.RedisPollingDeviceOpLockKey(deviceID)TTL 30 秒)
  • 获取失败(其他协程正在处理同一设备):记录 Debug 日志「设备停机操作已在进行中,跳过: deviceID=xxx」直接返回 nil
  • 获取成功:执行停机逻辑,defer 释放锁

变更resumeDeviceCards

  • 同上,使用同一把锁(RedisPollingDeviceOpLockKey
  • 同时:遍历设备下停机卡时,对每张卡调用 isRealnameOK(card)
  • 不满足(普通卡未实名)的卡:更新 stop_reason='not_realname',跳过复机
  • 遍历中单张卡失败不中断整体(记录 Error 日志,继续处理其他卡)
// stopDeviceCards 停机设备下所有在线卡(含幂等锁)
func (s *Service) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) {
    lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
    locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
    if !locked {
        s.logger.Debug("设备停机操作已在进行中,跳过重复调用", zap.Uint("device_id", deviceID))
        return
    }
    defer s.redis.Del(ctx, lockKey)
    // ... 遍历设备下在线卡,逐一调 stopCardWithRetry
}

验证条件PostgreSQL MCP

-- 验证设备下停机卡状态
SELECT id, card_category, real_name_status, network_status, stop_reason
FROM tb_iot_card
WHERE device_id = :deviceID AND network_status = 0;
-- 行业卡或已实名卡应已复机 (network_status=1)
-- 未实名普通卡应保持停机 (stop_reason='not_realname')

验证条件PostgreSQL MCP

-- 验证设备下停机卡状态
SELECT id, card_category, real_name_status, network_status, stop_reason
FROM tb_iot_card
WHERE device_id = :deviceID AND network_status = 0;
-- 行业卡或已实名卡应已复机 (network_status=1)
-- 未实名普通卡应保持停机 (stop_reason='not_realname')

2.9 定义 StopResumeServiceInterface 接口Mi3

文件internal/service/iot_card/stop_resume_service.go(或同包下新建 interfaces.go

背景Phase 4 的 4 个 Task Handler 通过接口注入 StopResumeService,需要事先定义接口,否则 Phase 4 的构造函数无法编译。

新增接口

// StopResumeServiceInterface 停复机服务接口
// 供 4 个 Task Handler 通过接口注入,避免循环依赖
type StopResumeServiceInterface interface {
    // EvaluateAndAct 停复机统一入口,根据卡的当前状态自动判断并执行停机或复机
    EvaluateAndAct(ctx context.Context, card *model.IotCard) error
}

验证条件

  • internal/service/iot_card/Service 实现了 StopResumeServiceInterface(编译时验证:var _ StopResumeServiceInterface = (*Service)(nil)
  • go build ./... 编译通过

2.10 删除旧 hasAvailablePackage 方法

文件internal/service/iot_card/stop_resume_service.go

变更:删除原 hasAvailablePackage 函数(仅查 iot_card_id,存在 Bug

验证条件

  • 文件中不再出现 hasAvailablePackage 函数定义
  • 所有原来调用 hasAvailablePackage 的地方已改为调用 hasValidPackage
  • 编译通过,无未使用函数

2.11 PostgreSQL MCP 验证设备套餐停复机正确性

验证脚本(使用 PostgreSQL MCP 工具):

-- 验证1购买设备套餐的卡不被误停机
-- 选取一张绑定了设备且有设备套餐的在线卡
SELECT c.id, c.device_id, c.network_status, pu.device_id as pkg_device_id, pu.status
FROM tb_iot_card c
JOIN tb_package_usage pu ON pu.device_id = c.device_id
WHERE c.device_id IS NOT NULL
  AND pu.status IN (0,1)
  AND c.network_status = 1
LIMIT 5;
-- 以上卡在重构后不应被停机

-- 验证2行业卡有套餐未实名不被停机
SELECT id, card_category, real_name_status, network_status
FROM tb_iot_card
WHERE card_category = 'industry'
  AND real_name_status = 0
  AND network_status = 1
LIMIT 5;
-- 以上卡不应有 stop_reason='not_realname'

Phase 3PollingQueueManager + PollingConfigManager基础组件

3.1 新建 queue_manager.go统一 Redis 队列操作)

文件internal/polling/queue_manager.go< 200行

实现要点

  • DequeueReady:使用 Lua 脚本原子执行 ZRANGEBYSCORE + ZREM保留时间过滤语义只取 score ≤ now 的到期卡):
    var dequeueScript = redis.NewScript(`
        local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
        -- 分批 ZREMLua unpack() 受 LUAI_MAXCSTACK 限制(约 8000按 7000 分批避免溢出
        for i = 1, #results, 7000 do
            local j = math.min(i + 6999, #results)
            redis.call('ZREM', KEYS[1], unpack(results, i, j))
        end
        return results
    `)
    
  • Requeue:使用 redis.ZAdd(ctx, key, redis.Z{Score: float64(nextCheckAt.Unix()), Member: cardID})
  • RemoveFromAllQueues遍历所有分片0 到 shardCount-1× 4 个任务类型,执行 ZREM
  • EnqueueManual:使用 redis.RPush(ctx, manualKey, cardID) 推入 List
  • OnCardDeleted:调 RemoveFromAllQueues + 清理卡信息缓存(redis.Del(ctx, cacheKey)
  • GetQueueDepth:使用 redis.ZCard(ctx, shardKey).Result()

Key 生成:统一调用 constants.RedisPollingShardQueueKey(shardID, taskType)

验证条件

  • 文件行数 < 200 行
  • DequeueReady 使用 Lua 脚本(redis.NewScript)且采用分批 ZREM不使用 ZPOPMIN
  • RemoveFromAllQueues 覆盖 4 个任务类型(含 protect
  • go build ./... 编译通过

3.2 新建 config_manager.go配置管理

文件internal/polling/config_manager.go< 150行

从 scheduler.go 提取loadConfigssyncConfigsToRedismatchConfigmatchConfigConditionsgetCardCondition

新增

  • 内存缓存(sync.RWMutex 保护的 []PollingConfig 切片)
  • Start(ctx) 方法goroutine 每 5 分钟调用 Load(ctx) 刷新
  • Load(ctx) 加载失败时保留原缓存不清空

⚠️ 必须修复 getCardConditionM1 修复)
getCardCondition 从不返回 "suspended",导致停机卡匹配到 carddata/package 间隔为 null 的配置,无法自动复机。
新实现最先判断网络状态:

// getCardCondition 获取卡的状态条件(用于匹配轮询配置)
// ⚠️ 注意判断顺序:停机优先,避免停机卡错误匹配 activated 或 real_name 配置
func getCardCondition(card *model.IotCard) string {
    // 停机卡(无论实名状态),返回 suspended匹配含 carddata/package 间隔的停机配置
    // 停机卡需要继续轮询以检测复机条件(套餐购买、流量重置、实名完成)
    if card.NetworkStatus == 0 {
        return "suspended"
    }
    // 在线卡按实名状态区分
    if card.RealNameStatus != constants.RealNameStatusVerified {
        return "not_real_name"
    }
    return "activated"
}

验证条件

  • 文件行数 < 150 行
  • scheduler.go 中不再出现 loadConfigssyncConfigsToRedis 字样
  • getCardConditionNetworkStatus=0 的卡返回 "suspended"(编写单函数验证或日志观察)
  • go build ./... 编译通过

Phase 4Task Handler 拆分4个专注 Handler

4.1 新建 polling_realname_handler.go< 200行

文件internal/task/polling_realname_handler.go

从 polling_handler.go 提取HandleRealnameCheck 相关逻辑

构造函数

// NewPollingRealnameHandler 创建实名检查任务处理器
func NewPollingRealnameHandler(
    base        *PollingBase,
    gateway     GatewayClient,
    iotCardStore IotCardStore,
    queueClient QueueClient,
) *PollingRealnameHandler

方法列表Handle(ctx, task), processCard(ctx, cardID)

关键要求

  • triggerFirstRealnameActivation 保留(首次实名激活任务入队)
  • ⚠️ S1 修复:当 Gateway 返回实名状态由 0→1 时,在入队激活任务后,必须调用 stopResumeService.EvaluateAndAct(ctx, card)
    理由:卡可能因 not_realname 处于停机状态,实名完成后应立即触发复机,不能等下一次 carddata/package 轮询(可能长达 1 小时)
    调用时机:UpdateRealNameStatus Store 写入成功后,从 Store 重新加载卡信息(确保状态最新),再调 EvaluateAndAct
  • 文件中不出现无条件的 stopCardresumeCard(仅允许通过 EvaluateAndAct 委托)
  • 所有 DB 操作通过 iotCardStore 方法(无 h.db.Model()

验证条件

  • 文件行数 < 200 行
  • 文件中包含 EvaluateAndAct 字样(搜索应有匹配,且在 real_name_status 0→1 的条件分支内)
  • go build ./... 编译通过

4.2 新建 polling_carddata_handler.go< 300行

文件internal/task/polling_carddata_handler.go

从 polling_handler.go 提取HandleCarddataCheck 相关逻辑

构造函数

// NewPollingCarddataHandler 创建流量检查任务处理器
func NewPollingCarddataHandler(
    base             *PollingBase,
    gateway          GatewayClient,
    iotCardStore     IotCardStore,
    packageStore     PackageUsageStore,
    usageService     UsageService,
    stopResumeService StopResumeServiceInterface,
) *PollingCarddataHandler

方法列表Handle(ctx, task), processCard(ctx, cardID), collectUsageData(ctx, card)

关键要求

  • 删除原有 checkStopResumeshouldStopCardstopCardByUsageExhaustedresumeCardByPackageAvailable 函数
  • 改为调用 stopResumeService.EvaluateAndAct(ctx, card)Mi2无 carrierType/carrierID 参数)
  • 删除全部 13 处 h.db.Model() 直接 DB 操作
  • ⚠️ 必须完整迁移跨月流量边界检测逻辑(详见决策 13
    • 比较 Gateway 返回的月度总流量与 card.CurrentMonthStartDate 判断是否跨月
    • 跨月:保存 LastMonthTotalMB、重置 CurrentMonthUsageMB、更新 CurrentMonthStartDate
    • 同月:计算增量 delta = Gateway值 - LastMonthTotalMB
    • 记录流量历史到 data_usage_records

验证条件

  • 文件行数 < 300 行
  • 文件中不出现 h.db..db.Model()shouldStophasAvailable
  • 文件中包含跨月检测逻辑(搜索 CurrentMonthStartDate 应有匹配)
  • go build ./... 编译通过

4.3 新建 polling_package_handler.go< 200行

文件internal/task/polling_package_handler.go

从 polling_handler.go 提取HandlePackageCheck 相关逻辑

构造函数

// NewPollingPackageHandler 创建套餐检查任务处理器
func NewPollingPackageHandler(
    base             *PollingBase,
    gateway          GatewayClient,
    packageStore     PackageUsageStore,
    stopResumeService StopResumeServiceInterface,
) *PollingPackageHandler

方法列表Handle(ctx, task), processCard(ctx, cardID)

关键要求

  • 删除原有直接 Gateway 停复机调用(无重试),改为 EvaluateAndAct
  • 所有 DB 操作通过 packageStore 方法

验证条件

  • 文件行数 < 200 行
  • go build ./... 编译通过

4.4 新建 polling_protect_handler.go< 200行

文件internal/task/polling_protect_handler.go

从 polling_handler.go 提取HandleProtectConsistencyCheck 相关逻辑

构造函数

// NewPollingProtectHandler 创建保护期一致性检查任务处理器
func NewPollingProtectHandler(
    base             *PollingBase,
    gateway          GatewayClient,     // ⚠️ 保护期内需要直接调 Gateway 强制停复机
    iotCardStore     IotCardStore,
    stopResumeService StopResumeServiceInterface,
) *PollingProtectHandler

方法列表Handle(ctx, task), processCard(ctx, cardID)

处理流程(恢复原始保护期一致性语义)

processCard(ctx, cardID):
  1. 查 Store 获取卡信息
  2. 检查前置跳过条件:
     - 卡未实名real_name_status=0→ 直接 requeue跳过未实名卡不参与保护期逻辑
     - 卡未绑定设备is_standalone=true→ 直接 requeue跳过
  3. 读取设备保护期 Redis Key
     - stop 保护期 Keypolling:protect:stop:{deviceID}
     - start 保护期 Keypolling:protect:start:{deviceID}
  4. 根据保护期状态执行一致性修正:
     a. 有 stop 保护期 + 卡在线network_status=1
        → 直接调 gateway.StopCard不经过 EvaluateAndAct强制修正
        → 更新 DBnetwork_status=0stop_reason='protect'
     b. 有 start 保护期 + 卡停机network_status=0
        → 直接调 gateway.StartCard不经过 EvaluateAndAct强制修正
        → 更新 DBnetwork_status=1stop_reason=''
     c. 无保护期 Key保护期已结束
        → 调 stopResumeService.EvaluateAndAct(ctx, card)
        → 重新评估当前停复机状态(套餐/流量/实名)
     d. 有保护期 + 状态已一致 → 跳过(不调 Gateway不调 EvaluateAndAct
  5. 调 base.requeueCard 按间隔重新入队

关键要求

  • 步骤 4a/4b 必须直接调 Gateway(强制一致性,绕过三条件判断——保护期期间无论是否有套餐/流量都强制执行)
  • 步骤 4c 调 EvaluateAndAct(保护期结束后按正常停复机逻辑重新评估)
  • 两种调用路径不可混淆:保护期内=强制修正;保护期结束=重新评估

验证条件

  • 文件行数 < 200 行
  • 文件中同时包含 gateway.StopCard(或等价停机调用)和 EvaluateAndAct(两种路径)
  • go build ./... 编译通过

4.5 消除所有直接 DB 操作13处

文件:所有新建的 4 个 Handler 文件

变更:将原 polling_handler.go 中的 h.db.Model(&model.IotCard{}).Where(...).Update(...) 等直接 GORM 操作,替换为对应的 Store 方法:

  • iotCardStore.UpdateRealNameStatus(ctx, cardID, status)
  • iotCardStore.UpdateNetworkStatus(ctx, cardID, status, stopReason)
  • packageStore.UpdateUsage(ctx, packageID, usageMB)
  • 等(按实际 Store 接口定义)

验证条件

  • 在 4 个新 Handler 文件中 grep \.db\.,结果为空
  • go vet ./... 通过

4.6 提取 PollingBase 共享基类

文件internal/task/polling_base.go< 150行

提取的公共方法(所有 4 个 Handler 均需要):

  • acquireConcurrency(taskType string) bool
  • releaseConcurrency(taskType string)
  • getCardWithCache(ctx, cardID uint) (*model.IotCard, error)
  • updateCardCache(ctx, card *model.IotCard)
  • requeueCard(ctx, cardID uint, taskType string, interval time.Duration) error
  • getMatchedPollingInterval(card *model.IotCard) time.Duration

构造函数

// NewPollingBase 创建轮询共享基类
func NewPollingBase(
    redis        *redis.Client,
    queueMgr     *PollingQueueManager,
    configMgr    *PollingConfigManager,
) *PollingBase

⚠️ acquireConcurrency 并发满时必须 requeue关键正确性约束

Lua 脚本原子出队后,卡已从 Redis Sorted Set 中删除。若并发满时直接返回而不 requeue该卡将永久消失不再被轮询

正确用法:每个 Handler 在 acquireConcurrency 返回 false 时,必须先调 requeueCard 再返回:

// ✅ 正确:并发满时先 requeue再返回
if !base.acquireConcurrency(taskType) {
    base.logger.Debug("并发数已满,已重新入队", zap.Uint("card_id", cardID))
    return base.requeueCard(ctx, cardID, taskType, time.Now()) // 立即重入队,不延迟
}

// ❌ 错误:直接返回,卡从队列消失
if !base.acquireConcurrency(taskType) {
    base.logger.Debug("并发数已满,跳过本次执行", zap.Uint("card_id", cardID))
    return nil // 卡已被 Lua 原子删除,此处 return 后卡永久丢失!
}

验证条件

  • 4 个 Handler 文件中不出现重复的 acquireConcurrencygetCardWithCache 等函数定义
  • 4 个 Handler 中 acquireConcurrency 返回 false 的分支必须包含 requeueCard 调用grep 验证)
  • polling_base.go 行数 < 150 行

4.7 更新 pkg/queue/handler.go 注册 4 个新 Handler

文件pkg/queue/handler.go(或 Asynq Worker 注册入口)

变更

  • 注册 polling_realname_handlermux.HandleFunc(constants.TaskTypeRealnameCheck, realnameHandler.Handle)
  • 注册 polling_carddata_handlermux.HandleFunc(constants.TaskTypeCarddataCheck, carddataHandler.Handle)
  • 注册 polling_package_handlermux.HandleFunc(constants.TaskTypePackageCheck, packageHandler.Handle)
  • 注册 polling_protect_handlermux.HandleFunc(constants.TaskTypeProtectCheck, protectHandler.Handle)

验证条件

  • 4 种任务类型常量不变(向后兼容)
  • Asynq 任务提交保持 MaxRetry(0)(不可改为 MaxRetry(3),详见决策 12
  • go build ./cmd/worker/... 编译通过

4.8 删除旧 polling_handler.go

文件internal/task/polling_handler.go

前提4.1 4.7 全部完成且验证通过后执行。

验证条件

  • 文件不存在
  • go build ./... 编译通过(无遗留引用)

4.9 验证 4 种轮询任务均能正常触发和执行

验证方式Redis CLI + 日志观察

# 手动将测试卡推入各队列,验证任务被处理
redis-cli ZADD polling:shard:0:queue:realname $(date +%s) "testCardID"
redis-cli ZADD polling:shard:0:queue:carddata $(date +%s) "testCardID"
redis-cli ZADD polling:shard:0:queue:package $(date +%s) "testCardID"
redis-cli ZADD polling:shard:0:queue:protect $(date +%s) "testCardID"

# 观察 Worker 日志,应出现 4 种任务的处理日志
# 验证 Lua 脚本原子出队后队列中不再有该卡
redis-cli ZRANK polling:shard:0:queue:realname "testCardID"
# 预期返回 nil已被 Lua 脚本原子移除)

Phase 5Scheduler 精简 + CardInitializer + LifecycleService + 删除旧文件

⚠️ Phase 4 和 Phase 5 必须原子部署(同一次上线)。新 Handler 写分片键,新 Scheduler 读分片键,拆开部署会导致卡永久丢失轮询。详见 design.md 决策 8。

5.1 新建 initializer.go分片渐进式初始化

文件internal/polling/initializer.go< 250行

从 scheduler.go 提取progressiveInitinitCardsBatchinitCardPolling

变更

  • 入队改为调用 PollingQueueManager.Requeue()(分片路由)
  • 新增 enable_polling=false 过滤(跳过不参与轮询的卡)
  • 新增 GetProgress() 方法暴露进度

新增 DB 查询Store 方法):

  • store.ListIotCardsForPolling(ctx, offset, limit) — 返回 enable_polling=true 的卡

验证条件

  • 文件行数 < 250 行
  • scheduler.go 中不再出现 progressiveInitinitCardsBatch 字样
  • 初始化时 enable_polling=false 的卡不写入 Redis 队列Redis CLI 验证)
  • go build ./... 编译通过

5.2 精简 scheduler.go目标 < 250行

文件internal/polling/scheduler.go

保留

  • Start(ctx):启动初始化器、配置管理器、调度循环
  • scheduleLoop(ctx):每秒 ticker并行消费 N 个分片
  • processShardSchedule(ctx, shardID):背压检测 + 手动队列 + 定时队列出队
  • enqueueBatch(ctx, entries, taskType):批量推入 AsynqMaxRetry(0) 不变
  • ⚠️ 必须保留(决策 9
    • 每 10 秒调用 packageActivationHandler.HandlePackageActivationCheck(ctx)
    • 每 10 秒调用 dataResetHandler.HandleDataReset(ctx)

删除(已迁移到其他模块):

  • 所有 db.Find()db.Where() 等 DB 查询
  • loadConfigssyncConfigsToRedis(→ ConfigManager
  • progressiveInitinitCardsBatchinitCardPolling(→ Initializer
  • 所有 Callback 相关代码(→ LifecycleService + QueueManager
  • 任何直接的 ZRANGEBYSCOREZREMRANGEBYSCORE 操作

验证条件

  • 文件行数 < 250 行(含注释,因保留套餐过期/流量重置触发)
  • grep "\.db\." scheduler.go 结果为空
  • grep "loadConfigs\|progressiveInit" scheduler.go 结果为空
  • grep "HandlePackageActivationCheck\|HandleDataReset" scheduler.go 结果非空(确认保留)
  • go build ./... 编译通过

5.3 删除 callbacks.go

文件internal/polling/callbacks.go

前提:所有调用 callbacks.go 中方法的地方已改为调用 PollingQueueManager

验证条件

  • 文件不存在
  • go build ./... 编译通过(无遗留引用)

5.4 删除 api_callback.go

文件internal/polling/api_callback.go

前提:所有调用 api_callback.go 中方法的地方已改为调用 PollingQueueManager

验证条件

  • 文件不存在
  • go build ./... 编译通过(无遗留引用)

5.5 新建 lifecycle_service.go替代 callbacks.go 生命周期方法)

文件internal/polling/lifecycle_service.go< 200行

从 callbacks.go 提取OnCardCreatedOnBatchCardsCreatedOnCardStatusChangedOnCardEnabledOnCardDisabledOnCardDeletedLazyLoad

新组件

// PollingLifecycleService 卡生命周期轮询管理
// 实现 PollingCallback 接口,替代 callbacks.go 和 api_callback.go
type PollingLifecycleService struct {
    queueMgr   *PollingQueueManager
    configMgr  *PollingConfigManager
    cardStore  IotCardStore
    logger     *zap.Logger
}

关键要求

  • 实现 PollingCallback 接口(确保 iot_card/service.go 无需改动注入类型)
  • ⚠️ M3 修复OnCardCreated/OnCardEnabled/OnCardStatusChanged 在调用 queueMgr.Requeue() 前,必须检查卡所属设备的 enable_polling 状态:
    • 若卡绑定设备(card.DeviceID != nil),查询 device.EnablePolling,若为 false 则跳过入队(仅记录 Debug 日志)
    • 若卡无绑定设备,检查 card.EnablePolling,若为 false 则跳过入队
    • 目的:防止"禁用轮询"设置因生命周期事件被绕过,导致禁用后重新入队
  • OnCardCreated/OnCardEnabled:检查 enable_polling → 通过则 configMgr.MatchConfig(card)queueMgr.Requeue() 分片入队
  • OnCardStatusChangedqueueMgr.RemoveFromAllQueues() → 检查 enable_polling → 通过则重新匹配配置 → queueMgr.Requeue()
  • OnCardDeletedqueueMgr.OnCardDeleted() 移除所有队列 + 清理缓存(删除不需要检查 enable_polling
  • OnCardDisabledqueueMgr.RemoveFromAllQueues()(禁用,不需要检查 enable_polling
  • OnBatchCardsCreated:批量调用 OnCardCreated

验证条件

  • 文件行数 < 200 行
  • 实现了 PollingCallback 接口的所有方法
  • go build ./... 编译通过
  • grep PollingCallbackiot_card/service.go 中的注入类型未变
  • 设备 enable_polling=false 后,触发 OnCardStatusChanged 不会将卡重新入队Redis CLI 验证)

5.6 更新 MonitoringService 适配分片队列

文件internal/service/polling/monitoring_service.go

变更

  • 注入 PollingQueueManager(新增依赖)
  • GetOverview() 中队列深度查询改为 queueMgr.GetTotalQueueDepth(ctx, taskType)
  • GetQueueStatuses() 中队列指标改为聚合所有分片
  • 删除直接 s.redis.ZCard(ctx, constants.RedisPollingQueueXxxKey()) 调用

PollingQueueManager 新增方法Phase 3 的 queue_manager.go 补充):

// GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片)
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error)

验证条件

  • grep "RedisPollingQueueRealnameKey\|RedisPollingQueueCarddataKey\|RedisPollingQueuePackageKey" monitoring_service.go 结果为空(不再直接读旧 Key
  • 调用监控 API 返回的队列深度值与 redis-cli 手动聚合所有分片 ZCARD 之和一致
  • go build ./... 编译通过

5.7 更新 cmd/worker/main.go 和 cmd/api/main.go 启动流程

文件cmd/worker/main.gocmd/api/main.go

Worker 进程cmd/worker/main.go

变更:按以下顺序初始化新组件:

// 1. 基础组件
queueMgr := polling.NewPollingQueueManager(rdb, shardCount)

// 2. 配置管理器(依赖 DB
configMgr := polling.NewPollingConfigManager(store, rdb)
configMgr.Load(ctx)  // 启动时同步加载(阻塞,确保配置就绪后再启动 Scheduler
configMgr.Start(ctx) // 启动 goroutine 每 5 分钟定时刷新

// 3. 初始化器(依赖 DB、QueueMgr、ConfigMgr
initializer := polling.NewCardInitializer(store, queueMgr, configMgr)

// 4. 卡生命周期服务(替代 callbacks.go实现 PollingCallback 接口)
lifecycleService := polling.NewPollingLifecycleService(queueMgr, configMgr, iotCardStore, logger)

// 5. 共享基类(供 4 个 Handler 使用)
pollingBase := task.NewPollingBase(rdb, queueMgr, configMgr)

// 6. 4 个 Task Handler
realnameHandler := task.NewPollingRealnameHandler(pollingBase, gateway, iotCardStore, queueClient)
carddataHandler := task.NewPollingCarddataHandler(pollingBase, gateway, iotCardStore, packageStore, usageService, stopResumeService)
packageHandler  := task.NewPollingPackageHandler(pollingBase, gateway, packageStore, stopResumeService)
protectHandler  := task.NewPollingProtectHandler(pollingBase, gateway, iotCardStore, stopResumeService)

// 7. Scheduler依赖所有上层组件 保留套餐过期/流量重置触发器)
scheduler := polling.NewScheduler(queueMgr, configMgr, initializer, asynqClient,
    packageActivationHandler, dataResetHandler)
scheduler.Start(ctx)

API 进程cmd/api/main.go

背景API 进程处理卡创建、删除、状态变更等事件,需要 PollingLifecycleService(依赖 PollingConfigManager)来处理卡生命周期的队列操作。

变更:在 API 进程的 bootstrap 中同样初始化 ConfigManager 和 LifecycleService

// API 进程也需要配置管理器LifecycleService.OnCardCreated 需要 MatchConfig
apiConfigMgr := polling.NewPollingConfigManager(store, rdb)
apiConfigMgr.Load(ctx) // 启动时同步加载一次API 进程不需要 Scheduler但需要当前配置
apiConfigMgr.Start(ctx) // 启动 goroutine 定时刷新,确保新建卡时使用最新配置

// API 进程也需要 QueueManager处理卡删除、禁用等事件
apiQueueMgr := polling.NewPollingQueueManager(rdb, shardCount)

// API 进程的 LifecycleService实现 PollingCallback 接口)
apiLifecycleService := polling.NewPollingLifecycleService(apiQueueMgr, apiConfigMgr, iotCardStore, logger)

注意API 进程的 ConfigManager 必须调用 Start(ctx) 定时刷新否则管理员在后台修改轮询配置后API 进程的 MatchConfig 会使用过期配置,导致新建卡匹配到错误的轮询间隔。

验证条件

  • go build ./cmd/worker/... 编译通过
  • go build ./cmd/api/... 编译通过
  • Worker 进程启动日志出现「配置管理器已启动」「开始渐进式初始化」
  • API 进程启动日志出现「配置管理器已启动」(来自 apiConfigMgr
  • lifecycleServiceapiLifecycleService 分别注入到对应进程的 iot_card/service.go 作为 PollingCallback 实现

5.8 更新所有 PollingCallback 注入点

文件:所有注入 PollingCallback 接口的地方

变更:将注入的 PollingCallback 实现从旧 callbacks.go 的实例改为新 PollingLifecycleService 实例。注意:PollingCallback 接口本身不变,因为 PollingLifecycleService 实现了该接口。

需要更新的注入点:

  • cmd/worker/main.go:注入 lifecycleServiceiot_card/service.go
  • cmd/api/main.go:注入 lifecycleService 到 API 进程的 iot_card/service.goAPI 进程也需要处理卡删除等事件)
  • bootstrap/services.go:如有 bootstrap 层的注入逻辑,同步更新

⚠️ 不要变更的

  • StopResumeCallback 接口和注入点(与 PollingCallback 是不同接口,不在本次范围)
  • PollingCallback 接口定义(PollingLifecycleService 实现它)

验证条件

  • grep -r "callbacks\.New\|NewPollingCallbacks\|NewAPICallback" internal/ 结果为空(旧实现不再被引用)
  • grep -r "PollingCallback" internal/ 仍有结果(接口定义和注入类型保留)
  • go build ./... 编译通过

Phase 6轮询管控 APIenable_polling 接口)

6.1 新增 DTO

文件internal/model/dto/asset.go(或新建 internal/model/dto/polling_control.go

新增

// UpdateAssetPollingStatusRequest 更新资产轮询状态请求
type UpdateAssetPollingStatusRequest struct {
    // EnablePolling 是否启用轮询
    EnablePolling bool `json:"enable_polling" description:"是否启用轮询false 时卡不加入轮询队列"`
}

// UpdateAssetPollingStatusResponse 更新资产轮询状态响应
type UpdateAssetPollingStatusResponse struct {
    // AssetType 资产类型card/device
    AssetType string `json:"asset_type"`
    // AssetID 资产ID
    AssetID   uint   `json:"asset_id"`
    // EnablePolling 更新后的轮询状态
    EnablePolling bool `json:"enable_polling"`
}

验证条件

  • 两个 DTO 均有 description 标签(符合 dto-standards Skill 规范)
  • go build ./... 编译通过

6.2 新增 Store 方法

文件internal/store/postgres/device_store.go

新增方法

// UpdatePollingStatus 更新设备的轮询启用状态
// deviceID: 设备ID
// enablePolling: 是否启用轮询
func (s *DeviceStore) UpdatePollingStatus(ctx context.Context, deviceID uint, enablePolling bool) error

验证条件PostgreSQL MCP

-- 验证接口调用后 DB 更新
SELECT id, enable_polling FROM tb_device WHERE id = :deviceID;
-- 应反映接口传入的 enable_polling 值

6.3 新增 Service 方法(遵循 Handler → Service → Store 分层)

文件internal/service/polling/asset_polling_service.go(新建)

方法签名S2 修复card 类型委托给 IotCardService

// AssetPollingService 资产轮询管控服务
// 注意card 类型委托给已有的 IotCardService.UpdatePollingStatus避免绕过 DB 更新和 callback
type AssetPollingService struct {
    deviceStore    DeviceStoreInterface
    iotCardService IotCardServiceInterface  // 复用卡轮询状态更新(含 DB 写入 + callback
    pollingQueueMgr *PollingQueueManager
    logger         *zap.Logger
}

// UpdatePollingStatus 更新资产轮询状态
// assetType: "card" 或 "device"
// assetID: 资产ID
// enablePolling: 是否启用轮询
func (s *AssetPollingService) UpdatePollingStatus(ctx context.Context, assetType string, assetID uint, enablePolling bool) error

实现要点

  • assetType 校验:不在 [card, device] 时返回 errors.New(errors.CodeInvalidParam)
  • card 类型S2 修复):
    • 调用 iotCardService.UpdatePollingStatus(ctx, assetID, enablePolling)
    • 该方法已有完整实现DB 更新 tb_iot_card.enable_polling + pollingCallback 通知),不要重复实现
  • device 类型
    • 调用 deviceStore.UpdatePollingStatus(ctx, assetID, enablePolling) 更新 tb_device.enable_polling
    • enablePolling=false:查询设备下所有卡,逐一调用 pollingQueueMgr.RemoveFromAllQueues(ctx, cardID)
    • enablePolling=trueLifecycleService 的 OnCardEnabled 负责重新入队(通过 iotCardService 触发 callback

验证条件

  • card 类型调用后,tb_iot_card.enable_polling 已更新PostgreSQL MCP 验证)
  • device 类型调用后,tb_device.enable_polling 已更新
  • Service 不依赖 Fiber 上下文(纯业务逻辑)
  • go build ./... 编译通过

6.4 新增 Handler 方法

文件internal/handler/admin/asset.go

新增方法

// UpdatePollingStatus 更新资产轮询状态
// PATCH /api/admin/assets/:asset_type/:id/polling-status
func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error

实现要点

  • 解析路由参数 asset_typeid
  • 解析请求体 UpdateAssetPollingStatusRequest
  • 委托 assetPollingService.UpdatePollingStatus()Handler 不直接调用 Store 或 QueueManager
  • 返回 UpdateAssetPollingStatusResponse

验证条件

  • Handler 中不出现 store.queueMgr. 等直接调用(全部通过 Service
  • 参数校验失败返回 errors.New(errors.CodeInvalidParam),不泄露内部细节

6.5 注册路由

文件internal/routes/asset.go(或相关路由文件)

新增路由

assetGroup.Patch("/:asset_type/:id/polling-status", assetHandler.UpdatePollingStatus)
// 完整路径PATCH /api/admin/assets/:asset_type/:id/polling-status

验证条件

  • 路由注册到正确的路由组(需认证的 admin 路由)
  • go build ./cmd/api/... 编译通过

6.6 更新文档生成器

文件cmd/api/docs.gocmd/gendocs/main.go

变更(参考 api-routing Skill 规范):

// 在 Handlers 初始化中新增
handlers := &bootstrap.Handlers{
    // ... 现有 handlers
    AssetHandler: admin.NewAssetHandler(nil), // 新增 UpdatePollingStatus 方法
}

验证条件

  • go run cmd/gendocs/main.go 运行通过
  • 生成的 OpenAPI 文档中包含 PATCH /api/admin/assets/{asset_type}/{id}/polling-status 接口

Phase 7全面验证

7.1 DB 验证设备套餐停复机正确性PostgreSQL MCP

-- 找一张绑定设备且有设备套餐的卡
SELECT c.id AS card_id, c.device_id, c.network_status, c.stop_reason,
       pu.id AS pkg_id, pu.device_id AS pkg_device_id, pu.status AS pkg_status
FROM tb_iot_card c
JOIN tb_package_usage pu ON pu.device_id = c.device_id
WHERE c.device_id IS NOT NULL
  AND pu.status IN (0,1)
  AND c.network_status = 1
LIMIT 5;
-- 预期:这些卡 stop_reason 不为 'no_package'(修复前的误停机)

通过条件:查询返回行,所有卡均未被误停机(stop_reason IS NULL OR stop_reason = ''


7.2 DB 验证非行业卡未实名有套餐触发停机PostgreSQL MCP

-- 验证未实名普通卡被正确停机
SELECT id, card_category, real_name_status, network_status, stop_reason
FROM tb_iot_card
WHERE card_category != 'industry'
  AND real_name_status = 0
  AND network_status = 0
  AND stop_reason = 'not_realname'
LIMIT 10;
-- 预期:存在此类记录(说明新逻辑生效)

7.3 DB 验证行业卡未实名不停机PostgreSQL MCP

-- 验证行业卡不被未实名停机
SELECT COUNT(*) FROM tb_iot_card
WHERE card_category = 'industry'
  AND real_name_status = 0
  AND stop_reason = 'not_realname';
-- 预期COUNT = 0行业卡不应有此停机原因

7.4 Redis 验证protect 队列 Bug 修复

操作步骤

  1. 选取一张测试卡 ID如 testCardID=999
  2. 手动向 4 个队列写入:
    redis-cli ZADD polling:shard:7:queue:realname 9999999999 "999"
    redis-cli ZADD polling:shard:7:queue:carddata 9999999999 "999"
    redis-cli ZADD polling:shard:7:queue:package 9999999999 "999"
    redis-cli ZADD polling:shard:7:queue:protect 9999999999 "999"
    
  3. 触发 OnCardDeleted(ctx, 999) 或调用删除卡接口
  4. 验证:
    redis-cli ZRANK polling:shard:7:queue:protect "999"
    # 预期返回 nil已从 protect 队列移除,修复前这里会返回一个数字)
    

7.5 接口验证enable_polling 管控接口

步骤

  1. 调用 PATCH /api/admin/assets/device/1/polling-status Body: {"enable_polling": false}
  2. 验证 DBSELECT enable_polling FROM tb_device WHERE id=1 应为 false
  3. 验证 Redis设备下所有卡从分片队列移除
  4. 调用 PATCH /api/admin/assets/device/1/polling-status Body: {"enable_polling": true}
  5. 验证 DBenable_polling 恢复为 true

7.6 代码行数验证(合规性)

# 验证各文件行数
wc -l internal/polling/scheduler.go        # 应 < 200
wc -l internal/polling/queue_manager.go    # 应 < 200
wc -l internal/polling/config_manager.go   # 应 < 150
wc -l internal/polling/initializer.go      # 应 < 250
wc -l internal/task/polling_base.go        # 应 < 150
wc -l internal/task/polling_realname_handler.go  # 应 < 200
wc -l internal/task/polling_carddata_handler.go  # 应 < 300
wc -l internal/task/polling_package_handler.go   # 应 < 200
wc -l internal/task/polling_protect_handler.go   # 应 < 200含 Gateway 强制停复机逻辑)

# 验证旧文件已删除
ls internal/task/polling_handler.go        # 应不存在
ls internal/polling/callbacks.go           # 应不存在
ls internal/polling/api_callback.go        # 应不存在

7.7 Redis 原子性验证Lua 脚本出队)

# 准备测试数据向分片0的carddata队列写入10张卡score=当前时间,即立即到期)
for i in $(seq 1 10); do
    redis-cli ZADD polling:shard:0:queue:carddata $(date +%s) "test_card_$i"
done

# 同时写入5张未到期卡score=未来时间戳)
for i in $(seq 11 15); do
    redis-cli ZADD polling:shard:0:queue:carddata 9999999999 "test_card_$i"
done

# 验证 Lua 脚本只取到期卡,不触碰未来项
# Lua 脚本等效命令(实际由 PollingQueueManager 封装调用):
redis-cli EVAL "local r=redis.call('ZRANGEBYSCORE',KEYS[1],'-inf',ARGV[1],'LIMIT',0,tonumber(ARGV[2])) if #r>0 then redis.call('ZREM',KEYS[1],unpack(r)) end return r" 1 polling:shard:0:queue:carddata $(date +%s) 20

# 预期返回10张到期卡test_card_1 ~ test_card_10

redis-cli ZCARD polling:shard:0:queue:carddata
# 预期5只剩5张未到期卡到期卡已被原子移除

# 验证未到期卡未被触碰
redis-cli ZRANK polling:shard:0:queue:carddata "test_card_11"
# 预期:非 nil仍在队列中未被错误取出

# 验证到期卡已被移除
redis-cli ZRANK polling:shard:0:queue:carddata "test_card_1"
# 预期nil已被 Lua 脚本原子移除)

7.8 兼容性验证36 个已有接口零改动

验证方式:使用现有接口的 Hurl 测试文件或 Postman Collection逐一调用

  • 轮询配置管理7个增删改查配置
  • 手动触发6个手动触发各类轮询任务
  • 告警管理6个告警规则 CRUD
  • 监控统计4个队列深度、处理统计
  • 并发控制4个并发数调整
  • 数据清理9个清理各类历史数据

通过条件:所有接口返回 HTTP 200业务逻辑与重构前一致。


7.9 not_realname 停机迁移影响评估(已前移至 Phase 2 门控)

此任务已前移至 Phase 2 任务 2.0,作为 Phase 2 的强制前置门控。
请参见 ### 2.0 前置评估not_realname 停机迁移影响(门控任务),完成后方可继续 Phase 2 后续任务。