diff --git a/docs/改造方向.md b/docs/改造方向.md new file mode 100644 index 0000000..8244996 --- /dev/null +++ b/docs/改造方向.md @@ -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 追调用链 | 用例文件即流程,一目了然 |