feat(通道流量阈值): AUG26-011 运营商通道流量阈值达量停机与周期复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s

This commit is contained in:
2026-09-16 15:54:49 +08:00
parent 41722760b1
commit 59b3df868a
36 changed files with 2431 additions and 93 deletions

View File

@@ -1,29 +1,86 @@
## 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
- 通道配置和卡周期锁分表;锁以通道、卡、周期唯一。
- 达量事务写锁和 Outbox 停机任务;新周期扫描按锁、主套餐和其他锁决定仅解锁或复机。
### 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 公式(上海时区)
- 扩展既有运营商通道创建/编辑 DTO`traffic_threshold_enabled``traffic_threshold_value``traffic_threshold_unit``traffic_period_type``traffic_period_start_day``traffic_threshold_status`。仅超级管理员、平台用户可读写;阈值必须为正数,单位只能是系统支持的 MB/GB周期起始日为 128所有时间边界按上海时区计算。
- 启用时完整校验字段;停用时停止后续达量判断但不删除当前周期锁或历史任务结果。新增、更新、启用、停用均写通道 ID、前后字段、操作者和时间审计不向代理、企业、个人客户暴露配置字段。
`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 点
```
- 运营商流量同步/周期扫描按卡当前所属通道及上海时区周期边界读取运营商回传累计流量,换算至配置单位;不使用套餐真流量预警数据。达到或超过阈值时,事务中以 `(channel_id, card_id, period_start)` 唯一键创建通道阈值停机锁和 Outbox 停机任务
- 唯一冲突表示该周期已处理;重复同步、并发扫描或任务重放不得创建第二把锁或重复发起停机。停机调用失败/未知保留锁、任务结果与安全失败原因,复用既有外部调用恢复;锁存在时所有人工或自动复机入口先拒绝。
`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`
## Migration Plan
### 4. 停因与复机拒绝
新增成对迁移;隔离库验证周期边界、重复达量、停机失败、复机条件和 up/down/up。
- 新增 `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 分钟超期转人工,锁保留,无数据丢失。

View File

@@ -21,4 +21,11 @@
## Impact
影响通道、卡状态、可靠任务、外部运营商调用和 Schema。
影响通道、卡状态、可靠任务、外部运营商调用和 Schema。具体落点:
- `tb_carrier` 扩列:`traffic_threshold_enabled``traffic_threshold_value``traffic_threshold_unit`(周期起始日复用既有 `data_reset_day`,不新增列)。
- 新增周期阈值停机锁表:`(carrier_id, card_id, period_start)` 唯一键、任务状态组、提交认领字段、`anomaly_flag`;成对迁移。
- 新增 2 个 Outbox 事件类型停机、复机各一worker 消费。
- 新增 2 个 cron 与 worker 装配点:周期处理(解锁/条件复机)、恢复扫描(只查询回填),均 `@every 1m` + `asynq.Unique(10m)`,照 `TaskTypeRefundChannelRecovery` 形态注册。
- 新增停因常量 `channel_threshold`4 个复机入口新增持锁拒绝。
- carrier 管理接口字段级守卫与响应过滤(阈值字段仅平台账号可见可写)。

View File

@@ -5,16 +5,22 @@
## ADDED Requirements
### Requirement: 通道阈值配置、权限与审计
仅超级管理员和平台用户 SHALL 在运营商通道新建或编辑时配置流量阈值开关、阈值数值流量单位、统计周期和生效状态;阈值数值必须为正,单位必须为系统支持单位,统计周期必须能换算为明确起止边界。新增、修改、启用、停用均必须记录操作者、修改前后字段时间。代理、企业和个人客户不得读取或修改通道阈值配置
仅超级管理员和平台用户 SHALL 在运营商通道新建或编辑时配置流量阈值开关、阈值数值流量单位;阈值数值必须为正,流量单位必须为系统支持的 MB 或 GB统计周期为通道既有的上游流量重置日128上海时区不另行配置周期。阈值字段仅对平台账号可见列表与详情响应对非平台账号不返回阈值字段非平台账号提交包含阈值字段的创建或编辑请求必须被拒绝不改变通道其余字段的既有可见性与可编辑范围。新增、修改、启用、停用均必须并入通道更新审计的操作者、修改前后字段时间记录,不另建独立审计动作。停用仅停止后续新锁创建:已持锁卡当前周期继续拒绝复机,新周期解锁与条件复机照常;停用不得删除当前周期锁或历史任务结果
#### Scenario: 越权修改通道阈值
- **WHEN** 非超级管理员、非平台用户请求创建、编辑或启停通道阈值
- **THEN** 系统拒绝请求,不修改配置且不产生审计成功事实
### Requirement: 通道阈值停机与周期恢复
系统 SHALL 为每个已启用运营商通道按其配置统计周期判断运营商回传的每张卡当前周期累计流量;达量即写通道阈值停机锁、创建可靠停机任务并调用运营商停机。持锁卡在当前周期内 MUST 拒绝复机;调用失败或未知保留任务结果并按既有恢复机制处理。
#### Scenario: 停用后已持锁卡进入新周期
- **WHEN** 通道阈值停用后,曾达量持锁的卡进入新计费周期
- **THEN** 系统不再创建新锁,且该卡按新周期规则正常解锁并按条件复机,历史锁与任务结果保留
新周期开始时,系统 SHALL 对仍持锁卡解除通道锁;仅存在有效主套餐且不存在风险停机、销户或其他停机锁时调用自动复机,不符合条件不得调用复机。复机失败记录结果并可靠处理。
### Requirement: 通道阈值停机与周期恢复
系统 SHALL 为每个已启用运营商通道按通道既有的上游流量重置日(与网关计数器清零同一事实,上海时区周期边界)判断运营商回传的每张卡当前周期累计流量,累计流量仅取网关累计读数换算,不使用本地用量统计或套餐真流量;每张卡独立判断,不扩散到设备维度,不进入既有停复机的设备扩散路径。达到或超过阈值时,系统为当前周期创建通道阈值停机锁并创建可靠停机任务;同一周期重复判定、并发或任务重放不得重复创建锁或重复发起停机。改阈值不生效于当前周期已持锁卡:当前周期维持拒绝复机,新周期按新阈值判断。
持锁卡在当前周期内 MUST 拒绝一切复机,包括自动复机、手动复机、保护期强制复机和机卡分离复机;拒绝时保留锁并记录拒绝事实。停机调用失败或结果未知时保留锁与任务状态,由本能力新增的恢复扫描处理:只查询运营商状态回填、确认成功后补写卡状态;自提交起超过约定窗口仍无法确认结果的,标记异常并转人工处理,不自动删除锁。本能力新增停因 `channel_threshold`,不参与既有自动复机判定,复机仅由周期处理发起。
新周期开始时,系统 SHALL 对仍持锁卡解除通道锁;仅存在有效主套餐、流量未耗尽、实名满足、不存在风险停机、销户或其他停机锁时创建自动复机任务,任一条件不满足时只解锁不调用运营商。其他停机锁指卡停机原因为非空且非通道阈值的其他停因,或网关扩展状态为风险停机、销户。复机失败或结果未知同样由恢复扫描处理。卡更换运营商后,旧周期锁保留,新周期按新运营商的重置日计算。
#### Scenario: 达量后人工复机
- **WHEN** 当前计费周期内持有通道阈值停机锁的卡请求复机
@@ -23,3 +29,19 @@
#### Scenario: 新周期仍有其他停机锁
- **WHEN** 新周期开始的持锁卡没有风险停机但存在其他停机锁
- **THEN** 系统解除通道阈值锁但不调用运营商复机
#### Scenario: 达量后强制复机被拒
- **WHEN** 当前计费周期内的持锁卡触发保护期强制复机或机卡分离复机
- **THEN** 系统拒绝复机、保留该锁并记录拒绝事实
#### Scenario: 停机结果未知恢复确认
- **WHEN** 通道阈值停机调用后结果未知,恢复扫描向运营商查询确认卡已停机
- **THEN** 系统补写卡停机状态并将任务结果记为已确认
#### Scenario: 停机结果未知超期转人工
- **WHEN** 通道阈值停机任务自提交起超过约定窗口仍无法确认结果
- **THEN** 系统标记异常并留存安全失败原因转人工处理,不自动删除锁
#### Scenario: 换运营商后周期归属
- **WHEN** 持锁卡更换运营商后到达新计费周期
- **THEN** 旧周期锁保留,新周期按新运营商的上游流量重置日计算并处理

View File

@@ -1,8 +1,30 @@
## 1. 阈值控制
- [ ] 1.1 追踪通道流量、卡状态、停复机锁、运营商任务与恢复链路
- [ ] 1.2 新增通道配置、周期停机锁、任务结果的成对迁移、模型、索引和管理接口
- [ ] 1.3 实现达量写锁/可靠停机、周期扫描解锁/条件复机及幂等恢复。
## 1. 迁移与模型
- [x] 1.1 成对迁移:`tb_carrier` 扩列 `traffic_threshold_enabled`(默认 false`traffic_threshold_value`(正数 numeric`traffic_threshold_unit`MB/GB新增周期阈值停机锁表`(carrier_id, card_id, period_start)` 唯一键软删感知、任务状态组stop/resumepending/submitted/confirmed/failed/unknown、提交认领字段`stop_submitted_at`/`resume_submitted_at`)、`anomaly_flag``failure_reason`
- [x] 1.2 锁表模型与 Store含唯一键冲突 23505 识别、条件更新认领方法)
## 2. 验证
- [ ] 2.1 隔离库验证周期、达量、拒绝复机、其他锁、失败重试和 up/down/up
- [ ] 2.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-carrier-channel-traffic-thresholds --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。
## 2. 阈值配置与权限
- [x] 2.1 carrier 创建/更新 DTO 增阈值字段与校验(正数、单位 MB/GB、周期复用 `data_reset_day`
- [x] 2.2 字段级平台守卫Create/Update 仅 SuperAdmin/Platform 可写阈值字段非平台提交即拒绝List/Get 对非平台账号不返回阈值字段;不改 carrier 其余字段既有可见性。
- [x] 2.3 阈值新增、修改、启用、停用并入既有 carrier 更新审计前后值快照;同步 `cmd/gendocs` 文档。
## 3. 达量判定
- [x] 3.1 period_start 计算(显式 Asia/ShanghairesetDay 取卡 carrier 的 `data_reset_day`)与 GB→MB 换算常量1 GB = 1024 MB
- [x] 3.2 `ApplyTrafficObservation` 事务内达量判定:`ReadingAccepted == true` 且 carrier 阈值启用时,以 `last_gateway_reading_mb` 换算比较;达量写锁 + 停机 Outbox 事件同事务。
- [x] 3.3 锁唯一键 23505 单独捕获为「该周期已处理」跳过,与流量基线 CAS 冲突分离。
## 4. 可靠停复机消费者
- [x] 4.1 停机消费者:提交认领(`stop_submitted_at IS NULL` 条件更新);卡已 offline 跳过 Gateway 直接确认;否则复用 `stopCardWithRetry`;成功后写 `network_status=offline``stop_reason=channel_threshold``stopped_at`;失败/未知保留锁与任务状态unknown 的 Integration Log 必带 RecoveryStrategy。
- [x] 4.2 复机消费者:结构同停机(`resume_submitted_at` 认领、复用 `resumeCardWithRetry`、成功后写 online/清停因/resumed_at新增 2 个 Outbox 事件类型与 worker 消费者注册、装配。
## 5. 周期处理与恢复 cron
- [x] 5.1 周期处理 cron`@every 1m` + `asynq.Unique(10m)` + 无 payload扫描 period_start 过期持锁锁行(按锁行自身 carrier 的 `data_reset_day` 判断),条件更新认领解锁,逐卡评估(有效主套餐 + 流量未耗尽 + 实名 OK + 非风险 extend + 无其他停因),全满足写复机事件,否则只解锁。
- [x] 5.2 恢复扫描 cron同形态扫描 stop/resume 为 submitted 的锁,只查询网关状态回填、绝不重复发起停复机;确认成功补写卡状态;自提交起超 30 分钟(常量)仍不可查 → `anomaly_flag` + 安全失败原因转人工,不自动删除锁。
- [x] 5.3 worker 注册两个 cron 与装配;周期处理与恢复 Handler 审计上下文固定 `ActorKind=AuditActorScheduledJob``Source=AuditSourceScheduler`
## 6. 停因与复机拒绝
- [x] 6.1 新增 `StopReasonChannelThreshold = "channel_threshold"` 常量;确认不纳入 `isPollingStopReason``isDeviceScopeReason`
- [x] 6.2 持锁拒绝覆盖四个入口(`resumeSingleCard``ManualStartCard``ForceStartCard``StartMachineSeparatedCard`):拒绝 + 审计 denied + 锁保留。
## 7. 验证
- [x] 7.1 隔离库验证:平台配置阈值(含 GB 换算)成功 + 审计前后值,代理/企业写被拒且读不到字段;达量触发停机、同周期重复判定唯一冲突不重复锁/不重复停机;持锁期四入口复机拒绝且锁保留;停机 unknown 保留 + RecoveryStrategy + 恢复扫描确认补写卡状态、超期 anomaly 转人工新周期解锁、条件满足才复机、其他停因stop_reason 非空非本停因或风险 extend只解锁停用后新卡不再锁、已持锁卡新周期照常解锁复机换运营商旧锁保留、新周期按新 carrier 重置日;第 12 项套餐预警与既有 3 种停因停复机回归不变;迁移 up/down/up。
- [x] 7.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-carrier-channel-traffic-thresholds --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。