All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m12s
1. 修正 retail_price 架构:
- 删除 batch-pricing 接口的 pricing_target 字段和 retail_price 分支
(上级只能改下级成本价,不能改零售价)
- 新增 PATCH /api/admin/packages/:id/retail-price 接口
(代理自己改自己的零售价,校验 retail_price >= cost_price)
2. 清理旧微信 YAML 配置(已全部迁移到数据库 tb_wechat_config):
- 删除 config.yaml 中 wechat.official_account 配置节
- 删除 NewOfficialAccountApp() 旧工厂函数
- 清理 personal_customer service 中的死代码(旧登录/绑定微信方法)
- 清理 docker-compose.prod.yml 中旧微信环境变量和证书挂载注释
3. 归档四个已完成提案到 openspec/changes/archive/
4. 新增前端接口变更说明文档(docs/前端接口变更说明.md)
5. 修正归档提案和 specs 中关于 pricing_target 的错误描述
790 lines
33 KiB
Markdown
790 lines
33 KiB
Markdown
# IoT Card Management
|
||
|
||
## Purpose
|
||
|
||
Manage IoT cards (SIM cards) for the IoT management system, including inventory management, distribution, activation, status tracking, and Gateway integration.
|
||
|
||
This capability supports:
|
||
- IoT card entity definition and lifecycle management
|
||
- Platform self-operation and agent distribution models
|
||
- Integration with Gateway project for real-time status synchronization
|
||
- Batch import and multi-dimensional querying
|
||
- Support for normal cards (require real-name verification) and industry cards (no real-name required)
|
||
## Requirements
|
||
### Requirement: IoT 卡实体定义
|
||
|
||
系统 SHALL 定义 IoT 卡(IotCard)实体,包含 IoT 卡(物联网卡/流量卡/SIM卡)的商品属性、状态属性、所有权信息和 Gateway 集成字段。
|
||
|
||
**核心概念**: IoT 卡 = 物联网卡 = SIM 卡 = 网卡 = 流量卡(同一个东西,不同叫法)。系统使用 ICCID 作为 IoT 卡的唯一标识。
|
||
|
||
**卡业务类型**:
|
||
- **普通卡(normal)**: 需要实名认证才能激活使用,遵循运营商实名制要求
|
||
- **行业卡(industry)**: 不需要实名认证,可以直接激活使用,适用于企业/行业客户批量采购场景
|
||
|
||
**实体字段**:
|
||
|
||
**商品属性**:
|
||
- `id`: IoT 卡 ID(主键,BIGINT)
|
||
- `iccid`: ICCID(VARCHAR(50),唯一,国际移动用户识别码,IoT卡的唯一标识)
|
||
- `card_type`: 卡类型(VARCHAR(50),如 "4G"、"5G"、"NB-IoT")
|
||
- `card_category`: 卡业务类型(VARCHAR(20),枚举值:"normal"-普通卡 | "industry"-行业卡,默认 "normal")
|
||
- `carrier_id`: 运营商 ID(BIGINT,关联 carriers 表,如中国移动、中国联通、中国电信)
|
||
- `imsi`: IMSI(VARCHAR(50),可选,国际移动用户识别码)
|
||
- `msisdn`: 手机号码(VARCHAR(20),可选)
|
||
- `batch_no`: 批次号(VARCHAR(100),用于批量导入追溯)
|
||
- `supplier`: 供应商名称(VARCHAR(255),可选)
|
||
- `cost_price`: 成本价(DECIMAL(10,2),平台进货价)
|
||
- `distribute_price`: 分销价(DECIMAL(10,2),分销给代理的价格,仅当 owner_type 为 agent 时有值)
|
||
|
||
**所有权和状态**:
|
||
- `status`: IoT 卡状态(INT,1-在库 2-已分销 3-已激活 4-已停用)
|
||
- `owner_type`: 所有者类型(VARCHAR(20),"platform"-平台自营 | "agent"-代理商 | "user"-用户 | "device"-设备)
|
||
- `owner_id`: 所有者 ID(BIGINT,platform 时为 0,agent/user/device 时为对应的 ID)
|
||
- `activated_at`: 激活时间(TIMESTAMP,可空)
|
||
|
||
**Gateway 集成字段**(从 Gateway 项目同步):
|
||
- `activation_status`: 激活状态(INT,0-未激活 1-已激活)
|
||
- `real_name_status`: 实名状态(INT,0-未实名 1-已实名)
|
||
- `network_status`: 网络状态(INT,0-停机 1-开机)
|
||
- `data_usage_mb`: 累计流量使用(BIGINT,MB 为单位,默认 0)
|
||
- `last_sync_time`: 最后一次与 Gateway 同步时间(TIMESTAMP,可空)
|
||
|
||
**轮询控制字段**:
|
||
- `enable_polling`: 是否参与轮询(BOOLEAN,默认 true,用于控制是否对该卡进行定时轮询)
|
||
- `last_data_check_at`: 最后一次卡流量检查时间(TIMESTAMP,可空,记录上次轮询卡流量的时间)
|
||
- `last_real_name_check_at`: 最后一次实名检查时间(TIMESTAMP,可空,记录上次轮询实名状态的时间)
|
||
|
||
**系统字段**:
|
||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||
|
||
#### Scenario: 创建平台自营 IoT 卡
|
||
|
||
- **WHEN** 平台批量导入 IoT 卡数据,ICCID 为 "89860123456789012345"
|
||
- **THEN** 系统创建 IoT 卡记录,`owner_type` 为 "platform",`owner_id` 为 0,状态为 1(在库),`activation_status` 为 0(未激活)
|
||
|
||
#### Scenario: 平台分销 IoT 卡给代理
|
||
|
||
- **WHEN** 平台将在库 IoT 卡分销给代理商(用户 ID 为 123),设置分销价为 50.00 元
|
||
- **THEN** 系统将 IoT 卡状态从 1(在库) 变更为 2(已分销),`owner_type` 变更为 "agent",`owner_id` 设置为 123,`distribute_price` 设置为 50.00
|
||
|
||
#### Scenario: IoT 卡绑定到设备
|
||
|
||
- **WHEN** 用户将 IoT 卡(ICCID 为 "8986...")绑定到设备(ID 为 1001)
|
||
- **THEN** 系统在 `device_sim_bindings` 表创建绑定记录,IoT 卡的 `owner_type` 变更为 "device",`owner_id` 变更为 1001
|
||
|
||
#### Scenario: IoT 卡直接销售给用户
|
||
|
||
- **WHEN** 平台或代理将 IoT 卡直接销售给用户(用户 ID 为 2001)
|
||
- **THEN** 系统创建套餐订单记录,IoT 卡的 `owner_type` 变更为 "user",`owner_id` 变更为 2001
|
||
|
||
#### Scenario: 行业卡无需实名认证
|
||
|
||
- **WHEN** 创建卡业务类型为 "industry"(行业卡)的 IoT 卡
|
||
- **THEN** 系统允许该卡在 `real_name_status` 为 0(未实名)的情况下激活使用,不强制要求实名认证
|
||
|
||
#### Scenario: 普通卡需要实名认证
|
||
|
||
- **WHEN** 创建卡业务类型为 "normal"(普通卡)的 IoT 卡
|
||
- **THEN** 系统要求该卡必须先完成实名认证(`real_name_status` 为 1)才能激活使用
|
||
|
||
---
|
||
|
||
### Requirement: IoT 卡状态流转
|
||
|
||
系统 SHALL 管理 IoT 卡的状态流转,确保状态变更符合业务规则。
|
||
|
||
**状态定义**:
|
||
- **1-在库**: IoT 卡在平台库存中,未分销
|
||
- **2-已分销**: IoT 卡已分销给代理商,代理可销售
|
||
- **3-已激活**: IoT 卡已被终端用户激活使用
|
||
- **4-已停用**: IoT 卡已停用,不可使用
|
||
|
||
**状态流转规则**:
|
||
- 在库(1) → 已分销(2): 平台分销给代理
|
||
- 在库(1) → 已激活(3): 平台自营直接销售给用户并激活
|
||
- 已分销(2) → 已激活(3): 代理销售给用户并激活
|
||
- 已激活(3) → 已停用(4): 用户或平台主动停用
|
||
- 已停用(4) → 已激活(3): 用户或平台主动复机(仅在符合业务规则时)
|
||
|
||
#### Scenario: 代理销售 IoT 卡给用户
|
||
|
||
- **WHEN** 代理商销售已分销 IoT 卡给终端用户并激活
|
||
- **THEN** 系统将 IoT 卡状态从 2(已分销) 变更为 3(已激活),`activated_at` 记录激活时间,`activation_status` 从 Gateway 同步后变更为 1
|
||
|
||
#### Scenario: 平台自营销售 IoT 卡
|
||
|
||
- **WHEN** 平台直接销售在库 IoT 卡给终端用户并激活
|
||
- **THEN** 系统将 IoT 卡状态从 1(在库) 变更为 3(已激活),`owner_type` 保持 "platform",`activated_at` 记录激活时间
|
||
|
||
#### Scenario: 停用已激活 IoT 卡
|
||
|
||
- **WHEN** 用户或平台停用已激活 IoT 卡
|
||
- **THEN** 系统将 IoT 卡状态从 3(已激活) 变更为 4(已停用),通过 Gateway API 执行停机操作
|
||
|
||
---
|
||
|
||
### Requirement: IoT 卡平台自营和代理分销
|
||
|
||
系统 SHALL 支持 IoT 卡的平台自营销售和代理分销两种模式,通过 `owner_type` 和 `owner_id` 区分所有者。
|
||
|
||
**平台自营**:
|
||
- `owner_type` 为 "platform"
|
||
- `owner_id` 为 0
|
||
- 平台直接销售给终端用户
|
||
- 销售价格由平台自主定价
|
||
|
||
**代理分销**:
|
||
- `owner_type` 为 "agent"
|
||
- `owner_id` 为代理用户 ID
|
||
- 代理商可以销售给终端用户或下级代理
|
||
- 分销价格由平台设置(`distribute_price`),代理商可在分销价基础上加价(但不能超过 2 倍)
|
||
|
||
#### Scenario: 查询平台自营 IoT 卡库存
|
||
|
||
- **WHEN** 查询平台自营 IoT 卡库存
|
||
- **THEN** 系统返回 `owner_type` 为 "platform" 且 `status` 为 1(在库) 的 IoT 卡列表
|
||
|
||
#### Scenario: 查询代理分销 IoT 卡库存
|
||
|
||
- **WHEN** 代理商(用户 ID 为 123)查询自己的 IoT 卡库存
|
||
- **THEN** 系统返回 `owner_type` 为 "agent" 且 `owner_id` 为 123 且 `status` 为 2(已分销) 的 IoT 卡列表
|
||
|
||
#### Scenario: 代理加价销售 IoT 卡套餐
|
||
|
||
- **WHEN** 代理商为已分销 IoT 卡设置套餐售价
|
||
- **THEN** 系统校验套餐售价不超过分销价的 2 倍,校验通过后允许销售
|
||
|
||
---
|
||
|
||
### Requirement: IoT 卡批量导入
|
||
|
||
系统 SHALL 支持通过 CSV 文件批量导入 IoT 卡 ICCID,支持大批量数据(几万条),异步处理并跟踪导入进度。
|
||
|
||
**导入方式**:
|
||
- 上传 CSV 文件,每行一个 ICCID
|
||
- 在界面选择运营商、批次号等公共参数
|
||
- 不支持一次导入多种运营商的卡
|
||
|
||
**导入参数**:
|
||
- CSV 文件(必填): 仅包含 ICCID 列
|
||
- 运营商 ID(必填): 在界面选择
|
||
- 批次号(可选): 在界面填写
|
||
|
||
**校验规则**:
|
||
- ICCID 格式校验: 字母数字混合,长度根据运营商(电信19位,其他20位)
|
||
- ICCID 唯一性校验: 重复 ICCID 跳过,不中断导入
|
||
|
||
**处理规则**:
|
||
- 异步处理: 创建导入任务后立即返回任务 ID
|
||
- 分批处理: 每批 1000 条
|
||
- 重复处理: 跳过已存在的 ICCID,记录跳过原因
|
||
- 格式错误: 记录失败原因,继续处理其他行
|
||
|
||
**导入结果**:
|
||
- 总数(total_count)
|
||
- 成功数(success_count)
|
||
- 跳过数(skip_count): 因重复等原因跳过
|
||
- 失败数(fail_count): 因格式错误等原因失败
|
||
- 跳过详情: 包含行号、ICCID、原因
|
||
- 失败详情: 包含行号、ICCID、原因
|
||
|
||
#### Scenario: 发起 IoT 卡批量导入
|
||
|
||
- **WHEN** 管理员上传包含 10000 个 ICCID 的 CSV 文件,选择运营商为电信,批次号为 "BATCH-2025-001"
|
||
- **THEN** 系统创建导入任务,返回任务 ID,后台异步处理导入
|
||
|
||
#### Scenario: 导入时跳过重复 ICCID
|
||
|
||
- **WHEN** CSV 文件中的 ICCID "8986001234567890123" 已存在于系统中
|
||
- **THEN** 系统跳过该 ICCID,记录跳过原因为"ICCID 已存在",继续处理其他 ICCID
|
||
|
||
#### Scenario: 导入时记录格式错误
|
||
|
||
- **WHEN** CSV 文件第 100 行的 ICCID "12345" 长度不符合电信卡要求(19位)
|
||
- **THEN** 系统记录失败,原因为"电信 ICCID 必须为 19 位",行号为 100,继续处理其他 ICCID
|
||
|
||
#### Scenario: 查询导入任务进度
|
||
|
||
- **WHEN** 管理员查询导入任务(ID 为 1)的进度
|
||
- **THEN** 系统返回任务状态、总数、成功数、跳过数、失败数、开始时间、完成时间
|
||
|
||
#### Scenario: 查询导入任务失败详情
|
||
|
||
- **WHEN** 管理员查询导入任务(ID 为 1)的失败详情
|
||
- **THEN** 系统返回失败记录列表,每条包含行号、ICCID、失败原因
|
||
|
||
### Requirement: IoT 卡查询和筛选
|
||
|
||
系统 SHALL 支持多维度查询和筛选 IoT 卡,包括状态、所有者、批次号、卡类型等。
|
||
|
||
**查询条件**:
|
||
- ICCID(精确匹配或模糊匹配)
|
||
- IoT 卡状态(单选或多选)
|
||
- 所有者类型(platform | agent | user | device)
|
||
- 所有者 ID(仅当所有者类型为 agent/user/device 时有效)
|
||
- 批次号(精确匹配)
|
||
- 卡类型(单选或多选)
|
||
- 运营商 ID(单选或多选,从 carriers 表选择)
|
||
- 激活状态(0-未激活 | 1-已激活)
|
||
- 实名状态(0-未实名 | 1-已实名)
|
||
- 网络状态(0-停机 | 1-开机)
|
||
- 是否参与轮询(true | false)
|
||
- 激活时间范围(开始时间 - 结束时间)
|
||
- 创建时间范围(开始时间 - 结束时间)
|
||
|
||
**分页**:
|
||
- 默认每页 20 条,最大每页 100 条
|
||
- 返回总记录数和总页数
|
||
|
||
#### Scenario: 查询特定批次的在库 IoT 卡
|
||
|
||
- **WHEN** 平台查询批次号为 "BATCH-2025-001" 且状态为 1(在库) 的 IoT 卡
|
||
- **THEN** 系统返回符合条件的 IoT 卡列表,包含 ICCID、类型、运营商、成本价等信息
|
||
|
||
#### Scenario: 代理查询自己的已分销 IoT 卡
|
||
|
||
- **WHEN** 代理商(用户 ID 为 123)查询自己的已分销 IoT 卡
|
||
- **THEN** 系统返回 `owner_type` 为 "agent" 且 `owner_id` 为 123 且 `status` 为 2(已分销) 的 IoT 卡列表
|
||
|
||
#### Scenario: 分页查询 IoT 卡
|
||
|
||
- **WHEN** 平台查询在库 IoT 卡,指定每页 50 条,查询第 2 页
|
||
- **THEN** 系统返回第 51-100 条 IoT 卡记录,以及总记录数和总页数
|
||
|
||
---
|
||
|
||
### Requirement: Gateway 集成
|
||
|
||
系统 SHALL 预留 IoT 卡状态相关字段,用于后续与 Gateway 项目集成。
|
||
|
||
**集成字段**:
|
||
- `activation_status`: 激活状态(从 Gateway 同步)
|
||
- `real_name_status`: 实名状态(从 Gateway 同步)
|
||
- `network_status`: 网络状态(从 Gateway 同步)
|
||
- `data_usage_mb`: 累计流量使用(从 Gateway 同步)
|
||
- `last_sync_time`: 最后同步时间
|
||
|
||
**集成说明**:
|
||
- 本阶段只设计数据模型字段,不实现 Gateway HTTP 客户端代码
|
||
- 后续 Service 层将调用 Gateway API 获取 IoT 卡状态并更新这些字段
|
||
- Gateway 使用 AES 加密 + MD5 签名的统一传输协议(参考 design.md)
|
||
|
||
**Gateway API 功能**:
|
||
- 查询 IoT 卡状态(激活状态、实名状态、网络状态)
|
||
- 查询流量详情(累计流量使用、剩余流量)
|
||
- 停复机操作(停机、复机)
|
||
- 实名认证操作
|
||
|
||
#### Scenario: 预留 Gateway 集成字段
|
||
|
||
- **WHEN** 创建 IoT 卡记录
|
||
- **THEN** 系统初始化 Gateway 相关字段为默认值:`activation_status` 为 0,`real_name_status` 为 0,`network_status` 为 0,`data_usage_mb` 为 0,`last_sync_time` 为空
|
||
|
||
#### Scenario: 从 Gateway 同步 IoT 卡状态
|
||
|
||
- **WHEN** Service 层调用 Gateway API 查询 IoT 卡状态
|
||
- **THEN** 系统更新 IoT 卡的 `activation_status`、`real_name_status`、`network_status`、`data_usage_mb` 和 `last_sync_time` 字段
|
||
|
||
---
|
||
|
||
### Requirement: IoT 卡数据校验
|
||
|
||
系统 SHALL 对 IoT 卡数据进行校验,确保数据完整性和一致性。
|
||
|
||
**校验规则**:
|
||
- ICCID(iccid):必填,长度 19-20 字符,唯一
|
||
- 卡类型(card_type):必填,长度 1-50 字符
|
||
- 卡业务类型(card_category):必填,枚举值 "normal"(普通卡) | "industry"(行业卡),默认 "normal"
|
||
- 运营商 ID(carrier_id):必填,≥ 1,必须是有效的运营商 ID
|
||
- 成本价(cost_price):必填,≥ 0,最多 2 位小数
|
||
- 分销价(distribute_price):可选,≥ 0,最多 2 位小数,≥ 成本价
|
||
- 所有者类型(owner_type):必填,枚举值 "platform" | "agent" | "user" | "device"
|
||
- 所有者 ID(owner_id):必填,≥ 0,当 owner_type 为 "platform" 时必须为 0
|
||
- 激活状态(activation_status):必填,枚举值 0(未激活) | 1(已激活)
|
||
- 实名状态(real_name_status):必填,枚举值 0(未实名) | 1(已实名),当 card_category 为 "industry"(行业卡)时可以保持 0
|
||
- 网络状态(network_status):必填,枚举值 0(停机) | 1(开机)
|
||
- 轮询开关(enable_polling):必填,布尔值 true | false
|
||
|
||
#### Scenario: 创建 IoT 卡时 ICCID 格式错误
|
||
|
||
- **WHEN** 平台创建 IoT 卡,ICCID 长度为 15(小于 19)
|
||
- **THEN** 系统拒绝创建,返回错误信息"ICCID 长度必须为 19-20 字符"
|
||
|
||
#### Scenario: 创建 IoT 卡时 ICCID 重复
|
||
|
||
- **WHEN** 平台创建 IoT 卡,ICCID 为已存在的 "89860123456789012345"
|
||
- **THEN** 系统拒绝创建,返回错误信息"ICCID 已存在"
|
||
|
||
#### Scenario: 创建 IoT 卡时成本价为负数
|
||
|
||
- **WHEN** 平台创建 IoT 卡,成本价为 -10.00
|
||
- **THEN** 系统拒绝创建,返回错误信息"成本价必须 ≥ 0"
|
||
|
||
#### Scenario: 创建 IoT 卡时分销价低于成本价
|
||
|
||
- **WHEN** 平台创建 IoT 卡,成本价为 50.00,分销价为 40.00
|
||
- **THEN** 系统拒绝创建,返回错误信息"分销价不能低于成本价"
|
||
|
||
### Requirement: 单卡列表查询
|
||
|
||
系统 SHALL 提供单卡列表查询功能,用于管理未绑定设备的 IoT 卡资产。
|
||
|
||
**单卡定义**: 单卡是指未绑定到任何设备的 IoT 卡,即在 `device_sim_bindings` 表中不存在 `bind_status = 1`(已绑定) 的记录。
|
||
|
||
**查询条件**:
|
||
- 套餐 ID(package_id): 可选,筛选已购买指定套餐的卡
|
||
- 是否分销(is_distributed): 可选,true-已分销 false-未分销
|
||
- 卡号状态(status): 可选,1-在库 2-已分销 3-已激活 4-已停用
|
||
- 运营商(carrier_id): 可选,运营商 ID
|
||
- 分销商 ID(shop_id): 可选,店铺 ID
|
||
- 网卡号段(iccid_range): 可选,格式 "起始ICCID-结束ICCID"
|
||
- ICCID: 可选,模糊匹配
|
||
- 卡接入号(msisdn): 可选,模糊匹配
|
||
- 是否换卡(is_replaced): 可选,true-有换卡记录 false-无换卡记录
|
||
|
||
**分页**:
|
||
- 默认每页 20 条,最大每页 100 条
|
||
- 返回总记录数和总页数
|
||
|
||
**数据权限**:
|
||
- 基于 shop_id 自动应用数据权限过滤
|
||
- 代理只能看到自己店铺及下级店铺的卡
|
||
|
||
#### Scenario: 查询未绑定设备的单卡列表
|
||
|
||
- **WHEN** 管理员查询单卡列表
|
||
- **THEN** 系统返回所有未绑定设备的 IoT 卡(在 device_sim_bindings 中无 bind_status=1 记录的卡)
|
||
|
||
#### Scenario: 按运营商筛选单卡
|
||
|
||
- **WHEN** 管理员查询运营商 ID 为 1(电信)的单卡
|
||
- **THEN** 系统返回 carrier_id = 1 且未绑定设备的 IoT 卡列表
|
||
|
||
#### Scenario: 按网卡号段筛选单卡
|
||
|
||
- **WHEN** 管理员查询 ICCID 号段为 "8986001000000000000-8986001999999999999" 的单卡
|
||
- **THEN** 系统返回 ICCID 在该号段范围内且未绑定设备的 IoT 卡列表
|
||
|
||
#### Scenario: 按是否换卡筛选单卡
|
||
|
||
- **WHEN** 管理员查询有换卡记录的单卡(is_replaced=true)
|
||
- **THEN** 系统返回在 card_replacement_records 表中有记录的 IoT 卡列表
|
||
|
||
---
|
||
|
||
### Requirement: 单卡分配功能
|
||
|
||
系统 SHALL 支持将单卡(未绑定设备的 IoT 卡)分配给直属下级店铺,实现资产所有权的层级流转。
|
||
|
||
**分配规则**:
|
||
- 只能分配给直属下级店铺,不可跨级分配
|
||
- 平台(shop_id=NULL)只能分配状态为在库(status=1)的卡
|
||
- 代理店铺可以分配状态为已分销(status=2)的卡(继续往下分销)
|
||
- 分配后状态变更:在库(1)→已分销(2),已分销(2)保持不变
|
||
- 分配后 shop_id 变更为目标店铺 ID
|
||
- 分配不涉及费用,纯资产所有权转移
|
||
- 分配后上级仍能看到和管理(通过数据权限机制)
|
||
|
||
**选卡方式**(三选一):
|
||
- ICCID 列表:指定具体的 ICCID 列表
|
||
- 号段范围:指定起始 ICCID 和结束 ICCID
|
||
- 筛选条件:按运营商、批次号、状态等条件批量选择
|
||
|
||
**API 端点**: `POST /api/admin/iot-cards/standalone/allocate`
|
||
|
||
**请求参数**:
|
||
- `to_shop_id`(必填): 目标店铺 ID
|
||
- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter"
|
||
- `iccids`(selection_type=list 时必填): ICCID 列表
|
||
- `iccid_start`(selection_type=range 时必填): 起始 ICCID
|
||
- `iccid_end`(selection_type=range 时必填): 结束 ICCID
|
||
- `carrier_id`(selection_type=filter 时可选): 运营商 ID
|
||
- `batch_no`(selection_type=filter 时可选): 批次号
|
||
- `status`(selection_type=filter 时可选): 卡状态
|
||
- `remark`(可选): 备注
|
||
|
||
**响应**:
|
||
- `total_count`: 待分配总数
|
||
- `success_count`: 成功数
|
||
- `fail_count`: 失败数
|
||
- `failed_items`: 失败项列表(包含 ICCID 和失败原因)
|
||
|
||
#### Scenario: 平台通过 ICCID 列表分配单卡给一级代理
|
||
|
||
- **WHEN** 平台管理员选择 3 张在库单卡(ICCID 列表),分配给一级代理店铺(ID=10)
|
||
- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 从 1 变为 2,创建分配记录,返回成功数 3
|
||
|
||
#### Scenario: 平台通过号段范围批量分配单卡
|
||
|
||
- **WHEN** 平台管理员指定 ICCID 范围 "8986001000000000000" 至 "8986001000000000099",分配给一级代理店铺(ID=10)
|
||
- **THEN** 系统查询该范围内的所有在库单卡,批量更新 shop_id 和 status,创建分配记录
|
||
|
||
#### Scenario: 代理通过筛选条件分配单卡给下级
|
||
|
||
- **WHEN** 一级代理(店铺 ID=10)按条件筛选(运营商=电信,批次号=BATCH-001)自己的已分销卡,分配给二级代理店铺(ID=20)
|
||
- **THEN** 系统查询符合条件的卡,校验店铺 20 是店铺 10 的直属下级,批量更新 shop_id 为 20,status 保持 2
|
||
|
||
#### Scenario: 拒绝跨级分配
|
||
|
||
- **WHEN** 平台尝试将卡直接分配给二级代理店铺(非直属下级)
|
||
- **THEN** 系统拒绝分配,返回错误"只能分配给直属下级店铺"
|
||
|
||
#### Scenario: 拒绝平台分配已分销的卡
|
||
|
||
- **WHEN** 平台尝试分配状态为已分销(status=2)的卡
|
||
- **THEN** 系统拒绝分配,返回错误"在库状态的卡才能分配,请先回收"
|
||
|
||
#### Scenario: 拒绝分配已绑定设备的卡
|
||
|
||
- **WHEN** 用户尝试分配已绑定设备的卡(在 device_sim_bindings 中 bind_status=1)
|
||
- **THEN** 系统拒绝分配,返回错误"已绑定设备的卡不能单独分配"
|
||
|
||
---
|
||
|
||
### Requirement: 单卡回收功能
|
||
|
||
系统 SHALL 支持上级回收已分配给直属下级的单卡,将卡的所有权收回。
|
||
|
||
**回收规则**:
|
||
- 只有上级可以回收,代理不能主动退回给上级
|
||
- 只能回收直属下级的卡,不可跨级回收
|
||
- 平台回收:shop_id 变为 NULL,status 变为 1(在库)
|
||
- 店铺回收:shop_id 变为执行回收的店铺 ID,status 保持 2(已分销)
|
||
- 只能回收单卡(未绑定设备的卡)
|
||
|
||
**选卡方式**(与分配相同,三选一):
|
||
- ICCID 列表
|
||
- 号段范围
|
||
- 筛选条件
|
||
|
||
**API 端点**: `POST /api/admin/iot-cards/standalone/recall`
|
||
|
||
**请求参数**:
|
||
- `from_shop_id`(必填): 来源店铺 ID(被回收方)
|
||
- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter"
|
||
- `iccids`(selection_type=list 时必填): ICCID 列表
|
||
- `iccid_start`(selection_type=range 时必填): 起始 ICCID
|
||
- `iccid_end`(selection_type=range 时必填): 结束 ICCID
|
||
- `carrier_id`(selection_type=filter 时可选): 运营商 ID
|
||
- `batch_no`(selection_type=filter 时可选): 批次号
|
||
- `remark`(可选): 备注
|
||
|
||
**响应**:
|
||
- `total_count`: 待回收总数
|
||
- `success_count`: 成功数
|
||
- `fail_count`: 失败数
|
||
- `failed_items`: 失败项列表
|
||
|
||
#### Scenario: 平台回收一级代理的单卡
|
||
|
||
- **WHEN** 平台管理员选择一级代理店铺(ID=10)的 5 张单卡进行回收
|
||
- **THEN** 系统将这 5 张卡的 shop_id 更新为 NULL,status 从 2 变为 1,创建回收记录
|
||
|
||
#### Scenario: 一级代理回收二级代理的单卡
|
||
|
||
- **WHEN** 一级代理(店铺 ID=10)选择二级代理店铺(ID=20)的 3 张单卡进行回收
|
||
- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 保持 2,创建回收记录
|
||
|
||
#### Scenario: 拒绝回收非直属下级的卡
|
||
|
||
- **WHEN** 一级代理(店铺 ID=10)尝试回收非直属下级店铺(ID=30,归属于店铺 ID=20)的卡
|
||
- **THEN** 系统拒绝回收,返回错误"只能回收直属下级店铺的卡"
|
||
|
||
#### Scenario: 拒绝代理主动退回
|
||
|
||
- **WHEN** 二级代理(店铺 ID=20)尝试将卡退回给上级店铺(ID=10)
|
||
- **THEN** 系统拒绝操作,返回错误"不能主动退回卡给上级,请联系上级进行回收"
|
||
|
||
#### Scenario: 拒绝回收已绑定设备的卡
|
||
|
||
- **WHEN** 用户尝试回收已绑定设备的卡
|
||
- **THEN** 系统拒绝回收,返回错误"已绑定设备的卡不能单独回收"
|
||
|
||
---
|
||
|
||
### Requirement: 企业用户 IoT 卡查询权限控制
|
||
|
||
系统 SHALL 支持基于用户类型和授权关系的 IoT 卡查询权限控制。
|
||
|
||
**查询权限规则**:
|
||
- **超级管理员/平台用户**:可以查询所有 IoT 卡
|
||
- **代理用户**:可以查询自己店铺和下级店铺的 IoT 卡
|
||
- **企业用户**:
|
||
- 可以查询分配给自己企业的卡(owner_type="enterprise" 且 owner_id=自己的企业ID)
|
||
- 可以查询授权给自己企业的卡(通过 enterprise_card_authorization 表关联)
|
||
- **个人客户**:只能查询自己拥有的卡
|
||
|
||
**数据过滤**:
|
||
- 企业用户查询时,自动过滤敏感商业信息(cost_price、distribute_price、supplier)
|
||
- 其他用户类型可以看到完整信息
|
||
|
||
#### Scenario: 企业用户查询自己拥有的卡
|
||
|
||
- **WHEN** 企业用户查询 IoT 卡列表,且存在 owner_type="enterprise" 且 owner_id=该企业ID 的卡
|
||
- **THEN** 系统返回这些卡的信息,但隐藏 cost_price、distribute_price、supplier 字段
|
||
|
||
#### Scenario: 企业用户查询被授权的卡
|
||
|
||
- **WHEN** 企业用户查询 IoT 卡列表,且存在通过 enterprise_card_authorization 授权给该企业的卡
|
||
- **THEN** 系统返回这些授权卡的信息,但隐藏商业敏感字段,同时包含授权人和授权时间信息
|
||
|
||
#### Scenario: 企业用户无法查询未授权的卡
|
||
|
||
- **WHEN** 企业用户尝试查询既不属于自己也未被授权的卡
|
||
- **THEN** 系统在查询结果中不包含这些卡,如同它们不存在
|
||
|
||
#### Scenario: 代理用户正常查询
|
||
|
||
- **WHEN** 代理用户查询 IoT 卡
|
||
- **THEN** 系统返回该代理店铺及其下级店铺的所有卡,包含完整信息
|
||
|
||
#### Scenario: 授权被回收后企业无法查询
|
||
|
||
- **WHEN** 卡的授权被回收后(revoked_at 不为空),企业用户查询该卡
|
||
- **THEN** 系统不返回该卡信息,企业无法再看到该卡
|
||
|
||
---
|
||
|
||
### Requirement: 查询物联网卡时返回运营商信息
|
||
|
||
系统 SHALL 在查询物联网卡列表/详情时,直接从 IotCard 记录的冗余字段返回 carrier_type 和 carrier_name,无需 JOIN Carrier 表。
|
||
|
||
#### Scenario: 列表查询返回运营商信息
|
||
- **WHEN** 管理员查询物联网卡列表
|
||
- **THEN** 响应中的 carrier_type 和 carrier_name 直接来自 IotCard 记录的冗余字段
|
||
|
||
#### Scenario: 详情查询返回运营商信息
|
||
- **WHEN** 管理员查询单张物联网卡详情
|
||
- **THEN** 响应中的 carrier_type 和 carrier_name 直接来自 IotCard 记录的冗余字段
|
||
|
||
---
|
||
|
||
### Requirement: 查询导入任务时返回运营商名称
|
||
|
||
系统 SHALL 在查询导入任务列表/详情时,直接从 IotCardImportTask 记录的冗余字段返回 carrier_name,无需 JOIN Carrier 表。
|
||
|
||
#### Scenario: 导入任务列表返回运营商名称
|
||
- **WHEN** 管理员查询导入任务列表
|
||
- **THEN** 响应中的 carrier_name 直接来自 IotCardImportTask 记录的冗余字段
|
||
|
||
---
|
||
|
||
### Requirement: 设备绑定卡查询返回运营商信息
|
||
|
||
系统 SHALL 在查询设备绑定的物联网卡时,直接从 IotCard 记录的冗余字段返回 carrier_name,无需 JOIN Carrier 表。
|
||
|
||
#### Scenario: 设备绑定卡列表返回运营商名称
|
||
- **WHEN** 管理员查询设备绑定的物联网卡列表
|
||
- **THEN** 响应中的 carrier_name 直接来自 IotCard 记录的冗余字段
|
||
|
||
---
|
||
|
||
### Requirement: 流量检查任务支持新的扣减优先级
|
||
系统 SHALL 在轮询系统的流量检查任务(HandleCarddataCheck)中,实现新的流量扣减优先级机制。
|
||
|
||
#### Scenario: 优先扣减加油包流量
|
||
- **WHEN** 轮询系统检测到卡流量增加,卡有主套餐和加油包
|
||
- **THEN** 系统优先更新加油包的 data_usage_mb,再更新主套餐
|
||
|
||
#### Scenario: 按 Priority 顺序扣减多个加油包
|
||
- **WHEN** 卡有多个加油包,流量增加
|
||
- **THEN** 系统按 priority 从小到大顺序扣减流量
|
||
|
||
### Requirement: 停机条件检查调整
|
||
系统 SHALL 在轮询系统中,仅当主套餐和所有加油包流量都用完时触发停机。
|
||
|
||
#### Scenario: 主套餐用完但加油包有剩余不停机
|
||
- **WHEN** 主套餐 data_usage_mb >= data_limit_mb,但加油包有剩余流量
|
||
- **THEN** 系统不触发停机操作
|
||
|
||
#### Scenario: 所有套餐流量用完触发停机
|
||
- **WHEN** 主套餐和所有加油包 data_usage_mb >= data_limit_mb
|
||
- **THEN** 系统触发停机操作
|
||
|
||
### Requirement: 套餐激活检查任务
|
||
系统 SHALL 新增套餐激活检查任务(HandlePackageActivation),定期检查待激活的主套餐。
|
||
|
||
#### Scenario: 定期检查待激活主套餐
|
||
- **WHEN** 轮询系统每分钟执行一次套餐激活检查
|
||
- **THEN** 系统查询所有已过期主套餐,激活 priority 最小的待生效主套餐
|
||
|
||
#### Scenario: 激活延迟小于1分钟
|
||
- **WHEN** 主套餐在 00:00:00 过期
|
||
- **THEN** 系统在 00:01:00 之前完成下一个主套餐的激活
|
||
|
||
### Requirement: 流量重置调度任务
|
||
系统 SHALL 新增流量重置调度任务(HandleDataReset),根据套餐的 data_reset_cycle 定期重置流量。
|
||
|
||
#### Scenario: 每日0点触发日重置任务
|
||
- **WHEN** 系统时间到达 00:00:00
|
||
- **THEN** 系统重置所有 data_reset_cycle=daily 的套餐 data_usage_mb=0
|
||
|
||
#### Scenario: 每月1号触发月重置任务
|
||
- **WHEN** 系统时间到达每月1号 00:00:00
|
||
- **THEN** 系统重置所有 data_reset_cycle=monthly 的套餐 data_usage_mb=0
|
||
|
||
---
|
||
|
||
### Requirement: IotCard Handler 分层修复
|
||
|
||
IotCard Handler SHALL 不再直接持有 `gateway.Client` 引用。所有 Gateway API 调用 SHALL 通过 IotCard Service 层发起。
|
||
|
||
#### Scenario: IotCardHandler 不持有 gatewayClient
|
||
|
||
- **WHEN** 创建 `IotCardHandler` 实例
|
||
- **THEN** `NewIotCardHandler` 构造函数不接收 `gateway.Client` 参数
|
||
- **AND** Handler 结构体不包含 `gatewayClient` 字段
|
||
|
||
#### Scenario: 查询卡实时状态通过 Service 调用
|
||
|
||
- **WHEN** Handler 的 `GetGatewayStatus` 方法被调用
|
||
- **THEN** Handler 调用 `service.QueryGatewayStatus(ctx, iccid)`
|
||
- **AND** Service 内部完成权限检查 + Gateway API 调用
|
||
|
||
#### Scenario: 查询流量使用通过 Service 调用
|
||
|
||
- **WHEN** Handler 的 `GetGatewayFlow` 方法被调用
|
||
- **THEN** Handler 调用 `service.QueryGatewayFlow(ctx, iccid)`
|
||
|
||
#### Scenario: 查询实名状态通过 Service 调用
|
||
|
||
- **WHEN** Handler 的 `GetGatewayRealname` 方法被调用
|
||
- **THEN** Handler 调用 `service.QueryGatewayRealname(ctx, iccid)`
|
||
|
||
#### Scenario: 获取实名链接通过 Service 调用
|
||
|
||
- **WHEN** Handler 的 `GetRealnameLink` 方法被调用
|
||
- **THEN** Handler 调用 `service.GetGatewayRealnameLink(ctx, iccid)`
|
||
|
||
#### Scenario: 停卡通过 Service 调用
|
||
|
||
- **WHEN** Handler 的 `StopCard` 方法被调用
|
||
- **THEN** Handler 调用 `service.GatewayStopCard(ctx, iccid)`
|
||
|
||
#### Scenario: 复机通过 Service 调用
|
||
|
||
- **WHEN** Handler 的 `StartCard` 方法被调用
|
||
- **THEN** Handler 调用 `service.GatewayStartCard(ctx, iccid)`
|
||
|
||
### Requirement: IotCard Service Gateway 代理方法
|
||
|
||
IotCard Service SHALL 提供 Gateway API 的代理方法,封装权限检查和 Gateway 调用。
|
||
|
||
#### Scenario: QueryGatewayStatus 方法
|
||
|
||
- **WHEN** 调用 `service.QueryGatewayStatus(ctx, iccid)`
|
||
- **THEN** 先通过 `GetByICCID` 验证卡存在且用户有权限
|
||
- **AND** 然后调用 `gatewayClient.QueryCardStatus`
|
||
- **AND** 返回 `*gateway.CardStatusResp`
|
||
|
||
#### Scenario: QueryGatewayFlow 方法
|
||
|
||
- **WHEN** 调用 `service.QueryGatewayFlow(ctx, iccid)`
|
||
- **THEN** 先验证权限,再调用 `gatewayClient.QueryFlow`
|
||
- **AND** 返回 `*gateway.FlowUsageResp`
|
||
|
||
#### Scenario: QueryGatewayRealname 方法
|
||
|
||
- **WHEN** 调用 `service.QueryGatewayRealname(ctx, iccid)`
|
||
- **THEN** 先验证权限,再调用 `gatewayClient.QueryRealnameStatus`
|
||
- **AND** 返回 `*gateway.RealnameStatusResp`
|
||
|
||
#### Scenario: GetGatewayRealnameLink 方法
|
||
|
||
- **WHEN** 调用 `service.GetGatewayRealnameLink(ctx, iccid)`
|
||
- **THEN** 先验证权限,再调用 `gatewayClient.GetRealnameLink`
|
||
- **AND** 返回 `*gateway.RealnameLinkResp`
|
||
|
||
#### Scenario: GatewayStopCard 方法
|
||
|
||
- **WHEN** 调用 `service.GatewayStopCard(ctx, iccid)`
|
||
- **THEN** 先验证权限,再调用 `gatewayClient.StopCard`
|
||
- **AND** 返回 error
|
||
|
||
#### Scenario: GatewayStartCard 方法
|
||
|
||
- **WHEN** 调用 `service.GatewayStartCard(ctx, iccid)`
|
||
- **THEN** 先验证权限,再调用 `gatewayClient.StartCard`
|
||
- **AND** 返回 error
|
||
|
||
#### Scenario: 卡不存在或无权限
|
||
|
||
- **WHEN** 调用任意 Gateway 代理方法且 ICCID 对应的卡不存在或用户无权限
|
||
- **THEN** 返回 `CodeNotFound` 错误
|
||
- **AND** 错误信息为 "卡不存在或无权限访问"
|
||
|
||
---
|
||
|
||
### Requirement: IoT 卡虚拟号字段
|
||
|
||
系统 SHALL 在 `tb_iot_card` 表新增 `virtual_no` 字段,与设备的虚拟号概念对等,供客服和客户通过统一虚拟号查找资产。
|
||
|
||
**字段定义**:
|
||
- 字段名:`virtual_no`(VARCHAR(50),可空)
|
||
- 全局唯一索引:`CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card (virtual_no) WHERE deleted_at IS NULL`
|
||
- 老数据:`virtual_no` 为 NULL(已有卡不强制要求有虚拟号)
|
||
- 允许手动修改
|
||
|
||
**唯一性规则**:
|
||
- 在所有未软删除的卡中唯一(部分索引,deleted_at IS NULL)
|
||
- 导入时与数据库现存数据重复则整批失败,响应中包含冲突的具体 virtual_no 列表
|
||
|
||
**虚拟号的使用场景**:
|
||
- resolve 接口:支持通过 virtual_no 查找卡
|
||
- 客服工单:客服将虚拟号告知客户,客户通过虚拟号自助查询
|
||
|
||
#### Scenario: 为卡设置唯一虚拟号
|
||
|
||
- **WHEN** 管理员为 ICCID 为 "898601234..." 的卡设置 virtual_no = "CARD-001"
|
||
- **THEN** 系统保存成功,`idx_iot_card_virtual_no` 确保全局唯一
|
||
|
||
#### Scenario: 导入批次中有重复虚拟号
|
||
|
||
- **WHEN** ICCID 导入批次中,有 1 条记录的 virtual_no 与数据库现存卡的 virtual_no 重复
|
||
- **THEN** 系统拒绝整批导入,响应中返回冲突的 virtual_no 及所属行号
|
||
|
||
#### Scenario: virtual_no 为空的老卡
|
||
|
||
- **WHEN** 系统中有历史导入的卡,没有 virtual_no
|
||
- **THEN** 这些卡的 virtual_no = NULL,不影响唯一索引(部分索引跳过 NULL 值)
|
||
|
||
### Requirement: IoT 卡资产生命周期字段
|
||
|
||
系统 SHALL 在 `IotCard` 模型新增以下资产生命周期追踪字段:
|
||
- `asset_status int NOT NULL DEFAULT 1`
|
||
- `generation int NOT NULL DEFAULT 1`
|
||
|
||
#### Scenario: 新建 IoT 卡默认资产状态
|
||
- **WHEN** 创建新的 IoT 卡记录
|
||
- **THEN** `asset_status` MUST 默认为 `1`(在库)
|
||
|
||
#### Scenario: 新建 IoT 卡默认代际
|
||
- **WHEN** 创建新的 IoT 卡记录
|
||
- **THEN** `generation` MUST 默认为 `1`
|
||
|
||
---
|
||
|
||
### Requirement: IoT 卡换货状态语义扩展
|
||
|
||
系统 SHALL 将 `asset_status=3` 定义为"已换货",用于标记已被换出、不可继续作为当前代际在售资产的 IoT 卡。
|
||
|
||
#### Scenario: 换货完成后旧卡标记为已换货
|
||
- **WHEN** H5 确认完成且旧资产为 IoT 卡
|
||
- **THEN** 系统 MUST 将旧卡 `asset_status` 更新为 `3`
|
||
|
||
---
|
||
|
||
### Requirement: IoT 卡转新重置规则
|
||
|
||
系统 SHALL 在 H7 转新时对 IoT 卡执行以下重置:
|
||
- `generation = generation + 1`
|
||
- `asset_status = 1`(在库)
|
||
- 清空累计充值与首充触发相关状态(含 `AccumulatedRecharge`、`FirstCommissionPaid`、系列首充/累计字段)
|
||
- 清除个人客户绑定关系
|
||
|
||
#### Scenario: 转新后进入新代际
|
||
- **WHEN** 对旧卡执行转新
|
||
- **THEN** 系统 MUST 使该卡进入新代际并以在库状态重新销售
|
||
|