## 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

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