Files
junhong_cmp_fiber/openspec/specs/auto-stop-resume/spec.md
huang 8980e6d999
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m11s
fix: 补全三处轮询覆盖缺口
问题一:protect 轮询任务从未被调度器初始化入队
PollingConfig 新增 protect_check_interval 字段(NULL=不参与),
调度器 initCardsBatch/initCardPolling/requeueCard 补全 protect 队列
初始化逻辑,IotCard 新增 last_protect_check_at 记录上次检查时间。
迁移文件:000102_add_polling_protect_fields

问题二:设备套餐流量耗尽时绑定卡未被停机
UsageService.checkAndTriggerSuspension 新增 carrier_type=device 分支,
通过 DeviceSimBindingStore.ListByDeviceID 查询绑定卡,对每张卡异步
触发 CheckAndStopCard,同步注入 Bootstrap 依赖。

问题三:主套餐过期后不立即停机,依赖下次轮询兜底
PackageActivationHandler.processExpiredPackage 在 updateCarrierSuspended
Status 后,若载体无后续生效套餐,立即异步调用 CheckAndStopCard(iot_card
类型直接触发,device 类型遍历绑定卡),消除停机延迟窗口。

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-02 14:36:24 +08:00

16 KiB
Raw Blame History

Spec: 自动停复机机制

业务背景

为什么需要自动停复机

现状问题

  • 当前系统流量耗尽后手动停机,用户购买加油包后需手动复机
  • 停复机时机不精确,可能出现流量已耗尽但仍可上网的情况
  • 用户购买加油包后不知道需要复机,导致流量无法使用

业务目标

  • 所有套餐流量耗尽时自动停机,避免超额使用
  • 购买新套餐(正式/加油包)后自动复机,提升用户体验
  • 停复机延迟 < 2分钟,确保及时性

业务规则

1. 停机触发条件

停机条件 = (所有生效套餐流量 = 0) AND (卡当前状态 = active)

详细逻辑

-- 检查是否有剩余流量
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)

可用流量套餐定义

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流量耗尽停机

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购买加油包复机

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 使用数据库行锁:
    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

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

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.CheckAndStopCardiot_card 类型或遍历绑定卡逐一触发device 类型)。

幂等保护CheckAndStopCard 已有"卡已停机则跳过"逻辑,重复触发安全。

与 carddata 轮询的关系此处主动触发为优化项消除延迟窗口carddata 轮询的 checkStopResume 仍作为兜底保障,两者共存不冲突。

Scenario: 主套餐过期且无后续套餐,载体为 iot_card

  • WHEN HandlePackageActivationCheck 检测到一张卡的主套餐已过期(expires_at <= NOW),执行 processExpiredPackage 后确认该卡无待生效或生效中的套餐
  • THEN 系统异步调用 CheckAndStopCard(cardID),将卡停机(network_status = 0stop_reason = "traffic_exhausted"),不等待网关响应

Scenario: 主套餐过期且无后续套餐,载体为 device

  • WHEN HandlePackageActivationCheck 检测到一个设备的主套餐已过期,确认该设备无后续套餐
  • THEN 系统查询设备绑定的所有在线已实名卡,逐一异步触发 CheckAndStopCard

Scenario: 主套餐过期但有排队待生效套餐

  • WHEN processExpiredPackage 处理过期套餐,activateNextPackage 成功激活了下一个待生效套餐
  • THEN 系统不触发停机(因有生效套餐),updateCarrierSuspendedStatus 检查后确认有生效套餐即返回

Scenario: carddata 轮询兜底仍有效

  • WHEN 套餐过期主动停机的异步触发因 gateway 异常失败,卡仍处于在线状态
  • THEN 下一次 HandleCarddataCheckcheckStopResume 检测到在线无套餐,再次尝试停机(兜底保障)

迭代更新fix-stop-resume-lifecycle-engine

MODIFIED Requirement: 流量耗尽自动复机

系统 SHALL 在套餐激活或流量重置后,对满足以下全部条件的卡自动调用运营商接口复机:

  1. stop_reason = "traffic_exhausted"(仅限流量耗尽停机,手动停机不自动复机)
  2. real_name_status = 1(已完成实名认证)
  3. network_status = 0(当前处于停机状态)

系统 SHALL 在执行复机前获取 Redis 分布式锁Keycard:resume:lock:{cardID}TTL 30s防止并发重复调用 Gateway。

Scenario: 所有条件满足时自动复机

  • GIVEN 卡 C1 满足:stop_reason=traffic_exhaustedreal_name_status=1network_status=0
  • WHEN ResumeCardIfStopped(ctx, "iot_card", cardID) 被调用
  • THEN 系统获取分布式锁,调用 gateway.StartCard,更新 network_status=1,清空 stop_reason,释放锁

Scenario: 未实名卡跳过自动复机

  • GIVEN 卡 C2 stop_reason=traffic_exhaustedreal_name_status=0network_status=0
  • WHEN ResumeCardIfStopped 被调用
  • THEN 系统记录 WARN 日志,静默跳过,不调用 Gateway函数返回 nil

Scenario: 手动停机的卡不被自动复机

  • GIVEN 卡 C3 stop_reason=manualreal_name_status=1network_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_exhaustedstopped_at 到 IoT 卡记录。

Scenario: 虚流量超额停机完整写入 DB

  • GIVEN 卡 C1 当月流量超过套餐 virtual_data_mb 上限,network_status=1
  • WHEN HandlePackageCheckstopCards 执行
  • THEN Gateway 调用成功后DB 更新包含:network_status=0stop_reason=traffic_exhaustedstopped_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 跳过,不会错误触发自动复机