From 9c3c258f160bbb2b83da010456ad657cfab0f569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Thu, 29 Oct 2026 12:18:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=A1=E6=B5=81=E9=87=8F=E8=AF=BB?= =?UTF-8?q?=E6=95=B0=E5=85=88=E8=BF=87=E9=87=87=E9=9B=86=E9=98=9F=E5=88=97?= =?UTF-8?q?=E5=86=8D=E5=85=A5=E8=B4=A6=EF=BC=8C=E6=89=A3=E5=87=8F=E6=8C=89?= =?UTF-8?q?=E9=87=87=E9=9B=86=E6=97=B6=E5=88=BB=E7=A1=AE=E5=AE=9A=E5=A5=97?= =?UTF-8?q?=E9=A4=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每卡 Redis 采集队列(默认 N=3、跨天清空、回退容差 1 MB),攒满且不下降才写最后一个点 - 测试环境全量切换日快照计量,下线灰度名单、切换水位与卡表基线扣费;全部卡置待建起点 - 读数区分没给值与 0;有套餐的卡读数 0 也写快照与日用量 0 行;重置日比较基准为 0 照常防旧值 - 新增采集记录(按月分区保留 90 天)、人工冲正流水与后台接口、采集告警 - 扣减按采集时刻有效套餐查找,只对生效中套餐做状态迁移,查不到套餐按成功跳过 - 换货新卡(新设备下的卡)打待建起点;采集间隔上限只校验启用中的轮询配置 Change: add-card-traffic-queue-and-observed-deduction Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/deploy.yaml | 3 - cmd/worker/main.go | 39 ++ docker-compose.prod.yml | 6 - docs/卡流量采集写入与扣减技术方案.md | 443 ++++++++++++++++++ internal/application/cardobservation/apply.go | 23 +- .../application/cardobservation/traffic.go | 353 ++++++++------ .../cardobservation/traffic_calc_policy.go | 171 ------- .../cardobservation/traffic_collection.go | 74 +++ .../cardobservation/traffic_flow.go | 158 +++---- .../cardobservation/traffic_queue_settings.go | 193 ++++++++ internal/bootstrap/handlers.go | 17 + internal/bootstrap/services.go | 16 +- internal/bootstrap/worker_services.go | 11 +- internal/domain/cardflow/queue.go | 50 ++ internal/domain/cardflow/rules.go | 10 +- internal/domain/cardobservation/traffic.go | 107 ++--- internal/gateway/models.go | 58 +++ internal/handler/admin/asset_traffic.go | 87 ++++ .../infrastructure/cardobservation/event.go | 7 +- .../pre_activation_settlement.go | 6 +- .../cardobservation/series_runner.go | 11 +- .../cardobservation/traffic_alert.go | 120 +++++ .../cardobservation/traffic_queue.go | 130 +++++ internal/model/card_traffic_collection.go | 54 +++ internal/model/dto/asset_dto.go | 119 ++++- internal/model/iot_card.go | 25 +- internal/routes/asset.go | 29 +- internal/service/asset/service.go | 48 +- internal/service/asset/traffic_adjustment.go | 201 ++++++++ .../service/asset/traffic_collection_query.go | 66 +++ internal/service/exchange/migration.go | 16 + internal/service/iot_card/service.go | 25 + internal/service/package/usage_service.go | 268 +++-------- internal/service/polling/config_service.go | 15 + internal/service/polling/traffic_interval.go | 64 +++ .../store/postgres/card_flow_day_store.go | 13 + .../store/postgres/card_flow_switch_store.go | 43 -- .../postgres/card_traffic_collection_store.go | 97 ++++ .../store/postgres/package_usage_effective.go | 94 ++++ .../store/postgres/package_usage_store.go | 12 + internal/task/polling_carddata_handler.go | 33 +- ...00267_add_card_traffic_collection.down.sql | 3 + .../000267_add_card_traffic_collection.up.sql | 52 ++ ...0268_add_package_usage_adjustment.down.sql | 2 + ...000268_add_package_usage_adjustment.up.sql | 38 ++ .../.openspec.yaml | 2 + .../design.md | 79 ++++ .../proposal.md | 38 ++ .../specs/card-traffic-metering/spec.md | 263 +++++++++++ .../specs/package-traffic-deduction/spec.md | 64 +++ .../tasks.md | 74 +++ pkg/config/config.go | 27 -- pkg/config/defaults/config.yaml | 6 - pkg/config/loader.go | 4 - pkg/constants/card_traffic_queue.go | 56 +++ 55 files changed, 3142 insertions(+), 881 deletions(-) create mode 100644 docs/卡流量采集写入与扣减技术方案.md delete mode 100644 internal/application/cardobservation/traffic_calc_policy.go create mode 100644 internal/application/cardobservation/traffic_collection.go create mode 100644 internal/application/cardobservation/traffic_queue_settings.go create mode 100644 internal/domain/cardflow/queue.go create mode 100644 internal/handler/admin/asset_traffic.go create mode 100644 internal/infrastructure/cardobservation/traffic_alert.go create mode 100644 internal/infrastructure/cardobservation/traffic_queue.go create mode 100644 internal/model/card_traffic_collection.go create mode 100644 internal/service/asset/traffic_adjustment.go create mode 100644 internal/service/asset/traffic_collection_query.go create mode 100644 internal/service/polling/traffic_interval.go delete mode 100644 internal/store/postgres/card_flow_switch_store.go create mode 100644 internal/store/postgres/card_traffic_collection_store.go create mode 100644 internal/store/postgres/package_usage_effective.go create mode 100644 migrations/000267_add_card_traffic_collection.down.sql create mode 100644 migrations/000267_add_card_traffic_collection.up.sql create mode 100644 migrations/000268_add_package_usage_adjustment.down.sql create mode 100644 migrations/000268_add_package_usage_adjustment.up.sql create mode 100644 openspec/changes/add-card-traffic-queue-and-observed-deduction/.openspec.yaml create mode 100644 openspec/changes/add-card-traffic-queue-and-observed-deduction/design.md create mode 100644 openspec/changes/add-card-traffic-queue-and-observed-deduction/proposal.md create mode 100644 openspec/changes/add-card-traffic-queue-and-observed-deduction/specs/card-traffic-metering/spec.md create mode 100644 openspec/changes/add-card-traffic-queue-and-observed-deduction/specs/package-traffic-deduction/spec.md create mode 100644 openspec/changes/add-card-traffic-queue-and-observed-deduction/tasks.md create mode 100644 pkg/constants/card_traffic_queue.go diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 3d414581..8beda915 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -60,9 +60,6 @@ jobs: printf 'JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true\n' printf 'JUNHONG_WORKER_ROLE=all\n' printf 'JUNHONG_WORKER_INSTANCE_NAME=test-worker-all-1\n' - # 卡流量日快照计量灰度(rebuild-card-traffic-metering-and-stop-resume):仅测试设备 10 走新算法 - printf 'JUNHONG_TRAFFIC_CALC_MODE=gray\n' - printf 'JUNHONG_TRAFFIC_CALC_CARDS=device:10\n' } > ${{ env.DEPLOY_DIR }}/.env # 强制更新 docker-compose.prod.yml(确保使用最新配置) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index fe49a411..f08f9e8c 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -28,6 +28,7 @@ import ( refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/bootstrap" + "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" "github.com/break/junhong_cmp_fiber/internal/gateway" approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval" assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal" @@ -173,6 +174,7 @@ func runWorker(cfg *config.Config) { if runsSingletonModules(cfg.Worker.Role) { pollingInitializer = startPollingInitializer(ctx, runtime, appLogger) + startTrafficCollectionPartitionMaintainer(ctx, runtime, appLogger) pollingScheduler = startPollingScheduler(ctx, runtime, pollingInitializer, appLogger) asynqScheduler = startAsynqScheduler(cfg, runtime.redisAddr, appLogger) } @@ -858,6 +860,43 @@ func startOutboxRelay(ctx context.Context, runtime *workerRuntime, instanceName appLogger.Info("Outbox Relay 已启动", zap.String("lease_owner", owner)) } +// startTrafficCollectionPartitionMaintainer 为 leader/all 维护卡流量采集记录月分区并扫描采集告警: +// 每 12 小时预建本月起 2 个月的分区、删除整月都早于保留期的分区;每天扫描一次前一天的采集告警。 +func startTrafficCollectionPartitionMaintainer(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) { + store := postgres.NewCardTrafficCollectionStore(runtime.db) + alerts := cardObservationInfra.NewTrafficAlertScanner(runtime.db, appLogger) + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + var lastMaintained time.Time + var lastScannedDay string + for { + now := time.Now() + if now.Sub(lastMaintained) >= 12*time.Hour { + if err := store.MaintainPartitions(ctx, now, 2, constants.TrafficCollectionRetentionDays); err != nil && ctx.Err() == nil { + appLogger.Error("维护卡流量采集记录分区失败", zap.Error(err)) + } else { + lastMaintained = now + } + } + // 每天扫描一次前一天(上海日期)的采集告警。 + yesterday := cardflow.ShanghaiDate(now).AddDate(0, 0, -1) + if day := yesterday.Format("2006-01-02"); day != lastScannedDay { + if err := alerts.Scan(ctx, yesterday); err != nil && ctx.Err() == nil { + appLogger.Error("扫描卡流量采集告警失败", zap.Error(err)) + } else { + lastScannedDay = day + } + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() +} + // startPollingInitializer 为 leader/all 启动渐进式初始化器,并保留配置变更重启逻辑。 func startPollingInitializer(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) *polling.PollingInitializer { pollingInitializer := polling.NewPollingInitializer( diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e67f9162..358c1b20 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -78,9 +78,6 @@ services: - JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=${JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED:-true} - JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=${JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED:-true} # 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变) - - JUNHONG_TRAFFIC_CALC_MODE=${JUNHONG_TRAFFIC_CALC_MODE:-off} - - JUNHONG_TRAFFIC_CALC_CARDS=${JUNHONG_TRAFFIC_CALC_CARDS:-} - - JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED=${JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED:-true} # 短信服务配置 - JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443 - JUNHONG_SMS_USERNAME=JH0001 @@ -148,9 +145,6 @@ services: - JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all} - JUNHONG_WORKER_INSTANCE_NAME=${JUNHONG_WORKER_INSTANCE_NAME:-test-worker-all-1} # 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变) - - JUNHONG_TRAFFIC_CALC_MODE=${JUNHONG_TRAFFIC_CALC_MODE:-off} - - JUNHONG_TRAFFIC_CALC_CARDS=${JUNHONG_TRAFFIC_CALC_CARDS:-} - - JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED=${JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED:-true} # Worker 执行企微审批提交、同步和恢复任务 - JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com - JUNHONG_WECOM_TIMEOUT=10s diff --git a/docs/卡流量采集写入与扣减技术方案.md b/docs/卡流量采集写入与扣减技术方案.md new file mode 100644 index 00000000..c3ee96d7 --- /dev/null +++ b/docs/卡流量采集写入与扣减技术方案.md @@ -0,0 +1,443 @@ +# 卡流量采集写入与扣减技术方案 + +| 项目 | 内容 | +|---|---| +| 日期 | 2026-10-28 | +| 状态 | 技术方案已确认,2026-10-28 按评审意见修订(T18–T25,T6 照搬老系统、按配置的重置日),**代码尚未修改** | +| 前置方案 | `docs/卡流量计算技术方案-参考老卡管.md`(第 2 版):日快照、按重置日换算、重置日防旧值、按月对账、切换水位。本方案在它的基础上补齐"读数写入前的过滤""采集留痕""扣减口径""换卡""冲正" | +| 对应 Change | `add-card-traffic-queue-and-observed-deduction`(基于已实现的 `rebuild-card-traffic-metering-and-stop-resume`) | +| 范围 | 测试环境 | + +--- + +## 1. 结论 + +1. **核心问题是多扣和少扣。** 现在的卡表基线算法(`last_gateway_reading_mb` + 重置窗口)一个读数就直接生效:暴涨立即扣、窗口内抖动重复扣、延迟重置整月不扣。灰度路径(日快照 + 本月最大值 − 已结算)解决了重置相关的问题,但没有过滤异常点,而且当天快照取最大值,暴涨会被留下来。 +2. **本方案:** + - 读数先进 **Redis 队列**,队列内读数不下降(允许小容差)才写**最后一个点**,异常点在写入前拦下。 + - **一次性全量切换**为日快照 + 本月最大值 − 已结算,卡表基线算法、灰度名单、切换水位下线。 + - 有套餐的卡**每次采集都留记录**,日快照读数为 0 也写;没套餐的卡只在读数 > 0 时写日快照。 + - 扣减保持**增量扣减**,扣费按**采集时刻**确定套餐,日期按采集时间算。 + - 扣错了走**人工冲正流水**;初期不做自动对账,由人工排查。 + +### 1.1 已定决策(2026-10-28) + +| # | 事项 | 决定 | +|---|---|---| +| T1 | 无效读数 | 接口失败、限流、缺字段、`used` 为 null / 空 / 解析失败、负数、NaN:不入队、不写快照;`FlexFloat` 要能区分"没值"和 0 | +| T2 | 队列存储 | Redis,每卡一个 key,Lua 原子更新 | +| T3 | 队列长度 | N = 3,可配置 | +| T4 | 写入条件 | 队列满 N 个点,相邻两段都不下降(回退在容差内算持平),写**最后一个点**(即本次采集的点);新点不在队尾时不写 | +| T5 | 跨天清空 | 可配置开关 `traffic.queue.clear_on_new_day`,默认打开;打开时新点与队列里的点不在同一天(上海时间),先清空再入队 | +| T6 | 重置日 | 照搬老系统(移动 `YidongDayFlowService`、联通 `PtLiantongDayFlowService`),只把重置日换成通道配置的重置日:按配置的重置日换算成自然月累计;重置日当天全天防旧值(读数 > 比较基准 × 0.9 按 0 计;比较基准为 0 时当天任何正读数都按 0 计,不论配置的重置日是几号),不按小时区分,不设多日警惕期;重置日当天没取到有效数据,第二天起照常按新周期计;重置日早上的旧周期读数不记回前一天,月末尾巴丢失 | +| T7 | 路径统一 | 测试环境一次性全量切换,不再灰度:所有卡走日快照 + 本月最大值 − 已结算;卡表基线算法、`TrafficCalcPolicy`、切换水位下线;切换时所有卡打"待建起点"标记(见 6.2) | +| T8 | 写入范围 | 有套餐:每次采集写采集记录;日快照读数为 0 也写;套餐日用量每天一行,没扣也写 0。没套餐:不写采集记录,读数 > 0 才写日快照 | +| T9 | 没套餐期间的用量 | 记为已结算,之后买了套餐**不补扣** | +| T10 | 扣减方式 | 增量扣减;按采集时刻生效的套餐扣,套餐到扣费时已用完或到期也照扣 | +| T11 | 停复机判断 | 通道阈值只在**写入的点**上判断,被队列拦下的点不参与 | +| T12 | 其他来源 | 人工刷新、激活前结算与轮询进同一个队列,规则一致(没有运营商流量推送);人工刷新返回本次读数、是否写入、原因 | +| T13 | 当天没有写入 | 不补写日快照,靠告警发现 | +| T14 | 换卡 | 换卡完成时给新卡打"待建起点"标记;新卡第一次**通过队列写入**时建起点(本月最大快照全部记为已结算,不扣费),之后正常扣 | +| T15 | 扣错 | 人工冲正,走冲正流水 | +| T16 | 对账 | 初期不做,人工排查 | +| T17 | "写入值 ≥ 上次写入值" | 不单独判断:日快照取最大值、增量 = 最大值 − 已结算,小的值本来就不产生增量 | +| T18 | "有套餐"口径 | 采集侧(写入范围、是否发事件、采集记录)与扣费侧用同一个"采集时刻有效套餐"判断,**已用完但仍在有效期内的套餐算有套餐**(见 6.1) | +| T19 | 按采集时刻扣费的边界 | 只对当前"生效中"的套餐做状态迁移;停机按当前状态判断;扣费时一个套餐都查不到按成功跳过;采集后套餐流量重置的,扣在重置后的周期(见 8.1) | +| T20 | 读数 0 | 读数校验放宽为"有限且 ≥ 0",有没有套餐决定 0 写不写,不再由读数校验拦(见第 6 节) | +| T21 | 采集间隔上限 | 留 2 次冗余:开关打开时间隔 ≤ 24h ÷ (N+2),关闭时 ≤ `max_point_age_hours` ÷ (N+2)(见 4.4) | +| T22 | 回退容差 | 相邻两点回退 ≤ `traffic.queue.drop_tolerance_mb`(默认 1 MB)算持平;重置等大幅下降仍被拦下 | +| T23 | 冲正 | 锁套餐行、按当前值加减;退回后已用量不能小于 0;提交后走停复机判断,不直接复机(见第 11 节) | +| T24 | 日期 | 整条链路的日期统一用上海日期(`cardflow.ShanghaiDate`),包括消费者的日流量缓冲 key | +| T25 | 套餐日用量 0 行 | 在观测事务里写:当天第一次写入时,为采集时刻的每个有效套餐确保当天一行 | + +--- + +## 2. 整体流程 + +``` +运营商接口 + │ + ├─ ① 解析读数 ──────────── 无效:记采集记录(有套餐的卡),结束 + │ + ├─ ② Redis 队列(Lua 原子):跨天清空(可配)→ 丢弃过期点 → 入队 → 只留最后 N 个 + │ + ├─ ③ 写入判断:新点在队尾、队列满 N 个点且不下降(容差内)→ 写最后一个点 ── 不满足:记采集记录(有套餐的卡),结束 + │ + ├─ ④ 数据库事务(卡级行锁) + │ 有"待建起点"标记:写快照 → 本月最大全部记为已结算 → 清标记,结束(第 10 节) + │ 换算月累计(重置日防旧值)→ 写日快照(取最大) + │ → Δ = 本月最大快照 − 本月已结算 → 记已结算 + │ → 采集时刻有套餐:确保当天套餐日用量行(T25);Δ > 0 时写流量事件(outbox,带采集时间、采集 ID) + │ → 通道阈值判断(只在写入的点上) + │ → 写采集记录(有套餐的卡) + │ + └─ ⑤ 异步消费流量事件 + 按采集时刻生效的套餐,按优先级扣减(容量按虚流量阈值) + → 套餐日用量 upsert(日期 = 采集日期)→ 扣费审计 → 套餐用完触发停机 +``` + +--- + +## 3. 读数解析 + +| 运营商返回 | 入队 | 写快照 | 采集记录(有套餐的卡) | +|---|---|---|---| +| 接口失败、超时、非 200 | 否 | 否 | 记,`result = failed` | +| 限流 | 否 | 否 | 记,`result = rate_limited` | +| 响应缺 ICCID 等必要字段 | 否 | 否 | 记,`result = invalid_payload` | +| `used` 为 null、空、无法解析 | 否 | 否 | 记,`result = no_reading`,读数为空 | +| 负数、NaN、Inf | 否 | 否 | 记,`result = invalid_reading` | +| ≥ 0 的数 | 是 | 看第 5 节 | 记 | + +**改动**:`internal/gateway/models.go:55` 的 `FlexFloat` 目前把 null、空、无法解析都转成 0。改为带"是否有值"的类型(或 `*float64`),流量查询结果据此区分"没值"和真实的 0;其他使用 `FlexFloat` 的字段保持原行为。 + +新类型的**零值必须表示"没值"**:响应里完全没有 `used` 字段时,JSON 解析不会调用 `UnmarshalJSON`,字段停在零值。用 `*float64`(nil)或带 `Valid` 标志的结构(`Valid = false`)都满足;不能用零值为 0 的类型。 + +--- + +## 4. 采集队列(Redis) + +### 4.1 存储 + +| 项 | 内容 | +|---|---| +| key | `traffic:queue:{card_id}` | +| 值 | 最近 N 个点,每个点 = 采集时间(Unix 毫秒)+ 运营商原始读数(MB,原样,不换算) | +| 过期 | 每次更新后设为第二天 01:00(上海时间);跨天清空关闭时设为 `max_point_age_hours` 之后 | +| 内存 | 每卡约 150 字节,100 万张卡约 150 MB | + +队列只是写入前的过滤器,**不是账**: + +- key 丢了(Redis 重启、淘汰)→ 队列从空开始,晚写 N−1 次,不多扣也不少扣。 +- 入队成功、后面的数据库事务失败 → 队列里多一个点,下次采集重新判断即可。 + +Redis 淘汰策略:测试环境已确认为 `noeviction`、未设 `maxmemory`,队列不会被淘汰;内存需随卡数观察。 + +### 4.2 Lua 脚本 + +入参:`card_id`、新点(采集时间、读数)、`N`、`clear_on_new_day`、`max_point_age_ms`、过期时间。上海日期由 Go 侧算好传入(每个点存日号),Lua 里不做时区换算。 + +``` +points = 读出队列 +if clear_on_new_day and points 非空 and 上海日期(points 最后一个点) != 上海日期(新点): + points = [] +if max_point_age_ms > 0: + 丢掉 points 中 新点时间 − 点时间 > max_point_age_ms 的点 +按采集时间把新点插入 points(同一时间的点去重) +只保留最后 N 个 +写回,设置过期 +return points, 新点是否在队尾 +``` + +按采集时间插入,保证同一张卡同时有两次采集(轮询 + 人工刷新)时顺序正确;观测事务本身仍然加卡级行锁。 + +**乱序**:新点插入后不在队尾(比队列里已有的点更早到达顺序靠后),或者插进去就被裁掉,本次**不写**,`decision = out_of_order`。队尾的点之前已经判断过,再写一次虽然不影响账(GREATEST + 增量公式),但会让这条采集记录看起来写了自己的读数,排查时误导。 + +### 4.3 配置项 + +放在系统配置里,读法与沉默期总开关 `package.silence.enabled` 相同(`internal/application/packagesilence/settings.go`,带短缓存),改了不用重新发布。 + +| key | 默认 | 说明 | +|---|---|---| +| `traffic.queue.size` | 3 | 队列长度 N,最小 2 | +| `traffic.queue.clear_on_new_day` | true | 跨天是否清空 | +| `traffic.queue.max_point_age_hours` | 72 | 队列里超过这个时长的点,入队时丢掉;0 表示不限 | +| `traffic.queue.drop_tolerance_mb` | 1 | 相邻两点回退不超过这个值算持平;0 表示不允许回退 | + +### 4.4 跨天清空开关 + +| | 打开(默认) | 关闭 | +|---|---|---| +| 队列里的点 | 只有当天的 | 可以跨天,最长 `max_point_age_hours` | +| 每天首次写入 | 当天攒满 N 个点 | 跨天后的新点进来就能判断 | +| 前一天最后一段用量 | 算进第二天第一次写入 | 跨天的点能直接写 | +| 采集间隔上限 | **间隔 ≤ 24h ÷ (N+2)**(N=3 时为 4.8 小时) | 间隔 ≤ `max_point_age_hours` ÷ (N+2)(默认为 14.4 小时) | + +关闭时的重置日:队列里存的是原始读数,重置时读数大幅下降、斜率为负,自然被拦下,新周期攒满 N 个不下降的点后才写,不会把新旧周期混着写。 + +`max_point_age_hours` 的作用:卡停用几天后重新采集时,不用几天前的旧点来确认当前读数是否稳定。 + +**采集间隔上限**:理论最低要求是窗口内采到 N 次(开关打开时 24h ÷ N),但这样一次失败或限流,当天就写不进去,T13 告警会常态触发。上限按 N+2 次算,留 2 次冗余。 + +间隔还决定每天第一次写入的时间:开关打开时,当天第一次写入在第一次采集之后 (N−1) × 间隔(N=3、间隔 4.8 小时,约为当天第一次采集后 9.6 小时)。这之前的用量(含前一天最后一段)都在这次写入时一起扣,停机也在这时才判断。需要更及时的扣费和停机时,应缩短间隔,而不是只满足上限。 + +**采集间隔校验**:只校验**启用中**的轮询配置,停用的不管。保存(启用状态)或启用轮询配置时,流量采集间隔超过当前开关下的上限直接报错;`traffic.queue.size` 或开关修改时,同样校验现有启用中的轮询配置,不满足的拒绝保存。测试库启用中的只有 id 29(间隔 60 秒,满足上限);id 25(86400 秒)是停用的,不拦,以后要启用时再按上限调小。 + +--- + +## 5. 写入判断 + +同时满足以下条件,写**最后一个点**(即本次采集的点): + +1. 新点在队尾(4.2 乱序); +2. 队列满 N 个点; +3. 相邻两点都不下降:后一个读数 ≥ 前一个读数 − `drop_tolerance_mb`。 + +- 只比较先后读数,与采集间隔是否均匀无关。 +- 持平也写:有套餐但没用流量的卡能写入 0 用量。 +- **容差**:日快照取当天最大值、增量 = 最大值 − 已结算,小幅回退的点写进去也不产生增量,所以容差不会造成多扣;它只是避免读数在几 KB 内来回跳时整窗不写。重置时的大幅下降远超容差,照样被拦下。 + +N=3、容差 1 MB 的例子: + +| 队列 | 结果 | +|---|---| +| 100, 150, 200 | 写 200 | +| 100, 100, 100 | 写 100 | +| 100, 150, 120 | 不写 | +| 100, 99999, 120 | 不写(中间点暴涨被拦下) | +| 100, 110, 99999 | 写 99999(最后一个点暴涨挡不住,见第 14 节) | +| 100.002, 100.001, 100.003 | 回退 0.001 在容差内,写 100.003 | +| 3010, 20, 25(重置) | 不写 | +| 20, 25, 30(新周期) | 写 30 | + +不写时,原因记进采集记录(第 7.1 节)。 + +--- + +## 6. 写入:日快照与结算 + +沿用灰度路径(`internal/application/cardobservation/traffic_flow.go`),改动见下。 + +**读数校验放宽**:`cardflow.ValidReading` 现在要求读数 > 0,`writeSnapshot` 与 `writeUnswitchedSnapshot` 都以它为前提,读数为 0 的点写不进快照,与 T8 冲突。改为"有限且 ≥ 0"(与第 3 节的入队条件一致);没套餐的卡读数为 0 不写快照,由 6.1 的写入范围判断,不再由读数校验拦。读数 0 在重置日不触发防旧值(`isStale` 对 ≤ 0 直接返回否),换算结果不变。 + +1. **换算**:`cardflow.Convert`,按卡所属通道的重置日把**被写入那个点**的读数换算成自然月累计;重置日当天读数超过比较基准 × 0.9 按 0 计(防旧值)。日期取被写入那个点的采集日期。 +2. **写日快照** `tb_card_flow_day`:upsert,`month_used_mb` 取当天最大(`GREATEST`)。 +3. **结算**:`Δ = 本月最大快照 − 本月已结算`,Δ > 0 记入当天 `settled_mb`。 +4. **事件**:采集时刻卡有套餐(6.1.1)且 Δ > 0,同事务写流量事件(outbox),事件带采集时间和采集 ID。 +5. **停复机**:通道阈值只在这里判断(被队列拦下的点不判断)。 + +### 6.1 写入范围 + +| | 有套餐 | 没套餐 | +|---|---|---| +| 被写入的点读数为 0 | 写日快照 | 不写 | +| 被写入的点读数 > 0 | 写日快照 | 写日快照 | +| 套餐日用量 | 每个有效套餐(6.1.1)每天一行,没扣也写一行 0 | — | +| Δ | 发事件扣费 | 只记已结算(T9) | + +**套餐日用量 0 行在观测事务里写**(T25):扣减消费者只在 Δ > 0 时才运行,没用流量的卡走不到那里。所以在观测事务里,当天第一次写入时,为采集时刻的每个有效套餐 `INSERT ... ON CONFLICT (package_usage_id, date) DO NOTHING` 一行 0;之后扣减按 8.2 累加。由 `usage_service` 提供方法,观测事务调用,不在 cardobservation 里直接写套餐表。 + +#### 6.1.1 "有套餐"口径(T18) + +采集侧(写入范围、是否发事件、是否写采集记录)与扣费侧(8.1 查套餐)用**同一个函数**判断"采集时刻有效套餐": + +| 条件 | 说明 | +|---|---| +| 状态 | `status IN (生效中, 已用完, 已过期)`;待生效、已失效不算 | +| 已生效 | `activated_at ≤ observed_at`;`activated_at` 为空的历史数据按已生效处理(与现有口径一致) | +| 未到期 | `expires_at` 为空,或 `expires_at > observed_at` | +| 未退款 | `refund_id IS NULL` | +| 载体 | 卡自己的套餐;卡自己一个都没有时,看卡当前绑定设备的套餐 | + +- **已用完但仍在有效期内的套餐算有套餐**:套餐用完到真正停机之间的用量照常发事件,扣费时进最后一个套餐的超出部分(T10)。现在 `countActivePackages`(`traffic_calc_policy.go:148`)只认"生效中"、不看 `expires_at`,这段用量被记为已结算、不发事件,`recordOverflowToDepletedPackage` 实际收不到流量,需要改。 +- 已过期状态之所以纳入,是因为状态可能在采集之后才变为已过期;是否在采集时刻有效由 `expires_at` 判断。 +- 待实名激活、沉默期等待中的套餐处于待生效状态,不算有套餐,期间用量按 T9 记为已结算。 + +### 6.2 路径统一 + +测试环境**一次性全量切换**,不再灰度(T7): + +- 删掉 `TrafficCalcPolicy`(灰度名单)、卡表基线算法的扣费作用、第一次切换逻辑(`firstSwitch`)和切换水位(`tb_card_flow_switch` 不再读写)。 +- **切换时所有卡打"待建起点"标记**(随迁移执行,与换卡同一个标记,第 10 节)。原因:切换前扣费走的是卡表基线算法,日快照上没有本月已结算(测试库 `tb_card_flow_day` 为 0 行);不打标记,切换后第一次写入的 Δ 会是运营商读数对应的整月用量,而这些用量老路径已经扣过。打了标记,每张卡切换后第一次写入只建起点、不扣费。 +- 切换过程中老代码还在跑也安全:老代码不认标记,照常按老路径扣;新代码第一次写入只建起点,不会重复扣。 +- 代价:老路径最后一次扣费到新路径建起点之间的用量不扣(约 N−1 个采集周期),测试环境可接受。 +- 卡表上的 `current_month_usage_mb` 等展示字段由日快照覆盖(与现灰度路径一致);`last_gateway_reading_mb` 不再使用。 + +--- + +## 7. 留痕 + +### 7.1 采集记录(新表) + +只记**有套餐的卡**,每次采集一条,包括接口失败和读数为 0。 + +| 字段 | 说明 | +|---|---| +| `id`、`iot_card_id`、`observed_at` | | +| `observation_id` | 采集 ID,与流量事件、扣费审计、对接日志串联 | +| `source`、`scene` | 轮询、人工刷新、激活前结算 | +| `result` | 读数是否有效:`ok` / `failed` / `rate_limited` / `invalid_payload` / `no_reading` / `invalid_reading` | +| `vendor_used_mb` | 运营商原始读数,可为空 | +| `queue_points` | 入队后的队列(jsonb:采集时间 + 读数) | +| `decision` | 读数有效之后怎么判断的,`result ≠ ok` 时为空:`written` / `baseline`(建起点)/ `queue_not_full` / `slope_negative` / `out_of_order` / `settled_existing`(激活前结算没写入,用已有本月最大快照结算) | +| `month_used_mb`、`stale_guarded` | 写入时才有:换算后月累计、是否触发防旧值 | +| `delta_mb`、`event_id` | 写入时才有:本次 Δ、流量事件 ID | +| `created_at` | | + +- 按 `observed_at` 按月分区,保留 90 天,过期直接删分区(不用 `DELETE ... LIMIT`)。整月都过期才能删,所以同时存在 4 个分区(约 120 天的数据)。 +- 数据量:每天行数 ≈ 有套餐的卡数 × 每天采集次数。例:10 万张卡、间隔 4.8 小时(每天 5 次),每天 50 万行,4 个分区约 6000 万行。 +- 索引:`(iot_card_id, observed_at DESC)`、`observation_id`。 + +### 7.2 排查路径 + +``` +扣费审计(package_usage.deduct_traffic:扣了哪个套餐、多少、前后值) + → parent_event_id → 流量事件(卡、增量、采集时间、采集 ID) + → observation_id → 采集记录(当时的原始读数、队列、判断结果) + → 卡 + 日期 → 日快照(当天最大值、已结算) +``` + +--- + +## 8. 扣减 + +扣减逻辑沿用 `internal/service/package/usage_service.go` 的 `DeductDataUsage`:小数部分攒在 Redis;加油包优先,再按 `priority`、`id`;每个套餐容量 = 阈值 − 已用(启用虚流量时阈值为虚总量,否则为真总量);最后一个套餐承接超出部分;全部用完触发停机。 + +改动两点: + +### 8.1 按采集时刻确定套餐 + +- 流量事件已带 `observed_at`,消费者把它传给扣减。 +- 查套餐由"当前 `status = 生效中`"改为"**采集时刻处于生效期内**":已激活、`activated_at ≤ observed_at`、未到期(`expires_at > observed_at`)、未退款、未作废;设备回退口径不变。 +- 查询条件与 6.1.1 共用同一个函数,采集侧和扣费侧不会出现"采集说有、扣费说没有"(采集后退款、作废的情况除外,见下)。 +- 采集时刻生效、但扣费时已用完或已到期的套餐**照扣**(T10)。 +- 排序、容量、超出承接规则不变。 + +**状态迁移只作用于当前"生效中"的套餐**(T19)。现有循环(`usage_service.go:124-166`)在余量 ≤ 0 或扣到阈值时把套餐改成"已用完"。查询范围扩大后,集合里会有已过期、已用完的套餐,按现有写法会把"已过期"改回"已用完"。改为: + +| 套餐当前状态 | 累加 `data_usage_mb`、写日用量 | 改状态 | +|---|---|---| +| 生效中 | 是 | 扣到阈值改为已用完(同现在) | +| 已用完 | 是(只会作为最后一个套餐承接超出) | 否 | +| 已过期 | 是 | 否 | + +- 余量 ≤ 0 且不是最后一个套餐的,跳过;只有当前是"生效中"的才顺带改为已用完。 +- 停机判断 `checkAndTriggerSuspension` 不变,仍按**当前**状态判断是否还有可用套餐。 + +**扣费时一个套餐都查不到**:采集时刻有套餐(所以发了事件),扣费前该套餐被退款或作废。现在 `DeductDataUsage` 返回"没有可用套餐"错误,消费者会一直重试。改为:记告警日志和扣费审计(标明跳过原因),事件按成功处理,不重试;该笔用量需要时走人工冲正。 + +**采集之后套餐流量被重置**:日/月重置任务(`reset_service.go`)把 `data_usage_mb` 清零。采集时刻早于套餐 `last_reset_at`、扣费在重置之后的,**扣在重置后的周期**,不跳过(跳过就是少扣)。审计里记录 `observed_at` 与 `last_reset_at`,排查时能看出这笔是跨重置扣的。列入第 14 节已知限制。 + +### 8.2 套餐日用量 + +- `updateDailyRecord` 由"先查后插"改为 upsert(`(package_usage_id, date)` 唯一),`daily_usage_mb` 累加、`cumulative_usage_mb` 覆盖。 +- `date` 取采集日期(`cardflow.ShanghaiDate(observed_at)`),不取扣费时间。 +- 消费者的日流量缓冲 key(`event.go` 里 `RedisCardDailyTrafficKey(..., event.ObservedAt.Format(...))`)同样改为上海日期;现在直接 `Format`,时间值是 UTC 时上海 0~8 点会落到前一天(T24)。 + +--- + +## 9. 其他读数来源 + +| 来源 | 处理 | +|---|---| +| 轮询 | 第 2~6 节 | +| 人工刷新 | 同一个队列、同一套规则;接口返回本次读数、是否写入、不写原因 | +| 激活前结算 | 读数同样过队列;本次没写入时,用卡上已有的本月最大快照做结算(把它全部记为已结算),不新写快照,`decision = settled_existing` | + +激活前结算没写入时,"最近一次写入到激活"之间的用量会算给新套餐,精度到写入粒度(第 14 节)。 + +--- + +## 10. 换卡 + +套餐使用记录随换卡整条迁到新卡(`internal/service/exchange/migration.go:103`),已用量跟着走,所以不需要老系统那套"旧卡流量补算"(`tbl_new_card_add_flow`)。需要处理的是新卡的起点: + +| 问题 | 例子 | 处理 | +|---|---|---| +| 新卡换卡前已有读数 | 出库前测试用了 50 MB,库存期间没轮询 | 新卡**建起点**(下文),这 50 MB 不扣 | +| 旧卡最后一段 | 最后一次采集 10:00,换卡完成 10:30 | 不处理:旧卡之后的采集按没套餐处理,记为已结算(与 T9 一致) | + +**建起点**: + +1. 换卡事务内,在新卡上打"待建起点"标记(`tb_iot_card` 新增标记字段),不在换卡流程里调用运营商接口。 +2. 新卡的读数照常过队列。**第一次满足写入条件**时,看到标记:写日快照,把新卡本月最大快照全部记为已结算,不发事件,清掉标记,写采集记录(`decision = baseline`)。 +3. 之后按正常规则处理。 + +建起点也过队列,是为了不让一个异常读数当起点:第一个读数是 99999 时如果直接当起点,本月最大值一直是 99999,之后整月增量为 0、不扣。 + +N=3、间隔 1 小时的例子: + +| 时间 | 事件 | 读数 | 队列 | 处理 | +|---|---|---|---|---| +| 10-20 10:00 | 换卡完成 | — | — | 打标记 | +| 10-20 11:00 | 采集 | 52 | 52 | 不满,不写 | +| 10-20 12:00 | 采集 | 53 | 52, 53 | 不满,不写 | +| 10-20 13:00 | 采集 | 55 | 52, 53, 55 | 写 55,建起点:55 记为已结算,不扣,清标记 | +| 10-20 14:00 | 采集 | 58 | 53, 55, 58 | 写 58,扣 3 | + +代价:换卡完成到建起点之间的用量不扣(比直接用第一个读数多 N−1 个采集周期),与"旧卡最后一段不补扣"口径一致。 + +设备换货时,设备套餐迁到新设备,新设备绑定的每张卡同样打标记。 + +--- + +## 11. 人工冲正 + +新增冲正流水表,冲正只通过它进行,不直接改套餐字段。 + +| 字段 | 说明 | +|---|---| +| `id`、`package_usage_id`、`iot_card_id` / `device_id` | | +| `delta_mb` | 正数补扣,负数退回 | +| `before_usage_mb`、`after_usage_mb` | 套餐已用量前后值 | +| `reason` | 必填 | +| `ref_observation_id`、`ref_event_id` | 关联的采集记录、流量事件,可选 | +| `operator_id`、`created_at` | | + +处理: + +1. 事务内先 `SELECT ... FOR UPDATE` 锁套餐行(与扣减同一把锁),按**锁住后的当前值** + `delta_mb` 计算新值,`before_usage_mb` 取锁住后的值。避免与扣减并发时互相覆盖。 +2. 退回后已用量小于 0 直接报错,不截成 0。 +3. 同一事务内写流水、更新套餐 `data_usage_mb`,按阈值重新计算状态(已用完 ↔ 生效中);已过期、已失效的套餐只改已用量,不改状态(与 8.1 一致)。 +4. 写审计(新动作码,例如 `package_usage.adjust_traffic`)。 +5. 提交后调用现有停复机判断(`evaluator.EvaluateAndAct`),**不直接复机或停机**:卡可能同时因为实名、沉默期、人工等原因停着,冲正只改变了"套餐用完"这一个条件,由停复机判断综合决定。 +6. 卡上的日快照与已结算不改,冲正只作用在套餐上。 + +--- + +## 12. 监控与告警 + +| 指标 | 告警条件 | +|---|---| +| 有套餐的卡一整天没写入 | 开启轮询的卡,当天采集记录里没有 `decision = written`(T13) | +| 队列拦截率 | 某张卡当天 `slope_negative` 占比过高,或全局拦截率突增 | +| 待建起点 | 开启轮询的卡,"待建起点"标记超过 1 天未清除(一直没写入);不轮询的卡不告警 | +| 乱序 | `out_of_order` 数量突增(排查并发采集或时钟问题) | +| 防旧值命中 | 重置日命中数,便于核对重置日配置 | + +--- + +## 13. 改动清单 + +| 位置 | 改动 | +|---|---| +| `internal/gateway/models.go` | 流量读数区分"没值"和 0,零值表示没值 | +| `internal/task/polling_carddata_handler.go` | 无效读数分类;调用队列;写采集记录 | +| 新增队列组件(Redis + Lua)、配置项注册 | 第 4 节;返回新点是否在队尾;写入判断(不下降 + 容差、写最后一个点) | +| `internal/application/cardobservation/traffic.go`、`traffic_flow.go` | 按 6.1 的写入范围;确保套餐日用量 0 行;通道阈值只在写入点判断;删除未切换分支、`firstSwitch` 与切换水位 | +| `internal/domain/cardobservation/traffic.go` | 基线算法下线 | +| `internal/application/cardobservation/traffic_calc_policy.go` | `hasActivePackageForCard` 改为调用 6.1.1 的共用判断(含已用完、按 `expires_at`、排除退款);删除灰度名单 | +| `internal/domain/cardflow/rules.go` | `ValidReading` 放宽为"有限且 ≥ 0";`isStale` 去掉 `ThisMonthBeforeResetMax > 0` 条件,比较基准为 0 时不论配置的重置日是几号都照常防旧值;调用方注释同步 | +| `internal/infrastructure/cardobservation/event.go` | 把 `observed_at` 传给扣减;日流量缓冲 key 用上海日期 | +| `internal/service/package/usage_service.go` | 按采集时刻查套餐(与采集侧共用判断);状态迁移只作用于当前生效中的套餐;查不到套餐按成功跳过;`updateDailyRecord` upsert,日期按采集日期(上海);提供"确保当天日用量行"方法供观测事务调用 | +| 人工刷新、激活前结算入口 | 过队列;人工刷新返回写入结果 | +| `internal/service/exchange` | 换卡完成时给新卡打"待建起点"标记 | +| 观测写入 | 有"待建起点"标记时,第一次满足写入条件时建起点并清标记 | +| 轮询配置保存/启用、队列配置保存 | 采集间隔上限校验(24h 或 `max_point_age_hours` ÷ (N+2)),只校验启用中的配置 | +| 新表 + 迁移 | 采集记录(按月分区)、冲正流水;`tb_iot_card` 待建起点标记,迁移时**所有卡置为待建起点**(6.2);`(package_usage_id, date)` 唯一索引 | +| 后台接口 | 采集记录查询、冲正(锁套餐行、不允许负数、提交后走停复机判断) | + +--- + +## 14. 已知限制 + +| 限制 | 说明 | +|---|---| +| 最后一个点暴涨 | 队列挡不住,靠人工冲正 | +| 运营商连续返回错误且递增 | 队列挡不住,靠人工冲正 | +| 运营商整体抬高、事后下调 | 队列挡不住;初期靠人工,后期可接运营商 T+1 数据对账 | +| 月末尾巴 | 最后一次**写入**的旧周期读数到运营商归零之间的用量丢失,与老系统一致 | +| 防旧值比较基准为 0 | 没有快照可比(新卡、之前没写入)时,配置的重置日当天任何正读数都按 0 计,次日如实写入;只会晚一天扣,不会多扣 | +| 运营商跨天才归零 | 防旧值只管重置日当天(T6,不设警惕期);运营商晚一天才清零、重置日当天没取到数后第二天拿到旧数、清零后又返回缓存旧数,这些读数都会被当成新周期用量,造成多扣 | +| 写入粒度 | 激活前结算、换卡、没套餐 → 有套餐的切换点,精度都到"最近一次写入" | +| 晚写 | 队列需要攒满 N 个点,读数回退超过容差时会一直不写,扣费与停机相应延后 | +| 切换和换卡的起点 | 切换、换卡后到建起点之间的用量不扣(约 N−1 个采集周期) | +| 跨套餐流量重置 | 采集在套餐重置前、扣费在重置后的,扣在重置后的周期 | +| 采集后退款、作废 | 扣费时查不到套餐,跳过不扣,需要时人工冲正 | + +--- + +## 15. 后续 + +- **对账**:接入运营商 T+1 日用量或月账单后,定期从日快照(或运营商数据)重算,与套餐已用量比对;多扣自动退回、少扣告警,调整同样走冲正流水。 +- **月末尾巴**:重置日把旧周期读数的增量补上(旧周期读数 ≥ 上次旧周期读数时,差额计入本月),可选。 diff --git a/internal/application/cardobservation/apply.go b/internal/application/cardobservation/apply.go index 6f215334..476f9518 100644 --- a/internal/application/cardobservation/apply.go +++ b/internal/application/cardobservation/apply.go @@ -74,9 +74,11 @@ type Service struct { auditWriter StateAuditWriter // channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。 channelThreshold ChannelThresholdEvaluator - // trafficCalc 判定卡是否走日快照计量算法;未注入时全部卡走卡表基线算法且不写快照。 - trafficCalc *TrafficCalcPolicy - // logger 记录快照写入失败等不影响观测结果的告警;未注入时不输出。 + // trafficQueue 是卡流量采集队列;未注入时流量观测报错。 + trafficQueue TrafficQueuePort + // trafficQueueSettings 返回采集队列配置;未注入时使用代码默认值。 + trafficQueueSettings TrafficQueueSettingsSource + // logger 记录采集记录写入失败等不影响观测结果的告警;未注入时不输出。 logger *zap.Logger // commandGrace 返回停复机成功后的状态保护窗口;未注入时不保护。 commandGrace func(ctx context.Context) time.Duration @@ -94,9 +96,10 @@ func (s *Service) currentCommandGrace(ctx context.Context) time.Duration { return s.commandGrace(ctx) } -// SetTrafficCalcPolicy 注入卡流量计量切换策略与告警日志。 -func (s *Service) SetTrafficCalcPolicy(policy *TrafficCalcPolicy, logger *zap.Logger) { - s.trafficCalc = policy +// SetTrafficQueue 注入卡流量采集队列、队列配置与告警日志。 +func (s *Service) SetTrafficQueue(queue TrafficQueuePort, settings TrafficQueueSettingsSource, logger *zap.Logger) { + s.trafficQueue = queue + s.trafficQueueSettings = settings s.logger = logger } @@ -305,11 +308,3 @@ func realnameChangedEventID(cardID uint, observationID string) string { prefix := "card-realname:" return outboxid.Stable(prefix, strconv.FormatUint(uint64(cardID), 10)+":"+observationID+":changed") } - -// TrafficSwitched 判定卡是否已切换到日快照计量(激活前流量结算只对已切换的卡执行)。 -func (s *Service) TrafficSwitched(cardID uint) bool { - if s == nil { - return false - } - return s.trafficCalc.Switched(cardID) -} diff --git a/internal/application/cardobservation/traffic.go b/internal/application/cardobservation/traffic.go index b5fe25ef..2cca730c 100644 --- a/internal/application/cardobservation/traffic.go +++ b/internal/application/cardobservation/traffic.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -12,6 +13,7 @@ import ( 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" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/outboxid" @@ -19,24 +21,56 @@ import ( // TrafficIncrementedEvent 是卡流量正增量的可靠领域事实。 type TrafficIncrementedEvent struct { - EventID string `json:"event_id"` - CardID uint `json:"card_id"` - IncrementMB float64 `json:"increment_mb"` - ObservedAt time.Time `json:"observed_at"` - Source string `json:"source"` - Scene string `json:"scene"` - RequestID string `json:"request_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` + EventID string `json:"event_id"` + CardID uint `json:"card_id"` + IncrementMB float64 `json:"increment_mb"` + ObservedAt time.Time `json:"observed_at"` + // ObservationID 是采集 ID,扣费审计据此追溯到采集记录。 + ObservationID string `json:"observation_id,omitempty"` + Source string `json:"source"` + Scene string `json:"scene"` + RequestID string `json:"request_id,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` } -// ApplyTrafficObservation 串行应用流量读数,并在正增量时同事务写 Outbox。 +// TrafficQueuePort 是卡流量采集队列:原子入队并返回入队后的队列与新点是否在队尾。 +type TrafficQueuePort interface { + Push(ctx context.Context, cardID uint, point cardflow.QueuePoint, settings TrafficQueueSettings) ([]cardflow.QueuePoint, bool, error) +} + +// TrafficQueueSettingsSource 返回当前采集队列配置。 +type TrafficQueueSettingsSource interface { + Current(ctx context.Context) TrafficQueueSettings +} + +// ApplyTrafficObservation 应用一次流量采集:读数先进采集队列,满足写入条件才写日快照、按月对账、 +// 在采集时刻有套餐时同事务写流量事件;有套餐的卡每次采集写一条采集记录。 func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domain.TrafficObservation) (domain.TrafficDecision, error) { - if s == nil || s.db == nil || s.eventWriter == nil { + if s == nil || s.db == nil || s.eventWriter == nil || s.trafficQueue == nil { return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置") } - var decision domain.TrafficDecision - var auditedCard *model.IotCard - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := domain.ValidateTrafficObservation(observation); err != nil { + return domain.TrafficDecision{}, err + } + record := newCollectionRecord(observation) + decision := domain.TrafficDecision{Result: record.Result} + if record.Result != constants.TrafficCollectionResultOK { + s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt) + return decision, nil + } + reading := observation.GatewayReadingMB + decision.ReadingMB = &reading + settings := s.queueSettings() + points, tail, err := s.trafficQueue.Push(ctx, observation.CardID, + cardflow.QueuePoint{ObservedAt: observation.Metadata.ObservedAt, ReadingMB: reading}, settings) + if err != nil { + return decision, err + } + record.setQueue(points) + judge := cardflow.JudgeQueue(points, tail, settings.Size, settings.DropToleranceMB) + record.Decision = judge + var outcome *flowOutcome + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var card model.IotCard if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil { if err == gorm.ErrRecordNotFound { @@ -44,164 +78,177 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai } return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败") } - auditedCard = &card - nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{ - CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB, - CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB, - LastGatewayReadingMB: card.LastGatewayReadingMB, - }, observation) - if decisionErr != nil { - return decisionErr + decision.CurrentMonthUsageMB, decision.LastGatewayReadingMB = card.CurrentMonthUsageMB, card.LastGatewayReadingMB + packages, err := postgres.FindEffectivePackagesAt(ctx, tx, card.ID, observation.Metadata.ObservedAt, false) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询卡采集时刻有效套餐失败") } - 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 + record.HasPackage = len(packages.Packages) > 0 + if judge != cardflow.QueueWrite { + if observation.Metadata.Scene == constants.CardObservationScenePreActivationSettlement { + // 激活前结算没写入:用已有本月最大快照结算,不新写快照。 + if err := s.settleExisting(ctx, tx, &card, observation.Metadata.ObservedAt); err != nil { + return err + } + record.Decision = constants.TrafficDecisionSettledExisting } - flow = outcome + return touchTrafficCheck(tx, card.ID, observation.Metadata.ObservedAt) } - 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, - "current_month_usage_mb": decision.CurrentMonthUsageMB, "data_usage_mb": decision.DataUsageMB, - } - if decision.ReadingAccepted { - updates["last_gateway_reading_mb"] = decision.LastGatewayReadingMB - } - result := tx.Model(&model.IotCard{}). - Where("id = ? AND last_gateway_reading_mb = ?", card.ID, card.LastGatewayReadingMB). - Updates(updates) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量事实失败") - } - if result.RowsAffected != 1 { - 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, - ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source, - Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID, - CorrelationID: observation.Metadata.CorrelationID, - }); err != nil { - return err - } - } - evaluateThreshold := decision.ReadingAccepted - if switched { - evaluateThreshold = switchedThresholdEvaluable(flow, decision.ReadingAccepted) - } - if observation.Metadata.Scene == constants.CardObservationScenePreActivationSettlement { - // 激活前结算只记已结算用量,不触发停复机评估;下一次正常轮询观测照常判定。 - evaluateThreshold = false - } - if evaluateThreshold && s.channelThreshold != nil { - // 未切换卡只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定; - // 已切换卡读数有效且未被重置日防旧值处理即判定,不再受卡表基线影响。 - // 判定失败必须回滚整个流量事务,与既有流量事实保持同事务语义。 - if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{ - CardID: card.ID, CarrierID: card.CarrierID, - ReadingMB: thresholdReading(switched, flow, observation, decision), ObservedAt: observation.Metadata.ObservedAt, - // 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。 - Source: observation.Metadata.Source, - }); err != nil { - return err - } - } - stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB - if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited { - if s.auditWriter == nil { - return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置") - } - if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{ - ActionCode: actionCode, - Summary: "人工刷新 IoT 卡流量", - Card: &card, - BeforeData: map[string]any{ - "data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB, - "current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB, - "last_gateway_reading_mb": card.LastGatewayReadingMB, - }, - AfterData: map[string]any{ - "data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB, - "current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB, - "last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB, - }, - }); err != nil { - return err - } - } else if workerObservationAudited(ctx) && stateChanged { - if s.auditWriter == nil { - return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置") - } - if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{ - ActionCode: constants.AuditActionIotCardWorkerTrafficSynced, - Summary: "Worker 同步 IoT 卡流量事实", Card: &card, - IntegrationID: observation.Metadata.ObservationID, - BeforeData: map[string]any{ - "data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB, - "current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB, - "last_gateway_reading_mb": card.LastGatewayReadingMB, - }, - AfterData: map[string]any{ - "data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB, - "current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB, - "last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB, - }, - }); err != nil { - return err - } + next, err := s.applyWrittenPoint(ctx, tx, &card, observation, packages) + if err != nil { + return err } + outcome = next return nil }) if err != nil { - if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil { - s.auditWriter.WriteCardStateFailure(ctx, StateAudit{ - ActionCode: constants.AuditActionIotCardWorkerTrafficSynced, - Summary: "Worker 同步 IoT 卡流量事实失败", Card: auditedCard, - IntegrationID: observation.Metadata.ObservationID, - }, err) - } + s.writeTrafficFailureAudit(ctx, observation, err) return domain.TrafficDecision{}, err } + if outcome != nil { + record.applyOutcome(outcome) + decision.ReadingAccepted = outcome.Written + decision.IncrementMB = outcome.EmitMB + decision.CrossMonth = outcome.CrossMonth + decision.CurrentMonthUsageMB = outcome.MonthMaxMB + } + decision.Decision = record.Decision + s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt) 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 +// applyWrittenPoint 在观测事务内处理一个通过队列判断的写入点。 +func (s *Service) applyWrittenPoint(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, packages postgres.EffectivePackages) (*flowOutcome, error) { + hasPackage := len(packages.Packages) > 0 + reading := observation.GatewayReadingMB + observedAt := observation.Metadata.ObservedAt + outcome := &flowOutcome{MonthMaxMB: card.CurrentMonthUsageMB, Decision: constants.TrafficDecisionWritten} + if reading == 0 && !hasPackage { + // 没套餐的卡读数为 0 不写快照。 + return outcome, touchTrafficCheck(tx, card.ID, observedAt) } - if flow.FirstSwitch { - return oldAccepted + flow, err := s.writeFlow(ctx, tx, card, reading, observedAt, hasPackage) + if err != nil { + return nil, err } - return !flow.StaleGuarded + outcome.Written, outcome.MonthMaxMB, outcome.StaleGuarded = true, flow.monthMax, flow.conversion.StaleGuarded + outcome.MonthUsedMB, outcome.DeltaMB = flow.conversion.MonthUsedMB, flow.delta + if card.TrafficBaselinePending { + outcome.Decision = constants.TrafficDecisionBaseline + } else if hasPackage && flow.delta > 0 { + outcome.EmitMB = flow.delta + } + if hasPackage { + if err := ensureDailyUsageRows(ctx, tx, packages.Packages, flow.flowDate); err != nil { + return nil, err + } + } + monthStart := cardflow.MonthStart(flow.flowDate) + outcome.CrossMonth = card.CurrentMonthStartDate == nil || card.CurrentMonthStartDate.Before(monthStart) + updates := map[string]any{ + "last_data_check_at": observedAt, "last_sync_time": observedAt, + "current_month_usage_mb": flow.monthMax, "current_month_start_date": monthStart, + "data_usage_mb": card.DataUsageMB + int64(flow.delta), "traffic_baseline_pending": false, + } + if outcome.CrossMonth { + updates["last_month_total_mb"] = card.CurrentMonthUsageMB + } + if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(updates).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量事实失败") + } + if outcome.EmitMB > 0 { + outcome.EventID = outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented") + if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{ + EventID: outcome.EventID, CardID: card.ID, IncrementMB: outcome.EmitMB, + ObservedAt: observedAt, ObservationID: observation.Metadata.ObservationID, + Source: observation.Metadata.Source, Scene: observation.Metadata.Scene, + RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID, + }); err != nil { + return nil, err + } + } + // 通道阈值只在写入点判断:读数大于 0 且未被防旧值按 0 处理;激活前结算不触发停复机评估。 + if reading > 0 && !outcome.StaleGuarded && s.channelThreshold != nil && + observation.Metadata.Scene != constants.CardObservationScenePreActivationSettlement { + if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{ + CardID: card.ID, CarrierID: card.CarrierID, ReadingMB: reading, ObservedAt: observedAt, + // 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。 + Source: observation.Metadata.Source, + }); err != nil { + return nil, err + } + } + if err := s.writeTrafficStateAudit(ctx, tx, card, observation, outcome); err != nil { + return nil, err + } + return outcome, nil } -// thresholdReading 返回通道阈值判定使用的网关累计读数:已切换卡(非第一次切换观测)用本次读数。 -func thresholdReading(switched bool, flow *switchedFlowOutcome, observation domain.TrafficObservation, decision domain.TrafficDecision) float64 { - if switched && flow != nil && !flow.FirstSwitch { - return observation.GatewayReadingMB +// touchTrafficCheck 只刷新卡的最近流量检查时间(本次没有写入快照)。 +func touchTrafficCheck(tx *gorm.DB, cardID uint, observedAt time.Time) error { + if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID). + Updates(map[string]any{"last_data_check_at": observedAt, "last_sync_time": observedAt}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量检查时间失败") } - return decision.LastGatewayReadingMB + return nil +} + +// writeTrafficStateAudit 在本次写入改变了卡流量事实时写统一审计(人工刷新或 Worker 观测)。 +func (s *Service) writeTrafficStateAudit(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, outcome *flowOutcome) error { + if outcome.DeltaMB <= 0 && !outcome.CrossMonth { + return nil + } + before := map[string]any{ + "data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB, + "current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB, + } + after := map[string]any{ + "data_usage_mb": card.DataUsageMB + int64(outcome.DeltaMB), "current_month_usage_mb": outcome.MonthMaxMB, + "increment_mb": outcome.EmitMB, "delta_mb": outcome.DeltaMB, "decision": outcome.Decision, + "observation_id": observation.Metadata.ObservationID, + } + input := StateAudit{Card: card, BeforeData: before, AfterData: after} + if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && audited { + input.ActionCode, input.Summary = actionCode, "人工刷新 IoT 卡流量" + } else if workerObservationAudited(ctx) { + input.ActionCode, input.Summary = constants.AuditActionIotCardWorkerTrafficSynced, "Worker 同步 IoT 卡流量事实" + input.IntegrationID = observation.Metadata.ObservationID + } else { + return nil + } + if s.auditWriter == nil { + return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置") + } + return s.auditWriter.WriteCardStateAudit(ctx, tx, input) +} + +// writeTrafficFailureAudit 记录 Worker 观测事务失败的审计。 +func (s *Service) writeTrafficFailureAudit(ctx context.Context, observation domain.TrafficObservation, err error) { + if !workerObservationAudited(ctx) || s.auditWriter == nil { + return + } + var card model.IotCard + if loadErr := s.db.WithContext(ctx).Where("id = ?", observation.CardID).First(&card).Error; loadErr != nil { + return + } + s.auditWriter.WriteCardStateFailure(ctx, StateAudit{ + ActionCode: constants.AuditActionIotCardWorkerTrafficSynced, + Summary: "Worker 同步 IoT 卡流量事实失败", Card: &card, + IntegrationID: observation.Metadata.ObservationID, + }, err) +} + +func (s *Service) queueSettings() TrafficQueueSettings { + if s.trafficQueueSettings == nil { + return DefaultTrafficQueueSettings() + } + return s.trafficQueueSettings.Current(context.Background()) +} + +// logTrafficWarn 记录不影响观测结果的告警。 +func (s *Service) logTrafficWarn(message string, fields ...zap.Field) { + s.warnLogger().Warn(message, fields...) } diff --git a/internal/application/cardobservation/traffic_calc_policy.go b/internal/application/cardobservation/traffic_calc_policy.go deleted file mode 100644 index 8b51daf6..00000000 --- a/internal/application/cardobservation/traffic_calc_policy.go +++ /dev/null @@ -1,171 +0,0 @@ -package cardobservation - -import ( - "time" - - "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, time.Time{}) - 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, time.Time{}) - return deviceActive > 0, err -} - -// hasActivePackageForCard 判定卡本次增量是否有可扣的生效中套餐:卡自身或当前绑定设备存在生效中套餐。 -// observedAt 非零时只认激活时间不晚于观测时间的生效套餐(流量按观测时间归属): -// 观测早于套餐激活的用量属于激活前,只记已结算、不扣新套餐;激活时间为空的历史套餐视为早已生效。 -func hasActivePackageForCard(ctx context.Context, db *gorm.DB, cardID uint, observedAt time.Time) (bool, error) { - ownActive, err := countActivePackages(ctx, db, "iot_card_id = ?", cardID, observedAt) - 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, observedAt) - return deviceActive > 0, err -} - -func countActivePackages(ctx context.Context, db *gorm.DB, carrierCondition string, carrierID uint, observedAt time.Time) (int64, error) { - var count int64 - query := db.WithContext(ctx).Model(&model.PackageUsage{}). - Where(carrierCondition, carrierID). - Where("status = ?", constants.PackageUsageStatusActive) - if !observedAt.IsZero() { - query = query.Where("activated_at IS NULL OR activated_at <= ?", observedAt) - } - err := query.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_collection.go b/internal/application/cardobservation/traffic_collection.go new file mode 100644 index 00000000..49e2f097 --- /dev/null +++ b/internal/application/cardobservation/traffic_collection.go @@ -0,0 +1,74 @@ +package cardobservation + +import ( + "context" + "encoding/json" + "time" + + "go.uber.org/zap" + + 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/constants" +) + +// collectionRecordTimeout 是事务提交后写采集记录的超时;采集记录只用于排查,写失败只告警。 +const collectionRecordTimeout = 3 * time.Second + +// collectionRecord 是一次采集的留痕内容;HasPackage 在观测事务内判断,读数无效的观测写记录前再查。 +type collectionRecord struct { + model.CardTrafficCollection + HasPackage bool +} + +func newCollectionRecord(observation domain.TrafficObservation) *collectionRecord { + record := &collectionRecord{CardTrafficCollection: model.CardTrafficCollection{ + IotCardID: observation.CardID, ObservedAt: observation.Metadata.ObservedAt, + ObservationID: observation.Metadata.ObservationID, Source: observation.Metadata.Source, + Scene: observation.Metadata.Scene, Result: domain.ResolveReadingResult(observation), + }} + if record.Result == constants.TrafficCollectionResultOK { + reading := observation.GatewayReadingMB + record.VendorUsedMB = &reading + } + return record +} + +func (r *collectionRecord) setQueue(points []cardflow.QueuePoint) { + if encoded, err := json.Marshal(points); err == nil { + r.QueuePoints = encoded + } +} + +func (r *collectionRecord) applyOutcome(outcome *flowOutcome) { + r.Decision = outcome.Decision + if !outcome.Written { + return + } + monthUsed, delta, stale := outcome.MonthUsedMB, outcome.DeltaMB, outcome.StaleGuarded + r.MonthUsedMB, r.DeltaMB, r.StaleGuarded, r.EventID = &monthUsed, &delta, &stale, outcome.EventID +} + +// writeCollectionRecord 在观测事务之外为采集时刻有套餐的卡写采集记录;没套餐的卡不写。 +func (s *Service) writeCollectionRecord(ctx context.Context, record *collectionRecord, observedAt time.Time) { + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), collectionRecordTimeout) + defer cancel() + hasPackage := record.HasPackage + if record.Result != constants.TrafficCollectionResultOK { + packages, err := postgres.FindEffectivePackagesAt(writeCtx, s.db, record.IotCardID, observedAt, false) + if err != nil { + s.logTrafficWarn("查询卡采集时刻有效套餐失败,跳过采集记录", zap.Uint("card_id", record.IotCardID), zap.Error(err)) + return + } + hasPackage = len(packages.Packages) > 0 + } + if !hasPackage { + return + } + if err := postgres.NewCardTrafficCollectionStore(s.db).Create(writeCtx, &record.CardTrafficCollection); err != nil { + s.logTrafficWarn("写卡流量采集记录失败", zap.Uint("card_id", record.IotCardID), + zap.String("observation_id", record.ObservationID), zap.Error(err)) + } +} diff --git a/internal/application/cardobservation/traffic_flow.go b/internal/application/cardobservation/traffic_flow.go index d26c7056..402aa485 100644 --- a/internal/application/cardobservation/traffic_flow.go +++ b/internal/application/cardobservation/traffic_flow.go @@ -8,50 +8,93 @@ import ( "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 表示不写事件。 +// flowOutcome 是一个写入点的日快照计量结果。 +type flowOutcome struct { + // Written 为 true 表示本次写入了日快照(没套餐且读数为 0 时为 false)。 + Written bool + // Decision 是写入判断:已写入或建起点。 + Decision string + // EmitMB 是本次写入流量事件的增量,0 表示不写事件。 EmitMB float64 - // MonthMaxMB 是写入本次快照后的本月最大快照,用于覆盖卡表本月用量。 - MonthMaxMB float64 + // DeltaMB 是本次按月对账计入已结算的增量(含没套餐、建起点时只记已结算的部分)。 + DeltaMB float64 + // MonthUsedMB 是本次读数换算后的本月累计;MonthMaxMB 是写入后的本月最大快照。 + MonthUsedMB float64 + MonthMaxMB float64 // StaleGuarded 为 true 表示本次读数在重置日触发防旧值、按 0 计。 StaleGuarded bool - // FirstSwitch 为 true 表示这是卡的第一次切换观测(本次写入了切换水位)。 - FirstSwitch bool + // CrossMonth 为 true 表示卡表本月用量跨入新的自然月。 + CrossMonth bool + // EventID 是本次写入的流量事件 ID。 + EventID string } -// snapshotResult 是一次快照换算与写入的结果。 -type snapshotResult struct { +// flowWrite 是一次快照写入与按月对账的结果。 +type flowWrite struct { flowDate time.Time conversion cardflow.Conversion + monthMax float64 + delta float64 } -// 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) +// writeFlow 按卡所属通道的重置日换算并原子写入当天快照,再按月对账把增量计入当天已结算。 +func (s *Service) writeFlow(ctx context.Context, tx *gorm.DB, card *model.IotCard, readingMB float64, observedAt time.Time, hasPackage bool) (flowWrite, error) { + resetDay := s.strictResetDay(ctx, tx, card) flowDate := cardflow.ShanghaiDate(observedAt) - days := postgres.NewCardFlowDayStore(db) + days := postgres.NewCardFlowDayStore(tx) monthCtx, err := days.MonthContext(ctx, card.ID, flowDate, resetDay) if err != nil { - return snapshotResult{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡日流量快照失败") + return flowWrite{}, 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 flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "写入卡日流量快照失败") } - return snapshotResult{flowDate: flowDate, conversion: conversion}, nil + monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate) + if err != nil { + return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败") + } + delta := cardflow.MonthlyDelta(monthMax, settled) + if err := days.AddSettled(ctx, card.ID, flowDate, delta); err != nil { + return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "结算卡流量增量失败") + } + return flowWrite{flowDate: flowDate, conversion: conversion, monthMax: monthMax, delta: delta}, nil +} + +// settleExisting 用卡上已有的本月最大快照结算(激活前结算本次没写入时):差额全部记为已结算,不新写快照。 +func (s *Service) settleExisting(ctx context.Context, tx *gorm.DB, card *model.IotCard, observedAt time.Time) error { + flowDate := cardflow.ShanghaiDate(observedAt) + days := postgres.NewCardFlowDayStore(tx) + monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败") + } + if err := days.AddSettledToLatest(ctx, card.ID, flowDate, cardflow.MonthlyDelta(monthMax, settled)); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "激活前结算已有快照失败") + } + return nil +} + +// ensureDailyUsageRows 为采集时刻每个有效套餐确保当天一行套餐日用量(没扣也写 0)。 +func ensureDailyUsageRows(ctx context.Context, tx *gorm.DB, packages []*model.PackageUsage, flowDate time.Time) error { + ids := make([]uint, 0, len(packages)) + cumulative := make(map[uint]int64, len(packages)) + for _, pkg := range packages { + ids = append(ids, pkg.ID) + cumulative[pkg.ID] = pkg.DataUsageMB + } + if err := postgres.EnsureDailyUsageRows(ctx, tx, ids, flowDate, cumulative); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐当天日用量失败") + } + return nil } // strictResetDay 在当前连接内按卡的 carrier_id 读取上游流量重置日,不使用调用方预先计算的值; @@ -73,78 +116,3 @@ func (s *Service) strictResetDay(ctx context.Context, db *gorm.DB, card *model.I } 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, observation.Metadata.ObservedAt) - 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/cardobservation/traffic_queue_settings.go b/internal/application/cardobservation/traffic_queue_settings.go new file mode 100644 index 00000000..5e5ec08a --- /dev/null +++ b/internal/application/cardobservation/traffic_queue_settings.go @@ -0,0 +1,193 @@ +package cardobservation + +import ( + "context" + stderrors "errors" + "math" + "strconv" + "sync" + "time" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// trafficQueueSettingsTTL 是队列配置在进程内的缓存时长;后台修改数秒内生效。 +const trafficQueueSettingsTTL = 5 * time.Second + +// TrafficQueueSettings 是卡流量采集队列的运行参数。 +type TrafficQueueSettings struct { + Size int + ClearOnNewDay bool + MaxPointAgeHours int + DropToleranceMB float64 +} + +// DefaultTrafficQueueSettings 返回代码默认值。 +func DefaultTrafficQueueSettings() TrafficQueueSettings { + return TrafficQueueSettings{ + Size: constants.TrafficQueueDefaultSize, ClearOnNewDay: true, + MaxPointAgeHours: constants.TrafficQueueDefaultMaxPointAgeHours, DropToleranceMB: constants.TrafficQueueDefaultDropToleranceMB, + } +} + +// TrafficIntervalChecker 校验队列配置变更后启用中轮询配置的流量采集间隔是否仍在上限内。 +type TrafficIntervalChecker func(settings TrafficQueueSettings) error + +// RegisterTrafficQueueConfigDefinitions 注册采集队列系统配置。checker 非空时,队列长度、跨天清空、最长点龄 +// 修改前用它校验启用中的轮询配置(读取其余三项的当前值组合成修改后的配置)。 +func RegisterTrafficQueueConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger, current func() TrafficQueueSettings, checker TrafficIntervalChecker) { + if registry == nil { + return + } + minSize, maxSize := int64(constants.TrafficQueueMinSize), int64(constants.TrafficQueueMaxSize) + minAge, maxAge := int64(0), int64(24*30) + withCheck := func(apply func(*TrafficQueueSettings, string) error) func(string) error { + return func(value string) error { + next := DefaultTrafficQueueSettings() + if current != nil { + next = current() + } + if err := apply(&next, value); err != nil { + return err + } + if checker == nil { + return nil + } + return checker(next) + } + } + definitions := []systemconfig.Definition{ + {Key: constants.SystemConfigTrafficQueueSize, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt, + DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultSize), Min: &minSize, Max: &maxSize, + Description: "卡流量采集队列长度 N:攒满 N 个不下降的读数才写最后一个点", + Validator: withCheck(func(s *TrafficQueueSettings, v string) error { + n, err := strconv.Atoi(v) + s.Size = n + return err + })}, + {Key: constants.SystemConfigTrafficQueueClearOnNewDay, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeBool, + DefaultValue: "true", Description: "卡流量采集队列跨天清空:新点与队列中的点不在同一上海日期时先清空", + Validator: withCheck(func(s *TrafficQueueSettings, v string) error { + s.ClearOnNewDay = v == "true" + return nil + })}, + {Key: constants.SystemConfigTrafficQueueMaxPointAgeHours, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt, + DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultMaxPointAgeHours), Min: &minAge, Max: &maxAge, + Description: "卡流量采集队列最长点龄(小时):入队时丢弃与新点相差超过该时长的点,0 表示不限", + Validator: withCheck(func(s *TrafficQueueSettings, v string) error { + n, err := strconv.Atoi(v) + s.MaxPointAgeHours = n + return err + })}, + {Key: constants.SystemConfigTrafficQueueDropToleranceMB, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeString, + DefaultValue: "1", Description: "卡流量采集队列回退容差(MB):相邻两点回退不超过该值算持平,0 表示不允许回退", + Validator: func(v string) error { + _, err := parseDropTolerance(v) + return err + }}, + } + for _, definition := range definitions { + if _, exists := registry.Get(definition.Key); exists { + continue + } + if err := registry.Register(definition); err != nil && logger != nil { + logger.Error("注册卡流量采集队列系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err)) + } + } +} + +func parseDropTolerance(value string) (float64, error) { + tolerance, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) || tolerance < 0 || tolerance > 1024 { + return 0, stderrors.New("回退容差必须是 0~1024 之间的数字(MB)") + } + return tolerance, nil +} + +// trafficConfigReader 读取系统配置的最小接缝。 +type trafficConfigReader interface { + Get(ctx context.Context, key string) (string, error) +} + +// TrafficQueueSettingsProvider 读取采集队列配置,带短缓存;读取失败时使用代码默认值。 +type TrafficQueueSettingsProvider struct { + reader trafficConfigReader + logger *zap.Logger + + mu sync.Mutex + cached TrafficQueueSettings + expiresAt time.Time +} + +// NewTrafficQueueSettingsProvider 创建采集队列配置读取器。 +func NewTrafficQueueSettingsProvider(reader trafficConfigReader, logger *zap.Logger) *TrafficQueueSettingsProvider { + if logger == nil { + logger = zap.NewNop() + } + return &TrafficQueueSettingsProvider{reader: reader, logger: logger} +} + +// NewDefaultTrafficQueueSettingsProvider 用独立注册表与读取器创建采集队列配置读取器(API 与 Worker 共用同一份数据)。 +func NewDefaultTrafficQueueSettingsProvider(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *TrafficQueueSettingsProvider { + registry := systemconfig.NewRegistry() + RegisterTrafficQueueConfigDefinitions(registry, logger, nil, nil) + var cache systemconfig.Cache + if redisClient != nil { + cache = systemconfig.NewRedisCache(redisClient) + } + reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger)) + return NewTrafficQueueSettingsProvider(reader, logger) +} + +// Current 返回当前采集队列配置。 +func (p *TrafficQueueSettingsProvider) Current(ctx context.Context) TrafficQueueSettings { + if p == nil || p.reader == nil { + return DefaultTrafficQueueSettings() + } + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + if now.Before(p.expiresAt) { + return p.cached + } + settings, err := p.load(ctx) + if err != nil { + p.logger.Warn("读取卡流量采集队列配置失败,使用代码默认值", zap.Error(err)) + settings = DefaultTrafficQueueSettings() + } + p.cached, p.expiresAt = settings, now.Add(trafficQueueSettingsTTL) + return settings +} + +func (p *TrafficQueueSettingsProvider) load(ctx context.Context) (TrafficQueueSettings, error) { + settings := DefaultTrafficQueueSettings() + raw, err := p.reader.Get(ctx, constants.SystemConfigTrafficQueueSize) + if err != nil { + return settings, err + } + if settings.Size, err = strconv.Atoi(raw); err != nil || settings.Size < constants.TrafficQueueMinSize { + return DefaultTrafficQueueSettings(), stderrors.New("采集队列长度配置非法") + } + if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueClearOnNewDay); err != nil { + return DefaultTrafficQueueSettings(), err + } + settings.ClearOnNewDay = raw == "true" + if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueMaxPointAgeHours); err != nil { + return DefaultTrafficQueueSettings(), err + } + if settings.MaxPointAgeHours, err = strconv.Atoi(raw); err != nil || settings.MaxPointAgeHours < 0 { + return DefaultTrafficQueueSettings(), stderrors.New("采集队列最长点龄配置非法") + } + if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueDropToleranceMB); err != nil { + return DefaultTrafficQueueSettings(), err + } + if settings.DropToleranceMB, err = parseDropTolerance(raw); err != nil { + return DefaultTrafficQueueSettings(), err + } + return settings, nil +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 9d9eb1e0..18c7aacd 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -1,8 +1,10 @@ package bootstrap import ( + "context" agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" businessUserGroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" h5PopupApp "github.com/break/junhong_cmp_fiber/internal/application/h5popup" merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" @@ -129,6 +131,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { gatewaylimit.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger) carrierqueue.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger) packagesilenceApp.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger) + registerTrafficQueueConfigDefinitions(systemConfigRegistry, deps) systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger) var systemConfigCache systemConfigInfra.Cache if deps.Redis != nil { @@ -437,5 +440,19 @@ func newAssetPollingService(svc *services, deps *Dependencies) *pollingSvcPkg.As ) svc.Order.SetPollingRestorer(assetPollingSvc) svc.Asset.SetPollingRestorer(assetPollingSvc, svc.StopResumeService, deps.Logger) + svc.Asset.SetTrafficAdjustmentEvaluator(svc.StopResumeService) return assetPollingSvc } + +// registerTrafficQueueConfigDefinitions 注册卡流量采集队列配置:修改队列长度、跨天清空、最长点龄前 +// 校验全部启用中的轮询配置的流量采集间隔仍在上限内,否则拒绝修改。 +func registerTrafficQueueConfigDefinitions(registry *systemConfigInfra.Registry, deps *Dependencies) { + settings := cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger) + current := func() cardObservationApp.TrafficQueueSettings { return settings.Current(context.Background()) } + checker := func(next cardObservationApp.TrafficQueueSettings) error { + return pollingSvcPkg.CheckEnabledTrafficIntervals(context.Background(), deps.DB, pollingSvcPkg.TrafficQueueLimits{ + Size: next.Size, ClearOnNewDay: next.ClearOnNewDay, MaxPointAgeHours: next.MaxPointAgeHours, + }) + } + cardObservationApp.RegisterTrafficQueueConfigDefinitions(registry, deps.Logger, current, checker) +} diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index bef782f1..408fc70a 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -207,12 +207,9 @@ 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) + // 卡流量采集队列:读数先进每卡 Redis 队列,满足写入条件才写日快照;队列配置为受控系统配置。 + cardObservationService.SetTrafficQueue(cardObservationInfra.NewTrafficQueue(deps.Redis), + cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger), deps.Logger) cardObservationService.SetCommandGrace(commandGraceProvider(deps.DB, deps.Redis, deps.Logger)) iotCard.SetCardObservationService(cardObservationService) iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB)) @@ -509,6 +506,11 @@ func initServices(s *stores, deps *Dependencies) *services { pollingConfigService := pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger) pollingConfigService.SetAudit(deps.DB, auditWriter) pollingConfigService.SetTierDeps(pollingConfigMgr, deps.QueueClient) + trafficQueueSettings := cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger) + pollingConfigService.SetTrafficQueueLimits(func(ctx context.Context) pollingSvc.TrafficQueueLimits { + current := trafficQueueSettings.Current(ctx) + return pollingSvc.TrafficQueueLimits{Size: current.Size, ClearOnNewDay: current.ClearOnNewDay, MaxPointAgeHours: current.MaxPointAgeHours} + }) pollingConcurrencyService := pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis) pollingConcurrencyService.SetAudit(deps.DB, auditWriter) pollingAlertService := pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger) @@ -604,7 +606,7 @@ func initServices(s *stores, deps *Dependencies) *services { AgentRechargePaymentConfirm: agentRechargePaymentConfirm, AgentRechargeVoucherOCR: agentRechargeVoucherOCR, PackageActivation: packageActivation, - TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage).WithFlowSnapshots(trafficCalcPolicy.Switched, postgres.NewCardFlowDayStore(deps.DB)), + TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage).WithFlowSnapshots(func(uint) bool { return true }, 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 aac5fea9..573132a8 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -1,13 +1,10 @@ package bootstrap import ( - "context" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" - "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" @@ -140,11 +137,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q // 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。 cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService) cardObservationService.SetCommandGrace(commandGraceProvider(deps.DB, deps.Redis, deps.Logger)) - // 卡流量计量切换策略:启动时展开灰度名单为内存卡集合,API 与全部 Worker 必须使用一致配置。 - if cfg := config.Get(); cfg != nil { - cardObservationService.SetTrafficCalcPolicy( - cardObservationApp.BuildTrafficCalcPolicy(context.Background(), deps.DB, cfg.TrafficCalc, deps.Logger), deps.Logger) - } + // 卡流量采集队列:读数先进每卡 Redis 队列,满足写入条件才写日快照;队列配置为受控系统配置。 + cardObservationService.SetTrafficQueue(cardObservationInfra.NewTrafficQueue(deps.Redis), + cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger), deps.Logger) // 激活前流量结算:与 API 进程一致,注入激活服务与订单服务。 preActivationSettler := cardObservationInfra.NewPreActivationSettler(deps.DB, deps.GatewayClient, cardObservationService, postgres.NewCarrierStore(deps.DB), deps.Logger) activationService.SetPreActivationSettler(preActivationSettler) diff --git a/internal/domain/cardflow/queue.go b/internal/domain/cardflow/queue.go new file mode 100644 index 00000000..694fa03b --- /dev/null +++ b/internal/domain/cardflow/queue.go @@ -0,0 +1,50 @@ +package cardflow + +import ( + "math" + "time" +) + +// 队列写入判断结果,与采集记录的写入判断取值一致。 +const ( + QueueWrite = "written" + QueueNotFull = "queue_not_full" + QueueSlopeNegative = "slope_negative" + QueueOutOfOrder = "out_of_order" +) + +// QueuePoint 是采集队列中的一个点:采集时间与运营商原始读数。 +type QueuePoint struct { + ObservedAt time.Time `json:"observed_at"` + ReadingMB float64 `json:"reading_mb"` +} + +// JudgeQueue 判断本次新点能否写入:新点在队尾、队列满 size 个点、相邻两点后一个 ≥ 前一个 − 回退容差。 +// 不满足时返回不写原因;满足时写入的是队列最后一个点(即本次新点)。 +func JudgeQueue(points []QueuePoint, newPointIsTail bool, size int, dropToleranceMB float64) string { + if !newPointIsTail { + return QueueOutOfOrder + } + if len(points) < size { + return QueueNotFull + } + for i := 1; i < len(points); i++ { + if points[i].ReadingMB < points[i-1].ReadingMB-dropToleranceMB { + return QueueSlopeNegative + } + } + return QueueWrite +} + +// MaxCollectionInterval 返回启用中轮询配置流量采集间隔的上限:留 2 次冗余, +// 跨天清空开启时为 24 小时 ÷ (N+2),关闭时为最长点龄 ÷ (N+2);最长点龄为 0(不限)时不设上限,返回 0。 +func MaxCollectionInterval(size int, clearOnNewDay bool, maxPointAgeHours int, spare int) time.Duration { + window := 24 * time.Hour + if !clearOnNewDay { + if maxPointAgeHours <= 0 { + return 0 + } + window = time.Duration(maxPointAgeHours) * time.Hour + } + return time.Duration(math.Floor(float64(window) / float64(size+spare))) +} diff --git a/internal/domain/cardflow/rules.go b/internal/domain/cardflow/rules.go index 2e1c1acf..4f62f901 100644 --- a/internal/domain/cardflow/rules.go +++ b/internal/domain/cardflow/rules.go @@ -87,7 +87,8 @@ func Convert(readingMB float64, day int, resetDay int, ctx MonthContext) Convers return Conversion{MonthUsedMB: nonNegative(round3(month)), StaleGuarded: guarded} } -// isStale 判断重置日当天的读数是否仍接近上周期总量。 +// isStale 判断重置日当天的读数是否仍接近上周期总量;只看配置的重置日,当天全天判断。 +// 比较基准为 0(没有快照可比)时,不论重置日是几号,当天任何正读数都按旧值处理,次日如实写入。 func isStale(readingMB float64, day int, resetDay int, ctx MonthContext) bool { if day != resetDay || readingMB <= 0 { return false @@ -95,7 +96,7 @@ func isStale(readingMB float64, day int, resetDay int, ctx MonthContext) bool { if resetDay <= 1 { return readingMB > ctx.PrevMonthMax*StaleGuardRatio } - return ctx.ThisMonthBeforeResetMax > 0 && readingMB > ctx.ThisMonthBeforeResetMax*StaleGuardRatio + return readingMB > ctx.ThisMonthBeforeResetMax*StaleGuardRatio } // MonthlyDelta 返回按月对账的待结算增量:本月最大快照 − 本月已结算合计,不大于 0 时返回 0。 @@ -108,9 +109,10 @@ func DailyUsage(dayMaxMB float64, priorMaxMB float64) float64 { return nonNegative(round3(dayMaxMB - priorMaxMB)) } -// ValidReading 判断运营商读数能否写入快照:必须为有限正数。 +// ValidReading 判断运营商读数是否有效:必须为有限且不小于 0 的数。 +// 读数为 0 写不写快照由卡在采集时刻有没有套餐决定,不在这里拦。 func ValidReading(readingMB float64) bool { - return !math.IsNaN(readingMB) && !math.IsInf(readingMB, 0) && readingMB > 0 + return !math.IsNaN(readingMB) && !math.IsInf(readingMB, 0) && readingMB >= 0 } func nonNegative(v float64) float64 { diff --git a/internal/domain/cardobservation/traffic.go b/internal/domain/cardobservation/traffic.go index 4f8aca8b..bc39011c 100644 --- a/internal/domain/cardobservation/traffic.go +++ b/internal/domain/cardobservation/traffic.go @@ -2,8 +2,8 @@ package cardobservation import ( "math" - "time" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -11,82 +11,59 @@ import ( type TrafficObservation struct { CardID uint GatewayReadingMB float64 - ResetDay int - Metadata ObservationMetadata + // ReadingResult 是读数结果(constants.TrafficCollectionResult*);为空表示运营商给出了读数, + // 是否有效(有限且 ≥ 0)由观测用例判断。非 ok 的观测只写采集记录,不入队、不写快照。 + ReadingResult string + ResetDay int + Metadata ObservationMetadata } -// CardTrafficSnapshot 是流量规则需要的最小卡快照。 -type CardTrafficSnapshot struct { - CardID uint - DataUsageMB int64 - CurrentMonthUsageMB float64 - CurrentMonthStartDate *time.Time - LastMonthTotalMB float64 - LastGatewayReadingMB float64 -} - -// TrafficDecision 描述一次流量观测应持久化的最终值。 +// TrafficDecision 描述一次流量观测的处理结果。 type TrafficDecision struct { - IncrementMB float64 - ReadingAccepted bool - CrossMonth bool - DataUsageMB int64 - CurrentMonthUsageMB float64 - CurrentMonthStartDate time.Time - LastMonthTotalMB float64 - LastGatewayReadingMB float64 + // IncrementMB 是本次写入流量事件的增量,0 表示没有写事件。 + IncrementMB float64 + // ReadingAccepted 为 true 表示本次读数通过采集队列判断并写入了日快照。 + ReadingAccepted bool + // Decision 是写入判断(constants.TrafficDecision*);读数无效时为空。 + Decision string + // Result 是读数结果(constants.TrafficCollectionResult*)。 + Result string + // ReadingMB 是本次运营商读数,没有给值时为 nil。 + ReadingMB *float64 + // CrossMonth 为 true 表示本次写入使卡表本月用量跨入新的自然月。 + CrossMonth bool + // CurrentMonthUsageMB 是写入后的本月最大快照(未写入时为卡表原值)。 + CurrentMonthUsageMB float64 + // LastGatewayReadingMB 保留卡表原值,卡表基线算法已下线,不再更新。 + LastGatewayReadingMB float64 } -// ApplyTraffic 应用运营商重置、跨月和异常下降保护规则。 -func ApplyTraffic(snapshot CardTrafficSnapshot, observation TrafficObservation) (TrafficDecision, error) { +// ValidateTrafficObservation 校验观测元数据与读数结果;给出读数时读数必须是数字(有效性由用例判断)。 +func ValidateTrafficObservation(observation TrafficObservation) error { if err := validateObservationMetadata(observation.CardID, observation.Metadata); err != nil { - return TrafficDecision{}, err + return err } - if math.IsNaN(observation.GatewayReadingMB) || math.IsInf(observation.GatewayReadingMB, 0) || observation.GatewayReadingMB < 0 { - return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "流量观测读数无效") + switch observation.ReadingResult { + case "", constants.TrafficCollectionResultOK, constants.TrafficCollectionResultFailed, constants.TrafficCollectionResultRateLimited, + constants.TrafficCollectionResultInvalidPayload, constants.TrafficCollectionResultNoReading, constants.TrafficCollectionResultInvalidReading: + return nil + default: + return errors.New(errors.CodeInvalidParam, "流量观测读数结果无效") } - if observation.ResetDay < 1 || observation.ResetDay > 31 { - return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "运营商流量重置日无效") +} + +// ResolveReadingResult 返回观测的最终读数结果:给出了读数但不是有限且 ≥ 0 的数时为读数无效。 +func ResolveReadingResult(observation TrafficObservation) string { + if observation.ReadingResult != "" && observation.ReadingResult != constants.TrafficCollectionResultOK { + return observation.ReadingResult } - now := observation.Metadata.ObservedAt - monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - decision := TrafficDecision{ - ReadingAccepted: true, DataUsageMB: snapshot.DataUsageMB, - CurrentMonthUsageMB: snapshot.CurrentMonthUsageMB, CurrentMonthStartDate: monthStart, - LastMonthTotalMB: snapshot.LastMonthTotalMB, LastGatewayReadingMB: observation.GatewayReadingMB, + reading := observation.GatewayReadingMB + if math.IsNaN(reading) || math.IsInf(reading, 0) || reading < 0 { + return constants.TrafficCollectionResultInvalidReading } - increment := observation.GatewayReadingMB - snapshot.LastGatewayReadingMB - if increment < 0 { - if isTrafficResetWindow(now, observation.ResetDay) { - increment = observation.GatewayReadingMB - } else { - increment = 0 - decision.ReadingAccepted = false - decision.LastGatewayReadingMB = snapshot.LastGatewayReadingMB - } - } - decision.IncrementMB = increment - decision.CrossMonth = snapshot.CurrentMonthStartDate == nil || snapshot.CurrentMonthStartDate.Before(monthStart) - if decision.CrossMonth { - decision.LastMonthTotalMB = snapshot.CurrentMonthUsageMB - decision.CurrentMonthUsageMB = increment - } else if increment > 0 { - decision.CurrentMonthUsageMB += increment - } - if increment > 0 { - decision.DataUsageMB += int64(increment) - } - return decision, nil + return constants.TrafficCollectionResultOK } func validateObservationMetadata(cardID uint, metadata ObservationMetadata) error { return validateObservation(RealnameObservation{CardID: cardID, Verified: true, Metadata: metadata}) } - -func isTrafficResetWindow(now time.Time, resetDay int) bool { - if now.Day() == resetDay { - return true - } - resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location()) - return now.Day() == resetDate.AddDate(0, 0, -1).Day() -} diff --git a/internal/gateway/models.go b/internal/gateway/models.go index e47b5bf8..2f635f63 100644 --- a/internal/gateway/models.go +++ b/internal/gateway/models.go @@ -138,6 +138,64 @@ type FlowUsageResp struct { ICCID string `json:"iccid" description:"ICCID"` Used FlexFloat `json:"used" description:"当月已用流量(MB,兼容数字或字符串)"` Unit string `json:"unit" description:"流量单位(MB)"` + // UsedState 区分运营商"没有给值"与真实读数;零值表示没有给值(响应缺少 used 字段时不会调用解析)。 + UsedState FlowReadingState `json:"-"` +} + +// FlowReadingState 是流量读数的解析结果。 +type FlowReadingState int + +const ( + // FlowReadingMissing 表示 used 缺失、为 null、为空或无法解析。 + FlowReadingMissing FlowReadingState = iota + // FlowReadingPresent 表示 used 解析为数字(可能为负数、NaN、Inf,由调用方判断是否有效)。 + FlowReadingPresent +) + +// UnmarshalJSON 解析流量响应,并记录 used 是否给出了可解析的数字。 +func (r *FlowUsageResp) UnmarshalJSON(data []byte) error { + type plain FlowUsageResp + var raw struct { + plain + Used json.RawMessage `json:"used"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *r = FlowUsageResp(raw.plain) + r.Used, r.UsedState = 0, FlowReadingMissing + if value, ok := parseFlowReading(raw.Used); ok { + r.Used, r.UsedState = FlexFloat(value), FlowReadingPresent + } + return nil +} + +// HasReading 返回运营商是否给出了可解析的读数。 +func (r *FlowUsageResp) HasReading() bool { + return r != nil && r.UsedState == FlowReadingPresent +} + +// parseFlowReading 按 FlexFloat 的兼容规则解析读数,null、空、无法解析时返回 false。 +func parseFlowReading(data json.RawMessage) (float64, bool) { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + return 0, false + } + if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { + unquoted, err := strconv.Unquote(raw) + if err != nil { + return 0, false + } + raw = strings.TrimSpace(unquoted) + } + if raw == "" { + return 0, false + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, false + } + return value, true } // CardOperationReq 是停机/复机请求 diff --git a/internal/handler/admin/asset_traffic.go b/internal/handler/admin/asset_traffic.go new file mode 100644 index 00000000..1d98cf4a --- /dev/null +++ b/internal/handler/admin/asset_traffic.go @@ -0,0 +1,87 @@ +package admin + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/validation" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// AdjustPackageTraffic 人工冲正套餐流量 +// POST /api/admin/assets/:identifier/packages/:package_usage_id/traffic-adjustments +func (h *AssetHandler) AdjustPackageTraffic(c *fiber.Ctx) error { + if err := rejectEnterpriseAssetAdmin(c); err != nil { + return err + } + asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c) + if err != nil { + return err + } + var req dto.AdjustAssetPackageTrafficRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数格式不正确") + } + req.PackageUsageID = packageUsageID + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, validation.Message("冲正参数不合法", &req, err)) + } + result, err := h.assetService.AdjustPackageTraffic(c.UserContext(), asset.AssetType, asset.AssetID, assetIdentifierForAudit(asset), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListPackageTrafficAdjustments 查询套餐冲正流水 +// GET /api/admin/assets/:identifier/packages/:package_usage_id/traffic-adjustments +func (h *AssetHandler) ListPackageTrafficAdjustments(c *fiber.Ctx) error { + if err := rejectEnterpriseAssetAdmin(c); err != nil { + return err + } + asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c) + if err != nil { + return err + } + var req dto.PackageTrafficAdjustmentListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数格式不正确") + } + req.PackageUsageID = packageUsageID + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, validation.Message("冲正流水查询参数不合法", &req, err)) + } + result, err := h.assetService.ListPackageTrafficAdjustments(c.UserContext(), asset.AssetType, asset.AssetID, &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListTrafficCollections 查询卡流量采集记录 +// GET /api/admin/assets/:identifier/traffic-collections +func (h *AssetHandler) ListTrafficCollections(c *fiber.Ctx) error { + if err := rejectEnterpriseAssetAdmin(c); err != nil { + return err + } + asset, err := h.resolveByIdentifier(c) + if err != nil { + return err + } + if asset.AssetType != "card" { + return errors.New(errors.CodeInvalidParam, "采集记录只支持按卡查询") + } + var req dto.CardTrafficCollectionListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数格式不正确") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, validation.Message("采集记录查询参数不合法", &req, err)) + } + result, err := h.assetService.ListCardTrafficCollections(c.UserContext(), asset.AssetID, &req) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/infrastructure/cardobservation/event.go b/internal/infrastructure/cardobservation/event.go index db0aa9c1..88d221ef 100644 --- a/internal/infrastructure/cardobservation/event.go +++ b/internal/infrastructure/cardobservation/event.go @@ -14,6 +14,7 @@ import ( "gorm.io/gorm/clause" cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/auditcontext" @@ -111,7 +112,7 @@ type RealnameActivator interface { // TrafficDeductor 按卡流量正增量扣减套餐流量。 type TrafficDeductor interface { - DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error + DeductDataUsage(ctx context.Context, cardID uint, usageMB float64, observedAt time.Time, observationID string) error } // StopResumeEvaluator 根据卡的当前事实执行停复机评估。 @@ -258,7 +259,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo return nil } if receipt.Status == constants.CardObservationEffectStatusProcessing { - dailyKey := constants.RedisCardDailyTrafficKey(event.CardID, event.ObservedAt.Format("2006-01-02")) + dailyKey := constants.RedisCardDailyTrafficKey(event.CardID, cardflow.ShanghaiDate(event.ObservedAt).Format("2006-01-02")) pipe := c.redis.TxPipeline() pipe.IncrByFloat(ctx, dailyKey, event.IncrementMB) pipe.Expire(ctx, dailyKey, 48*time.Hour) @@ -274,7 +275,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusProcessing); err != nil { return err } - if err := c.deductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, event.CardID, event.IncrementMB); err != nil { + if err := c.deductor.DeductDataUsage(ctx, event.CardID, event.IncrementMB, event.ObservedAt, event.ObservationID); err != nil { return err } if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDeducted); err != nil { diff --git a/internal/infrastructure/cardobservation/pre_activation_settlement.go b/internal/infrastructure/cardobservation/pre_activation_settlement.go index e42d3fed..12182c77 100644 --- a/internal/infrastructure/cardobservation/pre_activation_settlement.go +++ b/internal/infrastructure/cardobservation/pre_activation_settlement.go @@ -55,9 +55,6 @@ func (s *PreActivationSettler) SettleBeforeActivation(ctx context.Context, carri } deadline := time.Now().Add(constants.PreActivationSettlementTotalTimeoutSeconds * time.Second) for _, card := range cards { - if !s.observation.TrafficSwitched(card.ID) { - continue - } if time.Now().After(deadline) { s.alert(card, carrierType, carrierID, "整次结算达到总超时,跳过该卡") continue @@ -88,7 +85,7 @@ func (s *PreActivationSettler) settleCard(ctx context.Context, card *model.IotCa } observedAt := time.Now().UTC() decision, err := s.observation.ApplyTrafficObservation(stepCtx, carddomain.TrafficObservation{ - CardID: card.ID, GatewayReadingMB: float64(response.Used), ResetDay: resetDay, + CardID: card.ID, GatewayReadingMB: float64(response.Used), ReadingResult: flowReadingResult(response), ResetDay: resetDay, Metadata: carddomain.ObservationMetadata{ ObservationID: "pre-activation-" + card.ICCID + "-" + observedAt.Format("20060102150405.000"), Source: constants.CardObservationSourceBusinessEvent, Scene: constants.CardObservationScenePreActivationSettlement, @@ -102,6 +99,7 @@ func (s *PreActivationSettler) settleCard(ctx context.Context, card *model.IotCa s.logger.Info("激活前流量结算完成", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID), zap.String("carrier_type", carrierType), zap.Uint("asset_id", carrierID), zap.Float64("gateway_flow_mb", float64(response.Used)), zap.Float64("increment_mb", decision.IncrementMB), + zap.String("result", decision.Result), zap.String("decision", decision.Decision), zap.String("metric", "package.pre_activation_settlement.done")) } diff --git a/internal/infrastructure/cardobservation/series_runner.go b/internal/infrastructure/cardobservation/series_runner.go index cc19d8b3..0ee473aa 100644 --- a/internal/infrastructure/cardobservation/series_runner.go +++ b/internal/infrastructure/cardobservation/series_runner.go @@ -558,7 +558,8 @@ func (r *SeriesRunner) queryAndApply(ctx context.Context, payload cardapp.Series return cardapp.RunResult{}, err } decision, err := r.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ - CardID: card.ID, GatewayReadingMB: float64(response.Used), ResetDay: r.carrier.GetDataResetDay(ctx, card.CarrierID), Metadata: metadata, + CardID: card.ID, GatewayReadingMB: float64(response.Used), ReadingResult: flowReadingResult(response), + ResetDay: r.carrier.GetDataResetDay(ctx, card.CarrierID), Metadata: metadata, }) return cardapp.RunResult{StateChanged: decision.IncrementMB > 0 || decision.CrossMonth}, err case constants.CardObservationSyncTypeNetwork: @@ -640,3 +641,11 @@ func initialResult(result string) string { var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil) var _ cardapp.SeriesRunner = (*SeriesRunner)(nil) + +// flowReadingResult 把运营商流量响应映射为读数结果:没给出可解析读数时为无读数。 +func flowReadingResult(response *gateway.FlowUsageResp) string { + if !response.HasReading() { + return constants.TrafficCollectionResultNoReading + } + return constants.TrafficCollectionResultOK +} diff --git a/internal/infrastructure/cardobservation/traffic_alert.go b/internal/infrastructure/cardobservation/traffic_alert.go new file mode 100644 index 00000000..6b2ace4f --- /dev/null +++ b/internal/infrastructure/cardobservation/traffic_alert.go @@ -0,0 +1,120 @@ +package cardobservation + +import ( + "context" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// 采集告警阈值。 +const ( + // trafficAlertSlopeRatio 是单卡当天"读数下降"拦截占比告警阈值。 + trafficAlertSlopeRatio = 0.5 + // trafficAlertSlopeMinRecords 是单卡当天参与拦截占比统计的最少采集记录数。 + trafficAlertSlopeMinRecords = 6 + // trafficAlertSampleLimit 是每类告警日志里列出的卡数上限。 + trafficAlertSampleLimit = 50 +) + +// TrafficAlertScanner 按天扫描采集记录与日快照,输出卡流量采集告警日志(只看开启轮询的卡)。 +type TrafficAlertScanner struct { + db *gorm.DB + logger *zap.Logger +} + +// NewTrafficAlertScanner 创建卡流量采集告警扫描器。 +func NewTrafficAlertScanner(db *gorm.DB, logger *zap.Logger) *TrafficAlertScanner { + if logger == nil { + logger = zap.NewNop() + } + return &TrafficAlertScanner{db: db, logger: logger} +} + +// Scan 扫描 day(上海日期)一整天:有套餐的卡全天没有写入、待建起点仍未清除、单卡读数下降占比过高、 +// 乱序数量与重置日防旧值命中数。当天没有写入时不补写快照,只告警。 +func (s *TrafficAlertScanner) Scan(ctx context.Context, day time.Time) error { + from := cardflow.ShanghaiDate(day) + to := from.AddDate(0, 0, 1) + db := s.db.WithContext(ctx) + + var notWritten []uint + if err := db.Raw(` +SELECT c.iot_card_id FROM tb_card_traffic_collection c +JOIN tb_iot_card card ON card.id = c.iot_card_id AND card.enable_polling = TRUE AND card.deleted_at IS NULL +WHERE c.observed_at >= ? AND c.observed_at < ? +GROUP BY c.iot_card_id +HAVING COUNT(*) FILTER (WHERE c.decision IN (?, ?)) = 0 +LIMIT ?`, from, to, constants.TrafficDecisionWritten, constants.TrafficDecisionBaseline, trafficAlertSampleLimit). + Scan(¬Written).Error; err != nil { + return err + } + if len(notWritten) > 0 { + s.logger.Error("有套餐的卡一整天没有写入流量快照,请核对运营商读数与采集间隔", + zap.String("date", from.Format("2006-01-02")), zap.Uints("card_ids", notWritten), + zap.String("metric", "card_traffic.collection.day_not_written")) + } + + var baselinePending []uint + if err := db.Raw(` +SELECT id FROM tb_iot_card +WHERE traffic_baseline_pending = TRUE AND enable_polling = TRUE AND deleted_at IS NULL + AND last_data_check_at IS NOT NULL AND last_data_check_at < ? AND last_data_check_at >= ? +LIMIT ?`, to, from, trafficAlertSampleLimit).Scan(&baselinePending).Error; err != nil { + return err + } + if len(baselinePending) > 0 { + s.logger.Error("开启轮询的卡已被采集一天仍未建起点,请核对采集队列是否一直不满足写入条件", + zap.String("date", from.Format("2006-01-02")), zap.Uints("card_ids", baselinePending), + zap.String("metric", "card_traffic.collection.baseline_pending")) + } + + var slopeCards []uint + if err := db.Raw(` +SELECT c.iot_card_id FROM tb_card_traffic_collection c +JOIN tb_iot_card card ON card.id = c.iot_card_id AND card.enable_polling = TRUE AND card.deleted_at IS NULL +WHERE c.observed_at >= ? AND c.observed_at < ? AND c.result = ? +GROUP BY c.iot_card_id +HAVING COUNT(*) >= ? AND COUNT(*) FILTER (WHERE c.decision = ?)::float / COUNT(*) > ? +LIMIT ?`, from, to, constants.TrafficCollectionResultOK, trafficAlertSlopeMinRecords, + constants.TrafficDecisionSlopeNegative, trafficAlertSlopeRatio, trafficAlertSampleLimit).Scan(&slopeCards).Error; err != nil { + return err + } + if len(slopeCards) > 0 { + s.logger.Error("卡当天读数下降拦截占比过高,请核对运营商读数", + zap.String("date", from.Format("2006-01-02")), zap.Uints("card_ids", slopeCards), + zap.Float64("ratio_threshold", trafficAlertSlopeRatio), zap.String("metric", "card_traffic.collection.slope_negative_high")) + } + + var totals struct { + Total int64 + Slope int64 + OutOfOrder int64 + } + if err := db.Raw(` +SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE decision = ?) AS slope, + COUNT(*) FILTER (WHERE decision = ?) AS out_of_order +FROM tb_card_traffic_collection WHERE observed_at >= ? AND observed_at < ?`, + constants.TrafficDecisionSlopeNegative, constants.TrafficDecisionOutOfOrder, from, to).Scan(&totals).Error; err != nil { + return err + } + var staleHits int64 + if err := db.Raw(`SELECT COUNT(*) FROM tb_card_flow_day WHERE flow_date = ?::date AND stale_guarded = TRUE`, + from.Format("2006-01-02")).Scan(&staleHits).Error; err != nil { + return err + } + level := s.logger.Info + if totals.OutOfOrder > 0 { + level = s.logger.Warn + } + level("卡流量采集日统计", + zap.String("date", from.Format("2006-01-02")), zap.Int64("collections", totals.Total), + zap.Int64("slope_negative", totals.Slope), zap.Int64("out_of_order", totals.OutOfOrder), + zap.Int64("stale_guarded_cards", staleHits), zap.String("metric", "card_traffic.collection.daily")) + return nil +} diff --git a/internal/infrastructure/cardobservation/traffic_queue.go b/internal/infrastructure/cardobservation/traffic_queue.go new file mode 100644 index 00000000..36f5f416 --- /dev/null +++ b/internal/infrastructure/cardobservation/traffic_queue.go @@ -0,0 +1,130 @@ +package cardobservation + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + + cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// trafficQueueScript 原子更新一张卡的采集队列:跨天清空(新点日期晚于队尾日期时)、丢弃过期点、 +// 按采集时间插入并对同一时间去重、只保留最后 N 个、设置过期,返回队列与新点是否在队尾。 +// 新点日期早于队尾日期(迟到的前一天点)时不改队列,按乱序处理。日期由调用方按上海时区算好传入。 +// +// KEYS[1] 队列 key;ARGV: 采集时间毫秒、读数、日期(YYYYMMDD)、N、是否跨天清空(1/0)、最长点龄毫秒(0 不限)、过期毫秒。 +var trafficQueueScript = redis.NewScript(` +local raw = redis.call('GET', KEYS[1]) +local pts = {} +if raw then pts = cjson.decode(raw) end +local t = tonumber(ARGV[1]) +local v = tonumber(ARGV[2]) +local d = ARGV[3] +local n = tonumber(ARGV[4]) +local clear = ARGV[5] == '1' +local maxAge = tonumber(ARGV[6]) +local ttl = tonumber(ARGV[7]) +if clear and #pts > 0 then + local lastDay = pts[#pts].d + if d < lastDay then + return {cjson.encode(pts), 0} + end + if d > lastDay then pts = {} end +end +if maxAge > 0 then + local kept = {} + for _, p in ipairs(pts) do + if t - p.t <= maxAge then table.insert(kept, p) end + end + pts = kept +end +local out = {} +local inserted = false +for _, p in ipairs(pts) do + if not inserted and t <= p.t then + table.insert(out, {t = t, v = v, d = d}) + inserted = true + end + if p.t ~= t then table.insert(out, p) end +end +if not inserted then table.insert(out, {t = t, v = v, d = d}) end +while #out > n do table.remove(out, 1) end +local tail = 0 +if #out > 0 and out[#out].t == t then tail = 1 end +redis.call('SET', KEYS[1], cjson.encode(out), 'PX', ttl) +return {cjson.encode(out), tail} +`) + +// TrafficQueue 是基于 Redis 的卡流量采集队列。队列只是写入前的过滤器:key 丢失时从空开始,只晚写不错账。 +type TrafficQueue struct { + redis *redis.Client +} + +// NewTrafficQueue 创建卡流量采集队列。 +func NewTrafficQueue(redisClient *redis.Client) *TrafficQueue { + return &TrafficQueue{redis: redisClient} +} + +type queuedPoint struct { + T int64 `json:"t"` + V float64 `json:"v"` + D string `json:"d"` +} + +// Push 把新点放入卡的采集队列,返回入队后的队列(按采集时间升序)与新点是否在队尾。 +func (q *TrafficQueue) Push(ctx context.Context, cardID uint, point cardflow.QueuePoint, settings cardapp.TrafficQueueSettings) ([]cardflow.QueuePoint, bool, error) { + if q == nil || q.redis == nil { + return nil, false, errors.New(errors.CodeInternalError, "卡流量采集队列未配置") + } + day := cardflow.ShanghaiDate(point.ObservedAt) + clear := "0" + if settings.ClearOnNewDay { + clear = "1" + } + maxAgeMS := int64(settings.MaxPointAgeHours) * int64(time.Hour/time.Millisecond) + result, err := trafficQueueScript.Run(ctx, q.redis, []string{constants.RedisCardTrafficQueueKey(cardID)}, + point.ObservedAt.UnixMilli(), strconv.FormatFloat(point.ReadingMB, 'f', -1, 64), day.Format("20060102"), + settings.Size, clear, maxAgeMS, queueTTL(point.ObservedAt, settings).Milliseconds(), + ).Slice() + if err != nil { + return nil, false, errors.Wrap(errors.CodeRedisError, err, "更新卡流量采集队列失败") + } + if len(result) != 2 { + return nil, false, errors.New(errors.CodeRedisError, "卡流量采集队列返回格式异常") + } + encoded, _ := result[0].(string) + var stored []queuedPoint + if encoded != "" && encoded != "{}" { + if err := json.Unmarshal([]byte(encoded), &stored); err != nil { + return nil, false, errors.Wrap(errors.CodeRedisError, err, "解析卡流量采集队列失败") + } + } + points := make([]cardflow.QueuePoint, 0, len(stored)) + for _, p := range stored { + points = append(points, cardflow.QueuePoint{ObservedAt: time.UnixMilli(p.T), ReadingMB: p.V}) + } + tail, _ := result[1].(int64) + return points, tail == 1, nil +} + +// queueTTL 返回队列 key 的过期时长:跨天清空开启时到次日 01:00(上海时间),关闭时为最长点龄(不限时 7 天)。 +func queueTTL(observedAt time.Time, settings cardapp.TrafficQueueSettings) time.Duration { + if settings.ClearOnNewDay { + expireAt := cardflow.ShanghaiDate(observedAt).AddDate(0, 0, 1).Add(time.Hour) + if ttl := time.Until(expireAt); ttl > time.Minute { + return ttl + } + return time.Hour + } + hours := settings.MaxPointAgeHours + if hours <= 0 { + hours = constants.TrafficQueueUnlimitedTTLHours + } + return time.Duration(hours) * time.Hour +} diff --git a/internal/model/card_traffic_collection.go b/internal/model/card_traffic_collection.go new file mode 100644 index 00000000..9223e487 --- /dev/null +++ b/internal/model/card_traffic_collection.go @@ -0,0 +1,54 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// CardTrafficCollection 卡流量采集记录:采集时刻有套餐的卡每次采集一条,按月分区保留 90 天。 +type CardTrafficCollection struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + IotCardID uint `gorm:"column:iot_card_id;not null" json:"iot_card_id"` + ObservedAt time.Time `gorm:"column:observed_at;primaryKey;not null" json:"observed_at"` + ObservationID string `gorm:"column:observation_id;type:varchar(128);not null;default:''" json:"observation_id"` + Source string `gorm:"column:source;type:varchar(32);not null;default:''" json:"source"` + Scene string `gorm:"column:scene;type:varchar(64);not null;default:''" json:"scene"` + Result string `gorm:"column:result;type:varchar(32);not null" json:"result"` + VendorUsedMB *float64 `gorm:"column:vendor_used_mb;type:numeric(14,3)" json:"vendor_used_mb"` + QueuePoints datatypes.JSON `gorm:"column:queue_points;type:jsonb" json:"queue_points"` + Decision string `gorm:"column:decision;type:varchar(32);not null;default:''" json:"decision"` + MonthUsedMB *float64 `gorm:"column:month_used_mb;type:numeric(14,3)" json:"month_used_mb"` + StaleGuarded *bool `gorm:"column:stale_guarded" json:"stale_guarded"` + DeltaMB *float64 `gorm:"column:delta_mb;type:numeric(14,3)" json:"delta_mb"` + EventID string `gorm:"column:event_id;type:varchar(128);not null;default:''" json:"event_id"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"` +} + +// TableName 指定表名。 +func (CardTrafficCollection) TableName() string { + return "tb_card_traffic_collection" +} + +// PackageUsageAdjustment 套餐流量人工冲正流水:只新增,不修改、不删除。 +type PackageUsageAdjustment struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + PackageUsageID uint `gorm:"column:package_usage_id;not null" json:"package_usage_id"` + IotCardID uint `gorm:"column:iot_card_id;not null;default:0" json:"iot_card_id"` + DeviceID uint `gorm:"column:device_id;not null;default:0" json:"device_id"` + DeltaMB int64 `gorm:"column:delta_mb;not null" json:"delta_mb"` + BeforeUsageMB int64 `gorm:"column:before_usage_mb;not null" json:"before_usage_mb"` + AfterUsageMB int64 `gorm:"column:after_usage_mb;not null" json:"after_usage_mb"` + BeforeStatus int `gorm:"column:before_status;not null" json:"before_status"` + AfterStatus int `gorm:"column:after_status;not null" json:"after_status"` + Reason string `gorm:"column:reason;type:varchar(500);not null" json:"reason"` + RefObservationID string `gorm:"column:ref_observation_id;type:varchar(128);not null;default:''" json:"ref_observation_id"` + RefEventID string `gorm:"column:ref_event_id;type:varchar(128);not null;default:''" json:"ref_event_id"` + OperatorID uint `gorm:"column:operator_id;not null" json:"operator_id"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"` +} + +// TableName 指定表名。 +func (PackageUsageAdjustment) TableName() string { + return "tb_package_usage_adjustment" +} diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index 9d7cc7c5..b7f3999a 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -1,6 +1,9 @@ package dto -import "time" +import ( + "encoding/json" + "time" +) // AssetResolveResponse 统一资产解析响应 type AssetResolveResponse struct { @@ -129,22 +132,32 @@ type BoundCardInfo struct { // AssetRealtimeStatusResponse 资产实时状态(只读DB/Redis) type AssetRealtimeStatusResponse struct { - AssetType string `json:"asset_type" description:"资产类型:card 或 device"` - AssetID uint `json:"asset_id" description:"资产ID"` - NetworkStatus int `json:"network_status" description:"网络状态(asset_type=card时有效):0停机 1开机"` - GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"` - GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"` - RealNameStatus int `json:"real_name_status" description:"实名状态(asset_type=card时有效):0未实名 1已实名"` - RealnamePolicy string `json:"realname_policy,omitempty" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` - CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统自然月累计流量(MB,asset_type=card时有效,不等于运营商周期口径)"` - LastGatewayReadingMB float64 `json:"last_gateway_reading_mb,omitempty" description:"运营商当前周期累计流量读数(MB,asset_type=card时有效,运营商口径展示建议读取该字段)"` - LastSyncTime *time.Time `json:"last_sync_time" description:"最后同步时间(asset_type=card时有效)"` - LastDataCheckAt *time.Time `json:"last_data_check_at" description:"最后一次流量检查时间(asset_type=card时有效)"` - LastRealNameCheckAt *time.Time `json:"last_real_name_check_at" description:"最后一次实名检查时间(asset_type=card时有效)"` - LastCardStatusCheckAt *time.Time `json:"last_card_status_check_at" description:"最后一次卡状态检查时间(asset_type=card时有效)"` - DeviceProtectStatus string `json:"device_protect_status,omitempty" deprecated:"true" description:"已废弃:设备保护期已下线,固定返回 none,仅为兼容保留(asset_type=device时有效)"` - Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"` - DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info,始终返回,失败时仅含 gateway_msg)"` + AssetType string `json:"asset_type" description:"资产类型:card 或 device"` + AssetID uint `json:"asset_id" description:"资产ID"` + NetworkStatus int `json:"network_status" description:"网络状态(asset_type=card时有效):0停机 1开机"` + GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"` + GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"` + RealNameStatus int `json:"real_name_status" description:"实名状态(asset_type=card时有效):0未实名 1已实名"` + RealnamePolicy string `json:"realname_policy,omitempty" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` + CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统自然月累计流量(MB,asset_type=card时有效,不等于运营商周期口径)"` + LastGatewayReadingMB float64 `json:"last_gateway_reading_mb,omitempty" description:"运营商当前周期累计流量读数(MB,asset_type=card时有效,运营商口径展示建议读取该字段)"` + LastSyncTime *time.Time `json:"last_sync_time" description:"最后同步时间(asset_type=card时有效)"` + LastDataCheckAt *time.Time `json:"last_data_check_at" description:"最后一次流量检查时间(asset_type=card时有效)"` + LastRealNameCheckAt *time.Time `json:"last_real_name_check_at" description:"最后一次实名检查时间(asset_type=card时有效)"` + LastCardStatusCheckAt *time.Time `json:"last_card_status_check_at" description:"最后一次卡状态检查时间(asset_type=card时有效)"` + DeviceProtectStatus string `json:"device_protect_status,omitempty" deprecated:"true" description:"已废弃:设备保护期已下线,固定返回 none,仅为兼容保留(asset_type=device时有效)"` + Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"` + DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info,始终返回,失败时仅含 gateway_msg)"` + TrafficRefresh *CardTrafficRefreshResult `json:"traffic_refresh,omitempty" description:"本次刷新的流量写入结果(仅刷新单卡接口返回)"` +} + +// CardTrafficRefreshResult 人工刷新卡流量的写入结果。 +type CardTrafficRefreshResult struct { + ReadingMB *float64 `json:"reading_mb" description:"本次运营商读数(MB),没有给值时为空"` + Result string `json:"result" description:"读数结果:ok/failed/rate_limited/invalid_payload/no_reading/invalid_reading"` + Written bool `json:"written" description:"本次读数是否写入日快照"` + Decision string `json:"decision" description:"写入判断:written/baseline/queue_not_full/slope_negative/out_of_order,读数无效时为空"` + DeltaMB float64 `json:"delta_mb" description:"本次写入流量事件的增量(MB)"` } // AssetPackageResponse 资产套餐信息 @@ -252,6 +265,78 @@ type UpdateAssetPackageExpiresAtRequest struct { } // UpdateAssetPackageUsageRequest 修改资产套餐已用量请求 +// AdjustAssetPackageTrafficRequest 人工冲正套餐流量请求。 +type AdjustAssetPackageTrafficRequest struct { + Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"` + PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"` + DeltaMB int64 `json:"delta_mb" validate:"required,ne=0" required:"true" description:"调整量(MB):正数补扣,负数退回,不能为 0"` + Reason string `json:"reason" validate:"required,max=500" required:"true" maxLength:"500" description:"冲正原因"` + RefObservationID string `json:"ref_observation_id" validate:"omitempty,max=128" maxLength:"128" description:"关联的采集 ID(可选)"` + RefEventID string `json:"ref_event_id" validate:"omitempty,max=128" maxLength:"128" description:"关联的流量事件 ID(可选)"` +} + +// PackageTrafficAdjustmentListRequest 冲正流水查询请求。 +type PackageTrafficAdjustmentListRequest struct { + Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"` + PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"` + Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"` + PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认 20"` +} + +// PackageTrafficAdjustmentItem 冲正流水。 +type PackageTrafficAdjustmentItem struct { + ID uint `json:"id" description:"流水ID"` + PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"` + DeltaMB int64 `json:"delta_mb" description:"调整量(MB):正数补扣,负数退回"` + BeforeUsageMB int64 `json:"before_usage_mb" description:"调整前已用量(MB)"` + AfterUsageMB int64 `json:"after_usage_mb" description:"调整后已用量(MB)"` + BeforeStatus int `json:"before_status" description:"调整前套餐状态"` + AfterStatus int `json:"after_status" description:"调整后套餐状态"` + Reason string `json:"reason" description:"冲正原因"` + RefObservationID string `json:"ref_observation_id" description:"关联的采集 ID"` + RefEventID string `json:"ref_event_id" description:"关联的流量事件 ID"` + OperatorID uint `json:"operator_id" description:"操作人账号ID"` + CreatedAt time.Time `json:"created_at" description:"冲正时间"` +} + +// PackageTrafficAdjustmentListResponse 冲正流水分页结果。 +type PackageTrafficAdjustmentListResponse struct { + Items []PackageTrafficAdjustmentItem `json:"items" description:"冲正流水"` + Total int64 `json:"total" description:"总数"` +} + +// CardTrafficCollectionListRequest 卡流量采集记录查询请求。 +type CardTrafficCollectionListRequest struct { + Identifier string `path:"identifier" description:"卡标识符(虚拟号/ICCID/MSISDN)" required:"true"` + StartTime string `query:"start_time" validate:"required" required:"true" description:"开始时间(RFC3339 或 2006-01-02 15:04:05,上海时间)"` + EndTime string `query:"end_time" validate:"required" required:"true" description:"结束时间(不含),最长跨度 31 天"` + Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"` + PageSize int `query:"page_size" validate:"omitempty,min=1,max=200" minimum:"1" maximum:"200" description:"每页条数,默认 50"` +} + +// CardTrafficCollectionItem 卡流量采集记录。 +type CardTrafficCollectionItem struct { + ID uint `json:"id" description:"记录ID"` + ObservedAt time.Time `json:"observed_at" description:"采集时间"` + ObservationID string `json:"observation_id" description:"采集 ID"` + Source string `json:"source" description:"来源:polling/manual_sync/business_event"` + Scene string `json:"scene" description:"场景"` + Result string `json:"result" description:"读数结果:ok/failed/rate_limited/invalid_payload/no_reading/invalid_reading"` + VendorUsedMB *float64 `json:"vendor_used_mb" description:"运营商原始读数(MB)"` + QueuePoints json.RawMessage `json:"queue_points" description:"入队后的采集队列"` + Decision string `json:"decision" description:"写入判断:written/baseline/queue_not_full/slope_negative/out_of_order/settled_existing"` + MonthUsedMB *float64 `json:"month_used_mb" description:"写入时换算后的本月累计(MB)"` + StaleGuarded *bool `json:"stale_guarded" description:"写入时是否触发重置日防旧值"` + DeltaMB *float64 `json:"delta_mb" description:"写入时本次增量(MB)"` + EventID string `json:"event_id" description:"流量事件 ID"` +} + +// CardTrafficCollectionListResponse 卡流量采集记录分页结果。 +type CardTrafficCollectionListResponse struct { + Items []CardTrafficCollectionItem `json:"items" description:"采集记录"` + Total int64 `json:"total" description:"总数"` +} + type UpdateAssetPackageUsageRequest struct { Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"` PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"` diff --git a/internal/model/iot_card.go b/internal/model/iot_card.go index e7026e5b..353ccffa 100644 --- a/internal/model/iot_card.go +++ b/internal/model/iot_card.go @@ -45,18 +45,19 @@ type IotCard struct { FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"` // 任务 24.1: 停复机相关字段 - FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间(用于触发首次实名激活)" json:"first_realname_at,omitempty"` - StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"` - ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"` - StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"` - AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"` - Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"` - FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at;comment:首次购买套餐时间(当前世代第一笔套餐订单的支付时间)" json:"first_purchase_at,omitempty"` - IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"` - VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可选,非空时全局唯一)" json:"virtual_no,omitempty"` - DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"` - LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:运营商当前周期累计流量读数(MB,来自网关)" json:"last_gateway_reading_mb"` - GatewayExtend string `gorm:"column:gateway_extend;type:text;not null;default:'';comment:Gateway 卡状态扩展字段,原样保存上游 extend,用于展示运营商侧实际停机原因" json:"gateway_extend"` + FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间(用于触发首次实名激活)" json:"first_realname_at,omitempty"` + StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"` + ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"` + StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"` + AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"` + Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"` + FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at;comment:首次购买套餐时间(当前世代第一笔套餐订单的支付时间)" json:"first_purchase_at,omitempty"` + IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"` + VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可选,非空时全局唯一)" json:"virtual_no,omitempty"` + DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"` + LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:运营商当前周期累计流量读数(MB,来自网关)" json:"last_gateway_reading_mb"` + TrafficBaselinePending bool `gorm:"column:traffic_baseline_pending;type:boolean;not null;default:false;comment:卡流量待建起点:第一次通过采集队列写入时只建起点不扣费" json:"traffic_baseline_pending"` + GatewayExtend string `gorm:"column:gateway_extend;type:text;not null;default:'';comment:Gateway 卡状态扩展字段,原样保存上游 extend,用于展示运营商侧实际停机原因" json:"gateway_extend"` // GatewayCardIMEI 为插拔卡业务中网关上报的设备 IMEI,非设备自身 IMEI,仅用于数据同步落库 GatewayCardIMEI string `gorm:"column:gateway_card_imei;type:varchar(50);not null;default:'';comment:插拔卡业务 IMEI,网关卡状态接口同步,非设备 IMEI" json:"gateway_card_imei"` RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"` diff --git a/internal/routes/asset.go b/internal/routes/asset.go index 1d9a7a9d..484fad46 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -67,13 +67,40 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Register(assets, doc, groupPath, "PATCH", "/:identifier/packages/:package_usage_id/used-data", handler.UpdatePackageUsage, RouteSpec{ Summary: "修改资产套餐已用量", - Description: "修改指定资产下某条套餐使用记录的真实已用量(MB)。仅允许已认证账号调用,并保留资产与套餐记录归属校验。企业账号无权调用。", + Description: "修改指定资产下某条套餐使用记录的真实已用量(MB):按与当前已用量的差额写一条人工冲正流水(原因记为后台修改资产套餐已用量),提交后走停复机判断。仅允许已认证账号调用,并保留资产与套餐记录归属校验。企业账号无权调用。", Tags: []string{"资产管理"}, Input: new(dto.UpdateAssetPackageUsageRequest), Output: new(dto.AssetPackageResponse), Auth: true, }) + Register(assets, doc, groupPath, "POST", "/:identifier/packages/:package_usage_id/traffic-adjustments", handler.AdjustPackageTraffic, RouteSpec{ + Summary: "人工冲正套餐流量", + Description: "按调整量冲正套餐已用量(正数补扣、负数退回),原因必填;锁套餐行按当前值加减,退回后不得小于 0,同事务写冲正流水与审计,提交后走停复机判断,不直接复机。不修改卡日快照。企业账号无权调用。", + Tags: []string{"资产管理"}, + Input: new(dto.AdjustAssetPackageTrafficRequest), + Output: new(dto.PackageTrafficAdjustmentItem), + Auth: true, + }) + + Register(assets, doc, groupPath, "GET", "/:identifier/packages/:package_usage_id/traffic-adjustments", handler.ListPackageTrafficAdjustments, RouteSpec{ + Summary: "查询套餐冲正流水", + Description: "分页查询指定套餐使用记录的人工冲正流水,按时间倒序。企业账号无权调用。", + Tags: []string{"资产管理"}, + Input: new(dto.PackageTrafficAdjustmentListRequest), + Output: new(dto.PackageTrafficAdjustmentListResponse), + Auth: true, + }) + + Register(assets, doc, groupPath, "GET", "/:identifier/traffic-collections", handler.ListTrafficCollections, RouteSpec{ + Summary: "查询卡流量采集记录", + Description: "按卡与采集时间范围分页查询流量采集记录(仅采集时刻有套餐的卡有记录,保留 90 天),含原始读数、队列与写入判断,按采集时间倒序;单次跨度不超过 31 天。企业账号无权调用。", + Tags: []string{"资产管理"}, + Input: new(dto.CardTrafficCollectionListRequest), + Output: new(dto.CardTrafficCollectionListResponse), + Auth: true, + }) + Register(assets, doc, groupPath, "POST", "/:identifier/stop", handler.Stop, RouteSpec{ Summary: "停机", Description: "设备批量停机设备下所有已实名卡;单卡手动停机。", diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index c2ec508c..2fbfe7e8 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -30,6 +30,7 @@ import ( // IotCardRefresher 用于调用 RefreshCardDataFromGateway,避免循环依赖 type IotCardRefresher interface { RefreshCardDataFromGateway(ctx context.Context, iccid string) error + RefreshCardDataWithTraffic(ctx context.Context, iccid string) (*dto.CardTrafficRefreshResult, error) } // PackageExpiryResolver 查询资产的套餐最终到期推算结果。 @@ -60,7 +61,9 @@ type Service struct { packageExpiryQuery PackageExpiryResolver pollingRestorer PollingRestorer resumeEvaluator AssetResumeEvaluator - logger *zap.Logger + // trafficAdjustmentEvaluator 在冲正提交后评估停复机;未注入时冲正后不评估。 + trafficAdjustmentEvaluator TrafficAdjustmentEvaluator + logger *zap.Logger } // SetPackageExpiryQuery 注入套餐最终到期查询,供资产详情统一投影使用。 @@ -681,10 +684,16 @@ func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto. if err != nil { return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在") } - if err := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); err != nil { + trafficResult, err := s.iotCardService.RefreshCardDataWithTraffic(ctx, card.ICCID) + if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "刷新卡数据失败") } - return s.GetRealtimeStatus(ctx, "card", id) + status, err := s.GetRealtimeStatus(ctx, "card", id) + if err != nil { + return nil, err + } + status.TrafficRefresh = trafficResult + return status, nil case "device": totalStart := time.Now() @@ -1017,44 +1026,23 @@ func (s *Service) UpdatePackageExpiresAt(ctx context.Context, assetType string, return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil } -// UpdatePackageUsage 修改资产套餐已用量。 +// UpdatePackageUsage 修改资产套餐已用量:改为经冲正流水进行,调整量 = 新已用量 − 锁定后的当前已用量。 func (s *Service) UpdatePackageUsage(ctx context.Context, assetType string, assetID uint, assetIdentifier string, packageUsageID uint, dataUsageMB int64) (*dto.AssetPackageResponse, error) { if dataUsageMB < 0 { return nil, errors.New(errors.CodeInvalidParam) } - carrierType, err := normalizeAssetCarrierType(assetType) if err != nil { return nil, err } - - before, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID) + if _, err := s.adjustPackageTraffic(ctx, assetType, assetID, assetIdentifier, + trafficAdjustment{PackageUsageID: packageUsageID, Reason: legacyUsageEditReason}, &dataUsageMB); err != nil { + return nil, err + } + updated, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID) if err != nil { return nil, wrapPackageUsageNotFound(err) } - - beforeData := packageUsageTrafficAuditData(before) - nextStatus := statusForManualDataUsage(before, dataUsageMB) - var updated *model.PackageUsage - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - store := postgres.NewPackageUsageStore(tx, nil) - rows, updateErr := store.UpdateDataUsageForCarrier(ctx, packageUsageID, carrierType, assetID, dataUsageMB, nextStatus) - if updateErr != nil { - return errors.Wrap(errors.CodeDatabaseError, updateErr, "修改套餐已用量失败") - } - if rows == 0 { - return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") - } - updated, updateErr = store.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID) - if updateErr != nil { - return errors.Wrap(errors.CodeDatabaseError, updateErr, "查询套餐使用记录失败") - } - return s.appendPackageAdjustmentAudit(ctx, tx, constants.AuditActionPackageUsageTrafficAdjusted, "修改资产套餐已用量", constants.AuditResultSuccess, updated, assetType, assetID, assetIdentifier, beforeData, packageUsageTrafficAuditData(updated), nil) - }) - if err != nil { - s.recordPackageAdjustmentFailure(ctx, constants.AuditActionPackageUsageTrafficAdjusted, "修改资产套餐已用量失败", before, assetType, assetID, assetIdentifier, beforeData, map[string]any{"package_usage_id": packageUsageID, "data_usage_mb": dataUsageMB}, err) - return nil, err - } return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil } diff --git a/internal/service/asset/traffic_adjustment.go b/internal/service/asset/traffic_adjustment.go new file mode 100644 index 00000000..a70ebdac --- /dev/null +++ b/internal/service/asset/traffic_adjustment.go @@ -0,0 +1,201 @@ +package asset + +import ( + "context" + "strings" + + "go.uber.org/zap" + "gorm.io/gorm" + + stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// legacyUsageEditReason 是旧"修改资产套餐已用量"接口改走冲正流水时记录的原因。 +const legacyUsageEditReason = "后台修改资产套餐已用量" + +// TrafficAdjustmentEvaluator 在冲正提交后按最新套餐、流量、实名事实评估卡的停复机。 +type TrafficAdjustmentEvaluator interface { + EvaluateAndAct(ctx context.Context, card *model.IotCard) error +} + +// SetTrafficAdjustmentEvaluator 注入冲正提交后的停复机判断。 +func (s *Service) SetTrafficAdjustmentEvaluator(evaluator TrafficAdjustmentEvaluator) { + s.trafficAdjustmentEvaluator = evaluator +} + +// trafficAdjustment 是一次冲正的输入。 +type trafficAdjustment struct { + PackageUsageID uint + DeltaMB int64 + Reason string + RefObservationID string + RefEventID string +} + +// AdjustPackageTraffic 人工冲正套餐流量:锁套餐行、按锁定后的当前已用量加减、退回后不得小于 0, +// 同事务写冲正流水、更新已用量并按阈值重算状态(已过期、已失效只改已用量)、写审计;提交后走停复机判断。 +// 冲正不修改卡日快照与已结算。 +func (s *Service) AdjustPackageTraffic(ctx context.Context, assetType string, assetID uint, assetIdentifier string, req *dto.AdjustAssetPackageTrafficRequest) (*dto.PackageTrafficAdjustmentItem, error) { + if req == nil || req.DeltaMB == 0 || strings.TrimSpace(req.Reason) == "" { + return nil, errors.New(errors.CodeInvalidParam, "冲正调整量不能为 0,原因必填") + } + return s.adjustPackageTraffic(ctx, assetType, assetID, assetIdentifier, trafficAdjustment{ + PackageUsageID: req.PackageUsageID, DeltaMB: req.DeltaMB, Reason: strings.TrimSpace(req.Reason), + RefObservationID: strings.TrimSpace(req.RefObservationID), RefEventID: strings.TrimSpace(req.RefEventID), + }, nil) +} + +// adjustPackageTraffic 执行冲正;targetUsageMB 非空时按"改成该已用量"计算调整量(旧接口兼容)。 +func (s *Service) adjustPackageTraffic(ctx context.Context, assetType string, assetID uint, assetIdentifier string, input trafficAdjustment, targetUsageMB *int64) (*dto.PackageTrafficAdjustmentItem, error) { + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return nil, errors.New(errors.CodeUnauthorized, "未授权访问") + } + carrierType, err := normalizeAssetCarrierType(assetType) + if err != nil { + return nil, err + } + var record model.PackageUsageAdjustment + var before, updated *model.PackageUsage + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + store := postgres.NewPackageUsageStore(tx, nil) + locked, lockErr := store.LockByIDForCarrier(ctx, input.PackageUsageID, carrierType, assetID) + if lockErr != nil { + return wrapPackageUsageNotFound(lockErr) + } + before = locked + delta := input.DeltaMB + if targetUsageMB != nil { + delta = *targetUsageMB - locked.DataUsageMB + } + if delta == 0 { + if targetUsageMB != nil { + // 旧接口改成与当前相同的已用量:不产生冲正流水。 + return nil + } + return errors.New(errors.CodeInvalidParam, "冲正调整量不能为 0") + } + after := locked.DataUsageMB + delta + if after < 0 { + return errors.New(errors.CodeInvalidParam, "退回后套餐已用量不能小于 0") + } + nextStatus := statusForManualDataUsage(locked, after) + if _, updateErr := store.UpdateDataUsageForCarrier(ctx, locked.ID, carrierType, assetID, after, nextStatus); updateErr != nil { + return errors.Wrap(errors.CodeDatabaseError, updateErr, "冲正套餐已用量失败") + } + afterStatus := locked.Status + if nextStatus != nil { + afterStatus = *nextStatus + } + record = model.PackageUsageAdjustment{ + PackageUsageID: locked.ID, IotCardID: locked.IotCardID, DeviceID: locked.DeviceID, + DeltaMB: delta, BeforeUsageMB: locked.DataUsageMB, AfterUsageMB: after, + BeforeStatus: locked.Status, AfterStatus: afterStatus, Reason: input.Reason, + RefObservationID: input.RefObservationID, RefEventID: input.RefEventID, OperatorID: operatorID, + } + if createErr := tx.WithContext(ctx).Create(&record).Error; createErr != nil { + return errors.Wrap(errors.CodeDatabaseError, createErr, "写入冲正流水失败") + } + var loadErr error + if updated, loadErr = store.GetByIDForCarrier(ctx, locked.ID, carrierType, assetID); loadErr != nil { + return errors.Wrap(errors.CodeDatabaseError, loadErr, "查询套餐使用记录失败") + } + afterData := packageUsageTrafficAuditData(updated) + afterData["adjustment_id"], afterData["delta_mb"], afterData["reason"] = record.ID, delta, input.Reason + return s.appendPackageAdjustmentAudit(ctx, tx, constants.AuditActionPackageUsageTrafficAdjusted, "人工冲正套餐流量", constants.AuditResultSuccess, + updated, assetType, assetID, assetIdentifier, packageUsageTrafficAuditData(locked), afterData, nil) + }) + if err != nil { + if before != nil { + s.recordPackageAdjustmentFailure(ctx, constants.AuditActionPackageUsageTrafficAdjusted, "人工冲正套餐流量失败", before, assetType, assetID, assetIdentifier, + packageUsageTrafficAuditData(before), map[string]any{"package_usage_id": input.PackageUsageID, "delta_mb": input.DeltaMB, "reason": input.Reason}, err) + } + return nil, err + } + if record.ID == 0 { + return nil, nil + } + s.evaluateAfterAdjustment(ctx, carrierType, assetID) + item := toTrafficAdjustmentItem(record) + return &item, nil +} + +// evaluateAfterAdjustment 冲正提交后对载体下的卡调用停复机判断,不直接复机或停机。 +func (s *Service) evaluateAfterAdjustment(ctx context.Context, carrierType string, carrierID uint) { + if s.trafficAdjustmentEvaluator == nil { + return + } + evalCtx := stopresumedomain.WithScene(context.WithoutCancel(ctx), constants.StopResumeSceneUsage) + var cards []*model.IotCard + query := s.db.WithContext(evalCtx).Model(&model.IotCard{}) + if carrierType == constants.AssetTypeDevice { + query = query.Where("id IN (?)", s.db.Model(&model.DeviceSimBinding{}).Select("iot_card_id"). + Where("device_id = ? AND bind_status = ?", carrierID, constants.BindStatusBound)) + } else { + query = query.Where("id = ?", carrierID) + } + if err := query.Find(&cards).Error; err != nil { + s.adjustmentLogger().Warn("冲正后查询载体下的卡失败,未做停复机判断", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Error(err)) + return + } + for _, card := range cards { + if err := s.trafficAdjustmentEvaluator.EvaluateAndAct(evalCtx, card); err != nil { + s.adjustmentLogger().Warn("冲正后停复机判断失败", zap.Uint("card_id", card.ID), zap.Error(err)) + } + } +} + +func (s *Service) adjustmentLogger() *zap.Logger { + if s.logger == nil { + return zap.NewNop() + } + return s.logger +} + +// ListPackageTrafficAdjustments 分页查询套餐的冲正流水,按时间倒序。 +func (s *Service) ListPackageTrafficAdjustments(ctx context.Context, assetType string, assetID uint, req *dto.PackageTrafficAdjustmentListRequest) (*dto.PackageTrafficAdjustmentListResponse, error) { + carrierType, err := normalizeAssetCarrierType(assetType) + if err != nil { + return nil, err + } + if _, err := s.packageUsageStore.GetByIDForCarrier(ctx, req.PackageUsageID, carrierType, assetID); err != nil { + return nil, wrapPackageUsageNotFound(err) + } + page, size := req.Page, req.PageSize + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + query := s.db.WithContext(ctx).Model(&model.PackageUsageAdjustment{}).Where("package_usage_id = ?", req.PackageUsageID) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询冲正流水失败") + } + var rows []model.PackageUsageAdjustment + if err := query.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询冲正流水失败") + } + items := make([]dto.PackageTrafficAdjustmentItem, 0, len(rows)) + for _, row := range rows { + items = append(items, toTrafficAdjustmentItem(row)) + } + return &dto.PackageTrafficAdjustmentListResponse{Items: items, Total: total}, nil +} + +func toTrafficAdjustmentItem(row model.PackageUsageAdjustment) dto.PackageTrafficAdjustmentItem { + return dto.PackageTrafficAdjustmentItem{ + ID: row.ID, PackageUsageID: row.PackageUsageID, DeltaMB: row.DeltaMB, + BeforeUsageMB: row.BeforeUsageMB, AfterUsageMB: row.AfterUsageMB, + BeforeStatus: row.BeforeStatus, AfterStatus: row.AfterStatus, Reason: row.Reason, + RefObservationID: row.RefObservationID, RefEventID: row.RefEventID, + OperatorID: row.OperatorID, CreatedAt: row.CreatedAt, + } +} diff --git a/internal/service/asset/traffic_collection_query.go b/internal/service/asset/traffic_collection_query.go new file mode 100644 index 00000000..7f3401b6 --- /dev/null +++ b/internal/service/asset/traffic_collection_query.go @@ -0,0 +1,66 @@ +package asset + +import ( + "context" + "encoding/json" + "time" + + "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// maxCollectionQuerySpan 是采集记录单次查询的最长时间跨度。 +const maxCollectionQuerySpan = 31 * 24 * time.Hour + +// collectionTimeLayouts 是采集记录查询时间参数接受的格式(无时区时按上海时间解析)。 +var collectionTimeLayouts = []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} + +// ListCardTrafficCollections 按卡与采集时间范围分页查询卡流量采集记录,按采集时间倒序。 +func (s *Service) ListCardTrafficCollections(ctx context.Context, cardID uint, req *dto.CardTrafficCollectionListRequest) (*dto.CardTrafficCollectionListResponse, error) { + from, err := parseCollectionTime(req.StartTime) + if err != nil { + return nil, err + } + to, err := parseCollectionTime(req.EndTime) + if err != nil { + return nil, err + } + if !to.After(from) || to.Sub(from) > maxCollectionQuerySpan { + return nil, errors.New(errors.CodeInvalidParam, "结束时间必须晚于开始时间,且跨度不超过 31 天") + } + page, size := req.Page, req.PageSize + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 50 + } + rows, total, err := postgres.NewCardTrafficCollectionStore(s.db).List(ctx, postgres.CardTrafficCollectionQuery{ + CardID: cardID, From: from, To: to, Page: page, PageSize: size, + }) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡流量采集记录失败") + } + items := make([]dto.CardTrafficCollectionItem, 0, len(rows)) + for _, row := range rows { + items = append(items, dto.CardTrafficCollectionItem{ + ID: row.ID, ObservedAt: row.ObservedAt, ObservationID: row.ObservationID, Source: row.Source, + Scene: row.Scene, Result: row.Result, VendorUsedMB: row.VendorUsedMB, QueuePoints: json.RawMessage(row.QueuePoints), + Decision: row.Decision, MonthUsedMB: row.MonthUsedMB, StaleGuarded: row.StaleGuarded, + DeltaMB: row.DeltaMB, EventID: row.EventID, + }) + } + return &dto.CardTrafficCollectionListResponse{Items: items, Total: total}, nil +} + +func parseCollectionTime(value string) (time.Time, error) { + location := cardflow.ShanghaiDate(time.Now()).Location() + for _, layout := range collectionTimeLayouts { + if t, err := time.ParseInLocation(layout, value, location); err == nil { + return t, nil + } + } + return time.Time{}, errors.New(errors.CodeInvalidParam, "时间格式无效") +} diff --git a/internal/service/exchange/migration.go b/internal/service/exchange/migration.go index 51abcf40..f43afb99 100644 --- a/internal/service/exchange/migration.go +++ b/internal/service/exchange/migration.go @@ -32,6 +32,9 @@ func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil { return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败") } + if err = markTrafficBaselinePendingWithTx(ctx, tx, newAsset); err != nil { + return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "标记新资产流量待建起点失败") + } return &exchangeMigrationResult{Balance: migrationBalance, PackageUsageIDs: usageIDs}, nil } @@ -136,6 +139,19 @@ func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, ol return usageIDs, nil } +// markTrafficBaselinePendingWithTx 给换货新卡(设备换货时为新设备当前绑定的每张卡)打"待建起点"标记: +// 新卡第一次通过采集队列写入时只建起点不扣费,出库前已有的读数不算给迁过来的套餐。不调用运营商接口。 +func markTrafficBaselinePendingWithTx(ctx context.Context, tx *gorm.DB, newAsset *resolvedExchangeAsset) error { + query := tx.WithContext(ctx).Model(&model.IotCard{}) + if newAsset.AssetType == constants.ExchangeAssetTypeIotCard { + query = query.Where("id = ?", newAsset.AssetID) + } else { + query = query.Where("id IN (?)", tx.Model(&model.DeviceSimBinding{}).Select("iot_card_id"). + Where("device_id = ? AND bind_status = ?", newAsset.AssetID, constants.BindStatusBound)) + } + return query.Update("traffic_baseline_pending", true).Error +} + func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard { if oldAsset.Card == nil { diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 66b2fe6f..6d067e19 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -1408,6 +1408,18 @@ func (s *Service) RefreshCardDataByID(ctx context.Context, cardID uint) error { // 调用网关查询网络状态、实名状态、本月流量,并写回数据库 // 流量采用增量计算(与轮询逻辑一致):increment = 当前网关读数 - 上次网关读数 func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) error { + _, err := s.RefreshCardDataWithTraffic(ctx, iccid) + return err +} + +// RefreshCardDataWithTraffic 同 RefreshCardDataFromGateway,并返回本次流量读数、是否写入与原因。 +func (s *Service) RefreshCardDataWithTraffic(ctx context.Context, iccid string) (*dto.CardTrafficRefreshResult, error) { + var trafficResult *dto.CardTrafficRefreshResult + err := s.refreshCardDataFromGateway(ctx, iccid, &trafficResult) + return trafficResult, err +} + +func (s *Service) refreshCardDataFromGateway(ctx context.Context, iccid string, trafficResult **dto.CardTrafficRefreshResult) error { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { if err == gorm.ErrRecordNotFound { @@ -1644,6 +1656,7 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) resetDay := postgres.NewCarrierStore(s.db).GetDataResetDay(ctx, card.CarrierID) decision, applyErr := s.cardObservation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ CardID: card.ID, GatewayReadingMB: float64(flowResp.Used), ResetDay: resetDay, + ReadingResult: refreshReadingResult(flowResp), Metadata: carddomain.ObservationMetadata{ ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, @@ -1663,6 +1676,10 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) } refreshSuccess++ flowIncrementMB = decision.IncrementMB + *trafficResult = &dto.CardTrafficRefreshResult{ + ReadingMB: decision.ReadingMB, Result: decision.Result, Written: decision.ReadingAccepted, + Decision: decision.Decision, DeltaMB: decision.IncrementMB, + } } } } @@ -2096,3 +2113,11 @@ func applyFirstPurchaseFilters(filters map[string]any, start, end string) error } return nil } + +// refreshReadingResult 把运营商流量响应映射为读数结果:没给出可解析读数时为无读数。 +func refreshReadingResult(response *gateway.FlowUsageResp) string { + if !response.HasReading() { + return constants.TrafficCollectionResultNoReading + } + return constants.TrafficCollectionResultOK +} diff --git a/internal/service/package/usage_service.go b/internal/service/package/usage_service.go index 800d28dd..24811d31 100644 --- a/internal/service/package/usage_service.go +++ b/internal/service/package/usage_service.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -15,7 +16,6 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" - "gorm.io/gorm/clause" ) const ( @@ -72,16 +72,19 @@ func (s *UsageService) SetStopResumeCallback(callback StopResumeCallback) { s.stopResumeCallback = callback } -// DeductDataUsage 任务 10.2-10.6: 按优先级扣减流量 -// 扣减顺序:加油包(按 priority ASC) → 主套餐 -// 真实用量允许超过套餐阈值,超额部分记入最后一个生效套餐,用于后续虚流量阈值评估。 -// 流量用完时自动标记 status=2,所有套餐用完时触发停机。 -func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error { +// DeductDataUsage 按采集时刻扣减卡的流量增量。 +// 扣减套餐按"有套餐"共用口径(postgres.FindEffectivePackagesAt)在采集时刻查找:采集时刻有效、扣费时已用完或已到期的套餐照扣; +// 扣减顺序:加油包(按 priority ASC) → 主套餐,超额部分记入最后一个套餐,用于后续虚流量阈值评估。 +// 只对当前"生效中"的套餐做"扣到阈值改为已用完";一个套餐都查不到时记跳过审计并按成功返回;全部用完时触发停机。 +func (s *UsageService) DeductDataUsage(ctx context.Context, cardID uint, usageMB float64, observedAt time.Time, observationID string) error { if usageMB <= 0 { return errors.New(errors.CodeInvalidParam, "扣减流量必须大于0") } + if observedAt.IsZero() { + observedAt = time.Now() + } - deductUsageMB := s.convertToDeductMB(ctx, carrierType, carrierID, usageMB) + deductUsageMB := s.convertToDeductMB(ctx, constants.AssetTypeIotCard, cardID, usageMB) if deductUsageMB <= 0 { return nil } @@ -92,78 +95,60 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, var auditChanges []packageUsageAuditChange err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - targetCarrierType, targetCarrierID, packages, err := s.resolveActivePackages(ctx, tx, carrierType, carrierID) + effective, err := postgres.FindEffectivePackagesAt(ctx, tx, cardID, observedAt, true) if err != nil { - return err + return errors.Wrap(errors.CodeDatabaseError, err, "查询采集时刻有效套餐失败") } - - today := time.Now().Format("2006-01-02") - + packages := effective.Packages if len(packages) == 0 { - // 无生效套餐:尝试找 Depleted 套餐记录溢出流量详单,避免详单断档 - if recordErr := s.recordOverflowToDepletedPackage(ctx, tx, targetCarrierType, targetCarrierID, deductUsageMB, today); recordErr != nil { - s.logger.Warn("记录溢出流量到已耗尽套餐失败", - zap.String("carrier_type", targetCarrierType), - zap.Uint("carrier_id", targetCarrierID), - zap.Int64("usage_mb", deductUsageMB), - zap.Error(recordErr)) - } - return errors.New(errors.CodeNoAvailablePackage, "没有可用套餐") + // 采集时刻有套餐、扣费前被退款或作废:记告警并按成功处理,不重试;需要时人工冲正。 + s.logger.Warn("扣减卡流量时查不到采集时刻有效套餐,按成功跳过", + zap.Uint("card_id", cardID), zap.Int64("usage_mb", deductUsageMB), + zap.Time("observed_at", observedAt), zap.String("observation_id", observationID), + zap.String("metric", "package.traffic_deduct.skipped_no_package")) + return nil } + flowDate := cardflow.ShanghaiDate(observedAt) // 按优先级扣减流量 remainingUsage := deductUsageMB - for index, pkg := range packages { if remainingUsage <= 0 { break } - - // 计算当前套餐剩余额度 effectiveThresholdMB := pkg.EffectiveThresholdMB() remainingQuota := pkg.RemainingQuotaMB() isLastPackage := index == len(packages)-1 - if remainingQuota <= 0 && !isLastPackage { - // 套餐已用完,标记为已用完 - beforeData := packageUsageStateData(pkg) - if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败") - } - pkg.Status = constants.PackageUsageStatusDepleted - auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)}) - continue - } - // 本次从该套餐扣减的流量 var deductFromPkg int64 - if isLastPackage { + switch { + case isLastPackage: deductFromPkg = remainingUsage - } else if remainingUsage <= remainingQuota { + case remainingQuota <= 0: + deductFromPkg = 0 + case remainingUsage <= remainingQuota: deductFromPkg = remainingUsage - } else { + default: deductFromPkg = remainingQuota } if deductFromPkg <= 0 { - beforeData := packageUsageStateData(pkg) - if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败") + // 余量已用完且不是最后一个套餐:跳过;只有当前生效中的套餐顺带改为已用完。 + if pkg.Status == constants.PackageUsageStatusActive { + beforeData := packageUsageStateData(pkg) + if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败") + } + pkg.Status = constants.PackageUsageStatusDepleted + auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)}) } - pkg.Status = constants.PackageUsageStatusDepleted - auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)}) continue } - // 更新套餐使用量 newUsage := pkg.DataUsageMB + deductFromPkg - updates := map[string]interface{}{ - "data_usage_mb": newUsage, - } - - // 检查是否用完 - if effectiveThresholdMB > 0 && newUsage >= effectiveThresholdMB { + updates := map[string]interface{}{"data_usage_mb": newUsage} + if pkg.Status == constants.PackageUsageStatusActive && effectiveThresholdMB > 0 && newUsage >= effectiveThresholdMB { updates["status"] = constants.PackageUsageStatusDepleted } - beforeData := packageUsageStateData(pkg) if err := tx.Model(pkg).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐使用量失败") @@ -172,42 +157,42 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, if status, ok := updates["status"].(int); ok { pkg.Status = status } - - // 任务 10.6: 写入日记录 - if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, deductFromPkg, newUsage); err != nil { - return err + if err := postgres.AddDailyUsage(ctx, tx, pkg.ID, flowDate, deductFromPkg, newUsage); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐日用量失败") } auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)}) - remainingUsage -= deductFromPkg s.logger.Info("扣减套餐流量", zap.Uint("usage_id", pkg.ID), + zap.Int("status", pkg.Status), zap.Int64("deduct_mb", deductFromPkg), zap.Int64("new_usage_mb", newUsage), zap.Int64("effective_threshold_mb", effectiveThresholdMB), zap.Int64("data_limit_mb", pkg.DataLimitMB), zap.Int64("remaining_quota_mb", remainingQuota), + zap.Bool("crossed_reset", crossedReset(pkg, observedAt)), zap.Bool("over_threshold", effectiveThresholdMB > 0 && newUsage > effectiveThresholdMB)) } - // 任务 10.5: 检查是否所有套餐都用完(触发停机) - shouldSuspendCurrent, suspensionChanges, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID) + // 检查是否所有套餐都用完(触发停机),按套餐当前状态判断 + shouldSuspendCurrent, suspensionChanges, err := s.checkAndTriggerSuspension(ctx, tx, effective.CarrierType, effective.CarrierID) if err != nil { return err } auditChanges = append(auditChanges, suspensionChanges...) shouldSuspend = shouldSuspendCurrent - suspendCarrierType = targetCarrierType - suspendCarrierID = targetCarrierID + suspendCarrierType = effective.CarrierType + suspendCarrierID = effective.CarrierID if len(auditChanges) > 0 { if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量", normalizePackageUsageAuditChanges(auditChanges), nil, map[string]any{ - "carrier_type": targetCarrierType, "carrier_id": targetCarrierID, "usage_mb": deductUsageMB, + "carrier_type": effective.CarrierType, "carrier_id": effective.CarrierID, "usage_mb": deductUsageMB, + "card_id": cardID, "observed_at": observedAt, "observation_id": observationID, + "crossed_reset_usage_ids": crossedResetUsageIDs(packages, observedAt), }); err != nil { return err } } - return nil }) if err != nil { @@ -224,6 +209,22 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, return nil } +// crossedReset 判断采集时间是否早于套餐最近一次流量重置:这笔用量会扣在重置后的周期。 +func crossedReset(pkg *model.PackageUsage, observedAt time.Time) bool { + return pkg.LastResetAt != nil && observedAt.Before(*pkg.LastResetAt) +} + +// crossedResetUsageIDs 返回采集时间早于最近一次流量重置的套餐,写入扣费审计便于排查跨重置扣费。 +func crossedResetUsageIDs(packages []*model.PackageUsage, observedAt time.Time) []uint { + var ids []uint + for _, pkg := range packages { + if crossedReset(pkg, observedAt) { + ids = append(ids, pkg.ID) + } + } + return ids +} + // convertToDeductMB 将浮点增量转换为可扣减的整数 MB // 规则:先合并 Redis 中的小数余量,再按整数 MB 扣减;余数回写 Redis func (s *UsageService) convertToDeductMB(ctx context.Context, carrierType string, carrierID uint, usageMB float64) int64 { @@ -290,107 +291,6 @@ func (s *UsageService) persistUsageRemainder(ctx context.Context, key string, re return s.redis.Set(ctx, key, value, usageRemainderTTL).Err() } -// resolveActivePackages 统一解析可扣减载体 -// 优先按请求载体查找;当 iot_card 无生效套餐且存在设备绑定时,回退到 device 载体 -func (s *UsageService) resolveActivePackages(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (string, uint, []*model.PackageUsage, error) { - packages, err := s.queryActivePackages(tx, carrierType, carrierID) - if err != nil { - return "", 0, nil, err - } - if len(packages) > 0 { - return carrierType, carrierID, packages, nil - } - - if carrierType != constants.AssetTypeIotCard { - return carrierType, carrierID, packages, nil - } - - binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, carrierID) - if err == gorm.ErrRecordNotFound { - return carrierType, carrierID, packages, nil - } - if err != nil { - return "", 0, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败") - } - - devicePackages, queryErr := s.queryActivePackages(tx, constants.AssetTypeDevice, binding.DeviceID) - if queryErr != nil { - return "", 0, nil, queryErr - } - if len(devicePackages) == 0 { - return carrierType, carrierID, packages, nil - } - - s.logger.Info("卡载体无生效套餐,回退设备载体扣减流量", - zap.Uint("card_id", carrierID), - zap.Uint("device_id", binding.DeviceID)) - - return constants.AssetTypeDevice, binding.DeviceID, devicePackages, nil -} - -// queryActivePackages 查询指定载体下的生效套餐(按扣减优先级排序) -func (s *UsageService) queryActivePackages(tx *gorm.DB, carrierType string, carrierID uint) ([]*model.PackageUsage, error) { - query := tx.Where("status = ?", constants.PackageUsageStatusActive) - - switch carrierType { - case constants.AssetTypeIotCard: - query = query.Where("iot_card_id = ?", carrierID) - case constants.AssetTypeDevice: - query = query.Where("device_id = ?", carrierID) - default: - return nil, errors.New(errors.CodeInvalidParam, "无效的载体类型") - } - - var packages []*model.PackageUsage - if err := query. - Clauses(clause.Locking{Strength: "UPDATE"}). - Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC, id ASC"). - Find(&packages).Error; err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐失败") - } - return packages, nil -} - -// updateDailyRecord 任务 10.6: 更新日流量记录 -func (s *UsageService) updateDailyRecord(ctx context.Context, tx *gorm.DB, packageUsageID uint, dateStr string, dailyUsageMB, cumulativeUsageMB int64) error { - // 解析日期字符串 - date, err := time.Parse("2006-01-02", dateStr) - if err != nil { - return errors.Wrap(errors.CodeInvalidParam, err, "日期格式错误") - } - - // 查询是否已有今日记录 - var record model.PackageUsageDailyRecord - err = tx.Where("package_usage_id = ? AND date = ?", packageUsageID, date). - First(&record).Error - - if err == gorm.ErrRecordNotFound { - // 创建新记录 - record = model.PackageUsageDailyRecord{ - PackageUsageID: packageUsageID, - Date: date, - DailyUsageMB: int(dailyUsageMB), - CumulativeUsageMB: cumulativeUsageMB, - } - if err := tx.Create(&record).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建日流量记录失败") - } - } else if err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询日流量记录失败") - } else { - // 更新现有记录 - updates := map[string]interface{}{ - "daily_usage_mb": record.DailyUsageMB + int(dailyUsageMB), - "cumulative_usage_mb": cumulativeUsageMB, - } - if err := tx.Model(&record).Updates(updates).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新日流量记录失败") - } - } - - return nil -} - // checkAndTriggerSuspension 任务 10.5: 检查停机条件 func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, []packageUsageAuditChange, error) { query := tx.Model(&model.PackageUsage{}). @@ -490,45 +390,3 @@ func (s *UsageService) triggerSuspensionAfterCommit(ctx context.Context, carrier }) } } - -// recordOverflowToDepletedPackage 在所有套餐已耗尽时,将溢出流量写入最近的 Depleted 套餐日记录。 -// 目的:保持流量详单连续性,即便套餐用完期间上游仍有流量使用,详单不会断档。 -// 只写日记录和累加 data_usage_mb,不改变套餐 status,不触发停机。 -func (s *UsageService) recordOverflowToDepletedPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, overflowMB int64, today string) error { - query := tx.Where("status = ?", constants.PackageUsageStatusDepleted) - switch carrierType { - case constants.AssetTypeIotCard: - query = query.Where("iot_card_id = ?", carrierID) - case constants.AssetTypeDevice: - query = query.Where("device_id = ?", carrierID) - default: - return nil - } - - // 优先选主套餐(master_usage_id IS NULL),主套餐不存在时再退而求其次取加油包 - var pkg model.PackageUsage - err := query.Where("master_usage_id IS NULL").Order("id DESC").First(&pkg).Error - if err == gorm.ErrRecordNotFound { - err = query.Order("id DESC").First(&pkg).Error - } - if err != nil { - // 找不到任何 Depleted 套餐,静默忽略 - return nil - } - - newUsage := pkg.DataUsageMB + overflowMB - if err := tx.Model(&pkg).Update("data_usage_mb", newUsage).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新溢出流量失败") - } - - if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, overflowMB, newUsage); err != nil { - return err - } - - s.logger.Info("记录溢出流量到已耗尽套餐", - zap.Uint("package_usage_id", pkg.ID), - zap.Int64("overflow_mb", overflowMB), - zap.Int64("new_usage_mb", newUsage)) - - return nil -} diff --git a/internal/service/polling/config_service.go b/internal/service/polling/config_service.go index 41cdf34e..9317d4f5 100644 --- a/internal/service/polling/config_service.go +++ b/internal/service/polling/config_service.go @@ -28,6 +28,8 @@ type ConfigService struct { logger *zap.Logger configMgr *pollingcore.PollingConfigManager queueClient TaskEnqueuer + // trafficQueueLimits 返回当前采集队列配置,用于校验启用中配置的流量采集间隔上限。 + trafficQueueLimits func(ctx context.Context) TrafficQueueLimits } // SetAudit 注入轮询配置事务与统一审计 Writer。 @@ -92,6 +94,9 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig if err := validatePollingConfig(config); err != nil { return nil, err } + if err := s.validateTrafficInterval(ctx, config); err != nil { + return nil, err + } err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { if err := s.configStore.WithTx(tx).Create(ctx, config); err != nil { @@ -211,6 +216,9 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll if err := validatePollingConfig(config); err != nil { return nil, err } + if err := s.validateTrafficInterval(ctx, config); err != nil { + return nil, err + } config.UpdatedBy = ¤tUserID err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { @@ -343,6 +351,13 @@ func (s *ConfigService) UpdateStatus(ctx context.Context, id uint, status int16) } before := pollingConfigState(config) + if status == 1 { + enabled := *config + enabled.Status = status + if err := s.validateTrafficInterval(ctx, &enabled); err != nil { + return err + } + } err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { if err := s.configStore.WithTx(tx).UpdateStatus(ctx, id, status, currentUserID); err != nil { return err diff --git a/internal/service/polling/traffic_interval.go b/internal/service/polling/traffic_interval.go new file mode 100644 index 00000000..50e31432 --- /dev/null +++ b/internal/service/polling/traffic_interval.go @@ -0,0 +1,64 @@ +package polling + +import ( + "context" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// TrafficQueueLimits 是校验流量采集间隔需要的采集队列配置。 +type TrafficQueueLimits struct { + Size int + ClearOnNewDay bool + MaxPointAgeHours int +} + +// SetTrafficQueueLimits 注入当前采集队列配置;未注入时不校验流量采集间隔。 +func (s *ConfigService) SetTrafficQueueLimits(limits func(ctx context.Context) TrafficQueueLimits) { + s.trafficQueueLimits = limits +} + +// validateTrafficInterval 只对启用中的轮询配置校验流量采集间隔:不超过 24h ÷ (N+2)(跨天清空开启) +// 或最长点龄 ÷ (N+2)(关闭);停用的配置不校验。 +func (s *ConfigService) validateTrafficInterval(ctx context.Context, config *model.PollingConfig) error { + if s.trafficQueueLimits == nil || config.Status != 1 { + return nil + } + return checkTrafficInterval(config, s.trafficQueueLimits(ctx)) +} + +func checkTrafficInterval(config *model.PollingConfig, limits TrafficQueueLimits) error { + limit := cardflow.MaxCollectionInterval(limits.Size, limits.ClearOnNewDay, limits.MaxPointAgeHours, constants.TrafficQueueIntervalSpare) + if limit <= 0 || config.CarddataCheckInterval == nil || *config.CarddataCheckInterval <= 0 { + return nil + } + interval := time.Duration(*config.CarddataCheckInterval) * time.Second + if interval <= limit { + return nil + } + return errors.New(errors.CodeInvalidParam, fmt.Sprintf( + "轮询配置「%s」流量检查间隔 %d 秒超过上限 %d 秒(采集队列长度 %d,留 %d 次冗余),请调小间隔", + config.ConfigName, *config.CarddataCheckInterval, int64(limit/time.Second), limits.Size, constants.TrafficQueueIntervalSpare)) +} + +// CheckEnabledTrafficIntervals 校验全部启用中的轮询配置在给定队列配置下是否都满足流量采集间隔上限, +// 用于修改队列长度、跨天清空、最长点龄前的拦截。 +func CheckEnabledTrafficIntervals(ctx context.Context, db *gorm.DB, limits TrafficQueueLimits) error { + var configs []*model.PollingConfig + if err := db.WithContext(ctx).Where("status = ? AND carddata_check_interval > 0", 1).Find(&configs).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询启用中的轮询配置失败") + } + for _, config := range configs { + if err := checkTrafficInterval(config, limits); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/postgres/card_flow_day_store.go b/internal/store/postgres/card_flow_day_store.go index 7b598221..3b6c7b70 100644 --- a/internal/store/postgres/card_flow_day_store.go +++ b/internal/store/postgres/card_flow_day_store.go @@ -88,6 +88,19 @@ func (s *CardFlowDayStore) AddSettled(ctx context.Context, cardID uint, flowDate ).Error } +// AddSettledToLatest 把增量计入本月截至 flowDate 最近一天快照的已结算;本月没有快照时不做任何事。 +func (s *CardFlowDayStore) AddSettledToLatest(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 id = (SELECT id FROM tb_card_flow_day WHERE iot_card_id = ? AND flow_date >= ?::date AND flow_date <= ?::date + ORDER BY flow_date DESC LIMIT 1)`, + deltaMB, cardID, cardflow.MonthStart(flowDate).Format(cardFlowDateLayout), 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) diff --git a/internal/store/postgres/card_flow_switch_store.go b/internal/store/postgres/card_flow_switch_store.go deleted file mode 100644 index ccee2f65..00000000 --- a/internal/store/postgres/card_flow_switch_store.go +++ /dev/null @@ -1,43 +0,0 @@ -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/store/postgres/card_traffic_collection_store.go b/internal/store/postgres/card_traffic_collection_store.go new file mode 100644 index 00000000..30c13d86 --- /dev/null +++ b/internal/store/postgres/card_traffic_collection_store.go @@ -0,0 +1,97 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/domain/cardflow" + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// collectionPartitionPrefix 是采集记录月分区表名前缀,后接 YYYYMM。 +const collectionPartitionPrefix = "tb_card_traffic_collection_" + +// CardTrafficCollectionStore 卡流量采集记录存储。 +type CardTrafficCollectionStore struct { + db *gorm.DB +} + +// NewCardTrafficCollectionStore 创建采集记录存储。 +func NewCardTrafficCollectionStore(db *gorm.DB) *CardTrafficCollectionStore { + return &CardTrafficCollectionStore{db: db} +} + +// Create 写入一条采集记录。 +func (s *CardTrafficCollectionStore) Create(ctx context.Context, record *model.CardTrafficCollection) error { + return s.db.WithContext(ctx).Create(record).Error +} + +// CardTrafficCollectionQuery 是采集记录查询条件。 +type CardTrafficCollectionQuery struct { + CardID uint + From, To time.Time + Page int + PageSize int +} + +// List 按卡与采集时间范围分页查询采集记录,按采集时间倒序。 +func (s *CardTrafficCollectionStore) List(ctx context.Context, query CardTrafficCollectionQuery) ([]model.CardTrafficCollection, int64, error) { + db := s.db.WithContext(ctx).Model(&model.CardTrafficCollection{}). + Where("iot_card_id = ? AND observed_at >= ? AND observed_at < ?", query.CardID, query.From, query.To) + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []model.CardTrafficCollection + err := db.Order("observed_at DESC, id DESC").Offset((query.Page - 1) * query.PageSize).Limit(query.PageSize).Find(&rows).Error + return rows, total, err +} + +// FindByObservationID 按采集 ID 查询采集记录。 +func (s *CardTrafficCollectionStore) FindByObservationID(ctx context.Context, observationID string) ([]model.CardTrafficCollection, error) { + var rows []model.CardTrafficCollection + err := s.db.WithContext(ctx).Where("observation_id = ?", observationID).Order("observed_at DESC").Find(&rows).Error + return rows, err +} + +// MaintainPartitions 预建本月起 aheadMonths 个月的分区,并删除整月都早于保留期的分区。 +func (s *CardTrafficCollectionStore) MaintainPartitions(ctx context.Context, now time.Time, aheadMonths int, retentionDays int) error { + monthStart := cardflow.MonthStart(now) + for i := 0; i <= aheadMonths; i++ { + from := monthStart.AddDate(0, i, 0) + to := from.AddDate(0, 1, 0) + sql := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s%s PARTITION OF tb_card_traffic_collection FOR VALUES FROM ('%s') TO ('%s')`, + collectionPartitionPrefix, from.Format("200601"), from.Format(time.RFC3339), to.Format(time.RFC3339)) + if err := s.db.WithContext(ctx).Exec(sql).Error; err != nil { + return err + } + } + cutoff := cardflow.ShanghaiDate(now).AddDate(0, 0, -retentionDays) + var partitions []string + if err := s.db.WithContext(ctx).Raw(` +SELECT c.relname FROM pg_inherits i +JOIN pg_class c ON c.oid = i.inhrelid +JOIN pg_class p ON p.oid = i.inhparent +WHERE p.relname = 'tb_card_traffic_collection'`).Scan(&partitions).Error; err != nil { + return err + } + for _, name := range partitions { + if len(name) != len(collectionPartitionPrefix)+6 || !strings.HasPrefix(name, collectionPartitionPrefix) { + continue + } + start, err := time.ParseInLocation("200601", name[len(collectionPartitionPrefix):], cutoff.Location()) + if err != nil { + continue + } + if !start.AddDate(0, 1, 0).After(cutoff) { + if err := s.db.WithContext(ctx).Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", name)).Error; err != nil { + return err + } + } + } + return nil +} diff --git a/internal/store/postgres/package_usage_effective.go b/internal/store/postgres/package_usage_effective.go new file mode 100644 index 00000000..5cd1ab45 --- /dev/null +++ b/internal/store/postgres/package_usage_effective.go @@ -0,0 +1,94 @@ +package postgres + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// effectivePackageStatuses 是"采集时刻有效套餐"认可的套餐状态:状态可能在采集之后才变为已用完或已过期, +// 是否在采集时刻有效由激活时间与到期时间判断。 +var effectivePackageStatuses = []int{ + constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted, constants.PackageUsageStatusExpired, +} + +// EffectivePackages 是一次"采集时刻有效套餐"查询的结果。 +type EffectivePackages struct { + // CarrierType 与 CarrierID 是实际扣减载体:卡自身没有有效套餐时回退到卡当前绑定的设备。 + CarrierType string + CarrierID uint + // Packages 按扣减优先级排序:加油包优先,再按 priority、id。 + Packages []*model.PackageUsage +} + +// FindEffectivePackagesAt 按"有套餐"共用口径查询卡在采集时刻的有效套餐:状态为生效中、已用完或已过期, +// 激活时间不晚于采集时间(为空按已生效),到期时间为空或晚于采集时间,未退款; +// 卡自身一个都没有时看卡当前绑定设备的套餐。lock 为 true 时对查到的套餐行加 FOR UPDATE。 +// 采集侧(写入范围、是否发事件、采集记录、日用量 0 行)与扣费侧共用本函数,避免两边口径不一致。 +func FindEffectivePackagesAt(ctx context.Context, db *gorm.DB, cardID uint, observedAt time.Time, lock bool) (EffectivePackages, error) { + own, err := queryEffectivePackages(ctx, db, "iot_card_id = ?", cardID, observedAt, lock) + if err != nil || len(own) > 0 { + return EffectivePackages{CarrierType: constants.AssetTypeIotCard, CarrierID: cardID, Packages: own}, err + } + 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 EffectivePackages{}, err + } + if len(deviceIDs) == 0 { + return EffectivePackages{CarrierType: constants.AssetTypeIotCard, CarrierID: cardID}, nil + } + devicePackages, err := queryEffectivePackages(ctx, db, "device_id = ?", deviceIDs[0], observedAt, lock) + if err != nil { + return EffectivePackages{}, err + } + if len(devicePackages) == 0 { + return EffectivePackages{CarrierType: constants.AssetTypeIotCard, CarrierID: cardID}, nil + } + return EffectivePackages{CarrierType: constants.AssetTypeDevice, CarrierID: deviceIDs[0], Packages: devicePackages}, nil +} + +func queryEffectivePackages(ctx context.Context, db *gorm.DB, carrierCondition string, carrierID uint, observedAt time.Time, lock bool) ([]*model.PackageUsage, error) { + query := db.WithContext(ctx).Model(&model.PackageUsage{}). + Where(carrierCondition, carrierID). + Where("status IN ?", effectivePackageStatuses). + Where("activated_at IS NULL OR activated_at <= ?", observedAt). + Where("expires_at IS NULL OR expires_at > ?", observedAt). + Where("refund_id IS NULL") + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var packages []*model.PackageUsage + err := query.Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC, id ASC").Find(&packages).Error + return packages, err +} + +// EnsureDailyUsageRows 为每个套餐确保 date 当天一行套餐日用量(没有则写 0,已有不动)。 +func EnsureDailyUsageRows(ctx context.Context, db *gorm.DB, packageUsageIDs []uint, date time.Time, cumulative map[uint]int64) error { + for _, id := range packageUsageIDs { + if err := db.WithContext(ctx).Exec(` +INSERT INTO tb_package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at) +VALUES (?, ?::date, 0, ?, NOW(), NOW()) +ON CONFLICT (package_usage_id, date) DO NOTHING`, id, date.Format(cardFlowDateLayout), cumulative[id]).Error; err != nil { + return err + } + } + return nil +} + +// AddDailyUsage 原子累加套餐 date 当天用量并覆盖累计用量;当天没有行时新建。 +func AddDailyUsage(ctx context.Context, db *gorm.DB, packageUsageID uint, date time.Time, dailyMB int64, cumulativeMB int64) error { + return db.WithContext(ctx).Exec(` +INSERT INTO tb_package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at) +VALUES (?, ?::date, ?, ?, NOW(), NOW()) +ON CONFLICT (package_usage_id, date) DO UPDATE SET + daily_usage_mb = tb_package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb, + cumulative_usage_mb = EXCLUDED.cumulative_usage_mb, + updated_at = NOW()`, packageUsageID, date.Format(cardFlowDateLayout), dailyMB, cumulativeMB).Error +} diff --git a/internal/store/postgres/package_usage_store.go b/internal/store/postgres/package_usage_store.go index c09f3eb1..68f1eda4 100644 --- a/internal/store/postgres/package_usage_store.go +++ b/internal/store/postgres/package_usage_store.go @@ -8,6 +8,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/redis/go-redis/v9" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type PackageUsageStore struct { @@ -53,6 +54,17 @@ func (s *PackageUsageStore) GetByIDForCarrier(ctx context.Context, id uint, carr return &usage, nil } +// LockByIDForCarrier 在当前事务内按载体锁定套餐使用记录(FOR UPDATE),与流量扣减同一把行锁。 +func (s *PackageUsageStore) LockByIDForCarrier(ctx context.Context, id uint, carrierType string, carrierID uint) (*model.PackageUsage, error) { + var usage model.PackageUsage + query := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id) + query = applyPackageUsageCarrierFilter(query, carrierType, carrierID) + if err := query.First(&usage).Error; err != nil { + return nil, err + } + return &usage, nil +} + // Update 更新套餐使用记录 func (s *PackageUsageStore) Update(ctx context.Context, usage *model.PackageUsage) error { return s.db.WithContext(ctx).Save(usage).Error diff --git a/internal/task/polling_carddata_handler.go b/internal/task/polling_carddata_handler.go index 22b077fe..c9c5b2c4 100644 --- a/internal/task/polling_carddata_handler.go +++ b/internal/task/polling_carddata_handler.go @@ -92,15 +92,18 @@ func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutc attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling) result, err := h.gateway.QueryFlow(gateway.WithCarrier(ctx, card.CarrierID), &gateway.FlowQueryReq{CardNo: card.ICCID}) if gateway.IsRateLimited(err) { + h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultRateLimited) priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试") return StepOutcome{Result: StepRateLimited} } if err != nil { + h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultFailed) _ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败", priority) priority.FailRetryable(ctx, "查询流量失败") return h.fail(ctx, cardID, startedAt, "查询流量失败", err) } if strings.TrimSpace(result.ICCID) == "" { + h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultInvalidPayload) _ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "流量查询响应缺少 ICCID", priority) priority.FailRetryable(ctx, "流量查询响应缺少 ICCID") return h.fail(ctx, cardID, startedAt, "流量查询响应缺少 ICCID", nil) @@ -112,7 +115,8 @@ func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutc } now := time.Now() decision, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ - CardID: cardID, GatewayReadingMB: float64(result.Used), ResetDay: h.carrier.GetDataResetDay(ctx, card.CarrierID), + CardID: cardID, GatewayReadingMB: float64(result.Used), ReadingResult: gatewayReadingResult(result), + ResetDay: h.carrier.GetDataResetDay(ctx, card.CarrierID), Metadata: carddomain.ObservationMetadata{ ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling, Scene: constants.CardObservationSceneTrafficPolling, ObservedAt: now, CorrelationID: attempt.IntegrationID, @@ -131,7 +135,8 @@ func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutc if h.base.verboseLog { h.base.logger.Info("流量轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Float64("gateway_flow_mb", float64(result.Used)), zap.Float64("increment_mb", decision.IncrementMB), - zap.Bool("is_cross_month", decision.CrossMonth), zap.Bool("reading_accepted", decision.ReadingAccepted)) + zap.Bool("is_cross_month", decision.CrossMonth), zap.Bool("reading_accepted", decision.ReadingAccepted), + zap.String("result", decision.Result), zap.String("decision", decision.Decision)) } metric := "polling.observation.unchanged" if changed { @@ -152,3 +157,27 @@ func (h *PollingCarddataHandler) fail(ctx context.Context, cardID uint, startedA h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startedAt)) return h.base.stepFail(ctx, constants.GatewayOpQueryFlow, message, err) } + +// recordUnreadable 为没拿到有效读数的采集写采集记录(只记采集时刻有套餐的卡),失败只影响留痕。 +func (h *PollingCarddataHandler) recordUnreadable(ctx context.Context, cardID uint, observationID, result string) { + if h.observation == nil { + return + } + if _, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ + CardID: cardID, ReadingResult: result, ResetDay: 1, + Metadata: carddomain.ObservationMetadata{ + ObservationID: observationID, Source: constants.CardObservationSourcePolling, + Scene: constants.CardObservationSceneTrafficPolling, ObservedAt: time.Now(), CorrelationID: observationID, + }, + }); err != nil { + h.base.logger.Warn("记录卡流量采集失败结果失败", zap.Uint("card_id", cardID), zap.String("result", result), zap.Error(err)) + } +} + +// gatewayReadingResult 把运营商流量响应映射为读数结果:used 缺失、为 null、为空或无法解析时为无读数。 +func gatewayReadingResult(result *gateway.FlowUsageResp) string { + if !result.HasReading() { + return constants.TrafficCollectionResultNoReading + } + return constants.TrafficCollectionResultOK +} diff --git a/migrations/000267_add_card_traffic_collection.down.sql b/migrations/000267_add_card_traffic_collection.down.sql new file mode 100644 index 00000000..a0693ec0 --- /dev/null +++ b/migrations/000267_add_card_traffic_collection.down.sql @@ -0,0 +1,3 @@ +-- 回滚卡流量采集记录与待建起点标记。 +DROP TABLE IF EXISTS tb_card_traffic_collection; +ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS traffic_baseline_pending; diff --git a/migrations/000267_add_card_traffic_collection.up.sql b/migrations/000267_add_card_traffic_collection.up.sql new file mode 100644 index 00000000..437c7e31 --- /dev/null +++ b/migrations/000267_add_card_traffic_collection.up.sql @@ -0,0 +1,52 @@ +-- 卡流量采集队列与全量切换(add-card-traffic-queue-and-observed-deduction)。 +-- 1. tb_iot_card 增加"待建起点"标记:带标记的卡第一次通过采集队列写入时,只写快照、把本月最大快照全部记为已结算、 +-- 不扣费,然后清标记。全量切换时全部卡置位,避免把卡表基线算法已扣过的本月用量再扣一遍;换卡新卡同样置位。 +-- 2. 新增采集记录 tb_card_traffic_collection:采集时刻有套餐的卡每次采集一条(含接口失败、读数为 0、被队列拦下), +-- 按 observed_at 按月分区,保留 90 天,过期按整月分区删除(由应用层维护分区)。系统写入,不软删。 +-- 3. 不使用数据库外键;iot_card_id 由应用层显式校验(ENG-MODEL-001)。 + +ALTER TABLE tb_iot_card ADD COLUMN traffic_baseline_pending BOOLEAN NOT NULL DEFAULT FALSE; +COMMENT ON COLUMN tb_iot_card.traffic_baseline_pending IS '卡流量待建起点:第一次通过采集队列写入时只建起点不扣费,之后清除'; +UPDATE tb_iot_card SET traffic_baseline_pending = TRUE WHERE deleted_at IS NULL; + +CREATE TABLE tb_card_traffic_collection ( + id BIGSERIAL, + iot_card_id BIGINT NOT NULL, + observed_at TIMESTAMPTZ NOT NULL, + observation_id VARCHAR(128) NOT NULL DEFAULT '', + source VARCHAR(32) NOT NULL DEFAULT '', + scene VARCHAR(64) NOT NULL DEFAULT '', + result VARCHAR(32) NOT NULL, + vendor_used_mb NUMERIC(14,3), + queue_points JSONB, + decision VARCHAR(32) NOT NULL DEFAULT '', + month_used_mb NUMERIC(14,3), + stale_guarded BOOLEAN, + delta_mb NUMERIC(14,3), + event_id VARCHAR(128) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (id, observed_at) +) PARTITION BY RANGE (observed_at); + +CREATE INDEX idx_card_traffic_collection_card_time ON tb_card_traffic_collection (iot_card_id, observed_at DESC); +CREATE INDEX idx_card_traffic_collection_observation ON tb_card_traffic_collection (observation_id); + +COMMENT ON TABLE tb_card_traffic_collection IS '卡流量采集记录:采集时刻有套餐的卡每次采集一条,按月分区保留 90 天'; +COMMENT ON COLUMN tb_card_traffic_collection.observation_id IS '采集 ID,与流量事件、扣费审计串联'; +COMMENT ON COLUMN tb_card_traffic_collection.source IS '来源:polling/manual_sync/business_event'; +COMMENT ON COLUMN tb_card_traffic_collection.scene IS '场景:traffic_polling/manual_refresh/pre_activation_settlement 等'; +COMMENT ON COLUMN tb_card_traffic_collection.result IS '读数结果:ok/failed/rate_limited/invalid_payload/no_reading/invalid_reading'; +COMMENT ON COLUMN tb_card_traffic_collection.vendor_used_mb IS '运营商原始读数(MB),没有给值时为空'; +COMMENT ON COLUMN tb_card_traffic_collection.queue_points IS '入队后的采集队列(采集时间 + 读数)'; +COMMENT ON COLUMN tb_card_traffic_collection.decision IS '写入判断:written/baseline/queue_not_full/slope_negative/out_of_order/settled_existing,读数无效时为空'; +COMMENT ON COLUMN tb_card_traffic_collection.month_used_mb IS '写入时换算后的本月累计(MB)'; +COMMENT ON COLUMN tb_card_traffic_collection.stale_guarded IS '写入时是否触发重置日防旧值'; +COMMENT ON COLUMN tb_card_traffic_collection.delta_mb IS '写入时本次增量(MB)'; +COMMENT ON COLUMN tb_card_traffic_collection.event_id IS '写入时的流量事件 ID'; + +CREATE TABLE tb_card_traffic_collection_202610 PARTITION OF tb_card_traffic_collection + FOR VALUES FROM ('2026-10-01 00:00:00+08') TO ('2026-11-01 00:00:00+08'); +CREATE TABLE tb_card_traffic_collection_202611 PARTITION OF tb_card_traffic_collection + FOR VALUES FROM ('2026-11-01 00:00:00+08') TO ('2026-12-01 00:00:00+08'); +CREATE TABLE tb_card_traffic_collection_202612 PARTITION OF tb_card_traffic_collection + FOR VALUES FROM ('2026-12-01 00:00:00+08') TO ('2027-01-01 00:00:00+08'); diff --git a/migrations/000268_add_package_usage_adjustment.down.sql b/migrations/000268_add_package_usage_adjustment.down.sql new file mode 100644 index 00000000..b3542814 --- /dev/null +++ b/migrations/000268_add_package_usage_adjustment.down.sql @@ -0,0 +1,2 @@ +-- 回滚套餐流量人工冲正流水。 +DROP TABLE IF EXISTS tb_package_usage_adjustment; diff --git a/migrations/000268_add_package_usage_adjustment.up.sql b/migrations/000268_add_package_usage_adjustment.up.sql new file mode 100644 index 00000000..57b829ed --- /dev/null +++ b/migrations/000268_add_package_usage_adjustment.up.sql @@ -0,0 +1,38 @@ +-- 套餐流量人工冲正流水(add-card-traffic-queue-and-observed-deduction)。 +-- 冲正只通过本表进行:事务内锁套餐行、按当前已用量加减,退回后不得小于 0;冲正不改卡日快照与已结算。 +-- 只新增不修改、不删除;不使用数据库外键(ENG-MODEL-001)。 +CREATE TABLE tb_package_usage_adjustment ( + id BIGSERIAL PRIMARY KEY, + package_usage_id BIGINT NOT NULL, + iot_card_id BIGINT NOT NULL DEFAULT 0, + device_id BIGINT NOT NULL DEFAULT 0, + delta_mb BIGINT NOT NULL, + before_usage_mb BIGINT NOT NULL, + after_usage_mb BIGINT NOT NULL, + before_status INTEGER NOT NULL, + after_status INTEGER NOT NULL, + reason VARCHAR(500) NOT NULL, + ref_observation_id VARCHAR(128) NOT NULL DEFAULT '', + ref_event_id VARCHAR(128) NOT NULL DEFAULT '', + operator_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_package_usage_adjustment_delta CHECK (delta_mb <> 0), + CONSTRAINT ck_package_usage_adjustment_after CHECK (after_usage_mb >= 0) +); + +CREATE INDEX idx_package_usage_adjustment_usage ON tb_package_usage_adjustment (package_usage_id, created_at DESC); +CREATE INDEX idx_package_usage_adjustment_card ON tb_package_usage_adjustment (iot_card_id, created_at DESC); + +COMMENT ON TABLE tb_package_usage_adjustment IS '套餐流量人工冲正流水:正数补扣、负数退回'; +COMMENT ON COLUMN tb_package_usage_adjustment.package_usage_id IS '套餐使用记录ID'; +COMMENT ON COLUMN tb_package_usage_adjustment.iot_card_id IS '单卡套餐的卡ID,设备套餐为 0'; +COMMENT ON COLUMN tb_package_usage_adjustment.device_id IS '设备套餐的设备ID,单卡套餐为 0'; +COMMENT ON COLUMN tb_package_usage_adjustment.delta_mb IS '调整量(MB):正数补扣,负数退回'; +COMMENT ON COLUMN tb_package_usage_adjustment.before_usage_mb IS '锁定后调整前的套餐已用量(MB)'; +COMMENT ON COLUMN tb_package_usage_adjustment.after_usage_mb IS '调整后的套餐已用量(MB)'; +COMMENT ON COLUMN tb_package_usage_adjustment.before_status IS '调整前套餐状态'; +COMMENT ON COLUMN tb_package_usage_adjustment.after_status IS '调整后套餐状态'; +COMMENT ON COLUMN tb_package_usage_adjustment.reason IS '冲正原因(必填)'; +COMMENT ON COLUMN tb_package_usage_adjustment.ref_observation_id IS '关联的采集 ID(可选)'; +COMMENT ON COLUMN tb_package_usage_adjustment.ref_event_id IS '关联的流量事件 ID(可选)'; +COMMENT ON COLUMN tb_package_usage_adjustment.operator_id IS '操作人账号ID'; diff --git a/openspec/changes/add-card-traffic-queue-and-observed-deduction/.openspec.yaml b/openspec/changes/add-card-traffic-queue-and-observed-deduction/.openspec.yaml new file mode 100644 index 00000000..5c99d6d7 --- /dev/null +++ b/openspec/changes/add-card-traffic-queue-and-observed-deduction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-29 diff --git a/openspec/changes/add-card-traffic-queue-and-observed-deduction/design.md b/openspec/changes/add-card-traffic-queue-and-observed-deduction/design.md new file mode 100644 index 00000000..8e9ec40c --- /dev/null +++ b/openspec/changes/add-card-traffic-queue-and-observed-deduction/design.md @@ -0,0 +1,79 @@ +## Context + +完整推导、例子与决策编号(T1–T25)见 `docs/卡流量采集写入与扣减技术方案.md`。本文只记录实现层的关键决策。 + +现状(测试环境,`Iteration/8-11`): + +- `rebuild-card-traffic-metering-and-stop-resume` 已实现日快照(`tb_card_flow_day`)、自然月换算与重置日防旧值(`internal/domain/cardflow/rules.go`)、按月对账、切换水位与灰度名单(`traffic_calc_policy.go`);测试环境 `JUNHONG_TRAFFIC_CALC_MODE=gray`、名单 `device:10`。 +- `cardflow.ValidReading` 要求读数 > 0,测试库全部轮询卡读数为 0,`tb_card_flow_day` 与 `tb_card_flow_switch` 均为 0 行,扣费实际一直走卡表基线算法。 +- `internal/gateway/models.go` 的 `FlexFloat` 把 null、空、无法解析都转成 0。 +- `DeductDataUsage`(`internal/service/package/usage_service.go`)按扣费时刻 `status = 生效中` 查套餐,没有套餐时返回错误由 Asynq 重试;`updateDailyRecord` 先查后插、日期取 `time.Now()`;循环内会把余量 ≤ 0 的套餐改为已用完。 +- `hasActivePackageForCard` / `countActivePackages`(`traffic_calc_policy.go:135-171`)只认生效中、不看 `expires_at`。 +- 测试环境启用中的轮询配置只有 id 29(流量采集 60 秒),Redis 为 `noeviction`、未设 `maxmemory`,数据库与容器时区为上海;只有实名回调,没有运营商流量推送。 +- 约束:自动化测试为 N/A,验证在维护者指定的测试环境进行;不改既有迁移;不改对接日志与审计写入(本次不动)。 + +## Goals / Non-Goals + +**Goals:** + +- 单次异常读数(中间点暴涨、清零前后来回跳)在写入前拦下,不进快照、不扣费、不触发通道阈值。 +- 测试环境只剩一条计量路径,切换不重复扣。 +- 采集侧与扣费侧"有没有套餐"一致,套餐用完到停机之间的用量能扣到。 +- 每次采集可追溯,扣错可人工冲正。 + +**Non-Goals:** + +- 自动对账(运营商 T+1 日用量或月账单)、月末尾巴补算。 +- 最后一个点暴涨、运营商连续返回递增错误值、运营商晚清零的自动识别。 +- 跨套餐重置时由写入间隔造成的用量归属偏移(避免不了,不处理)。 +- 对接日志、审计的瘦身;复机无效重试问题。 +- 生产环境发布、迁移与回滚。 + +## Decisions + +**D1 队列放 Redis、Lua 原子更新,不放数据库。** 队列只是写入前的过滤器,不是账:丢了只是晚写 N−1 次,不多扣不少扣。放数据库会让每次采集都多一次行写,与 `polling-load-control` 的低写入目标冲突。Lua 内只做插入、去重、裁剪、跨天清空与过期丢弃,返回队列与"新点是否在队尾";上海日期由 Go 侧算好随点传入,Lua 不做时区换算。写入判断(不下降 + 容差)在 Go 侧做,便于记录原因。 + +**D2 写最后一个点,新点必须在队尾。** 写最后一个点让增量最及时;新点不在队尾说明有更晚的点已判断过,再写会让采集记录看起来写了自己的读数,排查时误导,所以记为乱序不写。容差只用于"不下降"判断,日快照取最大值、增量 = 最大值 − 已结算,小幅回退的点写进去不产生增量,不会多扣。替代方案"写倒数第二个点"能挡住最后一个点暴涨,但每次写入都晚一个采集周期,已否决。 + +**D3 采集间隔上限按 N+2 次。** 理论最低是窗口内采到 N 次,但一次失败当天就写不进去,告警会常态触发;留 2 次冗余。只校验启用中的轮询配置,停用配置(测试库 id 25,86400 秒)不阻止保存,启用时再校验。 + +**D4 全量切换用"待建起点"标记,不保留切换水位。** 切换前扣费走卡表基线算法,日快照上没有本月已结算;不打标记,切换后第一次写入的 Δ 会是整月用量,而这部分老路径已扣过。标记与换卡共用同一字段(`tb_iot_card` 新增),随迁移把全部卡置位;建起点也过队列,避免异常读数当起点。迁移执行时老代码仍在跑也安全:老代码不认标记照常按老路径扣,新代码第一次写入只建起点。代价是老路径最后一次扣费到建起点之间的用量不扣(约 N−1 个采集周期),测试环境可接受。 + +**D5 "有套餐"抽成一个共用判断,采集与扣费同时调用。** 由 package 服务提供按 `(卡, 采集时间)` 查有效套餐的方法;观测事务(写入范围、是否发事件、是否写采集记录、日用量 0 行)与 `DeductDataUsage` 都调用它。状态纳入已过期,是因为状态可能在采集之后才变;是否在采集时刻有效由 `expires_at` 判断。 + +**D6 读数 0 由写入范围决定写不写,不由读数校验拦。** `ValidReading` 放宽为"有限且 ≥ 0";有套餐的卡 0 也写快照与日用量 0 行,用于"每天都有记录"的排查需求;没套餐的卡读数 0 不写。`isStale` 对 ≤ 0 直接返回否,换算结果不变。 + +**D7 重置日照搬老系统,只认配置的重置日;比较基准为 0 统一照常防旧值。** 老系统移动(`YidongDayFlowService`)、联通(`PtLiantongDayFlowService`)都是重置日当天全天 0.9 判断、不按小时、尾巴不补。两套代码对基准为 0 的处理不同(移动照常判断、联通不判断),统一为照常判断:`isStale` 去掉 `ThisMonthBeforeResetMax > 0` 条件,最坏是晚一天扣,不会多扣。不设多日警惕期、不把 1 点前的读数记回前一天。 + +**D8 日用量 0 行在观测事务里写,扣减消费者只累加。** 消费者只在 Δ > 0 时运行,没用流量的卡走不到那里。观测事务调用 package 服务的"确保当天日用量行"(`INSERT ... ON CONFLICT (package_usage_id, date) DO NOTHING`),不在 cardobservation 里直接写套餐表;`updateDailyRecord` 改为 upsert 累加。 + +**D9 扣费查不到套餐按成功跳过。** 原来返回错误会让 Asynq 一直重试,而退款、作废后不可能再查到;改为写跳过审计、事件成功结束,需要时人工冲正。 + +**D10 采集记录单独建表、按月分区,不复用对接日志或审计。** 只记有套餐的卡;保留 90 天需要同时存在 4 个月分区,过期直接删整月分区,不用 `DELETE ... LIMIT`。`observation_id` 串起采集记录、流量事件、扣费审计。不进 Audit Event 与 Integration Log,与 `polling-load-control` 的"无变化观测不写审计"不冲突。 + +**D11 冲正锁套餐行、按当前值加减,提交后走停复机判断。** 与扣减同一把行锁,避免并发互相覆盖;不允许负数,避免掩盖数据问题。卡可能同时因实名、沉默期、人工等原因停着,冲正只改了"套餐用完"一个条件,交给 `evaluator.EvaluateAndAct` 综合判断。冲正不改日快照与已结算。 + +**D12 日期统一上海日期。** 套餐日用量日期与消费者日流量缓冲 key 都用 `cardflow.ShanghaiDate(observed_at)`;测试环境时区本就是上海,这是防止时间值为 UTC 时 0~8 点落到前一天。 + +## Risks / Trade-offs + +- [最后一个点暴涨挡不住] → 靠人工冲正;告警与采集记录便于发现。 +- [运营商晚一天清零、清零后返回缓存旧数会多扣] → 与老系统一致;重置日防旧值命中数用于核对,多扣走冲正。 +- [要攒满 N 个点才写,扣费与停机延后;跨天清空开启时当天第一次写入在第一次采集后 (N−1) 个间隔] → 测试环境间隔 60 秒,影响约 2 分钟;需要更及时时缩短间隔。 +- [全量切换、换卡后到建起点之间的用量不扣] → 约 N−1 个采集周期,测试环境可接受。 +- [采集后套餐被重置的扣在重置后周期] → 审计记录采集时间与重置时间,可追溯。 +- [采集后退款、作废的用量不扣] → 跳过审计 + 人工冲正。 +- [采集记录写入量 ≈ 有套餐卡数 × 每天采集次数,测试环境 60 秒间隔下单卡每天 1440 行] → 测试环境卡少;分区按月删除。 +- [Redis 内存随卡数增长,每卡约 150 字节] → 测试环境 `noeviction` 无上限,随卡数观察。 +- [`DeductDataUsage` 查询范围扩大后,旧循环会把已过期套餐改成已用完] → 状态迁移只作用于当前生效中套餐(规格已明确)。 + +## Migration Plan + +1. 前置:维护者确认后归档 `rebuild-card-traffic-metering-and-stop-resume`(灰度、切换水位相关验证 9.2–9.5 作废,停复机部分已由 `finalize-unified-stop-resume-rule` 覆盖)与 `add-tiered-polling-and-pre-activation-settlement`,使 `card-traffic-metering` 主规格存在。 +2. 迁移(成对):采集记录分区表、冲正流水表、`tb_iot_card` 待建起点标记(迁移内对全部卡置位)、套餐日用量 `(package_usage_id, date)` 唯一索引(建前核对并合并重复行)。 +3. 注册系统配置项默认值;删除计量模式与灰度名单配置及测试环境部署变量。 +4. 部署测试环境后按 tasks 验证;回滚只在测试环境:回退代码与迁移 down,队列 key 自然过期。 + +## Open Questions + +- 重置日读取失败时仍按运营商类型兜底(`CUCC` 取 27、其余取 1,`rebuild-...` 的 D6)。这与"只看配置的重置日"不完全一致,本 Change 保持不改,是否改为"读取失败本次不写入并告警"待定。 diff --git a/openspec/changes/add-card-traffic-queue-and-observed-deduction/proposal.md b/openspec/changes/add-card-traffic-queue-and-observed-deduction/proposal.md new file mode 100644 index 00000000..cbd8632d --- /dev/null +++ b/openspec/changes/add-card-traffic-queue-and-observed-deduction/proposal.md @@ -0,0 +1,38 @@ +## Why + +`rebuild-card-traffic-metering-and-stop-resume` 已把计量改为日快照 + 本月最大值 − 已结算,解决了基线冻结、重置窗口重复扣的问题,但运营商单次异常读数仍直接写进快照(当天取最大值,暴涨会留下来);测试环境仍在灰度与卡表基线算法两条路径并存;扣减按扣费时刻的"生效中"套餐查找,采集与扣费对"有没有套餐"口径不一致,套餐用完到停机之间的用量收不到;扣错没有冲正通道,排查也缺少逐次采集记录。依据为 `docs/卡流量采集写入与扣减技术方案.md`(2026-10-28 修订,决策 T1–T25 及 10-28/29 补充决定)。 + +## What Changes + +- 运营商流量读数先判有效性:接口失败、限流、缺字段、`used` 为空/null/无法解析、负数、NaN/Inf 不入队、不写快照;有效读数为有限且 ≥ 0,Gateway 流量读数区分"没值"与 0。 +- 新增每卡 Redis 采集队列(默认 N = 3,Lua 原子):按采集时间插入,跨天清空(可配,默认开)、丢弃过期点;新点在队尾、队列满 N 个点且相邻两点回退不超过容差(默认 1 MB)时才写**最后一个点**,否则不写并记录原因。轮询、人工刷新、激活前结算共用同一队列与规则;没有运营商流量推送。 +- 队列配置(长度、跨天清空、最长点龄、回退容差)放系统配置,改了即生效;启用中的轮询配置流量采集间隔超过上限(开关开 24h ÷ (N+2),关 `max_point_age_hours` ÷ (N+2))时拒绝保存或启用,停用的配置不校验。 +- **BREAKING**(测试环境)一次性全量切换:所有卡走日快照 + 本月最大值 − 已结算;删除计量模式与灰度名单、第一次切换观测与切换水位,卡表基线算法不再产生扣费增量。迁移时全部卡打"待建起点"标记,切换后第一次写入只建起点不扣费。 +- 写入范围:有套餐的卡读数为 0 也写日快照,每次采集写采集记录,每个有效套餐每天确保一行日用量(没扣写 0);没套餐的卡读数 > 0 才写快照,增量只记已结算、之后买套餐不补扣。 +- 重置日照搬老系统,只认通道配置的重置日:重置日当天全天读数 > 比较基准 × 0.9 按 0 计;比较基准为 0 时不论重置日是几号都照常按 0 计;次日起不再判断;月末尾巴不补、不记回前一天。 +- "有套餐"采集侧与扣费侧共用同一判断:采集时刻状态为生效中/已用完/已过期、已激活、未到期、未退款的套餐,卡自己没有时看绑定设备;已用完但在有效期内算有套餐。 +- 扣减按采集时刻确定套餐:只对当前"生效中"的套餐做状态迁移;一个套餐都查不到时记审计并按成功跳过;采集后套餐被重置的扣在重置后的周期;套餐日用量改为 upsert,日期取采集时间的上海日期(含消费者日流量缓冲 key)。 +- 换卡完成时新卡(设备换货时新设备下全部卡)打"待建起点"标记,第一次通过队列写入时建起点。 +- 新增采集记录表(有套餐的卡每次采集一条,按月分区保留 90 天)与后台查询;新增人工冲正流水(锁套餐行、退回后不得为负、提交后走停复机判断)与后台接口。 +- 新增告警:开启轮询且有套餐的卡一整天没写入、待建起点超过 1 天、队列拦截率异常、乱序突增、重置日防旧值命中数。 +- 不在本 Change:自动对账(运营商 T+1 数据)、月末尾巴补算、对接日志/审计瘦身、跨套餐重置时的用量偏移、生产环境发布与回滚。 + +## Capabilities + +### New Capabilities + +- `package-traffic-deduction`:按采集时刻确定扣减套餐、状态迁移范围、查不到套餐的处理、套餐日用量记录、人工冲正。 + +### Modified Capabilities + +- `card-traffic-metering`:读数有效性与快照写入范围(读数 0 写入)、重置日防旧值比较基准为 0 的处理、增量对全部卡生效且"有套餐"改为共用口径、通道阈值只在写入点判断、展示对全部卡生效、激活前结算过队列;新增采集队列、队列配置与采集间隔校验、待建起点(全量切换与换卡)、采集记录、告警;删除切换水位与按扣减载体灰度切换。 + +## Impact + +- 前置:`rebuild-card-traffic-metering-and-stop-resume` 与 `add-tiered-polling-and-pre-activation-settlement` 须先归档(本 Change 修改它们新增的 `card-traffic-metering` 要求);前者剩余的灰度、切换水位验证作废。 +- 数据库:新增采集记录表(按月分区)、冲正流水表;`tb_iot_card` 增加待建起点标记,迁移时全部卡置位;套餐日用量 `(package_usage_id, date)` 唯一索引;`tb_card_flow_switch` 不再读写。均为新的成对迁移。 +- 代码:`internal/gateway/models.go`、`internal/task/polling_carddata_handler.go`、新增队列组件与系统配置项、`internal/application/cardobservation/`(`traffic.go`、`traffic_flow.go`、`traffic_calc_policy.go`)、`internal/domain/cardflow/rules.go`、`internal/domain/cardobservation/traffic.go`、`internal/infrastructure/cardobservation/event.go`、`internal/service/package/usage_service.go`、`internal/service/exchange`、人工刷新与激活前结算入口、轮询配置保存。 +- 接口:人工刷新返回本次读数、是否写入与原因;新增采集记录查询、冲正接口;重新生成接口文档。 +- 配置:删除 `JUNHONG_TRAFFIC_CALC_MODE`、`JUNHONG_TRAFFIC_CALC_CARDS`;新增系统配置 `traffic.queue.size`、`traffic.queue.clear_on_new_day`、`traffic.queue.max_point_age_hours`、`traffic.queue.drop_tolerance_mb`。 +- Redis:每卡一个队列 key(约 150 字节);测试环境为 `noeviction`。 +- 范围:测试环境。 diff --git a/openspec/changes/add-card-traffic-queue-and-observed-deduction/specs/card-traffic-metering/spec.md b/openspec/changes/add-card-traffic-queue-and-observed-deduction/specs/card-traffic-metering/spec.md new file mode 100644 index 00000000..bdf5a50c --- /dev/null +++ b/openspec/changes/add-card-traffic-queue-and-observed-deduction/specs/card-traffic-metering/spec.md @@ -0,0 +1,263 @@ +## ADDED Requirements + +### Requirement: 流量读数有效性 +系统 SHALL 在运营商流量查询返回后先判断读数是否有效:接口失败、超时、非 200、限流、响应缺少 ICCID 等必要字段、`used` 为空或 null 或无法解析、读数为负数、NaN 或 Inf 的,均为无效读数,MUST NOT 进入采集队列、MUST NOT 写日快照、MUST NOT 产生增量。读数为有限且大于等于 0 的数时为有效读数。系统 MUST 区分运营商"没有给值"与"读数为 0",响应中完全缺少 `used` 字段时按没有给值处理。 + +#### Scenario: 运营商返回 null +- **WHEN** 运营商流量查询成功,但 `used` 为 null +- **THEN** 系统不入队、不写快照、不产生增量;卡有套餐时采集记录的结果为"无读数"、读数为空 + +#### Scenario: 运营商返回 0 +- **WHEN** 运营商流量查询成功,`used` 为 0 +- **THEN** 系统把 0 作为有效读数入队 + +#### Scenario: 响应缺少 used 字段 +- **WHEN** 运营商流量查询成功,但响应中没有 `used` 字段 +- **THEN** 系统按没有给值处理,不把它当成 0 + +### Requirement: 采集队列与写入判断 +系统 SHALL 为每张卡维护一个最近 N 个有效读数的采集队列(N 默认 3、最小 2),每个点为采集时间与运营商原始读数。新点 MUST 按采集时间插入队列,同一采集时间的点去重,只保留最后 N 个;队列更新 MUST 原子执行。同时满足以下条件时,系统 SHALL 把新点(即队列最后一个点)作为本次写入点进入日快照与结算:新点插入后位于队尾、队列满 N 个点、相邻两点后一个读数大于等于前一个读数减回退容差(默认 1 MB)。任一条件不满足时本次 MUST NOT 写快照、MUST NOT 产生增量、MUST NOT 做通道阈值判定,并记录不写原因(队列未满、读数下降、乱序)。轮询、人工刷新与激活前结算 MUST 使用同一队列与同一规则。队列丢失时系统 SHALL 从空队列重新开始,不补写。 + +#### Scenario: 中间点暴涨 +- **WHEN** 队列为 100、99999、120 +- **THEN** 系统不写入,原因为读数下降 + +#### Scenario: 持续上涨 +- **WHEN** 队列为 100、150、200,200 为新点 +- **THEN** 系统写入 200 + +#### Scenario: 持平 +- **WHEN** 有套餐的卡队列为 100、100、100 +- **THEN** 系统写入 100,增量为 0 + +#### Scenario: 回退在容差内 +- **WHEN** 回退容差为 1 MB,队列为 100.002、100.001、100.003 +- **THEN** 系统写入 100.003 + +#### Scenario: 运营商清零 +- **WHEN** 队列为 3010、20、25 +- **THEN** 系统不写入;之后队列为 20、25、30 时写入 30 + +#### Scenario: 迟到的采集点 +- **WHEN** 新点的采集时间早于队列中已有的点,插入后不在队尾 +- **THEN** 系统不写入,原因为乱序 + +#### Scenario: 队列丢失 +- **WHEN** 卡的队列因 Redis 重启丢失 +- **THEN** 系统从空队列开始,攒满 N 个点后照常写入,不多扣也不少扣 + +### Requirement: 人工刷新返回写入结果 +系统 SHALL 在人工刷新卡流量后返回本次运营商读数、是否写入以及不写入的原因(读数无效、队列未满、读数下降、乱序)。 + +#### Scenario: 人工刷新被队列拦下 +- **WHEN** 管理员人工刷新卡流量,本次读数使队列未满 +- **THEN** 接口返回本次读数、未写入、原因为队列未满 + +### Requirement: 跨天清空与过期点 +系统 SHALL 提供跨天清空开关(默认开启):开启时新点与队列中的点不在同一上海日期,先清空队列再入队。系统 SHALL 在入队时丢弃与新点采集时间相差超过最长点龄(默认 72 小时,0 表示不限)的点。 + +#### Scenario: 跨天 +- **WHEN** 跨天清空开启,队列中是前一天的点,新点是今天的 +- **THEN** 系统清空队列后再放入新点,今天攒满 N 个点后才写入 + +#### Scenario: 卡停用几天后重新采集 +- **WHEN** 跨天清空关闭,队列中最新的点是 4 天前的 +- **THEN** 系统丢弃这些点,只用新采集的点判断 + +### Requirement: 采集队列配置与采集间隔校验 +系统 SHALL 在系统配置中提供队列长度、跨天清空开关、最长点龄、回退容差四项配置,修改后无需重新发布即生效。系统 SHALL 只对启用中的轮询配置校验流量采集间隔:跨天清空开启时间隔 MUST 不超过 24 小时 ÷ (N+2),关闭时 MUST 不超过最长点龄 ÷ (N+2)。保存启用状态的轮询配置、启用轮询配置时间隔超限的 MUST 拒绝;修改队列长度或跨天清空开关会使任一启用中的轮询配置超限时 MUST 拒绝修改。停用的轮询配置 MUST NOT 被校验或阻止保存。 + +#### Scenario: 启用配置间隔过长 +- **WHEN** N = 3、跨天清空开启,维护者保存流量采集间隔为 6 小时的启用中轮询配置 +- **THEN** 系统拒绝保存并说明上限为 4.8 小时 + +#### Scenario: 停用配置不校验 +- **WHEN** 维护者保存流量采集间隔为 86400 秒的停用轮询配置 +- **THEN** 系统正常保存;启用该配置时按上限校验 + +#### Scenario: 调大队列长度使启用配置超限 +- **WHEN** 启用中的轮询配置间隔为 4 小时,维护者把队列长度从 3 改为 4 +- **THEN** 系统拒绝修改,因为上限变为 4 小时以下 + +### Requirement: 有套餐判断口径 +系统 SHALL 用同一个判断确定"采集时刻卡是否有套餐",供写入范围、是否发扣费事件、是否写采集记录与扣费时查找套餐共用:套餐状态为生效中、已用完或已过期,激活时间不晚于采集时间(激活时间为空的历史数据按已生效),到期时间为空或晚于采集时间,未退款;卡自身一个都没有时看卡当前绑定设备的套餐。待生效、已失效的套餐 MUST NOT 算有套餐。 + +#### Scenario: 已用完但仍在有效期 +- **WHEN** 卡唯一的套餐已用完,到期时间晚于采集时间 +- **THEN** 系统判定有套餐,增量照常发扣费事件 + +#### Scenario: 采集时套餐尚未激活 +- **WHEN** 采集时间早于套餐激活时间 +- **THEN** 系统判定没有套餐,增量只记已结算 + +#### Scenario: 沉默期等待中的套餐 +- **WHEN** 卡只有处于待生效状态的套餐 +- **THEN** 系统判定没有套餐 + +### Requirement: 待建起点 +系统 SHALL 在卡上维护"待建起点"标记。带标记的卡第一次满足队列写入条件时,系统 MUST 写日快照、把该卡本月最大快照全部记为已结算、不发扣费事件、清除标记,并在采集记录中记为建起点;之后按正常规则处理。以下情况 SHALL 置位:全量切换迁移时全部卡;换卡完成时的新卡;设备换货时新设备当前绑定的每张卡。换卡流程内 MUST NOT 调用运营商接口。 + +#### Scenario: 换卡后建起点 +- **WHEN** 新卡出库前已用 50 MB,换卡完成后依次采到 52、53、55、58 +- **THEN** 系统在写入 55 时建起点、不扣费,写入 58 时扣 3 MB + +#### Scenario: 起点读数异常 +- **WHEN** 带标记的新卡第一次采到 99999,之后为 52、53 +- **THEN** 队列不满足写入条件,系统不以 99999 建起点 + +#### Scenario: 全量切换不重复扣 +- **WHEN** 全量切换后某卡第一次满足写入条件,本月老路径已扣过 800 MB +- **THEN** 系统只建起点、不扣费,之后按增量正常扣 + +### Requirement: 采集记录 +系统 SHALL 为采集时刻有套餐的卡每次采集写一条采集记录,包括接口失败与读数为 0,记录采集时间、采集 ID、来源、读数结果(有效、失败、限流、响应无效、无读数、读数无效)、运营商原始读数、入队后的队列、写入判断(已写入、建起点、队列未满、读数下降、乱序、激活前结算用已有快照),写入时记录换算后的本月累计、是否触发防旧值、本次增量与流量事件 ID。没套餐的卡 MUST NOT 写采集记录。采集记录 SHALL 按采集时间按月分区,保留 90 天,过期按整月分区删除。系统 SHALL 提供后台按卡与时间范围查询采集记录的接口。采集 ID MUST 随流量事件与扣费审计传递,使扣费可追溯到对应采集记录。 + +#### Scenario: 被队列拦下的采集 +- **WHEN** 有套餐的卡本次读数使队列出现下降 +- **THEN** 系统写一条采集记录,判断为读数下降,记录队列内容,不写快照 + +#### Scenario: 从扣费追到采集 +- **WHEN** 维护者从一笔扣费审计排查 +- **THEN** 可经流量事件的采集 ID 找到对应采集记录中的原始读数与队列 + +#### Scenario: 没套餐的卡 +- **WHEN** 没套餐的卡被采集 +- **THEN** 系统不写采集记录 + +### Requirement: 流量采集告警 +系统 SHALL 对以下情况产生告警:开启轮询且有套餐的卡一整天没有写入;开启轮询的卡待建起点标记超过 1 天未清除;单卡当天读数下降拦截占比过高或全局拦截率突增;乱序数量突增。系统 SHALL 统计重置日防旧值命中数供核对重置日配置。未开启轮询的卡 MUST NOT 触发上述告警。当天没有写入时系统 MUST NOT 补写日快照。 + +#### Scenario: 有套餐的卡全天未写入 +- **WHEN** 开启轮询、有套餐的卡当天采集记录中没有已写入 +- **THEN** 系统产生告警,不补写当天快照 + +#### Scenario: 不轮询的卡 +- **WHEN** 未开启轮询的卡一直带待建起点标记 +- **THEN** 系统不告警 + +## MODIFIED Requirements + +### Requirement: 卡日快照记录 +系统 SHALL 为每张卡按上海日期每天保存一条"本月自然月累计流量"快照,同时记录当天最后一次写入的运营商原始读数、当天已结算增量合计、当天是否触发重置日防旧值和写入次数。快照只由通过采集队列判断的写入点写入,无效读数与被队列拦下的读数 MUST NOT 写入快照、MUST NOT 产生增量。写入点读数为 0 时,采集时刻有套餐的卡 MUST 写入快照,没套餐的卡 MUST NOT 写入;读数大于 0 时都写入。同一卡同一天多次写入时,本月累计 MUST 取已有值与本次换算值中的较大者,不得变小。快照写入 MUST 为原子写入,轮询、人工刷新与激活前结算并发写同一卡同一天时不得丢失较大值。快照永久保留。 + +#### Scenario: 同一天读到偏小的旧缓存 +- **WHEN** 某卡当天已有本月累计 500 MB 的快照,本次写入点换算值为 480 MB +- **THEN** 当天快照的本月累计保持 500 MB,不产生负增量 + +#### Scenario: 有套餐的卡读数为 0 +- **WHEN** 有套餐的卡写入点读数为 0 +- **THEN** 系统写入当天快照,增量为 0 + +#### Scenario: 没套餐的卡读数为 0 +- **WHEN** 没套餐的卡写入点读数为 0 +- **THEN** 系统不写快照 + +#### Scenario: 读数无效 +- **WHEN** 运营商返回的已用流量为空、null、无法解析或为负数 +- **THEN** 系统不入队、不写快照、不产生增量 + +### Requirement: 重置日防旧值 +系统 SHALL 只在卡所属通道配置的重置日当天判断旧值,当天全天判断、不按小时区分:`R = 1` 时比较基准为上月最大快照,`R > 1` 时比较基准为本月 1~R−1 号最大快照;读数超过比较基准的 0.9 倍时本次读数 MUST 按 0 参与换算,原始读数照常记录,并标记当天触发过防旧值。比较基准为 0 时,不论配置的重置日是几号,重置日当天任何大于 0 的读数 MUST 按 0 参与换算。重置日次日起不再判断,读数照常写入;重置日当天没有取到有效读数的,次日起照常按新周期计。重置日当天采到的上周期读数 MUST NOT 记回前一天,上周期最后一次写入到运营商清零之间的用量不补。 + +#### Scenario: 重置日读到上周期旧值 +- **WHEN** 重置日为 1 的卡在 1 号读到超过上月最大快照 0.9 倍的读数 +- **THEN** 当天本月累计按 0 记、标记触发防旧值;次日读到的读数照常写入 + +#### Scenario: 重置日当天真实用量较大 +- **WHEN** 重置日当天真实用量超过比较基准的 0.9 倍 +- **THEN** 当天按 0 记,次日如实写入,这部分用量随次日快照计入,不丢失 + +#### Scenario: 重置日为 27 且比较基准为 0 +- **WHEN** 重置日为 27 的卡本月 1~26 号没有快照,27 号读到 3010 MB +- **THEN** 当天按 0 记、标记触发防旧值;28 号读数照常写入 + +#### Scenario: 重置日早上的旧数 +- **WHEN** 重置日 00:10 采到上周期读数 3010 MB 并被写入 +- **THEN** 系统按 0 计入当天,不更新前一天的快照 + +### Requirement: 已切换卡按月对账产生增量 +系统 SHALL 对全部卡在写入点写入当天快照后计算增量 `Δ = 本月最大快照 − 本月各天已结算合计`,卡带待建起点标记时按"待建起点"处理。`Δ > 0` 且采集时刻卡有套餐(按"有套餐判断口径")时,系统写一条流量增量事件(携带采集时间与采集 ID)并把 `Δ` 计入当天已结算;`Δ > 0` 但没有套餐时 MUST NOT 写事件,只把 `Δ` 计入当天已结算,之后购买套餐 MUST NOT 补扣;`Δ ≤ 0` 时不写事件。采集时刻有套餐的卡当天第一次写入时,系统 SHALL 在同一事务内为采集时刻每个有效套餐确保当天一行套餐日用量(没有则写 0)。快照、已结算合计、套餐日用量 0 行与事件 MUST 同事务提交或回滚。每个写入点最多写一条事件,系统 MUST NOT 再按卡表基线计算增量。跨月时新月份已结算合计从 0 开始。 + +#### Scenario: 二次销售不带入上一个用户的用量 +- **WHEN** 卡在没套餐期间被写入本月累计 3020 MB,激活新套餐后写入 3050 MB +- **THEN** 3020 MB 及之前的增量只计入已结算不写事件,新套餐只被扣 30 MB + +#### Scenario: 迟到观测抬高昨天的快照 +- **WHEN** 今天已发出增量后,一次迟到写入把昨天的快照抬高 +- **THEN** 增量仍按本月最大快照减本月已结算合计计算,重叠部分不被扣两次 + +#### Scenario: 有套餐但当天没用流量 +- **WHEN** 有套餐的卡当天第一次写入,增量为 0 +- **THEN** 系统不写事件,为每个有效套餐写当天日用量 0 + +#### Scenario: 只有待生效套餐 +- **WHEN** 卡与绑定设备只有待生效套餐 +- **THEN** 增量只计入已结算,不写事件 + +### Requirement: 已切换卡的通道阈值判定 +系统 SHALL 只在写入点做通道阈值判定:本次写入点读数大于 0 且未被重置日防旧值按 0 处理时,用该读数做通道阈值判定。被队列拦下的读数、无效读数 MUST NOT 参与判定。 + +#### Scenario: 达量 +- **WHEN** 写入点读数达到通道阈值 +- **THEN** 系统写通道阈值停机锁并创建停机任务 + +#### Scenario: 被拦下的暴涨读数 +- **WHEN** 队列中间点读数超过通道阈值但被队列拦下 +- **THEN** 系统不做通道阈值判定 + +#### Scenario: 重置日读到旧总量 +- **WHEN** 卡在重置日写入上周期旧总量并触发防旧值 +- **THEN** 本次不做通道阈值判定;同一天之后的真实读数照常判定 + +### Requirement: 已切换卡的本月用量与日流量展示 +系统 SHALL 对全部卡在写入点写入后用本月最大快照覆盖卡表本月用量,使资产详情、卡列表与设备导出显示快照口径的本月用量;日流量查询 MUST 读取快照,某天用量为当天最大快照减本月此前最大快照,小于 0 按 0,本月第一条快照减数为 0。卡表 `last_gateway_reading_mb` 不再使用。 + +#### Scenario: 展示本月用量 +- **WHEN** 卡写入点写入本月累计 820 MB +- **THEN** 资产详情、卡列表与设备导出的本月用量为 820 MB + +#### Scenario: 中间几天没写入 +- **WHEN** 卡连续 3 天没有快照 +- **THEN** 这 3 天的用量并入之后第一个有快照的那天,不丢失 + +### Requirement: 流量按观测时间归属 +系统 SHALL 为每次流量采集记录网关返回时的采集时间,并以写入点的采集时间按"有套餐判断口径"确定本次增量是否扣减及扣减哪些套餐;采集时间早于套餐激活时间的,该套餐不算有套餐,增量 MUST 只记为已结算、不扣减该套餐。该规则 MUST 对轮询、激活前结算、人工刷新全部生效。 + +#### Scenario: 慢返回的结算晚于另一入口激活 +- **GIVEN** 资产无生效套餐,激活前结算 10:00:00 发出查询,另一入口 10:00:02 激活了套餐 +- **WHEN** 结算结果 10:00:05 返回并入账,采集时间早于激活时间 +- **THEN** 本次增量只记已结算,不扣新套餐 + +#### Scenario: 激活后的观测正常扣减 +- **WHEN** 写入点采集时间晚于套餐激活时间 +- **THEN** 增量按扣减规则扣减套餐 + +### Requirement: 激活前流量结算 +系统 SHALL 在资产从"无生效主套餐"变为"有生效主套餐"之前(实名激活、排队激活、指定激活、队首激活、订单购买即生效),对独立卡查一次流量、对设备逐张查设备下各卡,读数照常经过采集队列,然后再执行激活。本次读数满足写入条件时按观测入账(此时无套餐,增量只记已结算);不满足写入条件时,系统 MUST 用卡上已有的本月最大快照做结算(把它与本月已结算合计的差额全部记为已结算),不新写快照,采集记录判断记为"激活前结算用已有快照"。已有生效主套餐(续费、排队衔接)与流量重置 MUST NOT 执行结算。结算观测 MUST 携带"激活前结算"标记,入账照常但 MUST NOT 触发停复机评估;激活提交后的评估是激活链路唯一一次评估。结算查询 MUST 受运营商账户限流约束;单卡限流等待超过单步超时、查询失败或整次结算达到总超时时,系统 MUST 跳过剩余结算、照常激活,并按卡写告警。 + +#### Scenario: 二次销售激活 +- **GIVEN** 卡上一用户本月已用 3 GB,当前无生效套餐 +- **WHEN** 新用户购买套餐即生效 +- **THEN** 系统先查流量并把 3 GB 记为已结算,再激活,新套餐不承担这 3 GB + +#### Scenario: 结算读数未满足写入条件 +- **WHEN** 激活前结算的读数使队列未满或出现下降 +- **THEN** 系统用已有本月最大快照结算,不新写快照,再激活 + +#### Scenario: 结算不触发停机 +- **WHEN** 结算观测入账时资产无套餐 +- **THEN** 系统不生成"无有效套餐"停机,激活提交后再评估一次停复机 + +#### Scenario: 结算超时 +- **WHEN** 设备下第 3 张卡结算时整次结算达到总超时 +- **THEN** 系统跳过剩余卡、照常激活,并为未结算的卡写告警 + +## REMOVED Requirements + +### Requirement: 切换水位 +**Reason**: 测试环境一次性全量切换,不再有"第一次切换观测";切换不重复扣改由迁移时全部卡打"待建起点"标记实现。 +**Migration**: 迁移时全部卡置待建起点标记;`tb_card_flow_switch` 不再读写,数据保留不删。 + +### Requirement: 按扣减载体灰度切换 +**Reason**: 测试环境一次性全量切换,全部卡走日快照计量,灰度名单与计量模式下线。 +**Migration**: 删除 `JUNHONG_TRAFFIC_CALC_MODE`、`JUNHONG_TRAFFIC_CALC_CARDS` 配置;卡表基线算法不再产生扣费增量。 diff --git a/openspec/changes/add-card-traffic-queue-and-observed-deduction/specs/package-traffic-deduction/spec.md b/openspec/changes/add-card-traffic-queue-and-observed-deduction/specs/package-traffic-deduction/spec.md new file mode 100644 index 00000000..8f853f3a --- /dev/null +++ b/openspec/changes/add-card-traffic-queue-and-observed-deduction/specs/package-traffic-deduction/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: 按采集时刻确定扣减套餐 +系统 SHALL 在消费流量增量事件时,按事件携带的采集时间用 `card-traffic-metering` 的"有套餐判断口径"查找扣减套餐,不再只认扣费时刻状态为生效中的套餐。采集时刻有效、扣费时已用完或已到期的套餐 MUST 照扣。扣减顺序、每个套餐的容量(启用虚流量时为虚总量,否则为真总量)、最后一个套餐承接超出部分、小数部分累积的规则保持不变。采集时间早于套餐最近一次流量重置、扣费在重置之后的,增量 MUST 扣在重置后的周期,审计记录采集时间与该套餐最近重置时间。 + +#### Scenario: 采集后套餐到期 +- **WHEN** 采集时间为套餐到期前 1 分钟,扣费时套餐已到期 +- **THEN** 系统照常扣减该套餐 + +#### Scenario: 用完到停机之间的用量 +- **WHEN** 卡唯一套餐已用完但仍在有效期,写入点产生增量 +- **THEN** 系统把增量计入该套餐的超出部分 + +#### Scenario: 跨套餐流量重置 +- **WHEN** 采集时间在日重置套餐 00:00 重置之前,扣费在重置之后 +- **THEN** 系统把增量扣在重置后的周期,审计中记录采集时间与重置时间 + +### Requirement: 扣减时的套餐状态迁移 +系统 SHALL 只对当前状态为生效中的套餐做"扣到阈值改为已用完"的状态迁移。当前状态为已用完、已过期的套餐 MUST 照常累加已用量与套餐日用量,MUST NOT 改变状态。余量小于等于 0 且不是最后一个套餐的跳过。停机判断 MUST 仍按套餐当前状态判断是否还有可用套餐。 + +#### Scenario: 已过期套餐被扣 +- **WHEN** 采集时刻有效的套餐在扣费时已过期,本次扣到超过阈值 +- **THEN** 系统累加已用量,套餐状态仍为已过期 + +#### Scenario: 生效中套餐扣到阈值 +- **WHEN** 生效中套餐本次扣减后达到阈值 +- **THEN** 系统把套餐改为已用完,并按当前状态判断是否停机 + +### Requirement: 扣费时查不到套餐 +采集时刻有套餐、扣费时一个套餐都查不到(采集后被退款或作废)时,系统 SHALL 记录告警日志与扣费审计(标明跳过原因),把事件按成功处理,MUST NOT 重试。该笔用量需要时由人工冲正处理。 + +#### Scenario: 采集后退款 +- **WHEN** 流量事件消费前,采集时刻唯一有效的套餐已退款 +- **THEN** 系统写跳过审计,事件按成功结束,不重试 + +### Requirement: 套餐日用量记录 +系统 SHALL 按 `(套餐使用记录, 日期)` 唯一保存套餐日用量,扣减时原子累加当天用量、覆盖累计用量,并发扣减 MUST NOT 产生重复行或丢失累加。日期 MUST 取写入点采集时间的上海日期,不取扣费时间;消费者的日流量缓冲同样按采集时间的上海日期归属。 + +#### Scenario: 凌晨采集隔一会儿才扣费 +- **WHEN** 写入点采集时间为上海时间 10-29 00:30,扣费在 00:31 +- **THEN** 套餐日用量记在 10-29 + +#### Scenario: 并发扣减同一套餐同一天 +- **WHEN** 两条流量事件同时扣减同一套餐同一天 +- **THEN** 当天只有一行日用量,当天用量为两次之和 + +### Requirement: 人工冲正 +系统 SHALL 提供人工冲正:调整套餐已用量只能经冲正流水进行,流水记录套餐使用记录、卡或设备、调整量(正数补扣、负数退回)、调整前后已用量、必填原因、可选关联采集记录与流量事件、操作人与时间。冲正 MUST 在事务内先锁定套餐行,按锁定后的当前已用量计算新值;调整后已用量小于 0 时 MUST 拒绝,不截成 0。同一事务内写流水、更新已用量并按阈值重算状态(已用完与生效中之间);已过期、已失效的套餐只改已用量不改状态。冲正 MUST 写审计。提交后系统 SHALL 调用停复机判断,MUST NOT 直接复机或停机。冲正 MUST NOT 修改卡日快照与已结算。 + +#### Scenario: 退回多扣的流量 +- **WHEN** 管理员对已用完的套餐冲正 −500 MB,冲正后低于阈值 +- **THEN** 套餐改为生效中,写流水与审计,提交后由停复机判断决定是否复机 + +#### Scenario: 退回后为负 +- **WHEN** 套餐已用 100 MB,管理员冲正 −200 MB +- **THEN** 系统拒绝冲正,已用量不变 + +#### Scenario: 与扣减并发 +- **WHEN** 冲正与一笔流量扣减同时作用于同一套餐 +- **THEN** 两次修改都生效,不互相覆盖 + +#### Scenario: 卡因其他原因停机 +- **WHEN** 冲正后套餐不再用完,但卡因未实名停机 +- **THEN** 系统不复机 diff --git a/openspec/changes/add-card-traffic-queue-and-observed-deduction/tasks.md b/openspec/changes/add-card-traffic-queue-and-observed-deduction/tasks.md new file mode 100644 index 00000000..f19dae7d --- /dev/null +++ b/openspec/changes/add-card-traffic-queue-and-observed-deduction/tasks.md @@ -0,0 +1,74 @@ +## 0. 前置 + +- [ ] 0.1 经维护者确认,归档 `rebuild-card-traffic-metering-and-stop-resume`(9.2–9.5 灰度与切换水位验证作废)与 `add-tiered-polling-and-pre-activation-settlement`,确认 `openspec/specs/card-traffic-metering/spec.md` 存在且包含本 Change 修改与删除的要求。 + +## 1. 迁移与模型 + +- [x] 1.1 新增成对迁移:采集记录表(按 `observed_at` 按月分区,字段见方案 7.1,索引 `(iot_card_id, observed_at DESC)`、`observation_id`),预建当月与下月分区;down 删除。 +- [x] 1.2 新增成对迁移:冲正流水表(字段见方案第 11 节);down 删除。 +- [x] 1.3 新增成对迁移:`tb_iot_card` 增加待建起点标记,up 内把全部卡置位;down 删除该列。 +- [x] 1.4 新增成对迁移:套餐日用量 `(package_usage_id, date)` 唯一索引,建前合并已有重复行;down 删除索引。(测试库已有唯一索引 `idx_package_usage_daily_record_unique` 且无重复行,无需新迁移) +- [x] 1.5 新增对应 model;采集记录分区的按月新建与过期整月删除任务(保留 90 天)。 + +## 2. 读数有效性 + +- [x] 2.1 `internal/gateway/models.go`:流量查询结果的已用流量改为零值表示"没值"的类型,区分 null/空/无法解析/缺字段与 0;其他使用 `FlexFloat` 的字段不变。 +- [x] 2.2 `internal/task/polling_carddata_handler.go`:按方案第 3 节把结果分为有效、失败、限流、响应无效、无读数、读数无效。 +- [x] 2.3 `internal/domain/cardflow/rules.go`:`ValidReading` 放宽为有限且 ≥ 0;`isStale` 去掉 `ThisMonthBeforeResetMax > 0` 条件;同步注释。 + +## 3. 采集队列 + +- [x] 3.1 新增队列组件:key `traffic:queue:{card_id}`,Lua 原子完成跨天清空、过期丢弃、按采集时间插入去重、保留最后 N 个、设置过期(跨天清空开启时为次日 01:00 上海时间,关闭时为最长点龄后),返回队列与新点是否在队尾。 +- [x] 3.2 写入判断:新点在队尾、队列满 N、相邻两点不下降(容差内算持平);返回写入或不写原因(队列未满、读数下降、乱序)。 +- [x] 3.3 系统配置注册 `traffic.queue.size`(默认 3,最小 2)、`traffic.queue.clear_on_new_day`(默认 true)、`traffic.queue.max_point_age_hours`(默认 72)、`traffic.queue.drop_tolerance_mb`(默认 1),读法与 `package.silence.enabled` 相同(带短缓存)。 +- [x] 3.4 轮询配置保存与启用时、队列长度或跨天清空开关修改时,校验启用中轮询配置的流量采集间隔上限;停用配置不校验。 + +## 4. 有套餐判断与观测写入 + +- [x] 4.1 package 服务新增"采集时刻有效套餐"查询(状态生效中/已用完/已过期、`activated_at ≤ observed_at` 或为空、`expires_at` 为空或晚于采集时间、未退款,卡没有时看当前绑定设备),供观测与扣减共用。 +- [x] 4.2 `traffic_calc_policy.go`:`hasActivePackageForCard` 改调 4.1;删除灰度名单与计量模式;删除 `pkg/config` 中 `TrafficCalc` 的模式与名单配置及部署变量。 +- [x] 4.3 `traffic.go`、`traffic_flow.go`:只在队列写入点进入快照;删除未切换分支、`firstSwitch` 与切换水位读写;卡表基线算法不再产生扣费增量,`last_gateway_reading_mb` 不再使用。 +- [x] 4.4 写入范围:有套餐读数 0 写快照、没套餐读数 0 不写;Δ 有套餐发事件(带 `observed_at`、`observation_id`),没套餐只记已结算。 +- [x] 4.5 待建起点:带标记的卡第一次写入时写快照、本月最大全部记为已结算、不发事件、清标记,采集记录判断为建起点。 +- [x] 4.6 观测事务内当天第一次写入时,调用 package 服务为采集时刻每个有效套餐确保当天日用量 0 行。 +- [x] 4.7 通道阈值判定只在写入点、读数 > 0 且未触发防旧值时调用;卡表本月用量对全部卡用本月最大快照覆盖;日流量对全部卡读快照。 +- [x] 4.8 采集记录:有套餐的卡每次采集写一条(含失败、读数 0、被拦下),字段按方案 7.1。 + +## 5. 其他读数来源 + +- [x] 5.1 人工刷新过队列,接口返回本次读数、是否写入与原因;更新路由文档说明。 +- [x] 5.2 激活前结算过队列;未写入时用已有本月最大快照结算、不新写快照,采集记录判断为"激活前结算用已有快照";删除"未切换卡不做结算"的分支。 + +## 6. 换卡 + +- [x] 6.1 `internal/service/exchange`:换卡完成事务内给新卡置待建起点标记;设备换货时给新设备当前绑定的每张卡置位;不调用运营商接口。 + +## 7. 扣减 + +- [x] 7.1 `internal/infrastructure/cardobservation/event.go`:把 `observed_at`、`observation_id` 传给扣减;日流量缓冲 key 改用 `cardflow.ShanghaiDate(observed_at)`。 +- [x] 7.2 `DeductDataUsage` 按采集时刻调用 4.1 查套餐;排序、容量、超出承接与小数累积不变。 +- [x] 7.3 状态迁移只作用于当前生效中套餐;已用完、已过期只累加不改状态;停机判断仍按当前状态。 +- [x] 7.4 查不到套餐时写告警与跳过审计,事件按成功处理不重试。(没有套餐可挂审计资源,跳过只写带 metric 的告警日志) +- [x] 7.5 采集早于套餐 `last_reset_at` 的照扣在重置后周期,审计记录 `observed_at` 与 `last_reset_at`。 +- [x] 7.6 `updateDailyRecord` 改为 upsert(当天用量累加、累计用量覆盖),日期取采集时间上海日期;提供"确保当天日用量行"方法。 + +## 8. 冲正与后台接口 + +- [x] 8.1 冲正用例:事务内 `SELECT ... FOR UPDATE` 锁套餐行,按当前值加减,结果 < 0 拒绝;写流水、更新已用量、按阈值重算状态(已过期/已失效只改已用量);写审计 `package_usage.adjust_traffic`;提交后调用 `evaluator.EvaluateAndAct`。 +- [x] 8.2 后台接口:冲正(必填原因、可选关联采集与事件)、冲正流水查询、采集记录按卡与时间范围查询;权限与现有套餐管理一致。(原"修改资产套餐已用量"接口改为按差额写冲正流水) +- [x] 8.3 同步 `cmd/api/docs.go`、`cmd/gendocs/main.go`,运行 `go run cmd/gendocs/main.go`。 + +## 9. 告警 + +- [x] 9.1 开启轮询且有套餐的卡一整天没有已写入;开启轮询的卡待建起点超过 1 天;单卡读数下降占比过高或全局拦截率突增;乱序突增;重置日防旧值命中数统计。不轮询的卡不告警。 + +## 10. 构建与测试环境验证 + +- [x] 10.1 对变更 Go 文件执行 `gofmt -w`,运行 `go build ./cmd/api ./cmd/worker`、`openspec validate add-card-traffic-queue-and-observed-deduction --strict`。 +- [ ] 10.2 按 ENG-TEST-001 在测试环境执行迁移 up/down/up,核对新表、分区、唯一索引与全部卡待建起点标记。 +- [ ] 10.3 用构造的测试卡(参照 `cmd/zz_tmp_cardflow_verify`)逐项核对:正常累加、中间点暴涨被拦、清零前后不混写、容差内回退写入、乱序不写、跨天清空、读数 0 有/没套餐的写入范围、null 与缺字段不入队。 +- [ ] 10.4 重置日:配置重置日 1 与 27 各一张卡,核对重置日全天防旧值、比较基准为 0 时当天按 0 计且次日如实写入、重置日早上的旧数不更新前一天快照。 +- [ ] 10.5 全量切换与换卡:切换后第一次写入只建起点不扣费;换卡新卡写入 55 建起点、58 扣 3;起点读数异常不被当起点。 +- [ ] 10.6 扣减:已用完在有效期内的套餐承接超出;采集后到期照扣、状态不被改回已用完;采集后退款跳过不重试;套餐日用量按采集日期 upsert、没扣写 0。 +- [ ] 10.7 冲正:退回后状态恢复并交给停复机判断;结果为负被拒;与扣减并发不覆盖;因未实名停机的卡冲正后不复机。 +- [ ] 10.8 人工刷新返回写入结果;激活前结算未写入时用已有快照结算;采集记录可从扣费审计经采集 ID 追到;轮询配置间隔校验只拦启用中配置。 diff --git a/pkg/config/config.go b/pkg/config/config.go index 50f663dc..ae590684 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -32,28 +32,6 @@ type Config struct { Worker WorkerConfig `mapstructure:"worker"` WeCom WeComConfig `mapstructure:"wecom"` Approval ApprovalConfig `mapstructure:"approval"` - TrafficCalc TrafficCalcConfig `mapstructure:"traffic_calc"` -} - -// 卡流量计量模式取值。 -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"` } // ServerConfig HTTP 服务器配置 @@ -304,11 +282,6 @@ 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 7691535b..715b04c0 100644 --- a/pkg/config/defaults/config.yaml +++ b/pkg/config/defaults/config.yaml @@ -168,12 +168,6 @@ 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) - # 企业微信 Adapter 配置 wecom: base_url: "https://qyapi.weixin.qq.com" diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 3bab6cb7..5351f52f 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -160,13 +160,9 @@ func bindEnvVariables(v *viper.Viper) { "wechat.payment.notify_url", "wechat.payment.http_debug", "wechat.payment.timeout", - "traffic_calc.mode", - "traffic_calc.cards", } for _, key := range bindings { _ = v.BindEnv(key) } - // 快照写入开关沿用方案约定的环境变量名,与配置键的默认映射不同,需要显式绑定。 - _ = v.BindEnv("traffic_calc.flow_snapshot_enabled", "JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED") } diff --git a/pkg/constants/card_traffic_queue.go b/pkg/constants/card_traffic_queue.go new file mode 100644 index 00000000..314ed215 --- /dev/null +++ b/pkg/constants/card_traffic_queue.go @@ -0,0 +1,56 @@ +package constants + +import "fmt" + +// 卡流量采集队列系统配置(add-card-traffic-queue-and-observed-deduction)。 +const ( + // SystemConfigModuleTraffic 是卡流量采集配置模块。 + SystemConfigModuleTraffic = "traffic" + // SystemConfigTrafficQueueSize 是采集队列长度 N。 + SystemConfigTrafficQueueSize = "traffic.queue.size" + // SystemConfigTrafficQueueClearOnNewDay 是跨天是否清空队列。 + SystemConfigTrafficQueueClearOnNewDay = "traffic.queue.clear_on_new_day" + // SystemConfigTrafficQueueMaxPointAgeHours 是队列里点的最长保留时长(小时),0 表示不限。 + SystemConfigTrafficQueueMaxPointAgeHours = "traffic.queue.max_point_age_hours" + // SystemConfigTrafficQueueDropToleranceMB 是相邻两点回退不超过该值算持平(MB)。 + SystemConfigTrafficQueueDropToleranceMB = "traffic.queue.drop_tolerance_mb" + + // TrafficQueueDefaultSize 等是采集队列配置默认值。 + TrafficQueueDefaultSize = 3 + TrafficQueueMinSize = 2 + TrafficQueueMaxSize = 10 + TrafficQueueDefaultMaxPointAgeHours = 72 + TrafficQueueDefaultDropToleranceMB = 1.0 + // TrafficQueueIntervalSpare 是采集间隔上限留的冗余次数:间隔 ≤ 窗口 ÷ (N + 冗余)。 + TrafficQueueIntervalSpare = 2 + // TrafficQueueUnlimitedTTLHours 是最长点龄为 0(不限)且跨天清空关闭时队列 key 的过期时长。 + TrafficQueueUnlimitedTTLHours = 24 * 7 +) + +// 采集记录的读数结果。 +const ( + TrafficCollectionResultOK = "ok" + TrafficCollectionResultFailed = "failed" + TrafficCollectionResultRateLimited = "rate_limited" + TrafficCollectionResultInvalidPayload = "invalid_payload" + TrafficCollectionResultNoReading = "no_reading" + TrafficCollectionResultInvalidReading = "invalid_reading" +) + +// 采集记录的写入判断。 +const ( + TrafficDecisionWritten = "written" + TrafficDecisionBaseline = "baseline" + TrafficDecisionQueueNotFull = "queue_not_full" + TrafficDecisionSlopeNegative = "slope_negative" + TrafficDecisionOutOfOrder = "out_of_order" + TrafficDecisionSettledExisting = "settled_existing" +) + +// TrafficCollectionRetentionDays 是采集记录保留天数,过期按整月分区删除。 +const TrafficCollectionRetentionDays = 90 + +// RedisCardTrafficQueueKey 返回卡流量采集队列 key。 +func RedisCardTrafficQueueKey(cardID uint) string { + return fmt.Sprintf("traffic:queue:%d", cardID) +}