feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
- 每卡 Redis 采集队列(默认 N=3、跨天清空、回退容差 1 MB),攒满且不下降才写最后一个点 - 测试环境全量切换日快照计量,下线灰度名单、切换水位与卡表基线扣费;全部卡置待建起点 - 读数区分没给值与 0;有套餐的卡读数 0 也写快照与日用量 0 行;重置日比较基准为 0 照常防旧值 - 新增采集记录(按月分区保留 90 天)、人工冲正流水与后台接口、采集告警 - 扣减按采集时刻有效套餐查找,只对生效中套餐做状态迁移,查不到套餐按成功跳过 - 换货新卡(新设备下的卡)打待建起点;采集间隔上限只校验启用中的轮询配置 Change: add-card-traffic-queue-and-observed-deduction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
443
docs/卡流量采集写入与扣减技术方案.md
Normal file
443
docs/卡流量采集写入与扣减技术方案.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# 卡流量采集写入与扣减技术方案
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 日期 | 2026-10-28 |
|
||||
| 状态 | 技术方案已确认,2026-10-28 按评审意见修订(T18–T25,T6 照搬老系统、按配置的重置日),**代码尚未修改** |
|
||||
| 前置方案 | `docs/卡流量计算技术方案-参考老卡管.md`(第 2 版):日快照、按重置日换算、重置日防旧值、按月对账、切换水位。本方案在它的基础上补齐"读数写入前的过滤""采集留痕""扣减口径""换卡""冲正" |
|
||||
| 对应 Change | `add-card-traffic-queue-and-observed-deduction`(基于已实现的 `rebuild-card-traffic-metering-and-stop-resume`) |
|
||||
| 范围 | 测试环境 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论
|
||||
|
||||
1. **核心问题是多扣和少扣。** 现在的卡表基线算法(`last_gateway_reading_mb` + 重置窗口)一个读数就直接生效:暴涨立即扣、窗口内抖动重复扣、延迟重置整月不扣。灰度路径(日快照 + 本月最大值 − 已结算)解决了重置相关的问题,但没有过滤异常点,而且当天快照取最大值,暴涨会被留下来。
|
||||
2. **本方案:**
|
||||
- 读数先进 **Redis 队列**,队列内读数不下降(允许小容差)才写**最后一个点**,异常点在写入前拦下。
|
||||
- **一次性全量切换**为日快照 + 本月最大值 − 已结算,卡表基线算法、灰度名单、切换水位下线。
|
||||
- 有套餐的卡**每次采集都留记录**,日快照读数为 0 也写;没套餐的卡只在读数 > 0 时写日快照。
|
||||
- 扣减保持**增量扣减**,扣费按**采集时刻**确定套餐,日期按采集时间算。
|
||||
- 扣错了走**人工冲正流水**;初期不做自动对账,由人工排查。
|
||||
|
||||
### 1.1 已定决策(2026-10-28)
|
||||
|
||||
| # | 事项 | 决定 |
|
||||
|---|---|---|
|
||||
| T1 | 无效读数 | 接口失败、限流、缺字段、`used` 为 null / 空 / 解析失败、负数、NaN:不入队、不写快照;`FlexFloat` 要能区分"没值"和 0 |
|
||||
| T2 | 队列存储 | Redis,每卡一个 key,Lua 原子更新 |
|
||||
| T3 | 队列长度 | N = 3,可配置 |
|
||||
| T4 | 写入条件 | 队列满 N 个点,相邻两段都不下降(回退在容差内算持平),写**最后一个点**(即本次采集的点);新点不在队尾时不写 |
|
||||
| T5 | 跨天清空 | 可配置开关 `traffic.queue.clear_on_new_day`,默认打开;打开时新点与队列里的点不在同一天(上海时间),先清空再入队 |
|
||||
| T6 | 重置日 | 照搬老系统(移动 `YidongDayFlowService`、联通 `PtLiantongDayFlowService`),只把重置日换成通道配置的重置日:按配置的重置日换算成自然月累计;重置日当天全天防旧值(读数 > 比较基准 × 0.9 按 0 计;比较基准为 0 时当天任何正读数都按 0 计,不论配置的重置日是几号),不按小时区分,不设多日警惕期;重置日当天没取到有效数据,第二天起照常按新周期计;重置日早上的旧周期读数不记回前一天,月末尾巴丢失 |
|
||||
| T7 | 路径统一 | 测试环境一次性全量切换,不再灰度:所有卡走日快照 + 本月最大值 − 已结算;卡表基线算法、`TrafficCalcPolicy`、切换水位下线;切换时所有卡打"待建起点"标记(见 6.2) |
|
||||
| T8 | 写入范围 | 有套餐:每次采集写采集记录;日快照读数为 0 也写;套餐日用量每天一行,没扣也写 0。没套餐:不写采集记录,读数 > 0 才写日快照 |
|
||||
| T9 | 没套餐期间的用量 | 记为已结算,之后买了套餐**不补扣** |
|
||||
| T10 | 扣减方式 | 增量扣减;按采集时刻生效的套餐扣,套餐到扣费时已用完或到期也照扣 |
|
||||
| T11 | 停复机判断 | 通道阈值只在**写入的点**上判断,被队列拦下的点不参与 |
|
||||
| T12 | 其他来源 | 人工刷新、激活前结算与轮询进同一个队列,规则一致(没有运营商流量推送);人工刷新返回本次读数、是否写入、原因 |
|
||||
| T13 | 当天没有写入 | 不补写日快照,靠告警发现 |
|
||||
| T14 | 换卡 | 换卡完成时给新卡打"待建起点"标记;新卡第一次**通过队列写入**时建起点(本月最大快照全部记为已结算,不扣费),之后正常扣 |
|
||||
| T15 | 扣错 | 人工冲正,走冲正流水 |
|
||||
| T16 | 对账 | 初期不做,人工排查 |
|
||||
| T17 | "写入值 ≥ 上次写入值" | 不单独判断:日快照取最大值、增量 = 最大值 − 已结算,小的值本来就不产生增量 |
|
||||
| T18 | "有套餐"口径 | 采集侧(写入范围、是否发事件、采集记录)与扣费侧用同一个"采集时刻有效套餐"判断,**已用完但仍在有效期内的套餐算有套餐**(见 6.1) |
|
||||
| T19 | 按采集时刻扣费的边界 | 只对当前"生效中"的套餐做状态迁移;停机按当前状态判断;扣费时一个套餐都查不到按成功跳过;采集后套餐流量重置的,扣在重置后的周期(见 8.1) |
|
||||
| T20 | 读数 0 | 读数校验放宽为"有限且 ≥ 0",有没有套餐决定 0 写不写,不再由读数校验拦(见第 6 节) |
|
||||
| T21 | 采集间隔上限 | 留 2 次冗余:开关打开时间隔 ≤ 24h ÷ (N+2),关闭时 ≤ `max_point_age_hours` ÷ (N+2)(见 4.4) |
|
||||
| T22 | 回退容差 | 相邻两点回退 ≤ `traffic.queue.drop_tolerance_mb`(默认 1 MB)算持平;重置等大幅下降仍被拦下 |
|
||||
| T23 | 冲正 | 锁套餐行、按当前值加减;退回后已用量不能小于 0;提交后走停复机判断,不直接复机(见第 11 节) |
|
||||
| T24 | 日期 | 整条链路的日期统一用上海日期(`cardflow.ShanghaiDate`),包括消费者的日流量缓冲 key |
|
||||
| T25 | 套餐日用量 0 行 | 在观测事务里写:当天第一次写入时,为采集时刻的每个有效套餐确保当天一行 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 整体流程
|
||||
|
||||
```
|
||||
运营商接口
|
||||
│
|
||||
├─ ① 解析读数 ──────────── 无效:记采集记录(有套餐的卡),结束
|
||||
│
|
||||
├─ ② Redis 队列(Lua 原子):跨天清空(可配)→ 丢弃过期点 → 入队 → 只留最后 N 个
|
||||
│
|
||||
├─ ③ 写入判断:新点在队尾、队列满 N 个点且不下降(容差内)→ 写最后一个点 ── 不满足:记采集记录(有套餐的卡),结束
|
||||
│
|
||||
├─ ④ 数据库事务(卡级行锁)
|
||||
│ 有"待建起点"标记:写快照 → 本月最大全部记为已结算 → 清标记,结束(第 10 节)
|
||||
│ 换算月累计(重置日防旧值)→ 写日快照(取最大)
|
||||
│ → Δ = 本月最大快照 − 本月已结算 → 记已结算
|
||||
│ → 采集时刻有套餐:确保当天套餐日用量行(T25);Δ > 0 时写流量事件(outbox,带采集时间、采集 ID)
|
||||
│ → 通道阈值判断(只在写入的点上)
|
||||
│ → 写采集记录(有套餐的卡)
|
||||
│
|
||||
└─ ⑤ 异步消费流量事件
|
||||
按采集时刻生效的套餐,按优先级扣减(容量按虚流量阈值)
|
||||
→ 套餐日用量 upsert(日期 = 采集日期)→ 扣费审计 → 套餐用完触发停机
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 读数解析
|
||||
|
||||
| 运营商返回 | 入队 | 写快照 | 采集记录(有套餐的卡) |
|
||||
|---|---|---|---|
|
||||
| 接口失败、超时、非 200 | 否 | 否 | 记,`result = failed` |
|
||||
| 限流 | 否 | 否 | 记,`result = rate_limited` |
|
||||
| 响应缺 ICCID 等必要字段 | 否 | 否 | 记,`result = invalid_payload` |
|
||||
| `used` 为 null、空、无法解析 | 否 | 否 | 记,`result = no_reading`,读数为空 |
|
||||
| 负数、NaN、Inf | 否 | 否 | 记,`result = invalid_reading` |
|
||||
| ≥ 0 的数 | 是 | 看第 5 节 | 记 |
|
||||
|
||||
**改动**:`internal/gateway/models.go:55` 的 `FlexFloat` 目前把 null、空、无法解析都转成 0。改为带"是否有值"的类型(或 `*float64`),流量查询结果据此区分"没值"和真实的 0;其他使用 `FlexFloat` 的字段保持原行为。
|
||||
|
||||
新类型的**零值必须表示"没值"**:响应里完全没有 `used` 字段时,JSON 解析不会调用 `UnmarshalJSON`,字段停在零值。用 `*float64`(nil)或带 `Valid` 标志的结构(`Valid = false`)都满足;不能用零值为 0 的类型。
|
||||
|
||||
---
|
||||
|
||||
## 4. 采集队列(Redis)
|
||||
|
||||
### 4.1 存储
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| key | `traffic:queue:{card_id}` |
|
||||
| 值 | 最近 N 个点,每个点 = 采集时间(Unix 毫秒)+ 运营商原始读数(MB,原样,不换算) |
|
||||
| 过期 | 每次更新后设为第二天 01:00(上海时间);跨天清空关闭时设为 `max_point_age_hours` 之后 |
|
||||
| 内存 | 每卡约 150 字节,100 万张卡约 150 MB |
|
||||
|
||||
队列只是写入前的过滤器,**不是账**:
|
||||
|
||||
- key 丢了(Redis 重启、淘汰)→ 队列从空开始,晚写 N−1 次,不多扣也不少扣。
|
||||
- 入队成功、后面的数据库事务失败 → 队列里多一个点,下次采集重新判断即可。
|
||||
|
||||
Redis 淘汰策略:测试环境已确认为 `noeviction`、未设 `maxmemory`,队列不会被淘汰;内存需随卡数观察。
|
||||
|
||||
### 4.2 Lua 脚本
|
||||
|
||||
入参:`card_id`、新点(采集时间、读数)、`N`、`clear_on_new_day`、`max_point_age_ms`、过期时间。上海日期由 Go 侧算好传入(每个点存日号),Lua 里不做时区换算。
|
||||
|
||||
```
|
||||
points = 读出队列
|
||||
if clear_on_new_day and points 非空 and 上海日期(points 最后一个点) != 上海日期(新点):
|
||||
points = []
|
||||
if max_point_age_ms > 0:
|
||||
丢掉 points 中 新点时间 − 点时间 > max_point_age_ms 的点
|
||||
按采集时间把新点插入 points(同一时间的点去重)
|
||||
只保留最后 N 个
|
||||
写回,设置过期
|
||||
return points, 新点是否在队尾
|
||||
```
|
||||
|
||||
按采集时间插入,保证同一张卡同时有两次采集(轮询 + 人工刷新)时顺序正确;观测事务本身仍然加卡级行锁。
|
||||
|
||||
**乱序**:新点插入后不在队尾(比队列里已有的点更早到达顺序靠后),或者插进去就被裁掉,本次**不写**,`decision = out_of_order`。队尾的点之前已经判断过,再写一次虽然不影响账(GREATEST + 增量公式),但会让这条采集记录看起来写了自己的读数,排查时误导。
|
||||
|
||||
### 4.3 配置项
|
||||
|
||||
放在系统配置里,读法与沉默期总开关 `package.silence.enabled` 相同(`internal/application/packagesilence/settings.go`,带短缓存),改了不用重新发布。
|
||||
|
||||
| key | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `traffic.queue.size` | 3 | 队列长度 N,最小 2 |
|
||||
| `traffic.queue.clear_on_new_day` | true | 跨天是否清空 |
|
||||
| `traffic.queue.max_point_age_hours` | 72 | 队列里超过这个时长的点,入队时丢掉;0 表示不限 |
|
||||
| `traffic.queue.drop_tolerance_mb` | 1 | 相邻两点回退不超过这个值算持平;0 表示不允许回退 |
|
||||
|
||||
### 4.4 跨天清空开关
|
||||
|
||||
| | 打开(默认) | 关闭 |
|
||||
|---|---|---|
|
||||
| 队列里的点 | 只有当天的 | 可以跨天,最长 `max_point_age_hours` |
|
||||
| 每天首次写入 | 当天攒满 N 个点 | 跨天后的新点进来就能判断 |
|
||||
| 前一天最后一段用量 | 算进第二天第一次写入 | 跨天的点能直接写 |
|
||||
| 采集间隔上限 | **间隔 ≤ 24h ÷ (N+2)**(N=3 时为 4.8 小时) | 间隔 ≤ `max_point_age_hours` ÷ (N+2)(默认为 14.4 小时) |
|
||||
|
||||
关闭时的重置日:队列里存的是原始读数,重置时读数大幅下降、斜率为负,自然被拦下,新周期攒满 N 个不下降的点后才写,不会把新旧周期混着写。
|
||||
|
||||
`max_point_age_hours` 的作用:卡停用几天后重新采集时,不用几天前的旧点来确认当前读数是否稳定。
|
||||
|
||||
**采集间隔上限**:理论最低要求是窗口内采到 N 次(开关打开时 24h ÷ N),但这样一次失败或限流,当天就写不进去,T13 告警会常态触发。上限按 N+2 次算,留 2 次冗余。
|
||||
|
||||
间隔还决定每天第一次写入的时间:开关打开时,当天第一次写入在第一次采集之后 (N−1) × 间隔(N=3、间隔 4.8 小时,约为当天第一次采集后 9.6 小时)。这之前的用量(含前一天最后一段)都在这次写入时一起扣,停机也在这时才判断。需要更及时的扣费和停机时,应缩短间隔,而不是只满足上限。
|
||||
|
||||
**采集间隔校验**:只校验**启用中**的轮询配置,停用的不管。保存(启用状态)或启用轮询配置时,流量采集间隔超过当前开关下的上限直接报错;`traffic.queue.size` 或开关修改时,同样校验现有启用中的轮询配置,不满足的拒绝保存。测试库启用中的只有 id 29(间隔 60 秒,满足上限);id 25(86400 秒)是停用的,不拦,以后要启用时再按上限调小。
|
||||
|
||||
---
|
||||
|
||||
## 5. 写入判断
|
||||
|
||||
同时满足以下条件,写**最后一个点**(即本次采集的点):
|
||||
|
||||
1. 新点在队尾(4.2 乱序);
|
||||
2. 队列满 N 个点;
|
||||
3. 相邻两点都不下降:后一个读数 ≥ 前一个读数 − `drop_tolerance_mb`。
|
||||
|
||||
- 只比较先后读数,与采集间隔是否均匀无关。
|
||||
- 持平也写:有套餐但没用流量的卡能写入 0 用量。
|
||||
- **容差**:日快照取当天最大值、增量 = 最大值 − 已结算,小幅回退的点写进去也不产生增量,所以容差不会造成多扣;它只是避免读数在几 KB 内来回跳时整窗不写。重置时的大幅下降远超容差,照样被拦下。
|
||||
|
||||
N=3、容差 1 MB 的例子:
|
||||
|
||||
| 队列 | 结果 |
|
||||
|---|---|
|
||||
| 100, 150, 200 | 写 200 |
|
||||
| 100, 100, 100 | 写 100 |
|
||||
| 100, 150, 120 | 不写 |
|
||||
| 100, 99999, 120 | 不写(中间点暴涨被拦下) |
|
||||
| 100, 110, 99999 | 写 99999(最后一个点暴涨挡不住,见第 14 节) |
|
||||
| 100.002, 100.001, 100.003 | 回退 0.001 在容差内,写 100.003 |
|
||||
| 3010, 20, 25(重置) | 不写 |
|
||||
| 20, 25, 30(新周期) | 写 30 |
|
||||
|
||||
不写时,原因记进采集记录(第 7.1 节)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 写入:日快照与结算
|
||||
|
||||
沿用灰度路径(`internal/application/cardobservation/traffic_flow.go`),改动见下。
|
||||
|
||||
**读数校验放宽**:`cardflow.ValidReading` 现在要求读数 > 0,`writeSnapshot` 与 `writeUnswitchedSnapshot` 都以它为前提,读数为 0 的点写不进快照,与 T8 冲突。改为"有限且 ≥ 0"(与第 3 节的入队条件一致);没套餐的卡读数为 0 不写快照,由 6.1 的写入范围判断,不再由读数校验拦。读数 0 在重置日不触发防旧值(`isStale` 对 ≤ 0 直接返回否),换算结果不变。
|
||||
|
||||
1. **换算**:`cardflow.Convert`,按卡所属通道的重置日把**被写入那个点**的读数换算成自然月累计;重置日当天读数超过比较基准 × 0.9 按 0 计(防旧值)。日期取被写入那个点的采集日期。
|
||||
2. **写日快照** `tb_card_flow_day`:upsert,`month_used_mb` 取当天最大(`GREATEST`)。
|
||||
3. **结算**:`Δ = 本月最大快照 − 本月已结算`,Δ > 0 记入当天 `settled_mb`。
|
||||
4. **事件**:采集时刻卡有套餐(6.1.1)且 Δ > 0,同事务写流量事件(outbox),事件带采集时间和采集 ID。
|
||||
5. **停复机**:通道阈值只在这里判断(被队列拦下的点不判断)。
|
||||
|
||||
### 6.1 写入范围
|
||||
|
||||
| | 有套餐 | 没套餐 |
|
||||
|---|---|---|
|
||||
| 被写入的点读数为 0 | 写日快照 | 不写 |
|
||||
| 被写入的点读数 > 0 | 写日快照 | 写日快照 |
|
||||
| 套餐日用量 | 每个有效套餐(6.1.1)每天一行,没扣也写一行 0 | — |
|
||||
| Δ | 发事件扣费 | 只记已结算(T9) |
|
||||
|
||||
**套餐日用量 0 行在观测事务里写**(T25):扣减消费者只在 Δ > 0 时才运行,没用流量的卡走不到那里。所以在观测事务里,当天第一次写入时,为采集时刻的每个有效套餐 `INSERT ... ON CONFLICT (package_usage_id, date) DO NOTHING` 一行 0;之后扣减按 8.2 累加。由 `usage_service` 提供方法,观测事务调用,不在 cardobservation 里直接写套餐表。
|
||||
|
||||
#### 6.1.1 "有套餐"口径(T18)
|
||||
|
||||
采集侧(写入范围、是否发事件、是否写采集记录)与扣费侧(8.1 查套餐)用**同一个函数**判断"采集时刻有效套餐":
|
||||
|
||||
| 条件 | 说明 |
|
||||
|---|---|
|
||||
| 状态 | `status IN (生效中, 已用完, 已过期)`;待生效、已失效不算 |
|
||||
| 已生效 | `activated_at ≤ observed_at`;`activated_at` 为空的历史数据按已生效处理(与现有口径一致) |
|
||||
| 未到期 | `expires_at` 为空,或 `expires_at > observed_at` |
|
||||
| 未退款 | `refund_id IS NULL` |
|
||||
| 载体 | 卡自己的套餐;卡自己一个都没有时,看卡当前绑定设备的套餐 |
|
||||
|
||||
- **已用完但仍在有效期内的套餐算有套餐**:套餐用完到真正停机之间的用量照常发事件,扣费时进最后一个套餐的超出部分(T10)。现在 `countActivePackages`(`traffic_calc_policy.go:148`)只认"生效中"、不看 `expires_at`,这段用量被记为已结算、不发事件,`recordOverflowToDepletedPackage` 实际收不到流量,需要改。
|
||||
- 已过期状态之所以纳入,是因为状态可能在采集之后才变为已过期;是否在采集时刻有效由 `expires_at` 判断。
|
||||
- 待实名激活、沉默期等待中的套餐处于待生效状态,不算有套餐,期间用量按 T9 记为已结算。
|
||||
|
||||
### 6.2 路径统一
|
||||
|
||||
测试环境**一次性全量切换**,不再灰度(T7):
|
||||
|
||||
- 删掉 `TrafficCalcPolicy`(灰度名单)、卡表基线算法的扣费作用、第一次切换逻辑(`firstSwitch`)和切换水位(`tb_card_flow_switch` 不再读写)。
|
||||
- **切换时所有卡打"待建起点"标记**(随迁移执行,与换卡同一个标记,第 10 节)。原因:切换前扣费走的是卡表基线算法,日快照上没有本月已结算(测试库 `tb_card_flow_day` 为 0 行);不打标记,切换后第一次写入的 Δ 会是运营商读数对应的整月用量,而这些用量老路径已经扣过。打了标记,每张卡切换后第一次写入只建起点、不扣费。
|
||||
- 切换过程中老代码还在跑也安全:老代码不认标记,照常按老路径扣;新代码第一次写入只建起点,不会重复扣。
|
||||
- 代价:老路径最后一次扣费到新路径建起点之间的用量不扣(约 N−1 个采集周期),测试环境可接受。
|
||||
- 卡表上的 `current_month_usage_mb` 等展示字段由日快照覆盖(与现灰度路径一致);`last_gateway_reading_mb` 不再使用。
|
||||
|
||||
---
|
||||
|
||||
## 7. 留痕
|
||||
|
||||
### 7.1 采集记录(新表)
|
||||
|
||||
只记**有套餐的卡**,每次采集一条,包括接口失败和读数为 0。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id`、`iot_card_id`、`observed_at` | |
|
||||
| `observation_id` | 采集 ID,与流量事件、扣费审计、对接日志串联 |
|
||||
| `source`、`scene` | 轮询、人工刷新、激活前结算 |
|
||||
| `result` | 读数是否有效:`ok` / `failed` / `rate_limited` / `invalid_payload` / `no_reading` / `invalid_reading` |
|
||||
| `vendor_used_mb` | 运营商原始读数,可为空 |
|
||||
| `queue_points` | 入队后的队列(jsonb:采集时间 + 读数) |
|
||||
| `decision` | 读数有效之后怎么判断的,`result ≠ ok` 时为空:`written` / `baseline`(建起点)/ `queue_not_full` / `slope_negative` / `out_of_order` / `settled_existing`(激活前结算没写入,用已有本月最大快照结算) |
|
||||
| `month_used_mb`、`stale_guarded` | 写入时才有:换算后月累计、是否触发防旧值 |
|
||||
| `delta_mb`、`event_id` | 写入时才有:本次 Δ、流量事件 ID |
|
||||
| `created_at` | |
|
||||
|
||||
- 按 `observed_at` 按月分区,保留 90 天,过期直接删分区(不用 `DELETE ... LIMIT`)。整月都过期才能删,所以同时存在 4 个分区(约 120 天的数据)。
|
||||
- 数据量:每天行数 ≈ 有套餐的卡数 × 每天采集次数。例:10 万张卡、间隔 4.8 小时(每天 5 次),每天 50 万行,4 个分区约 6000 万行。
|
||||
- 索引:`(iot_card_id, observed_at DESC)`、`observation_id`。
|
||||
|
||||
### 7.2 排查路径
|
||||
|
||||
```
|
||||
扣费审计(package_usage.deduct_traffic:扣了哪个套餐、多少、前后值)
|
||||
→ parent_event_id → 流量事件(卡、增量、采集时间、采集 ID)
|
||||
→ observation_id → 采集记录(当时的原始读数、队列、判断结果)
|
||||
→ 卡 + 日期 → 日快照(当天最大值、已结算)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 扣减
|
||||
|
||||
扣减逻辑沿用 `internal/service/package/usage_service.go` 的 `DeductDataUsage`:小数部分攒在 Redis;加油包优先,再按 `priority`、`id`;每个套餐容量 = 阈值 − 已用(启用虚流量时阈值为虚总量,否则为真总量);最后一个套餐承接超出部分;全部用完触发停机。
|
||||
|
||||
改动两点:
|
||||
|
||||
### 8.1 按采集时刻确定套餐
|
||||
|
||||
- 流量事件已带 `observed_at`,消费者把它传给扣减。
|
||||
- 查套餐由"当前 `status = 生效中`"改为"**采集时刻处于生效期内**":已激活、`activated_at ≤ observed_at`、未到期(`expires_at > observed_at`)、未退款、未作废;设备回退口径不变。
|
||||
- 查询条件与 6.1.1 共用同一个函数,采集侧和扣费侧不会出现"采集说有、扣费说没有"(采集后退款、作废的情况除外,见下)。
|
||||
- 采集时刻生效、但扣费时已用完或已到期的套餐**照扣**(T10)。
|
||||
- 排序、容量、超出承接规则不变。
|
||||
|
||||
**状态迁移只作用于当前"生效中"的套餐**(T19)。现有循环(`usage_service.go:124-166`)在余量 ≤ 0 或扣到阈值时把套餐改成"已用完"。查询范围扩大后,集合里会有已过期、已用完的套餐,按现有写法会把"已过期"改回"已用完"。改为:
|
||||
|
||||
| 套餐当前状态 | 累加 `data_usage_mb`、写日用量 | 改状态 |
|
||||
|---|---|---|
|
||||
| 生效中 | 是 | 扣到阈值改为已用完(同现在) |
|
||||
| 已用完 | 是(只会作为最后一个套餐承接超出) | 否 |
|
||||
| 已过期 | 是 | 否 |
|
||||
|
||||
- 余量 ≤ 0 且不是最后一个套餐的,跳过;只有当前是"生效中"的才顺带改为已用完。
|
||||
- 停机判断 `checkAndTriggerSuspension` 不变,仍按**当前**状态判断是否还有可用套餐。
|
||||
|
||||
**扣费时一个套餐都查不到**:采集时刻有套餐(所以发了事件),扣费前该套餐被退款或作废。现在 `DeductDataUsage` 返回"没有可用套餐"错误,消费者会一直重试。改为:记告警日志和扣费审计(标明跳过原因),事件按成功处理,不重试;该笔用量需要时走人工冲正。
|
||||
|
||||
**采集之后套餐流量被重置**:日/月重置任务(`reset_service.go`)把 `data_usage_mb` 清零。采集时刻早于套餐 `last_reset_at`、扣费在重置之后的,**扣在重置后的周期**,不跳过(跳过就是少扣)。审计里记录 `observed_at` 与 `last_reset_at`,排查时能看出这笔是跨重置扣的。列入第 14 节已知限制。
|
||||
|
||||
### 8.2 套餐日用量
|
||||
|
||||
- `updateDailyRecord` 由"先查后插"改为 upsert(`(package_usage_id, date)` 唯一),`daily_usage_mb` 累加、`cumulative_usage_mb` 覆盖。
|
||||
- `date` 取采集日期(`cardflow.ShanghaiDate(observed_at)`),不取扣费时间。
|
||||
- 消费者的日流量缓冲 key(`event.go` 里 `RedisCardDailyTrafficKey(..., event.ObservedAt.Format(...))`)同样改为上海日期;现在直接 `Format`,时间值是 UTC 时上海 0~8 点会落到前一天(T24)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 其他读数来源
|
||||
|
||||
| 来源 | 处理 |
|
||||
|---|---|
|
||||
| 轮询 | 第 2~6 节 |
|
||||
| 人工刷新 | 同一个队列、同一套规则;接口返回本次读数、是否写入、不写原因 |
|
||||
| 激活前结算 | 读数同样过队列;本次没写入时,用卡上已有的本月最大快照做结算(把它全部记为已结算),不新写快照,`decision = settled_existing` |
|
||||
|
||||
激活前结算没写入时,"最近一次写入到激活"之间的用量会算给新套餐,精度到写入粒度(第 14 节)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 换卡
|
||||
|
||||
套餐使用记录随换卡整条迁到新卡(`internal/service/exchange/migration.go:103`),已用量跟着走,所以不需要老系统那套"旧卡流量补算"(`tbl_new_card_add_flow`)。需要处理的是新卡的起点:
|
||||
|
||||
| 问题 | 例子 | 处理 |
|
||||
|---|---|---|
|
||||
| 新卡换卡前已有读数 | 出库前测试用了 50 MB,库存期间没轮询 | 新卡**建起点**(下文),这 50 MB 不扣 |
|
||||
| 旧卡最后一段 | 最后一次采集 10:00,换卡完成 10:30 | 不处理:旧卡之后的采集按没套餐处理,记为已结算(与 T9 一致) |
|
||||
|
||||
**建起点**:
|
||||
|
||||
1. 换卡事务内,在新卡上打"待建起点"标记(`tb_iot_card` 新增标记字段),不在换卡流程里调用运营商接口。
|
||||
2. 新卡的读数照常过队列。**第一次满足写入条件**时,看到标记:写日快照,把新卡本月最大快照全部记为已结算,不发事件,清掉标记,写采集记录(`decision = baseline`)。
|
||||
3. 之后按正常规则处理。
|
||||
|
||||
建起点也过队列,是为了不让一个异常读数当起点:第一个读数是 99999 时如果直接当起点,本月最大值一直是 99999,之后整月增量为 0、不扣。
|
||||
|
||||
N=3、间隔 1 小时的例子:
|
||||
|
||||
| 时间 | 事件 | 读数 | 队列 | 处理 |
|
||||
|---|---|---|---|---|
|
||||
| 10-20 10:00 | 换卡完成 | — | — | 打标记 |
|
||||
| 10-20 11:00 | 采集 | 52 | 52 | 不满,不写 |
|
||||
| 10-20 12:00 | 采集 | 53 | 52, 53 | 不满,不写 |
|
||||
| 10-20 13:00 | 采集 | 55 | 52, 53, 55 | 写 55,建起点:55 记为已结算,不扣,清标记 |
|
||||
| 10-20 14:00 | 采集 | 58 | 53, 55, 58 | 写 58,扣 3 |
|
||||
|
||||
代价:换卡完成到建起点之间的用量不扣(比直接用第一个读数多 N−1 个采集周期),与"旧卡最后一段不补扣"口径一致。
|
||||
|
||||
设备换货时,设备套餐迁到新设备,新设备绑定的每张卡同样打标记。
|
||||
|
||||
---
|
||||
|
||||
## 11. 人工冲正
|
||||
|
||||
新增冲正流水表,冲正只通过它进行,不直接改套餐字段。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id`、`package_usage_id`、`iot_card_id` / `device_id` | |
|
||||
| `delta_mb` | 正数补扣,负数退回 |
|
||||
| `before_usage_mb`、`after_usage_mb` | 套餐已用量前后值 |
|
||||
| `reason` | 必填 |
|
||||
| `ref_observation_id`、`ref_event_id` | 关联的采集记录、流量事件,可选 |
|
||||
| `operator_id`、`created_at` | |
|
||||
|
||||
处理:
|
||||
|
||||
1. 事务内先 `SELECT ... FOR UPDATE` 锁套餐行(与扣减同一把锁),按**锁住后的当前值** + `delta_mb` 计算新值,`before_usage_mb` 取锁住后的值。避免与扣减并发时互相覆盖。
|
||||
2. 退回后已用量小于 0 直接报错,不截成 0。
|
||||
3. 同一事务内写流水、更新套餐 `data_usage_mb`,按阈值重新计算状态(已用完 ↔ 生效中);已过期、已失效的套餐只改已用量,不改状态(与 8.1 一致)。
|
||||
4. 写审计(新动作码,例如 `package_usage.adjust_traffic`)。
|
||||
5. 提交后调用现有停复机判断(`evaluator.EvaluateAndAct`),**不直接复机或停机**:卡可能同时因为实名、沉默期、人工等原因停着,冲正只改变了"套餐用完"这一个条件,由停复机判断综合决定。
|
||||
6. 卡上的日快照与已结算不改,冲正只作用在套餐上。
|
||||
|
||||
---
|
||||
|
||||
## 12. 监控与告警
|
||||
|
||||
| 指标 | 告警条件 |
|
||||
|---|---|
|
||||
| 有套餐的卡一整天没写入 | 开启轮询的卡,当天采集记录里没有 `decision = written`(T13) |
|
||||
| 队列拦截率 | 某张卡当天 `slope_negative` 占比过高,或全局拦截率突增 |
|
||||
| 待建起点 | 开启轮询的卡,"待建起点"标记超过 1 天未清除(一直没写入);不轮询的卡不告警 |
|
||||
| 乱序 | `out_of_order` 数量突增(排查并发采集或时钟问题) |
|
||||
| 防旧值命中 | 重置日命中数,便于核对重置日配置 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 改动清单
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| `internal/gateway/models.go` | 流量读数区分"没值"和 0,零值表示没值 |
|
||||
| `internal/task/polling_carddata_handler.go` | 无效读数分类;调用队列;写采集记录 |
|
||||
| 新增队列组件(Redis + Lua)、配置项注册 | 第 4 节;返回新点是否在队尾;写入判断(不下降 + 容差、写最后一个点) |
|
||||
| `internal/application/cardobservation/traffic.go`、`traffic_flow.go` | 按 6.1 的写入范围;确保套餐日用量 0 行;通道阈值只在写入点判断;删除未切换分支、`firstSwitch` 与切换水位 |
|
||||
| `internal/domain/cardobservation/traffic.go` | 基线算法下线 |
|
||||
| `internal/application/cardobservation/traffic_calc_policy.go` | `hasActivePackageForCard` 改为调用 6.1.1 的共用判断(含已用完、按 `expires_at`、排除退款);删除灰度名单 |
|
||||
| `internal/domain/cardflow/rules.go` | `ValidReading` 放宽为"有限且 ≥ 0";`isStale` 去掉 `ThisMonthBeforeResetMax > 0` 条件,比较基准为 0 时不论配置的重置日是几号都照常防旧值;调用方注释同步 |
|
||||
| `internal/infrastructure/cardobservation/event.go` | 把 `observed_at` 传给扣减;日流量缓冲 key 用上海日期 |
|
||||
| `internal/service/package/usage_service.go` | 按采集时刻查套餐(与采集侧共用判断);状态迁移只作用于当前生效中的套餐;查不到套餐按成功跳过;`updateDailyRecord` upsert,日期按采集日期(上海);提供"确保当天日用量行"方法供观测事务调用 |
|
||||
| 人工刷新、激活前结算入口 | 过队列;人工刷新返回写入结果 |
|
||||
| `internal/service/exchange` | 换卡完成时给新卡打"待建起点"标记 |
|
||||
| 观测写入 | 有"待建起点"标记时,第一次满足写入条件时建起点并清标记 |
|
||||
| 轮询配置保存/启用、队列配置保存 | 采集间隔上限校验(24h 或 `max_point_age_hours` ÷ (N+2)),只校验启用中的配置 |
|
||||
| 新表 + 迁移 | 采集记录(按月分区)、冲正流水;`tb_iot_card` 待建起点标记,迁移时**所有卡置为待建起点**(6.2);`(package_usage_id, date)` 唯一索引 |
|
||||
| 后台接口 | 采集记录查询、冲正(锁套餐行、不允许负数、提交后走停复机判断) |
|
||||
|
||||
---
|
||||
|
||||
## 14. 已知限制
|
||||
|
||||
| 限制 | 说明 |
|
||||
|---|---|
|
||||
| 最后一个点暴涨 | 队列挡不住,靠人工冲正 |
|
||||
| 运营商连续返回错误且递增 | 队列挡不住,靠人工冲正 |
|
||||
| 运营商整体抬高、事后下调 | 队列挡不住;初期靠人工,后期可接运营商 T+1 数据对账 |
|
||||
| 月末尾巴 | 最后一次**写入**的旧周期读数到运营商归零之间的用量丢失,与老系统一致 |
|
||||
| 防旧值比较基准为 0 | 没有快照可比(新卡、之前没写入)时,配置的重置日当天任何正读数都按 0 计,次日如实写入;只会晚一天扣,不会多扣 |
|
||||
| 运营商跨天才归零 | 防旧值只管重置日当天(T6,不设警惕期);运营商晚一天才清零、重置日当天没取到数后第二天拿到旧数、清零后又返回缓存旧数,这些读数都会被当成新周期用量,造成多扣 |
|
||||
| 写入粒度 | 激活前结算、换卡、没套餐 → 有套餐的切换点,精度都到"最近一次写入" |
|
||||
| 晚写 | 队列需要攒满 N 个点,读数回退超过容差时会一直不写,扣费与停机相应延后 |
|
||||
| 切换和换卡的起点 | 切换、换卡后到建起点之间的用量不扣(约 N−1 个采集周期) |
|
||||
| 跨套餐流量重置 | 采集在套餐重置前、扣费在重置后的,扣在重置后的周期 |
|
||||
| 采集后退款、作废 | 扣费时查不到套餐,跳过不扣,需要时人工冲正 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 后续
|
||||
|
||||
- **对账**:接入运营商 T+1 日用量或月账单后,定期从日快照(或运营商数据)重算,与套餐已用量比对;多扣自动退回、少扣告警,调整同样走冲正流水。
|
||||
- **月末尾巴**:重置日把旧周期读数的增量补上(旧周期读数 ≥ 上次旧周期读数时,差额计入本月),可选。
|
||||
Reference in New Issue
Block a user