# 君鸿卡管系统资产详情体系重构 - 讨论纪要

> 创建时间：2026-03-12
> 最后更新：2026-03-14
> 当前阶段：设计讨论（尚未进入 openspec 提案）
> 目的：保留完整上下文，供未来继续

---

## 一、背景与需求来源

### 1.1 项目背景

君鸿卡管系统（junhong_cmp_fiber）是一个面向代理/企业的物联网卡管理平台，核心资产有两类：
- **IoT 卡（IotCard）**：纯卡资源，含 ICCID、MSISDN、流量套餐
- **设备（Device）**：带卡的硬件设备，一个设备可绑定多张卡，设备级套餐

### 1.2 需求触发点

核心痛点：
1. **接口分散且重复** - 卡和设备的查询散落在多处，H5/Admin/Personal 三端各有一套
2. **详情信息严重缺失** - 现有的详情接口返回数据太少，前端无法据此渲染完整页面
3. **网关裸数据透传** - 封装程度不够，没有业务层的聚合和处理
4. **虚拟号只存在于设备** - 卡的查询只能靠 ICCID/MSISDN，不方便

### 1.3 已确认的核心决策

- ✅ **多接口组合** - 不做单一聚合大接口，前端按需调用
- ✅ **统一入口** - 一个接口告诉前端查的是"卡"还是"设备"
- ✅ **设备优先查找** - 统一入口先查设备表，再查卡表
- ✅ **卡加虚拟号** - 虚拟号概念延伸到卡，与设备的 virtual_no 对等
- ✅ **全部一步到位** - 改造不分期，一次性完成
- ✅ **resolve 返回中等版本** - 包含资产类型、ID、虚拟号、状态、实名状态、套餐概况、流量使用、所属设备（如果绑定）等关键信息
- ✅ **资产类型只有卡和设备两种** - 未来路由器也归属设备，无需预留更多类型
- ✅ **虚拟号客服和客户都要用** - 不是只有内部人员用
- ✅ **H5 端接口暂时不需要提供** - 后续做到时再删除旧接口
- ✅ **套餐查询看历史记录** - 通过套餐记录/订单记录页面查看历史，同时提供当前套餐接口
- ✅ **手动刷新接口复用 SyncCardStatusFromGateway** - 无需重新实现，设备时批量刷新所有绑定卡
- ✅ **权限不足返回 403** - 明确告知无权限，不假装资产不存在
- ✅ **虚拟号人工填写/批量导入** - 无格式规范，允许修改，重复时全批失败并告知原因
- ✅ **device_no 字段全量改名为 virtual_no** - 数据库+代码全部更新，不保留旧字段
- ✅ **设备停复机有保护期机制** - 保护状态一致性，时长 1 小时，存储在 Redis
- ✅ **realtime-status 只查持久化数据** - 不调用网关，刷新用 refresh 接口
- ✅ **未实名的卡不参与停复机** - 未实名卡永远是停机状态，保护期逻辑跳过
- ✅ **企业账号 resolve 接口** - 企业账号暂不支持 resolve，未来单独开新接口
- ✅ **resolve 响应含卡 ICCID** - card 类型时在响应中返回 ICCID，供前端调用停复机接口
- ✅ **批量停机部分失败仍设保护期** - 部分卡停机失败时也设置 Redis 保护期，已停机的卡不回滚，失败的卡记录日志
- ✅ **流量汇总逻辑统一** - 整个系统使用统一的流量汇总逻辑；设备级套餐从 PackageUsage 汇总多卡用量
- ✅ **套餐历史列表规则** - 按创建时间倒序，不分页，包含所有状态（含已失效）
- ✅ **current-package 返回主套餐** - 多套餐同时生效时只返回主套餐（master_usage_id IS NULL）
- ✅ **轮询系统新增第四种任务** - 保护期一致性检查封装为独立轮询任务类型，不修改现有三种任务
- ✅ **卡虚拟号导入只补空白** - 只允许为现有空白虚拟号的卡填入，不支持覆盖更新；与数据库现存数据重复则全批失败
- ✅ **设备批量刷新需限频** - Redis 限频保护，同一设备冷却期内（建议 30 秒）不允许重复触发
- ✅ **PersonalCustomerDevice 统一改名** - tb_personal_customer_device 表的 device_no 字段一并改为 virtual_no
- ✅ **realtime-status 与 resolve 分工明确** - resolve 用于初始加载（含查找），realtime-status 用于已知 ID 的轻量状态轮询（不含套餐流量计算）

---

## 二、现有系统审计结果

### 2.1 接口现状（三端盘点）

| 端 | 卡接口数 | 设备接口数 | 重复停复机 | 套餐接口 |
|---|---------|-----------|-----------|---------|
| Admin | 9 | 14 | 3处 | 仅流量详单 |
| H5 | 4 | 7 | 1处 | 有套餐聚合 |
| Personal | 2 | 0 | 无 | 无 |

**重复停复机的三处实现：**
1. Admin 卡端：`POST /iot-cards/:iccid/suspend|resume`（按 ICCID）
2. Admin 企业卡端：`POST /enterprises/:id/cards/:card_id/suspend|resume`（按 card_id）
3. H5 企业设备端：`POST /h5/devices/:device_id/cards/:card_id/suspend|resume`（按 card_id）

### 2.2 DTO 缺失分析

#### 卡详情（IotCardDetailResponse）

```go
// 当前实现 (iot_card_dto.go:134-136)
type IotCardDetailResponse struct {
    Code int                      `json:"code"`
    Msg  string                   `json:"msg"`
    Data *StandaloneIotCardResponse `json:"data"` // 只是空壳嵌套！
}
```

**问题**：详情响应只是列表响应的空包装，完全没有额外信息。无套餐、无所属设备、无聚合流量。

#### 设备详情（DeviceResponse）

```go
// 当前实现 (device_dto.go:20)
type DeviceResponse struct {
    // ... 基本字段
    BoundCardCount int `json:"bound_card_count"` // 只有一个数字！
}
```

**问题**：只返回绑定卡数量，看不到每张卡的实名状态、卡状态、流量使用。

#### H5 端已有参考实现

`EnterpriseDeviceDetailResp`（enterprise_device_authorization_dto.go）是目前唯一有"设备+绑定卡列表"聚合的 DTO，可作为 admin 端改造的参考。

### 2.3 网关接口问题

**6 个网关查询接口全部是纯透传**：
- `gateway.GetCardStatus`
- `gateway.GetFlowUsage`
- `gateway.GetRealNameStatus`
- `gateway.GetDeviceInfo`
- `gateway.GetSlotInfo`
- `gateway.GetDeviceFlowUsage`

**问题**：只读不写，不更新 DB 缓存，无业务封装。

### 2.4 数据模型现状

| 模型 | 虚拟号 | 缓存字段 | 套餐载体 |
|-----|-------|---------|---------|
| IotCard | ❌ 无（需新增） | CurrentMonthUsageMB, NetworkStatus, RealNameStatus, LastDataCheckAt | IotCardID |
| Device | ✅ device_no（需改名为 virtual_no） | 无 | DeviceID |

**关键发现**：
- `PackageUsage` 模型已支持两种载体：`IotCardID`（单卡）和 `DeviceID`（设备级）
- `IotCard.IsStandalone` 字段由触发器维护，标识卡是否绑定到设备
- `DeviceStore.GetByIdentifier` 已实现多字段匹配：`WHERE device_no = ? OR imei = ? OR sn = ?`（改造后改为 virtual_no）

---

## 三、设计方向（已确认）

### 3.1 统一资产入口（resolve）

**接口**：`GET /api/admin/assets/resolve/:identifier`

**查找逻辑**：
```
1. 先查 device 表（virtual_no / imei / sn）
2. 未命中则查 iot_card 表（virtual_no / iccid / msisdn）
3. 应用数据权限过滤：代理只能看自己及下级店铺的资产，平台账号看所有
4. 有权限 → 返回资产数据（中等版本）
5. 无权限 → 返回 HTTP 403
6. 未找到 → 返回 HTTP 404
```

**响应结构（已确认）**：

```go
// AssetResolveResponse 资产解析响应
type AssetResolveResponse struct {
    // 基础信息
    AssetType string `json:"asset_type"`  // "device" 或 "card"
    AssetID   uint   `json:"asset_id"`    // 对应表的主键
    VirtualNo string `json:"virtual_no"`  // 统一虚拟号字段（设备/卡均用此字段）
    ICCID     string `json:"iccid,omitempty"` // 仅 card 类型时有值，供前端调用停复机接口使用

    // 状态信息
    Status         int `json:"status"`           // 资产状态
    RealNameStatus int `json:"real_name_status"`  // 实名状态

    // 套餐和流量信息（无套餐时返回空字符串/0）
    CurrentPackage   string  `json:"current_package"`    // 当前套餐名称
    PackageTotalMB   float64 `json:"package_total_mb"`   // 真总流量（套餐标称，RealDataMB）
    PackageVirtualMB float64 `json:"package_virtual_mb"` // 虚总流量（停机阈值，VirtualDataMB）
    PackageUsedMB    float64 `json:"package_used_mb"`    // 客户端展示已使用流量（经虚流量换算）
    PackageRemainMB  float64 `json:"package_remain_mb"`  // 客户端展示剩余流量

    // 保护期状态（设备类型，以及绑定该设备的卡均返回）
    DeviceProtectStatus string `json:"device_protect_status"` // "none" / "stop" / "start"

    // 绑定信息（仅 card 类型，且卡绑定了设备时才有值）
    BoundDeviceID   *uint  `json:"bound_device_id,omitempty"`
    BoundDeviceNo   string `json:"bound_device_no,omitempty"`
    BoundDeviceName string `json:"bound_device_name,omitempty"`

    // 设备类型特有：绑定卡信息
    BoundCardCount int              `json:"bound_card_count"`
    Cards          []DeviceCardInfo `json:"cards,omitempty"` // 包含所有状态的卡（含未实名）
}

// DeviceCardInfo 设备下绑定卡信息
type DeviceCardInfo struct {
    IotCardID           uint       `json:"iot_card_id"`
    ICCID               string     `json:"iccid"`
    VirtualNo           string     `json:"virtual_no"`
    RealNameStatus      int        `json:"real_name_status"`
    NetworkStatus       int        `json:"network_status"`
    CurrentMonthUsageMB float64    `json:"current_month_usage_mb"`
    LastSyncAt          *time.Time `json:"last_sync_at"` // 最后与 Gateway 同步时间
}
```

**说明**：
- 卡绑定的设备被软删除时，该卡视为独立卡，不填充绑定信息
- 设备下的 `cards` 列表包含所有绑定卡（含未实名、已停用）

### 3.2 套餐查询接口

**接口一**：`GET /api/admin/assets/:asset_type/:id/packages`
- 返回所有套餐记录（含历史和当前生效套餐）
- 按 asset_type 区分查 PackageUsage.IotCardID 还是 PackageUsage.DeviceID
- 每条记录包含：套餐名称、真总流量、虚总流量、展示已使用、展示剩余、有效期、状态
- **排序**：按创建时间倒序（最新套餐在前）
- **分页**：不分页，全量返回
- **范围**：包含所有状态（含 status=4 已失效的历史套餐）

**接口二**：`GET /api/admin/assets/:asset_type/:id/current-package`
- 返回当前生效的**主套餐**（status=1 且 master_usage_id IS NULL）的详细信息
- 当同时有主套餐 + 加油包生效时，只返回主套餐；需要查看加油包通过接口一的列表查看
- 包含完整流量明细：真总量、虚总量、展示已使用、展示剩余

### 3.3 实时状态查询接口

**接口**：`GET /api/admin/assets/:asset_type/:id/realtime-status`

**与 resolve 的定位分工**：

> **resolve**：初始加载使用，包含查找逻辑 + 全量聚合数据（套餐/流量/绑定信息），数据较重。
> **realtime-status**：已知资产 ID 后的轻量状态轮询，**不含套餐流量计算**，专注于网络/实名/保护期状态的快速刷新。

**说明**：
- **只查询持久化数据（DB/Redis），不调用网关**
- 返回最近一次轮询/刷新同步到系统的状态
- "实时性"依赖轮询系统保持数据新鲜（实名 5 分钟，流量/套餐 10 分钟）
- 需要最新数据时，先调用 refresh 接口手动刷新，再查此接口
- 设备类型返回：保护期状态 + 每张绑定卡的状态（网络/实名/流量/最后同步时间）
- 卡类型返回：网络状态 + 实名状态 + 流量使用 + 最后同步时间

### 3.4 手动刷新接口

**接口**：`POST /api/admin/assets/:asset_type/:id/refresh`

**说明**：
- 调用网关获取最新数据，写回 DB 更新缓存字段，返回刷新后的最新状态
- 卡类型：调用已有的 `SyncCardStatusFromGateway(iccid)` 方法
- 设备类型：批量刷新所有绑定卡（遍历调用 `SyncCardStatusFromGateway`）
- **设备类型需要频率限制**：通过 Redis 记录最后刷新时间，同一设备冷却期内（建议 30 秒）不允许重复触发，防止前端多次快速点击打爆网关

### 3.5 设备停复机保护期机制

**背景**：
设备本身没有停机/复机概念，对设备停机 = 批量停用其下所有已实名卡。保护期机制确保操作期间所有卡的状态一致性，防止单卡被误操作破坏整体状态。

**接口**：
- `POST /api/admin/assets/device/:device_id/stop`
- `POST /api/admin/assets/device/:device_id/start`

**保护期规则**：

| 规则 | 说明 |
|------|------|
| 保护期时长 | **1 小时**（硬编码在代码常量中） |
| 存储方式 | Redis Key `protect:device:{device_id}:stop` 或 `protect:device:{device_id}:start`，TTL=1小时 |
| 未实名的卡 | **不参与停复机操作**，未实名卡永远是停机状态，跳过不处理 |
| 重叠操作 | 设备在保护期内不允许再次发起相同或相反的停复机操作，返回 HTTP 403 |
| 批量停机部分失败 | 部分卡调网关失败时，**仍设置 Redis 保护期**；已成功停机的卡不回滚；失败的卡记录错误日志 |

**stop 保护期（设备停机后 1 小时内）**：
- 对某张已实名卡手动发起复机 → **不允许**（HTTP 403，设备处于停机保护期）
- 对某张已实名卡手动发起停机 → 允许（本已是停机，无冲突）
- 轮询系统发现某张已实名卡处于开机状态 → **强制调网关停机**，保持一致

**start 保护期（设备复机后 1 小时内）**：
- 对某张已实名卡手动发起停机 → **允许**（用户可主动停单张卡）
- 对某张已实名卡手动发起复机 → 允许（本已是复机，无冲突）
- 轮询系统发现某张已实名卡处于停机状态 → **强制调网关复机**，保持一致

**保护期状态对外暴露**：
- resolve 接口的 `device_protect_status` 字段返回当前保护期状态
- 卡绑定的设备有保护期时，该卡的 resolve 结果也返回 `device_protect_status`

### 3.6 接口去重（废弃清单）

**废弃接口**（直接删除，不保留向后兼容）：

| 废弃接口 | 替代接口 |
|---------|---------|
| `POST /enterprises/:id/cards/:card_id/suspend` | `POST /api/admin/assets/card/:iccid/stop` |
| `POST /enterprises/:id/cards/:card_id/resume` | `POST /api/admin/assets/card/:iccid/start` |
| `POST /h5/devices/:device_id/cards/:card_id/suspend` | `POST /api/admin/assets/device/:device_id/stop` |
| `POST /h5/devices/:device_id/cards/:card_id/resume` | `POST /api/admin/assets/device/:device_id/start` |
| 旧 Admin 卡停复机接口（按 ICCID） | `POST /api/admin/assets/card/:iccid/stop|start` |
| `GET /devices/:id` | `GET /api/admin/assets/device/:id` |

### 3.7 数据层变更

**变更一：设备表字段改名（全量重构）**
```sql
ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no;
ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no;
```
涉及改动范围：Model 定义、DTO 响应、Store 查询、所有引用 `device_no` 的代码，以及 `tb_personal_customer_device` 表的 `device_no` 字段（一并改名为 `virtual_no`），确保系统中不再有 `device_no` 的身影。

**变更二：卡表新增 virtual_no 字段**
```sql
ALTER TABLE tb_iot_card ADD COLUMN virtual_no VARCHAR(50);
CREATE UNIQUE INDEX idx_iot_card_virtual_no
    ON tb_iot_card (virtual_no) WHERE deleted_at IS NULL;
```
- 允许为空（老数据无虚拟号）
- 允许手动修改
- 全局唯一（导入时检测重复，重复则全批失败并告知具体冲突数据）

**变更三：套餐表新增 virtual_ratio 字段**
```sql
ALTER TABLE tb_package ADD COLUMN virtual_ratio DECIMAL(10,6) DEFAULT 1.0;
```
- 创建套餐时计算并存储：`virtual_ratio = real_data_mb / virtual_data_mb`
- 用于客户端展示的流量换算（见第六节）
- 未启用虚流量时（`enable_virtual_data=false`），virtual_ratio = 1.0

---

## 四、完整接口清单

| # | 方法 | 路径 | 说明 |
|---|------|------|------|
| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析（通过任意标识符） |
| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析（通过任意标识符） |
| 2 | GET | `/api/admin/assets/:asset_type/:id/packages` | 套餐记录（历史+当前） |
| 3 | GET | `/api/admin/assets/:asset_type/:id/current-package` | 当前生效主套餐详情 |
| 4 | GET | `/api/admin/assets/:asset_type/:id/realtime-status` | 当前持久化状态查询（轻量） |
| 5 | POST | `/api/admin/assets/:asset_type/:id/refresh` | 手动刷新（调网关写回 DB） |
| 6 | POST | `/api/admin/assets/device/:device_id/stop` | 设备停机（批量停所有已实名卡） |
| 7 | POST | `/api/admin/assets/device/:device_id/start` | 设备复机（批量开所有已实名卡） |
| 8 | POST | `/api/admin/assets/card/:iccid/stop` | 卡停机 |
| 9 | POST | `/api/admin/assets/card/:iccid/start` | 卡复机 |

> `:asset_type` 取值：`device` 或 `card`

---

## 五、流程图

### 5.1 资产查找（resolve）流程

```mermaid
flowchart TD
    A["GET /api/admin/assets/resolve/:identifier"] --> B{"查询设备表\nvirtual_no / imei / sn"}
    B -->|找到| C{"应用数据权限过滤\n代理:仅自己及下级店铺\n平台:所有资产"}
    B -->|未找到| D{"查询卡表\nvirtual_no / iccid / msisdn"}
    D -->|找到| C
    D -->|未找到| E["返回 HTTP 404\n资产不存在"]
    C -->|有权限| F["聚合资产数据\n基础信息 + 状态 + 套餐流量 + 保护期 + 绑定信息"]
    C -->|无权限| G["返回 HTTP 403\n无权限查看该资产"]
    F --> H["返回 AssetResolveResponse"]
```

### 5.2 设备停机/复机流程

```mermaid
flowchart TD
    subgraph 设备停机
        A1["POST /assets/device/:id/stop"] --> B1{"设备是否存在?"}
        B1 -->|否| C1["HTTP 404"]
        B1 -->|是| D1{"设备是否在保护期?"}
        D1 -->|是| E1["HTTP 403\n设备处于保护期，不允许操作"]
        D1 -->|否| F1["获取所有已实名下属卡"]
        F1 --> G1["批量调网关停机"]
        G1 --> H1["更新各卡 NetworkStatus=停机\n（部分失败时已成功的卡不回滚）"]
        H1 --> I1["Redis SET protect:device:id:stop\nTTL = 1 小时（部分失败时仍设置）"]
        I1 --> J1["返回成功（附带失败卡日志）"]
    end

    subgraph 设备复机
        A2["POST /assets/device/:id/start"] --> B2{"设备是否存在?"}
        B2 -->|否| C2["HTTP 404"]
        B2 -->|是| D2{"设备是否在保护期?"}
        D2 -->|是| E2["HTTP 403\n设备处于保护期，不允许操作"]
        D2 -->|否| F2["获取所有已实名下属卡"]
        F2 --> G2["批量调网关复机"]
        G2 --> H2["更新各卡 NetworkStatus=开机"]
        H2 --> I2["Redis SET protect:device:id:start\nTTL = 1 小时"]
        I2 --> J2["返回成功"]
    end
```

### 5.3 手动操作单卡 + 保护期检查

```mermaid
flowchart TD
    subgraph 手动停机单卡
        A1["POST /assets/card/:iccid/stop"] --> B1{"卡是否存在?"}
        B1 -->|否| C1["HTTP 404"]
        B1 -->|是| D1{"卡是否已实名?"}
        D1 -->|未实名| E1["HTTP 403\n未实名卡不允许停复机"]
        D1 -->|已实名| F1{"卡是否绑定设备?"}
        F1 -->|未绑定| G1["正常执行停机"]
        F1 -->|已绑定| H1{"设备有 start 保护期?"}
        H1 -->|是| I1["允许停机\n与 start 保护期方向一致"]
        H1 -->|否| G1
    end

    subgraph 手动复机单卡
        A2["POST /assets/card/:iccid/start"] --> B2{"卡是否存在?"}
        B2 -->|否| C2["HTTP 404"]
        B2 -->|是| D2{"卡是否已实名?"}
        D2 -->|未实名| E2["HTTP 403\n未实名卡不允许停复机"]
        D2 -->|已实名| F2{"卡是否绑定设备?"}
        F2 -->|未绑定| G2["正常执行复机"]
        F2 -->|已绑定| H2{"设备有 stop 保护期?"}
        H2 -->|是| I2["HTTP 403\n设备处于停机保护期\n不允许手动复机"]
        H2 -->|否| G2
    end
```

### 5.4 轮询系统与保护期交互

```mermaid
flowchart TD
    A["轮询任务触发：检查卡状态"] --> B{"卡是否已实名?"}
    B -->|未实名| C["跳过，未实名卡不参与停复机逻辑"]
    B -->|已实名| D{"卡是否绑定设备?"}
    D -->|未绑定| E["按卡自身逻辑正常处理"]
    D -->|已绑定| F{"设备是否有保护期?"}
    F -->|无保护期| E
    F -->|"stop 保护期"| G{"卡当前网络状态?"}
    G -->|开机| H["强制调网关停机\n保持与设备保护期一致"]
    G -->|停机| I["已一致，跳过"]
    F -->|"start 保护期"| J{"卡当前网络状态?"}
    J -->|停机| K["强制调网关复机\n保持与设备保护期一致"]
    J -->|开机| L["已一致，跳过"]
```

### 5.5 手动刷新（refresh）流程

```mermaid
flowchart TD
    A["POST /api/admin/assets/:type/:id/refresh"] --> B{"资产类型"}
    B -->|card| C["调用 SyncCardStatusFromGateway(iccid)"]
    C --> D["更新 iot_card 表\nNetworkStatus / RealNameStatus\nCurrentMonthUsageMB / LastSyncTime"]
    D --> H["返回刷新后的最新状态"]
    B -->|device| E["检查 Redis 限频（冷却期 30 秒）"]
    E -->|冷却中| Z["HTTP 429 请勿频繁刷新"]
    E -->|可刷新| F["查询所有绑定卡列表"]
    F --> G["遍历每张卡\n调用 SyncCardStatusFromGateway"]
    G --> H
```

### 5.6 实时状态查询（realtime-status）流程

```mermaid
flowchart TD
    A["GET /api/admin/assets/:type/:id/realtime-status"] --> B{"资产类型"}
    B -->|card| C["从 DB/Redis 读取持久化的卡状态"]
    C --> D["返回卡状态\n网络状态 / 实名状态 / 本月已用流量\n最后同步时间"]
    B -->|device| E["从 DB/Redis 读取持久化的设备数据"]
    E --> F["读取所有绑定卡的持久化状态"]
    F --> G["返回设备状态\n保护期状态 + 各绑定卡当前状态 + 最后同步时间"]
```

> **注意**：此接口**不调用网关**，展示的是最近一次轮询/刷新写入的持久化数据。
> 如需获取最新数据，请先调用 `POST /refresh` 接口，再查询此接口。

### 5.7 虚流量计算规则

```mermaid
flowchart TD
    subgraph 创建["套餐创建时 - 存储比例"]
        A1["RealDataMB = 10G 真总流量"] --> C1
        A2["VirtualDataMB = 9G 虚总流量/停机阈值"] --> C1
        C1["virtual_ratio = RealDataMB / VirtualDataMB\n= 10 / 9 ≈ 1.111\n存储到 tb_package.virtual_ratio"]
    end

    subgraph 停机["系统内部 - 停机判断"]
        D1["真已使用\nCurrentMonthUsageMB"] --> E1{"真已使用 >= VirtualDataMB?"}
        D2["VirtualDataMB = 9G"] --> E1
        E1 -->|是| F1["触发停机"]
        E1 -->|否| F2["正常运行"]
    end

    subgraph 展示["客户端展示 - 流量换算"]
        G1["真已使用 = 9G"] --> H1
        H1["展示已使用 = 真已使用 x virtual_ratio\n= 9G x 1.111 = 10G"]
        G2["展示总量 = RealDataMB = 10G"]
        H1 --> I1["客户看到 已用10G/共10G = 100% 已停机"]
    end
```

---

## 六、虚流量计算规则详解

### 6.1 概念说明

| 字段 | 含义 | 来源 |
|------|------|------|
| 真总流量（RealDataMB） | 套餐标称总流量，用户购买的名义流量 | `Package.real_data_mb` |
| 虚总流量（VirtualDataMB） | 停机阈值，始终小于真总流量 | `Package.virtual_data_mb` |
| virtual_ratio | 换算比例 = RealDataMB / VirtualDataMB | `Package.virtual_ratio`（套餐创建时存储） |
| 真已使用 | 网关报告的实际用量 | `IotCard.current_month_usage_mb` |
| 展示已使用 | 客户看到的用量 = 真已使用 × virtual_ratio | 计算得出 |
| 展示剩余 | 客户看到的剩余 = 真总流量 − 展示已使用 | 计算得出 |

### 6.2 设计意图

虚总流量（VirtualDataMB）是系统内部的停机保护阈值。由于网关数据同步存在延迟，若以真总流量作为停机阈值，客户可能在用完 10G 后继续用到 10.5G 才被停机，产生超用。因此系统设置一个比真总流量略小的虚总流量（如 9G）作为实际停机阈值，保证不超用。

客户端展示时，系统将真实用量按比例换算回真总流量的尺度，使客户的体感与购买的套餐一致：
- 当真用量达到 9G（VirtualDataMB）时，卡被停机
- 此时展示用量 = 9G × (10G/9G) = 10G，客户看到"已用 10G / 共 10G = 100%"

### 6.3 计算示例

| 场景 | 真总 | 虚总（停机阈值） | 真已使用 | 展示已使用 | 展示剩余 | 是否停机 |
|------|------|----------------|---------|-----------|---------|---------|
| 刚开始 | 10G | 9G | 0G | 0G | 10G | 否 |
| 用了一半 | 10G | 9G | 4.5G | 5G | 5G | 否 |
| 接近阈值 | 10G | 9G | 8G | ≈8.89G | ≈1.11G | 否 |
| 触发停机 | 10G | 9G | 9G | 10G | 0G | **是** |

### 6.4 未启用虚流量时

当 `Package.enable_virtual_data = false` 时：
- `virtual_ratio = 1.0`
- 停机阈值 = 真总流量（RealDataMB）
- 展示已使用 = 真已使用（无换算）

---

## 七、用户的思考与担忧（已全部解决）

### 7.1 关于接口粒度

**已确认**：resolve 返回中等版本，多接口组合，前端按需调用。

### 7.2 关于网关封装程度

**已确认**：
- realtime-status：只查持久化数据，不调用网关
- refresh：调用网关并写回 DB，更新缓存字段

### 7.3 关于停复机去重

**已确认**：所有停复机统一迁移到 assets 路径，旧接口直接删除。

### 7.4 关于虚拟号

**已确认**：
- 卡的虚拟号给客服和客户用
- 人工填写/批量导入，无格式规范，允许修改
- 设备 device_no 全量重命名为 virtual_no
- 导入重复时全批失败，告知具体冲突数据

### 7.5 关于套餐查询

**已确认**：套餐查询分两个接口，历史套餐接口包含当前套餐，同时单独提供当前套餐接口。

### 7.6 关于停复机保护期

**已确认**：保护期 1 小时，Redis 存储，未实名卡不参与，stop 保护期内禁止手动复机，start 保护期内允许手动停机。

---

## 八、设计决策确认清单

| 序号 | 问题 | 确认结果 |
|-----|------|---------|
| 1 | resolve 返回数据范围 | 中等版本，含状态/套餐/流量/绑定信息/保护期 |
| 2 | realtime-status 和 refresh 区别 | realtime-status=查持久化数据（轻量），refresh=调网关写回DB |
| 3 | 实时状态封装 | 持久化数据展示，不调网关 |
| 4 | 手动刷新复用 SyncCardStatusFromGateway | 是，设备时批量刷新所有绑定卡 |
| 5 | 停复机统一 | 统一迁移到 /assets 路径，旧接口直接删除 |
| 6 | 卡虚拟号生成方式 | 人工填写/批量导入，无格式规范 |
| 7 | 废弃接口处理 | 直接删除 |
| 8 | 套餐查询接口 | 两个接口：历史套餐列表 + 当前套餐详情 |
| 9 | 权限不足的返回 | HTTP 403，明确告知无权限 |
| 10 | 保护期时长 | 1 小时，硬编码常量 |
| 11 | 虚流量计算 | virtual_ratio=RealDataMB/VirtualDataMB，套餐创建时存储 |
| 12 | device_no 改名 | 全量改为 virtual_no，数据库+代码全部更新 |
| 13 | 设备下卡列表 | 包含所有状态的卡（含未实名、已停用） |
| 14 | 卡绑定设备被软删除时 | 视为独立卡，不填充绑定信息 |
| 15 | 未实名卡参与停复机 | 不参与，永远是停机状态，保护期跳过 |
| 16 | 数据权限规则 | 代理:仅自己及下级店铺，平台账号:所有资产 |
| 17 | 查找失败 404 还是 403 | 资产不存在=404，有资产但无权限=403 |
| 18 | 设备卡列表排序 | 无要求 |
| 19 | resolve 中 current_package 无套餐时 | 返回空字符串/0 |
| 20 | 虚拟号唯一索引 | 需要，允许为空，允许手动修改 |
| 21 | 企业账号能否用 resolve | 暂不支持；企业账号未来开新接口 |
| 22 | 接口 #2（按主键查详情）的设计 | 已确认删除，与 resolve 功能重叠，无独立价值 |
| 23 | resolve 响应是否含 ICCID | 是，card 类型时返回 ICCID，供停复机接口使用 |
| 24 | 设备批量停机部分失败策略 | 仍设置 Redis 保护期；已成功停机的卡不回滚；失败的卡记录日志 |
| 25 | 流量数据汇总逻辑 | 统一用专门汇总逻辑，从 PackageUsage 读取；设备级套餐汇总所有绑定卡 |
| 26 | 套餐历史列表排序和范围 | 按创建时间倒序，不分页，包含所有状态（含 status=4 已失效） |
| 27 | current-package 多套餐时返回哪个 | 返回主套餐（master_usage_id IS NULL） |
| 28 | 轮询系统保护期检查实现方式 | 新增独立的第四种轮询任务类型，不修改现有三种任务 |
| 29 | 卡虚拟号导入规则 | 只允许为空白虚拟号的卡填入；与现存数据重复则全批失败 |
| 30 | 设备批量刷新频率限制 | 需要；Redis 限频，同一设备冷却期（建议 30 秒）内不允许重复触发 |
| 31 | PersonalCustomerDevice.device_no 改名 | 是，统一改为 virtual_no，与 tb_device 保持语义一致 |
| 32 | DeviceCardInfo 需要 last_sync_time | 是，添加 last_sync_at 字段 |

---

## 九、轮询系统补充说明

### 9.1 整体架构

轮询系统是君鸿卡管系统维护卡数据实时性的核心机制：

```
┌─────────────────────────────────────────────────────────────────────┐
│                        Worker 服务（后台）                            │
├─────────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐       │
│  │ Scheduler    │────▶│ Asynq 队列   │────▶│ Handler      │       │
│  │ (调度器)     │     │ (任务队列)   │     │ (处理器)     │       │
│  └──────────────┘     └──────────────┘     └──────────────┘       │
│         │                                               │          │
│         │ 定时循环 (每秒)                                 │          │
│         ▼                                               ▼          │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │              Redis Sorted Set 轮询队列                       │   │
│  │  - polling:queue:realname  (实名检查)                      │   │
│  │  - polling:queue:carddata  (流量检查)                      │   │
│  │  - polling:queue:package   (套餐检查)                       │   │
│  │  - polling:queue:protect   (保护期一致性检查)               │   │
│  └──────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
                                │
                                │ 调用网关 API
                                ▼
                    ┌──────────────────────┐
                    │ Gateway 网关        │
                    │ (第三方运营商)      │
                    └──────────────────────┘
```

### 9.2 四种轮询任务

| 任务类型 | 触发频率 | 作用 | 更新字段 |
|---------|---------|------|---------|
| **实名检查** | 默认 5 分钟 | 调用网关查实名状态 | real_name_status |
| **流量检查** | 默认 10 分钟 | 调用网关查流量，更新套餐 | current_month_usage_mb |
| **套餐检查** | 默认 10 分钟 | 检查是否超额，触发停机 | network_status |
| **保护期检查** | 同流量检查频率 | 检查绑定设备保护期，强制同步卡的网络状态 | network_status |

> **第四种任务设计说明**：保护期一致性检查封装为独立任务类型，不嵌入现有三种任务内部。只检查"已绑定设备且设备当前有保护期"的卡，范围小，可与流量检查同频触发。

### 9.3 关键特点

1. **启动时渐进式初始化**：系统启动时把卡分批加载到 Redis 队列（每批 10 万张）
2. **按时间排序**：Redis Sorted Set 的 score 是下次检查的时间戳，到期自动被调度器取出
3. **并发控制**：通过 Redis 信号量限制并发数（默认 50），防止打爆网关
4. **失败重试**：任务失败后重新入队
5. **缓存优化**：优先从 Redis 读取卡信息，避免频繁查 DB

### 9.4 与手动刷新接口的关系

- **轮询是后台自动跑**：所有卡都会按配置的时间间隔被检查，保证日常数据更新
- **手动刷新是前台客服主动用**：只更新这一张卡（或设备的所有绑定卡），满足客户急用场景
- **两者是互补关系**：轮询保证数据不会太旧，手动刷新满足实时性要求高的场景

### 9.5 与设备保护期的交互

轮询系统在处理设备的绑定卡时，需要检查设备是否有保护期（见 5.4 流程图）：
- 发现设备有 stop 保护期，且卡为开机状态 → 强制调网关停机
- 发现设备有 start 保护期，且卡为停机状态 → 强制调网关复机
- 未实名的卡跳过，不参与保护期逻辑

关键代码位置：
- `internal/task/polling_handler.go` - 轮询任务处理器（需新增独立的第四种任务：保护期一致性检查处理函数）
- `pkg/constants/redis.go` - 需新增 `RedisDeviceProtectKey()` 函数

### 9.6 涉及的关键代码

- `internal/polling/scheduler.go` - 轮询调度器（把卡加入队列）
- `internal/task/polling_handler.go` - 任务处理器（实际调网关更新数据）
- `internal/service/iot_card/service.go:799` - SyncCardStatusFromGateway 方法

---

## 十、下一步行动

### 10.1 当前阶段

**设计讨论** - 已完成，所有关键决策已确认，可进入 openspec 提案阶段

### 10.2 进入 openspec 提案后的任务拆分建议

**数据层（优先）**：
1. 数据库迁移：设备表 `device_no` → `virtual_no`（同步更新 `tb_personal_customer_device.device_no` → `virtual_no`）
2. 数据库迁移：卡表新增 `virtual_no` 字段（唯一索引，允许空）
3. 数据库迁移：套餐表新增 `virtual_ratio` 字段
4. 更新 Device Model 和所有引用 `device_no` 的代码（全量替换，含 PersonalCustomerDevice）
5. 更新 Package Service，创建/更新套餐时自动计算并存储 `virtual_ratio`

**接口层（依次实现）**：
6. 实现资产入口 `GET /assets/resolve/:identifier`
7. 实现当前状态查询 `GET /assets/:type/:id/realtime-status`
8. 实现手动刷新 `POST /assets/:type/:id/refresh`（含设备批量刷新 + Redis 限频）
9. 实现套餐记录查询 `GET /assets/:type/:id/packages`
10. 实现当前套餐查询 `GET /assets/:type/:id/current-package`
11. 实现设备停机 `POST /assets/device/:id/stop`（含保护期逻辑 + 部分失败策略）
12. 实现设备复机 `POST /assets/device/:id/start`（含保护期逻辑）
13. 实现卡停机 `POST /assets/card/:iccid/stop`（含保护期检查）
14. 实现卡复机 `POST /assets/card/:iccid/start`（含保护期检查）

**轮询系统**：
15. 新增第四种轮询任务：保护期一致性检查（独立任务类型，不修改现有三种任务内部逻辑）

**清理**：
16. 删除废弃的停复机接口（见 3.6 废弃清单）
17. 丰富现有卡/设备 DTO（IotCardDetailResponse、DeviceResponse）
18. 更新 API 文档生成器（docs.go 和 gendocs/main.go）

### 10.3 涉及的关键代码文件

**Handler 层**：
- `internal/handler/admin/iot_card.go`
- `internal/handler/admin/device.go`
- `internal/handler/h5/enterprise_device.go`（待删除的废弃接口）

**Service 层**：
- `internal/service/iot_card/service.go`（含 SyncCardStatusFromGateway:799）
- `internal/service/iot_card/stop_resume_service.go`（停复机逻辑，需扩展）
- `internal/service/device/service.go`（含 GetByIdentifier:177）
- `internal/service/package/customer_view_service.go`（套餐聚合，需复用）
- `internal/service/package/service.go`（创建套餐时存储 virtual_ratio）

**Store 层**：
- `internal/store/postgres/device_store.go`（GetByIdentifier:62，改用 virtual_no）
- `internal/store/postgres/iot_card_store.go`
- `internal/store/postgres/personal_customer_device_store.go`（device_no → virtual_no）

**Model 层**：
- `internal/model/iot_card.go`（新增 virtual_no 字段）
- `internal/model/device.go`（device_no → virtual_no）
- `internal/model/package.go`（新增 virtual_ratio 字段）
- `internal/model/personal_customer_device.go`（device_no → virtual_no）

**DTO 层**：
- `internal/model/dto/iot_card_dto.go`（需重构）
- `internal/model/dto/device_dto.go`（需丰富）

**常量层**：
- `pkg/constants/redis.go`（新增 `RedisDeviceProtectKey()` 函数）

**轮询层**：
- `internal/task/polling_handler.go`（新增保护期一致性检查独立任务处理函数）

---

## 十一、附录：关键代码片段

### 11.1 现有空壳详情 DTO

```go
// internal/model/dto/iot_card_dto.go:134-136
type IotCardDetailResponse struct {
    StandaloneIotCardResponse // 只是列表响应的空包装
}
```

### 11.2 设备详情 DTO

```go
// internal/model/dto/device_dto.go:20
type DeviceResponse struct {
    ID          uint   `json:"id"`
    DeviceNo    string `json:"device_no"` // 改名为 virtual_no
    // ...
    BoundCardCount int `json:"bound_card_count"` // 只有数字，需丰富
}
```

### 11.3 设备多字段查找 Store

```go
// internal/store/postgres/device_store.go:62
// 改造后：device_no → virtual_no
func (s *Store) GetByIdentifier(db *gorm.DB, identifier string) (*model.Device, error) {
    var device model.Device
    err := db.Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier).
        First(&device).Error
    return &device, err
}
```

### 11.4 手动刷新方法（待暴露为接口）

```go
// internal/service/iot_card/service.go:799
func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
    // 已有实现，需作为接口暴露，并支持设备批量刷新
}
```

### 11.5 新增 Redis Key 常量

```go
// pkg/constants/redis.go
// RedisDeviceProtectKey 设备停复机保护期 Key
// action: "stop" 或 "start"，TTL = 1 小时
func RedisDeviceProtectKey(deviceID uint, action string) string {
    return fmt.Sprintf("protect:device:%d:%s", deviceID, action)
}

// RedisDeviceRefreshCooldownKey 设备手动刷新冷却期 Key，TTL = 冷却时长（建议 30 秒）
func RedisDeviceRefreshCooldownKey(deviceID uint) string {
    return fmt.Sprintf("refresh:cooldown:device:%d", deviceID)
}
```

### 11.6 virtual_ratio 计算位置

```go
// internal/service/package/service.go
// 创建/更新套餐时计算并存储 virtual_ratio
if pkg.EnableVirtualData && pkg.VirtualDataMB > 0 {
    pkg.VirtualRatio = float64(pkg.RealDataMB) / float64(pkg.VirtualDataMB)
} else {
    pkg.VirtualRatio = 1.0
}
```

---

> **文档结束**
>
> 所有设计决策已确认，可进入 openspec 提案阶段。
