fix(通道流量阈值): AUG26-011 修复失败/未知结果收敛、锁定 carrier 缺失出路与审计回归
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m29s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m29s
This commit is contained in:
@@ -43,30 +43,32 @@ period_start = M.AddDate(0, -1, 0) if now < M // 上月重置日 0 点
|
||||
- 锁唯一键 `(carrier_id, card_id, period_start)`;**postgres 23505 单独捕获为「该周期已处理」并跳过**,不得与既有流量基线 CAS 冲突(`CodeConflict`)混流——两类冲突语义不同,前者幂等跳过,后者重放重试。
|
||||
- **改阈值不生效于当前周期已持锁卡**:唯一键已占位 = 该周期已处理,当前周期维持拒绝复机;新周期按新阈值判断。
|
||||
- **停机消费者**(worker,消费停机 Outbox 事件):
|
||||
1. 提交认领:锁行 `stop_submitted_at IS NULL` 条件更新(refundchannel `claimChannelSubmission` 范式),至多一次执行;认领失败 = 已提交过 → 只走恢复查询,绝不重复调用。
|
||||
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. 失败/未知:保留锁与任务状态、记安全失败原因;unknown 的 Integration Log 必带 `RecoveryStrategy`。
|
||||
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` 遍历 |
|
||||
| `ManualStartCard` | 手动复机 |
|
||||
| `ForceStartCard` | 保护期强制复机(不加此拒绝,保护期一致性检查会强行复机持锁卡) |
|
||||
| `StartMachineSeparatedCard` | 机卡分离复机 |
|
||||
- **持锁拒绝覆盖四个入口**,入口前置检查(锁存在 → 拒绝 + 审计 `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. **周期处理**:扫描 `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`。
|
||||
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 日志转人工;不写复机事件、不调运营商。该分支是必须的出路:`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. 权限与审计
|
||||
@@ -75,12 +77,29 @@ period_start = M.AddDate(0, -1, 0) if now < M // 上月重置日 0 点
|
||||
- 审计并入既有 carrier 更新审计(`writeAudit` 整体快照前后值),不新建独立审计动作;启停即 Update 的一部分,前后值自然覆盖。
|
||||
- 通道停用:`enabled=false` 只停止新锁创建;已持锁卡当前周期继续拒绝复机,新周期解锁/复机照常;锁与历史保留。
|
||||
|
||||
### 7. 锁表结构
|
||||
### 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 边界),该场景以计划任务 + 锁行留痕转人工。
|
||||
|
||||
Reference in New Issue
Block a user