refactor: 流量系统重构 — 增量累加算法 + 日粒度缓冲 + 旧详单清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m16s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m16s
核心改造: - 增量算法:流量计算从覆盖式改为增量累加(gateway - lastReading),支持上游运营商重置检测 - 日流量缓冲:insertDataUsageRecord 改为 Redis INCRBYFLOAT,每日凌晨落盘到 tb_card_daily_usage - 运营商:新增 data_reset_day 字段(联通=27,其余=1) - IoT卡:新增 last_gateway_reading_mb 字段存储上次网关读数 - 查询层:新建 TrafficQueryService 合并 Redis(今日)+ DB(历史)数据源 - 清理:删除 DataUsageRecord model/store,移除 polling_handler 旧引用 迁移:000094-000097(carrier字段、iot_card字段、数据初始化、日流量表)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
@@ -0,0 +1,188 @@
|
||||
## Context
|
||||
|
||||
轮询系统每 5-15 分钟调用网关获取所有卡的流量数据,写入 DB。当前问题:
|
||||
1. 每次轮询无条件写详单记录,增量=0 也写,DB 膨胀严重(P2-26)
|
||||
2. `current_month_usage_mb` 直接用网关值覆盖,上游自然月重置时我们的值也归零(P2-27)
|
||||
|
||||
核心改造对象:`internal/task/polling_handler.go` 中的 `calculateFlowUpdates()`、`calculateFlowIncrement()` 和 `insertDataUsageRecord()`。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 月流量改为增量累加,不受上游自然月重置影响(E-2)
|
||||
- 流量详单减少 90%+ 的无效写入(E-1)
|
||||
- 查询层无缝兼容新数据源(E-3)
|
||||
- 清理旧流量详单代码路径(`tb_data_usage_record` 相关代码)
|
||||
- 提供数据初始化脚本保证上线安全
|
||||
|
||||
**Non-Goals:**
|
||||
- 不修改轮询调度逻辑(频率、并发控制不变)
|
||||
- 不新增自动化测试
|
||||
- 不修改套餐级重置逻辑(`ResetService` 不受影响,套餐的 `data_reset_cycle` 机制独立运行)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. E-2 先于 E-1 的原因
|
||||
|
||||
**决策**:先改增量算法(E-2),再改存储介质(E-1)。
|
||||
|
||||
**原因**:E-2 修的是"数据正确性"(覆盖 vs 累加),E-1 修的是"写入效率"(DB vs Redis)。正确性比效率更紧迫。且 E-2 的 `increment` 变量是 E-1 Redis 写入的前提。
|
||||
|
||||
### 2. 合并 `calculateFlowUpdates()` 和 `calculateFlowIncrement()`
|
||||
|
||||
**决策**:合并为一个函数,返回 `(updates map[string]any, increment float64)`。
|
||||
|
||||
**原因**:当前两个函数各自独立计算增量,用途不同:
|
||||
- `calculateFlowUpdates()` → 更新卡的 `current_month_usage_mb`、`data_usage_mb` 等字段
|
||||
- `calculateFlowIncrement()` → 算增量给 `DeductDataUsage()`(将增量记录到套餐已用量)
|
||||
|
||||
改造后两者使用同一个增量公式(`gatewayFlowMB - card.LastGatewayReadingMB`),维护两份重复逻辑有不一致风险。合并后调用方式:
|
||||
|
||||
```go
|
||||
updates, increment := h.calculateFlowUpdates(card, gatewayFlowMB, now)
|
||||
// updates 用于更新卡字段
|
||||
// increment 用于套餐已用量记录
|
||||
```
|
||||
|
||||
### 3. 运营商重置日检测算法(上游重置)
|
||||
|
||||
`data_reset_day` 是**运营商级别**字段(`tb_carrier.data_reset_day`),标识上游运营商每月清零网关计数器的日期。这跟我们系统套餐的 `data_reset_cycle`(daily/monthly/yearly)是两个完全独立的维度。
|
||||
|
||||
```go
|
||||
// 增量 = 当前读数 - 上次读数
|
||||
increment := gatewayFlowMB - card.LastGatewayReadingMB
|
||||
|
||||
if increment < 0 {
|
||||
// 当前值比上次小 → 可能是上游运营商重置了
|
||||
resetDay := getCarrierResetDay(card.CarrierID)
|
||||
if isResetWindow(time.Now().Day(), resetDay) {
|
||||
// 在重置日窗口内,视为正常上游重置
|
||||
increment = gatewayFlowMB // 重置后原始值就是增量
|
||||
} else {
|
||||
// 非重置日出现值下降,异常,不计入
|
||||
logger.Warn("流量异常:非重置日出现值下降")
|
||||
increment = 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`isResetWindow` 精确定义**:
|
||||
|
||||
```go
|
||||
// isResetWindow 判断今天是否在运营商重置日窗口内
|
||||
// 窗口 = 重置日当天 + 前一天(容错网关数据延迟)
|
||||
// 例:resetDay=27 → 窗口包含 26、27
|
||||
// 例:resetDay=1 → 窗口包含上月最后一天、1号
|
||||
func isResetWindow(now time.Time, resetDay int) bool {
|
||||
today := now.Day()
|
||||
if today == resetDay {
|
||||
return true
|
||||
}
|
||||
// 前一天容错:计算 resetDay 的前一天
|
||||
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
|
||||
prevDay := resetDate.AddDate(0, 0, -1).Day()
|
||||
return today == prevDay
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 跨自然月重置逻辑
|
||||
|
||||
`current_month_usage_mb` 是**卡级字段**,记录这张卡本自然月的系统累计用量。跨自然月时需要重置。
|
||||
|
||||
改造后的跨月处理:
|
||||
```go
|
||||
if isCrossMonth {
|
||||
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
|
||||
updates["current_month_start_date"] = currentMonthStart
|
||||
updates["current_month_usage_mb"] = 0 // 新月从 0 开始累计(不再 = gatewayFlowMB)
|
||||
// increment 仍然正常计算(由 LastGatewayReadingMB 决定)
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:这里的"自然月重置"是我们**系统级别**对 `current_month_usage_mb` 的重置。跟套餐级别的 `data_reset_cycle` 完全独立——套餐的已用量重置由 `ResetService`(`reset_service.go`)负责,重置的是 `PackageUsage.DataUsageMB`,不涉及卡级字段。
|
||||
|
||||
### 5. Redis 缓冲策略
|
||||
|
||||
- **Key 格式**:`traffic:daily:{card_id}:{YYYY-MM-DD}`
|
||||
- **操作**:`INCRBYFLOAT`(原子增量)
|
||||
- **TTL**:48 小时(给落盘任务足够消费时间)
|
||||
- **落盘频率**:每天凌晨 2 点(Asynq Scheduler),SCAN 昨日 key → UPSERT → DELETE
|
||||
|
||||
**为什么不用 Sorted Set?** 每张卡每天只有一个值,简单 KV + INCRBYFLOAT 最高效。
|
||||
|
||||
### 6. 落盘 UPSERT 语义:覆盖(幂等安全)
|
||||
|
||||
```sql
|
||||
INSERT INTO tb_card_daily_usage (iot_card_id, date, usage_mb)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (iot_card_id, date)
|
||||
DO UPDATE SET usage_mb = EXCLUDED.usage_mb, updated_at = NOW();
|
||||
```
|
||||
|
||||
选择**覆盖**而非**累加**:Redis `INCRBYFLOAT` 保证值是正确的累积值,落盘只是"从 Redis 搬到 DB"。重试时覆盖写结果不变,天然幂等。如果用累加,任务重试会导致数据翻倍。
|
||||
|
||||
### 7. 落盘分批策略
|
||||
|
||||
10 万张活跃卡 ≈ 10 万个 Redis key。分批处理避免单次操作过大:
|
||||
|
||||
1. **SCAN 阶段**:`SCAN cursor pattern COUNT 500`,分批收集所有昨日 key
|
||||
2. **UPSERT 阶段**:每批 200 条,批量 UPSERT 到 DB
|
||||
3. **DELETE 阶段**:每批 200 条,Pipeline 批量删除已落盘 key
|
||||
|
||||
预估耗时:10 万 key ≈ 30-60 秒。Asynq 任务超时设为 5 分钟。
|
||||
|
||||
### 8. 数据初始化脚本
|
||||
|
||||
上线前必须运行:
|
||||
|
||||
```sql
|
||||
-- 将所有卡的 last_gateway_reading_mb 初始化为当前月用量
|
||||
-- 避免上线后第一次轮询把整个已用量当作增量
|
||||
UPDATE tb_iot_card
|
||||
SET last_gateway_reading_mb = current_month_usage_mb
|
||||
WHERE last_gateway_reading_mb = 0 AND current_month_usage_mb > 0;
|
||||
```
|
||||
|
||||
此脚本放在迁移文件中但标注为"上线前人工确认执行"。
|
||||
|
||||
**新卡首次轮询**:`last_gateway_reading_mb` 默认 0,首次轮询增量 = 全量(网关返回值)。对于新入库的卡(从没用过)这是正确的。批量导入已有使用量的卡时,导入脚本应同步设置 `last_gateway_reading_mb = current_month_usage_mb`。
|
||||
|
||||
### 9. 查询层兼容策略
|
||||
|
||||
`TrafficQueryService.GetDailyUsage()` 合并两段数据:
|
||||
- 今天 → 从 Redis 读(可能还没落盘)
|
||||
- 历史 → 从 `tb_card_daily_usage` 读
|
||||
- 排序合并后返回
|
||||
|
||||
### 10. 旧流量详单代码清理
|
||||
|
||||
`tb_data_usage_record` 不再写入后,相关代码路径全部删除:
|
||||
- `internal/model/data_usage.go`(DataUsageRecord model)
|
||||
- `internal/store/postgres/data_usage_record_store.go`(Store)
|
||||
- `internal/bootstrap/` 中的引用
|
||||
- `scripts/cleanup/main.go` 中的引用
|
||||
|
||||
旧表数据暂保留在 DB(后续可通过数据清理功能清除),但代码层完全去除依赖。
|
||||
|
||||
### 11. 运营商管理接口变更
|
||||
|
||||
`tb_carrier` 新增 `data_reset_day INT NOT NULL DEFAULT 1`。创建/编辑运营商时新增此字段(1-28),前端显示为"每月 N 日重置流量"。
|
||||
|
||||
建议初始值:联通=27,移动/电信/广电=1。可通过迁移 SQL 直接 UPDATE 已有运营商记录。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Redis 丢数据]** Redis 重启导致今日缓冲丢失 → TTL 48h + 落盘任务可补偿;极端情况丢一天数据,业务可接受
|
||||
- **[首次轮询增量暴增]** `last_gateway_reading_mb` 未初始化时第一次轮询增量=全量 → 通过初始化脚本消除
|
||||
- **[重置日误判]** 运营商非预期时间重置 → 前一天容错 + Warn 日志,人工可介入
|
||||
- **[落盘任务失败]** Redis key 在 48h TTL 内未消费 → 数据丢失。Asynq 有重试机制(MaxRetry=3),且可手动触发
|
||||
- **[跨自然月边界]** 轮询恰好在 0 点附近:先检测跨月 → 重置 `current_month_usage_mb = 0` → 再计算增量。两步原子完成
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **迁移 1**:`ALTER TABLE tb_carrier ADD COLUMN data_reset_day`,UPDATE 已有运营商初始值
|
||||
2. **迁移 2**:`ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb`
|
||||
3. **迁移 3**:`CREATE TABLE tb_card_daily_usage`(`usage_mb` 使用 `NUMERIC(12,2)` 类型)
|
||||
4. **数据初始化**:执行 `UPDATE tb_iot_card SET last_gateway_reading_mb = current_month_usage_mb`
|
||||
5. **部署代码**:低峰期发布
|
||||
6. **回滚策略**:如果增量算法异常,可临时切回覆盖写(保留旧代码为注释或 feature flag)
|
||||
@@ -0,0 +1,57 @@
|
||||
## Why
|
||||
|
||||
当前流量数据处理存在两个架构性问题:
|
||||
|
||||
1. **流量详单无差别写 DB**(P2-26):每次轮询无条件调用 `insertDataUsageRecord()`,即使增量为 0 也写记录,大量卡每日产生大量无意义记录,数据库膨胀。
|
||||
2. **月流量直接覆盖**(P2-27):`calculateFlowUpdates()` 直接用网关返回值覆盖 `current_month_usage_mb`。上游运营商按自然月重置(联通 27 号,移动/电信/广电 1 号),重置后网关返回 0,我们的套餐月用量也归零——但套餐周期未必与自然月一致,导致用量数据错误。
|
||||
|
||||
这是架构改造,涉及 DB 迁移 + Redis 引入 + 轮询核心路径修改,**建议低峰期发布**。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **E-2 月流量改增量累加**(先做):`tb_iot_card` 新增 `last_gateway_reading_mb` 字段记录上次网关读数,`tb_carrier` 新增 `data_reset_day` 字段记录运营商月重置日。合并 `calculateFlowUpdates()` 和 `calculateFlowIncrement()` 为一个函数,改为增量算法:`increment = 当前读数 - 上次读数`,检测到上游重置时特殊处理。`current_month_usage_mb` 从直接覆盖改为 `+= increment`。跨自然月时 `current_month_usage_mb` 重置为 0(不再 `= gatewayFlowMB`)。**⚠️ 上线前需运行数据初始化脚本**,将所有卡的 `last_gateway_reading_mb` 设为当前 `current_month_usage_mb`。
|
||||
- **E-1 流量详单改日粒度缓冲**:新建 `tb_card_daily_usage` 表(每卡每天一条)。`insertDataUsageRecord()` 改为只有增量 > 0 时写 Redis(`INCRBYFLOAT`),**直接替换旧写入路径,不再写 `tb_data_usage_record`**。新增每日落盘 Asynq 定时任务,凌晨 2 点分批 SCAN 昨日 Redis key → 覆盖 UPSERT 到 `tb_card_daily_usage` → 删 key。
|
||||
- **E-3 流量查询层适配**:新建 `TrafficQueryService`,查询时合并"今日 Redis + 历史 DB"两段数据。更新所有受影响的查询入口。
|
||||
- **清理**:删除 `tb_data_usage_record` 相关代码(model、store、bootstrap 引用)。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `traffic-daily-buffer`: 流量日粒度 Redis 缓冲 + 每日落盘机制
|
||||
- `traffic-query-service`: 统一流量查询服务(Redis + DB 合并)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `package-usage-daily-record`: 流量详单从无差别写 DB 改为 Redis 缓冲 + 日落盘(直接替换旧路径)
|
||||
- `carrier`: 运营商新增 `data_reset_day` 字段(上游重置日)
|
||||
- `iot-card`: IoT 卡新增 `last_gateway_reading_mb` 字段,月流量改为增量累加,跨自然月重置为 0
|
||||
|
||||
## Impact
|
||||
|
||||
- **DB 迁移**:3 个迁移文件(`tb_carrier` 加字段、`tb_iot_card` 加字段、新建 `tb_card_daily_usage` 表)
|
||||
- **数据初始化**:上线前需运行脚本初始化 `last_gateway_reading_mb`
|
||||
- **修改文件**:`internal/task/polling_handler.go`(核心改动,合并两个增量计算函数)、`internal/model/iot_card.go`、`internal/model/carrier.go`、相关 DTO、运营商管理接口
|
||||
- **新增文件**:`internal/model/card_daily_usage.go`、`internal/store/postgres/card_daily_usage_store.go`(落盘+查询共用)、`internal/service/traffic/query_service.go`、日落盘任务 handler
|
||||
- **删除文件**:`internal/model/data_usage.go`、`internal/store/postgres/data_usage_record_store.go`(旧流量详单代码)
|
||||
- **新增常量**:Redis key(`traffic:daily:{card_id}:{date}`)、任务类型(`TaskTypeDailyTrafficFlush`)
|
||||
- **Redis 新增用途**:流量增量缓存(INCRBYFLOAT + 48h TTL)
|
||||
- **轮询核心路径变更**:高风险,需低峰期发布
|
||||
|
||||
### 受影响的查询接口(E-3 需逐一适配)
|
||||
|
||||
| # | 接口路径 | 文件 | 影响方式 |
|
||||
|---|---------|------|---------|
|
||||
| 1 | `GET /api/admin/assets/:type/:id/realtime-status` | `service/asset/service.go` | `CurrentMonthUsageMB` 语义变化 |
|
||||
| 2 | `GET /api/admin/assets/resolve/:identifier` | `handler/admin/asset.go` | resolve 返回流量概况 |
|
||||
| 3 | `GET /api/admin/assets/device/:id/realtime-status` | `service/asset/service.go` | 设备级聚合绑定卡用量 |
|
||||
| 4 | `GET /api/c/v1/asset/info` | `handler/app/client_asset.go` | C端首页流量数据 |
|
||||
| 5 | `GET /api/c/v1/asset/packages` | `handler/app/client_asset.go` | C端套餐用量展示 |
|
||||
| 6 | `GET /api/c/v1/asset/refresh` | `handler/app/client_asset.go` | C端手动刷新 |
|
||||
| 7 | `GET /api/admin/assets/:type/:id/packages` | `service/asset/service.go` | 管理端套餐列表 |
|
||||
|
||||
### 不受影响的模块(确认独立)
|
||||
|
||||
- **套餐级流量重置**(`ResetService`):操作 `PackageUsage.DataUsageMB`,与卡级字段无关
|
||||
- **套餐日记录**(`tb_package_usage_daily_record`):套餐级日记录,由 `UsageService.updateDailyRecord()` 写入,与卡级 `tb_card_daily_usage` 是两个不同维度
|
||||
- **套餐创建/管理**:`data_reset_cycle` 配置不受影响
|
||||
@@ -0,0 +1,16 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 运营商上游流量重置日配置
|
||||
`tb_carrier` SHALL 包含 `data_reset_day` 字段(INT, 1-28),表示该运营商每月上游流量重置日(即上游运营商清零网关计数器的日期)。创建/编辑运营商时 SHALL 支持设置此字段。
|
||||
|
||||
注意:此字段与套餐级别的 `data_reset_cycle`(daily/monthly/yearly)是**完全独立的两个维度**。`data_reset_day` 用于检测上游网关值下降是否为正常重置,`data_reset_cycle` 用于我们系统套餐的已用量定时归零。
|
||||
|
||||
#### Scenario: 创建运营商时指定重置日
|
||||
- **WHEN** 管理员创建运营商,指定 `data_reset_day = 27`
|
||||
- **THEN** 该运营商记录的 `data_reset_day` SHALL 为 27
|
||||
|
||||
#### Scenario: 轮询时读取重置日判断上游重置
|
||||
- **WHEN** 轮询系统检测到网关流量值下降(`increment < 0`)
|
||||
- **THEN** 系统 SHALL 读取该卡对应运营商的 `data_reset_day`
|
||||
- **AND** 使用 `isResetWindow(now, resetDay)` 判断是否在重置日窗口内(重置日当天 + 前一天容错)
|
||||
- **AND** 窗口内视为正常上游重置,窗口外记录 Warn 日志并丢弃异常值
|
||||
@@ -0,0 +1,37 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: IoT 卡网关读数记录
|
||||
`tb_iot_card` SHALL 包含 `last_gateway_reading_mb` 字段(FLOAT, 默认 0),记录上次轮询时网关返回的流量读数,用于计算增量。
|
||||
|
||||
#### Scenario: 轮询更新网关读数
|
||||
- **WHEN** 轮询系统获取到网关流量值
|
||||
- **THEN** 系统 SHALL 将 `last_gateway_reading_mb` 更新为本次网关返回值(无论增量是否 > 0)
|
||||
|
||||
### Requirement: 月流量增量累加
|
||||
`current_month_usage_mb` SHALL 使用增量累加(`+= increment`)而非直接覆盖(`= gatewayValue`)。增量 = 当前网关读数 - 上次网关读数(`last_gateway_reading_mb`)。
|
||||
|
||||
#### Scenario: 正常流量增长
|
||||
- **WHEN** 上次读数 100MB,本次读数 105MB
|
||||
- **THEN** `current_month_usage_mb` SHALL 增加 5MB(而非被覆盖为 105MB)
|
||||
- **AND** `data_usage_mb`(卡生命周期总用量)SHALL 同步增加 5MB
|
||||
|
||||
#### Scenario: 上游自然月重置
|
||||
- **WHEN** 上次读数 500MB,本次读数 10MB,且在运营商重置日窗口内
|
||||
- **THEN** `increment` SHALL 为 10MB(本次原始值即为增量),`current_month_usage_mb += 10`
|
||||
|
||||
#### Scenario: 非重置日异常下降
|
||||
- **WHEN** 上次读数 500MB,本次读数 10MB,但不在重置日窗口内
|
||||
- **THEN** `increment` SHALL 为 0,记录 Warn 日志,`current_month_usage_mb` 不变
|
||||
|
||||
### Requirement: 跨自然月重置
|
||||
当检测到系统跨自然月时,`current_month_usage_mb` SHALL 重置为 0(不再等于 `gatewayFlowMB`),`last_month_total_mb` SHALL 记录上月累计值。
|
||||
|
||||
#### Scenario: 跨月轮询
|
||||
- **WHEN** 上次轮询在 3 月,本次轮询在 4 月
|
||||
- **THEN** `last_month_total_mb` = 原 `current_month_usage_mb`,`current_month_usage_mb` = 0,`current_month_start_date` 更新为本月 1 日
|
||||
|
||||
### Requirement: 新卡首次轮询
|
||||
新入库的卡 `last_gateway_reading_mb` 默认为 0,首次轮询的增量 = 网关返回的全量值。这是预期行为。批量导入已有使用量的卡时,导入脚本应同步设置 `last_gateway_reading_mb`。
|
||||
|
||||
### Requirement: 增量函数合并
|
||||
`calculateFlowUpdates()` 和 `calculateFlowIncrement()` SHALL 合并为一个函数,返回 `(updates map[string]any, increment float64)`。消除两个独立增量计算函数的不一致风险。
|
||||
@@ -0,0 +1,16 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 流量详单存储方式
|
||||
卡级流量详单 SHALL 从无差别直接写 DB 改为 Redis 缓冲 + 日落盘。`insertDataUsageRecord()` SHALL NOT 再创建 `DataUsageRecord` 记录,直接替换为 Redis 写入。
|
||||
|
||||
#### Scenario: 轮询写流量数据
|
||||
- **WHEN** 轮询系统检测到卡的流量变化(增量 > 0)
|
||||
- **THEN** 系统 SHALL 仅写 Redis 缓冲(`INCRBYFLOAT`),不写 DB
|
||||
|
||||
#### Scenario: 旧代码清理
|
||||
- **WHEN** 新存储路径完全就绪
|
||||
- **THEN** SHALL 删除 `DataUsageRecord` model、`DataUsageRecordStore`、bootstrap 中的相关引用
|
||||
- **AND** `tb_data_usage_record` 旧表数据暂保留在 DB(后续通过数据清理功能清除),代码层完全去除依赖
|
||||
|
||||
### Clarification: 套餐级日记录不受影响
|
||||
`tb_package_usage_daily_record`(套餐级日记录)由 `UsageService.updateDailyRecord()` 在轮询将增量记录到套餐已用量时同步写入,与本次改造的卡级 `tb_card_daily_usage` 是两个完全不同的维度,互不影响。
|
||||
@@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 流量增量写入 Redis 缓冲
|
||||
轮询检测到流量增量 > 0 时,系统 SHALL 使用 `INCRBYFLOAT` 将增量写入 Redis key(格式 `traffic:daily:{card_id}:{YYYY-MM-DD}`),TTL 48 小时。增量 <= 0 时 SHALL NOT 写入。**直接替换旧的 `insertDataUsageRecord()` 写 DB 路径,不双写。**
|
||||
|
||||
#### Scenario: 有流量增量时写 Redis
|
||||
- **WHEN** 轮询检测到卡 ID=100 今日流量增量为 5.2 MB
|
||||
- **THEN** 系统 SHALL 执行 `INCRBYFLOAT traffic:daily:100:2026-03-28 5.2`,并设置 48h TTL
|
||||
|
||||
#### Scenario: 无流量增量时跳过
|
||||
- **WHEN** 轮询检测到流量增量 <= 0
|
||||
- **THEN** 系统 SHALL NOT 执行任何 Redis 或 DB 写入
|
||||
|
||||
### Requirement: 每日落盘定时任务
|
||||
系统 SHALL 每天凌晨 2 点(Asia/Shanghai)通过 Asynq Scheduler 触发落盘任务。
|
||||
|
||||
#### Scenario: 正常落盘(分批处理)
|
||||
- **WHEN** 凌晨 2 点落盘任务执行
|
||||
- **THEN** 系统 SHALL 分批 SCAN 所有 `traffic:daily:*:{昨日}` key(每批 COUNT 500)
|
||||
- **AND** 分批 UPSERT 到 `tb_card_daily_usage`(每批 200 条,**覆盖语义**保证幂等:`ON CONFLICT DO UPDATE SET usage_mb = EXCLUDED.usage_mb`)
|
||||
- **AND** 分批 Pipeline 删除已落盘的 Redis key
|
||||
- **AND** 记录日志:落盘条数、耗时
|
||||
|
||||
#### Scenario: 落盘失败重试
|
||||
- **WHEN** 落盘任务执行失败
|
||||
- **THEN** Asynq SHALL 自动重试(MaxRetry=3,超时 5 分钟),Redis key 因 48h TTL 仍可用
|
||||
- **AND** 因 UPSERT 使用覆盖语义,重试时不会导致数据翻倍
|
||||
|
||||
### Requirement: tb_card_daily_usage 表结构
|
||||
`usage_mb` 字段 SHALL 使用 `NUMERIC(12,2)` 类型(匹配 Redis `INCRBYFLOAT` 的浮点精度和现有 `current_month_usage_mb` 的 `decimal(10,2)` 类型),不使用 BIGINT。
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 统一流量查询服务
|
||||
`TrafficQueryService.GetDailyUsage()` SHALL 合并 Redis(今日)和 DB(历史)两段数据,返回指定日期范围内的每日流量列表。
|
||||
|
||||
#### Scenario: 查询含今日的日期范围
|
||||
- **WHEN** 查询 2026-03-25 到 2026-03-28(今天)
|
||||
- **THEN** 系统 SHALL 从 `tb_card_daily_usage` 查 3/25~3/27,从 Redis 查 3/28,合并排序后返回
|
||||
|
||||
#### Scenario: 查询纯历史日期范围
|
||||
- **WHEN** 查询 2026-03-01 到 2026-03-20
|
||||
- **THEN** 系统 SHALL 仅从 `tb_card_daily_usage` 查询,不访问 Redis
|
||||
|
||||
### Requirement: CardDailyUsageStore 共用
|
||||
落盘任务(`daily_traffic_flush.go`)和查询服务(`TrafficQueryService`)SHALL 共用同一个 `CardDailyUsageStore`,避免重复实现 UPSERT 和查询逻辑。
|
||||
@@ -0,0 +1,125 @@
|
||||
# 任务清单:refactor-traffic-system
|
||||
|
||||
> ⚠️ 架构改造,涉及 DB 迁移 + 轮询核心路径修改,建议低峰期发布。
|
||||
> 严格串行:E-2(增量算法)→ E-1(Redis 缓冲)→ E-3(查询适配)→ 清理。
|
||||
|
||||
## 任务组 1:E-2 月流量改增量累加(P2-27)
|
||||
|
||||
### 1.1 DB 迁移 + Model 扩展
|
||||
|
||||
- [x] 1.1.1 新建迁移:`ALTER TABLE tb_carrier ADD COLUMN data_reset_day INT NOT NULL DEFAULT 1;`
|
||||
- [x] 1.1.2 在同一迁移中 UPDATE 已有运营商:联通 `data_reset_day=27`,其余 `=1`(需确认现有运营商数据)
|
||||
- [x] 1.1.3 新建迁移:`ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb FLOAT NOT NULL DEFAULT 0;`
|
||||
- [x] 1.1.4 `internal/model/carrier.go` 新增 `DataResetDay int` 字段
|
||||
- [x] 1.1.5 `internal/model/iot_card.go` 新增 `LastGatewayReadingMB float64` 字段
|
||||
- [x] 1.1.6 运营商相关 DTO 新增 `DataResetDay` 字段
|
||||
- [x] 1.1.7 运营商 Create/Update Handler/Service 支持 `data_reset_day` 参数(1-28 范围校验)
|
||||
- [x] 1.1.8 执行迁移,通过 DBHub 确认字段已添加
|
||||
- [x] 1.1.9 验证:`go build ./...` 编译通过
|
||||
|
||||
### 1.2 增量算法改写
|
||||
|
||||
- [x] 1.2.1 新增辅助方法 `getCarrierResetDay(carrierID uint) int`,从 DB/缓存读取运营商重置日
|
||||
- [x] 1.2.2 新增辅助函数 `isResetWindow(now time.Time, resetDay int) bool`:窗口 = 重置日当天 + 前一天(容错网关延迟)。`resetDay=27` 时窗口含 26、27;`resetDay=1` 时窗口含上月最后一天和 1 号
|
||||
- [x] 1.2.3 **合并** `calculateFlowUpdates()` 和 `calculateFlowIncrement()` 为一个函数,签名改为 `calculateFlowUpdates(card, gatewayFlowMB, now) (map[string]any, float64)`,返回 `(updates, increment)`:
|
||||
- 计算增量 `increment = gatewayFlowMB - card.LastGatewayReadingMB`
|
||||
- 检测上游运营商重置(increment < 0 时用 `isResetWindow` 判断)
|
||||
- 检测跨自然月:跨月时 `current_month_usage_mb = 0`(不再 `= gatewayFlowMB`),`last_month_total_mb = 旧值`
|
||||
- 正常增量:`current_month_usage_mb` 改为 `gorm.Expr("current_month_usage_mb + ?", increment)`
|
||||
- 更新 `data_usage_mb`(卡生命周期总用量):`gorm.Expr("data_usage_mb + ?", int64(increment))`
|
||||
- 始终更新 `last_gateway_reading_mb = gatewayFlowMB`
|
||||
- [x] 1.2.4 删除旧的 `calculateFlowIncrement()` 函数
|
||||
- [x] 1.2.5 更新调用方:`HandleCarddataCheck()` 中改为使用合并后的返回值
|
||||
```go
|
||||
updates, flowIncrementMB := h.calculateFlowUpdates(card, gatewayFlowMB, now)
|
||||
```
|
||||
- [x] 1.2.6 验证:`go build ./...` 编译通过
|
||||
|
||||
### 1.3 数据初始化脚本
|
||||
|
||||
- [x] 1.3.1 新建迁移文件(或独立 SQL 脚本):`UPDATE tb_iot_card SET last_gateway_reading_mb = current_month_usage_mb WHERE last_gateway_reading_mb = 0 AND current_month_usage_mb > 0;`
|
||||
- [x] 1.3.2 通过 DBHub 验证执行结果:`SELECT COUNT(*) FROM tb_iot_card WHERE last_gateway_reading_mb = 0 AND current_month_usage_mb > 0;` 应为 0
|
||||
|
||||
## 任务组 2:E-1 流量详单改日粒度缓冲(P2-26)
|
||||
|
||||
### 2.1 日流量表 + Model + Store + Redis Key
|
||||
|
||||
- [x] 2.1.1 新建迁移:
|
||||
```sql
|
||||
CREATE TABLE tb_card_daily_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
iot_card_id BIGINT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
usage_mb NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT uk_card_daily UNIQUE (iot_card_id, date)
|
||||
);
|
||||
CREATE INDEX idx_card_daily_date ON tb_card_daily_usage (date);
|
||||
```
|
||||
- [x] 2.1.2 新建 `internal/model/card_daily_usage.go` GORM Model(`UsageMB` 使用 `float64` 类型,对应 DB `NUMERIC(12,2)`)
|
||||
- [x] 2.1.3 新建 `internal/store/postgres/card_daily_usage_store.go`,实现 `Upsert()`(覆盖语义,ON CONFLICT DO UPDATE SET usage_mb = EXCLUDED.usage_mb)和 `ListByCardIDAndDateRange()` 方法。**此 Store 同时供落盘任务(2.3)和查询层(3.x)使用**
|
||||
- [x] 2.1.4 在 `pkg/constants/redis.go` 新增 `RedisCardDailyTrafficKey(cardID uint, date string) string`
|
||||
- [x] 2.1.5 在 `pkg/constants/constants.go` 新增 `TaskTypeDailyTrafficFlush` 常量
|
||||
- [x] 2.1.6 执行迁移,通过 DBHub 确认表已创建
|
||||
- [x] 2.1.7 验证:`go build ./...` 编译通过
|
||||
|
||||
### 2.2 insertDataUsageRecord 改造
|
||||
|
||||
- [x] 2.2.1 改写 `insertDataUsageRecord()`:增量 <= 0 直接 return;增量 > 0 时 `INCRBYFLOAT` 写 Redis + `Expire` 48h。**不再写 `tb_data_usage_record`**(直接替换,不双写)
|
||||
- [x] 2.2.2 验证:`go build ./...` 编译通过
|
||||
|
||||
### 2.3 每日落盘任务
|
||||
|
||||
- [x] 2.3.1 新建 `internal/task/daily_traffic_flush.go`,实现落盘 Handler:
|
||||
- 分批 SCAN `traffic:daily:*:{昨日}`(每批 COUNT 500)
|
||||
- 解析 card_id 和 date,收集所有 entry
|
||||
- 分批 UPSERT 到 `tb_card_daily_usage`(每批 200 条,使用 2.1.3 的 `CardDailyUsageStore.Upsert()`,覆盖语义保证幂等)
|
||||
- 分批 Pipeline 删除已落盘的 Redis key
|
||||
- 记录日志:落盘条数、耗时
|
||||
- [x] 2.3.2 在 `pkg/queue/handler.go` 中注册落盘 Handler
|
||||
- [x] 2.3.3 在 Asynq Scheduler 中配置每天凌晨 2 点(Asia/Shanghai)触发 `TaskTypeDailyTrafficFlush`,超时设为 5 分钟
|
||||
- [x] 2.3.4 验证:`go build ./...` 编译通过
|
||||
|
||||
## 任务组 3:E-3 流量查询层适配(P2-31)
|
||||
|
||||
### 3.1 TrafficQueryService + bootstrap 注册
|
||||
|
||||
- [x] 3.1.1 新建 `internal/service/traffic/query_service.go`,实现 `GetDailyUsage(ctx, cardID, startDate, endDate)` 方法:今天 → Redis,历史 → DB(使用 2.1.3 的 `CardDailyUsageStore`),合并排序返回
|
||||
- [x] 3.1.2 在 bootstrap 中注册 `TrafficQueryService`
|
||||
|
||||
### 3.2 管理端接口适配
|
||||
|
||||
- [x] 3.2.1 更新 `GET /api/admin/assets/:type/:id/realtime-status`(`service/asset/service.go`):`CurrentMonthUsageMB` 语义从"上游原始值"变为"系统累计值",确认注释和 DTO description 更新
|
||||
- [x] 3.2.2 更新 `GET /api/admin/assets/resolve/:identifier`(`handler/admin/asset.go`):resolve 返回的流量概况使用新语义
|
||||
- [x] 3.2.3 更新 `GET /api/admin/assets/device/:id/realtime-status`(`service/asset/service.go`):设备级聚合绑定卡的 `CurrentMonthUsageMB`,确认聚合逻辑和注释更新
|
||||
|
||||
### 3.3 C端接口适配
|
||||
|
||||
- [x] 3.3.1 更新 `GET /api/c/v1/asset/info`(`handler/app/client_asset.go`):C端首页资产信息中的流量数据使用新语义
|
||||
- [x] 3.3.2 更新 `GET /api/c/v1/asset/packages`(`handler/app/client_asset.go`):套餐列表中的用量展示
|
||||
- [x] 3.3.3 更新 `GET /api/c/v1/asset/refresh`(`handler/app/client_asset.go`):刷新后返回的流量数据
|
||||
- [x] 3.3.4 更新 `GET /api/admin/assets/:type/:id/packages`(`service/asset/service.go`):管理端套餐列表
|
||||
|
||||
### 3.4 DTO 和注释更新
|
||||
|
||||
- [x] 3.4.1 更新 `AssetRealtimeStatusResponse.CurrentMonthUsageMB` 的 description 标签:从"Gateway返回的自然月流量总量"改为"系统累计的自然月流量"
|
||||
- [x] 3.4.2 更新 `IotCard.CurrentMonthUsageMB` 的 GORM comment:同上
|
||||
- [x] 3.4.3 验证:`go build ./...` 编译通过
|
||||
|
||||
## 任务组 4:旧流量详单代码清理
|
||||
|
||||
- [x] 4.1 删除 `internal/model/data_usage.go`(`DataUsageRecord` model)
|
||||
- [x] 4.2 删除 `internal/store/postgres/data_usage_record_store.go`
|
||||
- [x] 4.3 清理 `internal/bootstrap/stores.go`、`services.go`、`worker_stores.go`、`worker_services.go`、`handlers.go` 中 `DataUsageRecord` 相关引用
|
||||
- [x] 4.4 清理 `pkg/queue/types.go` 中 `DataUsageRecord` 相关字段
|
||||
- [x] 4.5 清理 `scripts/cleanup/main.go` 中 `tb_data_usage_record` 引用(改为 `tb_card_daily_usage`)
|
||||
- [x] 4.6 删除 `internal/task/polling_handler.go` 中旧的 `insertDataUsageRecord` 相关的已废弃代码(如有残留)
|
||||
- [x] 4.7 验证:`go build ./...` 编译通过
|
||||
|
||||
## 收尾验证
|
||||
|
||||
- [x] 5.1 执行 `go build ./...`,确认全量编译通过
|
||||
- [x] 5.2 通过 DBHub 确认新增表和字段存在
|
||||
- [x] 5.3 通过 `rg "RedisCardDailyTrafficKey\|TaskTypeDailyTrafficFlush\|TrafficQueryService\|CardDailyUsageStore"` 确认新组件已连通
|
||||
- [x] 5.4 通过 `rg "DataUsageRecord\|data_usage_record"` 确认旧代码已清理(只允许出现在迁移文件或注释中)
|
||||
Reference in New Issue
Block a user