13 KiB
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 事件):
- 提交认领:锁行
stop_submitted_at IS NULL AND status='locked'条件更新(refundchannelclaimChannelSubmission范式),至多一次执行;认领失败 = 已提交过或锁已被周期处理跨期解锁 → 只走恢复查询,绝不重复调用。 - 卡已
offline(其他停因先行)→ 不调 Gateway,直接确认 success。 - 否则复用
stopCardWithRetry(内部 3 次重试、Integration Log、统一审计);成功后写network_status=offline、stop_reason=channel_threshold、stopped_at。 - 失败/未知:子任务落
failed/unknown并记安全失败原因,锁行与历史结果一律保留;两类结果都不退出收敛链路——恢复扫描的未决集合为{submitted, unknown, failed},继续用只读状态查询确认(调用失败或响应超时并不代表运营商侧未生效),超期转人工;unknown 的 Integration Log 必带RecoveryStrategy。
- 提交认领:锁行
4. 停因与复机拒绝
- 新增
StopReasonChannelThreshold = "channel_threshold"(pkg/constants/iot.go停因组)。不纳入isPollingStopReason(否则EvaluateAndAct离线分支会绕过周期逻辑直接自动复机),不纳入isDeviceScopeReason(不扩散设备)。复机只由周期处理发起。 - 持锁拒绝覆盖四个入口,入口前置检查(锁存在 → 拒绝 + 审计
denied+ 锁保留;顺序先于既有各类前置校验与上游调用):入口 覆盖路径 拒绝时的返回 resumeSingleCardEvaluateAndAct自动复机、ResumeCardIfStopped、resumeDeviceCards遍历返回 nil(自动链路不把拒绝当失败重试),但仍写 denied审计ManualStartCard手动复机 返回 CodeForbidden+ 中文拒绝文案ForceStartCard保护期强制复机(先持锁拒绝,再走既有保护期一致性检查,否则会强行复机锁定期内的卡) 返回 CodeForbidden+ 中文拒绝文案StartMachineSeparatedCard机卡分离复机 返回 CodeForbidden+ 中文拒绝文案 - 「其他停机锁」定义:
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 分钟处理。
- 周期处理:扫描待处理的持锁锁行(
status=locked),按锁行自身 carrier 的data_reset_day分两种结果处理:- 已跨期(
period_start早于该 carrier 的当前周期起点,支持换运营商后旧锁归属):条件更新认领解锁(status=locked → unlocked,防并发重复);逐卡评估有效主套餐(hasValidPackage)+ 流量未耗尽(isTrafficExhausted)+ 实名 OK(isRealnameOK)+ 非风险 extend + 无其他停因;全满足写复机 Outbox 事件,任一不满足只解锁并把原因写入锁行。 - 周期归属不可判定(锁行引用的 carrier 已不存在/软删,或
data_reset_day非法):同样认领解锁(按 spec「新周期对仍持锁卡解除通道锁」的语义)并同事务anomaly_flag=1+ 安全原因、Warn 日志转人工;不写复机事件、不调运营商。解锁与异常标记 MUST 共用同一事务句柄(解锁已持有该行锁,异常标记若改走连接池的另一条连接会等待该行锁直到语句超时,表现为该锁停在locked且周期处理每分钟空转)。该分支是必须的出路:ActiveLock对配置缺失按仍未生效处理(fail-closed,不放开复机),若周期处理也跳过这些行,持锁卡将永久禁止一切复机且无自动出路。
- 已跨期(
- 恢复扫描:扫描
anomaly_flag=0且stop_status/resume_status处于未决集合{submitted, unknown, failed}的锁;只查询网关状态回填,绝不重复发起停复机;确认成功 → 回填任务状态(confirmed只写一次,条件更新按未决集合为谓词)并补写卡状态(覆盖「Gateway 成功但 DB 更新失败」场景);自提交起超过 30 分钟(常量定义)仍不可查 →anomaly_flag=1+ 安全失败原因,退出扫描转人工,不自动删除锁。
- 复机消费者:结构同停机消费者,认领字段
resume_submitted_at,复用resumeCardWithRetry,成功后写network_status=online、resumed_at,并只在该卡停因正是channel_threshold时清除停因(不覆盖 arrears/manual 等其他停因)。 - 周期处理与恢复 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. 审计决定(ENG-AUDIT-001)
按 ENG-AUDIT-001「用例 MUST 明确 Audit Event、Domain Ledger、Integration Log、Outbox 的使用决定或 N/A 理由」,本能力的四类事实归属如下:
- Audit Event(有,复用既有动作,不新建动作):卡状态侧的用户可见结果与关键拒绝——达量停机成功/失败(
iot_card.auto_stop)、新周期复机成功(iot_card.auto_start)、四入口持锁拒绝(同上动作 +result=denied)——全部走停复机单一事实源的统一审计写入(与卡状态更新同事务)。 - Domain Ledger(N/A):本能力不涉及资金与额度,无独立账本事实。
- Integration Log(有):每次运营商停复机调用与每次恢复扫描的状态查询都写 Integration Log(
stop_card/start_card/query_card_status);结果未知必带RecoveryStrategy(既有基座强制)。 - Outbox(有):达量停机事件与周期复机事件与锁行事实同事务写入(ENG-OUTBOX-001)。
- 锁行状态迁移(
locked→unlocked、anomaly_flag=1)的 N/A 决定:这两类迁移是本能力内部的任务编排状态,不是用户可见的业务状态变更,因此不新建独立审计动作;其承载方式为:锁行自身的status/failure_reason/updated_at留痕 + 周期处理计划任务的 Warn 日志(含 lock_id/card_id/carrier_id 与原因)+ 该锁驱动的卡状态变更由上述 Audit Event 覆盖。该决定在此显式登记,四类事实不得互相替代。 - 读卡/写审计类基础设施故障使结果无法判定时,子任务保持
submitted,由恢复扫描按窗口收敛或转人工——不伪造终态。
8. 锁表结构
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)。
9. 既有能力的行为变更(Modified Capability)
持通道阈值锁的卡在锁定期内不再调用上游复机流程,这改变了既有 package-lifecycle「套餐状态流转」中「支付后已生效主套餐异步尝试自动复机」与「套餐激活/重置复机」的可观察行为(新增一条前置条件:持通道阈值锁时拒绝复机)。该变更以 openspec/changes/add-carrier-channel-traffic-thresholds/specs/package-lifecycle/spec.md 的 MODIFIED delta 显式建模(复述原 Requirement 全文并加入持锁条件),主 Spec 的同步在归档时进行。
Risks / Trade-offs
- 达量判定嵌入
ApplyTrafficObservation事务:该事务变重(多一次 carrier 查询 + 锁插入)。收益是并发安全与数据最新;风险是事务失败回滚会连同流量事实一起回滚——既有 CAS 冲突已按此语义处理,行为一致。 - 停机成功写
stop_reason=channel_threshold会覆盖卡上既有停因字段;仅当停机消费者确认 Gateway 成功后写入,且持锁期本就该拒绝其他路径,覆盖可接受。 - 恢复扫描「只查询回填」依赖网关状态查询接口可用;持续不可用 → 30 分钟超期转人工,锁保留,无数据丢失。
- 停机/复机调用明确失败(
failed)与结果未知(unknown)都继续留在收敛链路上:每分钟只做一次只读状态查询,绝不重试外呼;这在极端情况下会让同一笔结果在 30 分钟内被查询数十次,代价换来「调用已生效但响应丢失」场景能被自动纠正。 - 锁行引用的 carrier 被删除/软删,或
data_reset_day非法时,周期处理按跨期语义解锁并标记异常转人工:解锁意味着该卡不再被通道锁拒绝复机(配置缺失时无法判断是否仍在周期内),异常标记与 Warn 日志保证运维可见。本能力不新增 carrier 删除前置校验(既有 Change 边界),该场景以计划任务 + 锁行留痕转人工。