# 系统改造方向

> 本文档记录当前系统的核心问题、改造原因、改造方向和预期收益。
> 用于在开发过程中明确"干什么、为什么干、干的好处"。

---

## 一、现状

### 1.1 项目规模

- 当前在线卡数：约 3 万张（生产）
- 待迁移卡数：约 67 万张（来自旧系统）
- 核心业务链路：代理购卡 → 销售分配 → 套餐购买 → 流量同步 → 停复机

### 1.2 轮询系统现状

轮询系统的作用是**定期向运营商查询卡的状态**（实名状态、流量用量、卡网络状态等），保持系统数据与运营商同步。

当前架构：

```
全量卡 ID → Redis Sorted Set（分片，score = 下次检查时间）
→ Scheduler 每秒扫描到期卡
→ Asynq 队列
→ Worker 执行检查（实名/流量/卡状态/保护期/套餐，共 5 种类型）
```

**问题清单：**

| # | 问题 | 具体表现 |
|---|------|---------|
| 1 | **看不见，无法排查** | 业务反馈"某张卡一小时了还没同步实名"，没有任何结构化记录可以查，只能翻日志文件靠关键词过滤 |
| 2 | **配置改了不知道有没有用** | 轮询配置对哪些卡生效完全黑盒，改完后没有验证手段，全靠等和猜 |
| 3 | **大量无意义轮询** | 全量卡按固定频率轮询，大多数卡状态根本没有变化，这些查询全是浪费；67 万张卡迁移后压力更大 |
| 4 | **业务事件不触发检查** | 代理通过开放接口买了套餐，系统不知道，要等下次定时轮询碰到这张卡（可能要等几分钟到几十分钟）|
| 5 | **两种不同职责混在一个调度器** | 卡状态同步（每 1 秒触发）和业务流程调度（套餐过期处理、流量重置，每 10 秒触发）混在同一个 Scheduler，出问题难以定位是哪一层 |
| 6 | **调度状态全在 Redis，没有持久化** | Redis 重启或清空后，全量卡的调度状态丢失，需要重跑全量初始化才能恢复；67 万张卡的初始化需要相当长时间，期间所有卡停止轮询 |
| 7 | **单卡状态散在四处** | 上次检查时间在 IotCard 表；下次检查时间在 Redis Sorted Set；卡状态快照在 Redis Hash；执行结果在日志文件。没有一个地方能给完整答案 |

### 1.3 流量数据现状

运营商接口返回的是**当前账期内的累计已用流量**（不是增量，是总量）。
运营商账期重置日因运营商而异，在创建运营商时配置。
套餐周期可以是自然月或固定日期，在创建套餐时决定。

当前 IotCard 表上与流量相关的字段：

```
DataUsageMB             卡生命周期总用量（永不归零）
CurrentMonthUsageMB     系统自然月累计用量（展示用）
LastMonthTotalMB        上月结束时的流量总量（用于跨月计算）
CurrentMonthStartDate   系统自然月起始日期
LastGatewayReadingMB    上次轮询时运营商返回的累计读数
```

PackageUsage 表上：

```
DataUsageMB             套餐已用量（套餐周期到期时归零，用于判断是否耗尽）
```

**问题清单：**

| # | 问题 | 具体表现 |
|---|------|---------|
| 1 | **运营商重置靠猜，有漏检风险** | 只存上一次读数（`LastGatewayReadingMB`），靠"本次读数比上次小"来猜测运营商是否重置了账期。若轮询有空窗（例如系统故障几小时），重置后的新用量超过了上次读数，则增量为正，系统判断为"没有重置"，漏掉了那段用量 |
| 2 | **无原始读数，无法审计** | 运营商告诉了什么、什么时候告诉的、算出了多少增量——计算完成后全部丢弃。流量统计不对时，没有任何数据可以追溯原因 |
| 3 | **重置边界的流量归属不准** | 套餐在午夜重置，但上次轮询在 23:59，下次轮询在 00:01。这两分钟的增量（包含昨天剩余用量）全部算进今天，日流量套餐受影响明显 |
| 4 | **三套"月"的概念同时存在** | 运营商账期（各自定义重置日）+ 套餐周期（自然月或固定日）+ 系统自然月（展示用）。三者不对齐，边界处理散落在代码各处，逻辑复杂且脆弱 |
| 5 | **`CurrentMonthUsageMB` 不按时重置** | 这个字段不由重置任务触发，而是在下次轮询时内联检测跨月并重置。1 号凌晨用户看到的数据是上月的，要等到下次轮询才更新 |
| 6 | **两套日流量记录并存** | `CardDailyUsage`（按卡）和 `PackageUsageDailyRecord`（按套餐）同时存在，来源不同，权威性不明确，出现差异时不知道信谁 |

### 1.4 整体架构现状

当前分层：`Handler → Service → Store → Model`，分层存在但执行不彻底。

核心问题：**贫血模型 + 上帝 Service**

- Model 只是数据库映射，没有任何业务行为
- 所有业务逻辑全部堆在 Service，导致 Service 极度臃肿

| Service | 行数 | 混杂的业务域 |
|---------|------|------------|
| order/service.go | 3031 行 | 订单创建、支付、佣金计算、代购、库存扣减 |
| iot_card/service.go | 2310 行 | 卡管理、网关调用、停复机、生命周期、流量同步 |
| device/service.go | 2078 行 | 设备管理、绑定、批量导入 |

没有领域事件：业务事件（套餐支付完成、卡实名成功）不产生任何可订阅的事件，所有下游动作全部通过直接方法调用耦合，新增联动逻辑必须修改原有代码。

---

## 二、改造方向（优先级一）：轮询系统 + 流量数据

### 2.1 轮询系统改造

#### 改造 A：建立单卡可查询的轮询状态

**要干什么：** 新增一张表 `card_polling_state`，每张卡一行，只更新不追加，记录每种检查类型的上次执行时间、结果、命中配置、下次预计时间。

**为什么干：** 现在"这张卡的轮询状态"散在四个地方，没有一处能完整回答问题。这张表就是单一的事实来源。

**好处：**
- 业务反馈某张卡没同步 → 查这张表，30 秒给出答案（上次检查时间、结果是什么、下次什么时候）
- 不影响任何现有轮询逻辑，只需在执行检查后多写一次 UPDATE

**同时新增两个接口：**

```
GET /admin/polling/cards/:id/state
→ 返回这张卡的完整轮询状态

GET /admin/polling/preview?card_id=12345
→ 返回这张卡当前命中哪条配置、各检查类型的间隔是多少、下次检查时间
```

第二个接口解决"改了配置不知道有没有用"的问题——改完配置后，查任意一张卡，立刻知道新配置有没有生效。

---

#### 改造 B：引入事件触发层

**要干什么：** 在业务动作完成时，主动安排针对性的延迟检查，而不是等定时轮询碰到这张卡。

**为什么干：** 现在买了套餐，系统要等下次轮询才知道套餐有没有激活。运营商激活需要时间，但我们不知道什么时候激活完成，只能等碰到。

**好处：**
- 套餐购买 → 5 分钟后主动检查 → 激活状态及时同步，不用等下次定时轮询
- 轮询有了"意义"——有事件发生才检查，而不是盲目扫全量

**触发规则：**

| 业务事件 | 触发检查 | 延迟 |
|---------|---------|------|
| 套餐订单支付完成 | 检查套餐激活状态 | 5 分钟 |
| 代理开放接口购套餐 | 检查套餐激活状态 | 5 分钟 |
| 卡首次分配给店铺 | 检查实名状态 | 30 分钟 |
| 手动触发刷新 | 立即检查 | 0 |

实现方式：利用 Asynq 的定时任务功能（`asynq.ProcessAt`），在现有 Worker 框架内完成，不需要新增基础设施。

---

#### 改造 C：按卡状态分级调度

**要干什么：** 不同状态的卡，轮询频率不同，而不是所有卡一视同仁。

**为什么干：** 稳定运行了半年、没有套餐变动的卡，每 2 分钟轮询一次是纯粹的浪费。等实名的卡、刚购套餐的卡才需要频繁检查。

**好处：**
- 67 万张卡迁移后，无意义轮询量大幅下降（估计减少 60%-80%）
- 轮询资源集中在真正需要关注的卡

**分级规则（通过现有 PollingConfig 机制配置）：**

| 卡状态 | 实名检查间隔 | 流量检查间隔 |
|-------|------------|------------|
| 等待实名 | 10 分钟 | 30 分钟 |
| 活跃中（近期有套餐变动）| 15 分钟 | 30 分钟 |
| 稳定运行 | 2 小时 | 2 小时 |
| 停机 / 停用 | 每日一次 | 每日一次 |

---

#### 改造 D：分离业务调度

**要干什么：** 把套餐过期处理、流量重置从 `Scheduler` 里拆出去，用独立的定时任务管理。

**为什么干：** 现在卡状态同步（每 1 秒）和业务逻辑调度（每 10 秒）混在同一个 Scheduler 里。出了问题不知道是哪一层，改动也容易互相影响。

**好处：**
- 各司其职，互不干扰
- 业务调度（套餐过期）出问题不影响卡状态同步
- 代码更清晰，新增调度任务不需要修改 Scheduler

---

### 2.2 流量数据改造

#### 改造 A：保留原始读数

**要干什么：** 新增 `carrier_readings` 表，每次从运营商拿到读数都记录下来，永不修改。

```
carrier_readings:
  card_id          卡ID
  reading_mb       运营商返回的当期累计值
  read_at          读取时间
  cycle_start_date 本条读数所属的运营商账期开始日期
  source           来源（polling / manual / triggered）
```

**为什么干：** 现在计算完增量后，原始读数就丢弃了。流量统计不对时，没有任何数据可以追溯。

**好处：**
- 流量统计有问题 → 查这张表，还原每次拿到了什么数据、算出了多少增量
- 不再是"只有最终结果，不知道过程"
- 可以离线重算历史数据，修复过去的错误

**存储量控制：** 保留 30 天原始记录，超过 30 天只保留"状态变化"事件（数量极少）。分区表实现，不影响查询性能。

---

#### 改造 B：基于账期的重置检测

**要干什么：** 用 `cycle_start_date` 字段判断运营商是否重置了账期，替代现在靠"增量为负"来猜测的方式。

**为什么干：** 现在的猜测方式有漏检风险——如果轮询空窗期间运营商重置且新用量超过了旧读数，增量为正，系统无法检测到重置，漏计那段用量。

**好处：**
- 重置检测从"猜"变成"判断"，准确率 100%
- 彻底消除因轮询空窗导致的流量漏计
- 代码逻辑更简单，不再需要"重置窗口"时间判断

```go
// 改前：靠负增量猜测，有漏检风险
if increment < 0 && isRefreshResetWindow(now, resetDay) { ... }

// 改后：直接判断账期，准确无误
if prev.CycleStartDate != curr.CycleStartDate {
    increment = curr.ReadingMB  // 不同账期 = 运营商重置，当前读数即为增量
}
```

---

#### 改造 C：清理 IotCard 上的流量计算字段

**要干什么：** 将 `LastGatewayReadingMB`、`CurrentMonthStartDate`、`LastMonthTotalMB` 从 IotCard 表移除，这些信息改由 `carrier_readings` 表承载。

IotCard 只保留两个对外展示的汇总值：
- `CurrentMonthUsageMB`：展示用，由定时任务按自然月计算并主动更新（不再等轮询时内联检测）
- `DataUsageMB`：生命周期总量，由增量事件累加

**为什么干：** 现在 IotCard 同时承担了三个角色：卡的身份信息、流量计数器、流量计算中间状态。职责太多，一张表改动牵一发动全身。

**好处：**
- IotCard 回归本职：描述这张卡是谁、状态是什么
- 流量计算有了独立的数据层，不污染主体数据
- `CurrentMonthUsageMB` 在月初会被主动更新，不再有"1号凌晨数据是上月的"问题

---

## 三、改造方向（优先级二）：DDD 整体方向

> 这部分是更长期的方向，在优先级一完成后推进。

### 3.1 核心思想

**积木本身是安全的，拼积木组成业务流程。**

- **积木** = 领域对象（`IotCard`、`Order`、`Wallet`）：自己知道自己的规则，外部只能通过方法操作，不能随意改字段
- **拼积木** = 应用服务（用例）：只负责"拿数据 → 调对象方法 → 保存 → 发事件"，不含业务判断逻辑

**AI 辅助开发的约束价值：** 有了这个结构，AI 生成代码时只能往"一个用例一个文件"的模式里填，不会生成新的上帝 Service；业务规则在领域对象里，AI 调用对象方法时规则自动生效。

### 3.2 目标目录结构

```
internal/
├── domain/                    领域层（纯业务逻辑，不依赖任何框架）
│   ├── order/
│   │   ├── order.go           聚合根（Order 实体，有方法：Pay, Cancel, Reject）
│   │   ├── events.go          领域事件（OrderPaid, OrderCancelled）
│   │   ├── repository.go      仓储接口（interface，不含实现）
│   │   └── values.go          值对象（OrderStatus, Amount）
│   ├── asset/
│   │   ├── iot_card.go        IotCard 聚合根
│   │   └── repository.go
│   └── wallet/
│       └── wallet.go          Wallet 聚合根（Debit, Credit）
│
├── application/               应用层（用例，只做编排）
│   ├── order/
│   │   ├── create_order.go    一个文件一个用例
│   │   ├── pay_order.go
│   │   └── cancel_order.go
│   └── asset/
│
├── infrastructure/            基础设施层（实现接口）
│   ├── persistence/           原来的 store/postgres/
│   └── event/                 事件总线
│
└── interface/                 接口层
    ├── http/                  原来的 handler/ + routes/
    └── task/                  Asynq task handlers
```

### 3.3 迁移策略（绞杀者模式）

不全量重写，新旧并行，逐步替换：

1. 建好 `domain/`、`application/` 目录和规范文件
2. **所有新功能按新结构写**，不往旧 Service 里加代码
3. 最痛的模块优先迁移：`order`（3031 行）和 `iot_card`（2310 行）
4. 每次迁移一个用例，验证跑通后再迁移下一个

---

## 四、实施顺序

```
第一阶段（当前）
  ├── 新增 card_polling_state 表（轮询状态持久化）
  ├── 新增轮询状态查询接口 + 配置预览接口（可观测性）
  ├── 新增 carrier_readings 表（保留原始读数）
  ├── 改造增量计算逻辑（基于账期判断重置）
  └── 事件触发层（套餐购买 → 延迟检查）

第二阶段
  ├── 分级调度（按卡状态动态调整轮询频率）
  ├── 分离业务调度（套餐过期/流量重置从 Scheduler 独立）
  └── 清理 IotCard 流量计算字段

第三阶段（DDD 改造）
  ├── 建立目录骨架和规范示例
  ├── 新功能按新结构写
  └── 逐步迁移最痛的模块
```

---

## 五、预期收益

### 轮询 + 流量改造后

| 场景 | 改造前 | 改造后 |
|------|--------|--------|
| 业务反馈"某张卡没同步" | 翻日志，靠运气，可能花几十分钟 | 查接口，30 秒内给出答案 |
| 验证配置是否生效 | 完全黑盒，改完只能等和猜 | 配置预览接口，改完立即验证 |
| 流量统计不对 | 无法追溯，无从下手 | 查原始读数，还原每次增量计算 |
| 运营商重置漏计 | 有风险，轮询空窗时必现 | 账期判断，100% 准确 |
| 套餐购买后同步延迟 | 等下次定时轮询，最多几十分钟 | 5 分钟内主动检查 |
| 67 万卡迁移后轮询压力 | 全量扫描，压力线性增长 | 分级 + 事件触发，压力可控 |

### DDD 改造后

| 场景 | 改造前 | 改造后 |
|------|--------|--------|
| AI 辅助开发新功能 | 容易生成新的上帝 Service | 遵循用例模式，每次只加一个文件 |
| 新增业务联动逻辑 | 必须修改原有 Service 代码 | 新增事件监听器，原有代码不动 |
| 定位业务规则在哪里 | 散在几千行 Service 里找 | 在对应的领域对象方法里 |
| 排查某个流程的问题 | 跨多个 Service 追调用链 | 用例文件即流程，一目了然 |
