# 系统改造方向 > 本文档记录当前系统的核心问题、改造原因、改造方向和预期收益。 > 用于在开发过程中明确"干什么、为什么干、干的好处"。 --- ## 一、现状 ### 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 追调用链 | 用例文件即流程,一目了然 |