## 任务清单(openspec 跟踪用) - [x] 1.1 创建 DB 迁移文件(tb_device 新增 enable_polling 字段) - [x] 1.2 model/device.go 新增 EnablePolling 字段 - [x] 1.3 pkg/constants/iot.go 新增停机原因常量 - [x] 1.4 pkg/constants/polling.go 新增轮询系统常量 - [x] 1.5 pkg/constants/redis.go 新增分片队列 Key 函数和设备锁 Key 函数 - [x] 1.6 执行迁移并验证(DB 操作) - [x] 1.7 新增 "suspended" 轮询配置(DB 操作) - [x] 1.8 为现有活跃配置添加 protect_check_interval(DB 操作) - [x] 1.9 清理 real_name 存量配置条件(DB 操作) - [x] 2.0 前置评估:not_realname 停机迁移影响(门控任务) - [x] 2.1 新增 hasValidPackage 方法(修复设备套餐 Bug) - [x] 2.2 新增 isTrafficExhausted 方法 - [x] 2.3 新增 isRealnameOK 方法 - [x] 2.4 新增 checkStopReasons 方法 - [x] 2.5 新增 EvaluateAndAct 统一入口方法 - [x] 2.6 修改 stopCardWithRetry 接收 stopReason 参数 - [x] 2.7 修改 resumeSingleCard:复机条件使用新逻辑 - [x] 2.8 修改 stopDeviceCards 和 resumeDeviceCards:加设备维度幂等锁 - [x] 2.9 定义 StopResumeServiceInterface 接口 - [x] 2.10 删除旧 hasAvailablePackage 方法 - [x] 2.11 PostgreSQL MCP 验证设备套餐停复机正确性 - [x] 3.1 新建 queue_manager.go(统一 Redis 队列操作) - [x] 3.2 新建 config_manager.go(配置管理) - [x] 4.1 新建 polling_realname_handler.go - [x] 4.2 新建 polling_carddata_handler.go - [x] 4.3 新建 polling_package_handler.go - [x] 4.4 新建 polling_protect_handler.go - [x] 4.5 消除所有直接 DB 操作(13处) - [x] 4.6 提取 PollingBase 共享基类 - [x] 4.7 更新 pkg/queue/handler.go 注册 4 个新 Handler - [x] 4.8 删除旧 polling_handler.go(Phase 4+5 原子部署完成) - [x] 4.9 验证 4 种轮询任务均能正常触发和执行 - [x] 5.1 新建 initializer.go(分片渐进式初始化) - [x] 5.2 精简 scheduler.go(目标 < 250行,实际 227行) - [x] 5.3 删除 callbacks.go - [x] 5.4 删除 api_callback.go - [x] 5.5 新建 lifecycle_service.go(替代 callbacks.go) - [x] 5.6 更新 MonitoringService 适配分片队列 - [x] 5.7 更新 cmd/worker/main.go 启动流程(接入新轮询组件) - [x] 5.8 更新 PollingCallback 注入点(bootstrap/services.go) - [x] 6.1 新增 DTO(UpdateAssetPollingStatusRequest/Response) - [x] 6.2 新增 Store 方法(DeviceStore.UpdatePollingStatus) - [x] 6.3 新增 Service 方法(AssetPollingService) - [x] 6.4 新增 Handler 方法(AssetHandler.UpdatePollingStatus) - [x] 6.5 注册路由 - [x] 6.6 更新文档生成器 - [x] 7.1 DB 验证:设备套餐停复机正确性 - [x] 7.2 DB 验证:非行业卡未实名有套餐触发停机 - [x] 7.3 DB 验证:行业卡未实名不停机 - [x] 7.4 Redis 验证:protect 队列 Bug 修复 - [x] 7.5 接口验证:enable_polling 管控接口 - [x] 7.6 代码行数验证 - [x] 7.7 Redis 原子性验证 - [x] 7.8 兼容性验证:36 个已有接口零改动 - [x] 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` **内容**: ```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 标签: ```go // 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` 等常量附近): ```go // StopReasonNoPackage 停机原因:无有效套餐 StopReasonNoPackage = "no_package" // StopReasonNotRealname 停机原因:非行业卡且未完成实名认证 StopReasonNotRealname = "not_realname" ``` **验证条件**: - 两个常量均有中文注释 - 与现有 `StopReason*` 常量在同一分组下 - 常量值全小写下划线格式 --- ### 1.4 pkg/constants/polling.go 新增轮询系统常量 **文件**:`pkg/constants/polling.go`(若不存在则新建,否则追加) **新增常量**: ```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` **新增函数**: ```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 验证列已创建 **验证 SQL(PostgreSQL MCP 执行)**: ```sql -- 验证列存在 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,新增"停机卡轮询"配置: ```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)**: ```sql 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 保持一致): ```sql -- 为已激活卡添加保护期检查(每小时) 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)**: ```sql 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)** ```sql -- 查看现有 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 配置(若存在)** ```sql -- 将 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)**: ```sql -- 确认无残留 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 执行)**: ```sql -- 统计"在线 + 未实名 + 普通卡"(部署后将被停机的卡) 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` **方法签名**: ```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)**: ```sql -- 场景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` **方法签名**: ```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` 直接返回 true - `data_limit_mb > 0 && data_usage_mb >= data_limit_mb` 返回 true - `data_limit_mb == 0`(无限流量)不判断用量,返回 false - 无活跃套餐时返回 false **验证条件(PostgreSQL MCP)**: ```sql -- 验证流量耗尽场景 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` **方法签名**: ```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)**: ```sql -- 验证行业卡数据 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` **方法签名**: ```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 参数)**: ```go // 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)**: ```sql -- 验证停机后字段更新 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)**: ```sql -- 验证停机原因写入 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)**: ```sql -- 验证 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 日志,继续处理其他卡) ```go // 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)**: ```sql -- 验证设备下停机卡状态 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)**: ```sql -- 验证设备下停机卡状态 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 的构造函数无法编译。 **新增接口**: ```go // 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 工具): ```sql -- 验证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 的到期卡): ```go 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 个任务类型,执行 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 提取**:`loadConfigs`、`syncConfigsToRedis`、`matchConfig`、`matchConfigConditions`、`getCardCondition` **新增**: - 内存缓存(`sync.RWMutex` 保护的 `[]PollingConfig` 切片) - `Start(ctx)` 方法:goroutine 每 5 分钟调用 `Load(ctx)` 刷新 - `Load(ctx)` 加载失败时保留原缓存不清空 **⚠️ 必须修复 getCardCondition(M1 修复)**: 原 `getCardCondition` 从不返回 `"suspended"`,导致停机卡匹配到 carddata/package 间隔为 null 的配置,无法自动复机。 新实现**最先**判断网络状态: ```go // 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` 相关逻辑 **构造函数**: ```go // 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 - 文件中**不出现**无条件的 `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` 相关逻辑 **构造函数**: ```go // 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` 表 **验证条件**: - 文件行数 < 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` 相关逻辑 **构造函数**: ```go // 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` 相关逻辑 **构造函数**: ```go // 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) 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` **构造函数**: ```go // NewPollingBase 创建轮询共享基类 func NewPollingBase( redis *redis.Client, queueMgr *PollingQueueManager, configMgr *PollingConfigManager, ) *PollingBase ``` **⚠️ acquireConcurrency 并发满时必须 requeue(关键正确性约束)**: Lua 脚本原子出队后,卡已从 Redis Sorted Set 中删除。若并发满时直接返回而不 requeue,该卡将永久消失(不再被轮询)。 **正确用法**:每个 Handler 在 `acquireConcurrency` 返回 false 时,必须先调 `requeueCard` 再返回: ```go // ✅ 正确:并发满时先 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 + 日志观察 ```bash # 手动将测试卡推入各队列,验证任务被处理 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)` **删除**(已迁移到其他模块): - 所有 `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` **新组件**: ```go // 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 补充): ```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) **变更**:按以下顺序初始化新组件: ```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: ```go // 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.go` - `cmd/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`) **新增**: ```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` **新增方法**: ```go // UpdatePollingStatus 更新设备的轮询启用状态 // deviceID: 设备ID // enablePolling: 是否启用轮询 func (s *DeviceStore) UpdatePollingStatus(ctx context.Context, deviceID uint, enablePolling bool) error ``` **验证条件(PostgreSQL MCP)**: ```sql -- 验证接口调用后 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)**: ```go // 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` **新增方法**: ```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`(或相关路由文件) **新增路由**: ```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 规范): ```go // 在 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) ```sql -- 找一张绑定设备且有设备套餐的卡 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) ```sql -- 验证未实名普通卡被正确停机 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) ```sql -- 验证行业卡不被未实名停机 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 个队列写入: ```bash 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. 验证: ```bash 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. 验证 DB:`SELECT 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. 验证 DB:`enable_polling` 恢复为 true --- ### 7.6 代码行数验证(合规性) ```bash # 验证各文件行数 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 脚本出队) ```bash # 准备测试数据:向分片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 后续任务。