# Spec: 自动停复机机制 ## 业务背景 ### 为什么需要自动停复机 **现状问题**: - 当前系统流量耗尽后手动停机,用户购买加油包后需手动复机 - 停复机时机不精确,可能出现流量已耗尽但仍可上网的情况 - 用户购买加油包后不知道需要复机,导致流量无法使用 **业务目标**: - 所有套餐流量耗尽时自动停机,避免超额使用 - 购买新套餐(正式/加油包)后自动复机,提升用户体验 - 停复机延迟 < 2分钟,确保及时性 --- ## 业务规则 ### 1. 停机触发条件 ``` 停机条件 = (所有生效套餐流量 = 0) AND (卡当前状态 = active) ``` **详细逻辑**: ```sql -- 检查是否有剩余流量 SELECT COUNT(*) FROM tb_package_usage WHERE iot_card_id = ? AND status = 1 -- 生效中 AND data_usage_mb < data_limit_mb; -- 如果 COUNT = 0,触发停机 ``` ### 2. 复机触发条件 ``` 复机条件 = (存在可用流量套餐) AND (卡当前状态 = stopped) ``` **可用流量套餐定义**: ```sql status='active' AND remaining_data_amount > 0 ``` ### 3. 停复机延迟要求 - **目标延迟**:< 2分钟(从触发条件到完成停复机) - **实现方式**:流量检查后同步调用停复机接口(不走异步队列) ### 4. 运营商接口容错 - 停机/复机失败时: - 重试3次(间隔 1s, 2s, 4s) - 仍失败:记录错误日志,人工介入 - **不阻塞**套餐激活流程 --- ## ADDED Requirements ### Requirement: 流量耗尽自动停机 系统 SHALL 在主套餐和所有加油包流量都用完时,调用运营商接口停机。 #### Scenario: 所有套餐流量耗尽触发停机 - **GIVEN** 卡 C1 有主套餐(剩余0MB)和加油包(剩余0MB),卡状态为 active - **WHEN** 轮询系统检查停机条件 - **THEN** 系统执行停机操作: 1. 调用运营商停机接口 2. 更新 IotCard.network_status=0(已停机) 3. 记录 stopped_at 时间 4. 记录 stop_reason="traffic_exhausted" 5. 记录操作日志 #### Scenario: 有剩余流量时不停机 - **GIVEN** 主套餐流量用完,但加油包剩余1GB - **WHEN** 轮询系统检查停机条件 - **THEN** 系统查询到有剩余流量,不触发停机 #### Scenario: 停机接口调用失败重试 - **GIVEN** 所有套餐流量用完,需要停机 - **WHEN** 调用运营商停机接口失败(网络超时) - **THEN** 系统重试3次(间隔1s/2s/4s) - **AND** 3次都失败后记录 Error 日志,告警通知运维 #### Scenario: 停机幂等性 - **GIVEN** 卡已停机(network_status=0) - **WHEN** 轮询系统再次检测到流量用完 - **THEN** 系统检测到已停机,跳过停机调用 ### Requirement: 购买套餐自动复机 系统 SHALL 在购买新套餐(正式/加油包)激活后,自动调用运营商接口复机。 #### Scenario: 购买加油包自动复机 - **GIVEN** 卡 C1 已停机(network_status=0,stopped_at=2026-02-10 10:00) - **WHEN** 用户购买加油包,激活成功(status=active) - **THEN** 系统执行复机操作: 1. 调用运营商复机接口 2. 更新 IotCard.network_status=1(正常) 3. 记录 resumed_at 时间 4. 清空 stopped_at 5. 记录操作日志 #### Scenario: 复机幂等性 - **GIVEN** 卡 C1 已停机 - **WHEN** 用户快速购买2个加油包 - **THEN** 第1个加油包激活 → 触发复机成功 - **AND** 第2个加油包激活 → 检测到已是 active 状态,跳过复机 - **AND** 运营商复机接口调用仅1次 #### Scenario: 购买主套餐自动复机 - **GIVEN** 卡 C1 已停机,主套餐过期 - **WHEN** 用户购买新主套餐,激活成功 - **THEN** 系统自动触发复机 #### Scenario: 复机失败容错 - **GIVEN** 卡已停机 - **WHEN** 购买加油包激活,但运营商复机接口返回失败 - **THEN** 系统重试3次 - **AND** 仍失败后: - 套餐激活成功(status=active) - 卡状态仍为 stopped - 错误日志已记录 - 告警通知运维 ### Requirement: 复机延迟 < 2分钟 系统 SHALL 确保从套餐激活到卡复机完成的延迟 < 2分钟。 #### Scenario: 复机延迟达标 - **GIVEN** 加油包在 2026-02-10 10:00:00 激活成功 - **WHEN** 系统同步调用复机接口 - **THEN** 复机完成时间 < 2026-02-10 10:02:00(延迟 < 2分钟) #### Scenario: 复机失败后重试延迟 - **GIVEN** 加油包激活,第1次复机调用失败 - **WHEN** 系统重试3次(间隔1s/2s/4s) - **THEN** 复机在第3次重试成功,总延迟约7秒 --- ## 数据模型变更 ### tb_iot_card 新增字段 | 字段 | 类型 | 说明 | |------|------|------| | stopped_at | timestamp | 停机时间,NULL=未停机 | | resumed_at | timestamp | 最近复机时间 | | stop_reason | varchar(50) | 停机原因:`traffic_exhausted`, `manual`, `arrears` | **索引**: - 无需索引(非查询字段,仅用于审计) --- ## 业务流程 ### 流程1:流量耗尽停机 ```mermaid graph TD A[流量上报] --> B{所有套餐流量=0?} B -->|是| C{卡状态=active?} B -->|否| Z[结束] C -->|是| D[调用运营商停机接口] C -->|否| Z D --> E{停机成功?} E -->|是| F[更新卡状态=stopped] E -->|否| G[重试3次] F --> H[记录stopped_at] G --> E ``` ### 流程2:购买加油包复机 ```mermaid graph TD A[加油包激活成功] --> B{卡状态=stopped?} B -->|是| C[调用运营商复机接口] B -->|否| Z[跳过复机] C --> D{复机成功?} D -->|是| E[更新卡状态=active] D -->|否| F[重试3次] E --> G[清空stopped_at] F --> D F -->|3次失败| H[记录错误日志] ``` --- ## 并发场景 ### Scenario: 并发停复机 - **GIVEN** 卡流量刚好用完,同时用户购买加油包 - **WHEN** 停机任务和复机任务并发执行 - **THEN** 使用数据库行锁: ```sql SELECT * FROM iot_card WHERE id=? FOR UPDATE ``` - **AND** 后执行的操作覆盖前一个操作的状态 ### Scenario: 复机任务重复执行 - **GIVEN** 用户购买2个加油包,触发2次复机 - **WHEN** 第1次复机成功,卡状态=active - **THEN** 第2次复机检测到卡状态=active,跳过调用 --- ## 异常处理 ### 1. 停机接口超时 - **场景**:运营商停机接口响应超时(>5秒) - **处理**: 1. 记录 Error 日志(包含卡号、超时时间) 2. 重试3次,间隔1s/2s/4s 3. 3次都失败:记录到死信队列,告警通知 - **用户影响**:卡可能仍可上网(停机未成功) ### 2. 复机接口失败 - **场景**:运营商复机接口返回业务错误(如卡状态异常) - **处理**: 1. 记录 Error 日志(包含卡号、错误码、错误消息) 2. 重试3次 3. 3次都失败:套餐激活成功,但卡保持停机状态 4. 告警通知运维人工介入 - **用户影响**:购买加油包后仍无法上网 ### 3. 停复机状态不一致 - **场景**:系统记录已停机,但运营商侧仍正常 - **处理**: 1. 轮询系统定期同步卡状态 2. 检测到不一致时记录 Warning 日志 3. 自动修正系统状态(以运营商侧为准) - **修正频率**:每小时同步一次 --- ## 性能指标 | 操作 | 目标响应时间 | 监控指标 | |------|------------|---------| | 停机接口调用 | < 5秒 | 运营商API耗时 | | 复机接口调用 | < 5秒 | 运营商API耗时 | | 停机条件检查 | < 50ms | SELECT COUNT查询耗时 | | 端到端停机延迟 | < 2分钟 | 流量用完到停机完成 | | 端到端复机延迟 | < 2分钟 | 套餐激活到复机完成 | --- ## 错误码定义 | 错误码 | HTTP 状态码 | 错误消息 | 场景 | |--------|------------|---------|------| | CodeInternal | 500 | 停机操作失败,请重试 | 运营商停机接口失败 | | CodeInternal | 500 | 复机操作失败,请重试 | 运营商复机接口失败 | --- ## 测试场景矩阵 | 维度 | 场景 | 预期结果 | |------|------|---------| | **停机** | 所有套餐流量用完 | 自动停机 | | | 主套餐用完+加油包剩余 | 不停机 | | | 停机接口失败 | 重试3次,失败告警 | | | 已停机重复检测 | 跳过停机 | | **复机** | 购买加油包 | 自动复机 | | | 购买主套餐 | 自动复机 | | | 复机接口失败 | 重试3次,套餐激活成功,卡保持停机 | | | 并发购买2个加油包 | 复机接口调用1次 | | **延迟** | 复机延迟 | < 2分钟 | | | 停机延迟 | < 2分钟 | | **异常** | 停机超时 | 重试后告警 | | | 状态不一致 | 轮询同步修正 | --- ## 实现参考 ### Service 层:CheckAndStop ```go func (s *Service) CheckAndStopCard(ctx context.Context, cardID uint) error { // 1. 查询卡信息 card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { return err } // 2. 检查卡状态 if card.NetworkStatus != constants.NetworkStatusActive { return nil // 已停机,跳过 } // 3. 检查是否有剩余流量 hasAvailableData, err := s.packageUsageStore.HasAvailableData(ctx, cardID) if err != nil { return err } if hasAvailableData { return nil // 有剩余流量,不停机 } // 4. 调用运营商停机接口(带重试) err = s.carrierClient.StopCard(ctx, card.ICCID, 3) if err != nil { s.logger.Error("停机失败", zap.Uint("card_id", cardID), zap.Error(err)) return err } // 5. 更新卡状态 err = s.iotCardStore.UpdateStopStatus(ctx, cardID, time.Now(), "traffic_exhausted") if err != nil { return err } // 6. 记录审计日志 s.auditService.LogOperation(ctx, &model.OperationLog{ OperationType: "card_stop", OperationDesc: "流量耗尽自动停机", TargetID: cardID, }) return nil } ``` ### Service 层:ResumeCard ```go func (s *Service) ResumeCardIfStopped(ctx context.Context, cardID uint) error { // 1. 查询卡信息 card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { return err } // 2. 检查卡状态 if card.NetworkStatus != constants.NetworkStatusStopped { return nil // 未停机,跳过 } // 3. 调用运营商复机接口(带重试) err = s.carrierClient.ResumeCard(ctx, card.ICCID, 3) if err != nil { s.logger.Error("复机失败", zap.Uint("card_id", cardID), zap.Error(err)) // 复机失败不阻塞套餐激活 return nil } // 4. 更新卡状态 err = s.iotCardStore.UpdateResumeStatus(ctx, cardID, time.Now()) if err != nil { return err } // 5. 记录审计日志 s.auditService.LogOperation(ctx, &model.OperationLog{ OperationType: "card_resume", OperationDesc: "购买套餐自动复机", TargetID: cardID, }) return nil } ``` --- **本 Spec 完成**,包含: - ✅ 业务背景和业务规则 - ✅ 详细场景(停机、复机、幂等性、容错) - ✅ 数据模型变更 - ✅ 业务流程图 - ✅ 并发场景和异常处理 - ✅ 性能指标和错误码定义 - ✅ 测试场景矩阵和实现参考 --- ## 迭代更新(fix-polling-coverage-gaps) ### ADDED Requirement: 套餐过期时立即触发绑定卡停机 当主套餐过期处理完成后,若该套餐的载体(卡或设备)无任何后续生效套餐,系统 SHALL 立即异步触发停机检查,不依赖下一次 carddata 轮询兜底。 **触发位置**:`PackageActivationHandler.processExpiredPackage` 在执行 `updateCarrierSuspendedStatus` 后,若载体确认无生效套餐,异步调用 `StopResumeCallback.CheckAndStopCard`(iot_card 类型)或遍历绑定卡逐一触发(device 类型)。 **幂等保护**:`CheckAndStopCard` 已有"卡已停机则跳过"逻辑,重复触发安全。 **与 carddata 轮询的关系**:此处主动触发为优化项(消除延迟窗口),carddata 轮询的 `checkStopResume` 仍作为兜底保障,两者共存不冲突。 #### Scenario: 主套餐过期且无后续套餐,载体为 iot_card - **WHEN** `HandlePackageActivationCheck` 检测到一张卡的主套餐已过期(`expires_at <= NOW`),执行 `processExpiredPackage` 后确认该卡无待生效或生效中的套餐 - **THEN** 系统异步调用 `CheckAndStopCard(cardID)`,将卡停机(`network_status = 0`,`stop_reason = "traffic_exhausted"`),不等待网关响应 #### Scenario: 主套餐过期且无后续套餐,载体为 device - **WHEN** `HandlePackageActivationCheck` 检测到一个设备的主套餐已过期,确认该设备无后续套餐 - **THEN** 系统查询设备绑定的所有在线已实名卡,逐一异步触发 `CheckAndStopCard` #### Scenario: 主套餐过期但有排队待生效套餐 - **WHEN** `processExpiredPackage` 处理过期套餐,`activateNextPackage` 成功激活了下一个待生效套餐 - **THEN** 系统不触发停机(因有生效套餐),`updateCarrierSuspendedStatus` 检查后确认有生效套餐即返回 #### Scenario: carddata 轮询兜底仍有效 - **WHEN** 套餐过期主动停机的异步触发因 gateway 异常失败,卡仍处于在线状态 - **THEN** 下一次 `HandleCarddataCheck` 的 `checkStopResume` 检测到在线无套餐,再次尝试停机(兜底保障) --- ## 迭代更新(fix-stop-resume-lifecycle-engine) ### MODIFIED Requirement: 流量耗尽自动复机 系统 SHALL 在套餐激活或流量重置后,对满足以下全部条件的卡自动调用运营商接口复机: 1. `stop_reason = "traffic_exhausted"`(仅限流量耗尽停机,手动停机不自动复机) 2. `real_name_status = 1`(已完成实名认证) 3. `network_status = 0`(当前处于停机状态) 系统 SHALL 在执行复机前获取 Redis 分布式锁(Key:`card:resume:lock:{cardID}`,TTL 30s),防止并发重复调用 Gateway。 #### Scenario: 所有条件满足时自动复机 - **GIVEN** 卡 C1 满足:`stop_reason=traffic_exhausted`,`real_name_status=1`,`network_status=0` - **WHEN** `ResumeCardIfStopped(ctx, "iot_card", cardID)` 被调用 - **THEN** 系统获取分布式锁,调用 `gateway.StartCard`,更新 `network_status=1`,清空 `stop_reason`,释放锁 #### Scenario: 未实名卡跳过自动复机 - **GIVEN** 卡 C2 `stop_reason=traffic_exhausted`,`real_name_status=0`,`network_status=0` - **WHEN** `ResumeCardIfStopped` 被调用 - **THEN** 系统记录 WARN 日志,静默跳过,不调用 Gateway,函数返回 nil #### Scenario: 手动停机的卡不被自动复机 - **GIVEN** 卡 C3 `stop_reason=manual`,`real_name_status=1`,`network_status=0` - **WHEN** `ResumeCardIfStopped` 被调用 - **THEN** 系统检测到 `stop_reason != traffic_exhausted`,静默跳过 #### Scenario: 并发调用时只有一次复机执行 - **GIVEN** 卡 C4 满足复机条件,复机操作正在执行(分布式锁被持有) - **WHEN** 第二个 `ResumeCardIfStopped` 调用同时到达 - **THEN** 第二个调用获锁失败,直接返回,不调用 Gateway #### Scenario: 设备类型载体复机遍历所有绑定卡 - **GIVEN** 设备 D1 绑定 3 张卡(C1 已实名停机、C2 已实名停机、C3 未实名停机) - **WHEN** `ResumeCardIfStopped(ctx, "device", deviceID)` 被调用 - **THEN** C1 和 C2 自动复机,C3 因未实名跳过 ### ADDED Requirement: 套餐超额停机必须写入 stop_reason 和 stopped_at 系统 SHALL 在套餐虚流量超额触发停机时(`stopCards` 函数),写入 `stop_reason=traffic_exhausted` 和 `stopped_at` 到 IoT 卡记录。 #### Scenario: 虚流量超额停机完整写入 DB - **GIVEN** 卡 C1 当月流量超过套餐 `virtual_data_mb` 上限,`network_status=1` - **WHEN** `HandlePackageCheck` 的 `stopCards` 执行 - **THEN** Gateway 调用成功后,DB 更新包含:`network_status=0`,`stop_reason=traffic_exhausted`,`stopped_at=now()`,`updated_at=now()` ### ADDED Requirement: 保护期停机使用常量 stop_reason 系统 SHALL 在保护期一致性检查停机时,使用 `constants.StopReasonProtectPeriod` 常量(值:`protect_period`)记录 `stop_reason`。 #### Scenario: 保护期停机写入正确原因 - **GIVEN** 设备处于停机保护期,绑定的卡 C1 `network_status=1`(开机状态不一致) - **WHEN** `HandleProtectConsistencyCheck` 触发停机 - **THEN** DB 中 `stop_reason=protect_period`(使用常量,非中文硬编码) - **AND** `ResumeCardIfStopped` 调用时因 `stop_reason != traffic_exhausted` 跳过,不会错误触发自动复机