# 君鸿卡管系统资产详情体系重构 - 讨论纪要 > 创建时间: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 提案阶段。