需要改造的地方
This commit is contained in:
343
docs/改造方向.md
Normal file
343
docs/改造方向.md
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
# 系统改造方向
|
||||||
|
|
||||||
|
> 本文档记录当前系统的核心问题、改造原因、改造方向和预期收益。
|
||||||
|
> 用于在开发过程中明确"干什么、为什么干、干的好处"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状
|
||||||
|
|
||||||
|
### 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 追调用链 | 用例文件即流程,一目了然 |
|
||||||
Reference in New Issue
Block a user