Compare commits
2 Commits
b5b7f9a41e
...
76f657c986
| Author | SHA1 | Date | |
|---|---|---|---|
| 76f657c986 | |||
| a2793f7bec |
@@ -346,6 +346,19 @@ func startPollingScheduler(
|
||||
appLogger,
|
||||
)
|
||||
|
||||
// 注入套餐失效前流量同步器:最小化 iot_card.Service(只需 gateway + store + redis + 流量扣减)
|
||||
trafficSyncer := iot_card_svc.New(
|
||||
runtime.db,
|
||||
runtime.pollingIotCardStore,
|
||||
nil, nil, nil, nil, nil,
|
||||
runtime.gatewayClient,
|
||||
appLogger,
|
||||
nil,
|
||||
)
|
||||
trafficSyncer.SetRedisClient(runtime.redisClient)
|
||||
trafficSyncer.SetDataDeductor(runtime.workerResult.Services.UsageService)
|
||||
activationHandler.SetTrafficSyncer(trafficSyncer)
|
||||
|
||||
pollingScheduler := polling.NewScheduler(
|
||||
runtime.redisClient,
|
||||
runtime.asynqClient,
|
||||
|
||||
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 追调用链 | 用例文件即流程,一目了然 |
|
||||
@@ -17,6 +17,12 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// TrafficSyncer 套餐失效前流量同步接口
|
||||
// 在主套餐标记为已过期之前,从 Gateway 拉取最新流量写入 DB
|
||||
type TrafficSyncer interface {
|
||||
RefreshCardDataByID(ctx context.Context, cardID uint) error
|
||||
}
|
||||
|
||||
// PackageActivationHandler 套餐激活检查处理器
|
||||
// 任务 19: 处理主套餐过期、加油包级联失效、待生效主套餐激活
|
||||
type PackageActivationHandler struct {
|
||||
@@ -27,6 +33,7 @@ type PackageActivationHandler struct {
|
||||
deviceSimBinding *postgres.DeviceSimBindingStore
|
||||
activationService *packagepkg.ActivationService
|
||||
stopResumeCallback packagepkg.StopResumeCallback // 停复机回调,用于套餐过期后主动停机
|
||||
trafficSyncer TrafficSyncer // 套餐失效前流量同步,可选
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -60,6 +67,48 @@ func NewPackageActivationHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// SetTrafficSyncer 注入套餐失效前流量同步器(在 Start 前调用)
|
||||
func (h *PackageActivationHandler) SetTrafficSyncer(syncer TrafficSyncer) {
|
||||
h.trafficSyncer = syncer
|
||||
}
|
||||
|
||||
// syncTrafficBeforeExpiry 套餐失效前从 Gateway 同步一次最新流量
|
||||
// iot_card 直接同步;device 遍历所有绑定卡同步
|
||||
// 失败只记录警告,不阻断失效流程
|
||||
func (h *PackageActivationHandler) syncTrafficBeforeExpiry(ctx context.Context, carrierType string, carrierID uint) {
|
||||
if h.trafficSyncer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if carrierType == constants.AssetTypeIotCard {
|
||||
if err := h.trafficSyncer.RefreshCardDataByID(ctx, carrierID); err != nil {
|
||||
h.logger.Warn("套餐失效前流量同步失败",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if carrierType == constants.AssetTypeDevice {
|
||||
bindings, err := h.deviceSimBinding.ListByDeviceID(ctx, carrierID)
|
||||
if err != nil {
|
||||
h.logger.Warn("套餐失效前查询设备绑定卡失败",
|
||||
zap.Uint("device_id", carrierID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, b := range bindings {
|
||||
if err := h.trafficSyncer.RefreshCardDataByID(ctx, b.IotCardID); err != nil {
|
||||
h.logger.Warn("套餐失效前流量同步失败(设备绑定卡)",
|
||||
zap.Uint("device_id", carrierID),
|
||||
zap.Uint("card_id", b.IotCardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePackageActivationCheck 任务 19.2-19.5: 处理套餐激活检查
|
||||
// 每 10 秒调度一次,检查过期主套餐并激活下一个待生效主套餐
|
||||
func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Context) error {
|
||||
@@ -145,20 +194,21 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查该载体是否已有生效主套餐
|
||||
// 检查该载体是否已有占位主套餐(生效中或已用完均视为占位,不允许激活待生效套餐)
|
||||
var activeCount int64
|
||||
var countErr error
|
||||
occupiedStatuses := []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}
|
||||
if key.carrierType == "iot_card" {
|
||||
countErr = h.db.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("status IN ?", occupiedStatuses).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where("iot_card_id = ?", key.carrierID).
|
||||
Count(&activeCount).Error
|
||||
} else {
|
||||
countErr = h.db.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("status IN ?", occupiedStatuses).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where("device_id = ?", key.carrierID).
|
||||
Count(&activeCount).Error
|
||||
@@ -171,7 +221,7 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
|
||||
continue
|
||||
}
|
||||
|
||||
// 已有生效套餐,跳过
|
||||
// 已有生效或已用完(占位)套餐,跳过
|
||||
if activeCount > 0 {
|
||||
continue
|
||||
}
|
||||
@@ -219,10 +269,15 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
|
||||
}
|
||||
|
||||
// processExpiredPackage 处理单个过期套餐
|
||||
// triggerStopAfterExpiry 在事务提交后才调用,避免 goroutine 在 TX 提交前读到脏数据
|
||||
// 流程:先同步最新流量 → 事务内标记过期/失效/激活下一个 → 事务提交后触发停机
|
||||
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
|
||||
carrierType, carrierID := h.getCarrierInfo(pkg)
|
||||
|
||||
// 失效前先从 Gateway 同步一次最新流量,确保 data_usage_mb 精确
|
||||
if carrierType != "" && carrierID > 0 {
|
||||
h.syncTrafficBeforeExpiry(ctx, carrierType, carrierID)
|
||||
}
|
||||
|
||||
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 任务 19.3: 更新过期主套餐状态为 Expired (status=3)
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
|
||||
@@ -1494,6 +1494,19 @@ func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeries
|
||||
return items
|
||||
}
|
||||
|
||||
// RefreshCardDataByID 通过卡 ID 从 Gateway 同步卡数据
|
||||
// 内部查询 ICCID 后委托给 RefreshCardDataFromGateway,供套餐失效前流量同步使用
|
||||
func (s *Service) RefreshCardDataByID(ctx context.Context, cardID uint) error {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.RefreshCardDataFromGateway(ctx, card.ICCID)
|
||||
}
|
||||
|
||||
// RefreshCardDataFromGateway 从 Gateway 完整同步卡数据
|
||||
// 调用网关查询网络状态、实名状态、本月流量,并写回数据库
|
||||
// 流量采用增量计算(与轮询逻辑一致):increment = 当前网关读数 - 上次网关读数
|
||||
|
||||
@@ -92,19 +92,20 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
|
||||
|
||||
// 检查是否是主套餐
|
||||
if usage.MasterUsageID == nil {
|
||||
// 主套餐:需要检查是否有已激活的主套餐
|
||||
// 主套餐:需要检查是否有已激活或已用完(仍在有效期)的主套餐
|
||||
var activeMain model.PackageUsage
|
||||
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
|
||||
err := tx.Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where(carrierType+"_id = ?", carrierID).
|
||||
Order("priority ASC").
|
||||
First(&activeMain).Error
|
||||
|
||||
if err == nil {
|
||||
// 已有激活的主套餐,保持排队状态
|
||||
s.logger.Warn("已有激活主套餐,跳过激活",
|
||||
// 已有生效中或已用完(占位)的主套餐,保持排队状态
|
||||
s.logger.Warn("已有占位主套餐,跳过激活",
|
||||
zap.Uint("usage_id", usage.ID),
|
||||
zap.Uint("active_main_id", activeMain.ID))
|
||||
zap.Uint("active_main_id", activeMain.ID),
|
||||
zap.Int("active_main_status", activeMain.Status))
|
||||
continue
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
@@ -500,9 +501,10 @@ func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *g
|
||||
|
||||
func (s *ActivationService) hasActiveMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
|
||||
var count int64
|
||||
// 生效中(1)和已用完(2)都属于"占位"状态:已用完的套餐仍在有效期内,不允许提前激活下一个主套餐
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where(carrierType+"_id = ?", carrierID).
|
||||
Count(&count).Error; err != nil {
|
||||
|
||||
Reference in New Issue
Block a user