Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
7.1 KiB
Context
当前系统中 IoT 卡和设备的详情体系存在三大问题:
- 接口分散:Admin/H5 两端各自实现了停复机接口,逻辑重复且行为不一致
- 数据贫血:
IotCardDetailResponse是StandaloneIotCardResponse的空包装,DeviceResponse仅返回BoundCardCount一个数字,无法支撑前端渲染完整详情页 - 网关裸透传:
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
-
数据库迁移(先行):
- 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 计算)
- Migration 1:
-
代码变更:Model/Store/Service/Handler 按分层顺序依次实现
-
废弃接口删除:在新接口实现并验证后,删除旧停复机接口
-
回滚:数据库变更均可逆(改名可再改回,新增字段可删除);代码回滚通过 git revert
Open Questions
(无——所有关键决策已在讨论纪要中确认)