diff --git a/docs/流量事实模型技术方案.md b/docs/流量事实模型技术方案.md new file mode 100644 index 00000000..f1a7d0e9 --- /dev/null +++ b/docs/流量事实模型技术方案.md @@ -0,0 +1,1127 @@ +# 卡流量事实模型技术方案 + +| 项目 | 内容 | +|---|---| +| 日期 | 2026-10-15(第 5 版:新周期边界不再搁置扣减——超过上一周期 `C` 的部分实时扣、低读数确认前挂起、确认后补扣,满 `S` 天兜底按边界已清零处理,**只会晚扣、不丢量**;清零确认按不同小时计数,周期中途与边界统一;失败与无效的采集也写原始读数(`failed` / `invalid`),不参与计算(5.1、6.1、8.4、13.7)。第 4 版:冻结卡接管时**补扣**——`gray` 名单内载体接管时重放差额不论大小一律补扣,补扣、`credited_mb` 初始化、置载体 `fact_mode_since` 同一事务;问题卡优先进第一批灰度;切 `full` 前全量补扣预演,不会大面积停机就全部直接补扣(1 节第 10 条、13.7、14.2);非灰度零影响收紧为硬约束并逐点核对(13.4a):七类套餐事件全部移出原事务、P4 只在新表生效、事务 1 路径判定不查库不报错、事实写入总开关、`data_usage_exact_mb` 不改老写入点、重放不写非灰度载体;接管标记从套餐行移到载体接管表 `tb_traffic_fact_carrier`(6.3.1),`full` 模式新载体激活即接管,该表随老代码清理删除;评审修正:夜间对账与接管补扣都按载体总量;夜间对账只计已计入部分,接管补扣按完整 `usage_mb` 重放(14.2 第 1 步),不可重放载体不补扣,`stale_suspect` 改为见到低读数才解除(`S` 天兜底),有套餐卡原始读数并入事务 2,`spike` 确认期限 7 天,期初改用 `opening` 事件,新消费者补 `EvaluateAndAct` 与带 `tx` 精确扣减,阶段 2 实际切换随阶段 3,统一前后矛盾表述。第 3 版:灰度改为环境变量三档模式:关闭 / 灰度(只有名单内载体走新路径)/ 正式上线(全量切换),去掉按通道模式;路径在事务 1 定、事务 2 沿用,非灰度增长计入 `credited_mb`,消除接管时的重复扣减;新版事件退回后照常扣减;修正拆成两个事务后的遗留表述。第 2 版 2026-10-14:新增 13 节灰度范围与切换单元;跨账期回补移出范围;修正起算周期归属、`spike` 自动裁决、旧行补发事件、回执表归宿) | +| 状态 | 方案,待评审,**代码尚未修改** | +| 范围 | 卡流量观测、日流量、套餐用量、停机判定的数据来源与计算方式;阶段 3 的灰度范围与切换单元 | + +> 「已验证」指有代码直接支撑;「设计」指本方案新增;「待确认」指需要你们或 Gateway 提供方确认。 + +--- + +## 1. 结论 + +1. 现有系统以"读数 − 基线"算出的**增量为真相**,原始读数用完即弃。增量丢一次、重一次、基线被污染一次,错误就永久存在,且无法对账或回算。 +2. 老系统以"**运营商原始读数为事实,按周期取区间值,用量由事实派生**",没有"读数下降"这个概念,用量可重算。 +3. 本方案改成同一个思路:**每次采集都原样写一条原始读数(失败与无效的也写、只是不参与计算,保留 14 天),每卡每天 1 条日事实(永久);日流量、周期用量、套餐用量都由事实派生**;`tb_package_usage` 保留,但降级为"可重放校验的缓存"。 +4. 影响套餐用量的人工操作(改已用量、改过期时间、换货、重置、失效)作为**套餐事件**记录,与读数一起作为重放输入,不再被夜间对账冲掉。 +5. 采集**沿用系统现有轮询**,本方案不新增采集任务;采不到的用量在下一次采到时由包络差并入、不会丢,覆盖率照常暴露。采集能力提升(P17)由轮询改造另案(7 节)。 +6. 旧链路(02:00 落盘任务、Redis 日流量缓冲、小数余量缓存、`tb_card_daily_usage`)**本期保留并行运行,灰度结束后再下线**;**读数下降窗口判定与老 `ApplyTraffic` 整条路径本期不动**(11.2、13.4)。 +7. **已知缺口,本期不补**:旧周期最后一次观测到重置边界之间的用量无法由读数得到(8.2)。回补依赖上游按账期查询的能力,**跨账期回补移出本方案范围**,本期只在旧周期末日留痕(缺口时长 + 日均用量),待上游能力明确后另案补。 +8. **阶段 3 按载体灰度**:日事实全量算、夜间重放全量跑,只有灰度载体采用新结果扣套餐、判停机。**由环境变量模式决定:灰度模式下只有配置的名单内载体走新路径;正式上线模式下全量切换;默认关闭**;灰度单元是扣减载体(含设备下全部绑定卡与全部加油包),不是单张卡(13 节)。 +9. **非灰度卡零影响(硬约束)**:新代码出任何错,最坏结果只能是「灰度载体出错」或「新表数据缺失」,**不能改变非灰度卡的扣减、停复机、观测成功率、页面展示**。逐个接触点的保证见 13.4a,核心是:① 事务 1 里的路径判定不查库、不报错,出错一律按老路径;② 日事实写在观测事务**之外**,失败只告警,有总开关可整体关掉;③ **全部七类套餐事件都写在原操作事务之外**,失败只告警,不给激活、改已用量、退款、换货、重置、过期增加任何失败点;④ 老路径代码(`ApplyTraffic`、V1 消费者、`DeductDataUsage`、Redis 余量、日流量缓冲)一行不改;⑤ P4 无效响应在新表里记为 `invalid`、不参与计算,老路径行为不变;⑥ 日流量读侧非灰度卡读旧来源,判定出错也回退旧来源。 +10. **冻结卡接管时补扣,不免账**:P1 冻结卡在旁路期老路径扣 0、日事实照常累计,接管时重放差额很大。`gray` 名单就是人工确认——名单内载体接管时差额**不论大小一律补扣**到套餐;切 `full` 前先全量预演一次要补多少、会停多少,不会大面积停机就全部直接补扣(先加油包、后主套餐),补扣后立即重判状态,超额即停机;补扣与 `credited_mb` 初始化、置 `fact_mode_since` 同一事务,不重复扣。问题卡(冻结卡及其所在设备)优先进第一批灰度(14.2、13.7)。 + +## 2. 当前存在的问题(已验证) + +### 2.1 现在怎么算 + +1. 轮询调 Gateway `POST /flow-card/flow`,只能拿到一个数:运营商当前周期的累计已用 `r`(`internal/gateway/flow_card.go:18`)。 +2. 卡表保存上一次被接受的读数作为基线 `b`(`tb_iot_card.last_gateway_reading_mb`)。增量 = `r − b`,然后把 `b` 更新为 `r`(`internal/domain/cardobservation/traffic.go:41-80`)。 +3. 读数下降(`r < b`)时:观测日是重置日当天或前一天,认为运营商清零,增量 = `r`;其余日子拒绝该读数,增量为 0,基线不动。 +4. 增量写入 Outbox;消费者把增量扣到套餐 `data_usage_mb`(整数 MB,小数余量存 Redis),在扣减事务内判定是否耗尽、提交后异步触发停机,再调 `EvaluateAndAct` 做停复机评估(`internal/infrastructure/cardobservation/event.go`,`Consume` → `DeductDataUsage` → `EvaluateAndAct`;三步分别提交,靠五态回执续跑)。 +5. 同时把增量累加到 Redis `traffic:daily:{卡}:{日期}`,每天 02:00 落盘到 `tb_card_daily_usage` 作为日流量。 +6. 原始读数用完即丢,系统里只留下基线和累加出来的增量。 + +### 2.2 问题清单 + +**A. 计量:读数 → 增量** + +| # | 问题 | 后果 | 证据 | +|---|---|---|---| +| P1 | 读数下降只在"重置日当天或前一天"才认清零,其余日子直接拒绝,不留痕 | 只要基线被一次异常高读数抬高,或者重置日前后恰好没采到,之后所有读数都小于基线被拒,卡被**永久冻结**:套餐不再增长,也不会停机 | `traffic.go:58-67`。卡 103154:基线 72535.09 MB,运营商当前读数 48808.63 MB,9-25 之后套餐已用量一直为 0。2026-10-12 统计:近 2 天有检查、上月用量 ≥ 100 MB 的 15310 张卡中,3511 张(23%)10 月基线一次没动(含真实闲置,是上限估计) | +| P2 | 清零时旧周期尾部不补 | 旧周期最后一次观测到重置时刻之间的用量永久丢失 | 103154:9-25 10:44 最后一次观测到 9-27 0:00 约 37 小时,同类卡日均 1~5 GB | +| P3 | 重置窗口内读数为 0 也被当成清零 | 基线置 0,下一次真实读数整笔再计一次,**重复计** | `traffic.go:60-61`;电信卡抽查 124 张中 111 张读数为 0 | +| P4 | `used` 为空、null、无法解析时按 0 处理 | 无效响应被当成真实读数,触发 P3 的重复计,或被 P1 拒绝 | `internal/gateway/models.go:55-78` | +| P5 | Gateway 会缓存同一张卡的查询结果,后一次读数可能是旧值 | 重置日前后先读到新周期低值、再读到上周期旧缓存,会把上周期总量再计一次;周期中途读到旧值会被当成下降拒绝 | Gateway 维护者确认三类查询共用缓存,缓存时长未知 | +| P6 | 增量就是唯一的结果,原始读数不保存 | 错一次就永久错,无法对账,也无法修正后重算 | 2.1 第 6 步 | +| P7 | 套餐用量按整数 MB 记,小数余量放 Redis | Redis 键丢失或过期,余量不可恢复 | `internal/service/package/usage_service.go` `persistUsageRemainder` | + +**B. 归属:增量 → 套餐** + +| # | 问题 | 后果 | 证据 | +|---|---|---|---| +| P8 | 扣减时按"当前生效"选套餐,不看流量产生的时刻 | 套餐到期、切换前后的流量可能扣到错误的套餐 | `usage_service.go` `queryActivePackages` 只看 `status = 生效中` | +| P9 | 卡没有生效套餐时回退设备套餐,用的是"当前绑定" | 换绑后,旧绑定期间的流量可能归到新设备 | `resolveActivePackages` 调 `GetActiveBindingByCardID` | +| P10 | 设备套餐用量是多张卡各自增量之和,每张卡的基线互不感知 | 任何一张卡出现 P1~P5,设备套餐就跟着少计或多计,事后无法拆出是哪张卡的问题 | 103154 所在设备 3 张卡:9-25 套餐累计 79988 MB ≈ 三卡读数之和,之后该卡冻结,设备套餐少计 | +| P11 | 改已用量、改过期时间、周期重置直接改套餐,不留历史记录 | 套餐用量无法追溯,也无法和流量对账 | `internal/service/asset/service.go:960,996,1199`;`internal/service/package/reset_service.go:86,187,330` | +| P12 | 换货把套餐的 `iot_card_id` / `device_id` 原地改成新资产 | 换货前的载体信息丢失,旧卡历史用量不知道属于哪个套餐 | `internal/service/exchange/migration.go:104-130` | + +**C. 日流量** + +| # | 问题 | 后果 | 证据 | +|---|---|---|---| +| P13 | 日流量增量先进 Redis(48 小时过期),02:00 才落盘;批量失败只打日志,任务仍返回成功;按服务器本地时区算"昨天" | 落盘失败不会被发现,Redis 过期后数据丢失;服务器时区不是上海时,"昨天"算错 | `internal/task/daily_traffic_flush.go`;`cmd/worker/main.go:887,1044` | +| P14 | `tb_card_daily_usage` 实际表结构与代码模型不一致:库里是迁移 `000055` 的旧结构(`card_id / usage_date / total_data_usage`),代码用的是 `000097` 的新结构(`iot_card_id / date / usage_mb`),`000097` 因 `CREATE TABLE IF NOT EXISTS` 被跳过 | 落盘和历史查询都会因列不存在而失败,**日流量历史从来没有保存成功**,表为 0 行 | `internal/model/card_daily_usage.go`;`daily_traffic_flush.go:109-116` | +| P15 | 落盘是覆盖写,写完删 key,用 `SCAN` 扫全库;某批失败时 key 不删,但任务只处理"昨天",第二天不会再处理 | 失败批次没有重试,key 48 小时后过期,当天日流量丢失 | `card_daily_usage_store.go`;`daily_traffic_flush.go:109-127` | + +**D. 时间口径** + +| # | 问题 | 后果 | 证据 | +|---|---|---|---| +| P16 | 各观测入口的观测时间取时来源不一致;"本月起点"随服务器时区变化;`GetDataResetDay` 查询失败时静默返回 1;Asynq 定时任务用 `time.Local` | 服务器不是上海时区时,周期归属、跨月、重置窗口都会偏移;重置日取错会把正常读数判成清零或拒绝 | `polling_carddata_handler.go`;`internal/infrastructure/cardobservation/series_runner.go:544`;`traffic.go:52`;`carrier_store.go`;`cmd/worker/main.go:887` | + +**E. 采集** + +| # | 问题 | 后果 | 证据 | +|---|---|---|---| +| P17 | 轮询吞吐远低于需求:实际约 2.4k 次/小时,开启轮询的 89456 张卡按 2~5 小时间隔需要 1.8~4.5 万次/小时;约 39% 失败,主要是 Gateway 返回"请求过于频繁""系统忙" | 2026-10-12 统计约 3.9 万张卡 24 小时内没有有效采集,其中有生效套餐的 12608 张;采不到时用量延后,碰上重置日就变成 P2 的丢失 | `polling_carddata_handler.go:40-44,85-90`;Redis `polling:stats:*`;worker 日志 | + +### 2.3 根因 + +1. **把增量当事实**:读数用完即丢,所有派生值都没法回头重算(P1~P7、P10)。 +2. **用日号判断清零**:读数下降是否算清零,取决于"今天是不是重置日",而不是读数属于哪个周期(P1、P3、P5)。 +3. **改套餐不留事件**:人工操作和换货直接覆盖当前值,没有时间线(P8、P9、P11、P12)。 + +## 3. 目标与原则 + +| # | 原则 | +|---|---| +| G1 | **事实唯一**:运营商读数和套餐事件是事实,其余皆为派生;派生值错了,修事实后重算即可 | +| G2 | **不丢**:被接受的读数不会因规则被丢弃;可疑读数标记但保留 | +| G3 | **周期由时间决定**:不由读数大小、不由轮询当天日号决定 | +| G4 | **可重算**:日流量、周期用量、套餐用量都可由事实重放得到,并有夜间对账 | +| G5 | **只记有意义的卡**(第 5 节) | +| G6 | **新代码日期口径唯一**:新增字段与新增逻辑一律按 `Asia/Shanghai`(UTC+8);**本期不改任何现有时间计算**(11 节) | +| G7 | **停机及时**:观测提交后由 Outbox 消费者立即扣减,**扣减事务内判定是否耗尽、提交后触发停机**(与现状一致,15.2 第 14 项),不等夜间重放 | +| G8 | **不追溯改写**:人工改过期时间、换货等只从发生时刻起生效,不重新分配已经过去的用量 | +| G9 | **渐进**:分阶段落地,每阶段可在测试环境独立验证;阶段 3 再按扣减载体分批灰度,名单外继续走老路径(13 节) | + +非目标:运营商侧读数准确性、轮询吞吐提升(P17)、套餐自带 30 天重置周期的业务口径、**跨账期回补(旧周期尾部,8.2)**。 + +## 4. 总体设计 + +``` +轮询 / 人工刷新 / 系列观测 + │ + ▼ + 事务 1(观测事务,老逻辑不动) + 老 ApplyTraffic:锁卡 → 算增量 → 写卡表三列 → 通道阈值判定 + → 定路径:模式允许该载体(灰度:在名单内;正式上线:全部)且 fact_mode_since 非空 + → 非灰度载体写 V1 Outbox 事件(现状);灰度载体不写 V1 + │ + │ 提交,把「本次走哪条路径」交给事务 2 + ▼ + 事务 2(事实事务,提交后独立开启;失败只告警,不影响事务 1) + 锁当天日事实行 → 校验 → 周期归属 → 可疑判定 → UPSERT 日事实(本次增长 Δ) + → 灰度:usage_mb > credited_mb 时写新版 Outbox 事件 + → 非灰度:credited_mb += Δ(这部分由老路径的 V1 扣,新路径不再扣) + │ + │ 有套餐卡的原始读数在事务 2 内写;无套餐卡单独 INSERT(失败只打日志,可开关) + ▼ ▼ + tb_card_traffic_reading(原始读数,14 天) Outbox 消费者(扣减事务) + tb_card_traffic_daily(日事实,永久) 锁日事实行 → 增量 = 当日用量 − 已计入 + │ → 扣套餐 → 停机判定 → 更新已计入 + │ │ + └──────────────┬────────────────────────┘ + ▼ + 套餐事件 tb_package_usage_event ──► 夜间重放:日事实 + 套餐事件 + 绑定时间线 +(改已用量/改过期/换货/重置/过期/失效) → 校正 tb_package_usage 缓存及状态 +``` + +**为什么拆成两个事务:** 事务 1 就是今天的观测事务,除了「灰度载体不发 V1」这一处分支,内容与今天一致——这样写日事实失败绝不会连带回滚老路径对卡表的更新,非灰度卡的采集成功率与今天完全相同。事务 2 失败时这次观测的用量不丢:包络是单调的,**下一次观测的 `C` 会把漏掉的量一起算回来**(8.1),只是归日推迟。 + +**路径只在事务 1 定一次,事务 2 沿用,不重新判定。** 两个事务之间有提交间隙。如果事务 2 再去读一次接管状态,而该载体恰好在间隙里被接管,就会出现事务 1 已按老路径发了 V1(扣 Δ)、事务 2 又按新路径发新版事件(再扣 Δ)。沿用同一个判定后,每次观测的 Δ 只归一条路径:老路径由 V1 扣,并在事务 2 里计入 `credited_mb`;新路径由新版事件按 `usage_mb − credited_mb` 扣。非灰度用 `credited_mb += Δ` 而不是 `credited_mb = usage_mb`,是为了不把同一行上另一条路径产生、尚未扣减的用量一并吞掉(新旧进程并存时会出现,13.3)。 + +数据流向单向:**事实 → 派生**。 + +灰度期间(13 节)这张图只对**灰度范围内的扣减载体**成立:范围外的载体仍走老的"读数 − 基线"增量扣减,日事实与夜间重放照样全量进行,但结果只用于比对和告警,不落 `tb_package_usage`。 + +## 5. 记录原则(谁、什么时候记) + +### 5.1 原则 + +一次有效观测(正常取到读数),满足下面**任一**条件就写入原始读数与日事实: + +| 条件 | 判定 | 写入 | +|---|---|---| +| A. 有套餐 | 卡自身有 `status = 生效中` 的套餐;或卡无生效套餐但其当前绑定设备有生效套餐(与 `resolveActivePackages` 同口径) | **每次都写**,读数为 0、与上一条相同也写 | +| B. 无套餐但有流量 | 读数 `> 0`,且为该卡本周期首条记录,或与该卡最近一条读数不同 | 满足才写 | + +条件 B 的"最近一条读数"取该卡**日事实最新一行的 `reading_last_mb`**(无行则视为无最近读数),不查原始读数表——后者是按天分区的大表,每次观测扫一遍代价过高。 + +不记录:**无套餐**且读数为 0;**无套餐**且读数与最近一条相同。 + +- **失败与无效的采集也记录,但不参与计算**:查询失败(报错、限频、超时)记 `failed`;返回了但无效(空、null、无法解析、`< 0`、NaN/Inf)记 `invalid`;两者 `reading_mb` 为空、写 `error_code`。只记**有套餐**的卡(无套餐卡的失败与无效不记,控制写入量)。这两类不写日事实、不进包络、不作为可疑裁决与清零确认的依据,只用于排障、覆盖率归因与统计。`FlowUsageResp` 增加"读数是否有效"的标记,**只给新代码用**。**老路径行为不变**——仍按今天的方式把无效响应当 0 交给老 `ApplyTraffic`,`last_data_check_at` 照常刷新,非灰度卡的观测、扣减、页面展示与今天一致(13.4a)。 +- 原则只决定"写不写事实",不影响观测本身的其它处理(状态同步、通道阈值判定)。 + +### 5.2 为什么不是"每次观测都记" + +大量无套餐、无流量的卡没有计费意义;记录原则把写入限定在有计费意义的卡上。代价:无套餐卡"读数没变"与"没查到"在事实表里无法区分,只对有套餐的卡保证"有观测就有行"。 + +## 6. 数据模型 + +迁移从 `000240` 起(当前最新 `000239`),成对 up/down。 + +### 6.1 `tb_card_traffic_reading`(原始读数) + +运营商读数原样留底:**每次采集写一条**,不合并、不改读数,不按小时聚合;有套餐卡的失败与无效采集也各写一条(5.1)。每张卡 2~5 小时轮询一次,一天约 5~12 条(不含失败)。 + +| 列 | 类型 | 含义 | +|---|---|---| +| `id` | bigserial | | +| `iot_card_id` | bigint | | +| `observed_at` | timestamptz | 观测时间(上海) | +| `reading_mb` | numeric(14,3) | Gateway 返回的读数,原样;`failed` / `invalid` 时为空 | +| `error_code` | varchar(32) | 仅 `failed` / `invalid`:失败或无效的原因(限频、系统忙、超时、空值、无法解析等) | +| `cycle_start` | date | 读数所属运营商周期起始日(上海) | +| `segment_no` | smallint | 周期内段号,默认 0;未预期清零时 +1(8.4) | +| `source` | varchar(20) | `poll` / `manual` / `series`(与现有三个观测入口一一对应) | +| `has_package` | boolean | 记录时是否有套餐 | +| `status` | varchar(20) | `valid` / `zero_suspect` / `stale_suspect` / `regress_pending` / `excluded` / `spike`(8.4)/ `failed` / `invalid`(5.1);阶段 0 只写 `valid` / `failed` / `invalid`,阶段 1 起写判定结果 | +| `created_at` | timestamptz | | + +**写入方式(控制对数据库的压力):** + +| 项 | 做法 | +|---|---| +| 时机 | 不放进观测事务(事务 1)。阶段 0 在观测事务提交后单独 `INSERT`;阶段 1 起,**有套餐卡**的原始读数改为在事务 2 内与日事实一起写,无套餐卡仍单独 `INSERT`;`failed` / `invalid` 记录在采集失败处单独 `INSERT`,不进任何业务事务 | +| 失败 | 有套餐卡:随事务 2 一起失败、只告警(12 节)。无套餐卡与 `failed` / `invalid` 记录:只打日志、记失败计数,漏记不补 | +| 开关 | 配置项"写原始读数",关掉即停写,不用发版。阶段 1 起**只控制无套餐卡与全部 `failed` / `invalid` 记录**;有套餐卡的有效读数不可关——8.4 的可疑裁决与清零确认(`regress_pending` 的 K 个小时 / T 小时、`spike` 重算)依赖其读数历史 | +| 表 | 只追加;索引只建 `(iot_card_id, observed_at)`;按 `observed_at` 按天 `RANGE` 分区,保留 14 天,到期删分区 | +| 量 | 按现有吞吐约 2.4k 次/小时,平均每秒不到 1 条;轮询修好后上限约 4.5 万次/小时,平均每秒约 12.5 条;以上按全部采集尝试计,已含失败与无效记录(现失败率约 39%,有套餐卡行数比只记成功时多约六成);并发受轮询信号量(总 100)限制 | +| 上线 | 先压测(6.5),再只对有套餐的卡开启,观察后放开 | + +- 裁决改变状态时(8.4)才更新已有行的 `status`,频率很低。 +- 原始读数是留底与判定依据,计费以日事实为准;有套餐卡的原始读数与日事实同在事务 2 内写,二者一起成功或一起失败(4 节、6.2),裁决不会缺读数历史。 +- 只保留 14 天,所以依赖原始读数的人工裁决必须在保留期内完成:`spike` 人工确认期限 7 天(8.4)。 + +### 6.2 `tb_card_traffic_daily`(日事实,永久) + +| 列 | 类型 | 含义 | +|---|---|---| +| `id` | bigserial | | +| `iot_card_id`、`date`、`cycle_start`、`segment_no` | | 联合唯一。正常一天一行;未预期清零当天会有两行(两段) | +| `segment_base_mb` | numeric(14,3) | 本段起算值,正常为 0;仅阶段 1 起算的首行使用(14.1) | +| `reading_first_mb`、`reading_last_mb`、`reading_max_mb` | numeric(14,3) | 当天该段首次、最后一次、最大**有效**读数;当天全部观测均为可疑时为 NULL(见 8.1) | +| `envelope_mb` | numeric(14,3) | 截至当天的段内累计包络 `C(d)`(8.1) | +| `usage_mb` | numeric(14,3) | 当日该段用量 = `C(d) − C(上一行)` | +| `gap_days` | smallint | 与上一行之间缺失的天数;> 0 表示用量含缺口期。周期首行从 `cycle_start` 起算,不从上一周期最后一行起算(8.1) | +| `credited_mb` | numeric(14,3) | 本行 `usage_mb` 中已扣到套餐的部分(9.1) | +| `first_at`、`last_at`、`obs_count`、`suspect_count` | | 观测范围、次数、可疑观测次数 | +| `created_at`、`updated_at` | | | + +- **在观测事务之外的独立事务(事务 2)内更新**,不进观测事务(4 节)。事务 2 开始时先 `SELECT ... FOR UPDATE` 锁定该卡当天当段的行(不存在则插入),保证同卡并发观测串行;该事务只锁日事实与 Outbox,不回头锁卡,符合"卡 → 日事实 → 套餐"的全局锁顺序(15.2 第 14 项)。 +- 建表设 `fillfactor = 85`,配合 autovacuum 参数,减少同一行反复更新带来的膨胀。 +- **按 `date` 月度 `RANGE` 分区**,永久保留、不归档不删除(8.9 万卡约 3200 万行/年);与原始读数的按天分区留 14 天不同(15.2 第 16 项)。 +- 某一天的日流量 = 当天各段 `usage_mb` 之和。 + +### 6.3 `tb_package_usage`(保留,降级为缓存) + +补两列: + +| 列 | 含义 | +|---|---| +| `data_usage_exact_mb numeric(16,3)` | 精确已用量,**只由新路径维护、只对已接管载体可信**;新路径扣减时 `data_usage_mb` 取其向下取整供现有逻辑兼容;已接管载体不再用 Redis 小数余量,非灰度载体的老路径照旧(15.2 第 15 项) | +| `replay_checked_at timestamptz` | 最近一次重放对账时间 | + +接管标记**不放在套餐行上**,放在载体接管表(6.3.1)。 + +### 6.3.1 `tb_traffic_fact_carrier`(载体接管表) + +记录每个扣减载体是否已由新路径接管。一个载体一行,`(carrier_type, carrier_id)` 唯一。 + +| 列 | 含义 | +|---|---| +| `carrier_type` | `iot_card` / `device`(与 `resolveActivePackages` 解析出的扣减载体同口径) | +| `carrier_id` | 卡 ID 或设备 ID | +| `fact_mode_since timestamptz` | 该载体被新路径接管的时刻,**即 14.2 的 `t1`**,由启动对齐写入;为空表示走老路径。它是接管的**落库记录**,不是额外的灰度条件:对齐保证非空 ⇔ 模式允许该载体 ∧ 对齐成功;运行中被换货、换绑排除时清空(13.3、13.5) | +| `fact_mode_blocked_at timestamptz` | 被排除出灰度的时刻;非空时启动对齐不再把它拉回灰度,需人工清除(13.5) | +| `blocked_reason varchar(32)` | 排除原因:`exchange`(换货)/ `rebind`(换绑)/ `replay_deviation`(重放偏差超阈值,经人工核对后排除,13.7) | +| `created_at`、`updated_at` | | + +**为什么不放在套餐行上:** 路径按载体判定,而一个载体下的套餐行会不断新增和到期。标记放在套餐行上会出三类问题: + +- 接管后新买的加油包、续费产生的新套餐行没有标记,载体处于「部分套餐有标记」的未定义状态; +- 被接管的套餐全部到期、只剩新套餐时,载体悄悄退回老路径,扣减与日流量读侧来源一起切回,下次重启再被接管、再跑一遍补扣。主套餐每个周期都续,这种来回切换会周期性发生; +- 排除标记同理,新套餐行没有排除标记,重启时被拉回灰度。 + +放在载体上,套餐怎么新增、到期都不影响路径;排除只记一次;「当前接管了哪些载体」一条查询即得。 + +**新出现的载体(`full` 模式):** 载体首次激活套餐(新卡、新设备)时,在套餐激活**提交之后**插入一行,`fact_mode_since = now()`;插入失败只告警,该载体保持老路径,下次启动对齐再接管——不给激活增加失败点。新载体没有旁路期欠扣,不需要补扣与 `credited_mb` 初始化以外的步骤(其卡若已有日事实行,同事务按 14.2 第 2 步初始化)。`off` / `gray` 模式下不插入,由启动对齐按名单处理。 + +**生命周期:** 本表是新老两条路径并存期间的开关记录,改回 `off` / `gray` 的退回就靠它。全量稳定、老路径代码清理时随之删除(14 节清理表)。 + +### 6.4 `tb_package_usage_event`(套餐事件,永久) + +记录一切**不经过流量扣减**而改变套餐用量、有效期、载体或状态的操作。 + +| 列 | 含义 | +|---|---| +| `package_usage_id` | 套餐使用记录 | +| `event_type` | `opening`(期初快照,14.1)/ `activated` / `expires_changed` / `usage_set` / `reset` / `expired` / `carrier_changed` / `invalidated` | +| `occurred_at` | 发生时刻(上海) | +| `before_value`、`after_value` | jsonb,变更前后关键字段 | +| `source_type`、`source_id` | 来源(管理端操作、换货单、重置任务、退款单等) | + +**七类事件一律写在原操作事务之外**(原操作提交之后写),失败只告警 + 进补偿清单,不影响原操作。 + +| 事件 | 写入位置 | 事务 | 频次(生产 30 天实测) | +|---|---|---|---| +| `reset` | `package.ResetService` 三个重置入口(`reset_service.go`) | 原操作事务之外 | 28,650(≈1,150/天) | +| `expired` | 套餐过期的定时任务入口 | 原操作事务之外 | 7,976(≈320/天) | +| `activated` | 套餐激活的现有入口(`activation_service.go` 四处) | 原操作事务之外 | 2,531(≈100/天) | +| `expires_changed` | `asset.Service.UpdatePackageExpiresAt`(`service.go:960`) | 原操作事务之外 | 495(≈20/天) | +| `invalidated` | 退款失效的现有入口 | 原操作事务之外 | 108(≈4/天) | +| `usage_set` | `asset.Service.UpdatePackageUsage`(`service.go:996`) | 原操作事务之外 | 0(该入口 30 天未被使用) | +| `carrier_changed` | `exchange.migratePackageUsageWithTx`(`migration.go:104`),记录旧/新 `iot_card_id`、`device_id` | 原操作事务之外 | 0 | + +**为什么全部放事务外:** + +- 这些入口对**全部套餐**生效,不分灰度与否。放进原事务,事件表写失败(表锁、分区、约束、代码缺陷)就会让激活、改已用量、退款失效、换货、重置、过期整体回滚——**非灰度卡被连带影响**,违反 1 节第 9 条。 +- 以重置为例:事件写不进去会让 `data_usage_mb` 没清零、`next_reset_at` 没推进、`triggerResumeForPackages` 不被调用,**该复机的卡不复机且没人看见**。 +- 代价是可能缺事件。兜底是事件链缺口识别(下一段):缺事件的载体不可重放、不自动修正、不补扣接管,只进清单——后果是"失去自动对账",而不是"被错误修正",且只影响灰度载体。 +- `expired` 是本版新增:`expired` 改变"生效集合",而 8.3 规则 2 的重放需要按时刻判定哪些套餐生效,原先漏了这个写入点。 + +**事件缺失的兜底(必须,配合事务外写入):** 重放时若发现"套餐当前状态或载体推不出事件链",说明有事件缺失,该载体标记为**不可重放、不自动修正、只告警并进清单**(15.2 第 17 项)。这样漏事件的后果是"失去自动对账",而不是"被错误地向上修正"。 + +### 6.5 原始读数写入压测(测试环境,上线前) + +| 档位 | 速率 | 含义 | +|---|---|---| +| 1 | 12.5 条/秒 | 轮询修好后的平均值 | +| 2 | 50 条/秒 | 4 倍 | +| 3 | 125 条/秒 | 10 倍突发 | + +速率按全部采集尝试计(含 `failed` / `invalid` 记录)。每档同时观察:插入耗时、连接池等待、数据库 CPU 与 IO、现有轮询任务耗时是否变化。任何一档影响现有轮询,就不开启或改为批量写入。 + +**压测前必须注意:测试数据库实例是共享的。** 实测该实例(`172.21.0.2:5432`)上除 `junhong_cmp_test`(32 GB)外,还有 `erp_db`(46 GB)、`cdr`(12 GB)、`yt_cdr`、`jiesuan` 等其他业务库。第 3 档 125 条/秒的突发写入会消耗整个实例的 CPU 与 IO,**影响面不止本项目**。因此:压测时段需与该实例的其他使用方约定;优先在低峰执行;同时观察实例级指标而不只是本库指标;一旦实例级指标异常立即停止。 + +## 7. 采集沿用现有轮询(本方案不新增采集任务) + +### 7.1 结论 + +本方案**不新增任何采集任务**:观测来源就是现有的流量轮询、人工刷新、系列观测三个入口,频率、队列、并发信号量、限频与退避全部不动。原先设想的"每日凌晨补采"去掉,理由与取舍见 7.2;采集能力的提升(P17)由轮询改造另案解决。 + +### 7.2 为什么不加补采 + +- **按"当天 0 点后尚无观测"筛选,在凌晨等于不筛选。** 00:05 时"当天"才过去 5 分钟,按现有约 2.4k 次/小时的吞吐只处理了约 200 次,开启轮询的 89456 张卡里 99.8% 都满足条件,等于把全部卡再投一遍,与常规轮询完全重合,还会占满当天最前面几小时的吞吐,把真正的缺口(一整天都没采到的卡)挤到更后面。 +- **归日会系统性偏移。** 凌晨读到的读数,其增量大部分是前一天晚间产生的,但按归日规则整笔记在当天(包络模型下落在当天的行),等于每天把前一天的尾巴搬到后一天。放在当天晚间采才归属正确。 +- **真正的瓶颈是吞吐,不是任务个数。** 仅有生效套餐的 12608 张卡跑一轮就要占约 5 小时的全部吞吐;在 P17 修好之前,无论把补采放在几点都保证不了"每天每卡至少一次",只会放大当前约 39% 的失败率。 +- 因此:**先按系统本身的轮询跑**,覆盖率照常统计并暴露;要提高覆盖率,去改轮询(P17),不要再叠一个采集任务。 + +### 7.3 采不到的后果(可接受) + +- 没采到的卡,用量在下一次采到时通过包络差并入,**不会丢**(8.1);只是归日会推迟到有观测的那一天,并由 `gap_days` 标出跨了几天。 +- 覆盖率用一条查询暴露:`当日有日事实行的有套餐卡数 / 有套餐卡数`;缺口归因到 P17(有套餐卡的 `failed` 记录给出逐卡证据:没去查、被限频还是 Gateway 报错),不与"零用量"混在一起。 +- 重置边界附近采不到会形成旧周期尾部缺口,本期只留痕不补(8.2)。 + +## 8. 派生与计算 + +### 8.1 卡日用量 + +同一卡、同一 `(cycle_start, segment_no)` 内按日期排序: + +``` +C(首行之前) = segment_base_mb // 正常为 0 +C(d) = max( C(上一行), 当天 status = valid 的 reading_max_mb ) + = C(上一行) // 当天无 valid 读数(reading_max_mb 为 NULL) +usage(d) = C(d) − C(上一行) +``` + +- 当天有观测但全部可疑时照样建行:`obs_count > 0`、`suspect_count = obs_count`、三个读数列为 NULL、`usage_mb = 0`、`C` 不变。实现上 NULL 必须按"不参与 max"处理,不能当 0,否则包络会被打回去。 + +- 只有 `valid` 的读数进入包络,可疑读数不产生用量、不污染 `C`。 +- 缺口:中间缺失的天数记在 `gap_days`,用量并入下一个有观测的日子,不平摊。周期(段)首行的 `gap_days` 从 `cycle_start` 起算:例如 9-25 后一直没采到、9-29 才采到,新周期首行 `gap_days = 2`(9-27、9-28),表示用量含 9-27 起的未采集天数。 +- `cycle_start` 由 `carrierthreshold.PeriodStart` 计算并写入行,之后重置日配置变更不影响历史行。 + +### 8.2 周期用量与旧周期尾部(本期不回补) + +周期用量 = 该周期各段最后的 `C` 之和。新周期从 0 重新累计,不需要拿读数和基线比大小。 + +**旧周期尾部是已知缺口(P2),本方案不回补。** 旧周期最后一次观测到重置边界之间的用量,边界之后读数已经是新周期的值,本地读数无论怎么算都拿不到;要补只能向上游查已结束周期的终值,而上游当前的流量查询只有一个"当前周期已用"(`internal/gateway/flow_card.go:18`、`models.go:132-140`,入参仅 `cardNo`)。**跨账期回补因此移出本方案范围,待上游查询能力明确后另案处理。** + +本期只做留痕,不做补记: + +- 缺口时长**不新增列**:旧周期末日行已有 `last_at`,周期结束边界由 `PeriodStart` 算出,两者相减即得"最后观测时刻 → 边界"的时长;日均用量由该卡周期内的日事实算出。按卡出一张缺口清单(卡、周期、缺口时长、日均用量、估算缺口量)即可,查询层实现,不改表结构。 +- 缺口不计入任何套餐用量,不产生增量,不触发停机判定;对账时按"已知缺口"口径排除,不当成差异告警。 +- 本期**没有压缩手段**:不新增采集任务(7 节),缺口大小等于"最后一次观测到边界"的间隔,受现有轮询频率与 P17 吞吐决定。要压缩只能在轮询改造里做(例如重置边界前后加密采集),那属于 P17 的范围。 +- 103154:9-25 10:44 最后一次观测到 9-27 0:00 约 37 小时,同类卡日均 1~5 GB,不是小数。这类缺口在本期会以留痕形式可见,但不会被补回。 + +**后续另案时对上游的要求(留档,本期不实现):** 按周期起始日期查已结束周期的终值(不能只按"上月",联通重置日是 27 号);已结束周期的值必须稳定不变;取不到要返回明确错误而不是 0;至少可回溯 2 个周期;明确周期边界时区、以及是否与 `/flow` 共用缓存与限频桶(不能挤占现有轮询,P17)。补记的落地方式也留到那时再定——本方案的日事实与包络结构支持事后补一条旧周期末日读数再重算,不需要为此返工。 + +### 8.3 套餐用量(确定性重放) + +纯函数,放 domain 层。输入: + +| 输入 | 来源 | +|---|---| +| 卡日用量序列 | 日事实 | +| 卡-设备绑定时间线 | `tb_device_sim_binding.bind_time / unbind_time` | +| 套餐时间线 | `tb_package_usage` 当前值 + `tb_package_usage_event` 历史 | + +规则: + +1. **载体按时刻取**:套餐在某一时刻属于哪张卡或哪个设备,按 `carrier_changed` 事件分段;卡在某一时刻属于哪个设备,按绑定时间线。换货前旧卡的用量仍归该套餐。 +2. **生效集合按时刻取**:激活、过期、失效都以事件发生时刻为界,`expires_changed` 只从 `occurred_at` 起改变之后的有效期(G8),不重新分配已过去的用量;失效套餐在失效前的用量仍归它。 +3. **分配顺序**与现有一致:**先扣加油包、后扣主套餐**(`master_usage_id IS NOT NULL` 优先,`usage_service.go:346`),同类再按 `priority`、`id`;按剩余额度依次扣,耗尽转下一个,全部耗尽超额记在最后一个。 +4. **期初、重置与人工设值**:`opening` 事件时刻已用量设为期初值(14.1);`reset` 事件时刻已用量归 0;`usage_set` 事件时刻已用量直接设为 `after_value`;之后的用量在其上累加。 +5. **重放起点**:不必每次从 `t0` 重放。每个套餐最近一次 `opening` / `reset` / `usage_set` 之后的已用量与更早的历史无关,载体的重放起点取该载体当前全部生效套餐各自最近一次这三类事件中**最早**的时刻;没有则取 `t0`。 +6. 额度判定、虚总量快照、展示倍率沿用现有口径(`EffectiveThresholdMB`、`virtual_total_mb_snapshot`)。 + +**日粒度的限制:** 日事实一天一条,事件发生在一天中间时,当天用量按"事件时刻之前最后一条原始读数"切分;原始读数已过保留期的历史日子,整天归属当天结束时仍生效的套餐。 + +### 8.4 可疑读数 + +读数原样保留,只改状态。判定**不依赖本地基线**,只看周期、读数本身和后续走势。`failed` / `invalid` 记录不参与以下任何判定。 + +**目标:只会晚扣,不会丢量,也不会重复扣。** 拿不准的读数先挂起,确认后重算补扣(9.1 第 5 条)。 + +| 情形 | 状态 | 处理 | +|---|---|---| +| 新周期第 0 段、清零**尚未确认**时,读数 ≥ 上一周期最后的 `C` | `valid`(暂按超出部分计) | 可能是新周期真实用量,也可能是 Gateway 旧缓存或运营商清零延迟,暂时分不清。**不搁置**:第 0 段 `segment_base_mb` 暂设为上一周期最后的 `C`,读数进包络,只计超出 `C` 的部分并实时扣减。不依赖套餐大小,不设用量门槛 | +| 新周期第 0 段、清零尚未确认时,读数 `0 < r < C` | `regress_pending` | 疑似已清零,挂起不计,按下方「清零确认」裁决 | +| 读数恰好为 0、且本段 `C > 0`(周期中途,或新周期清零尚未确认时) | `zero_suspect` | 疑似无效返回(电信常见);不进包络,**不作为清零确认依据,永远不触发新段**;持续超过 1 天告警人工核对 | +| 周期中途读数 `0 < r < C` | `regress_pending` | 不进包络,按「清零确认」裁决 | +|  后续读数回到 `≥ C` 并停在高位 | `excluded` | **仅周期中途适用**:低读数是缓存或瞬时回退,作废。新周期边界附近的高读数视为旧缓存,不打断确认 | +|  满足「清零确认」 | 升级裁决 | 新周期边界:直接按已清零起算(见下),不看 `ρ`。周期中途:比值 `ρ = 最近读数 / C` | +|   `ρ ≥ 50%` | `spike` | 此前抬高 `C` 的读数**疑为**上游尖峰;标 `spike`、告警、进人工清单,**不自动移出包络**(见下);人工确认后才移出、低读数转 `valid` 并重算 | +|   `ρ < 10%` | 新段 | 未预期清零(运营商手工重置、改套餐):`segment_no + 1`,低读数作为新段首读 | +|   `10% ≤ ρ < 50%` | 保持 `regress_pending` | 告警人工核对 | + +**清零确认(周期中途与新周期边界统一):** + +- 依据是**大于 0 的低读数**,按**不同小时**计数:同一小时内多条只算 1 个(人工刷新、系列观测与轮询挨得很近时,可能是同一份缓存)。原始读数照常逐条存,按小时只是确认时的计数口径。 +- 低读数出现在 ≥ `K` 个不同小时(默认 3),首尾跨 ≥ `T`(默认 5 小时),按时间持续增长。 +- 新周期边界:低读数之后读数一路增长,**即使涨过上一周期的 `C`**,也算增长链上的确认依据(缓存是先高后低,不会先低后高);不在增长链上的高读数(旧缓存跳动)不打断确认。 +- 0 读数、`failed`、`invalid` 都不计入。 +- **确认之前:** 低读数挂起不计;超过 `C` 的部分照常实时扣(上表第 1 行)。凑不够小时数的效果是**晚扣**:一般 10~15 小时,采集稀疏的卡最长到下面的 `S` 天兜底;挂起部分确认后一次补扣,不丢。 +- **新周期边界确认后:** 第 0 段 `segment_base_mb` 改为 0;增长链上的读数(含涨过 `C` 的)转 `valid`,全部按新周期从 0 计,重算后用量变大的行按 9.1 第 5 条补发扣减;增长链之外的高读数改标 `stale_suspect` 移出包络——此前按超出 `C` 已扣的那部分是上一周期最后一次观测之后的真实用量,不退(9.2),进清单。 +- **新周期边界兜底(满 `S` 天,默认 3 天):** + - 有低读数但凑不够小时数:只要最近一条读数仍在增长链上(没有回跳到旧值),即确认,按上一条处理; + - **一条低读数都没出现**(清零发生在两次采集之间、新周期用量很快超过 `C`,下降被盖住):按「运营商已在边界清零」处理,`segment_base_mb` 改为 0,全部读数从 0 计,补扣此前少计的 `C`,进清单。 +- 周期中途挂起超过 `S` 天仍未裁决:告警进人工清单,读数继续挂起、不丢。 + +**其他说明:** + +- 上一周期最后的 `C` 不存在(上线后第一次重置)时,以起算基线 `b` 作为参照(14.1)。 +- "继续增长"是必要条件:只低不涨的读数(含 0)不能裁决为清零,防止把无效返回当成清零重复计。 +- `S`、`K = 3`(按不同小时计)、`T = 5 小时`、`50%`、`10%`、`spike` 确认期限全部配置项,其余为默认值、非实测值。**`S` 必须大于运营商实际清零延迟**:若运营商在 `S` 天之后才清零,兜底会把 `C` 再计一次;灰度期按 13.7 第 8 项实测各通道清零延迟后再调,兜底确认的卡全部进清单。 +- 原 `D`(6 小时后自动转正)与"新周期读数 ≥ `C` 一律不扣、等 `S` 天"的做法均取消:前者会把上一周期总量整笔再计一次;后者会让上一周期用量小的卡(如月总量几十 MB 的小流量套餐)每个新周期都停扣数天,且少计 `C`。 +- 已知晚扣场景:上一周期已用满、`C` 大于本周期额度的卡(小流量套餐常见),挂起期间新周期用量 < `C` 不扣,可能晚停,确认后补扣即停机。 +- 状态改变后,受影响日子的 `C`、`usage_mb` 整体重算:用量**变大**的行按 9.1 第 5 条补发事件扣减;用量**变小**的行实时不退,交 9.2 处理。 +- **`spike` 裁决不自动执行**,只标状态、告警、进人工清单,人工确认后才触发包络重算。**确认期限 7 天**(重算要用当天的原始读数,原始读数只留 14 天,6.1);超期未确认按「不移出」结案,包络维持现状,清单记为「超期未确认」。原因:`ρ` 的 50% / 10% 两条线与 `K`、`T` 按 15 节都是默认值、非实测值;判错会把一条真实的高读数剔出包络,`C` 降回去之后那段用量在后续读数越过旧 `C` 时会被**再计一次**,表现为多扣、提前停机。 +- `spike` 确认执行后,包络 `C` 降回正确值,**后续计量恢复正常**;但此前已经按高 `C` 扣掉的那一笔**不退**(9.2 不做向下修正),作为已知多扣进清单,由人工用改已用量入口处理。 + +## 9. 增量、停机与对账 + +### 9.1 实时扣减(Outbox 消费者,扣减事务内) + +观测事务(事务 1)与事实事务(事务 2)都只写事实和 Outbox,不扣减(与现状一致,2.1 第 4 步)。**本节描述的是灰度载体走的新路径**;非灰度载体仍在事务 1 发 V1 事件、由老路径按增量扣减,事务 2 只把本次增长计入 `credited_mb`(4 节、13.4)。 + +1. **事务 2(事实事务,在观测事务提交之后独立开启,4 节)**:锁当天日事实行 → UPSERT 日事实 → 按事务 1 定下的路径处理:灰度且当日 `usage_mb > credited_mb` 时写 Outbox 新版事件,载荷只带 `(卡, 日期)`;非灰度则 `credited_mb += Δ`(本次 UPSERT 带来的增长)。**不在事务 2 里重新判定灰度**(4 节)。事务 2 失败只告警,不回滚观测事务,用量不丢(下次观测的包络差带回)。 +2. 消费者事务:`SELECT ... FOR UPDATE` 锁定该卡该日的日事实行(同卡同日的并发事件因此串行); +3. 增量 = `usage_mb − credited_mb`: + - `> 0`:扣减并在**同一事务**把 `credited_mb` 更新为 `usage_mb`;扣减事务内判定是否耗尽,提交后触发停机(沿用 `checkAndTriggerSuspension` / `triggerSuspensionAfterCommit`)。现有 `DeductDataUsage` 自己开事务、且在事务外用 Redis 换算小数余量(`usage_service.go:78-93`),不能直接用:需新增接收外部 `tx` 的扣减方法,按 `data_usage_exact_mb` 精确累加,不经 Redis 余量; + - 提交后与 V1 消费者一样调用 `EvaluateAndAct` 做停复机评估(`event.go:285-292`)。V1 消费者做三件事:写 Redis 日流量、扣减、`EvaluateAndAct`;新版事件不需要写 Redis 日流量(读侧读日事实),但 `EvaluateAndAct` 不能漏; + - `< 0`(可疑读数裁决后用量变小):**不退回**。实时不退,夜间重放也不做向下修正(9.2),只告警并进清单,由人工用改已用量入口处理。 +4. 事件重复投递:第二次进来时 `usage_mb == credited_mb`,增量为 0,无副作用;事件丢失:下一次观测的事件或夜间重放补齐。 +5. **旧行用量变大时补发事件**:可疑读数裁决后重算(8.4)会让**过去某一天**的 `usage_mb` 变大(后续另案做跨账期回补时同理)。凡**灰度载体**出现 `usage_mb > credited_mb` 的行,一律按第 1 步同一判据对该 `(卡, 日期)` 补发 Outbox 事件,由消费者按第 3 步的公式扣减。不依赖夜间重放,否则停机会延后到次日 03:30 之后。非灰度载体的旧行变大不补发:它的扣减归老路径,差额在纳入灰度时由 14.2 第 1、2 步比对并初始化。 + +**幂等与现有回执表:** 新路径的幂等由"锁日事实行 + `credited_mb`"保证,不再需要 `tb_card_observation_effect` 的五态回执(`pkg/constants/card_observation.go:53-57`,`internal/infrastructure/cardobservation/event.go`)。该表**本期一律保留,表与数据都不动**:非灰度载体仍走 V1 事件与原回执,灰度载体的新版事件不写回执。是否下线放到本期之后单独评估(14 节),下线前提是 Outbox 中 V1 事件数为 0 且持续 N 天。 + +**实时路径的口径差异:** `DeductDataUsage` 仍按"当前生效"选套餐(P8)。实时扣减与重放结果可能不同,白天以实时为准保证停机及时,夜间以重放为准校正。 + +### 9.2 夜间重放对账 + +| 项 | 内容 | +|---|---| +| 时间 | 每日一次,上海时区 03:30 | +| 对象 | 过去 3 天内有日事实变化或套餐事件的载体;**灰度期间名单外的载体也全量跑**,但结果只比对、告警,不写 `tb_package_usage`(13.1)。切 `full` 前的全量补扣预演另跑全部生效载体,不受 3 天限制(13.7) | +| 方法 | 按 8.3 重放,得到每个套餐的精确已用量与应处状态,与 `tb_package_usage` 比对 | +| 比对口径 | **先按载体比总量,再看套餐**:偏差 = 载体内全部套餐重放已用量之和 − 库里之和。重放只计到已计入部分为止(各日事实行的 `credited_mb`),未消费的新版事件对应的用量留给消费者,不在对账里先扣 | +| 每次重放 | 已接管载体:无论是否修正,都更新其套餐的 `replay_checked_at`(6.3),用于查"哪些载体多久没被对账过"。**非灰度载体不写 `tb_package_usage` 的任何列**(含 `replay_checked_at`),比对结果只写日志与清单,避免与老路径扣减争行锁 | +| 资源 | 分批、批间限速,在 03:30 低峰跑;单批超时即跳过该批进清单,不长时间占用连接。**只比对不修正**的载体(非灰度载体、偏差在阈值内或向下的)用 `REPEATABLE READ` 只读事务,保证日事实与套餐在同一快照下读取、不加行锁;**需要向上修正**的载体按下方「修正事务」执行 | +| 事件链缺口 | 不可重放的载体(6.4、15.2 第 17 项)跳过比对与修正,只告警进清单 | +| 载体总偏差 ≤ 1 MB | 忽略;套餐之间的分布差异(实时按"当前生效"扣、重放按时刻归属,P8/P9)只进清单,不在套餐之间挪 | +| **载体总量:重放 < 库里(向下)** | **一律不修正**,只告警并列入清单,不分幅度(见下) | +| 载体总量向上,1 MB < 偏差 ≤ 阈值(默认 1024 MB) | 把**载体总差额**按 8.3 规则 3 的顺序(先加油包、后主套餐)在当前已用量上补扣,写审计;不按单个套餐分别往上修 | +| 载体总量向上,偏差 > 阈值 | **不自动修正**,告警并列入人工核对清单 | + +**修正事务(夜间向上修正与 14.2 接管补扣共用):** 一个载体一个事务,按以下顺序: + +1. **加锁**:`SELECT ... FOR UPDATE` 锁该载体全部卡的日事实行,再锁该载体全部套餐行,遵守全局锁顺序"日事实 → 套餐"(15.2 第 14 项); +2. **查在途 V1**:该载体的卡在 Outbox 中有尚未完成扣减的 V1 事件(`CardTrafficIncremented`)时,本次**跳过该载体**——夜间对账留到下次,接管保持老路径、由启动对齐后台稍后重试,进日志; +3. **锁内重算**:在锁内重新读取日事实与套餐当前值、重放并计算偏差,再写入;不使用锁外算出的结论。 + +三类风险由此排除: + +- **两个数不是同一时刻读到的**:默认 `READ COMMITTED` 下日事实与套餐分两条查询,中间消费者扣了一笔,会算出虚高的偏差(如应补 10 算成 60)。锁内重读后两者一致。 +- **读完到写入之间发生重置、人工改已用量、换货**:锁外算出的差额已过时,补到新周期或冲掉人工设值。锁内重算后以当前值为准。 +- **在途 V1**:非灰度观测时事务 1 发出带 Δ 的 V1,事务 2 随即 `credited_mb += Δ`;V1 被消费前,`credited_mb` 已含 Δ、套餐尚未扣 Δ。此时补扣会把 Δ 补一次,V1 随后再扣一次。检查在锁内可靠:事务 1 先提交 V1、事务 2 才计入 `credited_mb`,凡已计入 `credited_mb` 的 Δ,其 V1 一定已在 Outbox 中可查。 + +新版事件的消费者与修正事务之间不需要额外处理:消费者每扣 x 同时把 `credited_mb` 加 x,偏差不变;修正事务锁住日事实行后,消费者只能在其前或其后执行。 + +**为什么按载体总量修:** 实时扣减按"当前生效"选套餐,重放按时刻归属,同一笔用量可能实时扣在 B、重放归在 A。若逐个套餐只往上修,A 被补 x、B 又不许往下修,载体整体多扣 x。按载体总量算,这类差异总额为 0,不触发修正。 + +**为什么只计已计入部分:** 重放若按完整 `usage_mb` 修正,而此时还有新版事件在 Outbox 里没消费,消费者随后按 `usage_mb − credited_mb` 再扣一次,同一笔用量扣两回。只比对 `credited_mb` 部分,修正与消费者的扣减互不重叠。**这条只适用于夜间对账;接管补扣按完整 `usage_mb` 重放**(14.2 第 1 步):接管时第 2 步会在同一事务内把 `credited_mb` 置为 `usage_mb`,若补扣也只计已计入部分,`usage_mb − credited_mb` 那部分会被标成"已扣"却没人扣过。 + +**本方案不做向下修正。** 套餐已用量在重放路径上**只增不减**:重放算出来比库里小的时候,只告警、只进清单,不改库。理由: + +- 向下修正会把套餐从"已用完"变回"有可用额度",进而自动复机。而"算出来变小"有两种成因在数据上无法区分——上游之前给过虚高读数(该调小、该复机),或本地裁决判错(不该调小、该继续停机)。自动执行等于拿未实测的阈值(`ρ`、`K`、`T`,见 15 节)去赌其中一种。 +- 去掉之后,**重放不再有任何复机路径**:复机只来自套餐重置、加油包、人工操作等现有入口。 +- 多扣的部分不是没救:人工用现有的改已用量入口(`asset.Service.UpdatePackageUsage`)设值即可,会写 `usage_set` 事件,重放按 8.3 规则 4 尊重人工设值、不会再冲掉。也就是说**向下修正交给人工,不交给定时任务**。 +- 代价:被多扣的套餐在人工处理前一直是多扣状态;清单必须能看见(13.7 第 5 项)。 + +**状态联动(只剩一个方向):** 向上修正后按 `EffectiveThresholdMB` 重新判定套餐状态(与 `statusForManualDataUsage` 同口径): + +- 由"生效中"变为"已用完":走现有停机触发路径; +- 不存在由重放触发的"已用完 → 有可用额度",因此重放不触发复机; +- 状态变化一律写审计,注明"重放校正"。 + +### 9.3 其他保留项 + +- **通道阈值判定本期不动。** 现状是在观测事务内用 `decision.LastGatewayReadingMB` 判定(`application/cardobservation/traffic.go:85-94`,`channelThreshold.EvaluateInTx`),这是在跑的生产功能(AUG26-011 通道流量阈值达量停机)。它的输入源切换到日事实的 `envelope_mb` **单独成项、不在本期范围**,也不与阶段 4 的代码清理捆绑:切换前必须先确认该卡在日事实里一定有行,否则判定会失去输入,表现为"该停的不停"。 +- `tb_iot_card.last_gateway_reading_mb`、`current_month_usage_mb`、`last_month_total_mb` 保留为兼容缓存,**由老 `ApplyTraffic` 继续写入(唯一写入者,见 13.4)**,新路径只读、不得当判定依据。这三列仍有真实读者,不是内部缓存:`internal/exporter/device_scene.go:294`(设备场景导出)、`internal/model/dto/iot_card_dto.go:62`(API 响应)、`internal/task/polling_card_cache.go:85-90,134-135` 与 `internal/polling/initializer.go:288-290`(Redis 轮询缓存);只有运营报表明确禁用(`infrastructure/operationsreport/source.go:309-310`)。 + +## 10. 日流量 + +- 日流量 = 当天各段 `usage_mb` 之和,按 `(iot_card_id, date)` 索引查询;当天数据直接来自当日行。 +- **读侧按灰度生效**:查询时先由卡解析到它的扣减载体(与 `resolveActivePackages` 同口径),该载体 `fact_mode_since` 非空才读日事实;为空则仍读旧来源(当天 Redis、历史旧表),与今天表现一致。`off` 模式不做解析、直接读旧来源;解析或查接管表**出错一律回退旧来源**,不向接口抛错。目的是避免非灰度卡出现"日流量用新算法、套餐用量用老算法"的口径冲突(13.6)。全量切换后这个分支一并去掉。 +- `gap_days > 0` 的日子在展示上标注"含 N 天未采集用量"。 +- **本期不下线旧链路**:`traffic:daily:*` Redis 缓冲、`DailyTrafficFlushHandler`、`tb_card_daily_usage`、`RedisCardDailyTrafficKey` 继续并行运行(它们本来就落不进库,零成本),读侧切到日事实后可做一段并行比对;**灰度结束后再停**(13.6)。下线是不可逆动作,不与灰度并行。 +- 日内走势来自原始读数表(保留 14 天),用于排障与展示,不作计费依据。 + +## 11. 时间口径:本期不做时区统一,只约束新代码 + +### 11.1 现状核查结果(2026-10-14 实测) + +| 对象 | 时区 | 取证方式 | +|---|---|---| +| 生产 worker 进程 | `+08`(上海) | 近 3 小时 `iot_card.worker_traffic_sync` 审计里 `current_month_start_date` 2702 条 `+08:00`、47 条 `Z` | +| 测试 worker 容器 | `TZ=Asia/Shanghai`,容器内 `date` = CST,`tzdata` 存在 | SSH + `docker inspect`;`TZ` 固化在 `Dockerfile.worker:46` | +| 测试宿主机 | `Asia/Shanghai (CST, +0800)`,NTP 已同步 | `timedatectl` | +| 生产数据库 `junhong_cmp_prod` | `Asia/Shanghai`(库级 `ALTER DATABASE ... SET TimeZone`) | `current_setting('TimeZone')`、`pg_db_role_setting` | +| 测试数据库 `junhong_cmp_test` | 原为 `Etc/UTC`(集群默认,无库级设置)→ **2026-10-14 已对齐为 `Asia/Shanghai`** | 执行 `ALTER DATABASE junhong_cmp_test SET TimeZone = 'Asia/Shanghai';`,与生产库同一种做法;回退为 `ALTER DATABASE junhong_cmp_test RESET TimeZone;` | +| 应用连接会话 | 两环境始终 `Asia/Shanghai` | DSN 里硬编码 `TimeZone=Asia/Shanghai`(`pkg/database/postgres.go:19`),**不受库默认值影响** | + +- 因此那次 `Etc/UTC` 只影响**不走应用 DSN 的会话**:人工 `psql`、只读核对查询、`migrate` CLI、备份与报表脚本。应用行为从来没有受影响。对齐之后,两个环境的核对 SQL 结论一致。 +- 集群拓扑(实测):生产库在 `172.18.0.2:5432`,测试库在 `172.21.0.2:5432`,**是两个不同的实例**。但测试实例上还跑着其他业务库:`erp_db`(46 GB)、`cdr`(12 GB)、`yt_cdr`、`jiesuan` 等,`junhong_cmp_test` 自身 32 GB。这一点对 6.5 的写入压测有直接影响,见该节。 + +- 那 47 条 `Z` **不是服务器时区造成的**,100% 来自 `actor_id = card_observation:series`(卡观测序列任务),即代码里显式写 `time.Now().UTC()` 的那条路径(`internal/infrastructure/cardobservation/series_runner.go:544`),占观测约 1.7%。 +- 该路径的现存缺陷已在生产数据中确认发作:2026-10-01 上海时间 00:00~08:00,它写入的"本月起点"是 `2026-09-01T00:00:00Z`(当时 UTC 还在 9 月),共 11 条记录、6 张卡,`CrossMonth` 判定错位。每月 1 日前 8 小时都会复现。 + +### 11.2 决定:不做时区统一 + +轮询路径(约 98.3% 的观测)在两个环境本来就是 `+08`,现有行为已经正确。为 1.7% 的路径去改全量的时间计算,收益不抵风险。**本期不动下列任何现有行为:** + +| 保持原样 | 说明 | +|---|---| +| `current_month_start_date`、`CrossMonth`、`current_month_usage_mb`、`last_month_total_mb` 的算法 | 仍按各调用路径的 `now.Location()`,不改 | +| 重置窗口、通道阈值周期的现有判定 | 不改。下降窗口判定本期也不移除——老 `ApplyTraffic` 整条路径保留(13.4、14 节) | +| Asynq 定时任务的 `Location`(`cmd/worker/main.go:887` 的 `time.Local`) | 不改;进程 TZ 已是上海,现状行为正确 | +| `series_runner.go` 的显式 `.UTC()` | 不改;其对"本月起点"的影响是现状缺陷,范围 6 张卡/月量级,留档不修 | +| `GetDataResetDay` 查询失败时静默返回 1 | 不改(原 T7 退出本期范围) | +| 审计与日志的时间输出格式 | 不改(原 T8 退出本期范围) | + +### 11.3 但新代码必须自洽(只约束新增字段与新增逻辑) + +这不是"统一时区",是新表内部的日期口径必须唯一——否则同一张卡的日事实行会因为"是哪条路径观测的"而落到不同的 `date` 和 `cycle_start`,"周期由时间决定"这个前提就不成立了。只约束新东西,不碰任何现有列: + +| # | 规则 | 作用范围 | +|---|---|---| +| N1 | 固定偏移 `time.FixedZone("Asia/Shanghai", 8*60*60)`,domain 层单处导出 | 新代码 | +| N2 | 进入新领域逻辑前把 `ObservedAt` 统一 `In(上海)`,再算 `date`、`cycle_start`、`segment_no` | 新代码。对轮询路径是恒等变换(本来就是 `+08`),只对那 1.7% 的 `.UTC()` 观测生效,且**只影响新表的字段,不影响它现有的行为** | +| N3 | 周期起点复用 `carrierthreshold.PeriodStart`,不另写 | 新代码 | +| N4 | `date` 列写 `YYYY-MM-DD` 字符串,不传 `time.Time` | 新表 | +| N5 | 取时来源 = 应用层上海时间函数;**禁止用数据库时间或在 SQL 里用 `now()::date`、`CURRENT_DATE` 算业务日期** | 新代码。两库时区已对齐(11.1),但库默认值属于环境配置、可能再次漂移,而 DSN 里的 `TimeZone` 只保护走应用连接的会话;日期由应用层算死,结果不依赖任何一端的数据库配置 | +| N6 | 套餐事件 `occurred_at`、重放分段边界按上海时区 | 新代码 | +| N7 | 新路径**不写 `tb_iot_card`**,兼容三列由老 `ApplyTraffic` 继续维护(13.4、9.3) | 该表的 `last_gateway_reading_mb` 是观测更新的 CAS 条件,双写入者会让观测事务必然冲突回滚 | + +## 12. 失败处理 + +| 情形 | 处理 | +|---|---| +| 查询流量失败、限频、无效响应 | 有套餐卡写一条 `failed` / `invalid` 原始读数(单独 `INSERT`,失败只打日志),不写日事实、不参与计算;老路径与重入队按现状 | +| 观测事务(事务 1)失败 | 整体回滚;任务重入队。与今天完全一致 | +| 事实事务(事务 2)失败、超时、panic | **只告警,不回滚观测事务,不让观测任务失败或重入队**;持续失败时关闭事实写入总开关(13.4a);这次观测的日事实缺一次更新,用量由下一次观测的包络差带回(8.1),只是归日推迟 | +| `reset` / `expired` 事件写入失败 | 原操作(重置、过期)**照常提交**,只告警并进补偿清单;重放遇到事件链缺口按"不可重放、不自动修正、只告警"处理(6.4) | +| 原始读数写入失败 | 有套餐卡:随事务 2 一起失败,按上一行处理。无套餐卡:只打日志、记失败计数,不影响主流程;失败计数持续升高时告警,必要时关闭开关(开关只控制无套餐卡,6.1) | +| Outbox 事件丢失/延迟 | 不影响事实;下一次事件或夜间重放补齐 | +| 消费者并发 | 锁日事实行串行;增量为 0 时无副作用 | +| 当天没采到(吞吐不足 / 失败 / 限频) | 当天无行;用量并入下一个有观测日,`gap_days` 标出跨天数;覆盖率暴露 | +| 可疑读数裁决使用量变小 | 实时不退,夜间重放也不向下修正;只告警并进清单,由人工用改已用量入口处理(9.2) | +| 夜间重放向上偏差超阈值 | 不自动修正,告警,人工核对(9.2) | +| 接管时重放向上偏差(任意大小) | `gray` 与 `full` 都一律补扣,补扣后重判状态,写审计「接管补扣」并进补扣清单;切 `full` 前先做全量补扣预演,确认不会大面积停机(14.2 第 1 步、13.7) | +| 重放结果小于库里值 | 一律不修正,只告警进清单(9.2);套餐已用量在重放路径上只增不减 | +| 其余五类套餐事件写入失败(`activated` / `expires_changed` / `invalidated` / `usage_set` / `carrier_changed`) | 同上:原操作照常提交,只告警并进补偿清单(6.4) | + +## 13. 灰度范围与切换单元 + +### 13.1 灰度作用在哪一步 + +阶段 0、1 本身就是旁路(全量写原始读数与日事实,不改任何扣减结果),真正会把结果算错的只有阶段 3「谁来扣套餐、谁来判停机」。因此灰度只作用于阶段 3,口径是: + +> **日事实全量算、夜间重放全量跑;只有灰度范围内的载体采用新结果扣套餐、判停机。** + +- 名单外的载体:仍走老路径扣减,但日事实照写、重放照跑,结果只用于比对和告警,不落到 `tb_package_usage`。 +- 这样在一个载体都没切之前,就能看到全量 8.9 万张卡上新旧两套算法的差异分布,灰度批次只用来验证"采用新结果之后停复机行为正常"。 +- 反过来,如果做成"只有范围内的卡才按新模型计算",全量差异面要等到全量切换当天才第一次出现,风险只是被推迟,没有被降低。 + +### 13.2 灰度单元:扣减载体 + +扣减主体是 `(carrier_type, carrier_id)`,`carrier_type ∈ {iot_card, device}`(`internal/service/package/usage_service.go:294` `resolveActivePackages`)。灰度以它为单位,不以卡为单位。 + +| 项 | 定义 | +|---|---| +| 灰度单元 | 一个扣减载体:`(iot_card, 卡 ID)` 或 `(device, 设备 ID)` | +| 必须整体纳入 | 该载体下**全部** `tb_package_usage` 行——主套餐及 `master_usage_id` 指向它的所有加油包(`internal/model/package.go:87`) | +| 设备载体额外纳入 | 该设备**当前绑定的所有卡**(`tb_device_sim_binding`,`bind_status = 1`) | + +**为什么不能按卡灰度:** 设备套餐的已用量是多张卡各自增量之和(P10)。设备下 3 张卡里只有 1 张走新路,同一个 `tb_package_usage.data_usage_mb` 会被两套算法同时写——老路按增量累加、新路按 `usage_mb − credited_mb` 累加,结果既不是重复计也不是少计,而是无法解释,该载体的对账数据直接作废。103154 所在设备正好是 3 卡共用,必须整设备纳入。 + +### 13.3 切换模式:关闭 / 灰度 / 正式上线 + +**环境变量决定模式,灰度模式下名单决定范围:** + +| 配置键 | 环境变量 | 取值 | +|---|---|---| +| `traffic_fact.mode` | `JUNHONG_TRAFFIC_FACT_MODE` | `off`(默认)/ `gray` / `full` | +| `traffic_fact.carriers` | `JUNHONG_TRAFFIC_FACT_CARRIERS` | 仅 `gray` 生效:灰度载体名单,如 `device:15276,iot_card:103154`;默认空 | + +| 模式 | 哪些载体走新路径 | +|---|---| +| `off` 关闭 | 全部走老路径。阶段 3 之前的默认值,也是全局退路:改回 `off` 并重启,全部载体退回老路径 | +| `gray` 灰度 | **只有名单内的载体**;名单外全部走老路径;名单为空等于全部走老路径 | +| `full` 正式上线 | **全量切入**:全部载体走新路径,名单不再生效;切换前必须先做全量补扣预演(13.7) | + +- 不设按通道、按比例等中间模式:灰度范围就是名单里列出的载体,一眼可知;数据达标后直接切 `full`(13.7)。 +- 复用现有配置机制(`pkg/config/loader.go`:嵌入 yaml + `JUNHONG_` 前缀环境变量覆盖)。现有配置**没有热加载**(`loader.go` 未用 `WatchConfig`),改模式或名单都要重启 worker。这不是缺点:模式与名单在进程生命周期内是常量,路径切换只发生在重启这个确定的边界上。 + +**接管记录落库。** 模式与名单说的是「谁应该走新路径」,「从什么时候开始走」记在载体接管表 `tb_traffic_fact_carrier.fact_mode_since`(6.3.1)。必须落库是因为 14.2 的切换需要一个确定的 `t1`:环境变量里没有时间戳,多个 worker 实例的启动时刻也不同,`t1` 不落库,`credited_mb` 的初始化边界在实例之间就不一致,会重复扣或漏扣。一条 SQL(`fact_mode_since IS NOT NULL`)就能查出当前真实接管范围,不需要去读谁的环境变量。 + +**每次观测怎么判定。** 在事务 1 里判定一次,事务 2 沿用(4 节): + +> 模式允许该载体(`gray`:在名单内;`full`:全部)**且** 该载体 `fact_mode_since` 非空 → 新路径;否则 → 老路径。 + +**判定本身不能影响非灰度卡**(它跑在每一次观测的事务 1 里): + +- `off` 模式:不判定,直接老路径,零额外查询。 +- `gray` 模式:启动对齐后把已接管载体展开成**内存中的卡 ID 集合**(载体自身的卡 + 设备当前绑定的全部卡)。卡不在集合里 → 直接老路径,**不查库**;只有集合内的卡才去查接管表确认 `fact_mode_since`。 +- `full` 模式:查接管表(按 `(carrier_type, carrier_id)` 唯一索引,单行)。 +- 任何一步出错(查库失败、超时、解析不出载体、panic)→ **一律按老路径**,打日志计数,**绝不让事务 1 失败**。最坏结果是灰度载体某次观测走了老路径(由 V1 扣、事务 2 计入 `credited_mb`,不重不漏),不是非灰度卡出错。 + +`fact_mode_since` 不是额外的业务条件,而是「对齐已完成」的标记:模式允许但还没对齐的载体照常走老路径;运行中被 13.5 换货、换绑排除的载体也立即退回老路径,不用等重启。 + +**启动对齐(替代手工名单与手工脚本)。** worker 启动后按模式算出应纳入的载体集合,与数据对比后收敛,幂等: + +| 情形 | 动作 | +|---|---| +| 模式允许、`fact_mode_since` 为空、`fact_mode_blocked_at` 为空 | 执行 14.2 的初始化,置 `fact_mode_since = now()` | +| 模式允许、`fact_mode_blocked_at` 非空 | **跳过**,保持老路径,只打日志进清单(13.5),不自动拉回 | +| 模式允许、初始化失败(事务报错) | 保持老路径,打日志进清单 | +| 模式不允许(`off`,或 `gray` 下不在名单里)、`fact_mode_since` 非空 | 清空 `fact_mode_since`,退回老路径,打日志 | +| 模式允许、`fact_mode_since` 非空 | 不动 | + +- **执行方式按模式区分:** `gray` 名单小(第一批 10~20 个,13.7),对齐在**后台**执行、不阻塞 worker 启动(轮询、Outbox 消费、定时任务照常开始),单个载体对齐超时或失败即保持老路径进清单;未对齐完成前名单内载体照常走老路径,不会混路(路径以 `fact_mode_since` 为准);`full` 要为全部有生效套餐的载体跑一次,量大,**后台分批执行**,每批若干载体、**每个载体一个事务**,谁先提交谁先接管,进度与剩余量打日志。从 `full` 退回 `off` / `gray` 时,清空 `fact_mode_since` 同样后台分批。 +- **对齐与观测并行是安全的,不需要全局阻塞:** + - 每个载体一个事务,**步骤 1~3 与置 `fact_mode_since` 在同一个事务内完成,且置 `fact_mode_since` 是最后一步**(14.2);初始化时锁住该载体的日事实行,与事务 2 在同一行上串行。提交之前该载体完全是老路径,不存在「半接管」。 + - 对齐前后跨着的那次观测:事务 1 读到 `fact_mode_since` 为空就按老路径发 V1,事务 2 沿用老路径,把 Δ 计入 `credited_mb`;不会再被新路径扣一次。 + - 新旧进程短暂并存(滚动重启)时同理:每次观测的 Δ 只归它在事务 1 定下的那条路径,不重不漏。 +- 多实例并发启动:对齐过程加分布式锁(复用现有 Redis 锁),且本身幂等,重复执行结果不变。 +- 消费者兼容规则:**新版事件**无论载体当前 `fact_mode_since` 是否为空,都照常按 `usage_mb − credited_mb` 扣减——它对应的是新路径期间产生、尚未扣的用量;退回之后老路径的 Δ 已在事务 2 计入 `credited_mb`,不会重复。**V1 事件**遇到 `fact_mode_since` 非空也**仍要处理**——它是纳入之前或旧进程投递的,对应老路径的 Δ,已计入 `credited_mb`,与新路径不重复(14.2 第 4 步)。 + +**启动校验(`gray` 模式,必须,否则会混路):** + +- 配置里每个 `iot_card:X`,启动时按 `resolveActivePackages` 同口径解析其**实际**扣减载体;若该卡实际回退到设备套餐,**启动失败**并在日志里给出应填的值(`device:Y`)。不自动纠正——自动改会让灰度范围与运维以为的不一致。 +- 配置里每个 `device:Y`,自动展开到该设备当前全部绑定卡与该载体全部套餐(含加油包),启动日志打印展开结果:设备、卡清单、套餐清单(13.2)。 +- 名单为空:正常启动,日志提示「灰度模式但名单为空,全部走老路径」。`off` / `full` 模式下名单不生效,有值时日志提示。 +- 模式值不是 `off` / `gray` / `full`:**启动失败**,不按默认值猜。 + +### 13.4 双路分支:卡表只有一个写入者(老路径) + +**`tb_iot_card.last_gateway_reading_mb` 是观测更新的 CAS 条件**,不是普通缓存列: + +```go +// internal/application/cardobservation/traffic.go:64-72 +result := tx.Model(&model.IotCard{}). + Where("id = ? AND last_gateway_reading_mb = ?", card.ID, card.LastGatewayReadingMB). + Updates(updates) +if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新") +} +``` + +所以这一列**只能有一个写入者**。若新路径在另一个事务里改它,老路径下一次观测的 CAS 必然失败 → 报"卡流量基线已被其他请求更新" → 整个观测事务回滚 → 重入队 → 该卡观测彻底卡死。因此: + +> **老 `ApplyTraffic` 对所有卡照常运行,继续写兼容三列、继续做通道阈值判定;新路径不碰 `tb_iot_card`,只读。** + +路径在事务 1 里判定一次(模式允许该载体且 `fact_mode_since` 非空,13.3),事务 2 沿用,观测时按下表走(「灰度载体」指走新路径的载体,`full` 模式下即全部已对齐载体): + +| 动作 | 灰度载体的卡 | 非灰度载体的卡 | +|---|---|---| +| 事务 1 跑老的 `ApplyTraffic` | **是**(与现状完全一致:CAS、卡表三列、通道阈值判定都照常) | 是(现状) | +| 事务 2 写日事实、写原始读数(有套餐卡) | 是(观测事务之外,失败只告警) | 是(观测事务之外,失败只告警) | +| 新路径是否写 `tb_iot_card` | **否,只读** | 否 | +| 发哪种 Outbox 事件 | **新版**(载荷只带 `卡, 日期`),不发 V1 | V1(带增量值),现状 | +| 事务 2 对 `credited_mb` | 不动,由消费者扣减后更新(9.1) | `credited_mb += Δ`,本次增长归老路径 | +| 扣套餐的依据 | 日事实 `usage_mb − credited_mb`(9.1) | 老路径的增量(现状) | +| 夜间重放 | 跑,结果落 `tb_package_usage` | 跑,结果只比对、告警,不落库 | + +- 新版事件发不发由**日事实**决定(`usage_mb > credited_mb`),不由老 `ApplyTraffic` 的 `decision` 决定——老路径可能因为下降保护把读数拒了(`increment = 0`),而日事实照样有用量,这正是本方案要解决的问题。 +- 两种事件互斥:同一次观测要么发新版、要么发 V1,Outbox 量不翻倍;消费者对两种事件都照常处理,不按载体当前状态丢弃(13.3 兼容规则)。 +- 这样安排的三个好处:①卡表的写入者和写入语义与现状**完全一致**,零污染风险;②退回老路径天然可用,基线一直是老算法维护的最新值,不依赖任何派生逻辑写对;③灰度分支只落在"发哪种事件"这一处。 +- 代价:灰度载体上两套计量并行(老的增量累加写卡表、新的包络差扣套餐),卡表三列在被冻结的卡上仍然停滞——但那是现状,不是新增问题,且这三列已降级为兼容缓存(9.3)。 + +### 13.4a 非灰度载体零影响:逐个接触点核对 + +**硬约束:新代码出任何错,非灰度卡的扣减、停复机、观测成功率、页面展示都必须与今天一致。** 下表列出新代码碰到非灰度卡的每一处,以及为什么不会影响: + +| # | 接触点 | 碰到非灰度卡的方式 | 保证 | +|---|---|---|---| +| 1 | 事务 1 路径判定 | 每次观测都判定 | `off` 不判定;`gray` 内存集合判定、不查库;出错一律老路径,绝不让事务 1 失败(13.3) | +| 2 | 事务 1 的 V1 分支 | 非灰度照常发 V1 | 分支只对判定为新路径的载体生效;其余代码一行不改 | +| 3 | 事务 2(日事实 + 有套餐卡原始读数) | 全部卡都写 | 事务 1 提交后执行;失败、超时(默认 2 秒)、panic 一律捕获,只告警,**不让观测任务失败、不触发重入队**;**事实写入总开关** `traffic_fact.write_enabled` 关掉即整体停写,不用发版 | +| 4 | 无套餐卡原始读数 | 单独 INSERT | 失败只打日志;有独立开关 | +| 5 | 数据库负载 | 事务 2 与原始读数增加写入 | 上线前三档压测(6.5),**任何一档使现有轮询耗时上升即不开启**;上线后轮询耗时、连接池等待设告警线,越线即关总开关 | +| 6 | 七类套餐事件写入 | 激活、改已用量、退款、换货、重置、过期对全部套餐生效 | 全部在原操作提交之后写,失败只告警(6.4) | +| 7 | 载体接管表写入(`full` 新载体、换货 / 换绑排除) | 激活、换货、换绑入口 | 原操作提交之后写,失败只告警;排除只对已接管载体执行(6.3.1、13.5) | +| 8 | `tb_package_usage` 新增两列 | 表结构变更 | 只加可空列、无默认值(PG 即时完成、不重写表);老代码不读不写;新列不进老代码的模型字段,防止整行 `Save` 改到它(15.2 第 15 项) | +| 9 | 老路径代码 | — | `ApplyTraffic`、V1 消费者、回执表、`DeductDataUsage`、Redis 小数余量、Redis 日流量缓冲与 02:00 落盘、通道阈值判定**一行不改**;新消费者是独立代码 | +| 10 | Outbox | 新版事件与 V1 共用 Outbox | 两种事件互斥,总量不增;Relay 按 `SKIP LOCKED` 领取、失败按 `next_attempt_at` 退避(`internal/infrastructure/messaging/outbox/relay.go:162-165`),新版事件处理失败不阻塞 V1 | +| 11 | P4 无效响应与失败采集 | 全部卡都会遇到 | 新表记为 `invalid` / `failed`(仅有套餐卡)、不参与计算,单独 `INSERT`、失败只打日志、可开关关掉;老路径仍按今天处理(5.1、13.6) | +| 12 | 日流量查询 | 非灰度卡也走判定 | `off` 不判定;判定出错回退旧来源,不抛错(10 节) | +| 13 | 夜间重放 | 对全部载体比对 | 只读;非灰度载体不写 `tb_package_usage` 任何列、不改状态、不触发停复机;分批限速、单批超时跳过(9.2) | +| 14 | 全量补扣预演 | 对全部载体重放 | 只读,不写任何表(13.7) | +| 15 | 启动对齐 | worker 启动时执行 | 后台执行,不阻塞启动;只动名单内(或 `full` 下)载体(13.3) | +| 16 | 期初快照(阶段 1 上线) | 给全部生效套餐写 `opening` 事件、记卡基线 | 只写新表、只读老表,分批执行;失败可重跑,不影响业务 | + +**仍会影响全部卡的一处(已知,保留现状):** 灰度名单写错(`iot_card:X` 实际回退到设备套餐)时 worker 启动失败(13.3 启动校验),这会让轮询、扣减、定时任务全部停止,直到改好配置重启。这是按决定保留的行为,上线改配置时须核对名单。 + +### 13.5 载体在灰度期间发生变更 + +换绑、换货会让一张卡从灰度载体挪到非灰度载体(或反过来),在同一个 `tb_package_usage` 上造成跨路径。规则: + +- 灰度范围按载体判定,卡跟着载体走; +- 灰度期内发生 `carrier_changed`(换货,`internal/service/exchange/migration.go:104`)或换绑的载体,**自动退出灰度**:清空 `fact_mode_since`、写 `fact_mode_blocked_at`,回到老路径并进人工清单。该写入在换货 / 换绑**提交之后**进行、只对已接管载体执行,失败只告警,不影响换货与换绑本身;写 `fact_mode_blocked_at` 是必须的——否则下次重启时启动对齐会按配置又把它拉回灰度; +- 换货、换绑场景在测试环境专门构造验证,不依赖灰度期的真实数据碰上。 + +- 卡的扣减载体自己发生变化(卡自有套餐到期、回退到设备套餐;或卡新开了自有套餐、不再用设备套餐):路径一律按**观测当时解析出的载体**查接管表,查到哪个载体就按哪个载体的状态走,不会含糊;但同一张卡前后跨了两个载体,按换绑同样处理——两个载体都写 `fact_mode_blocked_at`(原因 `rebind`)、退回老路径、进人工清单。 +- 运行中排除立即生效:事务 1 读到 `fact_mode_since` 为空就走老路径,不用等重启;排除前已发出的新版事件照常扣减(13.3),不丢量。`full` 模式下同样适用:被排除的载体留在老路径,直到人工清除 `fact_mode_blocked_at`。 + +理由:换货原地改载体本身就是 P12 要修的问题,让它和灰度边界叠在一起,出问题时无法区分是哪一侧的缺陷。 + +### 13.6 不纳入灰度的部分 + +| 项 | 结论 | +|---|---| +| 阶段 2 日流量切换 | **需要灰度,按载体切读侧**。原先判断"不需要灰度"的理由只看了"旧表 0 行、没有可回退的旧值",漏了一件事:日事实是**给全量卡**写的,读侧若一次性全切,非灰度卡就变成"日流量用新算法、套餐已用量用老算法",两个数公开对不上——那 3,511 张冻结卡会显示每天用了几个 G 而套餐已用量一直为 0。因此读侧按卡所属扣减载体的 `fact_mode_since` 判定:灰度载体读日事实,非灰度卡仍读旧来源,与今天一致;该载体进灰度时两个数一起切 | +| 无效响应不当 0(P4) | **只在新表生效,老路径不改**。新代码把无效响应记为 `invalid`(仅有套餐卡)、不写日事实、不参与计算;老 `ApplyTraffic` 仍按今天的方式处理(当 0),非灰度卡行为不变。灰度载体的扣减依据是日事实,自然不受无效响应影响;老路径里那个"当 0"的缺陷随老路径一起在清理阶段消失 | +| 新字段日期口径 N1~N7(11.3) | 只作用于新增字段与新增逻辑,阶段 0/1 旁路期即全量生效,无需灰度;**现有时间计算本期不改**(11.2) | +| 原始读数写入(阶段 0) | 全量开启,已有开关与失败计数,不影响结果 | +| 下线 Redis 日流量缓冲、02:00 落盘、旧表、下降窗口判定 | 全部等灰度结束后再做,不与灰度并行 | + +### 13.7 灰度期间看哪些数据 + +**不设到期时间。** 扩批与全量由数据说话,不由日期说话:范围是配置项,随时可改,所以不需要事前拍批次节奏,也不需要给双轨设一个"到期要么全量要么回退"的死线。代价是双轨会存在到数据达标为止,因此分支必须集中在"发哪种 Outbox 事件"这一处(13.4);代码清理不在本期(14 节)。 + +**灰度范围与接管状态(随时可查,不依赖读环境变量):** + +| 看什么 | 怎么来 | +|---|---| +| 当前真实接管范围 | `tb_traffic_fact_carrier WHERE fact_mode_since IS NOT NULL`——这就是当前由新路径计费的载体全集(6.3.1、13.3) | +| 被排除的载体 | 同表 `fact_mode_blocked_at IS NOT NULL`,附 `blocked_reason`(换货/换绑/偏差超阈值) | +| 配置与数据是否一致 | 启动对齐日志:本次纳入、退回、跳过各多少,设备载体展开后的卡与套餐清单 | + +**灰度载体每天要看的数据:** + +| # | 数据 | 判读 | +|---|---|---| +| 1 | 新旧对比:日事实包络算出的周期用量 vs `tb_package_usage.data_usage_mb` vs 该卡最近一条 `valid` 读数 | 三者应当一致;不一致要能定位到具体哪一天、哪一条读数 | +| 2 | 逐日日流量(`usage_mb`、`obs_count`、`suspect_count`、`gap_days`) | 每天有行;`gap_days > 0` 的日子有解释;没有"整天无行"却又不在覆盖率缺口里的情况 | +| 3 | 可疑读数清单(原始读数表 `status` 不为 `valid` / `failed` / `invalid`,按状态分组) | `zero_suspect` 是否集中在电信卡;新周期边界的挂起是否在 `K` 个小时内被确认,挂起时长分布;靠 `S` 天兜底确认的卡逐条看;`stale_suspect`(确认后剔除的旧缓存)是否只出现在出账后;`regress_pending` 是否被后续观测裁决掉 | +| 4 | 重放与实时的偏差(9.2 的比对结果,按载体) | 偏差 ≤ 1 MB;超阈值的载体按 9.2 不自动修正、进人工核对清单,要逐条看原因;人工判定需退出灰度的,写 `fact_mode_blocked_at`(原因 `replay_deviation`) | +| 5 | 灰度载体在灰度期的停机/复机审计 | 无误停;复机只来自重置/加油包/人工等现有入口,不应出现重放触发的复机;`spike` 待确认与向下差异的清单条目逐条复核(8.4、9.2) | +| 6 | 覆盖率:灰度载体当日有日事实行的卡数 / 应有卡数 | 缺口要能归因到 P17 吞吐,而不是逻辑漏写 | +| 7 | 尾部缺口清单(8.2:缺口时长、日均用量、估算缺口量) | 本期不补也不压缩,但要能看见规模,作为 P17 轮询改造的输入 | +| 8 | 各通道清零时刻:边界之后第一条被确认的低读数离周期边界多久(结合 `failed` 记录区分"没采到"和"运营商没清零") | 用于校准 `S`(必须大于实测最大清零延迟,8.4);若运营商都准时清零,后续可评估缩短 `S` | + +**扩批依据(四条全部满足再扩大范围或全量切换):** + +1. 重放与实时扣减的偏差 ≤ 1 MB 的载体占比 100%,无一条进 9.2 的人工核对清单; +2. 灰度期内无误停、无误复;触发过 `spike` 或 `regress_pending` 裁决的载体逐条复核; +3. 日流量与原始读数逐日对得上,`gap_days > 0` 的日子有合理解释; +4. 灰度载体的周期用量与运营商侧读数一致;103154 同类卡从"冻结"恢复:接管补扣金额与重放差额一致、补扣后状态判定正确,之后按日事实正常扣减。 + +**但有一件事绕不过去:一轮灰度必须跨过两个周期**——一次运营商清零(联通 27 号,其余通道 1 号)和一次套餐重置(`by_day` 为激活日 +30 天、逐次提前一天)。这两件事不跨过去,`stale_suspect`、`zero_suspect`、新周期首读起算、`credited_mb` 跨重置这些核心分支一个都没被触发过。真实数据下一轮最少约 30 天。测试环境可以构造清零与重置把分支全部跑完——**测试环境把分支跑全是前提,灰度验的是真实数据的分布,两者不能互相替代。** + +**第一批按覆盖矩阵挑,不按数量挑(10~20 个载体即可):** + +| 必须覆盖 | 验证什么 | +|---|---| +| 103154 所在设备(3 卡共用设备套餐,含 1 张冻结卡) | P1 恢复 + 接管补扣 + P10 多卡汇总 | +| 其余已知问题卡(冻结卡、读数为 0 重复计的卡)所在载体 | 接管补扣、补扣后停机判定;**问题卡优先进第一批**——旁路期越长,期间套餐重置清掉的欠扣越多,可补的越少(14.2 第 1 步) | +| 单卡套餐的卡 | `iot_card` 载体分支 | +| 带加油包(`master_usage_id` 非空)的载体 | 扣减顺序与超额归属 | +| 联通(重置日 27)与重置日为 1 的通道各一组 | 周期归属、跨月 | +| 电信读数常为 0 的卡 | `zero_suspect` 不开新段 | +| 开启虚流量快照(`enable_virtual_data_snapshot`)的载体 | 耗尽阈值口径 | + +**切 `full` 之前:全量补扣预演(必须)。** `full` 会对全部载体一律补扣(14.2 第 1 步),切之前先全量统计一次到底丢了多少流量要补: + +| 输出 | 说明 | +|---|---| +| 补扣总量 | 全部载体重放应有用量 − 实际已用量(只算向上差额),即老路径旁路期间少扣的总量 | +| 按载体差额分布 | 差额分档(≤ 1 GB / 1~10 GB / > 10 GB)的载体数,及差额最大的载体清单 | +| 预计停机数 | 补扣后按 `EffectiveThresholdMB` 会由「生效中」变为「已用完」的套餐数与对应卡数 | +| 不可补部分 | 旁路期内发生过套餐重置、重置前欠扣补不回来的载体与估算量 | +| 不可重放载体 | 事件链有缺口、不补扣也不接管的载体数(14.2 第 1 步) | + +- 预演**对全部生效载体单独跑一次重放**,不复用夜间对账——夜间只跑过去 3 天有变化的载体(9.2),长期冻结又最近没采到的卡会被漏掉,统计偏小。只读、不写任何表,在切 `full` 前一天跑。 +- **判据:不会造成大面积停机,就直接切 `full` 一律补扣**;预计停机数异常时先不切,按清单核对后再定。 + +**推进节奏:** `off` → `gray` 第一批(覆盖矩阵)→ `gray` 逐批扩大名单 → 全量补扣预演 → `full` 正式上线;每一档都跑完上面四条扩批依据。每一档只改环境变量并重启,启动对齐自动把新增载体初始化、把移出的载体退回。出问题时改回 `gray`(保留名单)或 `off` 并重启即可退回。 + +## 14. 分阶段落地 + +| 阶段 | 内容 | 验证 | +|---|---|---| +| 0 原始读数留底 | 建原始读数表;三个观测入口在观测事务提交后按第 5 节记录原则写原始读数(`status` 一律 `valid`,周期按 `PeriodStart` 计算);`FlowUsageResp` 增加有效标记,有套餐卡的失败与无效采集写为 `failed` / `invalid`(老路径仍按今天处理);写入开关与失败计数;压测(6.5)。**现有逻辑不改** | 压测三档不影响现有轮询;有套餐的卡每次采集都有一行(含 0、重复读数、失败与无效);无套餐卡按原则写;关闭开关立即停写;103154 等被拒绝的读数有记录 | +| 1 事实写入(旁路) | 建日事实、套餐事件两表;原始读数开始写判定状态;**上线时打期初快照**(14.1);观测入口按第 5 节写事实;可疑判定;套餐事件各写入点;新字段日期口径(N1~N7);事实写入总开关。**不改任何现有扣减结果、不改老路径代码** | 日事实周期用量与运营商当前读数一致(含 103154);电信 0 读数、重置日旧缓存不进包络;人工改已用量、换货产生事件;每个生效套餐都有期初事件 | +| 2 日流量读侧(**代码先上,实际切换随阶段 3 接管发生**) | 本阶段上线读侧双来源代码与新旧比对查询;因按 `fact_mode_since` 判定、该字段到阶段 3 接管时才有值,**阶段 2 上线时所有卡仍读旧来源**,载体在阶段 3 被接管的同时日流量与套餐用量一起切。日流量查询按卡所属扣减载体的 `fact_mode_since` 判定来源:灰度载体读日事实,**非灰度卡仍读旧来源、与今天一致**(13.6);**Redis 缓冲与 02:00 落盘继续并行运行,本期不下线**(灰度结束后再停)。**至少晚于阶段 1 一个完整自然日** | 阶段 2 上线时全部卡日流量展示与今天完全相同;新旧比对查询可用;覆盖率查询正常。灰度载体的读侧切换在阶段 3 验证:日流量与原始读数一致、与套餐已用量口径一致、切换当天不断档 | +| 3 套餐用量切换(灰度) | 按 13 节分批纳入灰度、按 14.2 逐载体切换;消费者改为"日事实 − 已计入"扣减;夜间重放与状态联动上线;`data_usage_exact_mb`(只由新路径维护);**已接管载体**不再用 Redis 余量(非灰度载体的老路径照用,不下线);先只告警不修正运行一段时间,差异逐条评审后开启自动修正 | 切换前后无重复扣减;旧版事件积压清空;重放与缓存偏差在阈值内;三卡共用、加油包、重置、换货、人工设值用例通过 | +| ~~4 清理~~ | **不在本期范围**。全量稳定运行一个完整运营商周期之后,再单独评估是否清理(见下) | — | + +**本期到阶段 3 结束为止,老代码全部保留。** 原"阶段 4 清理"降级为后续独立事项,原因是它是整个方案里唯一的**不可逆点**: + +| 要清理的东西 | 为什么不能在本期做 | +|---|---| +| 老 `ApplyTraffic` 与下降窗口、基线判定 | 它是灰度的唯一退路——所有"改配置 + 重启退回"都依赖这段代码还在;而且它是兼容三列的唯一写入者(13.4),删掉后导出、API 响应、Redis 轮询缓存读到的值会停滞在删除那一刻 | +| 通道阈值的输入源 | 现状判定在观测事务内用 `decision.LastGatewayReadingMB`(9.3),这是在跑的生产功能;输入源切换单独成项,不与代码清理捆绑 | +| `tb_card_observation_effect` 回执表 | 生产 156.3 万行,其中 17,765 行卡在中间态(status 1/3,最老 2026-08-18),是"这批观测效果没走完"的唯一证据;**保留表与数据,新路径不写它**,不 DROP | +| 载体接管表 `tb_traffic_fact_carrier` | 它是新老路径的开关记录,改回 `off` / `gray` 的退回依赖它;`full` 稳定一个完整运营商周期、被排除载体处理完、老路径扣减分支与 V1 处理下线后,与老代码一起删除 | +| V1 事件处理 | 删除前提必须是明确条件:**Outbox 中 V1 事件数为 0 且持续 N 天**(N 待定,建议 ≥ 7),而不是"积压清空后"这种模糊判断;没清空就删会丢掉还没扣减的增量 | + +### 14.1 阶段 1 起算(期初快照) + +阶段 1 上线时,卡没有读数历史,套餐没有事件历史。上线时刻 `t0` 一次性打快照,作为之后一切重放的起点: + +1. **套餐期初**:每个生效中的套餐写一条 `opening` 期初事件(不复用 `usage_set`,避免与人工改已用量混在一起),`occurred_at = t0`,`after_value` 记 `data_usage_mb`、`expires_at`、`status`、载体。之后的重放都从这条事件开始,不早于 `t0`。 +2. **卡起算基线**:记下每张卡 `t0` 时的卡表基线 `b`(`last_gateway_reading_mb`),只用这一次。 +3. **不用旧基线写事实**。`t0` 之后的第一条真实观测作为该卡当前周期段的首行,首行 `segment_base_mb`(本段起算值)按下表设定,使首日用量只包含老路径尚未扣过的部分: + +**先判 `b` 属于哪个周期,再比大小。** 取该卡**最后一次被接受读数的时刻** `t_b`,与起算时当前周期起点 `cycle_start`(`PeriodStart`)比较: + +| `t_b` 与周期起点 | `r` 与 `b` | 判定 | `segment_base_mb` | 首日 `usage_mb` | +|---|---|---|---|---| +| `t_b ≥ cycle_start` | `r ≥ b` | `b` 与 `r` 同周期,`b` 之前已扣过 | `b` | `r − b` | +| `t_b ≥ cycle_start` | `r < b` | 同周期回退:`b` 是尖峰或 `r` 是旧缓存,**先不起算** | — | 首读按 8.4 `regress_pending` 裁决,裁决后再按本表起算 | +| `t_b < cycle_start` | 任意 | `b` 属旧周期,本周期从 0 起算 | `0` | `r` | +| `t_b` 取不到,或 `b = 0` | 任意 | 不可判定,保守全额计入(与老路径 `traffic.go:58` 一致) | `0` | `r` | + +**为什么不能只看 `r` 与 `b` 的大小:** 反例——重置前最后读数 `b = 100 MB`,重置后很快用了 5000 MB,起算时首次观测 `r = 5000 > b`。若按 `r ≥ b` 判成同周期,会记成 `r − b = 4900`:少计 `b`,而且 4900 被记到了错误的周期。这与 8.4 "周期归属由时间决定,不由读数大小决定"是同一条原则,起算环节不能例外。 + +**`t_b` 的取数来源:** `tb_iot_card` 没有这个时间戳——`last_data_check_at` 连被拒绝的读数也会刷新(核对文档 P2),`last_sync_time` 同理。只能从审计 `iot_card.worker_traffic_sync` 取该卡最后一条**有增量**记录的时间;取不到的卡按表中第 4 行处理。**不做上线前的全量统计**(15.1 第 9 项):`t_b` 本来就是每张卡在 `t0` 之后首次观测时各自查一次,天然分散;先看第一批灰度载体能否取到,取不到的按第 4 行起算并进清单。 + +4. 表中第 3、4 行的卡列入清单,标注起算口径;其中若 `b` 实为同周期值,会多计一次,由人工按清单核对。 +5. 起算日的 `b` 可能是几天前的值,首日 `usage_mb` 会偏大;日流量展示标注"起算日,含此前未统计用量"。 +6. 上线后第一次重置日如果该卡还没有"上一周期最后的 `C`",新周期边界的判定(超出部分实时扣、低读数确认、`S` 天兜底)以 `b` 作为上一周期的 `C`(8.4)。 + +### 14.2 阶段 3 切换(套餐用量,按载体执行) + +载体被接管之前,扣减走老路径;事务 2 按 `credited_mb += Δ` 把每次观测的增长记为已由老路径负责。但阶段 1 上线前没有这一步,可疑读数裁决重算(8.4)也会让旧行 `usage_mb` 变大而 `credited_mb` 不跟。若直接切换,这些差额会被第一个新事件当成新增用量再扣一次,所以接管时要先初始化。 + +走灰度之后**没有全局 `t1`**:每个载体的 `t1` 就是它在载体接管表里的 `fact_mode_since`(6.3.1、13.3)。下面四步由**启动对齐**按载体执行(13.3),每个载体一个事务,**幂等且可重入**(重复执行结果不变,便于改配置后重启补跑): + +1. **重放比对与接管补扣**:从 `t0` 的期初事件起,按 8.3 重放到该载体的 `t1`,得到每个套餐的应有用量,与实际 `data_usage_mb` 比对。**重放按各日事实行完整的 `usage_mb` 计,不是只计 `credited_mb`**——与夜间对账(9.2)不同:非灰度载体的日事实事后被重算变大(8.4 清零确认、`S` 天兜底补 `C`)时 `credited_mb` 不跟、也不补发事件(9.1 第 5 条),这部分差额老路径和新路径都没扣过,只能在这里补;若只计 `credited_mb`,第 2 步把 `credited_mb` 置为 `usage_mb` 后它就永久丢失。补扣与第 2 步同一事务、锁同一批日事实行,提交后差额为 0,在途的新版事件进来增量为 0,不会重复。差异就是老路径在旁路期间的误差(冻结卡就是扣了 0): + - **不可重放的载体不补扣、不接管**:事件链有缺口(6.4、15.2 第 17 项)时重放结果不可信,保持老路径、进清单,人工补齐事件或重打 `opening` 快照(15.2 第 17 项)后再纳入。 + - **向上差额(应有 > 实际)补扣**:按**载体总量**算差额(同 9.2 比对口径),把差额按 8.3 规则 3 的顺序(先加油包、后主套餐,耗尽转下一个,超额记最后一个)在当前已用量上补扣;不逐个套餐往上修,避免实时与重放归属不同的那部分被多扣(9.2)。 + - `gray` 模式:名单就是人工确认,名单内载体**不论差额大小一律补扣**,不受 9.2 的 1024 MB 阈值限制,也不受 15.1 第 5 条「夜间重放先只告警」的限制——那条约束的是夜间对账,不是接管。 + - `full` 模式:同样**不论大小一律补扣**。前提是切 `full` 之前先做一次**全量补扣预演**(13.7):统计全部载体要补多少、补扣后会有多少套餐变为已用完而停机;不会造成大面积停机才切 `full` 直接补扣,否则先不切、再议。 + - **按 9.2「修正事务」执行**:锁日事实行与套餐行后,先查该载体是否有尚未完成扣减的 V1 事件,有则本次不接管、保持老路径、启动对齐后台稍后重试(V1 一般几秒内消费完);无则在锁内重放并补扣。否则在途 V1 的 Δ 已计入 `credited_mb`、套餐尚未扣,补扣一次后 V1 再扣一次。 + - **向下差额(应有 < 实际)不退**,与 9.2 一致,只进清单。 + - 补扣后立即按 `EffectiveThresholdMB` 重判套餐状态(同 9.2 状态联动),超额即走现有停机路径;写审计,注明「接管补扣」及补扣量,并进**补扣清单**(载体、套餐、补扣量、补扣后状态)。 + - **补扣只能补到最近一次套餐重置**:重放遇到 `reset` 事件即归 0,重置之前的欠扣随重置清掉,补不回来。因此问题卡要尽早进灰度(13.7)。 + - 起算首日用量偏大的卡(14.1 第 3、5 步,如 103154 首日记 `r = 48808 MB`)补扣时整笔计入 `t0` 时生效的套餐;若该套餐周期起点晚于运营商周期起点会多计,补扣清单里单独标注,逐条核对。 +2. **已计入初始化**:该载体相关卡**当前已存在的全部日事实行**(含当天),`credited_mb = usage_mb`;此后消费者只扣新增部分。幂等做法是按 `WHERE credited_mb <> usage_mb` 更新。含当天是因为接管提交之前,当天的用量全部归老路径;初始化锁住这些日事实行,与事务 2 串行(见下面的时序)。 +3. **小数余量并入**:`data_usage_exact_mb = data_usage_mb + Redis 小数余量`(每载体不足 1 MB,`usage_service.go:236`),并删除该载体的余量键;键已删则跳过。 +4. **旧版事件兼容**:纳入灰度之前该载体已投递的 `CardTrafficIncremented` V1 事件(带增量值)继续被消费者处理,对应的是老路径未扣部分,与第 2 步不重复;与第 1 步的补扣也不重复,前提是接管前没有在途 V1——第 1 步在锁内检查,有在途 V1 则本次不接管(9.2「修正事务」)。纳入之后该载体不再产生 V1(13.4)。**V1 处理代码本期不移除**,移除前提是 Outbox 中 V1 事件数为 0 且持续 N 天(14 节)。 + +**纳入时序:** 改环境变量(`gray` 加名单,或切 `full`)→ 重启 worker → 启动对齐(13.3)按载体执行步骤 1~3,并在**同一事务的最后**置 `fact_mode_since = now()` → 该载体从下一次观测起走新路径。**不阻塞任何观测**,保证不交错靠三点: + +- 第 1 步的补扣、第 2 步的 `credited_mb` 初始化、置 `fact_mode_since` **同一事务、且置位在最后**;提交之前该载体完全走老路径,不存在「半接管」状态。补扣与 `credited_mb = usage_mb` 必须同事务:分开提交时,补扣过的用量会被随后的新版事件按 `usage_mb − credited_mb` 再扣一次。 +- 路径在事务 1 定、事务 2 沿用;对齐提交前后跨着的那次观测按老路径发 V1,事务 2 把 Δ 计入 `credited_mb`,新路径不会再扣(4 节、13.3)。 +- 同卡同日的并发由 9.1 的日事实行锁串行,初始化也锁同一批行。 + +**退回:** 从名单里去掉该载体,或把模式改回 `off`(`full` 可改回 `gray` 保留名单)→ 重启 → 启动对齐清空 `fact_mode_since`,载体回到老路径。不需要反向脚本:`credited_mb` 留在已初始化状态,兼容三列由 13.4 第 3 行保证一直是最新的;日后再次纳入时重跑步骤 1~3 即可。 + +### 14.3 存量数据影响 + +| 存量数据 | 影响 | 处理 | +|---|---|---| +| 套餐已用量(`t0` 之前) | 之前算错的部分(被卡死基线少记、清零窗口重复计)随期初值带入,重放无法修正 | 视为历史误差,不自动修;`t0` 时可出一次"套餐已用量 vs 运营商本周期读数"对比清单,人工核对 | +| 停复机状态 | 被错误用量停掉的卡,纳入灰度时不自动改变;重放只向上修正,不会自动把它复机 | 向上修正按 9.2 重判停机;向下差异(即"其实没用那么多")只进清单,由人工用改已用量入口处理后自然复机 | +| 卡表基线、本月用量列 | 起算时读一次 `b`;此后由老 `ApplyTraffic` 继续维护,新路径只读 | 保留为兼容缓存,本期不动(9.3、13.4) | +| 日流量历史 | `tb_card_daily_usage` 为 0 行(P14),无可迁移数据;`t0` 之前的日子查不到,与现状一致 | 不迁移 | +| 绑定时间线 | 解绑只写 `unbind_time`、不删除记录(`device_sim_binding_store.go:136-150`);老记录 `bind_time` 可能为空,重放不早于 `t0`,不受影响 | 不处理 | +| 已过期/失效的历史套餐 | 无期初事件,重放不涉及 | 不动 | + +## 15. 待确认(全部按默认定稿,由灰度实测校正) + +**总原则:参数一律取建议默认值并配置化,不做事前书面确认;正确性由第一批灰度载体实测校正(13.7)。** 灰度范围小、可随时改配置退回,实测比事前拍更可靠。 + +### 15.1 已定(按默认) + +| # | 问题 | 定为 | +|---|---|---| +| 1 | Gateway 限频口径、缓存时长、清零传播延迟(决定 `S`) | `S` 默认 3 天、配置化(原 6 小时 `D` 已取消,8.4)。**灰度期用第一批载体实测清零传播延迟再调**,`S` 必须大于实测最大延迟(13.7 第 8 项),不等 Gateway 书面确认(仍建议并行去问,但不作为前置) | +| 2 | 上游按账期查询历史用量 | **已移出本方案范围**(8.2),不再是待确认项 | +| 3 | 原始读数保留期 | 14 天 | +| 4 | 可疑读数阈值 `S=3 天`、`K=3`(按不同小时计)、`T=5h`、`50%`、`10%` | 按默认,全部配置化;阶段 1 旁路期先出真实分布直方图,灰度期再调 | +| 5 | 重放自动修正阈值 1024 MB | **夜间对账**先只告警不修正,灰度期差异逐条评审后再开自动修正;**接管补扣不受此条限制**(14.2 第 1 步) | +| 6 | 观测时间取时来源 | 应用层上海时间函数;禁止用数据库时间或在 SQL 里算业务日期(11.3 N5) | +| 7 | 套餐事件写入点是否齐全 | 实施前逐一核对;**灰度期范围小,漏写只影响名单内载体,由重放偏差告警暴露** | +| 8 | `bind_time` / `unbind_time` 历史换绑是否被正确维护 | 不做全量抽查,**只核对第一批灰度载体涉及的绑定记录** | +| 9 | 起算 `t_b` 的取数来源与取不到的占比 | 不做上线前全量统计。`t_b` 本来就是每张卡在 `t0` 之后首次观测时各自查一次(14.1 第 3 步),天然分散;**先看第一批灰度载体能否取到**,取不到的按保守全额计入并进清单 | +| 10 | `t0` 之前的套餐已用量误差 | 出一次对比清单,人工决定,不自动修 | +| 11 | 事件在一天中间、原始读数已过期时整天归期末套餐 | 接受 | +| 12 | 人工清单(`spike` 待确认、向上偏差超阈值、向下差异)由谁承接 | **技术**承接:研发按 SQL + 告警逐条处理,并统计三类真实发生率;是否做管理端页面、是否移交运营,全量前再按发生率决定 | +| 13 | 灰度范围改配置需重启 worker | 接受重启;重启也让老/新路径的交替发生在确定的边界上(14.2) | +| 14a | 冻结卡旁路期欠扣怎么处理 | **补扣,不免账**。`gray` 名单即确认,名单内载体接管时差额不论大小一律补扣;切 `full` 前全量预演一次,不会大面积停机就全部直接补扣;补扣后超额即停机;补扣只能补到最近一次套餐重置(14.2 第 1 步、13.7) | + +灰度批次节奏与双轨到期时间**不需要事前确定**:范围是配置项、随时可改,扩批依据是看数据(13.7)。 + +### 15.2 实施约定(按默认写死,不另行确认) + +| # | 约定 | +|---|---| +| 14 | **全局锁顺序:卡行 → 日事实行 → 套餐行**,所有路径遵守。依据(已核代码):观测事务(事务 1)`SELECT ... FOR UPDATE` 锁卡(`traffic.go:40`);日事实在事务 2 内锁,事务 2 不回头锁卡;消费者扣减事务只锁"日事实 → 套餐";**停机与复机都在事务提交后异步触发**(`usage_service.go:442` `triggerSuspensionAfterCommit`、`reset_service.go:378` `triggerResumeForPackages`),不在事务内回头锁卡。因此禁止任何路径在持有套餐行锁时去锁日事实行或卡行 | +| 15 | **`data_usage_exact_mb` 只由新路径维护,老路径的写入点一处不改**(`package_usage_store.go:159`、`:183`、`:219`、换货迁移、退款、重置、人工改已用量照旧只写 `data_usage_mb`)。因此 `data_usage_exact_mb` 只对已接管载体可信:接管时按 14.2 第 3 步初始化;接管期间老入口(重置、人工设值、换货、退款)改了 `data_usage_mb`,新路径下一次扣减前发现 `floor(exact) ≠ data_usage_mb` 即以 `data_usage_mb` 为准重置 `exact`(小数部分归 0),记日志。退回老路径后 `exact` 不再使用,再次接管时重新初始化。另:新增列的 GORM 字段要防止老代码 `Save` 整行时把它写成 0——列不进老代码使用的模型字段,新路径用独立的查询与更新语句读写 | +| 16 | **日事实表按 `date` 月度分区**,不归档不删除(永久保留,8.9 万卡约 3200 万行/年);原始读数仍按天分区留 14 天 | +| 17 | **重放必须能识别事件链缺口**:若套餐当前状态或载体推不出事件链(说明 `reset` / `expired` 等事务外写入漏了一条),该载体标记为**不可重放、不自动修正、不补扣接管、只告警并进清单**;不得在事件不全的情况下做向上修正(6.4)。恢复:人工按补偿清单补写缺失事件;补不齐的,人工核对当前已用量后为该载体全部生效套餐重打一条 `opening` 事件,重放从这条事件起算,载体恢复为可重放 | +| 18 | **事务边界写死**:事务 1 = 观测事务(老 `ApplyTraffic` + 卡表 + 通道阈值 + 非灰度 V1 事件),内容与今天一致;事务 2 = 事实事务(日事实 + 有套餐卡的原始读数 + 灰度载体的新版事件 / 非灰度 `credited_mb += Δ`);无套餐卡的原始读数单独 INSERT。三者失败互不影响,只有事务 1 失败才重入队;事务 1 内的路径判定出错一律按老路径,不得使事务 1 失败(4、6.1、12、13.4a 节) | + +### 15.3 需要协调,不是技术决策 + +| # | 事项 | +|---|---| +| 19 | **压测时段**:测试数据库实例是共享的(同实例还有 `erp_db` 46 GB、`cdr` 12 GB 等),6.5 第 3 档 125 条/秒会影响其他业务,需约定低峰时段并按实例级指标设停止线 | +| 20 | `tb_card_observation_effect` **本期不下线**(表与数据保留)。本期之后若评估下线,先处理卡在中间态的 17,765 行(status 1/3,超 7 天,最老 2026-08-18)——它们是"这批观测效果没走完"的唯一证据 | +| 21 | ~~P4 修复的运营可见影响~~:已取消。P4 只在新表生效、老路径不改(5.1、13.6),`last_data_check_at` 与页面展示不变,无需告知运营 | + +## 16. 测试 + +**领域层(表驱动)** + +| 场景 | 期望 | +|---|---| +| 同周期读数上涨 | 日用量 = 包络差 | +| 跨重置日 | 新周期首日用量 = 当日读数,旧周期不受影响 | +| 新周期清零未确认时读数 ≥ 旧周期 C | 只计超出 `C` 的部分并实时扣减,不搁置 | +| 新周期满 `S` 天一条低读数都没出现 | 按边界已清零处理:`segment_base_mb` 改为 0,补扣此前少计的 `C`,进清单 | +| 小流量套餐(月额度 30 MB,上周期 `C` = 25 MB),新周期首读 40 | 实时扣 15;不出现停扣数天;确认或兜底后补齐 `C` | +| 新周期边界低读数不足 `K` 个小时 | 挂起不计、不丢;确认后一次补扣 | +| 新周期边界先低后涨、涨过 `C`(10 → 300 → 800) | 增长链确认,按新周期从 0 计,补扣此前少计的 `C` | +| 同一小时内多条低读数(轮询 + 人工刷新) | 只算 1 个小时 | +| 确认依据中混有 0、`failed`、`invalid` | 不计入小时数 | +| 上周期已用满(`C` 大于本周期额度)的卡,新周期挂起期间用量超额度 | 挂起期间晚扣;确认后补扣并停机 | +| 清零延迟:出账后 8 小时内一直读到旧周期值,之后才出现低读数 | 确认后旧值改标 `stale_suspect` 移出包络,按超出 `C` 已扣部分不退、进清单;上一周期总量不被再计 | +| 周期首行跨缺口(9-25 后 9-29 才采到) | 新周期首行 `gap_days` 从 `cycle_start` 起算 | +| 周期中途读数 0 | `zero_suspect`,不进包络,不触发新段 | +| 连续多次读数 0 | 仍不触发新段,超 1 天告警 | +| 单次回退后恢复 | `excluded`,包络不变 | +| 稳定低位且增长,`ρ ≥ 50%` | 标 `spike` + 告警 + 进清单,**包络不变**;人工确认后才移出并重算(8.4) | +| 稳定低位且增长,`ρ < 10%` | 新段 | +| 稳定低位但不增长 | 不裁决 | +| 未预期清零当天 | 两段两行,日流量 = 两段之和 | +| 缺口 | 用量并入下一个有观测日,`gap_days` 正确 | +| 旧周期末日的尾部缺口 | 缺口时长由 `last_at` 与周期边界算出;缺口不计入任何用量、不产生增量、不触发停机;对账按已知缺口排除,不报差异 | +| 重置日配置变更 | 历史行不变 | +| UTC 与上海同一时刻;`TZ=UTC` 与上海 | 结果一致 | +| 记录原则 | 有套餐每次都写(含读数 0、与上一条相同、失败、无效);无套餐首条或变化写;无套餐读数 0、不变、失败、无效不写;`failed` / `invalid` 不写日事实、不参与计算 | +| 重放:主套餐/加油包、耗尽转下一个、超额 | 与现有顺序一致 | +| 重放:`reset`、`usage_set` | 事件时刻归零 / 设值,之后累加 | +| 重放:`expires_changed` | 只影响事件之后 | +| 重放:`carrier_changed`(换货) | 换货前旧卡用量仍归该套餐 | +| 重放:换绑 | 按绑定时间线归属 | +| 重放:失效 | 失效前用量仍归该套餐 | +| 起算:`t_b ≥ cycle_start` 且 `r ≥ b` | `segment_base_mb = b`,首日用量 `r − b` | +| 起算:`t_b ≥ cycle_start` 且 `r < b` | 先不起算,首读按 `regress_pending` 裁决后再按表起算 | +| 起算:`t_b < cycle_start`(任意 `r`,含 `r > b`) | `segment_base_mb = 0`,首日用量 `r`,不出现少计一个 `b` | +| 起算:`t_b` 取不到 或 `b = 0` | `segment_base_mb = 0`,首日用量 `r`,进起算清单 | +| 首次重置无上一周期 `C` | 以 `b` 作为上一周期 `C` 执行新周期边界判定 | + +**应用层** + +| 场景 | 期望 | +|---|---| +| 同一事件重复投递 | 第二次增量为 0 | +| 同卡同日两个事件并发 | 行锁串行,只扣一次 | +| 事件丢失 | 下一事件或重放补齐 | +| 可疑裁决使用量变小 | 实时不退,重放也不向下修正,只告警进清单 | +| 重放向上偏差小于阈值 | 修正并审计 | +| 重放向上偏差超阈值 | 不修正,告警 | +| 重放结果小于库里值(任意幅度) | 不修正、不复机,只告警进清单;套餐已用量只增不减 | +| 人工改已用量后跑重放 | 结果保留人工设值 | +| 阶段 3 切换后首个新事件 | `credited_mb` 已初始化,不重复扣减切换前用量 | +| 冻结卡接管(`gray` 名单内,差额远超 1024 MB) | 一律补扣,先加油包后主套餐;补扣、`credited_mb` 初始化、置 `fact_mode_since` 同一事务;超额即停机,审计注明「接管补扣」,进补扣清单 | +| 接管补扣后紧接着来一个新版事件 | 增量 = `usage_mb − credited_mb` = 仅接管后新增部分,补扣过的用量不再扣 | +| 接管时日事实存在 `usage_mb > credited_mb`(旁路期 8.4 重算变大,如 `usage` 合计 1000、`credited` 合计 950、实际已扣 950) | 接管补扣按完整 `usage_mb` 重放,补扣 50;第 2 步后 `credited_mb = usage_mb`;该 50 不丢、不重复 | +| 接管时 Outbox 里还有该卡的新版事件(曾接管、退回后再接管) | 接管事务提交后消费者算出增量为 0,不重复扣 | +| 接管补扣事务失败 | 不补扣、不置 `fact_mode_since`,载体保持老路径,进清单;重启后重试结果一致(幂等) | +| 旁路期间发生过套餐重置的冻结卡接管 | 只补最近一次重置之后的差额,重置之前的欠扣不补 | +| `full` 模式接管 | 全部载体一律补扣(不受阈值限制),与 `gray` 同规则 | +| 全量补扣预演 | 只读、不写任何表;输出的补扣总量、按载体差额、预计停机数与随后 `full` 实际补扣结果一致 | +| 切换时有 V1 事件积压 | 照常扣减,不丢不重 | +| 灰度载体的卡观测一次 | 老 `ApplyTraffic` 照常运行(CAS 成功、卡表三列按老算法更新、通道阈值判定照常);新路径不写 `tb_iot_card`;只发新版事件、不发 V1 | +| 灰度载体读数被老路径的下降保护拒绝(`increment = 0`) | 日事实仍记录用量并发出新版事件,套餐照常扣减——这是本方案要解决的核心场景 | +| 新路径写 `tb_iot_card` | **不允许**:任何新代码对该表的写入都应在评审中被拒;回归用例断言新路径只读 | +| 非灰度载体的卡观测一次 | 走老路径扣减、只发 V1;日事实照写(事务 2),`credited_mb += Δ`;重放结果只告警不落库 | +| **事务 2 写日事实失败** | 观测事务 1 已提交、不回滚;卡表三列与通道阈值判定结果保留;告警;下一次观测的包络差把漏掉的用量带回,总量不丢 | +| **非灰度卡的日流量查询** | 读旧来源(当天 Redis、历史旧表),展示与今天完全一致;不出现"日流量有数、套餐已用量为 0"的矛盾 | +| **灰度载体的日流量查询** | 读日事实,与该载体的套餐已用量口径一致 | +| **事务 1 路径判定查库失败 / panic** | 按老路径处理,事务 1 正常提交;计数告警 | +| **非灰度卡在 `gray` 模式下观测** | 内存判定、不查接管表;事务 1 内容与今天一致 | +| **事务 2 超时或 panic** | 观测任务成功返回、不重入队;告警 | +| **关闭事实写入总开关** | 事务 2 与原始读数立即停写;老路径完全不受影响 | +| **`activated` / `expires_changed` / `invalidated` / `usage_set` / `carrier_changed` 写入失败** | 原操作照常提交;只告警进补偿清单 | +| **无效响应(非灰度卡)** | 老路径行为与今天一致(当 0、刷 `last_data_check_at`);新表记 `invalid`(有套餐卡)、不参与计算 | +| **老代码整行 `Save` 套餐** | 不影响 `data_usage_exact_mb`(列不在老模型字段里) | +| **夜间重放跑非灰度载体** | `tb_package_usage` 无任何写入、无状态变化、无停复机 | +| **`reset` 事件写入失败** | 重置本身照常提交(已用量归零、`next_reset_at` 推进、复机被触发);只告警并进补偿清单 | +| **`expired` 事件写入失败** | 过期本身照常提交;只告警并进补偿清单 | +| **重放遇到事件链缺口** | 该载体标记不可重放,不做任何自动修正,只告警进清单 | +| 设备载体纳入灰度 | 该设备全部绑定卡与全部加油包一并纳入,无一张卡留在老路径 | +| `mode=gray`、名单写 `device:Y` | 启动日志打印展开后的卡与套餐清单;载体接管表中 `(device, Y)` 的 `fact_mode_since` 被置上 | +| 配置写 `iot_card:X`,但该卡实际回退设备套餐 | **启动失败**,日志给出应填的 `device:Y` | +| 从配置移除某载体后重启 | 启动对齐清空其 `fact_mode_since`,回老路径;`credited_mb` 不动 | +| 同一配置重复重启 | 启动对齐无变更(幂等),不重复初始化、不重复扣减 | +| 多实例同时启动 | 对齐只执行一次(分布式锁),结果与单实例一致 | +| `mode=off`,名单有值 | 全部走老路径;之前已接管的载体重启后被清空 `fact_mode_since` | +| `mode=gray`,载体不在名单 | 走老路径 | +| `mode=full` | 全部载体后台分批对齐,未对齐的照常走老路径;对齐完成的从下一次观测起走新路径 | +| 模式值非法 | 启动失败 | +| 对齐未轮到该载体时收到观测 | `fact_mode_since` 为空 → 照常走老路径扣减、发 V1,`credited_mb += Δ`,不阻塞、不丢量;对齐事务提交后的下一次观测起走新路径 | +| 新版事件遇到 `fact_mode_since` 为空(载体已退回或被排除) | 照常按 `usage_mb − credited_mb` 扣减;退回后老路径的 Δ 已计入 `credited_mb`,不重复 | +| V1 事件遇到 `fact_mode_since` 非空(纳入前投递的积压) | 仍要扣减,与 `credited_mb` 初始化不重复 | +| 事务 1 与事务 2 之间发生接管(对齐与观测并行、新旧进程并存) | 事务 2 沿用事务 1 的路径;老路径的 Δ 计入 `credited_mb`;回归用例构造该并发,断言不出现 V1 与新版事件对同一段用量各扣一次 | +| 被排除(`fact_mode_blocked_at` 非空)的载体仍在配置内 | 重启后不被拉回灰度,只打日志进清单 | +| 灰度载体发生换货 / 换绑 | 清空 `fact_mode_since`、写 `fact_mode_blocked_at`,退回老路径并进人工清单 | +| 接管后该载体新买加油包 | 载体仍走新路径,新加油包按先加油包顺序参与扣减,无需重新对齐 | +| 接管时的套餐全部到期、续费产生新套餐 | 载体仍走新路径,不退回老路径,重启不重复补扣 | +| `full` 模式下新卡 / 新设备首次激活套餐 | 激活提交之后在载体接管表插入一行并置 `fact_mode_since`,之后的观测即走新路径,不用等重启;插入失败只告警,保持老路径,下次启动对齐再接管(6.3.1) | +| 卡自有套餐到期、回退到已接管的设备套餐 | 按观测当时解析出的载体判定;卡跨载体按换绑处理:两个载体写 `fact_mode_blocked_at`(`rebind`)、退回老路径、进清单 | +| 被排除载体之后新激活套餐 | 仍为排除状态,重启不被拉回灰度 | +| 灰度载体退回后下一次观测 | 老路径增量等于这段时间的真实用量,不出现巨额增量、不被下降保护卡死 | +| 纳入脚本重复执行同一载体 | 结果不变(幂等) | +| 旧行用量变大(裁决 / 历史补录 / 缺口回填) | 补发事件并扣减,不等夜间重放 | +| `spike` 裁决命中 | 只标状态与告警,包络不变、套餐用量不变;人工确认后才重算 | +| `spike` 人工确认后执行 | `C` 降回正确值、后续计量恢复正常;此前多扣的那一笔不退,进多扣清单 | +| `spike` 超 7 天未确认 | 按「不移出」结案,包络不变,清单记「超期未确认」 | +| 夜间重放:实时扣在 B、重放归 A(载体总量相同) | 不修正,只进清单;不出现 A 补扣而 B 不退的多扣 | +| 夜间重放时有未消费的新版事件 | 对账只计 `credited_mb` 部分,消费者随后扣剩余部分,不重复 | +| 夜间修正时先读套餐、消费者扣减、再读日事实(构造两条查询之间插入扣减) | 修正事务锁内重读,偏差按一致的数计算,不出现虚高补扣 | +| 夜间算出差额后、写入前发生套餐重置 / 人工改已用量 | 锁内重算,以重置或人工设值后的当前值为准,不把旧周期差额补到新周期 | +| 接管或夜间修正时该载体有在途 V1(`credited_mb` 已含 Δ、套餐未扣) | 本次跳过:接管保持老路径、后台稍后重试;夜间留到下次;V1 消费后再执行,Δ 只扣一次 | +| 不可重放载体在名单内 / `full` 接管 | 不补扣、不接管,保持老路径,进清单;重打 `opening` 后可纳入 | +| 新版事件消费 | 扣减与 `credited_mb` 更新同事务,不经 Redis 余量;提交后调用 `EvaluateAndAct` | +| 有套餐卡关闭"写原始读数"开关 | 不生效,有套餐卡照写(开关只控制无套餐卡) | +| 重放结果小于库里值 | 不修正、不触发复机,只告警进清单 | + +**测试环境验证** + +1. 103154 同类卡:起算后周期用量等于运营商当前读数,首日用量为 `r`,卡在起算清单内;旁路跑几天后加入 `gray` 名单并重启,接管补扣量等于重放差额,补扣后套餐状态正确(超额则停机),之后新版事件只扣新增部分。 +2. 三卡共用设备套餐:一张跨周期、两张同周期,设备套餐只增加各卡日用量之和。 +3. 电信卡构造连续 0 读数,确认不开新段。 +4. 重置日 00:05 构造旧缓存读数,确认不进包络。 +5. 人工改已用量、改过期时间、换货各一次,次日重放结果正确,人工设值不被冲掉。 +6. 阶段 3 切换:纳入灰度前后同一张卡的套餐用量连续,无重复扣减;纳入时积压的 V1 事件正常扣减。 +7. 起算 `t_b` 取数:构造"`t_b < cycle_start` 且 `r > b`"的卡(重置前用量小、重置后用量大),确认按新周期起算、`segment_base_mb = 0`、首日用量为 `r`,不出现 `r − b` 的少计(14.1)。 +8. 灰度双路并存:同一设备下 3 张卡全部纳入灰度,与另一设备 3 张卡全部不纳入,并行跑一个完整自然日,两个设备套餐用量都正确且互不影响。 +9. 配置切换闭环:`off` → `gray`(名单含该设备)→ 重启 → 跑一天 → 从名单移除 → 重启 → 再跑一天 → 加回名单后切 `full` → 重启,确认后台分批对齐完成 → 改回 `off` → 重启,确认全部退回;全程套餐用量连续,无重复扣减、无漏扣,`fact_mode_since` 的置上与清空都与日志一致。 + +**灰度退出判据与批次矩阵见 13.7。** 测试环境负责把 13.7 四条判据涉及的分支全部构造出来(清零、套餐重置、`spike`、`zero_suspect`、缺口、换货),灰度只负责验证真实数据下的分布——两者不能互相替代。 + +## 17. 工作量(粗估) + +| # | 任务 | 人天 | +|---|---|:-:| +| 0 | 阶段 0:原始读数表与分区、三入口写入、无效响应区分、开关与计数、压测脚本 | 1.0 | +| 1 | 迁移(日事实、套餐事件、载体接管三表,`tb_package_usage` 两列)、模型 | 0.75 | +| 2 | domain:周期归属、包络、可疑判定与裁决、记录原则、单测(含时区、NULL 包络) | 2.5 | +| 3 | 观测入口改造与三个入口适配:**拆出事务 2**(日事实 + 新版事件,含同卡并发行锁)、路径在事务 1 定且事务 2 沿用(非灰度 `credited_mb += Δ`)、灰度分支仅决定发哪种 Outbox 事件;新路径不写 `tb_iot_card`(N7) | 1.75 | +| 4 | 消费者改造:锁日事实行、已计入扣减、旧行变大补发事件;新增接收外部 `tx` 的精确扣减方法(不经 Redis 余量)、提交后调 `EvaluateAndAct` | 1.25 | +| 5 | 套餐事件:**七类**写入点(新增 `expired`),全部写在原操作事务外 + 补偿清单 | 1.75 | +| 6 | 新字段日期口径(N1~N7)与无效响应有效标记(只给新代码用,老路径不改) | 0.75 | +| 7 | 日流量读侧**按载体**切换(卡→载体解析 + 双来源分支)+ 新旧并行比对查询(旧链路本期不下线,13.6) | 1.0 | +| 8 | 重放纯函数(载体/生效集合时间线、事件、分配)+ 事件链缺口识别 | 3.25 | +| 9 | 夜间对账、阈值、状态联动(只向上修正,向下只告警进清单) | 1.5 | +| 10 | 期初快照(`opening` 事件)、起算与清单,含 `t_b` 取数(14.1;不做全量占比统计,15.1 第 9 项) | 0.75 | +| 11 | 按载体切换:重放比对与接管补扣(含补扣后状态重判、补扣清单)、已计入初始化、余量并入、V1 兼容,同事务、幂等可重入(14.2);全量补扣预演查询(13.7) | 1.75 | +| 12 | 集成测试与测试环境验证(含灰度双路用例) | 3.0 | +| 13 | 切换机制:`off`/`gray`/`full` 模式与名单配置、启动校验(载体展开、`iot_card` 实为设备载体时启动失败)、启动对齐(`gray` 后台执行不阻塞启动、`full` 后台分批)与分布式锁、载体变更自动排除、13.7 的七张观测数据查询 | 2.5 | +| 14 | 灰度期双轨维护与观测(差异逐条评审、清单处理) | 2.0 | +| | 合计 | 约 25.5 | + +与初版 14.25 人天的差额来自:新增灰度机制与双轨维护(#13、#14 共 4.5;改配置化后省掉名单表与管理接口,但增加启动校验与启动对齐,净持平)、重放与 domain 按实际分支数上调(#2、#8 共 1.5)、起算 `t_b` 与按载体幂等切换脚本(#10、#11 共 1.0)、灰度双路集成用例(#12 上调 1.5)、其余按分支补齐。去掉凌晨补采(原第 6 项,0.5)后为 23.75;本版为"非灰度卡零影响"再加 1.0(拆事务 2 +0.25、日流量按载体切 +0.25、`expired` 事件与事务外写入 +0.25、事件链缺口识别 +0.25),得到 24.75;第 4 版接管补扣与全量补扣预演 +0.5(#11)、消费者精确扣减方法与 `EvaluateAndAct` +0.25(#4),为上表的 **25.5**。**合计不含灰度观察期的日历时间**(13.7:真实数据下一轮最少约 30 天)。 + +## 18. 风险 + +| 风险 | 缓解 | +|---|---| +| 改动横跨观测、消费、套餐、日流量 | 本期分四个阶段(0~3);阶段 0、1 只写不改结果;清理不在本期(14 节) | +| ~~非灰度卡被连带影响~~ | **已消除**(结论第 9 条):日事实写在观测事务外、日流量读侧按载体灰度、七类套餐事件全部写在原操作事务外。非灰度卡的采集成功率、页面展示、扣减与停机均与今天一致 | +| 事务 2 失败导致日事实缺更新 | 包络单调,下次观测的 `C` 把漏掉的量带回,总量不丢,只是归日推迟;失败计数持续升高时告警 | +| `reset`/`expired` 事件事务外写入可能漏条 | 重放必须识别事件链缺口并拒绝自动修正(15.2 第 17 项);漏事件的后果是失去自动对账,而不是被错误修正 | +| 本期不做时区统一,现有口径不一致保留 | 已实测两环境 worker 进程均为 `+08`,轮询路径行为正确;残留影响限于 `series_runner.go` 显式 `.UTC()` 的 1.7% 观测对"本月起点"的错算(生产 10-01 实测 6 张卡),留档不修(11.2) | +| ~~测试库与生产库时区不一致~~ | **已消除**:2026-10-14 执行 `ALTER DATABASE junhong_cmp_test SET TimeZone='Asia/Shanghai'`,与生产库同一种做法(11.1)。N5 仍保留,防止库默认值再次漂移 | +| 测试数据库实例被多个业务共享 | 该实例上还有 `erp_db`(46 GB)、`cdr`(12 GB)等库;6.5 的写入压测(最高 125 条/秒)会影响同实例的其他业务,需约定时段、低峰执行、按实例级指标设停止线 | +| 原始读数写入压垮数据库 | 事务外写、失败不影响主流程、开关可关、上线前三档压测、先只开有套餐的卡 | +| 重放规则与现网扣减存在差异 | 阶段 3 先只告警,差异逐条评审后再自动修正 | +| 旧周期尾部缺口(本期不补、也不压缩) | 明确移出范围(8.2):旧周期末日留痕缺口时长与日均用量,对账按已知缺口排除;压缩手段属于 P17 轮询改造 | +| 覆盖率受现有轮询吞吐限制(本方案不加采集任务) | 采不到不丢,用量由包络差并入下一个有观测日;覆盖率与 `gap_days` 暴露缺口,交 P17 轮询改造解决(7 节) | +| 套餐事件写入点遗漏 | 实施前全量核对写入点;重放偏差告警会暴露遗漏 | +| 可疑读数阈值非实测 | 全部配置化;`regress_pending` 与超阈值偏差进人工核对 | +| 运营商清零晚于 `S` 天,兜底把 `C` 再计一次 | `S` 必须大于灰度实测的最大清零延迟(13.7 第 8 项);兜底确认的卡全部进清单 | +| 新周期边界挂起导致晚扣 | 只晚不丢,确认后一次补扣;上周期已用满、`C` 大于本周期额度的卡可能晚停,挂起时长分布在 13.7 第 3 项监控 | +| 起算时 `r < b` 中混有同周期尖峰 | 出清单人工核对 | +| 冻结卡接管补扣后集中停机 | 补扣的是真实用量,停机是正确结果;名单即确认,补扣清单逐条可查;首日偏大(套餐周期起点晚于运营商周期起点)的卡在清单单独标注核对 | +| 补扣与新版事件重复扣 | 补扣、`credited_mb = usage_mb`、置 `fact_mode_since` 同一事务(14.2) | +| 纳入灰度时重复扣减旁路期用量 | 14.2 第 2 步按载体把**当前已存在的全部日事实行(含当天)** `credited_mb = usage_mb`,与补扣、置 `fact_mode_since` 同一事务;对齐与观测并行,靠"路径在事务 1 定、事务 2 沿用"和日事实行锁保证不交错(13.3、14.2) | +| 事务 2 失败恰好跨在接管边界上 | 非灰度时事务 2 失败:V1 已扣 Δ1 但 `credited_mb` 没加,接管后下一次观测的包络差把 Δ1 带回,新路径再扣一次(多扣 Δ1);反方向(灰度时失败、随后退回)会少扣。量为一次观测的增量、且需两件小概率事件叠加,接受;事务 2 失败告警附载体与 Δ,进清单人工核对 | +| `t0` 之前的套餐误差无法重放修正 | 期初对比清单人工核对 | +| 灰度期双轨并存,两套扣减逻辑同时在线 | 不设到期时间,按 13.7 的数据判据扩批;灰度分支只落在"发哪种 Outbox 事件"这一处(13.4);代码清理不在本期(14 节) | +| 配置与实际接管状态不一致(只改环境变量不代表已接管) | 启动对齐把模式与名单收敛到 `fact_mode_since` 并打日志;未对齐、对齐失败或被排除的载体照常走老路径扣减,进清单 | +| 对齐与观测并行时重复扣减 | 路径在事务 1 定、事务 2 沿用;老路径的 Δ 计入 `credited_mb`;初始化与事务 2 锁同一批日事实行(4 节、13.3) | +| 补扣与在途 V1 重复扣 | 接管补扣与夜间修正共用「修正事务」:锁内检查在途 V1,有则跳过稍后重试(9.2、14.2 第 1 步) | +| 灰度名单里误写 `iot_card:X`,而该卡实际回退设备套餐 | 启动校验按 `resolveActivePackages` 同口径解析,不一致则启动失败并给出应填值,不自动纠正(13.3) | +| 灰度载体退回时老路径基线已过期 | 不会发生:老 `ApplyTraffic` 对所有卡照常运行,基线一直是最新的(13.4);对应用例在 16 节 | +| 卡表被双写入者修改导致观测全部冲突回滚 | `last_gateway_reading_mb` 是观测更新的 CAS 条件(`traffic.go:64-72`)。硬性约束 N7:新路径不写 `tb_iot_card`,只读;回归用例断言 | +| 清理阶段是不可逆点 | 原阶段 4 移出本期:老代码、回执表、V1 处理全部保留;全量稳定一个完整运营商周期后再单独评估(14 节) | +| 按卡灰度导致设备套餐被两套算法同时写 | 灰度单元固定为扣减载体,设备载体必须整体纳入全部绑定卡与加油包(13.2) | +| 起算时把跨周期基线判成同周期(少计一个 `b`) | 14.1 先按 `t_b` 判周期归属再比大小;`t_b` 取不到的卡保守全额计入并进清单 | +| `spike` 误判 | `spike` 不自动移出包络,人工确认后才执行;重放不做向下修正,因此不会出现由它触发的误复机(8.4、9.2) | +| 多扣的套餐在人工处理前一直多扣 | 这是去掉向下修正的代价;清单必须可见(13.7 第 5 项),人工用改已用量入口处理,重放尊重 `usage_set` 不再冲掉 | + +## 19. 问题解决情况 + +| # | 问题 | 结果 | 怎么解决 | +|---|---|---|---| +| P1 | 下降按日号判清零,卡被冻结 | 解决 | 周期由时间决定;下降标"待裁决"保留,不再拒绝(8.1、8.4) | +| P2 | 旧周期尾部丢失 | **本期不解决** | 跨账期回补移出范围(8.2):本期只留痕(缺口时长 + 日均用量);回补待上游按账期查询能力明确后另案,压缩缺口属 P17 轮询改造 | +| P3 | 读数 0 被当清零重复计 | 解决 | `zero_suspect` 不进包络、不开新段(8.4) | +| P4 | 无效响应按 0 处理 | 解决(新路径) | 新表把无效响应记为 `invalid`、不参与计算,灰度载体扣减不受影响;老路径不改,非灰度卡维持现状(5.1、13.6) | +| P5 | Gateway 旧缓存 | 解决 | 新周期边界超过 `C` 的部分实时扣、低读数按小时数确认后补扣;周期中途 `regress_pending` 由后续走势裁决(8.4) | +| P6 | 增量不可重算 | 解决(自动修正仅向上) | 读数为事实,用量由事实派生,夜间重放对账;重放只自动向上修正,向下差异只告警进清单、由人工用改已用量入口处理(6、8、9.2) | +| P7 | 小数余量在 Redis | 解决 | `data_usage_exact_mb`(6.3) | +| P8 | 按"当前生效"选套餐 | **部分** | 实时扣减不变;夜间重放按时刻归属校正(8.3、9.1) | +| P9 | 回退设备套餐用当前绑定 | **部分** | 同上,重放按绑定时间线 | +| P10 | 多卡基线互不感知 | 解决 | 每张卡独立的日事实,设备套餐 = 各卡日用量之和,单卡问题可单独修正后重算 | +| P11 | 改套餐不留记录 | 解决 | 套餐事件表(6.4):七类事件都写在原操作事务外,不影响原操作;漏写由事件链缺口识别兜底 | +| P12 | 换货原地改载体 | 解决 | `carrier_changed` 事件(6.4、8.3) | +| P13 | 日流量 Redis 缓冲、02:00 落盘 | 解决(读侧,**按载体灰度**) | 灰度载体的日流量直接来自日事实;非灰度卡仍读旧来源;旧缓冲与落盘**本期保留并行,灰度结束后下线**(10、13.6) | +| P14 | 日流量表结构不一致 | 解决 | 新表 `tb_card_traffic_daily` 承担日流量;旧表不再被读,灰度结束后再下线 | +| P15 | 落盘失败无重试 | 解决(读侧不再依赖) | 新链路不经落盘;旧落盘任务随 P13 一起在灰度结束后停 | +| P16 | 时间口径不一 | **本期不解决** | 不做时区统一(11.2):轮询路径两环境本来就是 `+08`,现有行为正确;`series_runner.go` 的显式 `.UTC()` 对"本月起点"的影响留档不修(6 张卡/月量级)。只约束新增字段与新增逻辑(11.3 N1~N7),不碰任何现有列 | +| P17 | 轮询吞吐不足 | **不解决** | 非本方案范围,且本方案不叠加采集任务(7 节);采不到的用量不丢但归日推迟,覆盖率与 `gap_days` 把缺口暴露出来,作为轮询改造的输入 |