From 96771a204f2e21ccf88b67af0198255d15053aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Tue, 20 Oct 2026 17:49:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=A1=E6=B5=81=E9=87=8F=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=97=A5=E5=BF=AB=E7=85=A7=E8=AE=A1=E9=87=8F=E5=B9=B6?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=81=9C=E5=A4=8D=E6=9C=BA=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卡流量计量(按名单灰度,默认 off): - 新增卡日快照与切换水位表(000241),按通道上游流量重置日换算自然月、重置日防旧值 - 已切换的卡按月对账产生增量,无生效中套餐只结算不发事件;第一次切换写水位 - 已切换卡读数有效即判定通道阈值,卡表本月用量用快照覆盖,日流量读快照 - 配置 JUNHONG_TRAFFIC_CALC_MODE/CARDS、JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED 停复机规则(总开关 JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED,默认关闭): - 复机不看停机原因,持通道阈值锁仍拒绝;暂停轮询的卡不自动停复机 - 通道新增实名管控 realname_control_enabled(000242,默认 0),开启后不区分行业卡与实名策略 - 实名管控从 0 改 1 需确认,修改并入通道更新审计 OpenSpec Change: rebuild-card-traffic-metering-and-stop-resume --- docs/卡流量计算技术方案-参考老卡管.md | 316 ++++++++++++++---- internal/application/cardobservation/apply.go | 19 ++ .../application/cardobservation/traffic.go | 53 ++- .../cardobservation/traffic_calc_policy.go | 164 +++++++++ .../cardobservation/traffic_flow.go | 150 +++++++++ .../application/carrierthreshold/evaluate.go | 4 +- internal/bootstrap/services.go | 8 +- internal/bootstrap/worker_services.go | 9 + internal/domain/cardflow/doc.go | 5 + internal/domain/cardflow/rules.go | 126 +++++++ internal/model/card_flow.go | 44 +++ internal/model/carrier.go | 3 + internal/model/dto/carrier_dto.go | 18 +- internal/routes/carrier.go | 11 +- internal/service/carrier/service.go | 38 ++- .../service/iot_card/channel_threshold.go | 23 +- .../service/iot_card/stop_resume_service.go | 48 ++- .../service/iot_card/stop_resume_unified.go | 98 ++++++ internal/service/traffic/query_service.go | 42 ++- .../store/postgres/card_flow_day_store.go | 130 +++++++ .../store/postgres/card_flow_switch_store.go | 43 +++ internal/task/polling_protect_handler.go | 13 +- ...0241_add_card_flow_day_and_switch.down.sql | 8 + ...000241_add_card_flow_day_and_switch.up.sql | 60 ++++ ...0242_add_carrier_realname_control.down.sql | 5 + ...000242_add_carrier_realname_control.up.sql | 12 + .../.openspec.yaml | 2 + .../design.md | 80 +++++ .../proposal.md | 45 +++ .../specs/card-stop-resume/spec.md | 80 +++++ .../specs/card-traffic-metering/spec.md | 117 +++++++ .../carrier-channel-traffic-threshold/spec.md | 40 +++ .../specs/polling-operations/spec.md | 33 ++ .../tasks.md | 59 ++++ pkg/config/config.go | 36 ++ pkg/config/defaults/config.yaml | 10 + pkg/config/loader.go | 5 + 37 files changed, 1859 insertions(+), 98 deletions(-) create mode 100644 internal/application/cardobservation/traffic_calc_policy.go create mode 100644 internal/application/cardobservation/traffic_flow.go create mode 100644 internal/domain/cardflow/doc.go create mode 100644 internal/domain/cardflow/rules.go create mode 100644 internal/model/card_flow.go create mode 100644 internal/service/iot_card/stop_resume_unified.go create mode 100644 internal/store/postgres/card_flow_day_store.go create mode 100644 internal/store/postgres/card_flow_switch_store.go create mode 100644 migrations/000241_add_card_flow_day_and_switch.down.sql create mode 100644 migrations/000241_add_card_flow_day_and_switch.up.sql create mode 100644 migrations/000242_add_carrier_realname_control.down.sql create mode 100644 migrations/000242_add_carrier_realname_control.up.sql create mode 100644 openspec/changes/rebuild-card-traffic-metering-and-stop-resume/.openspec.yaml create mode 100644 openspec/changes/rebuild-card-traffic-metering-and-stop-resume/design.md create mode 100644 openspec/changes/rebuild-card-traffic-metering-and-stop-resume/proposal.md create mode 100644 openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/card-stop-resume/spec.md create mode 100644 openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/card-traffic-metering/spec.md create mode 100644 openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/carrier-channel-traffic-threshold/spec.md create mode 100644 openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/polling-operations/spec.md create mode 100644 openspec/changes/rebuild-card-traffic-metering-and-stop-resume/tasks.md diff --git a/docs/卡流量计算技术方案-参考老卡管.md b/docs/卡流量计算技术方案-参考老卡管.md index bc4fa31e..763e004e 100644 --- a/docs/卡流量计算技术方案-参考老卡管.md +++ b/docs/卡流量计算技术方案-参考老卡管.md @@ -5,7 +5,8 @@ | 日期 | 2026-10-20 | | 状态 | 方案,待评审,**代码尚未修改** | | 思路 | **流量怎么算,照搬老卡管;套餐那边一行不动**:只替换"增量从哪来",增量照旧通过 V1 事件交给现有 `DeductDataUsage` 扣减 | -| 改动范围 | 观测链路(算增量)+ 新增卡日快照表 + 日流量读取。**套餐表、套餐服务、激活/重置/改已用量/换货/退款入口、扣减顺序、停复机全部不改** | +| 修订 | 第 2 版(2026-10-20):对照反编译结果与新卡管代码复核后修订——增加切换水位(4.6)、增量改为按月对账(4.5)、已切换卡的通道阈值判定(4.5)、去掉"读数是否有效"标记、更正对老卡管的几处描述;新增第 10 章停复机规则与实名管控;已切换卡的卡表本月用量改由快照覆盖(4.5),切换与退回要求全部停止后再启动(5.2) | +| 改动范围 | 流量部分(第 1~9 章):观测链路(算增量)+ 新增卡日快照表 + 切换水位表 + 日流量读取 + 已切换卡的通道阈值判定条件;**套餐表、套餐服务、V1 消费者、激活/重置/改已用量/换货/退款入口、扣减顺序、停复机全部不改**。停复机部分(第 10 章):停机与复机只看套餐和流量(通道开启实名管控时加上实名)、复机不看停机原因、实名默认不管控、暂停轮询的卡不自动停复机;独立开发、独立上线 | | 与旧方案关系 | 替代 `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` | @@ -17,10 +18,12 @@ 2. **老卡管不依赖基线**:每张卡每天存一条"本月累计"快照,任何一段时间的用量都用快照现算(区间内最大值 − 区间开始前的最大值)。它没有"读数下降"的概念,已经稳定运行多年。 3. **本方案**: - 流量计算照搬老卡管:日快照、按各运营商的重置日(`tb_carrier.data_reset_day`)换算成自然月、重置日防旧值、按区间取最大值做差。 - - 每次观测的增量 = **今天的快照用量 − 今天已经发出的增量**,不再用卡表基线。 + - 每次观测的增量 = **本月最大快照 − 本月已结算合计**(按月对账),不再用卡表基线。 + - 卡第一次走新路径时记一条**切换水位**:本次仍按老路径的增量发,之前的部分视为老路径已结算。切换衔接、补数据截断、冻结卡判定都以它为准。 - 增量照旧写进 V1 事件 `CardTrafficIncremented`,由现有消费者调用 `DeductDataUsage` 扣减。**套餐分配、加油包顺序、虚流量阈值、停复机、流量详单全部沿用现有代码。** -4. **二次销售**:卡没有生效套餐时,增量照样计算,但**只推进"已发出"标记、不发事件**。所以激活之前的用量永远不会扣到新套餐上,新套餐只扣激活之后的增长。 -5. **先做卡流量计算,补数据放到后期**:新方案上线后,冻结卡从切换起恢复正常计量;切换前少扣的部分由后期的独立脚本补(5.5)。补数据依据的是运营商当前读数,只能补执行时所在运营商周期内的部分,更早的补不回来。 + - 已切换的卡,通道阈值按"读数有效且未被防旧值处理"判定,不再受卡表基线影响,冻结卡切换后通道达量停机随即恢复。 +4. **二次销售**:卡没有生效套餐时,增量照样计算,但**只记为已结算、不发事件**。激活之前被采到的用量不会扣到新套餐上;精度到观测粒度:激活后第一次观测里,"上次观测到激活"这一段仍会算给新套餐。 +5. **先做卡流量计算,补数据放到后期**:新方案上线后,冻结卡从切换起恢复正常计量;切换前少扣的部分由后期的独立脚本补(5.5)。补数据依据运营商读数(未切换的卡现场查询,已切换的卡取切换水位里切换时的读数),只能补执行时所在运营商周期内的部分,更早的补不回来。 ### 1.1 已定决策(2026-10-20) @@ -29,12 +32,17 @@ | 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`,不发事件,用量丢弃 | +| D4 | 卡无生效套餐时的增量 | 只记入 `settled_mb`(已结算),不发事件,用量丢弃 | | D5 | 快照记录范围 | 全部被采集的卡 | | D6 | 上线方式 | 按名单灰度,单位是扣减载体,设备整台切;第一批为 103154 所在设备 15276,核对后扩批再全量。非灰度卡行为与现在一致(4.8) | | D7 | 积压的无套餐回执 | 业务后续手动调整,不在本方案内;处理前不得重放 | | D8 | 顺序 | 先上卡流量计算,补数据放后期(5.5) | | D9 | 补数据:本周期中间重置过套餐的冻结卡 | 不自动补,进清单,附按天数比例的估算值,由人工决定 | +| D10 | 增量口径 | 按月对账:`Δ = 本月最大快照 − 本月已结算合计`,不按天对账(4.5) | +| D11 | 切换衔接 | 记切换水位:卡第一次走新路径时,本次照发老路径增量,之前的部分记为已结算;不依赖阶段 1 是否覆盖整月(4.6) | +| D12 | 已切换卡的通道阈值 | 读数有效且本次读数未被防旧值按 0 处理即判定,不再看老路径是否接受读数(4.5) | +| D13 | 套餐在扣减前刚好用完 | 消费者不改,写入已知限制并监控(第 9 节) | +| D14~D17 | 停复机规则、实名管控、暂停轮询 | 见 10.3 | ## 2. 现在的漏洞(已验证,详见旧方案第 2 节) @@ -45,18 +53,21 @@ | 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 张卡)对应的卡当前有生效套餐**,这批任务若被重试或人工重放,会扣到现在的套餐上,不得重放。**积压部分由业务后续手动调整,本方案只保证新增的不再产生** | +| 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`。 +出处:`YidongDayFlowService.saveYidongDayFlow`、`PtLiantongDayFlowService.saveLiantongDayFlow`。 - 表 `tbl_*_day_flow`:`iccid_mark`、`flow_date`、`used_flow`(自然月累计 MB);普联通另存 `vendor_used_flow`(运营商原始读数)。 - 同一天多次查询:用新值覆盖。 - 移动:新值比旧值小就跳过(每月 1 号除外)。 - - 普联通:读数 ≤ 0 不覆盖;当天第一条就是 0 时,沿用昨天的值。 + - 普联通:读数 ≤ 0 不覆盖;当天第一条就是 0 时,沿用昨天的值;其余情况后写覆盖,可以变小。 + - 电信、广电、广电物联网、5G:与移动、普联通同类(1 号防旧值 + 同一天覆盖),细节略有差异。 +- 老卡管按 category 分派到各自的表(`CardFlowUtils.getCardFlow`):电信 1、移动 3、联通 4/8/9、5G 108、广电 112/113 等。设备是主卡、副卡各自按区间取值后相加(`CardFlowUtils.getTraffic`)。 +- 电信采集时按"本月 1 号~今天"向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`),拿到的是自然月累计,之后同样写日快照、按快照计费。 ### 3.2 联通:写入时把运营商周期换算成自然月 @@ -67,14 +78,16 @@ | 27 号~月底 | 运营商读数 + 本月 1~26 号的最大快照 | | 1~26 号 | 运营商读数 − 上月 27 号~月底这段的用量(= 上月最大快照 − 上月 27 号前的最大快照) | -> 老代码第二行取"上月 27 号前"时用的是 `< 26 号`,少算了一天,本方案按"1 号~重置日前一天"修正。 +> 核实:老代码取"上月 1~26 号最大快照"用的是 `findFlowByBeforeDate`,SQL 为 `flow_date >= 1号 and flow_date <= 26号`,包含 26 号,取值正确;27 号起取"本月 1~26 号最大快照"用的是 `getMaxUsedFlow`,SQL 为 `flow_date < 27号`,同样正确。 +> 老代码的小缺陷:`if ((flow -= subFlow) < 0) subFlow = 0` 改的是 `subFlow` 而不是 `flow`,负值会写进快照,靠查询时 `used_flow > 0` 过滤掉。本方案直接按 0 处理。 ### 3.3 重置日防旧值:只有一条规则 | 运营商 | 规则 | |---|---| -| 移动(1 号清零) | 每月 1 号,读数 > 上月最大快照 × 0.9 → 记为 0 | -| 普联通(27 号清零) | 每月 27 号,读数 > 本月 1~26 号最大快照 × 0.9 → 运营商读数按 0 计 | +| 移动、电信、广电、广电物联网、5G(1 号清零) | 每月 1 号,读数 > 上月最大快照 × 0.9 → 记为 0 | +| 普联通 category 9(27 号清零) | 每月 27 号,读数 > 本月 1~26 号最大快照 × 0.9 → 运营商读数按 0 计(代码写作 `appendFlow > 0 && flow > appendFlow × 1.9`,`flow` 已含 `appendFlow`) | +| 联通 category 4、8 | 没有防旧值,只做自然月换算 | 意思是:重置日读到的值还接近上个周期的总量,就认为运营商还没清零或者读到了旧缓存,当天先不增长。除此之外,老卡管**没有任何读数下降的判定**。 @@ -100,13 +113,13 @@ | # | 原则 | |---|---| | R1 | **流量照搬老卡管**:日快照 + 自然月换算 + 重置日防旧值 + 区间取最大值做差 | -| R2 | **套餐侧零改动**:增量照旧走 V1 事件和 `DeductDataUsage`;套餐表、套餐入口、分配顺序、停复机都不改 | +| R2 | **套餐侧零改动**:增量照旧走 V1 事件和 `DeductDataUsage`;套餐表、套餐入口、分配顺序、停复机都不改(停复机的调整见第 10 章,与流量部分无关) | | R3 | **不再用卡表基线算增量**:增量只由快照得出;卡表三列照旧由老逻辑维护,只作兼容展示 | | R4 | **日期一律按 `Asia/Shanghai`** | ### 4.2 数据模型 -只新增一张表,**不改任何现有表**。迁移从当前最新编号之后开始,成对 up/down。 +新增两张表,**不改任何现有表**。迁移从当前最新编号之后开始,成对 up/down。 **`tb_card_flow_day`(卡日快照,永久保留;仓库现无分区表,一年约 3,200 万行,靠唯一索引足够,本期不分区)** @@ -117,13 +130,32 @@ | `flow_date` | date | 上海日期 | | `month_used_mb` | numeric(14,3) | 自然月累计(换算后),计算用 | | `vendor_used_mb` | numeric(14,3) | 当天最后一次有效的运营商原始读数,排障用 | -| `emitted_mb` | numeric(14,3) | 当天已经发出的增量合计(见 4.5),默认 0 | +| `settled_mb` | numeric(14,3) | 当天已结算的增量合计,默认 0。包括:发出 V1 的增量、无生效套餐时丢弃的增量、切换时记为"老路径已结算"的部分(见 4.5、4.6) | | `stale_guarded` | boolean | 重置日当天是否触发过防旧值(读数按 0 记) | | `obs_count` | int | 当天有效观测次数 | | `created_at`、`updated_at` | timestamptz | | 唯一键 `(iot_card_id, flow_date)`。每卡每天一行,8.9 万卡约 3200 万行/年。 +**`tb_card_flow_switch`(切换水位,每卡一行)** + +卡第一次走新路径时写入(4.6),之后只读。退回老路径时删除(5.2)。 + +| 列 | 类型 | 含义 | +|---|---|---| +| `id` | bigserial | | +| `iot_card_id` | bigint | 唯一 | +| `switched_at` | timestamptz | 第一次走新路径的观测时刻 | +| `month_used_mb` | numeric(14,3) | 切换时本月最大快照(写入本次读数后) | +| `vendor_used_mb` | numeric(14,3) | 切换时的运营商读数 `r` | +| `old_baseline_mb` | numeric(14,3) | 切换时卡表 `last_gateway_reading_mb` | +| `old_month_usage_mb` | numeric(14,3) | 切换时卡表 `current_month_usage_mb` | +| `old_reading_accepted` | boolean | 切换这次观测老路径是否接受读数(冻结卡为 false) | +| `old_increment_mb` | numeric(14,3) | 切换这次观测老路径算出的增量(照此发 V1) | +| `created_at` | timestamptz | | + +作用:切换衔接(4.6)、补数据截断与冻结卡判定(5.5)。 + ### 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 号重置会让自然月换算出错、累计值被抬高,按取最大值的规则会多扣且收不回。老路径继续用原函数,不改。 @@ -132,55 +164,73 @@ 每次观测(轮询、人工刷新、系列观测三个入口)读到 `r`: -1. **无效读数和 0 不写**:查询失败、`used` 为空 / null / 无法解析 / `< 0`、或 `r = 0`,一律不写快照、不发增量。(老卡管普联通对 0 是"当天已有行不覆盖、当天第一条沿用昨天的值",移动是写一条 0;本方案直接不写,因为计算只看最大值,效果相同。)`FlowUsageResp` 增加"读数是否有效"标记,只给新代码用。 +1. **无效读数和 0 不写**:查询失败、`used` 为空 / null / 无法解析 / `< 0`、或 `r = 0`,一律不写快照、不发增量。(老卡管普联通对 0 是"当天已有行不覆盖、当天第一条沿用昨天的值",移动是写一条 0;本方案直接不写,因为计算只看最大值,效果相同。)Gateway 的 `FlexFloat` 已把空、null、无法解析都转成 0(`internal/gateway/models.go:55-78`),与读数 0 走同一处理,**所以不需要新增"读数是否有效"标记**,Gateway 与三个观测入口都不用改。 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 天没有快照时不判断; + - `R = 1`:`r > 上月最大 month_used_mb × 0.9` 时按 `r = 0` 计算 `m`(同 `YidongDayFlowService.saveYidongDayFlow`;上月没有快照时比较值为 0,任何正读数当天都按 0 记,次日如实写入,与老卡管一致); + - `R > 1`:本月 1 号~R−1 号的最大 `month_used_mb` 大于 0,且 `r` 超过它的 0.9 倍时,按 `r = 0` 计算 `m`(同 `PtLiantongDayFlowService.saveLiantongDayFlow`:`appendFlow > 0 && flow > appendFlow × 1.9`);本月前 R−1 天没有快照时不判断; - 触发时置 `stale_guarded = true`;第二天起不再判断,读数照常写入。 -4. **同一天合并**:当天已有行时,`month_used_mb = max(旧值, m)`。 +4. **同一天合并**:当天已有行时,`month_used_mb = max(旧值, m)`。这一步是改进,不是照搬:老卡管普联通同一天后写覆盖、可以变小,但新方案的增量一旦发出就收不回,所以快照只增不减。 +5. **写法**:一律用 `INSERT ... ON CONFLICT (iot_card_id, flow_date) DO UPDATE SET month_used_mb = GREATEST(tb_card_flow_day.month_used_mb, EXCLUDED.month_used_mb), ...`,不先查后写。已切换卡在事务 1 内写,卡行已被锁(`traffic.go:40`),天然串行;未切换卡在事务 1 提交后写,没有卡锁,靠这条原子语句防止轮询与人工刷新并发写丢值。 -### 4.4 今天的快照用量(照搬 3.4) +### 4.4 每天的快照用量(照搬 3.4,用于日流量展示) ``` -今天用量 U(卡, 今天) = max(今天的 month_used_mb) − max(本月 1 号 ~ 昨天的 month_used_mb) - < 0 按 0;今天是本月第一条快照时减数为 0 +U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天的 month_used_mb) + < 0 按 0;那天是本月第一条快照时减数为 0 ``` -- 中间有几天没采到,那几天的用量自然并入今天,不会丢。 +- 中间有几天没采到,那几天的用量自然并入采到的那天,不会丢。 - 每天的日流量就是 `U(卡, 那天)`,直接替代 Redis 日流量缓冲、02:00 落盘和 `tb_card_daily_usage`。 +- **算增量不用这个公式**,用 4.5 的按月对账。两者按月合计相同,但按天对账遇到乱序观测会重复扣(见 4.5 说明)。 ### 4.5 算增量并发出(替代 `r − 基线`) -在观测事务(事务 1,现有 `ApplyTraffic` 所在事务)里,对**已切换的卡**: +在观测事务(事务 1,现有 `ApplyTraffic` 所在事务)里,对**已切换且已有切换水位的卡**(没有水位的走 4.6): -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 += Δ`。这部分用量不属于任何套餐,丢弃(日流量照常可见); +1. 卡行已在事务开头加锁(`traffic.go:40`),同卡观测天然串行,不再单独锁快照行; +2. 按 4.3 写今天的快照; +3. 按月对账:`M = 本月最大 month_used_mb`(写入后),`S = 本月各天 settled_mb 合计`,增量 `Δ = M − S`; +4. `Δ > 0` 且**该卡有可扣的生效套餐**(与 `resolveActivePackages` 同口径:卡自身有**生效中(status=1)**的套餐,或卡当前绑定的设备有生效中的套餐;**已用完(2)、待生效(0)不算**;只读查询,不加锁):写一条 V1 事件 `CardTrafficIncremented`,载荷与现在完全相同(只是增量值换了来源),同时今天的 `settled_mb += Δ`; +5. `Δ > 0` 但**没有生效套餐**:不发事件,只把今天的 `settled_mb += Δ`。这部分用量不属于任何套餐,丢弃(日流量照常可见); 6. `Δ ≤ 0`:不发事件。 +7. **通道阈值判定**:本次读数有效(`r > 0`)且本次读数未被防旧值按 0 处理时,用 `r` 调用 `EvaluateInTx`,不再看老路径是否接受读数(见下方说明)。 说明: -- **增量与发出标记在同一事务里**,和 Outbox 事件一起提交或一起回滚,不会重复发、也不会漏发。同卡并发观测由快照行锁串行。 +- **为什么按月对账**:若按天对账(`今天用量 − 今天已发出`),一次迟到的观测改写了昨天的快照,昨天会补发一笔,而今天的增量已按旧的"此前最大值"发过,两笔重叠的部分会扣两次(跨零点时轮询与人工刷新并发即可出现)。按月对账只看"本月最大值"和"本月已结算合计",与观测先后无关。 +- **跨月**:新月份 `S = 0`,`M` 只取新月份的快照,第一条快照的增量就是本月累计。重置日为 1 号的卡,"上月最后一次采到 → 清零"之间的用量拿不到(见第 9 节"旧周期尾部")。 +- **增量与已结算标记在同一事务里**,和 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 里只读判断一次,不改套餐侧代码;判断出错时按"有套餐"处理并发事件(保持现有行为,不丢量)。事务 1 看到有生效套餐、但消费者扣减前套餐刚好被前一条事件扣完的情况,仍会报错重试,见第 9 节。 +- 卡表三列(`last_gateway_reading_mb` 等):老 `ApplyTraffic` 照常计算、照常写,只是它算出的增量对已切换的卡不再使用。`last_gateway_reading_mb` 是观测的 CAS 条件,新代码只读不写。 +- **卡表本月用量**:卡表 `current_month_usage_mb` 被资产详情(`service/asset/service.go:482`)、卡列表(`service/iot_card/service.go:501`)、设备导出(`exporter/device_scene.go:423`)直接读取。冻结卡按老逻辑这一列一直是 0,所以**已切换的卡在事务 1 里用本月最大快照 `M` 覆盖这一列**(写在同一条 `Updates` 里,老逻辑算出的值对已切换卡不用),三处读取都不用改。这一列只用于展示;补数据判定冻结卡时,已切换卡看水位、不看这一列(5.5),不受影响。跨月时老逻辑照常把上月值转入 `last_month_total_mb`,已切换卡转入的就是上月快照的最大值。 +- **通道阈值**:老路径只在读数被接受时判定(`traffic.go:84`),冻结卡读数一直被拒,所以通道达量停机永远不触发;不改的话,全量切换后依然如此,只能等卡在重置窗口内被采到、基线自行重置。`EvaluateInTx` 只做"达量即写停机锁",没有"读数低就复机",所以放开判定不会误复机;排除被防旧值处理的读数,是为了防止重置日读到上周期旧总量、在新周期误停机;同一天之后读到的真实读数照常判定。此改动只在已切换分支内。 +- **已切换的卡不再发老增量的 V1**:老 `ApplyTraffic` 算出的 `decision.IncrementMB` 对已切换的卡丢弃(切换那一次除外,见 4.6),现在第 73-76 行的发事件逻辑只在未切换分支执行;每次观测只会发一条 V1,Outbox 量不增加。 - 只有已用完或待生效套餐的卡,增量同样丢弃。这和现状一致:现状下这类增量会因"没有可用套餐"扣减失败并积压(第 2 节第 6 条),并不会扣到已用完的套餐上。 -**未切换的卡**:事务 1 一切照旧(老增量、老 V1);快照在事务 1 提交后单独写,失败只告警(见 4.7),用于切换前积累数据和比对。 +**未切换的卡**:事务 1 一切照旧(老增量、老 V1、老通道阈值条件);快照在事务 1 提交后按 4.3 第 5 步原子写入,失败只告警(见 4.7),用于切换前积累数据和比对;`settled_mb` 不动。 -### 4.6 切换当天的衔接 +### 4.6 切换衔接:切换水位 -卡切换后的第一次观测,如果今天的快照行已经存在(切换前已经写过),先把 `emitted_mb` 初始化为"写入本次读数之前的 `U(卡, 今天)`",再按 4.5 计算。这样今天切换前已经由老路径扣过的部分不会再扣一次,只发本次观测带来的增长。 +卡已在新路径名单中、但 `tb_card_flow_switch` 里还没有它的行时,这次观测是它的**第一次切换观测**,在事务 1 内: -限制:同一张卡**退回老路径后当天不要再切回**。当天快照上的 `emitted_mb` 已经不是 0,再切回时不会重新初始化,退回期间老路径扣过的部分会被再扣一次。 +1. 按 4.3 写今天的快照,得到 `M`(本月最大)与 `S`(本月已结算合计,正常为 0); +2. 本次增量**照用老路径的结果** `decision.IncrementMB`:`> 0` 且有生效套餐时发 V1;无生效套餐时不发(与 4.5 一致,老路径这时会发); +3. 今天的 `settled_mb += max(M − S, 0)`,即把本月到此刻为止全部记为已结算; +4. 写入切换水位行(4.2),记下 `M`、`r`、卡表基线、卡表本月计入、老路径是否接受读数、老增量; +5. 本次的通道阈值仍按老路径条件判定,从下一次观测起按 4.5 第 7 步;卡表本月用量从本次起按 4.5 用 `M` 覆盖。 + +效果: + +- 切换这一次的增量与老路径相同,之后只发切换之后的增长,**不依赖切换前本月是否已有快照**,阶段 1 不需要覆盖整月。 +- 冻结卡:老路径本次增量为 0,切换前少扣的部分不在这里处理,由 5.5 按水位补。 +- 退回老路径时删除水位行(5.2);再切回会重新走一次本节,退回期间老路径扣过的部分不会重扣。 +- 写水位与快照、Outbox 在同一事务,失败随事务 1 回滚。 ### 4.7 失败处理 @@ -188,7 +238,7 @@ |---|---| | Gateway 查询失败 / 无效读数 / 读数为 0 | 不写快照、不发增量 | | 运营商重置日查不到或非法 | 按运营商类型兜底:联通(`CUCC`)27 号 0 点,其余 1 号 0 点,北京时间;记日志。老路径照旧 | -| 已切换卡:快照写入失败 | 随事务 1 回滚,观测重入队(与现在观测失败的处理一致) | +| 已切换卡:快照、已结算标记或切换水位写入失败 | 随事务 1 回滚,观测重入队(与现在观测失败的处理一致) | | 未切换卡:快照写入失败 | 只告警,不影响观测;总开关可整体停写 | | 某天没采到 | 用量并入下一个有快照的日子 | @@ -198,14 +248,17 @@ | # | 接触点 | 设计 | 结论 | |---|---|---|---| -| 1 | 写快照(阶段 1 起全部卡都写) | 非灰度卡在观测事务**提交之后**单独写,失败、超时只告警,不让观测失败、不重入队;总开关 `JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED` 关掉即停写,不用发版 | 不影响扣费与观测;数据库多一次写入(现约 2,400 次/小时),上线后观察轮询耗时,异常即关开关 | +| 1 | 写快照(阶段 1 起全部卡都写) | 非灰度卡在观测事务**提交之后**用一条 `INSERT ... ON CONFLICT DO UPDATE` 原子写入(4.3 第 5 步),失败、超时只告警,不让观测失败、不重入队;总开关 `JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED` 关掉即停写,不用发版 | 不影响扣费与观测;数据库多一次写入(现约 2,400 次/小时),上线后观察轮询耗时,异常即关开关 | | 2 | 判断走新路径还是老路径 | 观测事务内用启动时载入内存的卡集合判断,不查库;判断出错一律走老路径 | 不影响 | | 3 | 增量计算 | 非灰度卡仍用"读数 − 基线",仍发 V1 事件,代码不改 | 不影响 | | 4 | 无套餐时不发事件 | 只对灰度卡生效 | 不影响 | -| 5 | Gateway 返回加"读数是否有效"标记 | 只给新代码用,老路径照旧(无效仍按 0 处理) | 不影响 | +| 5 | Gateway 与三个观测入口 | 不改(不新增"读数是否有效"标记,见 4.3 第 1 步) | 不影响 | | 6 | 日流量查询 | 非灰度卡仍读老来源 | 不影响 | | 7 | 数据库迁移 | 只新建表,不改现有表 | 不影响 | | 8 | 设备部分卡切换 | 不允许:设备整台切(5.2) | 不影响 | +| 9 | 通道阈值判定条件 | 只在已切换分支改为"读数有效且本次未被防旧值处理";未切换分支仍是"老路径接受读数" | 不影响 | +| 10 | 切换水位表 | 只有已切换卡读写 | 不影响 | +| 11 | 卡表 `current_month_usage_mb` | 只对已切换卡用快照覆盖;未切换卡仍由老逻辑写 | 不影响 | **唯一无法靠设计消除的风险**:`ApplyTraffic` 是所有卡共用的代码,改动出错可能波及全部卡。约束:新逻辑只放在"判定为灰度卡"的分支里,老分支一行不改;评审重点核对这一处;测试环境回归"非灰度卡观测一次"的结果与改动前完全一致。 @@ -230,23 +283,25 @@ | 阶段 | 内容 | 对业务影响 | |---|---|---| | 1 写快照 | 建 `tb_card_flow_day`,全部卡在观测事务提交后写快照 | 无,只写不用 | -| 2 灰度切换 | 名单内的卡按 4.5 算增量;其余照旧 | 只影响名单内的卡 | +| 2 灰度切换 | 名单内的卡第一次观测按 4.6 写切换水位,之后按 4.5 算增量;其余照旧 | 只影响名单内的卡 | | 3 全量 | 全部卡切换;之后下线 Redis 日流量缓冲与 02:00 落盘 | — | -阶段 1 至少跑满一个完整自然日再进入阶段 2;跨月或跨运营商重置日时,最好已有上个周期的快照(防旧值要用)。 +阶段 1 至少跑满一个完整自然日再进入阶段 2。切换衔接靠切换水位(4.6),**不要求阶段 1 覆盖整月**。但防旧值和联通换算要用上个周期的快照:重置日为 1 号的卡,最好上月已有快照;联通卡最好上月 1~26 号已有快照,否则阶段 1 第一个月的联通快照会把上月 27 号以后的用量算进本月(只影响日流量展示的分摊,不影响按月对账的增量)。 ### 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 里做,用启动时展开的内存集合,不查库;判定出错一律按老路径。 +- **切换、扩批、退回都要先停掉 API 和全部 Worker,再一起启动,不逐个重启**。原因:逐个重启时会有新旧进程并存的窗口,同一张卡如果先被新进程写了水位、再被老进程观测(多为人工刷新),老进程发的那笔增量不计入已结算合计,下次新路径观测会再扣一次。窗口只有几分钟,概率很低,但停后再启即可完全避免。 - **配置位置**:新增 `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` 一直在维护卡表基线,退回后老路径接着算(冻结卡会回到冻结状态)。 +- **退回**:从名单删除或改回 `off`,**删除这些卡在 `tb_card_flow_switch` 里的水位行**,再重启。老 `ApplyTraffic` 一直在维护卡表基线,退回后老路径接着算(冻结卡会回到冻结状态)。水位行必须删:否则以后再切回时不会重新走 4.6,退回期间老路径扣过的增长会被新路径再扣一次。退回与再切回可以在同一天。 +- 灰度设备换绑后重启:新绑上的卡第一次走新路径时按 4.6 写水位,不会重复扣;换绑前后这台设备仍短暂处于"部分切换",所以上一条"不换绑"的约束保留。 ### 5.3 冻结卡与历史误差 -- 切换后冻结卡(如 103154)立即恢复正常计量:增量只看快照,不看卡表基线。 +- 切换后冻结卡(如 103154)立即恢复正常计量:切换那一次老路径增量为 0(4.6),之后增量只看快照,不看卡表基线;通道阈值判定同时恢复(4.5 第 7 步)。 - **切换前少扣的部分放到后期补**,用独立脚本执行,见 5.5。 - **上个周期及更早少扣的补不回来**:运营商读数已经清零,没有任何记录;期间套餐已经重置的,那个计费周期也已结束。 - **第一批灰度:103154 所在设备 15276**(联通 103154、移动 99426、电信 106882,三张卡本月计入都为 0)。核对无误后再放其他冻结卡、电信读数常为 0 的卡、二次销售的卡、联通卡、带加油包的载体。 @@ -281,15 +336,16 @@ ``` - 每张卡按自己运营商的重置日确定周期起点,设备下混着不同运营商的卡也能用;不需要按套餐拆分日记录。 -- `r` 在执行时现场查询 Gateway,限速执行;查询失败、无效、读数为 0 的跳过,进清单。 +- **未切换的卡**:`r` 在执行时现场查询 Gateway,限速执行;查询失败、无效、读数为 0 的跳过,进清单。 +- **已切换的卡**:`r` 取切换水位的 `vendor_used_mb`(切换时的运营商读数),不现场查。切换之后的用量已由新路径计算,用水位截断,不会重复。切换时刻不在执行时所在运营商周期内的,进清单。 **判定为冻结卡(全部满足才自动补)**: | 条件 | 原因 | |---|---| | 卡(或其所在设备)有**生效中(status=1)**的套餐(与 4.5 同口径) | 只有已用完或待生效套餐时,`DeductDataUsage` 找不到可扣套餐会报错,补扣事件会积压(第 2 节第 6 条);这类卡进清单 | -| 最近两天在被采集,本月计入(`current_month_usage_mb`)为 0 | 本周期没有任何读数被老路径接受 | -| 现场读数 `r` < 卡表基线 `last_gateway_reading_mb` | 基线属于上个周期或被异常值抬高,读数一直被拒 | +| 未切换卡:最近两天在被采集,本月计入(`current_month_usage_mb`)为 0。已切换卡:水位的 `old_reading_accepted = false` 且 `old_month_usage_mb = 0` | 本周期没有任何读数被老路径接受。已切换卡切换后卡表仍由老逻辑维护、本月计入可能一直是 0,所以只能看水位,不能看卡表 | +| `r` < 基线(未切换卡用现场读数与卡表 `last_gateway_reading_mb`;已切换卡用水位的 `vendor_used_mb` 与 `old_baseline_mb`) | 基线属于上个周期或被异常值抬高,读数一直被拒 | | 卡所在扣减载体的套餐计费起点(激活或最近一次重置)**早于本运营商周期起点** | 否则 `r` 里有一部分属于已经结束的上个计费周期,不该补到新周期 | | 本周期内没有回收换代、换绑 | 读数和套餐对不上;也排除了二次销售 | @@ -299,7 +355,7 @@ 1. **预演(只读)**:输出每张卡的卡号、运营商、扣减载体、`r`、基线、补扣量、补扣后是否超额;汇总补扣总量、预计停机数、清单与原因。 2. 确认后正式执行:每张卡写一条一次性的 V1 事件,事件 ID 由卡、运营商周期、批次组成,重复执行同一批次不重复写;由现有消费者调用 `DeductDataUsage` 扣减,超额照常停机,补扣量照常写入 `daily_record`。 -3. 已切换到新方案的卡,切换之后的用量由新方案计算,补扣只补切换之前那段;脚本按"切换时刻已知的读数"截断,避免与新方案重复。 +3. 已切换到新方案的卡,切换之后的用量由新方案计算,补扣只补切换之前那段:补扣量取水位的 `vendor_used_mb`,避免与新方案重复。 4. 补扣写审计,注明"冻结卡补扣"与补扣量。 5. 现有消费者会把每条 V1 事件的增量累加进当天的 Redis 日流量缓冲,所以**未切换卡的日流量会在补扣当天出现一次突增**(已切换卡的日流量读快照,不受影响)。执行前告知运营,或在预演清单中标出。 @@ -307,12 +363,11 @@ | 位置 | 改动 | |---|---| -| `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 提交后写 | +| `migrations/` | 新建 `tb_card_flow_day`、`tb_card_flow_switch` | +| 新增 `internal/domain/cardflow/` | 纯函数:自然月换算、重置日防旧值、每天用量、按月对账增量;表驱动单测 | +| 新增 `internal/store/postgres/card_flow_day_store.go` | 快照原子写入(`ON CONFLICT DO UPDATE`)、本月最大值与已结算合计查询 | +| 新增 `internal/store/postgres/card_flow_switch_store.go` | 切换水位读写 | +| `internal/application/cardobservation/traffic.go` | 已切换的卡:增量改为 4.5 的按月对账,第一次切换观测按 4.6 写水位,通道阈值按 4.5 第 7 步判定,卡表 `current_month_usage_mb` 用本月最大快照覆盖,V1 事件载荷不变,按卡的 `carrier_id` 严格查重置日;未切换的卡:老逻辑不变,提交后写快照。快照写入都在 `ApplyTrafficObservation` 里完成,三个观测入口与 Gateway 不改 | | 日流量查询 | 已切换的卡读快照 | | `pkg/config` | 三个环境变量(模式、灰度名单、快照写入总开关) | | 后期:补数据脚本(`cmd/`) | 预演(只读)+ 正式执行(写 V1 事件),见 5.5 | @@ -329,7 +384,7 @@ | 重置日 `R = 15`(任意非 1 号) | 换算规则同上,以 15 号为界,结果连续 | | 重置日 `R = 1` | 不换算,`month_used_mb` = 读数 | | 运营商的 `data_reset_day` 被修改 | 从修改后的下一次写入起按新重置日换算 | -| `R > 1` 时 1 号~R−1 号换算 | 减去上月 R 号~月底的用量,按"1 号~R−1 号"取值,不少一天 | +| `R > 1` 时 1 号~R−1 号换算 | 减去上月 R 号~月底的用量(= 上月最大 − 上月 1~R−1 号最大,含 R−1 号) | | 重置日当天读到上周期旧值(> 90%) | 当天按 0 记,`stale_guarded = true`;之后读到真实低值正常累计 | | 重置日次日起仍读到旧值 | 照常累加(与老卡管一致) | | 重置日当天真实用量超过上周期 90% | 当天按 0 记;次日如实写入,取最大值把这部分带回来,只晚一天,不丢 | @@ -340,20 +395,30 @@ | `R = 1` 重置日当天读数 > 上月最大快照 × 0.9 | 当天按 0 记 | | `R = 27` 重置日当天读数 > 本月 1~26 号最大快照 × 0.9 | 当天按 0 记 | | 中间 3 天没采到 | 3 天的用量并入采到的那天 | -| 跨自然月 | 本月第一条快照减数为 0,从本月累计开始算 | +| 跨自然月 | 本月第一条快照减数为 0,从本月累计开始算;按月对账时新月份已结算合计为 0 | +| 按月对账:本月最大 500、已结算合计 450 | 增量 50 | +| 乱序:今天已发出后,一次迟到观测抬高了昨天的快照 | 按月对账的增量 = 本月最大 − 已结算合计,不会把重叠部分扣两次 | **应用层** | 场景 | 期望 | |---|---| | **卡 103154 类:卡表基线 72535,运营商读数 48808** | 切换后增量按快照正常发出,套餐恢复增长 | -| **二次销售:上一个用户本月用到 3000;新用户 15 号 14:00 激活;激活前当天又读到 3020,激活后读到 3050** | 3020 及之前的增量在无套餐时只推进 `emitted_mb`、不发事件;新套餐只扣 30 | +| **二次销售:上一个用户本月用到 3000;新用户 15 号 14:00 激活;激活前当天又读到 3020,激活后读到 3050** | 3020 及之前的增量在无套餐时只记入 `settled_mb`、不发事件;新套餐只扣 30 | | 卡只有已用完或待生效的套餐 | 不发事件,增量丢弃 | | 灰度卡观测一次 | 只发一条 V1(新算法增量),不再发老增量的 V1 | | 无套餐期间连续观测,之后激活 | Outbox 里没有该卡积压的 V1 事件;激活后只扣激活之后的增长 | -| 同一次观测重复处理 / 同卡并发观测 | 行锁串行,`emitted_mb` 保证每段增长只发一次 | -| 事务 1 回滚 | 快照、`emitted_mb`、Outbox 一起回滚,不重不漏 | -| 切换当天切换前老路径已扣过一部分 | 按 4.6 初始化,不重复扣 | +| 同一次观测重复处理 / 同卡并发观测 | 卡行锁串行,按月对账保证每段增长只结算一次 | +| 事务 1 回滚 | 快照、`settled_mb`、切换水位、Outbox 一起回滚,不重不漏 | +| 第一次切换观测:正常卡,本月已有快照 | 只发老路径这次的增量;写水位;本月已结算合计 = 本月最大 | +| 第一次切换观测:正常卡,本月还没有快照 | 同上,不会把整月累计当增量发出 | +| 第一次切换观测:冻结卡(老路径拒绝读数) | 不发增量;水位记 `old_reading_accepted = false`、`vendor_used_mb = r`;下一次观测起按快照正常发 | +| 退回老路径(删水位)后当天再切回 | 重新按 4.6 写水位,退回期间老路径扣过的部分不重扣 | +| 冻结卡切换后读数超过通道阈值 | 写通道停机锁(老路径下不会触发) | +| 冻结卡切换后 | 资产详情、卡列表、设备导出的本月用量 = 本月最大快照,不再是 0 | +| 重置日当天先触发防旧值、之后读到真实低值 | 真实读数照常做通道阈值判定 | +| 重置日当天读到上周期旧总量并触发防旧值 | 不做通道阈值判定,不误停机 | +| 未切换的卡并发写快照(轮询与人工刷新同时) | 原子写入,`month_used_mb` 取较大值,不丢 | | 名单写 `device:15276` | 设备当前绑定的 3 张卡全部切换 | | 联通卡观测时运营商表查询失败(入口传进来的 `ResetDay` 已被兜底成 1) | 新逻辑不用入口的值,自行严格查询,失败按 27 号处理 | | 名单写 `iot_card:X`,但该卡实际用设备套餐 | 启动日志报错并跳过该项,worker 正常启动,该卡仍走老路径 | @@ -375,6 +440,8 @@ | 冻结卡只有已用完的套餐 | 不补,进清单 | | 少扣为负(多扣) | 不退,进清单 | | 同一批次重复执行 | 事件 ID 相同,不重复写 | +| 已切换的冻结卡 | 补扣量取水位的 `vendor_used_mb`,不现场查;切换之后的用量不重复补 | +| 已切换的正常卡(水位 `old_reading_accepted = true`) | 不补 | | 套餐计费起点在本周期内的冻结卡 | 不自动补,进清单并附按时间比例的估算值 | **测试环境验证**:103154 同类卡、三卡共用设备、二次销售卡、重置日为 27 号与 1 号的运营商各跨一次重置日、另构造一个非常规重置日(如 15 号)各一组,逐日核对快照、增量与套餐已用量。 @@ -384,14 +451,16 @@ | # | 任务 | 人天 | |---|---|:-:| | 1 | 迁移与模型 | 0.25 | -| 2 | 领域层:自然月换算、防旧值、今天用量 + 单测 | 1.5 | -| 3 | 快照存储(含行锁)+ 三个观测入口写快照 + 无效读数标记 | 1.0 | -| 4 | `ApplyTraffic` 已切换卡的增量替换与切换当天衔接 | 1.0 | +| 2 | 领域层:自然月换算、防旧值、每天用量、按月对账 + 单测 | 1.5 | +| 3 | 快照存储(原子写入)+ 切换水位存储 | 0.75 | +| 4 | `ApplyTraffic` 已切换卡:按月对账、切换水位、通道阈值条件 | 1.25 | | 5 | 切换机制(模式、名单、启动展开) | 0.5 | | 6 | 日流量读侧 | 0.5 | | 7 | 集成测试与测试环境验证 | 1.5 | | | 合计 | 约 6.25 | +与第 1 版相比:去掉"读数是否有效"标记和三个入口的改动,省下的工作量用于切换水位与通道阈值条件,合计不变。 + 后期补数据脚本(5.5:预演、条件判断、写 V1 事件、清单)另计约 1.5 人天。 不含灰度观察期:至少跨过一次运营商清零,约 30 天内。 @@ -401,11 +470,128 @@ | 限制 | 说明 | |---|---| | 切换前的误差 | 后期按 5.5 补,只能补执行时所在运营商周期内的部分;更早的补不回来;不满足条件的进人工清单 | +| 扣减前套餐刚好用完 | 事务 1 看到有生效套餐并发出事件,但消费者扣减前,同卡的前一条事件已把唯一的套餐扣到"已用完",这条事件扣减时报"没有可用套餐"并重试,之后可能扣到新激活的套餐上(第 2 节第 6 条的同一条路径)。需要同卡在很短时间内有两条事件,概率低。消费者不改(D13),灰度期监控"处理中"回执的新增数,确有发生再评估是否让消费者对已切换卡直接结束 | +| 二次销售精度 | 只精确到观测粒度:激活后第一次观测里"上次观测到激活"的用量会算给新套餐。要更精确需要激活时触发一次立即观测(改激活入口,本期不做) | +| 切换那一次沿用老路径增量 | 第一次切换观测发的是老路径算出的增量(4.6),如果这一次正好撞上老路径的问题(例如重置窗口读到 0 把基线置 0 后,又读到真实值整笔计入),会照样扣一次。每张卡最多一次 | +| 换绑后第一次观测 | 设备换上一张本月没有快照的卡,第一次观测(走 4.6)发出的是老路径的增量,与现状一致:老路径对从未采过的卡基线为 0,会把整张卡的周期读数算给设备套餐 | | 上一代套餐仍为"生效中" | `DeductDataUsage` 只按状态选套餐、不看世代,回收换代后上一代套餐如果仍为生效中,新用户的增量可能扣到它上面。这是套餐侧问题,按 `docs/资产回收换代技术方案.md` 单独修复,本方案不改套餐侧 | | 旧周期尾部 | 旧周期最后一次采集到清零之间的用量拿不到,老卡管同样如此 | | 防旧值 | 完全照搬老卡管:阈值 0.9、只在重置日当天判断。运营商延迟清零超过一天时,次日的旧值会被累加(老卡管同样如此,多年未暴露问题) | | 积压的无套餐回执 | 18,979 条卡在"处理中"的回执(含 4,141 条对应卡当前有生效套餐)由业务后续手动调整,不在本方案内;处理前不得重放 | | 修改运营商重置日 | 月中修改 `data_reset_day` 会让当月换算前后不连续,可能产生一次跳变多扣。修改应安排在运营商实际重置日前后,并在修改后核对受影响卡的当日增量 | -| 采集不足 | 轮询吞吐问题(约 2.4k 次/小时)不在本方案范围;采不到只会晚扣,不会丢 | +| 采集不足 | 轮询吞吐问题(约 2.4k 次/小时,平均每卡约 1.5 天一次)不在本方案范围;采不到只会晚扣,不会丢。但每张卡在自己的重置日(1 号或联通 27 号),最后一次采集到清零之间的用量会丢("旧周期尾部"),采得越稀丢得越多;重置日当天没采到的卡也不会触发防旧值 | | 扣错收不回 | 套餐侧仍是增量扣减,一旦扣错只能人工改已用量。快照可以完整复算每天应扣多少,作为核对依据 | -| 电信 | 老卡管的电信是直接按日期区间向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`);新卡管 Gateway 目前只返回当前周期累计,本期统一按快照处理,读数为 0 的电信卡仍扣不到。**后续安排 Gateway 支持按日期区间查询**:支持后,电信卡改为按区间查询的结果写快照,算增量的逻辑不变;同时可以用来补旧周期尾部、补更早周期的少扣数据 | +| 电信 | 老卡管的电信在采集时按"本月 1 号~今天"向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`),拿到自然月累计后同样写日快照、按快照计费;新卡管 Gateway 目前只返回当前周期累计,本期统一按快照处理,读数为 0 的电信卡仍扣不到。**后续安排 Gateway 支持按日期区间查询**:支持后,电信卡改为按区间查询的结果写快照,算增量的逻辑不变;同时可以用来补旧周期尾部、补更早周期的少扣数据 | + +## 10. 停复机规则与实名管控 + +本章与流量计算相互独立,可以分开开发、分开上线;放在同一份方案里一起评审。 + +### 10.1 停复机原则(已定) + +| | 条件 | 说明 | +|---|---|---| +| **停机** | 卡开机,且满足任一:① 没有套餐(套餐到期);② 流量用完;③ 卡所属通道开启了实名管控(`realname_control_enabled = 1`)且卡未实名 | 只有这三条。谁开的机不重要 | +| **复机** | 卡停机,且同时满足:① 有生效套餐;② 流量没用完;③ 卡已实名,**或**卡所属通道关闭了实名管控(`realname_control_enabled = 0`) | **不看停机原因**:外部停的、卡管自动停的、管理员手动停的、设备手动停的,满足条件都复机 | +| 不动作 | 卡或所在设备暂停轮询;卡持有本周期通道阈值锁(属于"流量用完",锁期内不复机) | 见 10.4 | + +- **外部停机与卡管无关**:外部环境导致的停机,卡管判断该开机就复机,**除非这张卡(或所在设备)已被加入暂停轮询**。卡管不判断是谁停的,只按上面的条件决定开或停;外部反复停卡,卡管就按规则反复复机,不设次数限制。 +- **实名默认不管控**:通道上开启"实名管控"后,未实名的卡会被停机,停机后实名了才复机;没开启的通道,实名不参与停复机。"实名管控"是通道上**新增的独立开关**(`realname_control_enabled`),只管停复机,**不复用通道现有的实名相关字段**(如实名链接类型 `realname_link_type`,它只决定实名链接怎么生成)。开启后,通道下所有卡都按"是否已实名"判断,**不再区分行业卡,也不再看卡或设备上的实名策略**(实名策略只用于购买流程)。 +- `stop_reason` 仍照常记录(流量耗尽、无套餐、未实名、手动、运营商侧停机、通道阈值),**只用于展示和审计,不参与复机判断**。 + +### 10.2 现状与差异(代码核实) + +| # | 现在的行为 | 与原则的差异 | 代码 | +|---|---|---|---| +| 1 | 自动复机只处理停机原因为流量耗尽、无套餐、未实名的卡 | 外部停机(记为 `carrier_stopped`)、手动停机(`manual`)、设备保护期停机(`protect_period`)满足条件也不复机,只能人工处理 | `stop_resume_service.go` 的 `isPollingStopReason`、`EvaluateAndAct` 停机分支、`resumeSingleCard`;`iot_card_store.go` 的 `ListByDeviceIDAndPollingStopReasons` | +| 2 | 非行业卡、未实名、卡或设备实名策略不是 `none` 时一律停机且不能复机 | 实名默认管控,且与行业卡、资产实名策略耦合;想放开只能逐张改实名策略 | `isRealnameOK`,被 `checkStopReasons`、`shouldResume`、`resumeDeviceCards`、`ManualStartCard` 共用;保护期一致性检查跳过未实名卡(`polling_protect_handler.go:77`) | + +停机这一侧现在已经只看无套餐、流量耗尽、未实名,符合原则;停机和复机里的实名都改成按通道判断。 + +### 10.3 已定决策(2026-10-20) + +| # | 事项 | 决定 | +|---|---|---| +| D14 | 停机条件 | 无套餐、流量用完、通道开启实名管控且卡未实名,三者任一 | +| D15 | 复机条件 | 有生效套餐、流量没用完、卡已实名或通道关闭了实名管控,三条同时满足即复机;**不看停机原因**(含外部停机、手动停机、设备手动停机) | +| D16 | 实名管控 | 通道(`tb_carrier`)新增独立开关 `realname_control_enabled`,不复用现有实名字段,**默认 0 = 不管控**;设为 1 时通道下所有卡按是否已实名判断(未实名停机、实名后才复机),不区分行业卡、不看资产实名策略。**本期只加通道开关,不迁移、不修改任何卡或设备数据**;卡级、设备级的实名管控以后需要时再加(同样用独立字段,默认继承通道) | +| D17 | 暂停轮询的卡 | 卡或所在设备暂停轮询时,卡管不自动停复机,人工停复机照常。风险停机、机卡分离停机、已销户等运营商异常状态**由人工判断并手动暂停轮询**,卡管不自动处理;现有"独立卡风险停机/已销户自动暂停轮询"的逻辑不改 | + +### 10.4 设计 + +**统一判断**:新增一个"目标状态"判断,`EvaluateAndAct` 开机分支、停机分支、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 都用它,不再各自判断。 + +- 开头先看暂停轮询:卡 `enable_polling = false`,或卡所在设备 `enable_polling = false`(与轮询范围 `ShouldEnqueue` 同口径),本次不动作。 +- 卡开机:命中 10.1 的停机条件即停机,停机原因按命中的条件记录。设备维度扩散规则不变(无套餐、流量用完扩散到设备;未实名只停该卡)。 +- 卡停机:满足 10.1 的复机条件即复机,**删除"只处理轮询原因停机"的限制**(`isPollingStopReason`)。持有通道阈值锁时不复机(现有 `channelThresholdBlocked`,不变)。 +- 设备复机:`ListByDeviceIDAndPollingStopReasons` 改为取设备下全部停机卡,逐张按复机条件判断。 +- 套餐判断沿用现有口径(`HasValidByCarrier`:生效中或待生效都算有套餐;流量用完沿用 `isTrafficExhausted`),本章不改套餐口径。 + +**实名管控(D16)**: + +- 迁移:`tb_carrier` 增加 `realname_control_enabled smallint not null default 0`。 +- `isRealnameOK`(停机判断、复机判断、设备复机、人工复机共用)改为:卡所属通道(`card.carrier_id`)`realname_control_enabled = 0` → 满足;= 1 → 卡 `real_name_status` 为已实名才满足。**删除行业卡放行和卡/设备实名策略判断**。查询通道失败时本次不动作并记日志(不按任何默认值停机或复机)。 +- 保护期一致性检查:只在通道开启实名管控且卡未实名时跳过。 +- 人工复机(`ManualStartCard`)同样经过 `isRealnameOK`,自动一致。 +- 通道管理接口(`service/carrier` 创建、修改、详情)增加该字段,修改写审计。从 0 改为 1 时,管理端二次确认并提示该通道未实名的开机卡数(下一次评估会被停机);从 1 改为 0 后,该通道因未实名停机的卡会在下一次评估时复机。 +- 卡和设备上的实名策略(`none` / `before_order` / `after_order`)、首次实名激活套餐,只用于购买与激活流程,不受影响。 + +**暂停轮询的卡(D17)**: + +- 运营商异常状态(风险停机、机卡分离停机、已销户)不做自动处理,由人工判断后在管理端暂停轮询(现有接口)。现有独立卡风险停机/已销户自动暂停轮询的逻辑(`ShouldStopPollingForRisk`)保持不变。 +- 暂停轮询后卡管不自动停复机(10.4 开头的检查),人工处理完再开启轮询,之后按 10.1 正常管理。 +- 没有被暂停轮询的异常状态卡,卡管照常按 10.1 判断;复机请求被上游拒绝时按现有失败处理。 + +### 10.5 上线 + +新规则上线后会有一批卡集中动作,用一个总开关控制: + +| 开关 | 默认 | 作用 | +|---|---|---| +| `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` | `false` | 开启 10.1 的新规则(复机不看停机原因、实名按通道判断);关闭时与现在完全一致 | +| `tb_carrier.realname_control_enabled` | `0` | 按通道配置;只在总开关开启后生效 | + +暂停轮询的卡不自动停复机(D17)也受总开关控制。 + +步骤: + +1. 发版,总开关保持关闭。 +2. **确认哪些通道需要实名管控**,在管理端设为 1。新规则默认不管控实名,没设置的通道,现在因未实名停机的卡在总开关打开后都会复机。 +3. **打开总开关前预演(只读 SQL)**,统计下一次评估会集中动作的卡: + - 会被复机:停机、未暂停轮询、有套餐、流量没用完、(通道开启实名管控时已实名),按停机原因分组(`carrier_stopped`、`manual`、`protect_period`、`not_realname` 等)。 + - 会被停机:开机、所属通道开启实名管控、未实名的卡(含行业卡,原来行业卡不停)。 +4. 业务确认后打开总开关,先停掉 API 和全部 Worker 再一起启动(与 5.2 相同);观察停复机审计和上游失败数。 + +### 10.6 改动清单 + +| 位置 | 改动 | +|---|---| +| `migrations/` | `tb_carrier` 增加 `realname_control_enabled` | +| `internal/model/carrier.go`、`service/carrier`、相关 DTO | 实名管控字段的读写与审计 | +| `internal/service/iot_card/stop_resume_service.go` | 目标状态判断(10.4);复机不看停机原因;`isRealnameOK` 改为按通道判断;暂停轮询不动作;总开关 | +| `internal/store/postgres/iot_card_store.go` | 设备复机改为取设备下全部停机卡 | +| `internal/task/polling_protect_handler.go` | 只在通道开启实名管控且卡未实名时跳过 | +| `pkg/config` | 总开关 | +| **外部停机仍记 `carrier_stopped`、套餐口径、流量侧、购买与激活流程的实名策略** | **不改** | + +### 10.7 测试用例 + +| 场景 | 期望 | +|---|---| +| 开机卡套餐到期 | 停机,原因 `no_package`(设备卡扩散到设备) | +| 开机卡流量用完 | 停机,原因 `traffic_exhausted` | +| 外部停机的卡,有套餐、流量没用完 | 自动复机 | +| 管理员手动停机的卡,有套餐、流量没用完 | 下一次评估自动复机 | +| 设备手动停机,保护期结束后 | 下一次评估按复机条件复机 | +| 停机卡没有套餐 | 保持停机;充值激活后复机 | +| 停机卡持有通道阈值锁 | 锁期内不复机 | +| 通道未开启实名管控(默认),未实名卡有套餐 | 不停机;已因未实名停机的卡复机;人工复机不因未实名被拒 | +| 通道开启实名管控,未实名的卡开着机、有套餐 | 停机,原因 `not_realname`,只停这张卡 | +| 通道开启实名管控,未实名的卡停着机、有套餐 | 不复机;实名后下一次评估复机;人工复机被拒 | +| 通道开启实名管控,未实名的行业卡 | 停机(不再区分行业卡) | +| 设备复机时设备下有一张未实名卡(通道开启实名管控) | 跳过这张卡(停机原因记为 `not_realname`),其他卡照常复机 | +| 通道开启实名管控,卡已实名、实名策略为 `before_order` | 按有套餐、流量判断,实名策略不参与 | +| 查询通道实名管控失败 | 本次不动作,记日志 | +| 设备下混有开启与未开启实名管控的通道的卡 | 各卡按自己通道判断实名;套餐与流量按设备维度 | +| 暂停轮询的卡(卡或所在设备暂停),充值激活触发评估 | 不停不复;人工停复机照常 | +| 总开关关闭 | 与现在完全一致 | diff --git a/internal/application/cardobservation/apply.go b/internal/application/cardobservation/apply.go index 1c9f939b..a061d2e2 100644 --- a/internal/application/cardobservation/apply.go +++ b/internal/application/cardobservation/apply.go @@ -13,6 +13,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/outboxid" + "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -73,6 +74,24 @@ type Service struct { auditWriter StateAuditWriter // channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。 channelThreshold ChannelThresholdEvaluator + // trafficCalc 判定卡是否走日快照计量算法;未注入时全部卡走卡表基线算法且不写快照。 + trafficCalc *TrafficCalcPolicy + // logger 记录快照写入失败等不影响观测结果的告警;未注入时不输出。 + logger *zap.Logger +} + +// SetTrafficCalcPolicy 注入卡流量计量切换策略与告警日志。 +func (s *Service) SetTrafficCalcPolicy(policy *TrafficCalcPolicy, logger *zap.Logger) { + s.trafficCalc = policy + s.logger = logger +} + +// warnLogger 返回可用的告警日志器。 +func (s *Service) warnLogger() *zap.Logger { + if s.logger == nil { + return zap.NewNop() + } + return s.logger } // NewService 创建卡实名观测应用服务。 diff --git a/internal/application/cardobservation/traffic.go b/internal/application/cardobservation/traffic.go index 64ba5134..f0d8d035 100644 --- a/internal/application/cardobservation/traffic.go +++ b/internal/application/cardobservation/traffic.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold" "github.com/break/junhong_cmp_fiber/internal/model" @@ -53,6 +54,23 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai return decisionErr } decision = nextDecision + // 已切换日快照算法的卡:增量、通道阈值与本月用量改由快照决定;读数为 0 或无效时不写快照、不产生增量。 + switched := s.trafficCalc.Switched(card.ID) + var flow *switchedFlowOutcome + if switched && cardflow.ValidReading(observation.GatewayReadingMB) { + outcome, flowErr := s.applySwitchedFlow(ctx, tx, &card, observation, decision) + if flowErr != nil { + return flowErr + } + flow = outcome + } + if switched { + decision.IncrementMB = 0 + if flow != nil { + decision.IncrementMB = flow.EmitMB + decision.CurrentMonthUsageMB = flow.MonthMaxMB + } + } updates := map[string]any{ "last_data_check_at": observation.Metadata.ObservedAt, "last_sync_time": observation.Metadata.ObservedAt, "current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB, @@ -71,6 +89,7 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新") } if decision.IncrementMB > 0 { + // 未切换卡沿用卡表基线算法的增量;已切换卡的增量已替换为按月对账结果,每次观测最多一条 V1 事件。 eventID := outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented") if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{ EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB, @@ -81,12 +100,17 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai return err } } - if decision.ReadingAccepted && s.channelThreshold != nil { - // 达量判定只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定; + evaluateThreshold := decision.ReadingAccepted + if switched { + evaluateThreshold = switchedThresholdEvaluable(flow, decision.ReadingAccepted) + } + if evaluateThreshold && s.channelThreshold != nil { + // 未切换卡只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定; + // 已切换卡读数有效且未被重置日防旧值处理即判定,不再受卡表基线影响。 // 判定失败必须回滚整个流量事务,与既有流量事实保持同事务语义。 if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{ CardID: card.ID, CarrierID: card.CarrierID, - ReadingMB: decision.LastGatewayReadingMB, ObservedAt: observation.Metadata.ObservedAt, + ReadingMB: thresholdReading(switched, flow, observation, decision), ObservedAt: observation.Metadata.ObservedAt, // 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。 Source: observation.Metadata.Source, }); err != nil { @@ -152,5 +176,28 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai if s.cache != nil { s.cache.Invalidate(ctx, observation.CardID) } + if auditedCard != nil && !s.trafficCalc.Switched(auditedCard.ID) { + s.writeUnswitchedSnapshot(ctx, auditedCard, observation) + } return decision, nil } + +// switchedThresholdEvaluable 判定已切换卡本次是否做通道阈值判定:读数有效且本次未被防旧值按 0 处理; +// 第一次切换观测仍按卡表基线算法是否接受读数判定。 +func switchedThresholdEvaluable(flow *switchedFlowOutcome, oldAccepted bool) bool { + if flow == nil { + return false + } + if flow.FirstSwitch { + return oldAccepted + } + return !flow.StaleGuarded +} + +// thresholdReading 返回通道阈值判定使用的网关累计读数:已切换卡(非第一次切换观测)用本次读数。 +func thresholdReading(switched bool, flow *switchedFlowOutcome, observation domain.TrafficObservation, decision domain.TrafficDecision) float64 { + if switched && flow != nil && !flow.FirstSwitch { + return observation.GatewayReadingMB + } + return decision.LastGatewayReadingMB +} diff --git a/internal/application/cardobservation/traffic_calc_policy.go b/internal/application/cardobservation/traffic_calc_policy.go new file mode 100644 index 00000000..250f1154 --- /dev/null +++ b/internal/application/cardobservation/traffic_calc_policy.go @@ -0,0 +1,164 @@ +package cardobservation + +import ( + "context" + "strconv" + "strings" + + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// 灰度名单中扣减载体的前缀。 +const ( + trafficCalcCarrierDevice = "device" + trafficCalcCarrierIotCard = "iot_card" +) + +// TrafficCalcPolicy 判定卡是否走日快照计量算法,以及未切换卡是否写快照。 +// +// 名单在进程启动时展开为内存卡集合,观测事务内只查内存、不查库;API 与全部 Worker 必须使用一致的配置。 +// 零值策略等同于 off 模式且不写快照。 +type TrafficCalcPolicy struct { + mode string + cards map[uint]struct{} + snapshotEnabled bool +} + +// Switched 判定卡是否走日快照算法。 +func (p *TrafficCalcPolicy) Switched(cardID uint) bool { + if p == nil { + return false + } + switch p.mode { + case config.TrafficCalcModeFull: + return true + case config.TrafficCalcModeGray: + _, ok := p.cards[cardID] + return ok + default: + return false + } +} + +// SnapshotEnabled 判定未切换卡是否写日快照。 +func (p *TrafficCalcPolicy) SnapshotEnabled() bool { + return p != nil && p.snapshotEnabled +} + +// BuildTrafficCalcPolicy 按配置展开灰度名单:device 展开为当前绑定的全部卡;iot_card 实际使用设备套餐时 +// 记录错误并跳过该项(不阻止启动)。展开查询失败时整体退回 off 模式,保证判定出错一律走卡表基线算法。 +func BuildTrafficCalcPolicy(ctx context.Context, db *gorm.DB, cfg config.TrafficCalcConfig, logger *zap.Logger) *TrafficCalcPolicy { + if logger == nil { + logger = zap.NewNop() + } + policy := &TrafficCalcPolicy{mode: cfg.Mode, cards: map[uint]struct{}{}, snapshotEnabled: cfg.FlowSnapshotEnabled} + if cfg.Mode != config.TrafficCalcModeGray { + logger.Info("卡流量计量模式", zap.String("mode", cfg.Mode), zap.Bool("flow_snapshot_enabled", cfg.FlowSnapshotEnabled)) + return policy + } + for _, item := range strings.Split(cfg.Cards, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if err := policy.expandItem(ctx, db, item, logger); err != nil { + logger.Error("展开卡流量计量灰度名单失败,本进程全部卡走卡表基线算法", zap.String("item", item), zap.Error(err)) + return &TrafficCalcPolicy{mode: config.TrafficCalcModeOff, snapshotEnabled: cfg.FlowSnapshotEnabled} + } + } + logger.Info("卡流量计量灰度名单已展开", zap.String("cards", cfg.Cards), zap.Int("card_count", len(policy.cards))) + return policy +} + +// expandItem 展开一项扣减载体;格式非法或单卡实际用设备套餐时只记录错误并跳过,查询失败时返回错误。 +func (p *TrafficCalcPolicy) expandItem(ctx context.Context, db *gorm.DB, item string, logger *zap.Logger) error { + kind, rawID, found := strings.Cut(item, ":") + id, parseErr := strconv.ParseUint(strings.TrimSpace(rawID), 10, 64) + if !found || parseErr != nil || id == 0 { + logger.Error("卡流量计量灰度名单项格式非法,已跳过", zap.String("item", item)) + return nil + } + switch strings.TrimSpace(kind) { + case trafficCalcCarrierDevice: + var cardIDs []uint + if err := db.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Where("device_id = ? AND bind_status = ?", id, constants.BindStatusBound). + Pluck("iot_card_id", &cardIDs).Error; err != nil { + return err + } + for _, cardID := range cardIDs { + p.cards[cardID] = struct{}{} + } + return nil + case trafficCalcCarrierIotCard: + usesDevice, err := cardUsesDevicePackage(ctx, db, uint(id)) + if err != nil { + return err + } + if usesDevice { + logger.Error("灰度名单中的单卡实际从设备套餐扣减,已跳过;请改为按设备整台切换", zap.String("item", item)) + return nil + } + p.cards[uint(id)] = struct{}{} + return nil + default: + logger.Error("卡流量计量灰度名单项载体类型非法,已跳过", zap.String("item", item)) + return nil + } +} + +// cardUsesDevicePackage 与套餐扣减的载体解析同口径:卡自身没有生效中套餐、且当前绑定设备有生效中套餐时, +// 卡的流量扣到设备套餐上。 +func cardUsesDevicePackage(ctx context.Context, db *gorm.DB, cardID uint) (bool, error) { + ownActive, err := countActivePackages(ctx, db, "iot_card_id = ?", cardID) + if err != nil || ownActive > 0 { + return false, err + } + deviceID, bound, err := activeBoundDeviceID(ctx, db, cardID) + if err != nil || !bound { + return false, err + } + deviceActive, err := countActivePackages(ctx, db, "device_id = ?", deviceID) + return deviceActive > 0, err +} + +// hasActivePackageForCard 判定卡本次增量是否有可扣的生效中套餐:卡自身或当前绑定设备存在生效中套餐。 +func hasActivePackageForCard(ctx context.Context, db *gorm.DB, cardID uint) (bool, error) { + ownActive, err := countActivePackages(ctx, db, "iot_card_id = ?", cardID) + if err != nil || ownActive > 0 { + return ownActive > 0, err + } + deviceID, bound, err := activeBoundDeviceID(ctx, db, cardID) + if err != nil || !bound { + return false, err + } + deviceActive, err := countActivePackages(ctx, db, "device_id = ?", deviceID) + return deviceActive > 0, err +} + +func countActivePackages(ctx context.Context, db *gorm.DB, carrierCondition string, carrierID uint) (int64, error) { + var count int64 + err := db.WithContext(ctx).Model(&model.PackageUsage{}). + Where(carrierCondition, carrierID). + Where("status = ?", constants.PackageUsageStatusActive). + Count(&count).Error + return count, err +} + +func activeBoundDeviceID(ctx context.Context, db *gorm.DB, cardID uint) (uint, bool, error) { + var deviceIDs []uint + if err := db.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Where("iot_card_id = ? AND bind_status = ?", cardID, constants.BindStatusBound). + Limit(1).Pluck("device_id", &deviceIDs).Error; err != nil { + return 0, false, err + } + if len(deviceIDs) == 0 { + return 0, false, nil + } + return deviceIDs[0], true, nil +} diff --git a/internal/application/cardobservation/traffic_flow.go b/internal/application/cardobservation/traffic_flow.go new file mode 100644 index 00000000..b380dd4b --- /dev/null +++ b/internal/application/cardobservation/traffic_flow.go @@ -0,0 +1,150 @@ +package cardobservation + +import ( + "context" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" + domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// unswitchedSnapshotTimeout 是未切换卡在观测事务提交后写快照的超时,超时只告警。 +const unswitchedSnapshotTimeout = 3 * time.Second + +// switchedFlowOutcome 是已切换卡一次观测的日快照计量结果。 +type switchedFlowOutcome struct { + // EmitMB 是本次应写入 V1 流量事件的增量,0 表示不写事件。 + EmitMB float64 + // MonthMaxMB 是写入本次快照后的本月最大快照,用于覆盖卡表本月用量。 + MonthMaxMB float64 + // StaleGuarded 为 true 表示本次读数在重置日触发防旧值、按 0 计。 + StaleGuarded bool + // FirstSwitch 为 true 表示这是卡的第一次切换观测(本次写入了切换水位)。 + FirstSwitch bool +} + +// snapshotResult 是一次快照换算与写入的结果。 +type snapshotResult struct { + flowDate time.Time + conversion cardflow.Conversion +} + +// writeSnapshot 按卡所属通道的重置日换算并原子写入当天快照;读数必须已通过 cardflow.ValidReading。 +func (s *Service) writeSnapshot(ctx context.Context, db *gorm.DB, card *model.IotCard, readingMB float64, observedAt time.Time) (snapshotResult, error) { + resetDay := s.strictResetDay(ctx, db, card) + flowDate := cardflow.ShanghaiDate(observedAt) + days := postgres.NewCardFlowDayStore(db) + monthCtx, err := days.MonthContext(ctx, card.ID, flowDate, resetDay) + if err != nil { + return snapshotResult{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡日流量快照失败") + } + conversion := cardflow.Convert(readingMB, flowDate.Day(), resetDay, monthCtx) + if err := days.Upsert(ctx, postgres.CardFlowSnapshotWrite{ + CardID: card.ID, FlowDate: flowDate, MonthUsedMB: conversion.MonthUsedMB, + VendorUsedMB: readingMB, StaleGuarded: conversion.StaleGuarded, + }); err != nil { + return snapshotResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入卡日流量快照失败") + } + return snapshotResult{flowDate: flowDate, conversion: conversion}, nil +} + +// strictResetDay 在当前连接内按卡的 carrier_id 读取上游流量重置日,不使用调用方预先计算的值; +// 查询失败、为空或非法时按卡的运营商类型兜底(联通 27,其余 1)并记录日志。 +func (s *Service) strictResetDay(ctx context.Context, db *gorm.DB, card *model.IotCard) int { + var days []int + queryErr := db.WithContext(ctx).Model(&model.Carrier{}). + Where("id = ?", card.CarrierID).Limit(1).Pluck("data_reset_day", &days).Error + queried := 0 + if len(days) > 0 { + queried = days[0] + } + resetDay, fallback := cardflow.ResolveResetDay(queried, queryErr == nil && len(days) > 0, card.CarrierType) + if fallback { + s.warnLogger().Warn("通道上游流量重置日不可用,按运营商类型兜底", + zap.Uint("card_id", card.ID), zap.Uint("carrier_id", card.CarrierID), + zap.String("carrier_type", card.CarrierType), zap.Int("queried", queried), + zap.Int("reset_day", resetDay), zap.Error(queryErr)) + } + return resetDay +} + +// applySwitchedFlow 在观测事务内为已切换卡写快照、按月对账结算增量,并在第一次切换观测时写切换水位。 +// +// oldDecision 是卡表基线算法对本次观测的结果:只在第一次切换观测时沿用其增量,此后不再使用。 +func (s *Service) applySwitchedFlow(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, oldDecision domain.TrafficDecision) (*switchedFlowOutcome, error) { + readingMB := observation.GatewayReadingMB + snapshot, err := s.writeSnapshot(ctx, tx, card, readingMB, observation.Metadata.ObservedAt) + if err != nil { + return nil, err + } + days := postgres.NewCardFlowDayStore(tx) + monthMax, settled, err := days.MonthTotals(ctx, card.ID, snapshot.flowDate) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败") + } + hasPackage, pkgErr := hasActivePackageForCard(ctx, tx, card.ID) + if pkgErr != nil { + // 判断出错按有套餐处理,保持不丢量。 + s.warnLogger().Warn("判断卡是否有生效中套餐失败,按有套餐处理", zap.Uint("card_id", card.ID), zap.Error(pkgErr)) + hasPackage = true + } + outcome := &switchedFlowOutcome{MonthMaxMB: monthMax, StaleGuarded: snapshot.conversion.StaleGuarded} + switches := postgres.NewCardFlowSwitchStore(tx) + watermark, err := switches.Get(ctx, card.ID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡流量切换水位失败") + } + if watermark == nil { + return outcome, s.firstSwitch(ctx, tx, card, observation, oldDecision, snapshot.flowDate, monthMax, settled, hasPackage, outcome) + } + delta := cardflow.MonthlyDelta(monthMax, settled) + if delta <= 0 { + return outcome, nil + } + if err := days.AddSettled(ctx, card.ID, snapshot.flowDate, delta); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "结算卡流量增量失败") + } + if hasPackage { + outcome.EmitMB = delta + } + return outcome, nil +} + +// firstSwitch 处理第一次切换观测:本次沿用卡表基线算法的增量,此前部分记为已结算,并写入切换水位。 +func (s *Service) firstSwitch(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, + oldDecision domain.TrafficDecision, flowDate time.Time, monthMax, settled float64, hasPackage bool, outcome *switchedFlowOutcome) error { + outcome.FirstSwitch = true + if oldDecision.IncrementMB > 0 && hasPackage { + outcome.EmitMB = oldDecision.IncrementMB + } + if err := postgres.NewCardFlowDayStore(tx).AddSettled(ctx, card.ID, flowDate, cardflow.MonthlyDelta(monthMax, settled)); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "结算卡流量切换前用量失败") + } + if err := postgres.NewCardFlowSwitchStore(tx).Create(ctx, &model.CardFlowSwitch{ + IotCardID: card.ID, SwitchedAt: observation.Metadata.ObservedAt, MonthUsedMB: monthMax, + VendorUsedMB: observation.GatewayReadingMB, OldBaselineMB: card.LastGatewayReadingMB, + OldMonthUsageMB: card.CurrentMonthUsageMB, OldReadingAccepted: oldDecision.ReadingAccepted, + OldIncrementMB: oldDecision.IncrementMB, + }); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入卡流量切换水位失败") + } + return nil +} + +// writeUnswitchedSnapshot 在观测事务提交后为未切换卡写快照;失败只告警,不影响本次观测结果。 +func (s *Service) writeUnswitchedSnapshot(ctx context.Context, card *model.IotCard, observation domain.TrafficObservation) { + if card == nil || !s.trafficCalc.SnapshotEnabled() || !cardflow.ValidReading(observation.GatewayReadingMB) { + return + } + writeCtx, cancel := context.WithTimeout(ctx, unswitchedSnapshotTimeout) + defer cancel() + if _, err := s.writeSnapshot(writeCtx, s.db, card, observation.GatewayReadingMB, observation.Metadata.ObservedAt); err != nil { + s.warnLogger().Warn("未切换卡写日流量快照失败,不影响本次观测", zap.Uint("card_id", card.ID), zap.Error(err)) + } +} diff --git a/internal/application/carrierthreshold/evaluate.go b/internal/application/carrierthreshold/evaluate.go index 9c1c7ab4..e3c65228 100644 --- a/internal/application/carrierthreshold/evaluate.go +++ b/internal/application/carrierthreshold/evaluate.go @@ -14,8 +14,8 @@ import ( // EvaluateInTx 在流量观测事务内判定通道阈值,达量时写周期锁并同事务写停机事件。 // -// 判定前提由调用方保证:只有「读数被接受」(ReadingAccepted)的观测才进入本方法, -// 异常下降保护命中的观测不参与判定。重复达量观测由部分唯一索引的 23505 识别为 +// 判定前提由调用方保证:未切换日快照算法的卡只有「读数被接受」(ReadingAccepted)的观测才进入本方法, +// 异常下降保护命中的观测不参与判定;已切换的卡读数有效且未被重置日防旧值处理即进入本方法。重复达量观测由部分唯一索引的 23505 识别为 // 「该周期已处理」并幂等跳过:不重复建锁、不重复写停机事件,该冲突与流量基线 CAS 的 // CodeConflict 语义不同,绝不能混流。返回错误表示必须整体回滚(与既有流量事实同事务)。 func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation domain.Evaluation) error { diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index a23de14b..40cb7ee6 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -201,6 +201,12 @@ func initServices(s *stores, deps *Dependencies) *services { cardObservationService.SetStateAuditWriter(iotCard) // 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。 cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService) + // 卡流量计量切换策略:启动时展开灰度名单为内存卡集合,API 与全部 Worker 必须使用一致配置。 + var trafficCalcPolicy *cardObservationApp.TrafficCalcPolicy + if cfg := config.Get(); cfg != nil { + trafficCalcPolicy = cardObservationApp.BuildTrafficCalcPolicy(context.Background(), deps.DB, cfg.TrafficCalc, deps.Logger) + } + cardObservationService.SetTrafficCalcPolicy(trafficCalcPolicy, deps.Logger) iotCard.SetCardObservationService(cardObservationService) iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB)) seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis) @@ -550,7 +556,7 @@ func initServices(s *stores, deps *Dependencies) *services { AgentRechargePaymentConfirm: agentRechargePaymentConfirm, AgentRechargeVoucherOCR: agentRechargeVoucherOCR, PackageActivation: packageActivation, - TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage), + TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage).WithFlowSnapshots(trafficCalcPolicy.Switched, postgres.NewCardFlowDayStore(deps.DB)), OperationPassword: operationPassword, AgentOpenAPI: agentOpenAPI, Refund: refundService, diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 8e202630..19b43ea1 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -1,6 +1,10 @@ package bootstrap import ( + "context" + + "github.com/break/junhong_cmp_fiber/pkg/config" + assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold" @@ -120,6 +124,11 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q cardObservationService.SetStateAuditWriter(iotCardAuditService) // 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。 cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService) + // 卡流量计量切换策略:启动时展开灰度名单为内存卡集合,API 与全部 Worker 必须使用一致配置。 + if cfg := config.Get(); cfg != nil { + cardObservationService.SetTrafficCalcPolicy( + cardObservationApp.BuildTrafficCalcPolicy(context.Background(), deps.DB, cfg.TrafficCalc, deps.Logger), deps.Logger) + } cardObservationIntegration := integrationlog.NewRepository(deps.DB) cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis) cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService( diff --git a/internal/domain/cardflow/doc.go b/internal/domain/cardflow/doc.go new file mode 100644 index 00000000..43a860d1 --- /dev/null +++ b/internal/domain/cardflow/doc.go @@ -0,0 +1,5 @@ +// Package cardflow 提供卡日流量快照计量的纯领域规则:自然月换算、重置日防旧值、按月对账增量与每天用量。 +// +// 规则照搬老卡管:每卡每天一条「本月自然月累计」快照,任意区间用量 = 区间内最大快照 − 区间开始前最大快照。 +// 本包不依赖数据库、Redis 或外部 SDK,取值由调用方从快照存储查询后传入。 +package cardflow diff --git a/internal/domain/cardflow/rules.go b/internal/domain/cardflow/rules.go new file mode 100644 index 00000000..2e1c1acf --- /dev/null +++ b/internal/domain/cardflow/rules.go @@ -0,0 +1,126 @@ +package cardflow + +import ( + "math" + "strings" + "time" +) + +// 计量规则常量。 +const ( + // StaleGuardRatio 是重置日防旧值阈值:重置日读数超过比较基准的该倍数时按 0 计。 + StaleGuardRatio = 0.9 + // MinResetDay 与 MaxResetDay 是通道上游流量重置日的合法范围。 + MinResetDay = 1 + MaxResetDay = 28 + // FallbackResetDayCUCC 是联通卡重置日查询失败时的兜底值。 + FallbackResetDayCUCC = 27 + // FallbackResetDayDefault 是非联通卡重置日查询失败时的兜底值。 + FallbackResetDayDefault = 1 + // carrierTypeCUCC 是联通运营商类型编码。 + carrierTypeCUCC = "CUCC" +) + +// shanghaiLocation 是快照日期与周期边界的固定时区。 +var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +// ShanghaiDate 返回观测时刻对应的上海日期(当天 0 点)。 +func ShanghaiDate(t time.Time) time.Time { + local := t.In(shanghaiLocation) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation) +} + +// MonthStart 返回日期所在自然月 1 号(上海时区)。 +func MonthStart(date time.Time) time.Time { + local := date.In(shanghaiLocation) + return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, shanghaiLocation) +} + +// ResolveResetDay 返回可用的重置日;查询失败或取值非法时按运营商类型兜底,fallback 为 true 表示使用了兜底值。 +func ResolveResetDay(queried int, queryOK bool, carrierType string) (resetDay int, fallback bool) { + if queryOK && queried >= MinResetDay && queried <= MaxResetDay { + return queried, false + } + if strings.EqualFold(strings.TrimSpace(carrierType), carrierTypeCUCC) { + return FallbackResetDayCUCC, true + } + return FallbackResetDayDefault, true +} + +// MonthContext 是换算与防旧值需要的已有快照取值,均为自然月累计(MB)。 +type MonthContext struct { + // ThisMonthBeforeResetMax 是本月 1 号~重置日前一天的最大快照。 + ThisMonthBeforeResetMax float64 + // PrevMonthMax 是上月最大快照。 + PrevMonthMax float64 + // PrevMonthBeforeResetMax 是上月 1 号~重置日前一天的最大快照。 + PrevMonthBeforeResetMax float64 +} + +// Conversion 是一次读数换算为自然月累计的结果。 +type Conversion struct { + // MonthUsedMB 是换算后的本月自然月累计。 + MonthUsedMB float64 + // StaleGuarded 为 true 表示本次读数在重置日触发了防旧值、按 0 参与换算。 + StaleGuarded bool +} + +// Convert 把运营商周期读数换算为自然月累计,并在重置日当天应用防旧值。 +// +// 重置日为 1 时运营商周期即自然月;重置日大于 1 时:日号 ≥ 重置日累加本月重置日前的最大快照, +// 日号 < 重置日扣除上月重置日及以后的用量(上月最大 − 上月重置日前最大),结果小于 0 按 0。 +func Convert(readingMB float64, day int, resetDay int, ctx MonthContext) Conversion { + reading := readingMB + guarded := isStale(readingMB, day, resetDay, ctx) + if guarded { + reading = 0 + } + var month float64 + switch { + case resetDay <= 1: + month = reading + case day >= resetDay: + month = reading + ctx.ThisMonthBeforeResetMax + default: + month = reading - (ctx.PrevMonthMax - ctx.PrevMonthBeforeResetMax) + } + return Conversion{MonthUsedMB: nonNegative(round3(month)), StaleGuarded: guarded} +} + +// isStale 判断重置日当天的读数是否仍接近上周期总量。 +func isStale(readingMB float64, day int, resetDay int, ctx MonthContext) bool { + if day != resetDay || readingMB <= 0 { + return false + } + if resetDay <= 1 { + return readingMB > ctx.PrevMonthMax*StaleGuardRatio + } + return ctx.ThisMonthBeforeResetMax > 0 && readingMB > ctx.ThisMonthBeforeResetMax*StaleGuardRatio +} + +// MonthlyDelta 返回按月对账的待结算增量:本月最大快照 − 本月已结算合计,不大于 0 时返回 0。 +func MonthlyDelta(monthMaxMB float64, settledSumMB float64) float64 { + return nonNegative(round3(monthMaxMB - settledSumMB)) +} + +// DailyUsage 返回某天用量:当天最大快照 − 本月此前最大快照,小于 0 按 0;本月第一条快照时 priorMax 传 0。 +func DailyUsage(dayMaxMB float64, priorMaxMB float64) float64 { + return nonNegative(round3(dayMaxMB - priorMaxMB)) +} + +// ValidReading 判断运营商读数能否写入快照:必须为有限正数。 +func ValidReading(readingMB float64) bool { + return !math.IsNaN(readingMB) && !math.IsInf(readingMB, 0) && readingMB > 0 +} + +func nonNegative(v float64) float64 { + if v < 0 { + return 0 + } + return v +} + +// round3 按快照列精度(三位小数)取整,避免浮点误差产生极小的虚假增量。 +func round3(v float64) float64 { + return math.Round(v*1000) / 1000 +} diff --git a/internal/model/card_flow.go b/internal/model/card_flow.go new file mode 100644 index 00000000..d0a601fe --- /dev/null +++ b/internal/model/card_flow.go @@ -0,0 +1,44 @@ +package model + +import "time" + +// CardFlowDay 卡日流量快照:每卡每天一条本月自然月累计。 +// +// MonthUsedMB 按通道上游流量重置日换算为自然月累计,同一天只增不减;SettledMB 是当天已结算的增量合计 +// (发出的增量、无生效套餐时丢弃的增量、切换时视为老算法已结算的部分)。由观测链路原子写入,永久保留。 +type CardFlowDay struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;uniqueIndex:uk_card_flow_day_card_date" json:"iot_card_id"` + FlowDate time.Time `gorm:"column:flow_date;type:date;not null;uniqueIndex:uk_card_flow_day_card_date" json:"flow_date"` + MonthUsedMB float64 `gorm:"column:month_used_mb;type:numeric(14,3);not null;default:0" json:"month_used_mb"` + VendorUsedMB float64 `gorm:"column:vendor_used_mb;type:numeric(14,3);not null;default:0" json:"vendor_used_mb"` + SettledMB float64 `gorm:"column:settled_mb;type:numeric(14,3);not null;default:0" json:"settled_mb"` + StaleGuarded bool `gorm:"column:stale_guarded;not null;default:false" json:"stale_guarded"` + ObsCount int `gorm:"column:obs_count;not null;default:0" json:"obs_count"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (CardFlowDay) TableName() string { + return "tb_card_flow_day" +} + +// CardFlowSwitch 卡流量计量切换水位:卡第一次走新算法时写入,退回老算法时由维护者删除。 +type CardFlowSwitch struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + IotCardID uint `gorm:"column:iot_card_id;type:bigint;not null;uniqueIndex:uk_card_flow_switch_card" json:"iot_card_id"` + SwitchedAt time.Time `gorm:"column:switched_at;type:timestamptz;not null" json:"switched_at"` + MonthUsedMB float64 `gorm:"column:month_used_mb;type:numeric(14,3);not null;default:0" json:"month_used_mb"` + VendorUsedMB float64 `gorm:"column:vendor_used_mb;type:numeric(14,3);not null;default:0" json:"vendor_used_mb"` + OldBaselineMB float64 `gorm:"column:old_baseline_mb;type:numeric(14,3);not null;default:0" json:"old_baseline_mb"` + OldMonthUsageMB float64 `gorm:"column:old_month_usage_mb;type:numeric(14,3);not null;default:0" json:"old_month_usage_mb"` + OldReadingAccepted bool `gorm:"column:old_reading_accepted;not null;default:false" json:"old_reading_accepted"` + OldIncrementMB float64 `gorm:"column:old_increment_mb;type:numeric(14,3);not null;default:0" json:"old_increment_mb"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` +} + +// TableName 指定表名 +func (CardFlowSwitch) TableName() string { + return "tb_card_flow_switch" +} diff --git a/internal/model/carrier.go b/internal/model/carrier.go index 783d6722..692b7c7a 100644 --- a/internal/model/carrier.go +++ b/internal/model/carrier.go @@ -22,6 +22,9 @@ type Carrier struct { TrafficThresholdValue *float64 `gorm:"column:traffic_threshold_value;type:numeric(18,2);comment:通道流量阈值数值,单位由 traffic_threshold_unit 决定" json:"traffic_threshold_value"` // TrafficThresholdUnit 是阈值单位,取值 MB/GB(1 GB = 1024 MB),换算后与网关累计读数同单位比较;空字符串表示未配置。 TrafficThresholdUnit string `gorm:"column:traffic_threshold_unit;type:varchar(8);not null;default:'';comment:通道流量阈值单位 MB/GB,空字符串-未配置" json:"traffic_threshold_unit"` + // RealnameControlEnabled 是通道实名管控开关,取值 0-不管控(默认)1-管控;只用于停复机, + // 且只在停复机新规则总开关开启后参与判断,不复用 RealnameLinkType 等现有实名字段。 + RealnameControlEnabled int `gorm:"column:realname_control_enabled;type:smallint;not null;default:0;comment:通道实名管控 0-不管控 1-管控" json:"realname_control_enabled"` } // TableName 指定表名 diff --git a/internal/model/dto/carrier_dto.go b/internal/model/dto/carrier_dto.go index c6e8e989..290bf35d 100644 --- a/internal/model/dto/carrier_dto.go +++ b/internal/model/dto/carrier_dto.go @@ -13,15 +13,17 @@ type CreateCarrierRequest struct { TrafficThresholdEnabled *int `json:"traffic_threshold_enabled" validate:"omitempty,oneof=0 1" description:"通道流量阈值是否启用 (0:禁用, 1:启用),仅超级管理员与平台账号可配置"` TrafficThresholdValue *float64 `json:"traffic_threshold_value" validate:"omitempty,gt=0" description:"通道流量阈值数值,必须为正数,单位由 traffic_threshold_unit 决定,仅超级管理员与平台账号可配置"` TrafficThresholdUnit *string `json:"traffic_threshold_unit" validate:"omitempty,oneof=MB GB" description:"通道流量阈值单位 (MB:兆字节, GB:吉字节,1 GB = 1024 MB),仅超级管理员与平台账号可配置"` + RealnameControlEnabled *int `json:"realname_control_enabled" validate:"omitempty,oneof=0 1" description:"通道实名管控 (0:不管控, 1:管控),默认0;只在停复机新规则开启后影响停复机:管控时未实名卡停机、实名后才复机。仅超级管理员与平台账号可配置"` } -// HasTrafficThresholdFields 判断请求是否携带通道流量阈值字段。 -// 非超管、非平台账号提交携带阈值字段的请求必须被整体拒绝。 +// HasTrafficThresholdFields 判断请求是否携带仅平台账号可配置的通道字段(通道流量阈值、通道实名管控)。 +// 非超管、非平台账号提交携带这些字段的请求必须被整体拒绝。 func (r *CreateCarrierRequest) HasTrafficThresholdFields() bool { if r == nil { return false } - return r.TrafficThresholdEnabled != nil || r.TrafficThresholdValue != nil || r.TrafficThresholdUnit != nil + return r.TrafficThresholdEnabled != nil || r.TrafficThresholdValue != nil || r.TrafficThresholdUnit != nil || + r.RealnameControlEnabled != nil } type UpdateCarrierRequest struct { @@ -33,15 +35,18 @@ type UpdateCarrierRequest struct { TrafficThresholdEnabled *int `json:"traffic_threshold_enabled" validate:"omitempty,oneof=0 1" description:"通道流量阈值是否启用 (0:禁用, 1:启用),仅超级管理员与平台账号可配置"` TrafficThresholdValue *float64 `json:"traffic_threshold_value" validate:"omitempty,gt=0" description:"通道流量阈值数值,必须为正数,单位由 traffic_threshold_unit 决定,仅超级管理员与平台账号可配置"` TrafficThresholdUnit *string `json:"traffic_threshold_unit" validate:"omitempty,oneof=MB GB" description:"通道流量阈值单位 (MB:兆字节, GB:吉字节,1 GB = 1024 MB),仅超级管理员与平台账号可配置"` + RealnameControlEnabled *int `json:"realname_control_enabled" validate:"omitempty,oneof=0 1" description:"通道实名管控 (0:不管控, 1:管控),默认0;只在停复机新规则开启后影响停复机:管控时未实名卡停机、实名后才复机。仅超级管理员与平台账号可配置"` + RealnameControlConfirm *bool `json:"realname_control_confirm" description:"把实名管控从 0 改为 1 时必须传 true,确认该通道未实名的开机卡会在下一次评估时被停机"` } -// HasTrafficThresholdFields 判断请求是否携带通道流量阈值字段。 -// 非超管、非平台账号提交携带阈值字段的请求必须被整体拒绝。 +// HasTrafficThresholdFields 判断请求是否携带仅平台账号可配置的通道字段(通道流量阈值、通道实名管控)。 +// 非超管、非平台账号提交携带这些字段的请求必须被整体拒绝。 func (r *UpdateCarrierRequest) HasTrafficThresholdFields() bool { if r == nil { return false } - return r.TrafficThresholdEnabled != nil || r.TrafficThresholdValue != nil || r.TrafficThresholdUnit != nil + return r.TrafficThresholdEnabled != nil || r.TrafficThresholdValue != nil || r.TrafficThresholdUnit != nil || + r.RealnameControlEnabled != nil } type CarrierListRequest struct { @@ -70,6 +75,7 @@ type CarrierResponse struct { TrafficThresholdEnabled *int `json:"traffic_threshold_enabled,omitempty" description:"通道流量阈值是否启用 (0:禁用, 1:启用),仅超级管理员与平台账号可见"` TrafficThresholdValue *float64 `json:"traffic_threshold_value,omitempty" description:"通道流量阈值数值,单位由 traffic_threshold_unit 决定,未配置时不返回,仅超级管理员与平台账号可见"` TrafficThresholdUnit *string `json:"traffic_threshold_unit,omitempty" description:"通道流量阈值单位 (MB:兆字节, GB:吉字节,1 GB = 1024 MB),未配置时不返回,仅超级管理员与平台账号可见"` + RealnameControlEnabled *int `json:"realname_control_enabled,omitempty" description:"通道实名管控 (0:不管控, 1:管控),仅超级管理员与平台账号可见"` CreatedAt string `json:"created_at" description:"创建时间"` UpdatedAt string `json:"updated_at" description:"更新时间"` } diff --git a/internal/routes/carrier.go b/internal/routes/carrier.go index c3d4da48..ca7f4d73 100644 --- a/internal/routes/carrier.go +++ b/internal/routes/carrier.go @@ -70,10 +70,13 @@ func registerCarrierRoutes(router fiber.Router, handler *admin.CarrierHandler, d Register(carriers, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{ Summary: "更新运营商", - Tags: []string{"运营商管理"}, - Input: new(dto.UpdateCarrierParams), - Output: new(dto.CarrierResponse), - Auth: true, + Description: "通道流量阈值与通道实名管控(realname_control_enabled)仅超级管理员与平台账号可修改,非平台账号携带即整体拒绝;" + + "实名管控从 0 改为 1 时必须携带 realname_control_confirm=true,未确认时返回该通道未实名开机卡数且不保存;" + + "修改并入运营商更新审计", + Tags: []string{"运营商管理"}, + Input: new(dto.UpdateCarrierParams), + Output: new(dto.CarrierResponse), + Auth: true, }) Register(carriers, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{ diff --git a/internal/service/carrier/service.go b/internal/service/carrier/service.go index 53c810cf..5383b9e3 100644 --- a/internal/service/carrier/service.go +++ b/internal/service/carrier/service.go @@ -2,6 +2,7 @@ package carrier import ( "context" + "fmt" "strconv" "time" @@ -54,7 +55,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d // 阈值字段只由超级管理员与平台账号配置:非平台账号提交即整体拒绝,不落任何配置。 if req.HasTrafficThresholdFields() && !canManageThreshold(ctx) { - s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝非平台账号配置运营商通道流量阈值", + s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝非平台账号配置运营商通道流量阈值或实名管控", thresholdCreateProbe(req), errors.CodeForbidden) return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) } @@ -92,6 +93,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d TrafficThresholdValue: thresholdValue, TrafficThresholdUnit: thresholdUnit, } + if req.RealnameControlEnabled != nil { + carrier.RealnameControlEnabled = *req.RealnameControlEnabled + } if req.DataResetDay != nil { carrier.DataResetDay = *req.DataResetDay } @@ -145,7 +149,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq // 阈值字段只由超级管理员与平台账号修改:非平台账号提交即整体拒绝。 if req.HasTrafficThresholdFields() && !canManageThreshold(ctx) { - s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝非平台账号配置运营商通道流量阈值", + s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝非平台账号配置运营商通道流量阈值或实名管控", &before, errors.CodeForbidden) return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) } @@ -168,6 +172,12 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq carrier.TrafficThresholdEnabled = thresholdEnabled carrier.TrafficThresholdValue = thresholdValue carrier.TrafficThresholdUnit = thresholdUnit + if req.RealnameControlEnabled != nil { + if err := s.confirmRealnameControl(ctx, &before, *req.RealnameControlEnabled, req.RealnameControlConfirm); err != nil { + return nil, err + } + carrier.RealnameControlEnabled = *req.RealnameControlEnabled + } if req.CarrierName != nil { carrier.CarrierName = *req.CarrierName @@ -375,9 +385,31 @@ func carrierAuditSnapshot(carrier *model.Carrier) map[string]any { "traffic_threshold_enabled": carrier.TrafficThresholdEnabled, "traffic_threshold_value": carrier.TrafficThresholdValue, "traffic_threshold_unit": carrier.TrafficThresholdUnit, + // 通道实名管控的修改同样并入运营商更新审计。 + "realname_control_enabled": carrier.RealnameControlEnabled, } } +// confirmRealnameControl 在把通道实名管控从 0 改为 1 时要求显式确认: +// 未确认时返回该通道未实名开机卡数,提示这些卡会在下一次评估时被停机,本次不保存。 +func (s *Service) confirmRealnameControl(ctx context.Context, before *model.Carrier, next int, confirm *bool) error { + if before.RealnameControlEnabled == next || next != constants.StatusEnabled { + return nil + } + if confirm != nil && *confirm { + return nil + } + var count int64 + if err := s.carrierStore.DB().WithContext(ctx).Model(&model.IotCard{}). + Where("carrier_id = ? AND real_name_status <> ? AND network_status = ?", + before.ID, constants.RealNameStatusVerified, constants.NetworkStatusOnline). + Count(&count).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "统计通道未实名开机卡数失败") + } + return errors.New(errors.CodeInvalidParam, fmt.Sprintf( + "开启实名管控后,该通道 %d 张未实名的开机卡会在下一次评估时被停机;确认后请携带 realname_control_confirm=true 重新提交", count)) +} + // canManageThreshold 判断当前账号是否具备通道流量阈值字段的读写权限。 // 只有超级管理员与平台账号可见可写,与 requirePlatformManagement 的判定一致(引用同一用户类型枚举)。 func canManageThreshold(ctx context.Context) bool { @@ -438,6 +470,8 @@ func (s *Service) toResponse(ctx context.Context, c *model.Carrier) *dto.Carrier } enabled := c.TrafficThresholdEnabled result.TrafficThresholdEnabled = &enabled + realnameControl := c.RealnameControlEnabled + result.RealnameControlEnabled = &realnameControl result.TrafficThresholdValue = c.TrafficThresholdValue if c.TrafficThresholdUnit != "" { unit := c.TrafficThresholdUnit diff --git a/internal/service/iot_card/channel_threshold.go b/internal/service/iot_card/channel_threshold.go index de619d78..3a693e1f 100644 --- a/internal/service/iot_card/channel_threshold.go +++ b/internal/service/iot_card/channel_threshold.go @@ -107,12 +107,23 @@ func (s *StopResumeService) ChannelThresholdResumeReady(ctx context.Context, car if err != nil { return false, "", errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值复机判定卡事实失败") } - // 其他停机锁:风险网关扩展(风险停机/销户)或非通道阈值的停因。 - if isRiskGatewayExtend(card.GatewayExtend) { - return false, "网关扩展状态为风险停机或已销户,不自动复机", nil - } - if card.StopReason != "" && card.StopReason != constants.StopReasonChannelThreshold { - return false, "存在其他停机原因,不自动复机", nil + if unifiedStopResumeEnabled() { + // 总开关开启时不看其他停因与网关扩展状态,卡或所在设备暂停轮询时只解锁不复机。 + suspended, suspendErr := s.autoControlSuspended(ctx, card) + if suspendErr != nil { + return false, "", suspendErr + } + if suspended { + return false, "卡或所在设备已暂停轮询,不自动复机", nil + } + } else { + // 其他停机锁:风险网关扩展(风险停机/销户)或非通道阈值的停因。 + if isRiskGatewayExtend(card.GatewayExtend) { + return false, "网关扩展状态为风险停机或已销户,不自动复机", nil + } + if card.StopReason != "" && card.StopReason != constants.StopReasonChannelThreshold { + return false, "存在其他停机原因,不自动复机", nil + } } hasPackage, err := s.hasValidPackage(ctx, card) if err != nil { diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index 123ccce8..a46415f7 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -34,6 +34,8 @@ type StopResumeServiceInterface interface { ForceStopCard(ctx context.Context, card *model.IotCard, stopReason string) error // ForceStartCard 强制复机单张卡,不执行正常复机条件判断。 ForceStartCard(ctx context.Context, card *model.IotCard) error + // ProtectCheckSkipsForRealname 判定保护期一致性检查是否因实名跳过该卡。 + ProtectCheckSkipsForRealname(ctx context.Context, card *model.IotCard) (bool, error) } // 编译时验证 StopResumeService 实现了 StopResumeServiceInterface @@ -109,6 +111,14 @@ func (s *StopResumeService) SetPollingCallback(callback PollingCallback) { // 根据卡的当前网络状态,自动判断并执行停机或复机操作 // 设备/单卡维度通过 card.IsStandalone 推导(false 则为设备维度,停/复机覆盖设备下所有卡) func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotCard) error { + // 总开关开启时,卡或所在设备暂停轮询的不自动停复机。 + suspended, err := s.autoControlSuspended(ctx, card) + if err != nil { + return err + } + if suspended { + return nil + } switch card.NetworkStatus { case constants.NetworkStatusOnline: // 卡在线,检查是否需要停机 @@ -142,8 +152,8 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC case constants.NetworkStatusOffline: // 卡停机,检查是否可以复机 - // 只处理轮询系统引起的停机原因(手动停机等不自动复机) - if !isPollingStopReason(card.StopReason) { + // 总开关关闭时只处理轮询系统引起的停机原因(手动停机等不自动复机);开启时不看停机原因 + if !autoResumableStopReason(card.StopReason) { return nil } shouldResume, err := s.shouldResume(ctx, card) @@ -318,6 +328,10 @@ func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model. // isRealnameOK 检查卡是否满足实名要求 // 行业卡、已实名卡直接放行;其他卡按有效设备或自身的无需实名策略判断。 func (s *StopResumeService) isRealnameOK(ctx context.Context, card *model.IotCard) (bool, error) { + // 总开关开启时实名只按卡所属通道的实名管控判断,不区分行业卡、不看卡或设备实名策略。 + if unifiedStopResumeEnabled() { + return s.unifiedRealnameOK(ctx, card) + } if card.CardCategory == constants.CardCategoryIndustry || card.RealNameStatus == constants.RealNameStatusVerified { return true, nil } @@ -420,6 +434,13 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, var cardErrors []error for _, card := range cards { + // 总开关开启时,暂停轮询的卡不随设备自动停机。 + if suspended, suspendErr := s.autoControlSuspended(ctx, card); suspendErr != nil { + cardErrors = append(cardErrors, suspendErr) + continue + } else if suspended { + continue + } if _, stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil { cardErrors = append(cardErrors, stopErr) s.logger.Warn("设备卡停机失败,继续处理其他卡", @@ -450,8 +471,14 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint } defer s.redis.Del(ctx, lockKey) - // 查询设备下因轮询原因停机的卡 - cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, deviceID) + // 查询设备下待复机的卡:总开关关闭时只取因轮询原因停机的卡,开启时取全部停机卡(不看停机原因) + var cards []*model.IotCard + var err error + if unifiedStopResumeEnabled() { + cards, err = s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOffline) + } else { + cards, err = s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, deviceID) + } if err != nil { s.logger.Error("查询设备停机卡失败", zap.Uint("device_id", deviceID), zap.Error(err)) @@ -569,6 +596,15 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络" + // 总开关开启时,卡或所在设备暂停轮询的不自动复机(含套餐激活、重置、充值等非轮询触发)。 + suspended, suspendErr := s.autoControlSuspended(ctx, card) + if suspendErr != nil { + return suspendErr + } + if suspended { + return nil + } + // 持通道阈值锁:周期内拒绝一切复机,记录拒绝事实并保留锁。 // 该入口被轮询评估、套餐激活/重置与设备维度复机复用,返回 nil 表示本次不动作, // 避免让自动链路按失败重试;用户可见入口由 ManualStartCard/ForceStartCard 返回拒绝错误。 @@ -587,8 +623,8 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e return nil } - // 只处理轮询系统引起的停机 - if !isPollingStopReason(card.StopReason) { + // 总开关关闭时只处理轮询系统引起的停机;开启时不看停机原因 + if !autoResumableStopReason(card.StopReason) { s.logger.Debug("卡非轮询停机原因,不自动复机", zap.Uint("card_id", cardID), zap.String("stop_reason", card.StopReason)) diff --git a/internal/service/iot_card/stop_resume_unified.go b/internal/service/iot_card/stop_resume_unified.go new file mode 100644 index 00000000..bb4bd117 --- /dev/null +++ b/internal/service/iot_card/stop_resume_unified.go @@ -0,0 +1,98 @@ +package iot_card + +import ( + "context" + stderrors "errors" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// realnameControlEnabled 是通道实名管控开启时的取值。 +const realnameControlEnabled = 1 + +// unifiedStopResumeEnabled 判定停复机新规则总开关是否开启。 +// +// 开启后:复机不看停机原因;实名按卡所属通道的实名管控判断(不区分行业卡、不看卡/设备实名策略); +// 卡或所在设备暂停轮询时不自动停复机。关闭时全部停复机路径与原有行为一致。 +func unifiedStopResumeEnabled() bool { + cfg := config.Get() + return cfg != nil && cfg.StopResume.UnifiedRuleEnabled +} + +// autoResumableStopReason 判定该停因的停机卡能否进入自动复机判断:总开关开启时不看停因, +// 关闭时只处理轮询系统引起的停机。 +func autoResumableStopReason(reason string) bool { + return unifiedStopResumeEnabled() || isPollingStopReason(reason) +} + +// autoControlSuspended 判定总开关开启时该卡是否因暂停轮询而不自动停复机:卡自身或其有效绑定设备暂停轮询。 +// 总开关关闭时恒为 false。 +func (s *StopResumeService) autoControlSuspended(ctx context.Context, card *model.IotCard) (bool, error) { + if !unifiedStopResumeEnabled() || card == nil { + return false, nil + } + if !card.EnablePolling { + return true, nil + } + deviceID, bound, err := s.getCardDeviceID(ctx, card) + if err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败") + } + if !bound { + return false, nil + } + var device model.Device + if err := s.db.WithContext(ctx).Select("id", "enable_polling").First(&device, deviceID).Error; err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备轮询状态失败") + } + return !device.EnablePolling, nil +} + +// channelRealnameControlled 查询卡所属通道是否开启实名管控。 +func (s *StopResumeService) channelRealnameControlled(ctx context.Context, card *model.IotCard) (bool, error) { + var values []int + if err := s.db.WithContext(ctx).Model(&model.Carrier{}). + Where("id = ?", card.CarrierID).Limit(1).Pluck("realname_control_enabled", &values).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询通道实名管控失败") + } + if len(values) == 0 { + return false, errors.New(errors.CodeNotFound, "卡所属运营商通道不存在,无法判断实名管控") + } + return values[0] == realnameControlEnabled, nil +} + +// unifiedRealnameOK 是总开关开启后的实名门槛:通道未开启实名管控即满足;开启时只看卡是否已实名。 +func (s *StopResumeService) unifiedRealnameOK(ctx context.Context, card *model.IotCard) (bool, error) { + controlled, err := s.channelRealnameControlled(ctx, card) + if err != nil { + return false, err + } + if !controlled { + return true, nil + } + return card.RealNameStatus == constants.RealNameStatusVerified, nil +} + +// ProtectCheckSkipsForRealname 判定保护期一致性检查是否因实名跳过该卡。 +// 总开关关闭时与原有行为一致(未实名即跳过);开启时只在通道开启实名管控且卡未实名时跳过。 +func (s *StopResumeService) ProtectCheckSkipsForRealname(ctx context.Context, card *model.IotCard) (bool, error) { + if card.RealNameStatus == constants.RealNameStatusVerified { + return false, nil + } + if !unifiedStopResumeEnabled() { + return true, nil + } + controlled, err := s.channelRealnameControlled(ctx, card) + if err != nil { + return false, err + } + return controlled, nil +} diff --git a/internal/service/traffic/query_service.go b/internal/service/traffic/query_service.go index e901f3f0..85a3ef6c 100644 --- a/internal/service/traffic/query_service.go +++ b/internal/service/traffic/query_service.go @@ -8,6 +8,7 @@ import ( "strconv" "time" + cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/redis/go-redis/v9" @@ -20,10 +21,46 @@ type DailyUsageItem struct { } // QueryService 流量查询服务 -// 合并 Redis(当天实时)和 DB(历史落盘)两段数据源 +// 未切换日快照算法的卡合并 Redis(当天实时)和 DB(历史落盘)两段数据源;已切换的卡读日流量快照。 type QueryService struct { redis *redis.Client cardDailyUsageStore *postgres.CardDailyUsageStore + switched func(cardID uint) bool + flowDays *postgres.CardFlowDayStore +} + +// WithFlowSnapshots 注入日快照数据源:switched 判定卡是否已切换日快照算法,已切换的卡改读快照。 +func (s *QueryService) WithFlowSnapshots(switched func(cardID uint) bool, flowDays *postgres.CardFlowDayStore) *QueryService { + s.switched = switched + s.flowDays = flowDays + return s +} + +// dailyUsageFromSnapshots 按快照计算每天用量:当天最大快照 − 本月此前最大快照,小于 0 按 0,跨月重新累计。 +func (s *QueryService) dailyUsageFromSnapshots(ctx context.Context, cardID uint, startDate, endDate time.Time) ([]*DailyUsageItem, error) { + start := cardflow.ShanghaiDate(startDate) + end := cardflow.ShanghaiDate(endDate) + rows, err := s.flowDays.ListBetween(ctx, cardID, cardflow.MonthStart(start), end) + if err != nil { + return nil, err + } + items := make([]*DailyUsageItem, 0, len(rows)) + var month string + var priorMax float64 + for _, row := range rows { + date := row.FlowDate.Format("2006-01-02") + if rowMonth := date[:7]; rowMonth != month { + month, priorMax = rowMonth, 0 + } + usage := cardflow.DailyUsage(row.MonthUsedMB, priorMax) + if row.MonthUsedMB > priorMax { + priorMax = row.MonthUsedMB + } + if date >= start.Format("2006-01-02") { + items = append(items, &DailyUsageItem{Date: date, UsageMB: usage}) + } + } + return items, nil } // NewQueryService 创建流量查询服务实例 @@ -37,6 +74,9 @@ func NewQueryService(redisClient *redis.Client, cardDailyUsageStore *postgres.Ca // GetDailyUsage 查询卡在指定日期范围内的日粒度流量 // 今天的数据从 Redis 缓冲读取(可能尚未落盘),历史数据从 DB 读取,合并后按日期 ASC 排序返回 func (s *QueryService) GetDailyUsage(ctx context.Context, cardID uint, startDate, endDate time.Time) ([]*DailyUsageItem, error) { + if s.switched != nil && s.flowDays != nil && s.switched(cardID) { + return s.dailyUsageFromSnapshots(ctx, cardID, startDate, endDate) + } now := time.Now() todayStr := now.Format("2006-01-02") diff --git a/internal/store/postgres/card_flow_day_store.go b/internal/store/postgres/card_flow_day_store.go new file mode 100644 index 00000000..7b598221 --- /dev/null +++ b/internal/store/postgres/card_flow_day_store.go @@ -0,0 +1,130 @@ +package postgres + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// cardFlowDateLayout 是快照日期参数格式,按上海日期字符串传给数据库,避免时区换算。 +const cardFlowDateLayout = "2006-01-02" + +// CardFlowDayStore 卡日流量快照存储。 +type CardFlowDayStore struct { + db *gorm.DB +} + +// NewCardFlowDayStore 创建卡日流量快照存储。 +func NewCardFlowDayStore(db *gorm.DB) *CardFlowDayStore { + return &CardFlowDayStore{db: db} +} + +// WithTx 返回复用当前事务的快照存储。 +func (s *CardFlowDayStore) WithTx(tx *gorm.DB) *CardFlowDayStore { + return &CardFlowDayStore{db: tx} +} + +// CardFlowSnapshotWrite 是一次快照写入的取值。 +type CardFlowSnapshotWrite struct { + CardID uint + FlowDate time.Time + MonthUsedMB float64 + VendorUsedMB float64 + StaleGuarded bool +} + +// Upsert 原子写入当天快照:本月累计取已有值与本次值中的较大者,防旧值标记只要当天触发过就保留。 +func (s *CardFlowDayStore) Upsert(ctx context.Context, write CardFlowSnapshotWrite) error { + return s.db.WithContext(ctx).Exec(` +INSERT INTO tb_card_flow_day (iot_card_id, flow_date, month_used_mb, vendor_used_mb, stale_guarded, obs_count, created_at, updated_at) +VALUES (?, ?::date, ?, ?, ?, 1, NOW(), NOW()) +ON CONFLICT (iot_card_id, flow_date) DO UPDATE SET + month_used_mb = GREATEST(tb_card_flow_day.month_used_mb, EXCLUDED.month_used_mb), + vendor_used_mb = EXCLUDED.vendor_used_mb, + stale_guarded = tb_card_flow_day.stale_guarded OR EXCLUDED.stale_guarded, + obs_count = tb_card_flow_day.obs_count + 1, + updated_at = NOW()`, + write.CardID, write.FlowDate.Format(cardFlowDateLayout), write.MonthUsedMB, write.VendorUsedMB, write.StaleGuarded, + ).Error +} + +// MaxBetween 返回 [from, to] 日期闭区间内的最大本月累计,没有快照时返回 0。 +func (s *CardFlowDayStore) MaxBetween(ctx context.Context, cardID uint, from, to time.Time) (float64, error) { + if to.Before(from) { + return 0, nil + } + var max float64 + err := s.db.WithContext(ctx).Model(&model.CardFlowDay{}). + Select("COALESCE(MAX(month_used_mb), 0)"). + Where("iot_card_id = ? AND flow_date >= ?::date AND flow_date <= ?::date", + cardID, from.Format(cardFlowDateLayout), to.Format(cardFlowDateLayout)). + Scan(&max).Error + return max, err +} + +// SettledBetween 返回 [from, to] 日期闭区间内的已结算合计。 +func (s *CardFlowDayStore) SettledBetween(ctx context.Context, cardID uint, from, to time.Time) (float64, error) { + var sum float64 + err := s.db.WithContext(ctx).Model(&model.CardFlowDay{}). + Select("COALESCE(SUM(settled_mb), 0)"). + Where("iot_card_id = ? AND flow_date >= ?::date AND flow_date <= ?::date", + cardID, from.Format(cardFlowDateLayout), to.Format(cardFlowDateLayout)). + Scan(&sum).Error + return sum, err +} + +// AddSettled 把增量计入当天已结算;当天快照行必须已由 Upsert 写入。 +func (s *CardFlowDayStore) AddSettled(ctx context.Context, cardID uint, flowDate time.Time, deltaMB float64) error { + if deltaMB <= 0 { + return nil + } + return s.db.WithContext(ctx).Exec( + "UPDATE tb_card_flow_day SET settled_mb = settled_mb + ?, updated_at = NOW() WHERE iot_card_id = ? AND flow_date = ?::date", + deltaMB, cardID, flowDate.Format(cardFlowDateLayout), + ).Error +} + +// MonthContext 查询换算与防旧值需要的已有快照取值:本月重置日前最大、上月最大与上月重置日前最大。 +func (s *CardFlowDayStore) MonthContext(ctx context.Context, cardID uint, flowDate time.Time, resetDay int) (cardflow.MonthContext, error) { + monthStart := cardflow.MonthStart(flowDate) + prevStart := monthStart.AddDate(0, -1, 0) + prevEnd := monthStart.AddDate(0, 0, -1) + var result cardflow.MonthContext + var err error + if result.PrevMonthMax, err = s.MaxBetween(ctx, cardID, prevStart, prevEnd); err != nil { + return result, err + } + if resetDay <= 1 { + return result, nil + } + if result.ThisMonthBeforeResetMax, err = s.MaxBetween(ctx, cardID, monthStart, monthStart.AddDate(0, 0, resetDay-2)); err != nil { + return result, err + } + result.PrevMonthBeforeResetMax, err = s.MaxBetween(ctx, cardID, prevStart, prevStart.AddDate(0, 0, resetDay-2)) + return result, err +} + +// MonthTotals 返回截至 flowDate 的本月最大快照与本月已结算合计,用于按月对账。 +func (s *CardFlowDayStore) MonthTotals(ctx context.Context, cardID uint, flowDate time.Time) (monthMax float64, settled float64, err error) { + monthStart := cardflow.MonthStart(flowDate) + monthEnd := monthStart.AddDate(0, 1, -1) + if monthMax, err = s.MaxBetween(ctx, cardID, monthStart, monthEnd); err != nil { + return 0, 0, err + } + settled, err = s.SettledBetween(ctx, cardID, monthStart, monthEnd) + return monthMax, settled, err +} + +// ListBetween 返回 [from, to] 日期闭区间内的快照,按日期升序。 +func (s *CardFlowDayStore) ListBetween(ctx context.Context, cardID uint, from, to time.Time) ([]model.CardFlowDay, error) { + var rows []model.CardFlowDay + err := s.db.WithContext(ctx). + Where("iot_card_id = ? AND flow_date >= ?::date AND flow_date <= ?::date", + cardID, from.Format(cardFlowDateLayout), to.Format(cardFlowDateLayout)). + Order("flow_date ASC").Find(&rows).Error + return rows, err +} diff --git a/internal/store/postgres/card_flow_switch_store.go b/internal/store/postgres/card_flow_switch_store.go new file mode 100644 index 00000000..ccee2f65 --- /dev/null +++ b/internal/store/postgres/card_flow_switch_store.go @@ -0,0 +1,43 @@ +package postgres + +import ( + "context" + stderrors "errors" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// CardFlowSwitchStore 卡流量计量切换水位存储。 +type CardFlowSwitchStore struct { + db *gorm.DB +} + +// NewCardFlowSwitchStore 创建切换水位存储。 +func NewCardFlowSwitchStore(db *gorm.DB) *CardFlowSwitchStore { + return &CardFlowSwitchStore{db: db} +} + +// WithTx 返回复用当前事务的切换水位存储。 +func (s *CardFlowSwitchStore) WithTx(tx *gorm.DB) *CardFlowSwitchStore { + return &CardFlowSwitchStore{db: tx} +} + +// Get 返回卡的切换水位;不存在时返回 nil、nil。 +func (s *CardFlowSwitchStore) Get(ctx context.Context, cardID uint) (*model.CardFlowSwitch, error) { + var row model.CardFlowSwitch + err := s.db.WithContext(ctx).Where("iot_card_id = ?", cardID).First(&row).Error + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +// Create 写入切换水位;同卡已存在时由唯一索引拒绝,调用方随事务回滚后重试。 +func (s *CardFlowSwitchStore) Create(ctx context.Context, row *model.CardFlowSwitch) error { + return s.db.WithContext(ctx).Create(row).Error +} diff --git a/internal/task/polling_protect_handler.go b/internal/task/polling_protect_handler.go index b0e26029..520e6fa0 100644 --- a/internal/task/polling_protect_handler.go +++ b/internal/task/polling_protect_handler.go @@ -74,8 +74,17 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) } - // 未实名卡跳过保护期检查 - if card.RealNameStatus != constants.RealNameStatusVerified { + // 实名不满足的卡跳过保护期检查:停复机新规则总开关关闭时未实名即跳过,开启时只在通道开启实名管控且未实名时跳过 + if h.stopResumeSvc != nil { + skip, skipErr := h.stopResumeSvc.ProtectCheckSkipsForRealname(ctx, card) + if skipErr != nil { + h.base.logger.Warn("保护期检查判断实名管控失败,本次跳过", zap.Uint("card_id", cardID), zap.Error(skipErr)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) + } + if skip { + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) + } + } else if card.RealNameStatus != constants.RealNameStatusVerified { return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) } diff --git a/migrations/000241_add_card_flow_day_and_switch.down.sql b/migrations/000241_add_card_flow_day_and_switch.down.sql new file mode 100644 index 00000000..7026982d --- /dev/null +++ b/migrations/000241_add_card_flow_day_and_switch.down.sql @@ -0,0 +1,8 @@ +-- 回滚卡日快照与切换水位:删除 tb_card_flow_switch 与 tb_card_flow_day,与 up 严格倒序成对。 +-- 不可逆说明(ENG-MIG-001 例外条件):快照与切换水位删除后无法由数据库重建;只在计量模式已退回 off、 +-- 且无需按快照复算或补数据时执行。删除后老算法(卡表基线)照常运行,不受影响。 + +DROP INDEX IF EXISTS uk_card_flow_switch_card; +DROP TABLE IF EXISTS tb_card_flow_switch; +DROP INDEX IF EXISTS uk_card_flow_day_card_date; +DROP TABLE IF EXISTS tb_card_flow_day; diff --git a/migrations/000241_add_card_flow_day_and_switch.up.sql b/migrations/000241_add_card_flow_day_and_switch.up.sql new file mode 100644 index 00000000..25b4d4df --- /dev/null +++ b/migrations/000241_add_card_flow_day_and_switch.up.sql @@ -0,0 +1,60 @@ +-- 卡流量计量改为日快照:新增卡日快照表与切换水位表。 +-- 背景:现有增量按「运营商读数 − tb_iot_card.last_gateway_reading_mb」计算,基线一旦被异常读数抬高 +-- 就会一直错下去(卡冻结、读数为 0 重复计、二次销售多计)。新算法照搬老卡管:每卡每天一条「本月自然月累计」 +-- 快照,增量 = 本月最大快照 − 本月已结算合计。详见 docs/卡流量计算技术方案-参考老卡管.md。 +-- +-- 设计选择: +-- 1. tb_card_flow_day 每卡每天一行,唯一键 (iot_card_id, flow_date),由应用层 INSERT ... ON CONFLICT 原子写入, +-- month_used_mb 只增不减;系统写入,不软删、不记 creator/updater,永久保留。 +-- 2. settled_mb 记录当天已结算增量合计:发出的增量、无生效套餐时丢弃的增量、切换时视为老算法已结算的部分。 +-- 3. tb_card_flow_switch 每卡一行,卡第一次走新算法时写入;退回老算法时由维护者删除,再次切换时重新写入。 +-- 4. 不使用数据库外键;iot_card_id 由应用层显式校验(ENG-MODEL-001)。 + +CREATE TABLE tb_card_flow_day ( + id BIGSERIAL PRIMARY KEY, + iot_card_id BIGINT NOT NULL, + flow_date DATE NOT NULL, + month_used_mb NUMERIC(14,3) NOT NULL DEFAULT 0, + vendor_used_mb NUMERIC(14,3) NOT NULL DEFAULT 0, + settled_mb NUMERIC(14,3) NOT NULL DEFAULT 0, + stale_guarded BOOLEAN NOT NULL DEFAULT FALSE, + obs_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uk_card_flow_day_card_date ON tb_card_flow_day (iot_card_id, flow_date); + +COMMENT ON TABLE tb_card_flow_day IS '卡日流量快照:每卡每天一条本月自然月累计'; +COMMENT ON COLUMN tb_card_flow_day.iot_card_id IS 'IoT卡ID'; +COMMENT ON COLUMN tb_card_flow_day.flow_date IS '快照日期(上海时区)'; +COMMENT ON COLUMN tb_card_flow_day.month_used_mb IS '本月自然月累计流量(MB),按通道上游流量重置日换算,只增不减'; +COMMENT ON COLUMN tb_card_flow_day.vendor_used_mb IS '当天最后一次有效的运营商原始读数(MB),排障用'; +COMMENT ON COLUMN tb_card_flow_day.settled_mb IS '当天已结算增量合计(MB)'; +COMMENT ON COLUMN tb_card_flow_day.stale_guarded IS '重置日当天是否触发过防旧值(读数按 0 记)'; +COMMENT ON COLUMN tb_card_flow_day.obs_count IS '当天有效观测次数'; + +CREATE TABLE tb_card_flow_switch ( + id BIGSERIAL PRIMARY KEY, + iot_card_id BIGINT NOT NULL, + switched_at TIMESTAMPTZ NOT NULL, + month_used_mb NUMERIC(14,3) NOT NULL DEFAULT 0, + vendor_used_mb NUMERIC(14,3) NOT NULL DEFAULT 0, + old_baseline_mb NUMERIC(14,3) NOT NULL DEFAULT 0, + old_month_usage_mb NUMERIC(14,3) NOT NULL DEFAULT 0, + old_reading_accepted BOOLEAN NOT NULL DEFAULT FALSE, + old_increment_mb NUMERIC(14,3) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uk_card_flow_switch_card ON tb_card_flow_switch (iot_card_id); + +COMMENT ON TABLE tb_card_flow_switch IS '卡流量计量切换水位:卡第一次走新算法时的衔接点'; +COMMENT ON COLUMN tb_card_flow_switch.iot_card_id IS 'IoT卡ID'; +COMMENT ON COLUMN tb_card_flow_switch.switched_at IS '第一次走新算法的观测时刻'; +COMMENT ON COLUMN tb_card_flow_switch.month_used_mb IS '切换时本月最大快照(MB)'; +COMMENT ON COLUMN tb_card_flow_switch.vendor_used_mb IS '切换时运营商读数(MB)'; +COMMENT ON COLUMN tb_card_flow_switch.old_baseline_mb IS '切换时卡表 last_gateway_reading_mb(MB)'; +COMMENT ON COLUMN tb_card_flow_switch.old_month_usage_mb IS '切换时卡表 current_month_usage_mb(MB)'; +COMMENT ON COLUMN tb_card_flow_switch.old_reading_accepted IS '切换这次观测基线算法是否接受读数(冻结卡为 false)'; +COMMENT ON COLUMN tb_card_flow_switch.old_increment_mb IS '切换这次观测基线算法算出的增量(MB)'; diff --git a/migrations/000242_add_carrier_realname_control.down.sql b/migrations/000242_add_carrier_realname_control.down.sql new file mode 100644 index 00000000..a19e64cb --- /dev/null +++ b/migrations/000242_add_carrier_realname_control.down.sql @@ -0,0 +1,5 @@ +-- 回滚通道实名管控开关:删除约束与 tb_carrier.realname_control_enabled,与 up 严格倒序成对。 +-- 不可逆说明(ENG-MIG-001 例外条件):各通道的实名管控配置删除后只能依据 tb_audit_event 中通道更新审计的前后值人工恢复。 + +ALTER TABLE tb_carrier DROP CONSTRAINT IF EXISTS ck_carrier_realname_control_enabled; +ALTER TABLE tb_carrier DROP COLUMN IF EXISTS realname_control_enabled; diff --git a/migrations/000242_add_carrier_realname_control.up.sql b/migrations/000242_add_carrier_realname_control.up.sql new file mode 100644 index 00000000..302c4278 --- /dev/null +++ b/migrations/000242_add_carrier_realname_control.up.sql @@ -0,0 +1,12 @@ +-- 通道实名管控开关:tb_carrier 增加 realname_control_enabled。 +-- 背景:停复机的实名门槛原来默认生效,并与行业卡、卡/设备实名策略耦合,想放开只能逐张改实名策略。 +-- 新规则(停复机新规则总开关开启后)改为按通道配置:0 不管控(默认),1 管控(未实名停机、实名后才复机)。 +-- 本列只用于停复机,不复用 realname_link_type 等现有实名字段;存量通道全部为 0,不迁移、不修改任何卡或设备数据。 + +ALTER TABLE tb_carrier + ADD COLUMN realname_control_enabled SMALLINT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_carrier.realname_control_enabled IS '通道实名管控 0-不管控 1-管控;只在停复机新规则总开关开启后参与停复机判断'; + +ALTER TABLE tb_carrier + ADD CONSTRAINT ck_carrier_realname_control_enabled CHECK (realname_control_enabled IN (0, 1)); diff --git a/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/.openspec.yaml b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/.openspec.yaml new file mode 100644 index 00000000..9076a660 --- /dev/null +++ b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-20 diff --git a/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/design.md b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/design.md new file mode 100644 index 00000000..8b911f53 --- /dev/null +++ b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/design.md @@ -0,0 +1,80 @@ +## Context + +完整推导、老卡管反编译依据与生产核实数据见 `docs/卡流量计算技术方案-参考老卡管.md`(第 2 版)。本文只记录实现层的关键决策。 + +现状: + +- `internal/domain/cardobservation/traffic.go` 的 `ApplyTraffic` 用"读数 − `last_gateway_reading_mb`"算增量,读数下降只在重置日当天或前一天被当作清零,其余一律拒绝;`internal/application/cardobservation/traffic.go` 在同一事务里锁卡行、CAS 更新卡表、正增量写 V1 Outbox,读数被接受时才判定通道阈值(第 84 行)。 +- V1 消费者(`internal/infrastructure/cardobservation/event.go`)调 `DeductDataUsage`;没有生效中套餐时报"没有可用套餐"并由 Asynq 重试。 +- 停复机集中在 `internal/service/iot_card/stop_resume_service.go`:自动复机只认 `traffic_exhausted`、`no_package`、`not_realname`;实名门槛 `isRealnameOK` 放行行业卡、已实名卡和实际生效策略为 `none` 的卡。停复机判断只读套餐用量与状态,不读卡表用量字段。 +- 卡表 `current_month_usage_mb` 被资产详情、卡列表、设备导出直接读取。 +- 约束:套餐侧、V1 消费者、Gateway 与三个观测入口不改;自动化测试为 N/A,验证在维护者指定的测试环境进行;生产迁移由维护者手工执行。 + +## Goals / Non-Goals + +**Goals:** + +- 已切换的卡不再依赖卡表基线计量,冻结、读数为 0 重复计、二次销售多计、无套餐积压不再新增。 +- 切换可灰度、可退回,不重不漏;未切换的卡行为不变。 +- 停复机按"停机看套餐、流量、通道实名管控;复机看套餐、流量、实名,不看停因"的统一规则执行,由总开关控制上线时机。 + +**Non-Goals:** + +- 冻结卡历史少扣的补数据脚本(后期另立 Change,依赖本 Change 的切换水位)。 +- 积压"处理中"回执的处理(业务手动,处理前不得重放)。 +- 套餐扣减口径、加油包顺序、虚流量阈值、`DeductDataUsage` 及消费者的任何改动。 +- 卡级、设备级实名管控字段;按运营商扩展状态自动暂停轮询。 +- Gateway 按日期区间查询(电信读数为 0 的问题本期不解决)。 + +## Decisions + +**D1 计量照搬老卡管日快照,而不是修补基线规则。** 基线是一个可被任意一次异常读数永久污染的单值状态;日快照取最大值做差,单次异常只影响当月,且可按快照复算。替代方案"放宽读数下降判定"仍依赖基线,无法根治二次销售与冻结。 + +**D2 增量按月对账(本月最大快照 − 本月已结算合计),不按天对账。** 按天对账遇到跨零点的迟到观测会重复扣;按月对账与观测先后无关,且已结算合计同时承载"发出的增量"、"无套餐丢弃的增量"和"切换时视为老算法已结算的部分"。 + +**D3 新逻辑放在 `ApplyTrafficObservation` 的已切换分支内,同一事务。** 卡行已在事务开头加锁,同卡观测天然串行,快照不另加行锁;快照、已结算、切换水位、Outbox 同事务提交。老 `ApplyTraffic` 照常计算并写卡表三列(`last_gateway_reading_mb` 仍是 CAS 条件,新代码只读),只是已切换卡不使用它的增量(第一次切换观测除外)。未切换卡在事务提交后用 `INSERT ... ON CONFLICT DO UPDATE SET month_used_mb = GREATEST(...)` 原子写快照,失败只告警。 + +**D4 切换水位表,而不是要求阶段 1 覆盖整月。** 平均每卡约 1.5 天才采一次,阶段 1 无法保证每张卡本月都有快照;水位让第一次切换观测沿用老增量并把此前部分记为已结算,同时为补数据提供截断点与冻结卡判定依据(切换后卡表本月计入仍由老逻辑维护,不能用来判定冻结卡)。退回时删除水位,再切回会重新执行。 + +**D5 无生效中套餐时在观测事务内只读判断并丢弃增量。** 与 `resolveActivePackages` 同口径(卡自身或当前绑定设备的 status=1 套餐),不改消费者;判断出错按有套餐处理,保持不丢量。 + +**D6 重置日在事务内按卡的 `carrier_id` 严格读取。** 入口传入的 `ResetDay` 在查询失败时已被兜底为 1,联通卡被当作 1 号重置会抬高自然月累计且按最大值规则收不回;失败时按卡表 `carrier_type` 兜底(`CUCC` 取 27,其余取 1)。 + +**D7 不新增"读数是否有效"标记。** Gateway 的 `FlexFloat` 已把空、null、无法解析转为 0,新逻辑对 0 与负数一律不写快照,结果相同,Gateway 与三个入口不改。 + +**D8 灰度单位为扣减载体,启动时展开为内存集合。** 设备整台切,避免同一设备套餐同时收到两种算法的增量;判定不查库,出错走老算法。切换、扩批、退回都要先停掉 API 与全部 Worker 再一起启动,避免新旧进程并存时老进程发的增量不计入已结算合计。 + +**D9 已切换卡的展示与通道阈值。** 卡表 `current_month_usage_mb` 用本月最大快照覆盖(写在同一条 `Updates` 里),三处读取方不改;通道阈值判定条件改为"读数大于 0 且本次未被防旧值处理",`EvaluateInTx` 只做达量写锁、无低读数复机,放开不会误复机。 + +**D10 停复机统一目标状态判断。** 新增一个判断函数供 `EvaluateAndAct` 开机分支、停机分支、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 共用:先判断暂停轮询(卡或设备 `enable_polling = false`,与 `ShouldEnqueue` 同口径)→ 不动作;开机时命中无套餐、流量用完、通道实名管控且未实名即停;停机时满足有套餐、流量未用完、已实名或通道未管控即复机,删除 `isPollingStopReason` 限制,保留通道阈值持锁拒绝。设备复机改为取设备下全部停机卡。套餐判断沿用 `HasValidByCarrier` 与 `isTrafficExhausted` 现有口径。 + +**D11 实名管控为通道独立开关,`isRealnameOK` 是唯一实现点。** 总开关开启时:通道 `realname_control_enabled = 0` 返回满足,`= 1` 时以 `real_name_status` 判断,删除行业卡与资产实名策略分支;通道查询失败返回错误、本次不动作。停机、复机、设备复机、人工复机、保护期一致性检查(仅通道管控且未实名时跳过)都经过它。替代方案"复用卡/设备实名策略"会让购买流程与停复机继续耦合,且需要修数据。 + +**D12 通道阈值新周期恢复复用 D10 的复机条件(总开关开启时)。** 避免同一张卡在通道阈值路径与常规路径上复机条件不一致。 + +## Risks / Trade-offs + +- [停复机总开关打开时集中动作:外部停机、人工停机的卡被集中复机;开启实名管控通道的未实名卡(含行业卡)被集中停机] → 总开关默认关闭;打开前在管理端设定需要管控的通道并预演统计,业务确认后再开。 +- [人工停机不再长期有效,下一次评估满足条件即复机] → 需要长期停用的卡由人工暂停轮询;已在规格中明确。 +- [外部系统反复停卡,卡管反复复机] → 按原则不设上限;需要卡管停止管理时人工暂停轮询。 +- [第一次切换观测沿用老增量,若恰好撞上老算法缺陷(如重置窗口读到 0 后又读到真实值)会扣一次] → 每张卡最多一次,灰度第一批核对。 +- [事务 1 判断有生效中套餐、消费者扣减前前一条事件刚好扣完唯一套餐,事件仍会积压] → 概率低;灰度期监控"处理中"回执新增数,确有发生再评估消费者调整。 +- [重置日前最后一次采集到清零之间的用量丢失,采得越稀丢得越多;重置日当天未采到的卡不触发防旧值] → 与老卡管一致,轮询吞吐另行处理。 +- [月中修改通道重置日导致当月换算跳变] → 修改安排在运营商实际重置日前后并核对受影响卡。 +- [`ApplyTraffic` 为全部卡共用路径,改动出错会波及全部卡] → 新逻辑只在已切换分支,老分支不改;测试环境回归非灰度卡观测结果与改动前一致。 +- [卡换绑设备后第一次观测把整张卡周期读数算给设备套餐] → 与现状一致,列入已知限制。 + +## Migration Plan + +1. 迁移:新增 `tb_card_flow_day`、`tb_card_flow_switch`,`tb_carrier` 增加 `realname_control_enabled smallint not null default 0`;成对 up/down,生产由维护者执行。 +2. 发版:计量模式 `off`、快照开关开、停复机总开关关;全部卡开始写快照,其余行为不变。 +3. 阶段 1 至少跑满一个完整自然日(最好跨过一次各运营商重置日)后进入灰度:名单 `device:15276`,先停后启全部进程。 +4. 扩批条件:灰度卡逐日增量 = 按快照复算;无误停误复;跨过一次各运营商清零;冻结卡恢复计量、二次销售不带入旧用量。之后扩批、全量;全量后下线 Redis 日流量缓冲与 02:00 落盘。 +5. 停复机:管理端设定需要实名管控的通道 → 预演 → 打开总开关并先停后启全部进程。 +6. 回滚:计量模式改回 `off` 或移出名单并删除对应卡的切换水位,先停后启;停复机总开关改回关闭。快照与水位表保留不影响老路径。 + +## Open Questions + +- "有套餐"的口径:本 Change 沿用现有口径(生效中或待生效都算有套餐);若业务要求只认生效中,需另行确认,会改变现有停机行为。 +- 套餐在扣减前刚好用完导致的积压,是否在灰度后调整消费者,待灰度监控结果再定。 +- 自动续费成功后的可靠复机(`asset-auto-renewal`「成功后的可靠复机」,实现为 `autoRenewalCardReady`)仍按"可轮询停因、非风险停机/销户"判断,本 Change 未改:总开关开启后,这条路径跳过的卡会在下一次套餐轮询评估时按新规则复机。是否让该路径也改用新复机条件,需另立 Change 修改 `asset-auto-renewal` 规格。 diff --git a/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/proposal.md b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/proposal.md new file mode 100644 index 00000000..d2c97331 --- /dev/null +++ b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/proposal.md @@ -0,0 +1,45 @@ +## Why + +卡流量现在按"本次运营商读数 − 卡上基线(`last_gateway_reading_mb`)"计算增量。基线是跨套餐、跨用户的卡级状态,一旦被异常高读数抬高或重置日没采到就会一直错下去:卡被冻结(2026-10-12 统计约 23% 活跃卡 10 月基线未动,上限估计)、读数为 0 时整笔重复计、二次销售把上一个用户的用量算给新用户、无套餐期间的增量积压后扣到新套餐上。老卡管用"每卡每天一条本月累计快照、区间取最大值做差"多年稳定,本 Change 照搬它的计量方式。 + +停复机同时存在两个问题:外部停机后卡管不再复机(只认流量耗尽、无套餐、未实名三种停因),实名与停复机默认强绑定且耦合行业卡和资产实名策略,想放开只能修数据。依据为 `docs/卡流量计算技术方案-参考老卡管.md`(第 2 版)。 + +## What Changes + +**卡流量计量(方案第 1~9 章)** + +- 新增卡日快照:每卡每天一条"自然月累计",写入时按通道上游流量重置日换算成自然月;重置日当天读数超过上周期最大快照 0.9 倍按 0 记;快照只增不减;读数为 0 或无效不写。全部被采集的卡都写快照。 +- 已切换的卡:增量 = 本月最大快照 − 本月已结算合计(按月对账),照旧写 V1 流量事件交给现有扣减;卡没有生效中套餐时增量只记为已结算、不发事件。 +- 新增切换水位:卡第一次走新算法时,这一次沿用老算法增量,此前部分记为已结算,切换不重复扣、不依赖切换前是否已有快照。 +- 已切换的卡:读数有效且本次未被防旧值处理即判定通道阈值;卡表 `current_month_usage_mb` 用本月最大快照覆盖;日流量读快照。 +- 按扣减载体名单灰度(`off` / `gray` / `full`),设备整台切;切换、扩批、退回先停全部进程再一起启动,退回时删除水位。未切换的卡扣费、停复机、观测结果、展示与现在一致。 +- 不在本 Change:冻结卡历史少扣的补数据脚本(后期另立 Change)、积压"处理中"回执的处理(业务手动)。 + +**停复机规则与实名管控(方案第 10 章,总开关控制,默认关闭)** + +- **BREAKING**(开关开启后)停机:卡开机且满足任一——没有套餐、流量用完、卡所属通道开启实名管控且卡未实名。 +- **BREAKING**(开关开启后)复机:卡停机且同时满足——有套餐、流量没用完、卡已实名或通道关闭实名管控;**不看停机原因**(外部停机、手动停机、设备手动停机都复机)。持有本周期通道阈值锁的卡不复机。 +- 新增通道配置 `realname_control_enabled`,默认 0(不管控),不复用通道现有实名字段;开启后不再区分行业卡,不看卡或设备实名策略。不迁移、不修改任何卡或设备数据。 +- 卡或所在设备暂停轮询时卡管不自动停复机;运营商异常状态(风险停机、机卡分离停机、已销户)由人工判断并暂停轮询,现有独立卡风险停机/已销户自动暂停轮询逻辑不变。 +- 通道阈值新周期解锁后的复机条件随新规则调整。 + +## Capabilities + +### New Capabilities + +- `card-traffic-metering`:卡日快照、自然月换算与重置日防旧值、按月对账增量、切换水位、按名单灰度、已切换卡的通道阈值判定、本月用量与日流量展示。 +- `card-stop-resume`:自动停复机条件、复机不看停机原因、通道实名管控配置、暂停轮询的卡不自动停复机、新规则总开关。 + +### Modified Capabilities + +- `polling-operations`:"停复机遵循实际生效实名策略"限定为停复机新规则总开关关闭时适用;开启后实名改由 `card-stop-resume` 的通道实名管控决定。 +- `carrier-channel-traffic-threshold`:新周期解锁后的自动复机条件在新规则开启时改为 `card-stop-resume` 的复机条件。 + +## Impact + +- 数据库:新增 `tb_card_flow_day`、`tb_card_flow_switch`;`tb_carrier` 增加 `realname_control_enabled`。均为新的成对迁移,不改既有表结构以外的数据。 +- 代码:`internal/application/cardobservation/traffic.go`(已切换分支)、新增 `internal/domain/cardflow/`、快照与水位 Store;`internal/service/iot_card/stop_resume_service.go`、`iot_card_store.go`、`polling_protect_handler.go`;`internal/model/carrier.go`、`service/carrier` 与 DTO;`internal/application/carrierthreshold/` 周期恢复;日流量查询;`pkg/config`、`pkg/constants`。 +- 不改:套餐表与套餐服务、V1 消费者、激活/重置/改已用量/换货/退款入口、Gateway 与三个观测入口、外部停机仍记 `carrier_stopped` 的规则、购买与激活流程的实名策略。 +- 接口:通道创建、修改、详情增加 `realname_control_enabled`;重新生成接口文档。 +- 配置:`JUNHONG_TRAFFIC_CALC_MODE`、`JUNHONG_TRAFFIC_CALC_CARDS`、`JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED`、`JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`;API 与全部 Worker 必须一致。 +- 运营:灰度第一批为设备 15276;打开停复机总开关前先在管理端设定需要实名管控的通道,并预演会集中复机、停机的卡。 diff --git a/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/card-stop-resume/spec.md b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/card-stop-resume/spec.md new file mode 100644 index 00000000..7d7637fd --- /dev/null +++ b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/card-stop-resume/spec.md @@ -0,0 +1,80 @@ +## ADDED Requirements + +### Requirement: 停复机新规则总开关 +系统 SHALL 提供停复机新规则总开关 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`,默认关闭。关闭时自动停机、自动复机、设备复机、人工复机与保护期一致性检查 MUST 与本变更前完全一致,通道实名管控配置不参与判断。开启后按本能力其余要求执行。API 与全部 Worker 进程 MUST 使用一致的开关值。 + +#### Scenario: 总开关关闭 +- **WHEN** 总开关关闭,外部停机(停因 `carrier_stopped`)的卡有套餐且流量未用完 +- **THEN** 系统与变更前一致,不自动复机 + +#### Scenario: 总开关开启 +- **WHEN** 维护者开启总开关并重启全部进程 +- **THEN** 后续每次停复机评估按本能力的停机与复机条件执行 + +### Requirement: 自动停机条件 +总开关开启时,系统 SHALL 对开机的卡在满足任一条件时自动停机:卡(绑定设备时按设备)没有套餐;流量已用完;卡所属通道开启实名管控且卡未实名。停机原因按命中条件分别记为 `no_package`、`traffic_exhausted`、`not_realname`。无套餐与流量用完扩散到设备下全部开机卡,未实名只停该卡。系统 MUST NOT 因卡当前开机是由外部、人工或其他原因造成而改变判断。 + +#### Scenario: 套餐到期 +- **WHEN** 开机卡绑定的设备没有套餐 +- **THEN** 系统停机设备下全部开机卡,停因为 `no_package` + +#### Scenario: 通道开启实名管控的未实名卡 +- **WHEN** 卡所属通道开启实名管控,卡未实名且开机 +- **THEN** 系统只停这张卡,停因为 `not_realname` + +#### Scenario: 通道未开启实名管控的未实名卡 +- **WHEN** 卡所属通道未开启实名管控,卡未实名、有套餐、流量未用完且开机 +- **THEN** 系统不停机 + +### Requirement: 自动复机条件不看停机原因 +总开关开启时,系统 SHALL 对停机的卡在同时满足以下条件时自动复机:卡(绑定设备时按设备)有套餐;流量未用完;卡已实名或卡所属通道未开启实名管控。系统 MUST NOT 以停机原因决定是否复机:外部停机(`carrier_stopped`)、卡管人工停机(`manual`)、设备保护期停机(`protect_period`)、未实名、无套餐、流量耗尽的卡满足条件都复机。持有本周期通道阈值锁的卡 MUST 拒绝复机。设备复机时系统 SHALL 逐张判断设备下全部停机卡,不满足实名条件的卡跳过并记停因 `not_realname`,其他卡照常复机。外部反复停机时,系统每次评估均按上述条件复机,不设次数上限。停机原因只用于展示与审计。 + +#### Scenario: 外部停机后满足条件 +- **WHEN** 卡被外部停机后记为 `carrier_stopped`,有套餐、流量未用完、实名条件满足 +- **THEN** 下一次评估时系统自动复机并清空停因 + +#### Scenario: 人工停机后满足条件 +- **WHEN** 管理员手动停机的卡有套餐、流量未用完、实名条件满足 +- **THEN** 下一次评估时系统自动复机 + +#### Scenario: 开启实名管控的通道中未实名的停机卡 +- **WHEN** 卡所属通道开启实名管控,卡未实名、停机、有套餐 +- **THEN** 系统不复机;卡实名后下一次评估复机 + +#### Scenario: 持有通道阈值锁 +- **WHEN** 停机卡在本周期持有通道阈值锁,其他条件均满足 +- **THEN** 系统拒绝复机并保留锁 + +### Requirement: 通道实名管控配置、权限与审计 +系统 SHALL 在运营商通道上提供独立的实名管控开关 `realname_control_enabled`(0 不管控、1 管控),默认 0,MUST NOT 复用通道现有的实名链接类型等字段;存量通道全部为 0,不迁移、不修改任何卡或设备数据。开启时,通道下所有卡停复机只按卡是否已实名判断,MUST NOT 以行业卡身份或卡、设备上的实名策略放行;实名策略仍只用于购买与首次实名激活流程。仅超级管理员和平台用户 SHALL 在通道新建或编辑时配置该开关,非平台账号提交该字段 MUST 被拒绝;修改并入通道更新审计。从 0 改为 1 时,管理端 MUST 二次确认并提示该通道未实名的开机卡数。查询卡所属通道的实名管控失败时,系统本次不对该卡发起停复机并记录日志。人工复机同样按本要求判断实名。 + +#### Scenario: 默认不管控 +- **WHEN** 总开关开启,通道未修改实名管控,非行业卡未实名、卡实名策略为 `after_order`、有套餐且流量未用完 +- **THEN** 系统不因未实名停机,人工复机不因未实名被拒 + +#### Scenario: 开启后行业卡不再放行 +- **WHEN** 通道开启实名管控,行业卡未实名且开机 +- **THEN** 系统停机该卡,停因为 `not_realname` + +#### Scenario: 越权修改实名管控 +- **WHEN** 非超级管理员、非平台用户提交通道实名管控字段 +- **THEN** 系统拒绝请求且不修改配置 + +#### Scenario: 查询通道失败 +- **WHEN** 停复机评估时卡所属通道的实名管控查询失败 +- **THEN** 系统本次不对该卡发起停复机命令并记录日志 + +### Requirement: 暂停轮询的卡不自动停复机 +总开关开启时,卡或其绑定设备暂停轮询的,系统 SHALL NOT 对该卡发起自动停机或自动复机,包括套餐激活、套餐重置、充值等非轮询触发的复机;人工停机与人工复机照常执行。运营商风险停机、机卡分离停机、已销户由人工判断并暂停轮询,系统 MUST NOT 新增按这些状态自动暂停轮询的处理;现有独立卡风险停机、已销户自动暂停轮询的处理保持不变。未暂停轮询的卡,不论运营商扩展状态如何,均按停机与复机条件处理,上游拒绝复机时按现有失败处理。 + +#### Scenario: 暂停轮询的卡充值后 +- **WHEN** 已被人工暂停轮询的停机卡充值并激活套餐 +- **THEN** 系统不自动复机;人工复机照常可用 + +#### Scenario: 恢复轮询后 +- **WHEN** 维护者为该卡恢复轮询 +- **THEN** 下一次评估起系统按停机与复机条件正常处理 + +#### Scenario: 设备卡出现机卡分离停机 +- **WHEN** 设备下的卡运营商扩展状态为机卡分离停机且未被暂停轮询 +- **THEN** 系统不自动暂停轮询,按停机与复机条件处理 diff --git a/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/card-traffic-metering/spec.md b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/card-traffic-metering/spec.md new file mode 100644 index 00000000..ba290cf2 --- /dev/null +++ b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/card-traffic-metering/spec.md @@ -0,0 +1,117 @@ +## ADDED Requirements + +### Requirement: 卡日快照记录 +系统 SHALL 为每张被采集的卡按上海日期每天保存一条"本月自然月累计流量"快照,同时记录当天最后一次有效的运营商原始读数、当天已结算增量合计、当天是否触发重置日防旧值和有效观测次数。运营商读数查询失败、为空、为 null、无法解析、小于 0 或等于 0 时 MUST NOT 写入快照、MUST NOT 产生增量。同一卡同一天多次观测时,本月累计 MUST 取已有值与本次换算值中的较大者,不得变小。快照写入 MUST 为原子写入,轮询、人工刷新与系列观测并发写同一卡同一天时不得丢失较大值。快照永久保留。 + +#### Scenario: 同一天读到偏小的旧缓存 +- **WHEN** 某卡当天已有本月累计 500 MB 的快照,本次换算值为 480 MB +- **THEN** 当天快照的本月累计保持 500 MB,不产生负增量 + +#### Scenario: 读数为 0 或无效 +- **WHEN** 运营商返回的已用流量为空、null、无法解析或为 0 +- **THEN** 系统不写快照、不产生增量 + +#### Scenario: 未切换卡快照写入失败 +- **WHEN** 未切换新算法的卡在观测事务提交后写快照失败或超时 +- **THEN** 系统只记录告警,本次观测结果、扣费与停复机不受影响,观测不重新入队 + +#### Scenario: 关闭快照写入总开关 +- **WHEN** 维护者把 `JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED` 设为关闭并重启 +- **THEN** 未切换的卡停止写快照,其观测、扣费与停复机不受影响 + +### Requirement: 快照按通道重置日换算自然月 +系统 SHALL 按卡所属通道的上游流量重置日 `R`(1~28,上海时区 `R` 号 0 点清零)把运营商周期读数 `r` 换算为自然月累计 `m`:`R = 1` 时 `m = r`;`R > 1` 且今天日号 ≥ `R` 时 `m = r + 本月 1~R−1 号最大快照`;`R > 1` 且今天日号 < `R` 时 `m = r − (上月最大快照 − 上月 1~R−1 号最大快照)`,结果小于 0 按 0。重置日 MUST 在观测事务内按卡的通道严格读取,不得使用调用方预先计算的值;读取失败、为空或不在 1~28 时,运营商类型为联通(`CUCC`)的卡按 27、其余按 1 处理并记录日志。 + +#### Scenario: 联通卡跨重置日连续 +- **WHEN** 重置日为 27 的卡 26 号最大快照为 5000 MB,27 号清零后读到 100 MB +- **THEN** 27 号快照的本月累计为 5100 MB,自然月累计连续 + +#### Scenario: 重置日查询失败的联通卡 +- **WHEN** 联通卡观测时通道重置日查询失败,调用方传入的重置日已被兜底为 1 +- **THEN** 系统按 27 号换算并记录日志 + +#### Scenario: 非常规重置日 +- **WHEN** 通道重置日为 15 +- **THEN** 系统以 15 号为界按同一规则换算,自然月累计连续 + +### Requirement: 重置日防旧值 +系统 SHALL 只在重置日当天判断旧值:`R = 1` 时读数超过上月最大快照的 0.9 倍(上月无快照时比较值为 0),或 `R > 1` 时本月 1~R−1 号最大快照大于 0 且读数超过其 0.9 倍,本次读数 MUST 按 0 参与换算,原始读数照常记录,并标记当天触发过防旧值。重置日次日起不再判断,读数照常写入。 + +#### Scenario: 重置日读到上周期旧值 +- **WHEN** 重置日为 1 的卡在 1 号读到超过上月最大快照 0.9 倍的读数 +- **THEN** 当天本月累计按 0 记、标记触发防旧值;次日读到的读数照常写入 + +#### Scenario: 重置日当天真实用量较大 +- **WHEN** 重置日当天真实用量超过上周期最大快照的 0.9 倍 +- **THEN** 当天按 0 记,次日如实写入,这部分用量随次日快照计入,不丢失 + +### Requirement: 已切换卡按月对账产生增量 +对已切换新算法且已有切换水位的卡,系统 SHALL 在观测事务内写入当天快照后计算增量 `Δ = 本月最大快照 − 本月各天已结算合计`。`Δ > 0` 且卡自身或卡当前绑定设备存在生效中(status=1)套餐时,系统写一条载荷与现有格式相同的 V1 流量增量事件并把 `Δ` 计入当天已结算;`Δ > 0` 但没有生效中套餐时 MUST NOT 写事件,只把 `Δ` 计入当天已结算;`Δ ≤ 0` 时不写事件。快照、已结算合计与事件 MUST 同事务提交或回滚。已切换的卡每次观测最多写一条 V1 事件,MUST NOT 再写按卡表基线计算的增量事件。生效中套餐查询出错时按有套餐处理。跨月时新月份已结算合计从 0 开始。 + +#### Scenario: 冻结卡切换后恢复计量 +- **WHEN** 卡表基线 72535 MB 而运营商读数 48808 MB 的卡切换后读数继续上涨 +- **THEN** 系统按快照计算出正增量并写 V1 事件,套餐已用量恢复增长 + +#### Scenario: 二次销售不带入上一个用户的用量 +- **WHEN** 卡在无生效中套餐期间被观测到本月累计 3020 MB,激活新套餐后读到 3050 MB +- **THEN** 3020 MB 及之前的增量只计入已结算不写事件,新套餐只被扣 30 MB + +#### Scenario: 迟到观测抬高昨天的快照 +- **WHEN** 今天已发出增量后,一次迟到观测把昨天的快照抬高 +- **THEN** 增量仍按本月最大快照减本月已结算合计计算,重叠部分不被扣两次 + +#### Scenario: 只有已用完或待生效套餐 +- **WHEN** 卡与绑定设备都没有生效中套餐,只有已用完或待生效套餐 +- **THEN** 增量只计入已结算,不写事件 + +### Requirement: 切换水位 +卡已在新算法名单中但没有切换水位时,系统 SHALL 把这次观测作为第一次切换观测:写入当天快照;本次增量沿用按卡表基线计算的结果,大于 0 且有生效中套餐时写 V1 事件,否则不写;把本月最大快照与本月已结算合计的差额计入当天已结算;同事务写入切换水位,记录切换时刻、本月最大快照、运营商读数、卡表基线、卡表本月计入、本次基线算法是否接受读数及其增量。退回老算法时维护者 MUST 删除对应卡的切换水位;再次切换时系统重新执行第一次切换观测。 + +#### Scenario: 切换时本月尚无快照 +- **WHEN** 正常计量的卡切换后的第一次观测发生在本月还没有任何快照时 +- **THEN** 系统只写按卡表基线计算的这一笔增量,不把整月累计当作增量写出 + +#### Scenario: 冻结卡第一次切换观测 +- **WHEN** 基线算法拒绝读数的冻结卡第一次切换观测 +- **THEN** 系统不写增量事件,切换水位记录读数未被接受及当时的运营商读数;下一次观测起按快照正常写增量 + +#### Scenario: 退回后同一天再切回 +- **WHEN** 卡退回老算法并删除切换水位后,同一天再次切换 +- **THEN** 系统重新写切换水位,退回期间老算法已扣的部分不被重复扣 + +### Requirement: 按扣减载体灰度切换 +系统 SHALL 支持 `off`、`gray`、`full` 三种计量模式与灰度名单。名单单位为扣减载体:`device:` 在启动时展开为该设备当前绑定的全部卡,`iot_card:` 指单卡套餐的卡;名单中的 `iot_card` 实际使用设备套餐时,系统在启动日志报错并跳过该项,不阻止进程启动。是否走新算法 MUST 用启动时载入的内存集合判断,判断出错一律走老算法。API 与全部 Worker 进程 MUST 使用一致的模式与名单。未切换的卡,卡表、V1 事件、扣减、停复机、观测结果与页面展示 MUST 与本变更前一致。 + +#### Scenario: 名单写设备 +- **WHEN** 灰度名单为 `device:15276` +- **THEN** 该设备当前绑定的全部卡走新算法 + +#### Scenario: 名单中的单卡实际用设备套餐 +- **WHEN** 灰度名单写 `iot_card:X`,但该卡实际从绑定设备的套餐扣减 +- **THEN** 启动日志报错并跳过该项,进程正常启动,该卡仍走老算法 + +#### Scenario: 非灰度卡观测 +- **WHEN** 不在名单中的卡被观测一次 +- **THEN** 卡表、V1 事件、扣减与停复机结果与变更前完全一致 + +### Requirement: 已切换卡的通道阈值判定 +对已切换且已有切换水位的卡,系统 SHALL 在本次读数大于 0 且本次读数未被重置日防旧值按 0 处理时,用本次运营商读数做通道阈值判定,MUST NOT 以基线算法是否接受读数作为判定前提。第一次切换观测仍按基线算法的条件判定。未切换的卡保持原判定条件。 + +#### Scenario: 冻结卡切换后达量 +- **WHEN** 已切换的冻结卡读数达到通道阈值 +- **THEN** 系统写通道阈值停机锁并创建停机任务 + +#### Scenario: 重置日读到旧总量 +- **WHEN** 已切换卡在重置日读到上周期旧总量并触发防旧值 +- **THEN** 本次不做通道阈值判定;同一天之后的真实读数照常判定 + +### Requirement: 已切换卡的本月用量与日流量展示 +对已切换的卡,系统 SHALL 在观测事务内用本月最大快照覆盖卡表本月用量,使资产详情、卡列表与设备导出显示快照口径的本月用量;日流量查询 MUST 读取快照,某天用量为当天最大快照减本月此前最大快照,小于 0 按 0,本月第一条快照减数为 0。未切换的卡仍读原来源。 + +#### Scenario: 冻结卡切换后展示本月用量 +- **WHEN** 冻结卡切换后被观测 +- **THEN** 资产详情、卡列表与设备导出的本月用量等于本月最大快照,不再为 0 + +#### Scenario: 中间几天没采到 +- **WHEN** 已切换卡连续 3 天没有快照 +- **THEN** 这 3 天的用量并入之后第一个有快照的那天,不丢失 diff --git a/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/carrier-channel-traffic-threshold/spec.md b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/carrier-channel-traffic-threshold/spec.md new file mode 100644 index 00000000..348dc58e --- /dev/null +++ b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/carrier-channel-traffic-threshold/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: 通道阈值停机与周期恢复 +系统 SHALL 为每个已启用运营商通道按通道既有的上游流量重置日(与网关计数器清零同一事实,上海时区周期边界)判断运营商回传的每张卡当前周期累计流量,累计流量仅取网关累计读数换算,不使用本地用量统计或套餐真流量;每张卡独立判断,不扩散到设备维度,不进入既有停复机的设备扩散路径。达到或超过阈值时,系统为当前周期创建通道阈值停机锁并创建可靠停机任务;同一周期重复判定、并发或任务重放不得重复创建锁或重复发起停机。改阈值不生效于当前周期已持锁卡:当前周期维持拒绝复机,新周期按新阈值判断。 + +持锁卡在当前周期内 MUST 拒绝一切复机,包括自动复机、手动复机、保护期强制复机和机卡分离复机;拒绝时保留锁并记录拒绝事实。停机调用失败或结果未知时保留锁与任务状态,由本能力新增的恢复扫描处理:只查询运营商状态回填、确认成功后补写卡状态;自提交起超过约定窗口仍无法确认结果的,标记异常并转人工处理,不自动删除锁。本能力新增停因 `channel_threshold`,不参与既有自动复机判定,复机仅由周期处理发起。 + +新周期开始时,系统 SHALL 对仍持锁卡解除通道锁;仅存在有效主套餐、流量未耗尽、实名满足、不存在风险停机、销户或其他停机锁时创建自动复机任务,任一条件不满足时只解锁不调用运营商。其他停机锁指卡停机原因为非空且非通道阈值的其他停因,或网关扩展状态为风险停机、销户。停复机新规则总开关(`JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`)开启时,新周期解锁后的复机条件改为 `card-stop-resume` 的复机条件:有套餐、流量未用完、卡已实名或卡所属通道未开启实名管控,且卡与其绑定设备未暂停轮询;不再以其他停机原因或网关扩展状态阻止复机,条件不满足时只解锁不调用运营商。复机失败或结果未知同样由恢复扫描处理。卡更换运营商后,旧周期锁保留,新周期按新运营商的重置日计算。 + +#### Scenario: 达量后人工复机 +- **WHEN** 当前计费周期内持有通道阈值停机锁的卡请求复机 +- **THEN** 系统拒绝复机且保留该锁 + +#### Scenario: 新周期仍有其他停机锁 +- **WHEN** 新周期开始的持锁卡没有风险停机但存在其他停机锁 +- **THEN** 系统解除通道阈值锁但不调用运营商复机 + +#### Scenario: 达量后强制复机被拒 +- **WHEN** 当前计费周期内的持锁卡触发保护期强制复机或机卡分离复机 +- **THEN** 系统拒绝复机、保留该锁并记录拒绝事实 + +#### Scenario: 停机结果未知恢复确认 +- **WHEN** 通道阈值停机调用后结果未知,恢复扫描向运营商查询确认卡已停机 +- **THEN** 系统补写卡停机状态并将任务结果记为已确认 + +#### Scenario: 停机结果未知超期转人工 +- **WHEN** 通道阈值停机任务自提交起超过约定窗口仍无法确认结果 +- **THEN** 系统标记异常并留存安全失败原因转人工处理,不自动删除锁 + +#### Scenario: 换运营商后周期归属 +- **WHEN** 持锁卡更换运营商后到达新计费周期 +- **THEN** 旧周期锁保留,新周期按新运营商的上游流量重置日计算并处理 + +#### Scenario: 总开关开启后新周期存在其他停因 +- **WHEN** 停复机新规则总开关开启,新周期开始的持锁卡停因记录为人工停机,有套餐、流量未用完、实名条件满足且未暂停轮询 +- **THEN** 系统解除通道阈值锁并创建自动复机任务 + +#### Scenario: 总开关开启后新周期卡已暂停轮询 +- **WHEN** 停复机新规则总开关开启,新周期开始的持锁卡已被暂停轮询 +- **THEN** 系统解除通道阈值锁但不调用运营商复机 diff --git a/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/polling-operations/spec.md b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/polling-operations/spec.md new file mode 100644 index 00000000..f3608e24 --- /dev/null +++ b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/specs/polling-operations/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: 停复机遵循实际生效实名策略 + +停复机新规则总开关(`JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`)关闭时,系统 SHALL 在自动停机、自动复机、设备批量复机和手动停复机的实名门槛中放行行业卡、已实名卡及实际生效策略为 `none` 的卡。独立卡或无有效设备绑定的卡使用卡策略,有有效设备绑定的非独立卡使用设备策略。系统 MUST 保留真实实名状态,不得以豁免伪造已实名事实。其他策略的未实名普通卡 MUST 保持实名限制;套餐、流量、风险、保护期及非轮询停机不自动复机等既有限制保持不变。总开关开启时,本要求不再适用,实名门槛改由 `card-stop-resume` 的通道实名管控决定,行业卡与卡、设备实名策略不参与停复机。 + +#### Scenario: 免实名独立卡有生效套餐 +- **WHEN** 未实名普通独立卡配置 `none`,存在生效套餐且流量未耗尽,当前网络在线 +- **THEN** 系统不因未实名向上游请求停机 + +#### Scenario: 免实名卡从轮询停机恢复 +- **WHEN** 上述卡当前因 `not_realname` 停机,满足其他自动复机条件 +- **THEN** 系统不因未实名阻止自动复机 + +#### Scenario: 设备策略优先 +- **WHEN** 未实名普通卡有效绑定设备,卡与设备的实名策略不同 +- **THEN** 系统以设备策略判定实名豁免,卡自身的 `none` 不得覆盖设备的实名要求 + +#### Scenario: 行业卡与已实名卡兼容 +- **WHEN** 行业卡未实名或普通卡已实名 +- **THEN** 系统保留已有实名门槛放行行为 + +#### Scenario: 无套餐和流量耗尽不豁免 +- **WHEN** 免实名卡没有生效套餐或流量已经耗尽 +- **THEN** 系统仍按对应业务原因执行停机判断 + +#### Scenario: 策略读取失败 +- **WHEN** 未实名普通卡的有效设备绑定或设备策略查询失败 +- **THEN** 系统报告错误且不对该卡发起本次停复机命令,不按卡策略回退放行 + +#### Scenario: 总开关开启后改按通道实名管控 +- **WHEN** 停复机新规则总开关开启,未实名普通卡的实名策略不是 `none`,卡所属通道未开启实名管控 +- **THEN** 系统不因未实名停机或阻止复机,按 `card-stop-resume` 的条件处理 diff --git a/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/tasks.md b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/tasks.md new file mode 100644 index 00000000..59bc3222 --- /dev/null +++ b/openspec/changes/rebuild-card-traffic-metering-and-stop-resume/tasks.md @@ -0,0 +1,59 @@ +## 1. 迁移与模型 + +- [x] 1.1 新增成对迁移 `000241`:建 `tb_card_flow_day`(`iot_card_id`、`flow_date`、`month_used_mb`、`vendor_used_mb`、`settled_mb`、`stale_guarded`、`obs_count`、时间戳,唯一键 `(iot_card_id, flow_date)`)与 `tb_card_flow_switch`(`iot_card_id` 唯一、`switched_at`、`month_used_mb`、`vendor_used_mb`、`old_baseline_mb`、`old_month_usage_mb`、`old_reading_accepted`、`old_increment_mb`、`created_at`);down 删除两表。 +- [x] 1.2 新增成对迁移 `000242`:`tb_carrier` 增加 `realname_control_enabled smallint not null default 0`;down 删除该列。 +- [x] 1.3 新增 `model.CardFlowDay`、`model.CardFlowSwitch`;`model.Carrier` 增加 `RealnameControlEnabled`。 + +## 2. 计量领域规则 + +- [x] 2.1 新增 `internal/domain/cardflow/`:按重置日 `R` 把读数换算为自然月累计(`R = 1`、`R > 1` 且日号 ≥ `R`、`R > 1` 且日号 < `R` 三种,小于 0 按 0)。 +- [x] 2.2 重置日防旧值:`R = 1` 比上月最大快照、`R > 1` 比本月 1~R−1 号最大快照(大于 0 才判断),超过 0.9 倍按 0;返回是否触发。 +- [x] 2.3 按月对账增量 `Δ = 本月最大 − 本月已结算合计`,以及每天用量(当天最大 − 本月此前最大,小于 0 按 0)。 +- [x] 2.4 重置日严格读取与兜底:按卡 `carrier_id` 查 `data_reset_day`,失败、为空或不在 1~28 时 `CUCC` 取 27、其余取 1 并记日志;不使用 `TrafficObservation.ResetDay`。 + +## 3. 快照与水位存储 + +- [x] 3.1 新增 `internal/store/postgres/card_flow_day_store.go`:`INSERT ... ON CONFLICT (iot_card_id, flow_date) DO UPDATE` 原子写入(本月累计取较大值);查询本月最大值、本月 1~R−1 号最大值、上月最大值与上月 1~R−1 号最大值、本月已结算合计;累加当天已结算。 +- [x] 3.2 新增 `internal/store/postgres/card_flow_switch_store.go`:按卡读取与写入切换水位。 + +## 4. 切换配置 + +- [x] 4.1 `pkg/config` 增加 `TrafficCalc` 配置段与 `defaults/config.yaml` 默认值(`mode: off`、名单为空、快照开关开),对应 `JUNHONG_TRAFFIC_CALC_MODE`、`JUNHONG_TRAFFIC_CALC_CARDS`、`JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED`。 +- [x] 4.2 启动时把名单展开为内存卡集合:`device:` 展开为当前绑定全部卡;`iot_card:` 实际使用设备套餐(与 `resolveActivePackages` 同口径)时启动日志报错并跳过;`full` 为全部卡。API 与全部 Worker 共用同一实现。 + +## 5. 观测事务的已切换分支 + +- [x] 5.1 `internal/application/cardobservation/traffic.go`:读数 `> 0` 时按第 2 节规则写当天快照;已切换卡在事务 1 内写,未切换卡在事务 1 提交后写(失败只告警,受快照开关控制);判定已切换出错一律走老分支,老分支逻辑不改。 +- [x] 5.2 已切换且有水位的卡:按月对账计算 `Δ`;只读判断卡或当前绑定设备是否有 status=1 套餐(出错按有套餐);有套餐写 V1 事件并累加已结算,无套餐只累加已结算;不再写老增量的 V1 事件。 +- [x] 5.3 已切换但无水位的卡(第一次切换观测):写快照,按老增量与套餐判断决定是否写 V1,把本月最大与已结算合计的差额计入已结算,同事务写切换水位。 +- [x] 5.4 已切换卡的通道阈值判定:读数 `> 0` 且本次未被防旧值处理即调用 `EvaluateInTx`;第一次切换观测仍按老条件;未切换卡条件不变。 +- [x] 5.5 已切换卡在同一条卡表 `Updates` 里用本月最大快照覆盖 `current_month_usage_mb`;`last_gateway_reading_mb` 只读不写。 + +## 6. 日流量读侧 + +- [x] 6.1 日流量查询对已切换卡改读快照(每天用量按 2.3),未切换卡仍读原来源;全量切换后的 Redis 日流量缓冲与 02:00 落盘下线不在本节,按上线步骤另行处理。 + +## 7. 停复机新规则 + +- [x] 7.1 `pkg/config` 增加 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`(默认关闭);关闭时 `stop_resume_service.go` 全部现有路径保持不变。 +- [x] 7.2 新增目标状态判断:卡或绑定设备暂停轮询 → 不动作;开机时无套餐、流量用完、通道实名管控且未实名即停(停因与设备扩散规则不变);停机时有套餐、流量未用完、已实名或通道未管控即复,不看停因;持通道阈值锁拒绝复机。 +- [x] 7.3 `EvaluateAndAct` 两个分支、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 在总开关开启时改用 7.2 判断;设备复机在总开关开启时复用 `iot_card_store.go` 现有的 `ListByDeviceIDAndNetworkStatus` 取设备下全部停机卡。 +- [x] 7.4 `isRealnameOK` 在总开关开启时改为按卡所属通道 `realname_control_enabled` 判断(0 满足、1 看 `real_name_status`),删除行业卡与卡/设备实名策略分支,通道查询失败返回错误;人工复机同步生效。 +- [x] 7.5 `internal/task/polling_protect_handler.go`:总开关开启时只在通道开启实名管控且卡未实名时跳过保护期检查。 +- [x] 7.6 `internal/application/carrierthreshold/` 新周期恢复:总开关开启时复机条件改用 7.2(不再以其他停因或网关扩展状态阻止),条件不满足只解锁。 + +## 8. 通道实名管控配置 + +- [x] 8.1 `service/carrier` 与通道 DTO:创建、修改、详情支持 `realname_control_enabled`,仅超级管理员与平台用户可提交,非平台账号提交被拒;修改并入通道更新审计。 +- [x] 8.2 通道修改从 0 改为 1 时返回或提供该通道未实名开机卡数,供管理端二次确认。 +- [x] 8.3 同步路由文档说明,运行 `go run cmd/gendocs/main.go` 重新生成接口文档。 + +## 9. 构建与测试环境验证 + +- [x] 9.1 对变更 Go 文件执行 `gofmt -w`,运行 `go build ./cmd/api ./cmd/worker`、`openspec validate rebuild-card-traffic-metering-and-stop-resume --strict`。 +- [ ] 9.2 按 ENG-TEST-001 在维护者指定的测试环境执行迁移 up/down/up,核对两张新表与 `tb_carrier` 新列默认值。 +- [ ] 9.3 计量模式 `off`:非灰度卡观测一次,卡表、V1 事件、扣减、停复机结果与改动前一致,且写入了快照;关闭快照开关后停写。 +- [ ] 9.4 灰度 `device:<测试设备>`:构造冻结卡(基线高于读数)、二次销售卡、本月无快照的正常卡、联通 27 号与 1 号重置的卡,逐次核对快照、已结算、水位、V1 事件与套餐已用量;退回删水位后同日再切回不重复扣。 +- [ ] 9.5 已切换冻结卡读数达阈值写通道停机锁;重置日防旧值触发时不判定;资产详情、卡列表、设备导出的本月用量等于本月最大快照;日流量读快照。 +- [ ] 9.6 停复机总开关关闭时行为与改动前一致;开启后核对:外部停机、人工停机、设备保护期停机的卡满足条件复机;暂停轮询的卡充值后不复机;通道开启实名管控时未实名卡(含行业卡)停机且不复机、实名后复机;通道未管控时未实名卡不停机、人工复机不被拒;持阈值锁拒绝复机;新周期解锁按新条件复机。 +- [ ] 9.7 通道实名管控字段的权限与审计:非平台账号提交被拒,平台账号修改写入通道更新审计。 diff --git a/pkg/config/config.go b/pkg/config/config.go index ba698e64..481f815e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -32,6 +32,37 @@ type Config struct { Worker WorkerConfig `mapstructure:"worker"` WeCom WeComConfig `mapstructure:"wecom"` Approval ApprovalConfig `mapstructure:"approval"` + TrafficCalc TrafficCalcConfig `mapstructure:"traffic_calc"` + StopResume StopResumeConfig `mapstructure:"stop_resume"` +} + +// 卡流量计量模式取值。 +const ( + TrafficCalcModeOff = "off" + TrafficCalcModeGray = "gray" + TrafficCalcModeFull = "full" +) + +// TrafficCalcConfig 卡流量计量(日快照算法)切换配置。 +// API 与全部 Worker 进程必须使用一致的取值,改动后先停掉全部进程再一起启动。 +type TrafficCalcConfig struct { + // Mode 计量模式:off 全部走卡表基线算法;gray 只有名单内的卡走日快照算法;full 全部卡走日快照算法。 + // 环境变量:JUNHONG_TRAFFIC_CALC_MODE + Mode string `mapstructure:"mode"` + // Cards 灰度名单,逗号分隔的扣减载体:device:<设备ID> 或 iot_card:<卡ID>。 + // 环境变量:JUNHONG_TRAFFIC_CALC_CARDS + Cards string `mapstructure:"cards"` + // FlowSnapshotEnabled 未切换卡的日快照写入总开关,关闭后未切换卡停止写快照。 + // 环境变量:JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED + FlowSnapshotEnabled bool `mapstructure:"flow_snapshot_enabled"` +} + +// StopResumeConfig 停复机规则配置。 +type StopResumeConfig struct { + // UnifiedRuleEnabled 停复机新规则总开关:开启后复机不看停机原因、实名按通道实名管控判断、 + // 暂停轮询的卡不自动停复机;关闭时与原有行为完全一致。 + // 环境变量:JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED + UnifiedRuleEnabled bool `mapstructure:"unified_rule_enabled"` } // ServerConfig HTTP 服务器配置 @@ -251,6 +282,11 @@ func (c *Config) ValidateRequired() error { } func (c *Config) Validate() error { + switch c.TrafficCalc.Mode { + case TrafficCalcModeOff, TrafficCalcModeGray, TrafficCalcModeFull: + default: + return fmt.Errorf("invalid configuration: traffic_calc.mode: invalid mode (current value: %s, expected: off, gray, full)", c.TrafficCalc.Mode) + } // 服务器验证 if c.Server.Address == "" { return fmt.Errorf("invalid configuration: server.address: must be non-empty (current value: empty)") diff --git a/pkg/config/defaults/config.yaml b/pkg/config/defaults/config.yaml index 6916b885..43f92c02 100644 --- a/pkg/config/defaults/config.yaml +++ b/pkg/config/defaults/config.yaml @@ -155,6 +155,16 @@ approval: legacy_offline_recharge_pay_enabled: true +# 卡流量计量(日快照算法)切换配置;API 与全部 Worker 必须一致,改动后先停全部进程再一起启动 +traffic_calc: + mode: "off" # off / gray / full(环境变量:JUNHONG_TRAFFIC_CALC_MODE) + cards: "" # 灰度名单,如 device:15276,iot_card:103154(环境变量:JUNHONG_TRAFFIC_CALC_CARDS) + flow_snapshot_enabled: true # 未切换卡写日快照(环境变量:JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED) + +# 停复机规则配置 +stop_resume: + unified_rule_enabled: false # 停复机新规则总开关(环境变量:JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED) + # 企业微信 Adapter 配置 wecom: base_url: "https://qyapi.weixin.qq.com" diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 0775d23f..0b7908ca 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -157,9 +157,14 @@ func bindEnvVariables(v *viper.Viper) { "wechat.payment.notify_url", "wechat.payment.http_debug", "wechat.payment.timeout", + "traffic_calc.mode", + "traffic_calc.cards", + "stop_resume.unified_rule_enabled", } for _, key := range bindings { _ = v.BindEnv(key) } + // 快照写入开关沿用方案约定的环境变量名,与配置键的默认映射不同,需要显式绑定。 + _ = v.BindEnv("traffic_calc.flow_snapshot_enabled", "JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED") }