## Context 「运营商通道」即既有 `Carrier`(`tb_carrier`),系统无独立通道实体;卡归属通道 = `IotCard.CarrierID`,下文 `carrier_id` 均指该外键。`Carrier.DataResetDay` 是上游流量重置日(1–28,运营商每月清零网关计数器的日期),已有 CRUD 管理接口。卡上 `last_gateway_reading_mb` 是运营商当前周期累计流量读数,由流量轮询经卡观测 `ApplyTrafficObservation` 事务写入。停复机统一入口 `StopResumeService.EvaluateAndAct`,现有停因 3 种轮询停因可自动复机,风险网关扩展态(风险停机/销户)禁止复机。资金类外部调用已有「DB 事实表 + 每分钟恢复扫描 + 至多一次提交认领」范式(refundchannel)。套餐真流量预警(第 12 项,已归档)只通知不停机,本能力与其零耦合。 ## Goals / Non-Goals **Goals:** - 通道阈值配置(扩列 `tb_carrier`)+ 字段级平台守卫 + 审计并入既有 carrier 审计。 - 达量停机:锁 + 可靠停机事件,周期内拒绝一切复机。 - 新周期解锁 + 条件复机;停机/复机失败未知由恢复扫描确认或超期转人工。 **Non-Goals:** - 不改套餐真流量预警代码与表。 - 不改既有 3 种轮询停因语义、不改网关调用层、不改 `isTrafficResetWindow`。 - 不扩散设备维度;不建独立通道实体;不为阈值配置建附属表。 ## Decisions ### 1. 阈值配置扩列 tb_carrier - 新增列:`traffic_threshold_enabled`(bool,默认 false)、`traffic_threshold_value`(正数,numeric)、`traffic_threshold_unit`(`MB`/`GB`)。 - **周期起始日复用既有 `data_reset_day`,不新增列。** 通道计费周期 = 网关计数器清零周期,是同一事实(列注释即此语义),两套并存必然口径漂移;因此不引入 `traffic_period_type`、`traffic_period_start_day` 字段,统计周期只有一个:月内起始日(1–28,上海时区)。 - **GB 换算系数固定 1 GB = 1024 MB**,以常量定义;换算后与 `last_gateway_reading_mb`(MB)同单位比较。 ### 2. period_start 公式(上海时区) `resetDay` 取该卡 carrier 的 `data_reset_day`: ``` M = time.Date(y, m, resetDay, 0, 0, 0, 0, time.FixedZone("", 8*3600)) // 本月重置日 0 点 period_start = M if now >= M period_start = M.AddDate(0, -1, 0) if now < M // 上月重置日 0 点 ``` `resetDay` 1–28 保证每月有定义(无 2 月 30 日问题)。与 `isTrafficResetWindow`(`internal/domain/cardobservation/traffic.go`,重置日当天+前一日)**是两个口径**:前者是网关计数器清零的观测窗口(判断读数回落是否为合法清零),后者是逻辑周期起点(锁唯一键);二者互不修改。 ### 3. 达量停机 - **判定落点:`ApplyTrafficObservation` 既有事务内**(行锁 + CAS 已持有,数据最新,并发天然安全;人工刷新同路径行为一致)。条件:`decision.ReadingAccepted == true` 且卡所属 carrier 阈值启用;异常下降保护(`ReadingAccepted == false`)不判定。只用 `last_gateway_reading_mb`,不用 `data_usage_mb`、`current_month_usage_mb` 或套餐真流量。 - 事务内写锁 + Outbox 停机事件(ENG-TX-001);事件 ID 用 `outboxid.Stable`。 - 锁唯一键 `(carrier_id, card_id, period_start)`;**postgres 23505 单独捕获为「该周期已处理」并跳过**,不得与既有流量基线 CAS 冲突(`CodeConflict`)混流——两类冲突语义不同,前者幂等跳过,后者重放重试。 - **改阈值不生效于当前周期已持锁卡**:唯一键已占位 = 该周期已处理,当前周期维持拒绝复机;新周期按新阈值判断。 - **停机消费者**(worker,消费停机 Outbox 事件): 1. 提交认领:锁行 `stop_submitted_at IS NULL` 条件更新(refundchannel `claimChannelSubmission` 范式),至多一次执行;认领失败 = 已提交过 → 只走恢复查询,绝不重复调用。 2. 卡已 `offline`(其他停因先行)→ 不调 Gateway,直接确认 success。 3. 否则复用 `stopCardWithRetry`(内部 3 次重试、Integration Log、统一审计);成功后写 `network_status=offline`、`stop_reason=channel_threshold`、`stopped_at`。 4. 失败/未知:保留锁与任务状态、记安全失败原因;unknown 的 Integration Log 必带 `RecoveryStrategy`。 ### 4. 停因与复机拒绝 - 新增 `StopReasonChannelThreshold = "channel_threshold"`(`pkg/constants/iot.go` 停因组)。**不纳入 `isPollingStopReason`**(否则 `EvaluateAndAct` 离线分支会绕过周期逻辑直接自动复机),**不纳入 `isDeviceScopeReason`**(不扩散设备)。复机只由周期处理发起。 - **持锁拒绝覆盖四个入口**,入口前置检查(锁存在 → 拒绝 + 审计 `denied` + 锁保留): | 入口 | 覆盖路径 | |---|---| | `resumeSingleCard` | `EvaluateAndAct` 自动复机、`ResumeCardIfStopped`、`resumeDeviceCards` 遍历 | | `ManualStartCard` | 手动复机 | | `ForceStartCard` | 保护期强制复机(不加此拒绝,保护期一致性检查会强行复机持锁卡) | | `StartMachineSeparatedCard` | 机卡分离复机 | - **「其他停机锁」定义**:`stop_reason` 非空且非 `channel_threshold`(arrears/manual/carrier_stopped/protect_period 等),或 `gateway_extend` 为风险停机/销户(`isRiskGatewayExtend`)。 ### 5. 周期处理与恢复(两个独立 cron) 均 `@every 1m` + `asynq.Unique(10m)` + 无 payload,照 `TaskTypeRefundChannelRecovery` 形态注册(`cmd/worker/main.go`);独立 cron 而非挂流量轮询同路径,是因为不依赖「停机卡是否继续被轮询」,新周期后最迟 1 分钟处理。 1. **周期处理**:扫描 `period_start` 已过期的持锁锁行(按**锁行自身 carrier** 的 `data_reset_day` 判断过期,支持换运营商后旧锁归属);条件更新认领解锁(`status=locked → unlocked`,防并发重复);逐卡评估:有效主套餐(`hasValidPackage`)+ 流量未耗尽(`isTrafficExhausted`)+ 实名 OK(`isRealnameOK`)+ 非风险 extend + 无其他停因;全满足写复机 Outbox 事件,任一不满足只解锁。 2. **恢复扫描**:扫描 `stop_status`/`resume_status` 为 `submitted` 的锁;**只查询网关状态回填,绝不重复发起停复机**;确认成功 → 回填任务状态并补写卡状态(覆盖「Gateway 成功但 DB 更新失败」场景);自提交起超过 **30 分钟**(常量定义)仍不可查 → `anomaly_flag=1` + 安全失败原因,退出扫描转人工,不自动删除锁。 - **复机消费者**:结构同停机消费者,认领字段 `resume_submitted_at`,复用 `resumeCardWithRetry`,成功后写 `network_status=online`、`stop_reason=""`、`resumed_at`。 - 周期处理与恢复 Handler 审计上下文固定 `ActorKind=AuditActorScheduledJob`、`Source=AuditSourceScheduler`。 ### 6. 权限与审计 - 现有 carrier CRUD 路由无角色守卫(仅 `Auth: true`,代理后端账号可达 admin 组)。**字段级守卫**:Create/Update 中阈值字段仅 `SuperAdmin`/`Platform` 可写(对齐 `requirePlatformManagement` 模式),非平台账号提交含阈值字段的请求即拒绝;List/Get 响应对非平台账号不返回阈值字段。**不把整个 carrier CRUD 改为平台专属**,避免影响既有非阈值字段用途。 - 审计并入既有 carrier 更新审计(`writeAudit` 整体快照前后值),不新建独立审计动作;启停即 Update 的一部分,前后值自然覆盖。 - 通道停用:`enabled=false` 只停止新锁创建;已持锁卡当前周期继续拒绝复机,新周期解锁/复机照常;锁与历史保留。 ### 7. 锁表结构 `carrier_id`、`card_id`、`period_start`(timestamptz,唯一键三列组合,软删感知)、`status`(`locked`/`unlocked`)、任务状态组(`stop_status`/`resume_status`:`pending`/`submitted`/`confirmed`/`failed`/`unknown`)、提交认领字段(`stop_submitted_at`/`resume_submitted_at`)、`anomaly_flag`、`failure_reason`、时间戳。成对迁移(up/down)。 ## Risks / Trade-offs - 达量判定嵌入 `ApplyTrafficObservation` 事务:该事务变重(多一次 carrier 查询 + 锁插入)。收益是并发安全与数据最新;风险是事务失败回滚会连同流量事实一起回滚——既有 CAS 冲突已按此语义处理,行为一致。 - 停机成功写 `stop_reason=channel_threshold` 会覆盖卡上既有停因字段;仅当停机消费者确认 Gateway 成功后写入,且持锁期本就该拒绝其他路径,覆盖可接受。 - 恢复扫描「只查询回填」依赖网关状态查询接口可用;持续不可用 → 30 分钟超期转人工,锁保留,无数据丢失。