Files
junhong_cmp_fiber/openspec/changes/add-card-traffic-queue-and-observed-deduction/design.md
邹进 9c3c258f16
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
- 每卡 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>
2026-10-29 12:18:47 +09:00

9.3 KiB
Raw Blame History

Context

完整推导、例子与决策编号(T1–T25)见 docs/卡流量采集写入与扣减技术方案.md。本文只记录实现层的关键决策。

现状(测试环境,Iteration/8-11):

  • rebuild-card-traffic-metering-and-stop-resume 已实现日快照(tb_card_flow_day)、自然月换算与重置日防旧值(internal/domain/cardflow/rules.go)、按月对账、切换水位与灰度名单(traffic_calc_policy.go);测试环境 JUNHONG_TRAFFIC_CALC_MODE=gray、名单 device:10。
  • cardflow.ValidReading 要求读数 > 0,测试库全部轮询卡读数为 0,tb_card_flow_day 与 tb_card_flow_switch 均为 0 行,扣费实际一直走卡表基线算法。
  • internal/gateway/models.go 的 FlexFloat 把 null、空、无法解析都转成 0。
  • DeductDataUsage(internal/service/package/usage_service.go)按扣费时刻 status = 生效中 查套餐,没有套餐时返回错误由 Asynq 重试;updateDailyRecord 先查后插、日期取 time.Now();循环内会把余量 ≤ 0 的套餐改为已用完。
  • hasActivePackageForCard / countActivePackages(traffic_calc_policy.go:135-171)只认生效中、不看 expires_at。
  • 测试环境启用中的轮询配置只有 id 29(流量采集 60 秒),Redis 为 noeviction、未设 maxmemory,数据库与容器时区为上海;只有实名回调,没有运营商流量推送。
  • 约束:自动化测试为 N/A,验证在维护者指定的测试环境进行;不改既有迁移;不改对接日志与审计写入(本次不动)。

Goals / Non-Goals

Goals:

  • 单次异常读数(中间点暴涨、清零前后来回跳)在写入前拦下,不进快照、不扣费、不触发通道阈值。
  • 测试环境只剩一条计量路径,切换不重复扣。
  • 采集侧与扣费侧"有没有套餐"一致,套餐用完到停机之间的用量能扣到。
  • 每次采集可追溯,扣错可人工冲正。

Non-Goals:

  • 自动对账(运营商 T+1 日用量或月账单)、月末尾巴补算。
  • 最后一个点暴涨、运营商连续返回递增错误值、运营商晚清零的自动识别。
  • 跨套餐重置时由写入间隔造成的用量归属偏移(避免不了,不处理)。
  • 对接日志、审计的瘦身;复机无效重试问题。
  • 生产环境发布、迁移与回滚。

Decisions

D1 队列放 Redis、Lua 原子更新,不放数据库。 队列只是写入前的过滤器,不是账:丢了只是晚写 N−1 次,不多扣不少扣。放数据库会让每次采集都多一次行写,与 polling-load-control 的低写入目标冲突。Lua 内只做插入、去重、裁剪、跨天清空与过期丢弃,返回队列与"新点是否在队尾";上海日期由 Go 侧算好随点传入,Lua 不做时区换算。写入判断(不下降 + 容差)在 Go 侧做,便于记录原因。

D2 写最后一个点,新点必须在队尾。 写最后一个点让增量最及时;新点不在队尾说明有更晚的点已判断过,再写会让采集记录看起来写了自己的读数,排查时误导,所以记为乱序不写。容差只用于"不下降"判断,日快照取最大值、增量 = 最大值 − 已结算,小幅回退的点写进去不产生增量,不会多扣。替代方案"写倒数第二个点"能挡住最后一个点暴涨,但每次写入都晚一个采集周期,已否决。

D3 采集间隔上限按 N+2 次。 理论最低是窗口内采到 N 次,但一次失败当天就写不进去,告警会常态触发;留 2 次冗余。只校验启用中的轮询配置,停用配置(测试库 id 25,86400 秒)不阻止保存,启用时再校验。

D4 全量切换用"待建起点"标记,不保留切换水位。 切换前扣费走卡表基线算法,日快照上没有本月已结算;不打标记,切换后第一次写入的 Δ 会是整月用量,而这部分老路径已扣过。标记与换卡共用同一字段(tb_iot_card 新增),随迁移把全部卡置位;建起点也过队列,避免异常读数当起点。迁移执行时老代码仍在跑也安全:老代码不认标记照常按老路径扣,新代码第一次写入只建起点。代价是老路径最后一次扣费到建起点之间的用量不扣(约 N−1 个采集周期),测试环境可接受。

D5 "有套餐"抽成一个共用判断,采集与扣费同时调用。 由 package 服务提供按 (卡, 采集时间) 查有效套餐的方法;观测事务(写入范围、是否发事件、是否写采集记录、日用量 0 行)与 DeductDataUsage 都调用它。状态纳入已过期,是因为状态可能在采集之后才变;是否在采集时刻有效由 expires_at 判断。

D6 读数 0 由写入范围决定写不写,不由读数校验拦。 ValidReading 放宽为"有限且 ≥ 0";有套餐的卡 0 也写快照与日用量 0 行,用于"每天都有记录"的排查需求;没套餐的卡读数 0 不写。isStale 对 ≤ 0 直接返回否,换算结果不变。

D7 重置日照搬老系统,只认配置的重置日;比较基准为 0 统一照常防旧值。 老系统移动(YidongDayFlowService)、联通(PtLiantongDayFlowService)都是重置日当天全天 0.9 判断、不按小时、尾巴不补。两套代码对基准为 0 的处理不同(移动照常判断、联通不判断),统一为照常判断:isStale 去掉 ThisMonthBeforeResetMax > 0 条件,最坏是晚一天扣,不会多扣。不设多日警惕期、不把 1 点前的读数记回前一天。

D8 日用量 0 行在观测事务里写,扣减消费者只累加。 消费者只在 Δ > 0 时运行,没用流量的卡走不到那里。观测事务调用 package 服务的"确保当天日用量行"(INSERT ... ON CONFLICT (package_usage_id, date) DO NOTHING),不在 cardobservation 里直接写套餐表;updateDailyRecord 改为 upsert 累加。

D9 扣费查不到套餐按成功跳过。 原来返回错误会让 Asynq 一直重试,而退款、作废后不可能再查到;改为写跳过审计、事件成功结束,需要时人工冲正。

D10 采集记录单独建表、按月分区,不复用对接日志或审计。 只记有套餐的卡;保留 90 天需要同时存在 4 个月分区,过期直接删整月分区,不用 DELETE ... LIMIT。observation_id 串起采集记录、流量事件、扣费审计。不进 Audit Event 与 Integration Log,与 polling-load-control 的"无变化观测不写审计"不冲突。

D11 冲正锁套餐行、按当前值加减,提交后走停复机判断。 与扣减同一把行锁,避免并发互相覆盖;不允许负数,避免掩盖数据问题。卡可能同时因实名、沉默期、人工等原因停着,冲正只改了"套餐用完"一个条件,交给 evaluator.EvaluateAndAct 综合判断。冲正不改日快照与已结算。

D12 日期统一上海日期。 套餐日用量日期与消费者日流量缓冲 key 都用 cardflow.ShanghaiDate(observed_at);测试环境时区本就是上海,这是防止时间值为 UTC 时 0~8 点落到前一天。

Risks / Trade-offs

  • [最后一个点暴涨挡不住] → 靠人工冲正;告警与采集记录便于发现。
  • [运营商晚一天清零、清零后返回缓存旧数会多扣] → 与老系统一致;重置日防旧值命中数用于核对,多扣走冲正。
  • [要攒满 N 个点才写,扣费与停机延后;跨天清空开启时当天第一次写入在第一次采集后 (N−1) 个间隔] → 测试环境间隔 60 秒,影响约 2 分钟;需要更及时时缩短间隔。
  • [全量切换、换卡后到建起点之间的用量不扣] → 约 N−1 个采集周期,测试环境可接受。
  • [采集后套餐被重置的扣在重置后周期] → 审计记录采集时间与重置时间,可追溯。
  • [采集后退款、作废的用量不扣] → 跳过审计 + 人工冲正。
  • [采集记录写入量 ≈ 有套餐卡数 × 每天采集次数,测试环境 60 秒间隔下单卡每天 1440 行] → 测试环境卡少;分区按月删除。
  • [Redis 内存随卡数增长,每卡约 150 字节] → 测试环境 noeviction 无上限,随卡数观察。
  • [DeductDataUsage 查询范围扩大后,旧循环会把已过期套餐改成已用完] → 状态迁移只作用于当前生效中套餐(规格已明确)。

Migration Plan

  1. 前置:维护者确认后归档 rebuild-card-traffic-metering-and-stop-resume(灰度、切换水位相关验证 9.2–9.5 作废,停复机部分已由 finalize-unified-stop-resume-rule 覆盖)与 add-tiered-polling-and-pre-activation-settlement,使 card-traffic-metering 主规格存在。
  2. 迁移(成对):采集记录分区表、冲正流水表、tb_iot_card 待建起点标记(迁移内对全部卡置位)、套餐日用量 (package_usage_id, date) 唯一索引(建前核对并合并重复行)。
  3. 注册系统配置项默认值;删除计量模式与灰度名单配置及测试环境部署变量。
  4. 部署测试环境后按 tasks 验证;回滚只在测试环境:回退代码与迁移 down,队列 key 自然过期。

Open Questions

  • 重置日读取失败时仍按运营商类型兜底(CUCC 取 27、其余取 1,rebuild-... 的 D6)。这与"只看配置的重置日"不完全一致,本 Change 保持不改,是否改为"读取失败本次不写入并告警"待定。