# asset-resolve Specification ## Purpose 提供统一的资产解析入口,通过任意标识符(虚拟号/ICCID/IMEI/SN/MSISDN)定位卡或设备,并返回该资产的中等聚合信息,包含套餐流量、保护期状态和绑定关系。 ## Requirements ### Requirement: 统一资产解析入口 系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。 **API 端点**: `GET /api/admin/assets/resolve/:identifier` **查找顺序(更新后)**: 1. **主路径**:查 `tb_asset_identifier` WHERE identifier = ? → 命中则得到 asset_type + asset_id,直接查对应表取完整记录 2. **Fallback 路径**(注册表未命中时): - 先查 `tb_device`(匹配 `virtual_no = ? OR imei = ? OR sn = ?`) - 未命中则查 `tb_iot_card`(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`) 3. 两条路径均未命中 → 返回 HTTP 404 4. 找到后应用数据权限过滤,无权限 → 返回 HTTP 403 **数据权限规则**: - 代理用户:只能查看 `shop_id` 在自己及下级店铺范围内的资产 - 平台用户(SuperAdmin/Platform):可查看所有资产 - 企业账号:暂不支持此接口,调用时返回 HTTP 403 **响应结构(AssetResolveResponse)**: *通用字段(device 和 card 均有)*: - `asset_type`: 资产类型(`"device"` 或 `"card"`) - `asset_id`: 资产主键 ID - `identifier`: 本次查询所用的标识符(原样回传) - `virtual_no`: 虚拟号(设备/卡均使用此字段) - `status`: 资产状态(整型) - `asset_status`: 业务状态(1-在库 2-已销售 3-已换货 4-已停用) - `generation`: 资产世代编号 - `batch_no`: 批次号 - `shop_id`: 所属店铺 ID(平台库存时为空) - `shop_name`: 所属店铺名称 - `series_id`: 套餐系列 ID(未绑定时为空) - `series_name`: 套餐系列名称 - `first_commission_paid`: 一次性佣金是否已发放 - `accumulated_recharge`: 累计充值金额(分) - `activated_at`: 激活时间(未激活时为空) - `created_at`: 创建时间 - `updated_at`: 更新时间 - `real_name_at`(`*time.Time`,可为 null):最近一次完成实名的时间 - **card 类型**:直接取 `IotCard.first_realname_at`(未实名过则为 null) - **device 类型**:当前所有绑定卡 `first_realname_at` 中的最小值(无已实名卡则为 null) *状态与套餐字段(device 和 card 均有)*: - `real_name_status`: 实名状态(整型) - `current_package`: 当前套餐名称(无套餐时返回空字符串) - `package_total_mb`: 真总流量,即 RealDataMB(无套餐时返回 0) - `package_virtual_mb`: 虚总流量/停机阈值,即 VirtualDataMB(无套餐时返回 0) - `package_used_mb`: 客户端展示已使用流量(经虚流量换算,见流量计算规则) - `package_remain_mb`: 客户端展示剩余流量 - `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`);card 类型时若绑定的设备有保护期也返回该设备的保护期状态 *绑定关系字段*: - `iccid`: 仅 card 类型时有值,供前端调用停复机接口使用 - `bound_device_id`: 仅 card 类型且卡绑定了设备时有值 - `bound_device_no`: 绑定设备的虚拟号 - `bound_device_name`: 绑定设备的名称 - `bound_card_count`: 仅 device 类型时有值,绑定卡的总数量 - `cards`: 仅 device 类型时有值,所有绑定卡列表(含未实名、已停用) *设备专属档案字段(asset_type=device 时有值,card 类型时为空/零值)*: - `device_name`: 设备名称 - `imei`: IMEI - `sn`: 序列号 - `device_model`: 设备型号 - `device_type`: 设备类型 - `max_sim_slots`: 最大插槽数 - `manufacturer`: 制造商 *卡专属档案字段(asset_type=card 时有值,device 类型时为空/零值)*: - `carrier_id`: 运营商 ID - `carrier_type`: 运营商类型(CMCC/CUCC/CTCC/CBN) - `carrier_name`: 运营商名称 - `msisdn`: 卡接入号 - `imsi`: IMSI - `card_category`: 卡业务类型(normal/industry) - `supplier`: 供应商 - `activation_status`: 激活状态(0-未激活 1-已激活) - `enable_polling`: 是否参与轮询 **DeviceCardInfo 结构**: - `iot_card_id`: 卡 ID - `iccid`: ICCID - `virtual_no`: 卡的虚拟号 - `real_name_status`: 实名状态 - `network_status`: 网络状态 - `current_month_usage_mb`: 本月已用流量(来自持久化缓存字段) - `last_sync_at`: 最后与 Gateway 同步时间 - `real_name_at`(`*time.Time`,可为 null):该卡最近一次完成实名的时间,取 `IotCard.first_realname_at` **流量展示计算规则**: - `package_used_mb = current_month_usage_mb × virtual_ratio` - `package_remain_mb = package_total_mb - package_used_mb` - 当 `enable_virtual_data = false` 时,`virtual_ratio = 1.0`(无换算) - 设备级套餐:`current_month_usage_mb` 为所有绑定卡本月用量之和 **特殊情况处理**: - 卡绑定的设备已被软删除:视为独立卡,不填充绑定信息 - `cards` 列表包含所有状态的绑定卡,不过滤未实名或已停用的卡 #### Scenario: 通过 ICCID 找到卡 - **WHEN** 管理员调用 `GET /api/admin/assets/resolve/89860123456789012345`,ICCID 匹配到一张独立卡 - **THEN** 系统返回 `asset_type="card"`,包含该卡的虚拟号、状态、套餐流量信息,`bound_device_id` 为空 #### Scenario: 通过虚拟号找到设备 - **WHEN** 管理员调用 `GET /api/admin/assets/resolve/GPS-001`,设备表中 `virtual_no = "GPS-001"` 存在 - **THEN** 系统返回 `asset_type="device"`,包含该设备的绑定卡列表(DeviceCardInfo 数组),`bound_card_count` 为绑定卡总数 #### Scenario: 标识符同时命中设备和卡(设备优先) - **WHEN** `GPS-001` 在 device 表和 iot_card 表均有匹配(virtual_no 相同) - **THEN** 系统返回设备信息(device 优先),不返回卡信息 #### Scenario: 标识符未命中任何资产 - **WHEN** 管理员查询不存在的标识符 `UNKNOWN-999` - **THEN** 系统返回 HTTP 404 #### Scenario: 代理用户查询无权限的资产 - **WHEN** 代理用户(shop_id=10)查询属于 shop_id=99(非下级)的设备 - **THEN** 系统返回 HTTP 403,明确提示无权限 #### Scenario: 企业账号调用 resolve - **WHEN** 企业账号调用 `GET /api/admin/assets/resolve/:identifier` - **THEN** 系统返回 HTTP 403,提示企业账号暂不支持此接口 #### Scenario: 卡绑定了有停机保护期的设备 - **WHEN** 管理员通过 ICCID 查询某张卡,该卡绑定的设备当前有 stop 保护期 - **THEN** 响应中 `device_protect_status = "stop"`,反映所属设备的保护期状态 #### Scenario: 设备无当前生效套餐 - **WHEN** 管理员查询一台没有购买任何套餐的设备 - **THEN** `current_package = ""`,`package_total_mb = 0`,`package_used_mb = 0`,`package_remain_mb = 0` #### Scenario: 通过注册表主路径精确解析 - **WHEN** 管理员输入 identifier 为已存在于 `tb_asset_identifier` 的 VirtualNo 或 ICCID - **THEN** 系统单次查询注册表命中,直接查对应表返回完整资产信息,响应时间 < 50ms #### Scenario: Fallback 路径解析 IMEI - **WHEN** 管理员输入 identifier 为设备 IMEI(不在注册表中) - **THEN** 注册表未命中,系统 fallback 查 tb_device 的 imei 字段,找到后返回资产信息 - **THEN** 响应中 `identifier` 字段原样回传该 IMEI 值 #### Scenario: Fallback 路径解析 MSISDN - **WHEN** 管理员输入 identifier 为 IoT 卡的手机号(MSISDN) - **THEN** 注册表未命中,fallback 查 tb_iot_card 的 msisdn 字段 - **THEN** 若存在多张卡的 MSISDN 相同,返回第一条匹配记录(MSISDN 非唯一,存在歧义,记录 warn 日志) #### Scenario: 已实名的卡查询实名时间 - **WHEN** 通过标识符解析一张 `real_name_status = 1` 的卡 - **THEN** 响应中 `real_name_at` 返回该卡最近一次 `0→1` 变化时的时间戳 #### Scenario: 未实名的卡查询实名时间 - **WHEN** 通过标识符解析一张 `real_name_status = 0` 的卡 - **THEN** 响应中 `real_name_at` 返回 null #### Scenario: 设备视角查询实名时间(部分卡已实名) - **WHEN** 通过标识符解析一台设备,其中绑定了多张卡,部分卡已实名 - **THEN** 响应中 `real_name_at` 返回所有已实名绑定卡中 `first_realname_at` 最小的时间戳 #### Scenario: 设备视角查询实名时间(无已实名卡) - **WHEN** 通过标识符解析一台设备,其所有绑定卡均未实名 - **THEN** 响应中 `real_name_at` 返回 null #### Scenario: 设备绑定卡列表实名时间 - **WHEN** 通过标识符解析一台设备,绑定卡列表非空 - **THEN** `cards` 数组中每个 `DeviceCardInfo` 的 `real_name_at` 返回各卡自己的 `first_realname_at`(未实名则为 null) --- ### Requirement: 手动刷新路径同步写入实名时间 系统 SHALL 在手动刷新路径(`RefreshCardDataFromGateway`)检测到实名状态由非已实名变为已实名(`0→1`)时,同步写入 `first_realname_at`,与轮询路径行为一致。 #### Scenario: 手动刷新触发实名状态变更 - **WHEN** 调用手动刷新接口,网关返回实名状态为已实名,且卡当前状态为未实名 - **THEN** `tb_iot_card.first_realname_at` 被更新为当前时间戳 #### Scenario: 手动刷新时实名状态无变化 - **WHEN** 调用手动刷新接口,网关返回实名状态与卡当前状态相同 - **THEN** `tb_iot_card.first_realname_at` 不被修改