Files
junhong_cmp_fiber/openspec/changes/add-carrier-channel-traffic-thresholds/design.md
break ef4d3696d4
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
fix(通道流量阈值): AUG26-011 修复周期处理连接池自锁并补齐根池句柄验证与文档
2026-09-16 16:59:32 +08:00

106 lines
13 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 AND status='locked'` 条件更新refundchannel `claimChannelSubmission` 范式),至多一次执行;认领失败 = 已提交过或锁已被周期处理跨期解锁 → 只走恢复查询,绝不重复调用。
2. 卡已 `offline`(其他停因先行)→ 不调 Gateway直接确认 success。
3. 否则复用 `stopCardWithRetry`(内部 3 次重试、Integration Log、统一审计成功后写 `network_status=offline``stop_reason=channel_threshold``stopped_at`
4. 失败/未知:子任务落 `failed`/`unknown` 并记安全失败原因,锁行与历史结果一律保留;**两类结果都不退出收敛链路**——恢复扫描的未决集合为 `{submitted, unknown, failed}`继续用只读状态查询确认调用失败或响应超时并不代表运营商侧未生效超期转人工unknown 的 Integration Log 必带 `RecoveryStrategy`
### 4. 停因与复机拒绝
- 新增 `StopReasonChannelThreshold = "channel_threshold"``pkg/constants/iot.go` 停因组)。**不纳入 `isPollingStopReason`**(否则 `EvaluateAndAct` 离线分支会绕过周期逻辑直接自动复机),**不纳入 `isDeviceScopeReason`**(不扩散设备)。复机只由周期处理发起。
- **持锁拒绝覆盖四个入口**,入口前置检查(锁存在 → 拒绝 + 审计 `denied` + 锁保留;顺序先于既有各类前置校验与上游调用):
| 入口 | 覆盖路径 | 拒绝时的返回 |
|---|---|---|
| `resumeSingleCard` | `EvaluateAndAct` 自动复机、`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 分钟处理。
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不放开复机若周期处理也跳过这些行持锁卡将永久禁止一切复机且无自动出路。
2. **恢复扫描**:扫描 `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 LedgerN/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 边界),该场景以计划任务 + 锁行留痕转人工。