Files
huang b9c3875c08
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
feat: 新增数据库迁移,重命名 device_no 为 virtual_no,新增 iot_card.virtual_no 和 package.virtual_ratio 字段
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-14 18:27:28 +08:00

7.1 KiB
Raw Permalink Blame History

Context

当前系统中 IoT 卡和设备的详情体系存在三大问题:

  1. 接口分散Admin/H5 两端各自实现了停复机接口,逻辑重复且行为不一致
  2. 数据贫血IotCardDetailResponseStandaloneIotCardResponse 的空包装,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 字段区分 选项 BGET /assets/card/:identifierGET /assets/device/:identifier 分开

选 A 的原因:虚拟号 identifier 在全局唯一时,前端无需预先知道是卡还是设备,一次请求拿到类型和 ID后续调用才按类型路由。这符合"统一入口"的核心设计目标。

查找顺序:先查 Devicevirtual_no / imei / sn未命中再查 IotCardvirtual_no / iccid / msisdn。设备优先因为设备标识符更具体。

决策 2resolve 返回中等聚合版本,而非最小或最大版本

resolve 包含:基础信息 + 状态 + 当前套餐流量概况 + 保护期状态 + 绑定信息(卡←→设备)。

不做最小版本(仅返回 asset_type + id前端页面需要立即展示套餐流量避免二次请求。 不做最大版本(返回全量历史套餐):套餐历史通过独立接口按需加载,避免 resolve 过重。

决策 3realtime-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。

决策 6RefreshCardDataFromGateway 增强现有方法而非新建

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 队列 keypolling:queue:protect与流量检查同频10 分钟)。

决策 10设备批量停机部分失败策略

部分卡调网关失败时:

  • 已成功停机的卡不回滚(回滚代价高且可能再次失败)
  • 仍设置 Redis 保护期(保护期从"发起操作"那一刻算起,而非"所有卡成功"后)
  • 失败卡记录 Error 日志,响应中携带失败列表

Risks / Trade-offs

[风险 1] device_no 全量改名影响范围广 → 缓解:先做数据库迁移,再用 lsp_rename 全量替换代码引用,最后运行编译检查确认无遗漏

[风险 2] resolve 接口的套餐流量计算可能超过 50ms 目标 → 缓解PackageUsage 查询已有 iot_card_iddevice_id 索引只查当前生效套餐status=1设备类型时只查 DeviceID不逐卡汇总

[风险 3] 保护期与轮询系统的竞争条件 → 缓解:轮询任务在处理卡状态前检查 Redis 保护期,保护期内强制按保护方向同步,不受卡自身状态影响

[风险 4] 设备批量刷新打爆网关 → 缓解Redis 限频(同一设备 30 秒冷却Handler 层返回 HTTP 429

[Trade-off] resolve 不支持企业账号 → 接受:企业账号的资产查询路径不同,未来单独开接口更合适,本次不过度设计

Migration Plan

  1. 数据库迁移(先行)

    • Migration 1tb_device.device_novirtual_notb_personal_customer_device.device_novirtual_no
    • Migration 2tb_iot_card 新增 virtual_no 字段 + 唯一部分索引
    • Migration 3tb_package 新增 virtual_ratio 字段,为现有数据回填(按 enable_virtual_data 计算)
  2. 代码变更Model/Store/Service/Handler 按分层顺序依次实现

  3. 废弃接口删除:在新接口实现并验证后,删除旧停复机接口

  4. 回滚:数据库变更均可逆(改名可再改回,新增字段可删除);代码回滚通过 git revert

Open Questions

(无——所有关键决策已在讨论纪要中确认)