## 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）
