Files
junhong_cmp_fiber/openspec/changes/add-carrier-channel-traffic-thresholds/design.md
break 59b3df868a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s
feat(通道流量阈值): AUG26-011 运营商通道流量阈值达量停机与周期复机
2026-09-16 15:54:49 +08:00

87 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
「运营商通道」即既有 `Carrier``tb_carrier`),系统无独立通道实体;卡归属通道 = `IotCard.CarrierID`,下文 `carrier_id` 均指该外键。`Carrier.DataResetDay` 是上游流量重置日128运营商每月清零网关计数器的日期已有 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` 字段统计周期只有一个月内起始日128上海时区
- **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` 128 保证每月有定义(无 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 分钟超期转人工,锁保留,无数据丢失。