### Requirement: EvaluateAndAct——停复机统一入口 `StopResumeService` 新增 `EvaluateAndAct(ctx context.Context, card *model.IotCard) error` 方法,封装完整的停复机判断和执行逻辑。 > **签名说明**:删除原设计的 `carrierType string, carrierID uint` 参数(仅用于日志,放入签名会误导实现者)。 > 设备/单卡维度通过 `card.DeviceID` 推导,日志上下文通过函数内部的 `zap.Field` 记录。 **删除** `internal/task/polling_handler.go` 中的以下函数(迁移到 StopResumeService): - `checkStopResume` - `shouldStopCard` - `hasAvailablePackage`(旧版,被新 `hasValidPackage` 替代) - `stopCardByUsageExhausted` - `resumeCardByPackageAvailable` 所有 Task Handler(carddata、package、protect)在需要停复机决策时,统一调用 `stopResumeService.EvaluateAndAct()`。 #### Scenario: Task Handler 调用统一停复机入口 - **GIVEN** `carddata_handler.go` 完成流量数据采集和 DB 写入 - **WHEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card)` - **THEN** StopResumeService 完整执行停复机判断逻辑;Handler 不包含任何无条件停复机相关代码(`shouldStop`、`hasPackage` 等函数不出现在 Handler 文件中) #### Scenario: 停机原因按优先级记录 - **GIVEN** 卡在线,同时满足「无套餐」和「流量耗尽」和「未实名」三个条件 - **WHEN** `EvaluateAndAct` 执行 `checkStopReasons` - **THEN** 按优先级取最高:`no_package > traffic_exhausted > not_realname`;`stop_reason` 字段记录 `no_package`;停机后 DB 中该卡 `stop_reason='no_package'` #### Scenario: EvaluateAndAct 幂等 - **GIVEN** 卡已停机,`stop_reason='no_package'`,无套餐状态未变化 - **WHEN** 下次轮询再次调用 `EvaluateAndAct` - **THEN** 检测到卡已停机(`network_status=0`),进入复机判断分支;复机条件不满足(仍无套餐),不发起 Gateway 调用,返回 nil;DB 状态不变 --- ### Requirement: 停机条件——三种场景全面覆盖 卡在线(`network_status=1`)时,满足以下**任一**条件触发停机: **条件 A(no_package)**:无有效套餐 - 独立卡:`tb_package_usage` 中无 `iot_card_id=卡ID AND status IN (0,1)` 的记录 - 绑定设备的卡:`tb_package_usage` 中无 `device_id=设备ID AND status IN (0,1)` 的记录 - status=0(待激活)和 status=1(激活中)均视为有效套餐 **条件 B(traffic_exhausted)**:虚流量耗尽 - 活跃套餐 `status=2`(系统标记为虚流量耗尽),或 - 活跃套餐 `data_usage_mb >= data_limit_mb`(且 `data_limit_mb > 0`,防止无限流量卡误判) **条件 C(not_realname)**:非行业卡且未实名 - `card_category != 'industry'` 且 `real_name_status = 0` #### Scenario: 无套餐触发停机(独立卡) - **GIVEN** cardID=100 的独立卡(无 device_id),`network_status=1` - **WHEN** `tb_package_usage` 中无任何 `iot_card_id=100 AND status IN(0,1)` 的记录 - **THEN** `checkStopReasons` 返回 `["no_package"]`;发起 Gateway 停机调用(3次重试);DB 更新 `network_status=0, stop_reason='no_package'` #### Scenario: 无套餐触发停机(设备卡,修复Bug1) - **GIVEN** cardID=200 的卡绑定 deviceID=50,`network_status=1` - **WHEN** `tb_package_usage` 中无 `iot_card_id=200` 的记录,但有 `device_id=50 AND status=1` 的记录(购买了设备套餐) - **THEN** `hasValidPackage` 检测到设备套餐存在,返回 true;不触发停机;卡保持在线状态(修复之前只查 iot_card_id 导致误停机的 Bug) #### Scenario: 流量耗尽触发停机 - **GIVEN** 卡在线,活跃套餐 `data_limit_mb=1000, data_usage_mb=1001` - **WHEN** `isTrafficExhausted` 检测 - **THEN** `data_usage_mb(1001) >= data_limit_mb(1000)` 条件满足;返回 true;触发停机,`stop_reason='traffic_exhausted'` #### Scenario: 套餐 status=2 触发停机 - **GIVEN** 卡在线,活跃套餐 `status=2`(系统已标记虚流量耗尽) - **WHEN** `isTrafficExhausted` 检测 - **THEN** 检测到 `status=2`;返回 true;触发停机,`stop_reason='traffic_exhausted'` #### Scenario: 无限流量套餐不误判流量耗尽 - **GIVEN** 卡在线,活跃套餐 `data_limit_mb=0`(无限流量),`data_usage_mb=9999` - **WHEN** `isTrafficExhausted` 检测 - **THEN** `data_limit_mb=0` 时跳过用量比较(防止无限流量卡被误停机);返回 false;不触发停机 #### Scenario: 未实名普通卡触发停机 - **GIVEN** 卡在线,`card_category='normal'`,`real_name_status=0`,有有效套餐且流量未耗尽 - **WHEN** `checkStopReasons` 检测条件 C - **THEN** `!isRealnameOK(card)` 为 true;触发停机,`stop_reason='not_realname'` #### Scenario: 行业卡无需实名不停机 - **GIVEN** 卡在线,`card_category='industry'`,`real_name_status=0`,有有效套餐 - **WHEN** `checkStopReasons` 检测 - **THEN** `isRealnameOK(card)` 返回 true(行业卡豁免实名要求);不触发停机;卡保持在线 --- ### Requirement: 复机条件——全部满足才复机 卡停机(`network_status=0`)时,以下**全部**条件满足才触发自动复机: 1. `stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')`(排除手动停机、运营商停机等其他原因) 2. 有有效套餐且流量未耗尽(`hasValidPackage=true` 且 `isTrafficExhausted=false`) 3. 行业卡 OR 已实名(`card_category='industry'` OR `real_name_status=1`) #### Scenario: 购买套餐后自动复机 - **GIVEN** cardID=300 因 `no_package` 停机,`stop_reason='no_package'`,现在购买了套餐(status=1) - **WHEN** 下次轮询执行 `EvaluateAndAct` - **THEN** 三个复机条件全部满足(stop_reason合规 + 有套餐 + 已实名或行业卡);发起 Gateway 复机调用(3次重试);DB 更新 `network_status=1, stop_reason=''` #### Scenario: 手动停机不自动复机 - **GIVEN** 卡 `stop_reason='manual'`(管理员手动停机) - **WHEN** 该卡购买了套餐,完成实名认证,轮询执行 `EvaluateAndAct` - **THEN** 条件1不满足(`'manual'` 不在 IN 列表中);不触发自动复机;卡保持停机状态;需管理员手动复机 #### Scenario: 流量耗尽停机后购买新套餐复机 - **GIVEN** 卡因 `traffic_exhausted` 停机,`stop_reason='traffic_exhausted'`;旧套餐已过期(status=3),新购套餐 status=1,usage=0 - **WHEN** 轮询执行 `EvaluateAndAct` - **THEN** 条件1满足(traffic_exhausted);条件2满足(新套餐有效,usage= data_limit_mb` - **WHEN** 任一绑定卡触发 `EvaluateAndAct`,检测到设备套餐流量耗尽 - **THEN** 调用 `stopDeviceCards(ctx, deviceID=10, "traffic_exhausted")`;3 张卡均发起 Gateway 停机调用;3 张卡均更新 `stop_reason='traffic_exhausted'` #### Scenario: 设备停机调用 Gateway 带3次重试 - **GIVEN** 设备下有 3 张卡需要停机,Gateway 第一次调用返回超时 - **WHEN** `stopCardWithRetry` 执行 - **THEN** 自动重试至多 3 次;至少 1 次成功则记录成功,最终失败则记录 Error 日志「卡停机失败: cardID=xxx, error=xxx」 #### Scenario: 购买设备套餐后全卡复机 - **GIVEN** deviceID=10 下 3 张卡均因 `traffic_exhausted` 停机,购买新套餐后 status=1 - **WHEN** 轮询执行 `EvaluateAndAct`,`shouldResume` 返回 true - **THEN** `resumeDeviceCards(ctx, deviceID=10)` 被调用;检查所有 `stop_reason IN(...)` 的停机卡;3 张卡均满足 `isRealnameOK`(均已实名);3 张卡均触发复机 #### Scenario: 设备复机跳过未实名普通卡 - **GIVEN** deviceID=20 下有 3 张卡:card-A(已实名)、card-B(已实名)、card-C(`card_category='normal'`, `real_name_status=0`) - **WHEN** 设备复机条件满足,执行 `resumeDeviceCards` - **THEN** card-A 和 card-B 触发复机(各执行 Gateway 复机调用);card-C 因 `isRealnameOK=false` 被跳过(保持停机);card-C 的 `stop_reason` 自动更新为 `not_realname` #### Scenario: 设备复机中单卡 Gateway 失败不阻止其他卡 - **GIVEN** 设备下 3 张卡需要复机,card-B 的 Gateway 复机调用失败(含重试) - **WHEN** `resumeDeviceCards` 遍历执行 - **THEN** card-A 和 card-C 正常复机;card-B 记录 Error 日志并跳过,不影响其他卡的复机;`resumeDeviceCards` 整体不返回 error(尽力复机语义) --- ### Requirement: 设备维度操作幂等锁——防止并发重复 Gateway 调用 设备下多张卡分布在不同分片队列,同一调度周期内可能同时触发 `EvaluateAndAct`,进而并发调用 `stopDeviceCards` / `resumeDeviceCards`,导致同一张卡被 Gateway 重复停/复机。 `stopDeviceCards` 和 `resumeDeviceCards` 均在执行前通过 Redis `SetNX` 获取设备操作锁(`polling:device:op_lock:{deviceID}`,TTL 30 秒)。获取失败时视为"其他协程正在处理",直接跳过。 #### Scenario: 多卡并发触发不重复调 Gateway - **GIVEN** deviceID=10 下有 card-A(shard 3)和 card-B(shard 7),同一调度周期被并发出队 - **WHEN** card-A 和 card-B 同时触发 `EvaluateAndAct` → 均尝试调用 `stopDeviceCards(deviceID=10)` - **THEN** 第一个调用获取设备锁成功,执行完整的停机流程;第二个调用 `SetNX` 返回 false,记录 Debug 日志「设备停机操作已在进行中,跳过: deviceID=10」,直接返回;设备下各卡只被 Gateway 停机一次 #### Scenario: 设备锁 TTL 超时后可重新获取 - **GIVEN** 设备操作锁已过期(上次操作完成后 `Del` 释放,或 TTL 30 秒到期) - **WHEN** 下一轮轮询再次触发 `stopDeviceCards` - **THEN** `SetNX` 成功获取锁,正常执行停机流程