Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
39 KiB
君鸿卡管系统资产详情体系重构 - 讨论纪要
创建时间:2026-03-12 最后更新:2026-03-14 当前阶段:设计讨论(尚未进入 openspec 提案) 目的:保留完整上下文,供未来继续
一、背景与需求来源
1.1 项目背景
君鸿卡管系统(junhong_cmp_fiber)是一个面向代理/企业的物联网卡管理平台,核心资产有两类:
- IoT 卡(IotCard):纯卡资源,含 ICCID、MSISDN、流量套餐
- 设备(Device):带卡的硬件设备,一个设备可绑定多张卡,设备级套餐
1.2 需求触发点
核心痛点:
- 接口分散且重复 - 卡和设备的查询散落在多处,H5/Admin/Personal 三端各有一套
- 详情信息严重缺失 - 现有的详情接口返回数据太少,前端无法据此渲染完整页面
- 网关裸数据透传 - 封装程度不够,没有业务层的聚合和处理
- 虚拟号只存在于设备 - 卡的查询只能靠 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 | 无 | 无 |
重复停复机的三处实现:
- Admin 卡端:
POST /iot-cards/:iccid/suspend|resume(按 ICCID) - Admin 企业卡端:
POST /enterprises/:id/cards/:card_id/suspend|resume(按 card_id) - H5 企业设备端:
POST /h5/devices/:device_id/cards/:card_id/suspend|resume(按 card_id)
2.2 DTO 缺失分析
卡详情(IotCardDetailResponse)
// 当前实现 (iot_card_dto.go:134-136)
type IotCardDetailResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *StandaloneIotCardResponse `json:"data"` // 只是空壳嵌套!
}
问题:详情响应只是列表响应的空包装,完全没有额外信息。无套餐、无所属设备、无聚合流量。
设备详情(DeviceResponse)
// 当前实现 (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.GetCardStatusgateway.GetFlowUsagegateway.GetRealNameStatusgateway.GetDeviceInfogateway.GetSlotInfogateway.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
响应结构(已确认):
// 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/stopPOST /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 |
GET /devices/:id |
GET /api/admin/assets/device/:id |
3.7 数据层变更
变更一:设备表字段改名(全量重构)
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 字段
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 字段
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)流程
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 设备停机/复机流程
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 手动操作单卡 + 保护期检查
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 轮询系统与保护期交互
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)流程
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)流程
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 虚流量计算规则
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 关键特点
- 启动时渐进式初始化:系统启动时把卡分批加载到 Redis 队列(每批 10 万张)
- 按时间排序:Redis Sorted Set 的 score 是下次检查的时间戳,到期自动被调度器取出
- 并发控制:通过 Redis 信号量限制并发数(默认 50),防止打爆网关
- 失败重试:任务失败后重新入队
- 缓存优化:优先从 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 提案后的任务拆分建议
数据层(优先):
- 数据库迁移:设备表
device_no→virtual_no(同步更新tb_personal_customer_device.device_no→virtual_no) - 数据库迁移:卡表新增
virtual_no字段(唯一索引,允许空) - 数据库迁移:套餐表新增
virtual_ratio字段 - 更新 Device Model 和所有引用
device_no的代码(全量替换,含 PersonalCustomerDevice) - 更新 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.gointernal/handler/admin/device.gointernal/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.gointernal/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
// internal/model/dto/iot_card_dto.go:134-136
type IotCardDetailResponse struct {
StandaloneIotCardResponse // 只是列表响应的空包装
}
11.2 设备详情 DTO
// 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
// 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 手动刷新方法(待暴露为接口)
// internal/service/iot_card/service.go:799
func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
// 已有实现,需作为接口暴露,并支持设备批量刷新
}
11.5 新增 Redis Key 常量
// 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 计算位置
// 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 提案阶段。