From 975a211efe1f114dd55b5954952eccc45cf38b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Tue, 20 Oct 2026 15:59:52 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E5=8D=A1=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E8=AE=A1=E7=AE=97=E6=8A=80=E6=9C=AF=E6=96=B9=E6=A1=88?= =?UTF-8?q?=EF=BC=88=E5=8F=82=E8=80=83=E8=80=81=E5=8D=A1=E7=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流量计算照搬老卡管的日快照与区间取最大值做差,套餐侧不改; 记录已定决策、生产核实结果、灰度与补数据方案。 Co-Authored-By: Claude Opus 5.5 --- docs/卡流量计算技术方案-参考老卡管.md | 411 ++++++++++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 docs/卡流量计算技术方案-参考老卡管.md diff --git a/docs/卡流量计算技术方案-参考老卡管.md b/docs/卡流量计算技术方案-参考老卡管.md new file mode 100644 index 00000000..bc4fa31e --- /dev/null +++ b/docs/卡流量计算技术方案-参考老卡管.md @@ -0,0 +1,411 @@ +# 卡流量计算技术方案(参考老卡管) + +| 项目 | 内容 | +|---|---| +| 日期 | 2026-10-20 | +| 状态 | 方案,待评审,**代码尚未修改** | +| 思路 | **流量怎么算,照搬老卡管;套餐那边一行不动**:只替换"增量从哪来",增量照旧通过 V1 事件交给现有 `DeductDataUsage` 扣减 | +| 改动范围 | 观测链路(算增量)+ 新增卡日快照表 + 日流量读取。**套餐表、套餐服务、激活/重置/改已用量/换货/退款入口、扣减顺序、停复机全部不改** | +| 与旧方案关系 | 替代 `docs/流量事实模型技术方案.md`(第 5 版)的计量部分 | +| 老卡管依据 | 反编译 `kyhl-api/WEB-INF/lib` 下 `kyhl-service-1.0.jar`、`kyhl-dal-1.0.jar`、`kyhl-common-1.0.jar`、`kyhl-common-jdbk-1.0.jar` | + +--- + +## 1. 结论 + +1. **漏洞根源**:新卡管现在的增量 = 本次读数 − 卡上的基线(`tb_iot_card.last_gateway_reading_mb`)。基线是跨套餐、跨用户的卡级状态,一旦出错(被异常高读数抬高、重置日没采到)就永久保留:卡被冻结、读数为 0 时重复计、二次销售时多计。 +2. **老卡管不依赖基线**:每张卡每天存一条"本月累计"快照,任何一段时间的用量都用快照现算(区间内最大值 − 区间开始前的最大值)。它没有"读数下降"的概念,已经稳定运行多年。 +3. **本方案**: + - 流量计算照搬老卡管:日快照、按各运营商的重置日(`tb_carrier.data_reset_day`)换算成自然月、重置日防旧值、按区间取最大值做差。 + - 每次观测的增量 = **今天的快照用量 − 今天已经发出的增量**,不再用卡表基线。 + - 增量照旧写进 V1 事件 `CardTrafficIncremented`,由现有消费者调用 `DeductDataUsage` 扣减。**套餐分配、加油包顺序、虚流量阈值、停复机、流量详单全部沿用现有代码。** +4. **二次销售**:卡没有生效套餐时,增量照样计算,但**只推进"已发出"标记、不发事件**。所以激活之前的用量永远不会扣到新套餐上,新套餐只扣激活之后的增长。 +5. **先做卡流量计算,补数据放到后期**:新方案上线后,冻结卡从切换起恢复正常计量;切换前少扣的部分由后期的独立脚本补(5.5)。补数据依据的是运营商当前读数,只能补执行时所在运营商周期内的部分,更早的补不回来。 + +### 1.1 已定决策(2026-10-20) + +| # | 事项 | 决定 | +|---|---|---| +| D1 | 流量计算 | 照搬老卡管:日快照、写入时按运营商重置日(`tb_carrier.data_reset_day`,任意 1~28 号)换算成自然月、按区间取最大值做差,没有读数下降判定。重置日不写死,运营商配置改了随之生效 | +| D2 | 套餐侧 | 一行不改:增量照旧走 V1 事件与 `DeductDataUsage` | +| D3 | 重置日防旧值 | **完全照搬老卡管**(含比较基准):只在重置日当天判断,`R = 1` 比上月最大快照、`R > 1` 比本月 1~R−1 号最大快照,超过 0.9 倍按 0 记;第二天起照常累加,不挂起、不告警。老卡管多年运行没有暴露问题 | +| D4 | 卡无生效套餐时的增量 | 只推进 `emitted_mb`,不发事件,用量丢弃 | +| D5 | 快照记录范围 | 全部被采集的卡 | +| D6 | 上线方式 | 按名单灰度,单位是扣减载体,设备整台切;第一批为 103154 所在设备 15276,核对后扩批再全量。非灰度卡行为与现在一致(4.8) | +| D7 | 积压的无套餐回执 | 业务后续手动调整,不在本方案内;处理前不得重放 | +| D8 | 顺序 | 先上卡流量计算,补数据放后期(5.5) | +| D9 | 补数据:本周期中间重置过套餐的冻结卡 | 不自动补,进清单,附按天数比例的估算值,由人工决定 | + +## 2. 现在的漏洞(已验证,详见旧方案第 2 节) + +| # | 漏洞 | 证据 | +|---|---|---| +| 1 | **卡被冻结**:读数下降只在重置日当天或前一天才被认作清零,其余日子一律拒绝。基线被抬高后,之后的读数全被拒,套餐不再增长,也不停机 | `internal/domain/cardobservation/traffic.go:58-67`。卡 103154:基线 72535 MB,运营商读数 48808 MB,9-25 之后已用量一直为 0。2026-10-12 统计 15310 张活跃卡中有 3511 张(23%,上限估计)10 月基线没动过 | +| 2 | **读数为 0 被当成清零**:重置窗口内读到 0,基线被置 0,下一次真实读数整笔重复计 | `traffic.go:60-61`;电信抽查 124 张中 111 张读数为 0 | +| 3 | **无效响应按 0 处理**:`used` 为空、null、无法解析时当成 0,进而触发第 2 条 | `internal/gateway/models.go:55-78` | +| 4 | **二次销售多计**:新用户激活时,卡上的基线如果不是激活那一刻的值,差额就扣到了新套餐上 | 资产回收换代见 `docs/资产回收换代技术方案.md` | +| 5 | **日流量从来没存成功**:`tb_card_daily_usage` 的表结构和代码不一致,表里 0 行 | 旧方案 P14 | +| 6 | **无套餐期间的增量会被扣到下一个套餐上**:老路径不管卡有没有套餐都发 V1 事件;消费者调 `DeductDataUsage` 遇到"没有可用套餐"直接返回错误,任务由 Asynq 重试,重试期间套餐一激活,积压的增量就被扣到新套餐上——这是二次销售多计的另一条路径 | `application/cardobservation/traffic.go:73-76` 无套餐判断;`infrastructure/cardobservation/event.go:276-277` 报错即返回;`usage_service.go:110`。**生产核实(2026-10-20)**:回执停在"处理中"(扣减未完成)的有 18,979 条、增量约 11.4 TB;其中 14,838 条对应的卡当前没有生效套餐,**4,141 条(2,404 张卡)对应的卡当前有生效套餐**,这批任务若被重试或人工重放,会扣到现在的套餐上,不得重放。**积压部分由业务后续手动调整,本方案只保证新增的不再产生** | + +## 3. 老卡管怎么算(反编译核实) + +### 3.1 日快照:每卡每天一条,存"本月累计" + +出处:`YidongDayFlowService.saveYidongDayFlow`、`PtLiantongDayFlowService.savePtLiantongDayFlow`。 + +- 表 `tbl_*_day_flow`:`iccid_mark`、`flow_date`、`used_flow`(自然月累计 MB);普联通另存 `vendor_used_flow`(运营商原始读数)。 +- 同一天多次查询:用新值覆盖。 + - 移动:新值比旧值小就跳过(每月 1 号除外)。 + - 普联通:读数 ≤ 0 不覆盖;当天第一条就是 0 时,沿用昨天的值。 + +### 3.2 联通:写入时把运营商周期换算成自然月 + +联通每月 27 号 0 点清零,运营商读数是"上月 27 号至今"。老卡管在写快照时就换算成自然月累计(老代码把 27 写死在联通的几个类里;本方案改为读 `tb_carrier.data_reset_day`,对任意重置日通用,见 4.3),后面统一按自然月计算: + +| 写入日期 | 存入值 | +|---|---| +| 27 号~月底 | 运营商读数 + 本月 1~26 号的最大快照 | +| 1~26 号 | 运营商读数 − 上月 27 号~月底这段的用量(= 上月最大快照 − 上月 27 号前的最大快照) | + +> 老代码第二行取"上月 27 号前"时用的是 `< 26 号`,少算了一天,本方案按"1 号~重置日前一天"修正。 + +### 3.3 重置日防旧值:只有一条规则 + +| 运营商 | 规则 | +|---|---| +| 移动(1 号清零) | 每月 1 号,读数 > 上月最大快照 × 0.9 → 记为 0 | +| 普联通(27 号清零) | 每月 27 号,读数 > 本月 1~26 号最大快照 × 0.9 → 运营商读数按 0 计 | + +意思是:重置日读到的值还接近上个周期的总量,就认为运营商还没清零或者读到了旧缓存,当天先不增长。除此之外,老卡管**没有任何读数下降的判定**。 + +### 3.4 按区间取值 + +出处:`YidongDayFlowMapper.xml` 的 `getQueryTrafficByDate`。 + +``` +区间用量 = max(区间内的快照) − max(本月 1 号 ~ 区间开始前一天的快照) +``` + +- 只取 `used_flow > 0` 的快照;不能跨月查询;结果 < 0 时按 0。 +- **取的是最大值,不是最新值**,所以旧缓存返回一个偏小的值,不会让用量变小。 + +### 3.5 套餐用量(本方案不照搬这一块) + +老卡管从套餐开始日到今天按月切段累加,再拿总用量和所有套餐的总额度比较,不区分每个套餐用了多少。新卡管有加油包、独立有效期、`by_day` 重置、按套餐退款,这一块**沿用新卡管现有的 `DeductDataUsage`**,不改。 + +## 4. 新方案 + +### 4.1 原则 + +| # | 原则 | +|---|---| +| R1 | **流量照搬老卡管**:日快照 + 自然月换算 + 重置日防旧值 + 区间取最大值做差 | +| R2 | **套餐侧零改动**:增量照旧走 V1 事件和 `DeductDataUsage`;套餐表、套餐入口、分配顺序、停复机都不改 | +| R3 | **不再用卡表基线算增量**:增量只由快照得出;卡表三列照旧由老逻辑维护,只作兼容展示 | +| R4 | **日期一律按 `Asia/Shanghai`** | + +### 4.2 数据模型 + +只新增一张表,**不改任何现有表**。迁移从当前最新编号之后开始,成对 up/down。 + +**`tb_card_flow_day`(卡日快照,永久保留;仓库现无分区表,一年约 3,200 万行,靠唯一索引足够,本期不分区)** + +| 列 | 类型 | 含义 | +|---|---|---| +| `id` | bigserial | | +| `iot_card_id` | bigint | | +| `flow_date` | date | 上海日期 | +| `month_used_mb` | numeric(14,3) | 自然月累计(换算后),计算用 | +| `vendor_used_mb` | numeric(14,3) | 当天最后一次有效的运营商原始读数,排障用 | +| `emitted_mb` | numeric(14,3) | 当天已经发出的增量合计(见 4.5),默认 0 | +| `stale_guarded` | boolean | 重置日当天是否触发过防旧值(读数按 0 记) | +| `obs_count` | int | 当天有效观测次数 | +| `created_at`、`updated_at` | timestamptz | | + +唯一键 `(iot_card_id, flow_date)`。每卡每天一行,8.9 万卡约 3200 万行/年。 + +### 4.3 写快照(照搬 3.1~3.3,按运营商重置日 `R` 统一) + +`R` 取 `tb_carrier.data_reset_day`。**运营商在每月 `R` 号 0 点(北京时间,`Asia/Shanghai`)清零**:联通为 27 号 0 点(业务确认,见 `docs/engineering/套餐流量计算逻辑核对.md`;与老卡管 `DateUtil.getLtPeriod` 一致),其余为 1 号 0 点。周期边界直接复用现有 `carrierthreshold.PeriodStart`(上海时区重置日 0 点),下文"今天日号""重置日当天"都按北京时间的日期判断。**查询失败、为空或不在 1~28 时,按卡的运营商类型兜底:`tb_iot_card.carrier_type = CUCC`(联通)取 27,其余取 1**,并记日志。不能沿用现有 `CarrierStore.GetDataResetDay` 一律返回 1 的做法(`carrier_store.go:103-104`)——联通卡被错当成 1 号重置会让自然月换算出错、累计值被抬高,按取最大值的规则会多扣且收不回。老路径继续用原函数,不改。 + +**注意取值位置**:三个入口现在调用 `ApplyTrafficObservation` 前就用 `GetDataResetDay` 算好 `TrafficObservation.ResetDay` 传进来(`polling_carddata_handler.go:103`、`series_runner.go:561`、`service/iot_card/service.go:1638`),查询失败时已经是 1。新逻辑**不能使用这个字段**,必须在观测事务里按已锁定卡的 `carrier_id` 严格查一次 `data_reset_day`,失败再按上面的运营商类型兜底。 + +每次观测(轮询、人工刷新、系列观测三个入口)读到 `r`: + +1. **无效读数和 0 不写**:查询失败、`used` 为空 / null / 无法解析 / `< 0`、或 `r = 0`,一律不写快照、不发增量。(老卡管普联通对 0 是"当天已有行不覆盖、当天第一条沿用昨天的值",移动是写一条 0;本方案直接不写,因为计算只看最大值,效果相同。)`FlowUsageResp` 增加"读数是否有效"标记,只给新代码用。 +2. **换算成自然月累计 `m`**: + - `R = 1`:`m = r`; + - `R > 1`,今天日号 `≥ R`:`m = r + max(本月 1 号~R−1 号的 month_used_mb)`; + - `R > 1`,今天日号 `< R`:`m = r − (上月最大 month_used_mb − 上月 1 号~R−1 号的最大 month_used_mb)`,< 0 按 0。 +3. **重置日防旧值(完全照搬老卡管,比较基准也一致)**:只在重置日当天判断,原始读数照存 `vendor_used_mb`: + - `R = 1`:`r > 上月最大 month_used_mb × 0.9` 时按 `r = 0` 计算 `m`(同 `YidongDayFlowService.java:123`;上月没有快照时比较值为 0,任何正读数当天都按 0 记,次日如实写入,与老卡管一致); + - `R > 1`:本月 1 号~R−1 号的最大 `month_used_mb` 大于 0,且 `r` 超过它的 0.9 倍时,按 `r = 0` 计算 `m`(同 `PtLiantongDayFlowService.java:126`:`appendFlow > 0 && flow > appendFlow × 1.9`);本月前 R−1 天没有快照时不判断; + - 触发时置 `stale_guarded = true`;第二天起不再判断,读数照常写入。 +4. **同一天合并**:当天已有行时,`month_used_mb = max(旧值, m)`。 + +### 4.4 今天的快照用量(照搬 3.4) + +``` +今天用量 U(卡, 今天) = max(今天的 month_used_mb) − max(本月 1 号 ~ 昨天的 month_used_mb) + < 0 按 0;今天是本月第一条快照时减数为 0 +``` + +- 中间有几天没采到,那几天的用量自然并入今天,不会丢。 +- 每天的日流量就是 `U(卡, 那天)`,直接替代 Redis 日流量缓冲、02:00 落盘和 `tb_card_daily_usage`。 + +### 4.5 算增量并发出(替代 `r − 基线`) + +在观测事务(事务 1,现有 `ApplyTraffic` 所在事务)里,对**已切换的卡**: + +1. `SELECT ... FOR UPDATE` 锁住该卡今天的快照行(不存在就插入); +2. 按 4.3 更新快照; +3. 增量 `Δ = U(卡, 今天) − emitted_mb`; +4. `Δ > 0` 且**该卡有可扣的生效套餐**(与 `resolveActivePackages` 同口径:卡自身有**生效中(status=1)**的套餐,或卡当前绑定的设备有生效中的套餐;**已用完(2)、待生效(0)不算**;只读查询,不加锁):写一条 V1 事件 `CardTrafficIncremented`,载荷与现在完全相同(只是增量值换了来源),同时 `emitted_mb += Δ`; +5. `Δ > 0` 但**没有生效套餐**:不发事件,只 `emitted_mb += Δ`。这部分用量不属于任何套餐,丢弃(日流量照常可见); +6. `Δ ≤ 0`:不发事件。 + +说明: + +- **增量与发出标记在同一事务里**,和 Outbox 事件一起提交或一起回滚,不会重复发、也不会漏发。同卡并发观测由快照行锁串行。 +- **消费者完全不改**:现有消费者照旧调用 `DeductDataUsage` 扣减(先加油包、后主套餐、超额记最后一个),写流量详单,调用 `EvaluateAndAct` 判断停复机,回执表照旧保证幂等。 +- **没有生效套餐时不发事件**:现有消费者遇到"没有可用套餐"会报错重试(第 2 节第 6 条),所以不能把这部分增量发出去。只推进 `emitted_mb`,**激活之前的用量永远不会扣到新套餐上**,二次销售天然正确,而且比老卡管按天算更精确:激活当天、激活之前的那部分也不会算给新用户。 +- 是否有生效套餐只在事务 1 里只读判断一次,不改套餐侧代码;判断出错时按"有套餐"处理并发事件(保持现有行为,不丢量)。 +- 卡表三列(`last_gateway_reading_mb` 等)和通道阈值判定:老 `ApplyTraffic` 照常计算、照常写,只是它算出的增量对已切换的卡不再使用。`last_gateway_reading_mb` 是观测的 CAS 条件,新代码只读不写。 +- **已切换的卡不再发老增量的 V1**:老 `ApplyTraffic` 算出的 `decision.IncrementMB` 对已切换的卡丢弃,现在第 73-76 行的发事件逻辑只在未切换分支执行;每次观测只会发一条 V1,Outbox 量不增加。 +- 只有已用完或待生效套餐的卡,增量同样丢弃。这和现状一致:现状下这类增量会因"没有可用套餐"扣减失败并积压(第 2 节第 6 条),并不会扣到已用完的套餐上。 + +**未切换的卡**:事务 1 一切照旧(老增量、老 V1);快照在事务 1 提交后单独写,失败只告警(见 4.7),用于切换前积累数据和比对。 + +### 4.6 切换当天的衔接 + +卡切换后的第一次观测,如果今天的快照行已经存在(切换前已经写过),先把 `emitted_mb` 初始化为"写入本次读数之前的 `U(卡, 今天)`",再按 4.5 计算。这样今天切换前已经由老路径扣过的部分不会再扣一次,只发本次观测带来的增长。 + +限制:同一张卡**退回老路径后当天不要再切回**。当天快照上的 `emitted_mb` 已经不是 0,再切回时不会重新初始化,退回期间老路径扣过的部分会被再扣一次。 + +### 4.7 失败处理 + +| 失败 | 处理 | +|---|---| +| Gateway 查询失败 / 无效读数 / 读数为 0 | 不写快照、不发增量 | +| 运营商重置日查不到或非法 | 按运营商类型兜底:联通(`CUCC`)27 号 0 点,其余 1 号 0 点,北京时间;记日志。老路径照旧 | +| 已切换卡:快照写入失败 | 随事务 1 回滚,观测重入队(与现在观测失败的处理一致) | +| 未切换卡:快照写入失败 | 只告警,不影响观测;总开关可整体停写 | +| 某天没采到 | 用量并入下一个有快照的日子 | + +### 4.8 对非灰度卡的影响核对 + +**硬约束:非灰度的卡和设备,扣费、停复机、观测结果、页面展示都与现在一致。** 新代码碰到非灰度卡的每一处: + +| # | 接触点 | 设计 | 结论 | +|---|---|---|---| +| 1 | 写快照(阶段 1 起全部卡都写) | 非灰度卡在观测事务**提交之后**单独写,失败、超时只告警,不让观测失败、不重入队;总开关 `JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED` 关掉即停写,不用发版 | 不影响扣费与观测;数据库多一次写入(现约 2,400 次/小时),上线后观察轮询耗时,异常即关开关 | +| 2 | 判断走新路径还是老路径 | 观测事务内用启动时载入内存的卡集合判断,不查库;判断出错一律走老路径 | 不影响 | +| 3 | 增量计算 | 非灰度卡仍用"读数 − 基线",仍发 V1 事件,代码不改 | 不影响 | +| 4 | 无套餐时不发事件 | 只对灰度卡生效 | 不影响 | +| 5 | Gateway 返回加"读数是否有效"标记 | 只给新代码用,老路径照旧(无效仍按 0 处理) | 不影响 | +| 6 | 日流量查询 | 非灰度卡仍读老来源 | 不影响 | +| 7 | 数据库迁移 | 只新建表,不改现有表 | 不影响 | +| 8 | 设备部分卡切换 | 不允许:设备整台切(5.2) | 不影响 | + +**唯一无法靠设计消除的风险**:`ApplyTraffic` 是所有卡共用的代码,改动出错可能波及全部卡。约束:新逻辑只放在"判定为灰度卡"的分支里,老分支一行不改;评审重点核对这一处;测试环境回归"非灰度卡观测一次"的结果与改动前完全一致。 + +## 5. 上线 + +### 5.0 上线前提核实(2026-10-20,生产只读抽样) + +新算法依赖"运营商读数在 `data_reset_day` 清零"。对生产卡表做 10% 块抽样,取本月有读数被接受的卡,比较当前读数与本月计入量: + +| 运营商分组 | 抽样卡数 | 读数 ≈ 本月计入 | 读数略大 | 读数远大于本月计入 | 结论 | +|---|---|---|---|---|---| +| 重置日 1 号(移动、电信、广电共 11 个运营商账号) | 765 | 712(93%) | 28 | 25 | 读数在 1 号清零,与配置一致 | +| 重置日 27 号(联通共 10 个运营商账号) | 406 | 134 | 243 | 29 | 读数含 9-27~9-30,略大于自然月计入,符合 27 号清零 | + +- 两组都符合各自配置的重置日。 +- "读数远大于本月计入"的少数卡(约 4%~7%)需要在灰度期抽查,可能是单卡读数口径异常或基线问题。 +- 抽样未覆盖的运营商账号(本月抽样中没有被接受读数的卡):1、10、11、12、16、17、20~23、25、27、30、34、37,灰度扩批前补查。 +- `tb_card_flow_day` 在生产库与测试库均不存在(两边迁移版本均为 240),代码中无引用,新建无冲突。 + +### 5.1 阶段 + +| 阶段 | 内容 | 对业务影响 | +|---|---|---| +| 1 写快照 | 建 `tb_card_flow_day`,全部卡在观测事务提交后写快照 | 无,只写不用 | +| 2 灰度切换 | 名单内的卡按 4.5 算增量;其余照旧 | 只影响名单内的卡 | +| 3 全量 | 全部卡切换;之后下线 Redis 日流量缓冲与 02:00 落盘 | — | + +阶段 1 至少跑满一个完整自然日再进入阶段 2;跨月或跨运营商重置日时,最好已有上个周期的快照(防旧值要用)。 + +### 5.2 切换方式 + +- 环境变量 `JUNHONG_TRAFFIC_CALC_MODE`:`off`(默认)/ `gray` / `full`;`JUNHONG_TRAFFIC_CALC_CARDS`:灰度名单,写扣减载体:`iot_card:103154`(单卡套餐的卡)或 `device:15276`(设备,展开为当前绑定的全部卡);`JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED`:快照写入总开关,默认开。改完重启 API 和全部 Worker(见下方配置位置)。 +- 判定在事务 1 里做,用启动时展开的内存集合,不查库;判定出错一律按老路径。 +- **配置位置**:新增 `pkg/config/config.go` 的 `TrafficCalc` 配置段与 `pkg/config/defaults/config.yaml` 的 `traffic_calc:` 默认值(`mode: off`、名单为空、快照开关开),沿用现有"嵌入默认配置 + `JUNHONG_` 环境变量覆盖"机制。生产上由维护者写入各运行目录的 `.env.prod`:**API(`/opt/junhong_cmp/api/.env.prod`,人工刷新走 API 进程)、调度 Worker(`/opt/junhong_cmp/worker/.env.prod`)和三个执行 Worker(`worker-1/2/3/.env.prod`)五处必须一致**,否则同一张卡在不同进程里会走不同路径。改完重启对应服务。 +- **灰度单位是扣减载体,设备必须整台切**:名单里的设备,当前绑定的全部卡一起切换;名单里写 `iot_card:X` 但这张卡实际用的是设备套餐(与 `resolveActivePackages` 同口径),启动日志报错并跳过该项,不阻止 worker 启动。否则同一个设备套餐会同时收到新旧两种算法的增量,设备上没切换的卡也会被连带影响。 +- **灰度期间灰度设备不换绑、不新绑卡**:名单在启动时展开成卡集合,之后新绑到灰度设备上的卡要重启后才会切换,这期间设备处于"部分切换"状态。确需换绑的,换绑后立即重启 API 和全部 Worker 重新展开;或先把该设备移出名单。 +- **退回**:从名单删除或改回 `off` 并重启。老 `ApplyTraffic` 一直在维护卡表基线,退回后老路径接着算(冻结卡会回到冻结状态)。 + +### 5.3 冻结卡与历史误差 + +- 切换后冻结卡(如 103154)立即恢复正常计量:增量只看快照,不看卡表基线。 +- **切换前少扣的部分放到后期补**,用独立脚本执行,见 5.5。 +- **上个周期及更早少扣的补不回来**:运营商读数已经清零,没有任何记录;期间套餐已经重置的,那个计费周期也已结束。 +- **第一批灰度:103154 所在设备 15276**(联通 103154、移动 99426、电信 106882,三张卡本月计入都为 0)。核对无误后再放其他冻结卡、电信读数常为 0 的卡、二次销售的卡、联通卡、带加油包的载体。 + +### 5.4 扩批条件 + +1. 灰度卡逐日的增量合计 = 按快照手工复算的结果; +2. 无误停、无误复; +3. 至少跨过一次运营商清零(每个运营商各自的重置日); +4. 冻结卡恢复计量,二次销售卡没有带入上一个用户的用量。 + +### 5.5 补数据(后期,独立脚本) + +**定位**:卡流量计算上线后再做,不阻塞主方案。只补冻结卡在**执行时所在运营商周期**内少扣的部分;更早周期的补不回来(运营商读数已清零)。 + +**生产现状(2026-10-20 只读核实)**: + +| 项 | 结果 | +|---|---| +| `tb_package_usage_daily_record` 是否漏记扣减 | **没有漏记**:生效中和已用完的套餐中,没有一个"已用量 > 最新日记录累计值"(10,718 个一致;1,972 个已用量为 0、是重置后尚未扣减;10,399 个从未扣减) | +| 疑似冻结卡(最近两天在采集、本月计入为 0、上月 ≥ 100 MB) | **3,425 张**:设备卡 1,705(联通 1,073、移动 450、电信 182),单卡 1,720(广电 931、电信 339、联通 236、移动 214)。含本月确实没用的卡,是上限 | +| 103154 所在设备 15276 | 联通 103154、移动 99426、电信 106882 三张卡**本月计入都是 0**,不只联通卡冻结 | +| 设备下混有不同重置日的卡(联通 27 号 + 其他 1 号) | 7,438 台设备、19,466 张卡,是设备套餐的大头 | +| 套餐计费起点落在本运营商周期内(多为 `by_day` 每 30 天重置) | 10,919 个载体 | + +后两项说明:按"载体读数之和 − 载体本周期扣减"的算法,大部分载体会因"重置日不同"或"起点在周期内"被排除,所以**改为按卡补冻结卡**。 + +**算法(按卡)**:冻结卡在整个本运营商周期里一条读数都没被接受,本周期计入为 0,所以 + +``` +这张卡本周期少扣 = 运营商当前读数 r +``` + +- 每张卡按自己运营商的重置日确定周期起点,设备下混着不同运营商的卡也能用;不需要按套餐拆分日记录。 +- `r` 在执行时现场查询 Gateway,限速执行;查询失败、无效、读数为 0 的跳过,进清单。 + +**判定为冻结卡(全部满足才自动补)**: + +| 条件 | 原因 | +|---|---| +| 卡(或其所在设备)有**生效中(status=1)**的套餐(与 4.5 同口径) | 只有已用完或待生效套餐时,`DeductDataUsage` 找不到可扣套餐会报错,补扣事件会积压(第 2 节第 6 条);这类卡进清单 | +| 最近两天在被采集,本月计入(`current_month_usage_mb`)为 0 | 本周期没有任何读数被老路径接受 | +| 现场读数 `r` < 卡表基线 `last_gateway_reading_mb` | 基线属于上个周期或被异常值抬高,读数一直被拒 | +| 卡所在扣减载体的套餐计费起点(激活或最近一次重置)**早于本运营商周期起点** | 否则 `r` 里有一部分属于已经结束的上个计费周期,不该补到新周期 | +| 本周期内没有回收换代、换绑 | 读数和套餐对不上;也排除了二次销售 | + +不满足的进人工清单。其中"计费起点在本周期内"的冻结卡,清单里附按时间比例估算的参考值(`r × 起点至今天数 ÷ 本周期已过天数`),由人工决定,不自动扣。 + +**执行(套餐侧不改)**: + +1. **预演(只读)**:输出每张卡的卡号、运营商、扣减载体、`r`、基线、补扣量、补扣后是否超额;汇总补扣总量、预计停机数、清单与原因。 +2. 确认后正式执行:每张卡写一条一次性的 V1 事件,事件 ID 由卡、运营商周期、批次组成,重复执行同一批次不重复写;由现有消费者调用 `DeductDataUsage` 扣减,超额照常停机,补扣量照常写入 `daily_record`。 +3. 已切换到新方案的卡,切换之后的用量由新方案计算,补扣只补切换之前那段;脚本按"切换时刻已知的读数"截断,避免与新方案重复。 +4. 补扣写审计,注明"冻结卡补扣"与补扣量。 +5. 现有消费者会把每条 V1 事件的增量累加进当天的 Redis 日流量缓冲,所以**未切换卡的日流量会在补扣当天出现一次突增**(已切换卡的日流量读快照,不受影响)。执行前告知运营,或在预演清单中标出。 + +## 6. 改动清单 + +| 位置 | 改动 | +|---|---| +| `migrations/` | 新建 `tb_card_flow_day` | +| `internal/gateway/models.go` | `FlowUsageResp` 增加"读数是否有效"标记(老路径行为不变) | +| 新增 `internal/domain/cardflow/` | 纯函数:自然月换算、重置日防旧值、今天用量;表驱动单测 | +| 新增 `internal/store/postgres/card_flow_day_store.go` | 快照读写(含行锁) | +| `internal/application/cardobservation/traffic.go` | 已切换的卡:增量改为 4.5 的算法,V1 事件载荷不变,按卡的 `carrier_id` 严格查重置日;未切换的卡:老逻辑不变,提交后写快照 | +| 三个观测入口(`polling_carddata_handler.go`、`service/iot_card/service.go`、`series_runner.go`) | 只把"读数是否有效"传进 `TrafficObservation`(新增字段 `ReadingInvalid`,默认值表示有效,老调用方不受影响);快照的写入都在 `ApplyTrafficObservation` 里完成:已切换的卡在事务 1 内写,未切换的卡在事务 1 提交后写 | +| 日流量查询 | 已切换的卡读快照 | +| `pkg/config` | 三个环境变量(模式、灰度名单、快照写入总开关) | +| 后期:补数据脚本(`cmd/`) | 预演(只读)+ 正式执行(写 V1 事件),见 5.5 | +| **套餐表、`internal/service/package/`、V1 消费者、激活/重置/改已用量/换货/退款入口** | **不改** | + +## 7. 测试用例 + +**领域层(表驱动)** + +| 场景 | 期望 | +|---|---| +| 移动同月读数上涨 | 今天用量 = 今天最大快照 − 本月此前最大快照 | +| 重置日 `R = 27`:26 号 23:59 读数 5000,27 号 00:05 清零后读数 100(北京时间) | 27 号 `month_used_mb` = 100 + 26 号前最大值;自然月累计连续 | +| 重置日 `R = 15`(任意非 1 号) | 换算规则同上,以 15 号为界,结果连续 | +| 重置日 `R = 1` | 不换算,`month_used_mb` = 读数 | +| 运营商的 `data_reset_day` 被修改 | 从修改后的下一次写入起按新重置日换算 | +| `R > 1` 时 1 号~R−1 号换算 | 减去上月 R 号~月底的用量,按"1 号~R−1 号"取值,不少一天 | +| 重置日当天读到上周期旧值(> 90%) | 当天按 0 记,`stale_guarded = true`;之后读到真实低值正常累计 | +| 重置日次日起仍读到旧值 | 照常累加(与老卡管一致) | +| 重置日当天真实用量超过上周期 90% | 当天按 0 记;次日如实写入,取最大值把这部分带回来,只晚一天,不丢 | +| 周期中途读到偏小的旧缓存 | 当天快照取最大值,用量不变小,不发负增量 | +| 读数 0 / 无效 | 不写快照,不发增量 | +| 运营商重置日查询失败:联通卡 | 按 27 号换算与防旧值,记日志 | +| 运营商重置日查询失败:非联通卡 | 按 1 号处理,记日志 | +| `R = 1` 重置日当天读数 > 上月最大快照 × 0.9 | 当天按 0 记 | +| `R = 27` 重置日当天读数 > 本月 1~26 号最大快照 × 0.9 | 当天按 0 记 | +| 中间 3 天没采到 | 3 天的用量并入采到的那天 | +| 跨自然月 | 本月第一条快照减数为 0,从本月累计开始算 | + +**应用层** + +| 场景 | 期望 | +|---|---| +| **卡 103154 类:卡表基线 72535,运营商读数 48808** | 切换后增量按快照正常发出,套餐恢复增长 | +| **二次销售:上一个用户本月用到 3000;新用户 15 号 14:00 激活;激活前当天又读到 3020,激活后读到 3050** | 3020 及之前的增量在无套餐时只推进 `emitted_mb`、不发事件;新套餐只扣 30 | +| 卡只有已用完或待生效的套餐 | 不发事件,增量丢弃 | +| 灰度卡观测一次 | 只发一条 V1(新算法增量),不再发老增量的 V1 | +| 无套餐期间连续观测,之后激活 | Outbox 里没有该卡积压的 V1 事件;激活后只扣激活之后的增长 | +| 同一次观测重复处理 / 同卡并发观测 | 行锁串行,`emitted_mb` 保证每段增长只发一次 | +| 事务 1 回滚 | 快照、`emitted_mb`、Outbox 一起回滚,不重不漏 | +| 切换当天切换前老路径已扣过一部分 | 按 4.6 初始化,不重复扣 | +| 名单写 `device:15276` | 设备当前绑定的 3 张卡全部切换 | +| 联通卡观测时运营商表查询失败(入口传进来的 `ResetDay` 已被兜底成 1) | 新逻辑不用入口的值,自行严格查询,失败按 27 号处理 | +| 名单写 `iot_card:X`,但该卡实际用设备套餐 | 启动日志报错并跳过该项,worker 正常启动,该卡仍走老路径 | +| 非灰度卡观测一次 | 卡表、V1 事件、扣减、停复机结果与改动前完全一致;快照在提交后单独写 | +| 关闭快照写入总开关 | 立即停写快照;非灰度卡不受任何影响 | +| 主套餐 + 加油包 | 与现有 `DeductDataUsage` 行为完全一致(不改) | +| 退回老路径后的下一次观测 | 老路径照常运行 | +| 查询失败 | 不写快照、不发增量、不改套餐状态 | + +**补数据脚本(后期)** + +| 场景 | 期望 | +|---|---| +| 单卡冻结:读数 48808 < 基线 72535,本月计入 0,套餐起点早于周期起点 | 补扣 48808;超额则停机 | +| 设备 3 卡(联通、移动、电信)都冻结 | 每张卡按自己的周期起点各补其读数,合计扣到设备套餐 | +| 套餐在本周期中间激活(含二次销售) | 不补,进清单 | +| 本周期内回收换代 / 换绑 | 不补,进清单 | +| 读数查询失败 / 为 0 | 不补,进清单 | +| 冻结卡只有已用完的套餐 | 不补,进清单 | +| 少扣为负(多扣) | 不退,进清单 | +| 同一批次重复执行 | 事件 ID 相同,不重复写 | +| 套餐计费起点在本周期内的冻结卡 | 不自动补,进清单并附按时间比例的估算值 | + +**测试环境验证**:103154 同类卡、三卡共用设备、二次销售卡、重置日为 27 号与 1 号的运营商各跨一次重置日、另构造一个非常规重置日(如 15 号)各一组,逐日核对快照、增量与套餐已用量。 + +## 8. 工作量(粗估) + +| # | 任务 | 人天 | +|---|---|:-:| +| 1 | 迁移与模型 | 0.25 | +| 2 | 领域层:自然月换算、防旧值、今天用量 + 单测 | 1.5 | +| 3 | 快照存储(含行锁)+ 三个观测入口写快照 + 无效读数标记 | 1.0 | +| 4 | `ApplyTraffic` 已切换卡的增量替换与切换当天衔接 | 1.0 | +| 5 | 切换机制(模式、名单、启动展开) | 0.5 | +| 6 | 日流量读侧 | 0.5 | +| 7 | 集成测试与测试环境验证 | 1.5 | +| | 合计 | 约 6.25 | + +后期补数据脚本(5.5:预演、条件判断、写 V1 事件、清单)另计约 1.5 人天。 + +不含灰度观察期:至少跨过一次运营商清零,约 30 天内。 + +## 9. 已知限制 + +| 限制 | 说明 | +|---|---| +| 切换前的误差 | 后期按 5.5 补,只能补执行时所在运营商周期内的部分;更早的补不回来;不满足条件的进人工清单 | +| 上一代套餐仍为"生效中" | `DeductDataUsage` 只按状态选套餐、不看世代,回收换代后上一代套餐如果仍为生效中,新用户的增量可能扣到它上面。这是套餐侧问题,按 `docs/资产回收换代技术方案.md` 单独修复,本方案不改套餐侧 | +| 旧周期尾部 | 旧周期最后一次采集到清零之间的用量拿不到,老卡管同样如此 | +| 防旧值 | 完全照搬老卡管:阈值 0.9、只在重置日当天判断。运营商延迟清零超过一天时,次日的旧值会被累加(老卡管同样如此,多年未暴露问题) | +| 积压的无套餐回执 | 18,979 条卡在"处理中"的回执(含 4,141 条对应卡当前有生效套餐)由业务后续手动调整,不在本方案内;处理前不得重放 | +| 修改运营商重置日 | 月中修改 `data_reset_day` 会让当月换算前后不连续,可能产生一次跳变多扣。修改应安排在运营商实际重置日前后,并在修改后核对受影响卡的当日增量 | +| 采集不足 | 轮询吞吐问题(约 2.4k 次/小时)不在本方案范围;采不到只会晚扣,不会丢 | +| 扣错收不回 | 套餐侧仍是增量扣减,一旦扣错只能人工改已用量。快照可以完整复算每天应扣多少,作为核对依据 | +| 电信 | 老卡管的电信是直接按日期区间向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`);新卡管 Gateway 目前只返回当前周期累计,本期统一按快照处理,读数为 0 的电信卡仍扣不到。**后续安排 Gateway 支持按日期区间查询**:支持后,电信卡改为按区间查询的结果写快照,算增量的逻辑不变;同时可以用来补旧周期尾部、补更早周期的少扣数据 |