【核心变更】
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>
59 KiB
任务清单(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_interval(DB 操作)
- 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.go(Phase 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 新增 DTO(UpdateAssetPollingStatusRequest/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- 存量数据全部为
TRUE(SELECT 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 null和default: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
验证条件:
- 三个常量均有中文注释说明含义和取值依据
PollingShardCount、PollingBackpressureThreshold在PollingQueueManager和Scheduler中引用(而非硬编码 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 执行迁移并验证
操作:
- 运行迁移命令(参考
db-migrationSkill 中的迁移规范) - 用 PostgreSQL MCP 验证列已创建
验证 SQL(PostgreSQL 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=3600,carddata_check_interval=3600,status=1
通过条件:查询返回停机卡配置,且 realname_check_interval IS NOT NULL(停机未实名卡必须能被实名检查,否则无法复机)。
1.8 为现有活跃配置添加 protect_check_interval(M2 修复)
背景: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 2:StopResumeService 重写(停复机逻辑统一)
⚠️ Phase 2 前置门控(必须在 2.1 之前完成):Phase 2 新增
not_realname停机条件,部署后所有"在线+未实名+普通卡"将在下一轮询周期被批量停机。必须先执行影响评估并与业务方确认,再继续后续任务。
2.0 前置评估:not_realname 停机迁移影响(门控任务)
⚠️ 此任务必须在 Phase 2 实施前完成,结果决定是否继续 Phase 2 或调整策略。
评估 SQL(PostgreSQL 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_mb(data_limit_mb > 0)
func (s *Service) isTrafficExhausted(ctx context.Context, card *model.IotCard) (bool, error)
实现要点:
- 查询活跃套餐(status=1),同样区分 device_id 和 iot_card_id
status=2直接返回 truedata_limit_mb > 0 && data_usage_mb >= data_limit_mb返回 truedata_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 3:PollingQueueManager + 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])) -- 分批 ZREM:Lua 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 个任务类型,执行 ZREMEnqueueManual:使用redis.RPush(ctx, manualKey, cardID)推入 ListOnCardDeleted:调RemoveFromAllQueues+ 清理卡信息缓存(redis.Del(ctx, cacheKey))GetQueueDepth:使用redis.ZCard(ctx, shardKey).Result()
Key 生成:统一调用 constants.RedisPollingShardQueueKey(shardID, taskType)
验证条件:
- 文件行数 < 200 行
DequeueReady使用 Lua 脚本(redis.NewScript)且采用分批 ZREM,不使用 ZPOPMINRemoveFromAllQueues覆盖 4 个任务类型(含 protect)go build ./...编译通过
3.2 新建 config_manager.go(配置管理)
文件:internal/polling/config_manager.go(< 150行)
从 scheduler.go 提取:loadConfigs、syncConfigsToRedis、matchConfig、matchConfigConditions、getCardCondition
新增:
- 内存缓存(
sync.RWMutex保护的[]PollingConfig切片) Start(ctx)方法:goroutine 每 5 分钟调用Load(ctx)刷新Load(ctx)加载失败时保留原缓存不清空
⚠️ 必须修复 getCardCondition(M1 修复):
原 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中不再出现loadConfigs、syncConfigsToRedis字样getCardCondition对NetworkStatus=0的卡返回"suspended"(编写单函数验证或日志观察)go build ./...编译通过
Phase 4:Task 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 小时)
调用时机:UpdateRealNameStatusStore 写入成功后,从 Store 重新加载卡信息(确保状态最新),再调 EvaluateAndAct - 文件中不出现无条件的
stopCard、resumeCard(仅允许通过 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)
关键要求:
- 删除原有
checkStopResume、shouldStopCard、stopCardByUsageExhausted、resumeCardByPackageAvailable函数 - 改为调用
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表
- 比较 Gateway 返回的月度总流量与
验证条件:
- 文件行数 < 300 行
- 文件中不出现
h.db.、.db.Model()、shouldStop、hasAvailable - 文件中包含跨月检测逻辑(搜索
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 保护期 Key(polling:protect:stop:{deviceID})
- start 保护期 Key(polling:protect:start:{deviceID})
4. 根据保护期状态执行一致性修正:
a. 有 stop 保护期 + 卡在线(network_status=1)
→ 直接调 gateway.StopCard(不经过 EvaluateAndAct,强制修正)
→ 更新 DB:network_status=0,stop_reason='protect'
b. 有 start 保护期 + 卡停机(network_status=0)
→ 直接调 gateway.StartCard(不经过 EvaluateAndAct,强制修正)
→ 更新 DB:network_status=1,stop_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) boolreleaseConcurrency(taskType string)getCardWithCache(ctx, cardID uint) (*model.IotCard, error)updateCardCache(ctx, card *model.IotCard)requeueCard(ctx, cardID uint, taskType string, interval time.Duration) errorgetMatchedPollingInterval(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 文件中不出现重复的
acquireConcurrency、getCardWithCache等函数定义 - 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_handler:mux.HandleFunc(constants.TaskTypeRealnameCheck, realnameHandler.Handle) - 注册
polling_carddata_handler:mux.HandleFunc(constants.TaskTypeCarddataCheck, carddataHandler.Handle) - 注册
polling_package_handler:mux.HandleFunc(constants.TaskTypePackageCheck, packageHandler.Handle) - 注册
polling_protect_handler:mux.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 5:Scheduler 精简 + CardInitializer + LifecycleService + 删除旧文件
⚠️ Phase 4 和 Phase 5 必须原子部署(同一次上线)。新 Handler 写分片键,新 Scheduler 读分片键,拆开部署会导致卡永久丢失轮询。详见 design.md 决策 8。
5.1 新建 initializer.go(分片渐进式初始化)
文件:internal/polling/initializer.go(< 250行)
从 scheduler.go 提取:progressiveInit、initCardsBatch、initCardPolling
变更:
- 入队改为调用
PollingQueueManager.Requeue()(分片路由) - 新增
enable_polling=false过滤(跳过不参与轮询的卡) - 新增
GetProgress()方法暴露进度
新增 DB 查询(Store 方法):
store.ListIotCardsForPolling(ctx, offset, limit)— 返回enable_polling=true的卡
验证条件:
- 文件行数 < 250 行
scheduler.go中不再出现progressiveInit、initCardsBatch字样- 初始化时
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):批量推入 Asynq(MaxRetry(0)不变)- ⚠️ 必须保留(决策 9):
- 每 10 秒调用
packageActivationHandler.HandlePackageActivationCheck(ctx) - 每 10 秒调用
dataResetHandler.HandleDataReset(ctx)
- 每 10 秒调用
删除(已迁移到其他模块):
- 所有
db.Find()、db.Where()等 DB 查询 loadConfigs、syncConfigsToRedis(→ ConfigManager)progressiveInit、initCardsBatch、initCardPolling(→ Initializer)- 所有 Callback 相关代码(→ LifecycleService + QueueManager)
- 任何直接的
ZRANGEBYSCORE、ZREMRANGEBYSCORE操作
验证条件:
- 文件行数 < 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 提取:OnCardCreated、OnBatchCardsCreated、OnCardStatusChanged、OnCardEnabled、OnCardDisabled、OnCardDeleted、LazyLoad
新组件:
// 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()分片入队OnCardStatusChanged:queueMgr.RemoveFromAllQueues()→ 检查 enable_polling → 通过则重新匹配配置 →queueMgr.Requeue()OnCardDeleted:queueMgr.OnCardDeleted()移除所有队列 + 清理缓存(删除不需要检查 enable_polling)OnCardDisabled:queueMgr.RemoveFromAllQueues()(禁用,不需要检查 enable_polling)OnBatchCardsCreated:批量调用OnCardCreated
验证条件:
- 文件行数 < 200 行
- 实现了
PollingCallback接口的所有方法 go build ./...编译通过- grep
PollingCallback在iot_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.go、cmd/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)
lifecycleService和apiLifecycleService分别注入到对应进程的iot_card/service.go作为PollingCallback实现
5.8 更新所有 PollingCallback 注入点
文件:所有注入 PollingCallback 接口的地方
变更:将注入的 PollingCallback 实现从旧 callbacks.go 的实例改为新 PollingLifecycleService 实例。注意:PollingCallback 接口本身不变,因为 PollingLifecycleService 实现了该接口。
需要更新的注入点:
cmd/worker/main.go:注入lifecycleService到iot_card/service.gocmd/api/main.go:注入lifecycleService到 API 进程的iot_card/service.go(API 进程也需要处理卡删除等事件)bootstrap/services.go:如有 bootstrap 层的注入逻辑,同步更新
⚠️ 不要变更的:
StopResumeCallback接口和注入点(与PollingCallback是不同接口,不在本次范围)PollingCallback接口定义(PollingLifecycleService实现它)
验证条件:
grep -r "callbacks\.New\|NewPollingCallbacks\|NewAPICallback" internal/结果为空(旧实现不再被引用)grep -r "PollingCallback" internal/仍有结果(接口定义和注入类型保留)go build ./...编译通过
Phase 6:轮询管控 API(enable_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=true:LifecycleService 的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_type和id - 解析请求体
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.go 和 cmd/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 修复
操作步骤:
- 选取一张测试卡 ID(如 testCardID=999)
- 手动向 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" - 触发
OnCardDeleted(ctx, 999)或调用删除卡接口 - 验证:
redis-cli ZRANK polling:shard:7:queue:protect "999" # 预期返回 nil(已从 protect 队列移除,修复前这里会返回一个数字)
7.5 接口验证:enable_polling 管控接口
步骤:
- 调用
PATCH /api/admin/assets/device/1/polling-statusBody:{"enable_polling": false} - 验证 DB:
SELECT enable_polling FROM tb_device WHERE id=1应为 false - 验证 Redis:设备下所有卡从分片队列移除
- 调用
PATCH /api/admin/assets/device/1/polling-statusBody:{"enable_polling": true} - 验证 DB:
enable_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 后续任务。