docs(通道流量阈值): AUG26-011 归档变更并同步 carrier-channel-traffic-threshold 与 package-lifecycle 主 Spec 及证据链
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m48s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 1m48s
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-31
|
||||
@@ -0,0 +1,105 @@
|
||||
## 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 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 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 边界),该场景以计划任务 + 锁行留痕转人工。
|
||||
@@ -0,0 +1,32 @@
|
||||
## Scope
|
||||
|
||||
- 迭代编号:`AUG26-011`。
|
||||
|
||||
## Why
|
||||
|
||||
运营商通道需按其计费周期流量自动停复机,不能与套餐预警混用。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增通道阈值、周期和停机锁。
|
||||
- 达量可靠停机,新周期按套餐和其他锁条件复机。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `carrier-channel-traffic-threshold`: 通道流量阈值控制。
|
||||
|
||||
### Modified Capabilities
|
||||
- `package-lifecycle`: 「套餐状态流转」新增一条复机前置条件——载体在当前计费周期内持有运营商通道流量阈值停机锁时,支付后异步自动复机与套餐激活/重置复机 MUST NOT 调用上游复机流程,并保留该锁(以 `specs/package-lifecycle/spec.md` 的 MODIFIED delta 建模;主 Spec 同步在归档时进行)。
|
||||
|
||||
## Impact
|
||||
|
||||
影响通道、卡状态、可靠任务、外部运营商调用和 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 个复机入口(`resumeSingleCard`、`ManualStartCard`、`ForceStartCard`、`StartMachineSeparatedCard`)新增持锁拒绝,因而改变了既有套餐生命周期自动复机路径的可观察行为(见 Modified Capabilities)。
|
||||
- 停机/复机调用明确失败或结果未知的子任务不退出收敛链路:恢复扫描的未决集合为 `{submitted, unknown, failed}`(只读状态查询,绝不重试外呼),超 30 分钟仍不可确认转人工;锁行引用的运营商缺失或重置日非法时,周期处理按跨期语义解锁并标记异常转人工。
|
||||
- carrier 管理接口字段级守卫与响应过滤(阈值字段仅平台账号可见可写)。
|
||||
@@ -0,0 +1,47 @@
|
||||
## Purpose
|
||||
|
||||
按运营商通道自身计费周期和累计流量控制停复机,独立于套餐真流量预警。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 通道阈值配置、权限与审计
|
||||
仅超级管理员和平台用户 SHALL 在运营商通道新建或编辑时配置流量阈值开关、阈值数值和流量单位;阈值数值必须为正,流量单位必须为系统支持的 MB 或 GB,统计周期为通道既有的上游流量重置日(1~28,上海时区),不另行配置周期。阈值字段仅对平台账号可见:列表与详情响应对非平台账号不返回阈值字段,非平台账号提交包含阈值字段的创建或编辑请求必须被拒绝;不改变通道其余字段的既有可见性与可编辑范围。新增、修改、启用、停用均必须并入通道更新审计的操作者、修改前后字段与时间记录,不另建独立审计动作。停用仅停止后续新锁创建:已持锁卡当前周期继续拒绝复机,新周期解锁与条件复机照常;停用不得删除当前周期锁或历史任务结果。
|
||||
|
||||
#### Scenario: 越权修改通道阈值
|
||||
- **WHEN** 非超级管理员、非平台用户请求创建、编辑或启停通道阈值
|
||||
- **THEN** 系统拒绝请求,不修改配置且不产生审计成功事实
|
||||
|
||||
#### Scenario: 停用后已持锁卡进入新周期
|
||||
- **WHEN** 通道阈值停用后,曾达量持锁的卡进入新计费周期
|
||||
- **THEN** 系统不再创建新锁,且该卡按新周期规则正常解锁并按条件复机,历史锁与任务结果保留
|
||||
|
||||
### Requirement: 通道阈值停机与周期恢复
|
||||
系统 SHALL 为每个已启用运营商通道按通道既有的上游流量重置日(与网关计数器清零同一事实,上海时区周期边界)判断运营商回传的每张卡当前周期累计流量,累计流量仅取网关累计读数换算,不使用本地用量统计或套餐真流量;每张卡独立判断,不扩散到设备维度,不进入既有停复机的设备扩散路径。达到或超过阈值时,系统为当前周期创建通道阈值停机锁并创建可靠停机任务;同一周期重复判定、并发或任务重放不得重复创建锁或重复发起停机。改阈值不生效于当前周期已持锁卡:当前周期维持拒绝复机,新周期按新阈值判断。
|
||||
|
||||
持锁卡在当前周期内 MUST 拒绝一切复机,包括自动复机、手动复机、保护期强制复机和机卡分离复机;拒绝时保留锁并记录拒绝事实。停机调用失败或结果未知时保留锁与任务状态,由本能力新增的恢复扫描处理:只查询运营商状态回填、确认成功后补写卡状态;自提交起超过约定窗口仍无法确认结果的,标记异常并转人工处理,不自动删除锁。本能力新增停因 `channel_threshold`,不参与既有自动复机判定,复机仅由周期处理发起。
|
||||
|
||||
新周期开始时,系统 SHALL 对仍持锁卡解除通道锁;仅存在有效主套餐、流量未耗尽、实名满足、不存在风险停机、销户或其他停机锁时创建自动复机任务,任一条件不满足时只解锁不调用运营商。其他停机锁指卡停机原因为非空且非通道阈值的其他停因,或网关扩展状态为风险停机、销户。复机失败或结果未知同样由恢复扫描处理。卡更换运营商后,旧周期锁保留,新周期按新运营商的重置日计算。
|
||||
|
||||
#### Scenario: 达量后人工复机
|
||||
- **WHEN** 当前计费周期内持有通道阈值停机锁的卡请求复机
|
||||
- **THEN** 系统拒绝复机且保留该锁
|
||||
|
||||
#### Scenario: 新周期仍有其他停机锁
|
||||
- **WHEN** 新周期开始的持锁卡没有风险停机但存在其他停机锁
|
||||
- **THEN** 系统解除通道阈值锁但不调用运营商复机
|
||||
|
||||
#### Scenario: 达量后强制复机被拒
|
||||
- **WHEN** 当前计费周期内的持锁卡触发保护期强制复机或机卡分离复机
|
||||
- **THEN** 系统拒绝复机、保留该锁并记录拒绝事实
|
||||
|
||||
#### Scenario: 停机结果未知恢复确认
|
||||
- **WHEN** 通道阈值停机调用后结果未知,恢复扫描向运营商查询确认卡已停机
|
||||
- **THEN** 系统补写卡停机状态并将任务结果记为已确认
|
||||
|
||||
#### Scenario: 停机结果未知超期转人工
|
||||
- **WHEN** 通道阈值停机任务自提交起超过约定窗口仍无法确认结果
|
||||
- **THEN** 系统标记异常并留存安全失败原因转人工处理,不自动删除锁
|
||||
|
||||
#### Scenario: 换运营商后周期归属
|
||||
- **WHEN** 持锁卡更换运营商后到达新计费周期
|
||||
- **THEN** 旧周期锁保留,新周期按新运营商的上游流量重置日计算并处理
|
||||
@@ -0,0 +1,55 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 套餐状态流转
|
||||
|
||||
系统 SHALL 按当前套餐和套餐使用状态控制上架、订购、激活、失效与到期处理。主套餐到期时,系统 MUST 先确定同一载体是否存在待生效的后续主套餐:存在时,后续套餐激活与停复机重新评估 MUST 由同一条顺序流程完成;系统 MUST NOT 依据后续套餐激活前的无套餐快照发起停机。后续套餐成功生效后,系统 MUST 依据最新套餐、流量和实名事实重新判断卡网络状态,且不得遗留 `no_package` 停机。不存在后续套餐或后续套餐经业务校验不能生效时,系统 SHALL 按现有停机规则评估卡状态。后续套餐激活结果未知或任务投递失败不得被当作无后续套餐处理并据此停机,系统 SHALL 保留既有激活恢复与轮询兜底路径。
|
||||
|
||||
套餐订单支付成功时,系统 MUST 在支付和套餐权益事务提交后,检查本订单是否存在已生效且未挂靠其他主套餐的主套餐权益。存在时,系统 MUST 基于已提交的套餐、流量、实名和停机原因事实异步尝试自动复机;支付回调不得等待上游复机结果。主套餐权益处于待生效、待实名生效或其他非生效状态时,系统 MUST NOT 因本次支付发起自动复机。载体在当前计费周期内持有运营商通道流量阈值停机锁时,系统 MUST NOT 调用上游复机流程,并 MUST 保留该通道阈值锁与既有网络状态。自动复机失败 SHALL 保留既有失败记录与轮询兜底机制。
|
||||
|
||||
#### Scenario: 支付后已生效主套餐触发自动复机
|
||||
|
||||
- **GIVEN** 套餐订单支付成功后,本订单存在已生效且未挂靠其他主套餐的主套餐权益,载体因可自动恢复的原因处于停机状态
|
||||
- **WHEN** 支付和套餐权益事务提交成功
|
||||
- **THEN** 系统异步按最新套餐、流量、实名和停机原因事实检查载体,并在全部复机条件满足时调用既有上游复机流程;支付回调不等待该调用结束
|
||||
|
||||
#### Scenario: 支付后主套餐未生效不触发自动复机
|
||||
|
||||
- **GIVEN** 套餐订单支付成功后,本订单主套餐权益仍处于待生效、待实名生效或其他非生效状态
|
||||
- **WHEN** 支付和套餐权益事务提交成功
|
||||
- **THEN** 系统不因本次支付发起自动复机,并保留后续套餐激活和轮询处理
|
||||
|
||||
#### Scenario: 支付后复机条件不满足
|
||||
|
||||
- **GIVEN** 套餐订单支付成功后,本订单存在已生效主套餐权益
|
||||
- **WHEN** 载体为手动停机、无有效套餐、流量已耗尽、不满足实名策略,或处于运营商通道流量阈值锁定期
|
||||
- **THEN** 系统不调用上游复机流程,保留当前网络状态、既有通道阈值锁与既有停复机处理路径
|
||||
|
||||
#### Scenario: 套餐状态流转
|
||||
|
||||
- **GIVEN** 套餐或使用记录处于允许的前置状态
|
||||
- **WHEN** 执行状态操作
|
||||
- **THEN** 仅发生一次允许的状态变化;不满足前置状态时返回业务错误
|
||||
|
||||
#### Scenario: 到期主套餐接续后续套餐
|
||||
|
||||
- **GIVEN** 某载体的当前主套餐到期,且存在满足激活条件的待生效后续主套餐
|
||||
- **WHEN** 系统处理该主套餐到期
|
||||
- **THEN** 系统先完成后续套餐激活并按最新权益事实重新评估停复机,且不得因到期前的无套餐快照对该载体发起 `no_package` 停机
|
||||
|
||||
#### Scenario: 到期主套餐无后续可生效套餐
|
||||
|
||||
- **GIVEN** 某载体的当前主套餐到期,且不存在后续主套餐或队首后续套餐不满足激活条件
|
||||
- **WHEN** 系统完成该套餐到期处理
|
||||
- **THEN** 系统按当前套餐、流量和实名事实执行既有停机评估
|
||||
|
||||
#### Scenario: 后续套餐激活结果未知
|
||||
|
||||
- **GIVEN** 某载体的当前主套餐到期,存在待生效后续主套餐,但激活任务投递或执行结果暂时未知
|
||||
- **WHEN** 系统处理该套餐到期
|
||||
- **THEN** 系统不得将该未知结果视为不存在后续套餐而依据旧快照发起停机,并保留既有激活恢复与套餐轮询兜底
|
||||
|
||||
#### Scenario: 卡状态轮询发现缺失的套餐任务
|
||||
|
||||
- **GIVEN** 启用轮询的卡匹配套餐检查配置,且其 `polling:package` 分片队列项因异常缺失
|
||||
- **WHEN** 卡状态轮询成功完成且未命中风险停机
|
||||
- **THEN** 系统基于最新卡状态仅补入缺失的套餐任务,不改写已存在套餐任务的执行时间;后续套餐任务仍按既有停复机条件评估该卡
|
||||
@@ -0,0 +1,40 @@
|
||||
## 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/resume:pending/submitted/confirmed/failed/unknown)、提交认领字段(`stop_submitted_at`/`resume_submitted_at`)、`anomaly_flag`、`failure_reason`。
|
||||
- [x] 1.2 锁表模型与 Store(含唯一键冲突 23505 识别、条件更新认领方法)。
|
||||
|
||||
## 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/Shanghai,resetDay 取卡 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(同形态):扫描未决子任务(`domain.UnresolvedTaskStatuses()` = `{submitted, unknown, failed}`,见 8.1)的锁,只查询网关状态回填、绝不重复发起停复机;确认成功补写卡状态;自提交起超 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 隔离库验证(验证口径与限制见 8.7:停复机执行以 stub 端点驱动,真实网关成功路径未端到端验证):平台配置阈值(含 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。
|
||||
|
||||
## 8. 审查修复
|
||||
- [x] 8.1(B1)停机/复机调用明确失败或结果未知的子任务不退出恢复扫描:扫描谓词与超期判定改用未决集合 `{submitted, unknown, failed}`、`markTaskConfirmed` 的 expected 改为未决集合(confirmed 仍只写一次)、部分索引谓词同步为未决集合(000227,已确认生产库未应用)。
|
||||
- [x] 8.2(B2)锁行引用的运营商缺失或 `data_reset_day` 非法时给出出路:周期处理按跨期语义认领解锁并同事务标记 `anomaly_flag=1` + 安全原因 + Warn 日志转人工,不写复机事件、不调运营商、不静默跳过;`ActiveLock` 维持配置缺失时不放开复机的 fail-closed 语义。
|
||||
- [x] 8.3(R1)恢复 `stopCardWithRetry`/`resumeCardWithRetry` 在 Integration Log 写入失败分支的「未完成」审计,保持既有审计覆盖与返回语义不变。
|
||||
- [x] 8.4(R2)按 ENG-AUDIT-001 显式登记锁行状态迁移(`unlocked`、`anomaly_flag=1`)的审计决定(不新建独立审计动作 + 承载方式),并在 design.md 补「审计决定」小节。
|
||||
- [x] 8.5(R4)以 `specs/package-lifecycle/spec.md` MODIFIED delta 建模「持通道阈值锁时拒绝复机」对既有套餐生命周期自动复机路径的行为变更,并更新 proposal 的 Modified Capabilities。
|
||||
- [x] 8.6 修正 design.md 与实现不符的表述(失败/未知与不可判定 carrier 的收敛路径、四入口拒绝顺序与返回、认领谓词)。
|
||||
- [x] 8.7 回归验证(口径已按实际验证方式写明):B1(unknown/failed 扫描与收敛、超期 anomaly)、B2(carrier 缺失/重置日非法的解锁与异常)与 B3(连接池自锁)**用生产等价句柄(`Service.s.db` = 根连接池)+ 已提交 fixture 建删**实测——这是唯一能暴露连接池自锁的写法(回滚事务式写法会让 `tx` 与 `s.db` 落到同一连接而掩蔽该缺陷);R1(审计写入)用回滚事务实测。**验证范围与限制**:本轮驱动的停复机「运营商侧」输入是本机 stub Gateway 端点(B2 段更是只注入会报错的空执行端口,用于证明不可判定分支不外呼),验证的是本 Change 的达量判定、锁状态机、扫描谓词与窗口逻辑;恢复扫描段用的是真实 `StopResumeService` + 真实 `CardCommander` 适配器(真实 Integration Log、真实卡状态回写与网关状态映射),仅 HTTP 端点被替换。**真实测试环境网关的成功路径未端到端验证**:测试环境网关对 `stop_card` 在本轮时间窗(2026-09-16 16:04–17:04 CST)前 60 分钟的响应为 `failed=2913 / success=14`(几乎全不可用),无法可靠构造「运营商已停机/已复机」的真实成功回执,因此 `network_status`/`stop_reason` 回写与 Integration Log 终态只在 stub 端点回执下验证过。**共享环境干扰**:验证期间**未停用**共享测试环境已部署 worker 的 cycle/recovery cron(其 `asynq.Unique(10m)` 是共享 Redis 键),存在并发处理同一批 fixture 行的可能;以亚秒级耗时(`ProcessDueLocks` 839ms/66ms、`RecoverSubmitted` 1.438s/780ms)与运行时间戳排除干扰,并在末尾连续清理两遍后核对锁表/事件/carrier/fixture 卡/审计/集成日志全 0。**证据留存**:harness 原始 stdout 未入库(仅与会话记录同在仓外,临时程序按交付前清理要求已删除);如需第三方复核,后续可在仓外保存完整 stdout,或经确认后决定是否作为 Change 证据文件入库。静态检查:gofmt/go build/go vet/openspec validate --strict/context-health 全通过。
|
||||
- [x] 8.8(B3)修复连接池自锁:新增事务句柄版 `markAnomalyInTx`(`markAnomaly` 以自身连接池委托同一实现),`processUnresolvableLock` 在事务闭包内用 `tx` 标记异常,使解锁与 anomaly 同事务提交(否则解锁已持行锁、另一条连接上的 anomaly 更新会等待该行锁直到语句超时,表现为不可判定锁行停在 locked 且周期处理 cron 每分钟空转);并完成同类自锁全面排查(结论:本 Change 仅此一处)。
|
||||
Reference in New Issue
Block a user