## 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 (无——所有关键决策已在讨论纪要中确认)