feat: 新增数据库迁移,重命名 device_no 为 virtual_no,新增 iot_card.virtual_no 和 package.virtual_ratio 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
## Context
|
||||
|
||||
当前系统中 IoT 卡和设备的详情体系存在三大问题:
|
||||
|
||||
1. **接口分散**:Admin/H5 两端各自实现了停复机接口,逻辑重复且行为不一致
|
||||
2. **数据贫血**:`IotCardDetailResponse` 是 `StandaloneIotCardResponse` 的空包装,`DeviceResponse` 仅返回 `BoundCardCount` 一个数字,无法支撑前端渲染完整详情页
|
||||
3. **网关裸透传**:`SyncCardStatusFromGateway`(改名后为 `RefreshCardDataFromGateway`)仅为示例方法,轮询系统的同步逻辑分散在 polling_handler.go 中,无统一入口
|
||||
|
||||
本次重构在 Admin 端建立统一的资产详情体系,含资产解析入口、状态查询、手动刷新、套餐查询、停复机接口,并完成数据模型的字段补全和改名。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 建立 `GET /api/admin/assets/resolve/:identifier` 作为唯一的资产查找入口
|
||||
- 卡和设备的停复机接口统一归入 `/api/admin/assets/` 路径,删除旧重复接口
|
||||
- 设备停复机引入 1 小时保护期机制,通过 Redis 存储保护期状态
|
||||
- IotCard 模型新增 `virtual_no` 字段,Device 模型 `device_no` 全量改名为 `virtual_no`
|
||||
- Package 模型新增 `virtual_ratio` 字段,套餐创建时自动计算
|
||||
- 轮询系统新增保护期一致性检查作为第四种独立任务类型
|
||||
|
||||
**Non-Goals:**
|
||||
- H5 端接口不在本次改造范围,旧 H5 接口保留(待后续单独处理)
|
||||
- 企业账号不支持 resolve 接口(未来单独开新接口)
|
||||
- 不引入新的外部依赖,不改变现有轮询系统的前三种任务类型内部逻辑
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:统一资产入口而非按类型分开
|
||||
|
||||
**选项 A(采用)**:单一入口 `GET /assets/resolve/:identifier`,返回响应中含 `asset_type` 字段区分
|
||||
**选项 B**:`GET /assets/card/:identifier` 和 `GET /assets/device/:identifier` 分开
|
||||
|
||||
选 A 的原因:虚拟号 identifier 在全局唯一时,前端无需预先知道是卡还是设备,一次请求拿到类型和 ID,后续调用才按类型路由。这符合"统一入口"的核心设计目标。
|
||||
|
||||
**查找顺序**:先查 Device(virtual_no / imei / sn),未命中再查 IotCard(virtual_no / iccid / msisdn)。设备优先因为设备标识符更具体。
|
||||
|
||||
### 决策 2:resolve 返回中等聚合版本,而非最小或最大版本
|
||||
|
||||
resolve 包含:基础信息 + 状态 + 当前套餐流量概况 + 保护期状态 + 绑定信息(卡←→设备)。
|
||||
|
||||
不做最小版本(仅返回 asset_type + id):前端页面需要立即展示套餐流量,避免二次请求。
|
||||
不做最大版本(返回全量历史套餐):套餐历史通过独立接口按需加载,避免 resolve 过重。
|
||||
|
||||
### 决策 3:realtime-status 不调网关
|
||||
|
||||
realtime-status 只读 DB/Redis 中已持久化的数据,"实时性"由轮询系统(5-10 分钟刷新一次)保证。需要最新数据时,先调 refresh 接口,再调此接口。
|
||||
|
||||
这个分工避免 realtime-status 因网关延迟而超时,保证轻量轮询性能。
|
||||
|
||||
### 决策 4:设备保护期存储在 Redis,而非数据库
|
||||
|
||||
保护期是临时状态,1 小时 TTL 后自然过期,无需持久化。Redis Key 格式:
|
||||
```
|
||||
protect:device:{device_id}:stop // 停机保护期
|
||||
protect:device:{device_id}:start // 复机保护期
|
||||
```
|
||||
这两个 key 互斥(发起 stop 时删除 start key,反之亦然)。
|
||||
|
||||
### 决策 5:新建 AssetService 而非扩展现有 Service
|
||||
|
||||
resolve 接口需要跨越 Device 和 IotCard 两张表,流量聚合逻辑来自 PackageUsage,保护期来自 Redis。
|
||||
如果分散在现有 Service 中会造成跨模块依赖混乱。新建 `internal/service/asset/service.go` 依赖注入 DeviceStore、IotCardStore、PackageUsageStore 和 Redis。
|
||||
|
||||
### 决策 6:RefreshCardDataFromGateway 增强现有方法而非新建
|
||||
|
||||
原 `SyncCardStatusFromGateway` 是示例实现,改名并增强为完整同步(NetworkStatus、RealNameStatus、CurrentMonthUsageMB、LastSyncTime)。轮询系统的 polling_handler.go 已有完整的同步逻辑,增强时参考该实现。
|
||||
|
||||
### 决策 7:卡 ICCID 导入新增可选 virtual_no 列
|
||||
|
||||
在现有导入模板中增加第 N+1 列 `virtual_no`(可选),导入时按规则处理:
|
||||
- 该行 virtual_no 不为空 + 数据库当前值为空 → 填入
|
||||
- 该行 virtual_no 不为空 + 数据库当前值不为空 → 跳过(不覆盖)
|
||||
- 批次中有任意 virtual_no 与数据库现存值重复(其他卡) → 整批失败,返回冲突列表
|
||||
|
||||
### 决策 8:虚流量比例 virtual_ratio 在套餐创建时计算并存储
|
||||
|
||||
不在查询时实时计算,原因:套餐参数确定后比例不变,存储避免每次查询重复除法,且支持未来通过 SQL 直接用于统计。
|
||||
```
|
||||
enable_virtual_data = true → virtual_ratio = real_data_mb / virtual_data_mb
|
||||
enable_virtual_data = false → virtual_ratio = 1.0
|
||||
```
|
||||
|
||||
### 决策 9:保护期一致性检查作为独立第四种轮询任务
|
||||
|
||||
不嵌入现有三种任务(实名/流量/套餐)中,原因:保护期检查的触发条件(设备有保护期)和处理逻辑完全不同,嵌入会破坏现有任务的单一职责。独立任务类型,Redis 队列 key:`polling:queue:protect`,与流量检查同频(10 分钟)。
|
||||
|
||||
### 决策 10:设备批量停机部分失败策略
|
||||
|
||||
部分卡调网关失败时:
|
||||
- 已成功停机的卡**不回滚**(回滚代价高且可能再次失败)
|
||||
- **仍设置** Redis 保护期(保护期从"发起操作"那一刻算起,而非"所有卡成功"后)
|
||||
- 失败卡记录 Error 日志,响应中携带失败列表
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[风险 1] device_no 全量改名影响范围广** → 缓解:先做数据库迁移,再用 `lsp_rename` 全量替换代码引用,最后运行编译检查确认无遗漏
|
||||
|
||||
**[风险 2] resolve 接口的套餐流量计算可能超过 50ms 目标** → 缓解:PackageUsage 查询已有 `iot_card_id` 和 `device_id` 索引,只查当前生效套餐(status=1);设备类型时只查 DeviceID,不逐卡汇总
|
||||
|
||||
**[风险 3] 保护期与轮询系统的竞争条件** → 缓解:轮询任务在处理卡状态前检查 Redis 保护期,保护期内强制按保护方向同步,不受卡自身状态影响
|
||||
|
||||
**[风险 4] 设备批量刷新打爆网关** → 缓解:Redis 限频(同一设备 30 秒冷却),Handler 层返回 HTTP 429
|
||||
|
||||
**[Trade-off] resolve 不支持企业账号** → 接受:企业账号的资产查询路径不同,未来单独开接口更合适,本次不过度设计
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **数据库迁移(先行)**:
|
||||
- Migration 1:`tb_device.device_no` → `virtual_no`,`tb_personal_customer_device.device_no` → `virtual_no`
|
||||
- Migration 2:`tb_iot_card` 新增 `virtual_no` 字段 + 唯一部分索引
|
||||
- Migration 3:`tb_package` 新增 `virtual_ratio` 字段,为现有数据回填(按 enable_virtual_data 计算)
|
||||
|
||||
2. **代码变更**:Model/Store/Service/Handler 按分层顺序依次实现
|
||||
|
||||
3. **废弃接口删除**:在新接口实现并验证后,删除旧停复机接口
|
||||
|
||||
4. **回滚**:数据库变更均可逆(改名可再改回,新增字段可删除);代码回滚通过 git revert
|
||||
|
||||
## Open Questions
|
||||
|
||||
(无——所有关键决策已在讨论纪要中确认)
|
||||
Reference in New Issue
Block a user