refactor: 流量系统重构 — 增量累加算法 + 日粒度缓冲 + 旧详单清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m16s

核心改造:
- 增量算法:流量计算从覆盖式改为增量累加(gateway - lastReading),支持上游运营商重置检测
- 日流量缓冲:insertDataUsageRecord 改为 Redis INCRBYFLOAT,每日凌晨落盘到 tb_card_daily_usage
- 运营商:新增 data_reset_day 字段(联通=27,其余=1)
- IoT卡:新增 last_gateway_reading_mb 字段存储上次网关读数
- 查询层:新建 TrafficQueryService 合并 Redis(今日)+ DB(历史)数据源
- 清理:删除 DataUsageRecord model/store,移除 polling_handler 旧引用

迁移:000094-000097(carrier字段、iot_card字段、数据初始化、日流量表)
This commit is contained in:
2026-03-30 09:59:30 +08:00
parent f5dd2ce4ab
commit cebcada950
44 changed files with 1149 additions and 1613 deletions

View File

@@ -246,3 +246,18 @@ This capability supports:
- **WHEN** 开发人员查看代码
- **THEN** 方法命名(`CreateAdminOrder` vs `CreateH5Order`)清楚表明了后台和 H5 端的差异,防止误用
---
## MODIFIED Requirements
### Requirement: 后台订单钱包支付权限校验
后台创建订单时若支付方式为钱包支付Handler 层 SHALL 校验当前操作人为代理账号(`user_type = agent`)。非代理账号使用钱包支付 SHALL 在 Handler 层即被拦截,返回参数错误。
#### Scenario: 非代理账号使用钱包支付被拦截
- **WHEN** 平台用户或企业用户提交订单且 `payment_method = wallet`
- **THEN** Handler SHALL 返回 `CodeInvalidParam` 错误,提示"仅代理账号可使用钱包支付",请求不会到达 Service 层
#### Scenario: 代理账号使用钱包支付正常通过
- **WHEN** 代理账号提交订单且 `payment_method = wallet`
- **THEN** Handler SHALL 放行,由 Service 层继续处理钱包扣款逻辑

View File

@@ -170,3 +170,14 @@
- **WHEN** 调用 `RefreshCardDataFromGateway(ctx, "89860123456789012345")`
- **THEN** 系统调用网关接口,将 network_status、real_name_status、current_month_usage_mb、last_sync_time 写回 DB
---
## MODIFIED Requirements
### Requirement: 资产查询错误处理
资产查询 Service 在获取绑定卡信息时,数据库查询错误 SHALL 被记录到日志而非被静默忽略。查询失败 SHALL NOT 中断主查询流程,但 SHALL 记录 Warn 级别日志,包含失败的卡 ID 列表和错误详情。
#### Scenario: 绑定卡查询失败时记录日志
- **WHEN** `iotCardStore.GetByIDs()` 返回错误
- **THEN** 系统 SHALL 记录 Warn 日志(含 card_ids 和 error继续返回已有数据不返回错误给调用方

View File

@@ -172,3 +172,18 @@
- **WHEN** 前端调用 `POST /api/admin/enterprises/:id/cards/:card_id/suspend`
- **THEN** 系统返回 HTTP 404路由已不存在
---
## MODIFIED Requirements
### Requirement: 停复机回调注入
系统启动时 SHALL 在 bootstrap 阶段注入停复机回调,确保停机/复机操作完成后自动触发套餐联动逻辑(暂停/恢复套餐计时)。
#### Scenario: 系统启动后回调已注入
- **WHEN** API 或 Worker 服务完成 bootstrap 初始化
- **THEN** `usageService.SetStopResumeCallback(stopResumeService)``activationService.SetResumeCallback(stopResumeService)` SHALL 已被调用
#### Scenario: 停机触发套餐暂停
- **WHEN** 管理员对卡执行停机操作且回调已注入
- **THEN** 停机成功后 SHALL 自动触发套餐暂停联动逻辑

View File

@@ -1,151 +1,16 @@
# carrier Specification
## MODIFIED Requirements
## Purpose
管理运营商(Carrier)实体,支持四大固定运营商(中国移动、中国联通、中国电信、广电)的 CRUD 操作
### Requirement: 运营商上游流量重置日配置
`tb_carrier` SHALL 包含 `data_reset_day` 字段INT, 1-28表示该运营商每月上游流量重置日即上游运营商清零网关计数器的日期。创建/编辑运营商时 SHALL 支持设置此字段
## Requirements
注意:此字段与套餐级别的 `data_reset_cycle`daily/monthly/yearly是**完全独立的两个维度**。`data_reset_day` 用于检测上游网关值下降是否为正常重置,`data_reset_cycle` 用于我们系统套餐的已用量定时归零。
### Requirement: 运营商实体定义
系统 SHALL 定义运营商(Carrier)实体,管理四大固定运营商(中国移动、中国联通、中国电信、广电)。
**四大运营商固定枚举**
- **CMCC**:中国移动
- **CUCC**:中国联通
- **CTCC**:中国电信
- **CBN**:广电
**实体字段**
- `id`:运营商 ID主键BIGINT
- `carrier_code`运营商编码VARCHAR(50),唯一约束)
- `carrier_name`运营商名称VARCHAR(100),如"中国移动"
- `carrier_type`运营商类型VARCHAR(20),枚举值:"CMCC" | "CUCC" | "CTCC" | "CBN"
- `description`运营商描述VARCHAR(500),可选)
- `status`状态INT1-启用 0-禁用)
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**唯一约束**`carrier_code``deleted_at IS NULL` 条件下唯一
---
### Requirement: 创建运营商
系统 SHALL 允许管理员创建新的运营商记录。创建时必须指定 carrier_code唯一编码、carrier_name显示名称、carrier_type运营商类型枚举值。description 为选填字段。创建成功后默认状态为启用status=1
#### Scenario: 成功创建运营商
- **WHEN** 管理员提交有效的创建请求carrier_code 不重复carrier_type 为有效枚举值
- **THEN** 系统创建运营商记录,返回完整的运营商信息
#### Scenario: carrier_code 重复
- **WHEN** 管理员提交的 carrier_code 已存在
- **THEN** 系统返回错误"运营商编码已存在"
#### Scenario: carrier_type 无效
- **WHEN** 管理员提交的 carrier_type 不是 CMCC/CUCC/CTCC/CBN 之一
- **THEN** 系统返回参数校验错误
---
### Requirement: 查询运营商列表
系统 SHALL 提供分页查询运营商列表的接口,支持按 carrier_type、status、carrier_name模糊搜索筛选。
#### Scenario: 无筛选条件查询
- **WHEN** 管理员请求列表,不带筛选条件
- **THEN** 系统返回所有运营商的分页列表,按 ID 降序排列
#### Scenario: 按运营商类型筛选
- **WHEN** 管理员指定 carrier_type=CMCC
- **THEN** 系统仅返回 carrier_type 为 CMCC 的记录
#### Scenario: 按名称模糊搜索
- **WHEN** 管理员指定 carrier_name=移动
- **THEN** 系统返回 carrier_name 包含"移动"的记录
---
### Requirement: 获取运营商详情
系统 SHALL 允许管理员通过 ID 获取单个运营商的详细信息。
#### Scenario: 成功获取详情
- **WHEN** 管理员请求存在的运营商 ID
- **THEN** 系统返回该运营商的完整信息
#### Scenario: 运营商不存在
- **WHEN** 管理员请求不存在的运营商 ID
- **THEN** 系统返回错误"运营商不存在"
---
### Requirement: 更新运营商
系统 SHALL 允许管理员更新运营商的 carrier_name 和 description 字段。carrier_code 和 carrier_type 创建后不可修改。
#### Scenario: 成功更新运营商
- **WHEN** 管理员提交有效的更新请求
- **THEN** 系统更新运营商信息,返回更新后的完整信息
#### Scenario: 尝试修改 carrier_code
- **WHEN** 管理员尝试修改 carrier_code
- **THEN** 系统忽略该字段(不报错,但不修改)
---
### Requirement: 删除运营商
系统 SHALL 允许管理员软删除运营商记录。
#### Scenario: 成功删除运营商
- **WHEN** 管理员请求删除存在的运营商
- **THEN** 系统软删除该记录(设置 deleted_at
#### Scenario: 删除不存在的运营商
- **WHEN** 管理员请求删除不存在的运营商 ID
- **THEN** 系统返回错误"运营商不存在"
---
### Requirement: 更新运营商状态
系统 SHALL 允许管理员启用或禁用运营商。状态值1=启用0=禁用。
#### Scenario: 启用运营商
- **WHEN** 管理员将状态设置为 1
- **THEN** 系统更新运营商状态为启用
#### Scenario: 禁用运营商
- **WHEN** 管理员将状态设置为 0
- **THEN** 系统更新运营商状态为禁用
#### Scenario: 无效状态值
- **WHEN** 管理员提交的状态值不是 0 或 1
- **THEN** 系统返回参数校验错误
---
### Requirement: 运营商数据校验
系统 SHALL 对运营商数据进行校验,确保数据完整性和一致性。
**校验规则**
- `carrier_type`:必填,枚举值 "CMCC" | "CUCC" | "CTCC" | "CBN"
- `carrier_name`:必填,长度 1-100 字符
- `carrier_code`:必填,长度 1-50 字符
- `description`:可选,长度 0-500 字符
- `status`:必填,枚举值 0 或 1
#### Scenario: 创建运营商时 carrier_type 无效
- **WHEN** 创建运营商,`carrier_type` 为 "INVALID"
- **THEN** 系统拒绝创建,返回错误信息"运营商类型无效"
#### Scenario: 创建运营商时 carrier_name 为空
- **WHEN** 创建运营商,`carrier_name` 为空
- **THEN** 系统拒绝创建,返回错误信息"运营商名称不能为空"
#### Scenario: 创建运营商时指定重置日
- **WHEN** 管理员创建运营商,指定 `data_reset_day = 27`
- **THEN** 该运营商记录的 `data_reset_day` SHALL 为 27
#### Scenario: 轮询时读取重置日判断上游重置
- **WHEN** 轮询系统检测到网关流量值下降(`increment < 0`
- **THEN** 系统 SHALL 读取该卡对应运营商的 `data_reset_day`
- **AND** 使用 `isResetWindow(now, resetDay)` 判断是否在重置日窗口内(重置日当天 + 前一天容错)
- **AND** 窗口内视为正常上游重置,窗口外记录 Warn 日志并丢弃异常值

View File

@@ -44,3 +44,20 @@
#### Scenario: 异步任务连续失败
- **WHEN** AutoPurchaseAfterRecharge 连续执行失败且达到最大重试次数
- **THEN** 系统将充值记录 `auto_purchase_status` 更新为 `failed`
---
## MODIFIED Requirements
### Requirement: C端支付状态枚举统一
C端订单查询接口返回的 `payment_status` SHALL 直接使用管理端统一枚举值1=待支付, 2=已支付, 3=已取消, 4=已退款),不再通过映射函数转换为 0/1/2。
#### Scenario: C端查询订单返回统一枚举
- **WHEN** C端客户查询订单列表或订单详情
- **THEN** 返回的 `payment_status` SHALL 为 1/2/3/4 之一,与管理端一致
## REMOVED Requirements
### Requirement: C端支付状态映射
**Reason**: `orderStatusToClientStatus()` 函数将管理端 1/2/3/4 映射为 C端 0/1/2造成前后端枚举不一致增加维护负担。
**Migration**: C端前端需更新支付状态枚举解析1=待支付, 2=已支付, 3=已取消, 4=已退款。

View File

@@ -1,789 +1,37 @@
# IoT Card Management
## MODIFIED Requirements
## Purpose
### Requirement: IoT 卡网关读数记录
`tb_iot_card` SHALL 包含 `last_gateway_reading_mb` 字段FLOAT, 默认 0记录上次轮询时网关返回的流量读数用于计算增量。
Manage IoT cards (SIM cards) for the IoT management system, including inventory management, distribution, activation, status tracking, and Gateway integration.
#### Scenario: 轮询更新网关读数
- **WHEN** 轮询系统获取到网关流量值
- **THEN** 系统 SHALL 将 `last_gateway_reading_mb` 更新为本次网关返回值(无论增量是否 > 0
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 卡实体定义
### Requirement: 月流量增量累加
`current_month_usage_mb` SHALL 使用增量累加(`+= increment`)而非直接覆盖(`= gatewayValue`)。增量 = 当前网关读数 - 上次网关读数(`last_gateway_reading_mb`)。
系统 SHALL 定义 IoT 卡(IotCard)实体,包含 IoT 卡(物联网卡/流量卡/SIM卡)的商品属性、状态属性、所有权信息和 Gateway 集成字段。
#### Scenario: 正常流量增长
- **WHEN** 上次读数 100MB本次读数 105MB
- **THEN** `current_month_usage_mb` SHALL 增加 5MB而非被覆盖为 105MB
- **AND** `data_usage_mb`卡生命周期总用量SHALL 同步增加 5MB
**核心概念**: IoT 卡 = 物联网卡 = SIM 卡 = 网卡 = 流量卡(同一个东西,不同叫法)。系统使用 ICCID 作为 IoT 卡的唯一标识。
#### Scenario: 上游自然月重置
- **WHEN** 上次读数 500MB本次读数 10MB且在运营商重置日窗口内
- **THEN** `increment` SHALL 为 10MB本次原始值即为增量`current_month_usage_mb += 10`
**卡业务类型**:
- **普通卡(normal)**: 需要实名认证才能激活使用,遵循运营商实名制要求
- **行业卡(industry)**: 不需要实名认证,可以直接激活使用,适用于企业/行业客户批量采购场景
#### Scenario: 非重置日异常下降
- **WHEN** 上次读数 500MB本次读数 10MB但不在重置日窗口内
- **THEN** `increment` SHALL 为 0记录 Warn 日志,`current_month_usage_mb` 不变
**实体字段**:
### Requirement: 跨自然月重置
当检测到系统跨自然月时,`current_month_usage_mb` SHALL 重置为 0不再等于 `gatewayFlowMB``last_month_total_mb` SHALL 记录上月累计值。
**商品属性**:
- `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 时有值)
#### Scenario: 跨月轮询
- **WHEN** 上次轮询在 3 月,本次轮询在 4 月
- **THEN** `last_month_total_mb` = 原 `current_month_usage_mb``current_month_usage_mb` = 0`current_month_start_date` 更新为本月 1 日
**所有权和状态**:
- `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 更新为 10status 从 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 为 20status 保持 2
#### Scenario: 拒绝跨级分配
- **WHEN** 平台尝试将卡直接分配给二级代理店铺(非直属下级)
- **THEN** 系统拒绝分配,返回错误"只能分配给直属下级店铺"
#### Scenario: 拒绝平台分配已分销的卡
- **WHEN** 平台尝试分配状态为已分销(status=2)的卡
- **THEN** 系统拒绝分配,返回错误"在库状态的卡才能分配,请先回收"
#### Scenario: 拒绝分配已绑定设备的卡
- **WHEN** 用户尝试分配已绑定设备的卡(在 device_sim_bindings 中 bind_status=1
- **THEN** 系统拒绝分配,返回错误"已绑定设备的卡不能单独分配"
---
### Requirement: 单卡回收功能
系统 SHALL 支持上级回收已分配给直属下级的单卡,将卡的所有权收回。
**回收规则**:
- 只有上级可以回收,代理不能主动退回给上级
- 只能回收直属下级的卡,不可跨级回收
- 平台回收shop_id 变为 NULLstatus 变为 1在库
- 店铺回收shop_id 变为执行回收的店铺 IDstatus 保持 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 更新为 NULLstatus 从 2 变为 1创建回收记录
#### Scenario: 一级代理回收二级代理的单卡
- **WHEN** 一级代理(店铺 ID=10选择二级代理店铺ID=20的 3 张单卡进行回收
- **THEN** 系统将这 3 张卡的 shop_id 更新为 10status 保持 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 使该卡进入新代际并以在库状态重新销售
### Requirement: 新卡首次轮询
新入库的卡 `last_gateway_reading_mb` 默认为 0首次轮询的增量 = 网关返回的全量值。这是预期行为。批量导入已有使用量的卡时,导入脚本应同步设置 `last_gateway_reading_mb`
### Requirement: 增量函数合并
`calculateFlowUpdates()``calculateFlowIncrement()` SHALL 合并为一个函数,返回 `(updates map[string]any, increment float64)`。消除两个独立增量计算函数的不一致风险。

View File

@@ -1,465 +1,16 @@
# Spec: 套餐流量日记录
## MODIFIED Requirements
## 业务背景
### Requirement: 流量详单存储方式
卡级流量详单 SHALL 从无差别直接写 DB 改为 Redis 缓冲 + 日落盘。`insertDataUsageRecord()` SHALL NOT 再创建 `DataUsageRecord` 记录,直接替换为 Redis 写入。
### 为什么需要流量日记录
#### Scenario: 轮询写流量数据
- **WHEN** 轮询系统检测到卡的流量变化(增量 > 0
- **THEN** 系统 SHALL 仅写 Redis 缓冲(`INCRBYFLOAT`),不写 DB
**现状问题**
- 用户需要查看每日流量使用明细(哪天用了多少流量)
- 套餐流量重置后,历史使用数据丢失
- 无法统计和分析用户流量使用趋势
- 计费对账需要每日流量记录
#### Scenario: 旧代码清理
- **WHEN** 新存储路径完全就绪
- **THEN** SHALL 删除 `DataUsageRecord` model、`DataUsageRecordStore`、bootstrap 中的相关引用
- **AND** `tb_data_usage_record` 旧表数据暂保留在 DB后续通过数据清理功能清除代码层完全去除依赖
**业务目标**
- 按套餐维度记录每日流量增量
- 支持按日期范围查询流量详单
- 流量重置后历史记录仍可查询
- 为计费对账和数据分析提供基础数据
---
## 业务规则
### 1. 日记录写入规则
每次流量扣减后,写入或更新当日记录:
```
写入流量日记录:
1. 获取当前日期date=today
2. 查询是否已有今日记录:
SELECT * FROM package_usage_daily_record
WHERE package_usage_id=? AND date=today
3. 如果存在 → UPDATE daily_usage_mb += increment
4. 如果不存在 → INSERT (package_usage_id, date, daily_usage_mb, cumulative_usage_mb)
5. 使用 UPSERTON CONFLICT UPDATE确保幂等性
```
### 2. 流量增量计算
```
每日流量增量 = 今日上游返回的累计流量 - 昨日记录的累计流量
特殊情况:
- 如果昨日无记录 → 增量 = 今日上游累计流量
- 如果上游重置(今日累计 < 昨日累计)→ 增量 = 今日上游累计流量
```
### 3. cumulative_usage_mb 字段
- **定义**:截止到当日的累计流量
- **计算规则**cumulative_usage_mb = 昨日 cumulative_usage_mb + 今日 daily_usage_mb
- **首日规则**:首日 cumulative_usage_mb = daily_usage_mb
### 4. 数据保留策略
- **保留期限**永久保留或根据业务需求保留1年/2年
- **流量重置不删除**:套餐流量重置后,日记录仍保留
- **套餐过期不删除**:套餐过期后,日记录仍保留
---
## ADDED Requirements
### Requirement: 按套餐维度记录每日流量
系统 SHALL 为每个 PackageUsage 创建每日流量记录PackageUsageDailyRecord记录每天的流量增量。
#### Scenario: 首次记录当日流量
- **GIVEN** 套餐 ID=123 在 2026-02-10 首次产生流量 1.5GB
- **WHEN** 流量扣减完成
- **THEN** 系统创建 PackageUsageDailyRecord
- package_usage_id=123
- date=2026-02-10
- daily_usage_mb=1536 (1.5GB)
- cumulative_usage_mb=1536
#### Scenario: 同一天多次流量更新
- **GIVEN** 套餐在 2026-02-10 已记录 1GB 流量
- **WHEN** 再产生 0.5GB 流量
- **THEN** 系统更新 PackageUsageDailyRecord
- daily_usage_mb=15361GB+0.5GB
- cumulative_usage_mb=1536
#### Scenario: 跨天流量记录
- **GIVEN** 套餐在 2026-02-10 使用 2GB
- **AND** 2026-02-11 使用 3GB
- **WHEN** 流量扣减完成
- **THEN** 系统创建两条记录:
- 2月10日daily_usage_mb=2GB, cumulative_usage_mb=2GB
- 2月11日daily_usage_mb=3GB, cumulative_usage_mb=5GB
#### Scenario: 流量重置后日记录仍保留
- **GIVEN** 套餐在 2月1日至2月28日有28条日记录
- **WHEN** 3月1日 00:00:00 触发流量重置
- **THEN** 套餐 data_usage_mb 重置为 0
- **AND** 2月的28条日记录仍存在且可查询
### Requirement: 流量增量基于上游查询计算
系统 SHALL 根据上游返回的累计流量,减去昨日记录的累计流量,计算每日增量。
#### Scenario: 计算每日流量增量
- **GIVEN** 昨日2月9日记录 cumulative_usage_mb=10GB
- **WHEN** 今日2月10日上游返回 cumulative=13GB
- **THEN** 今日 daily_usage_mb=3GB13GB - 10GB
- **AND** 今日 cumulative_usage_mb=13GB
#### Scenario: 上游周期重置后流量计算
- **GIVEN** 联通卡在 2月27日 00:00:00 上游重置
- **AND** 昨日2月26日记录 cumulative_usage_mb=15GB
- **WHEN** 今日2月27日上游返回 cumulative=2GB
- **THEN** 今日 daily_usage_mb=2GB上游重置取新增量
- **AND** 今日 cumulative_usage_mb=2GB
#### Scenario: 首日无昨日记录
- **GIVEN** 套餐首次激活,无任何日记录
- **WHEN** 上游返回 cumulative=5GB
- **THEN** 今日 daily_usage_mb=5GB
- **AND** 今日 cumulative_usage_mb=5GB
### Requirement: 支持按日期查询套餐流量详单
系统 SHALL 提供 API 查询指定套餐的每日流量记录。
#### Scenario: 查询套餐流量详单
- **WHEN** 用户通过 GET /api/admin/package-usage/:id/daily-records 查询套餐流量详单
- **THEN** 系统返回按日期排序的流量记录列表:
```json
{
"code": 200,
"data": [
{
"date": "2026-02-01",
"daily_usage_mb": 1024,
"cumulative_usage_mb": 1024
},
{
"date": "2026-02-02",
"daily_usage_mb": 2048,
"cumulative_usage_mb": 3072
}
]
}
```
#### Scenario: 查询指定日期范围
- **GIVEN** 套餐有 2月1日 至 2月28日 的流量记录
- **WHEN** 用户查询流量详单,参数 start_date=2026-02-01, end_date=2026-02-10
- **THEN** 系统返回 2月1日 至 2月10日 的流量记录10条
#### Scenario: 客户端查询自己的流量详单
- **WHEN** 客户通过 GET /api/customer/package-usage/:id/daily-records 查询
- **THEN** 系统校验套餐归属后,返回流量记录列表
### Requirement: 日记录索引优化
系统 SHALL 在 PackageUsageDailyRecord 表创建 (package_usage_id, date) 联合唯一索引。
#### Scenario: 同一套餐同一天只有一条记录
- **WHEN** 系统尝试为同一 package_usage_id=123 和 date=2026-02-10 创建第二条记录
- **THEN** 数据库返回唯一约束冲突错误
- **AND** 使用 UPSERT 自动转为 UPDATE 操作
#### Scenario: 查询性能达标
- **GIVEN** 套餐 ID=123 有 365 条日记录(一年数据)
- **WHEN** 查询全部流量详单
- **THEN** 查询响应时间 < 50ms
---
## 边界条件
### 1. 套餐过期后的日记录
- **场景**:套餐在 2月28日过期3月1日仍可查询历史日记录
- **处理**:日记录永久保留,不随套餐过期删除
### 2. 并发写入同一天记录
- **场景**:同一套餐在同一天有多个并发流量扣减请求
- **处理**:使用 UPSERTON CONFLICT UPDATE确保幂等性
### 3. 跨月查询日记录
- **场景**:查询 1月15日 至 2月15日 的日记录(跨月)
- **处理**:按日期范围查询,返回跨月数据
---
## 并发场景
### Scenario: 并发写入同一天记录
- **GIVEN** 套餐 ID=123 在 2026-02-10 10:00:00 和 10:00:01 同时扣减流量
- **WHEN** 两个请求同时写入日记录
- **THEN** 使用 UPSERTON CONFLICT UPDATE
```sql
INSERT INTO package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb)
VALUES (123, '2026-02-10', 1024, 1024)
ON CONFLICT (package_usage_id, date)
DO UPDATE SET
daily_usage_mb = package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb,
cumulative_usage_mb = package_usage_daily_record.cumulative_usage_mb + EXCLUDED.daily_usage_mb;
```
- **AND** 两个请求的流量累加到同一条记录
---
## 异常处理
### 1. 日记录写入失败
- **错误场景**:流量扣减成功,但日记录写入失败(数据库连接断开)
- **处理流程**
1. 不回滚流量扣减(已提交)
2. 记录 Error 日志包含套餐ID、日期、流量增量
3. 通过定时任务补录日记录
- **返回错误**:不影响用户,日记录补录在后台进行
### 2. 查询日记录超时
- **错误场景**查询大量日记录时超时如查询3年数据
- **处理流程**
1. 限制单次查询最多返回 365 条记录
2. 如果超过限制,返回错误 400"查询日期范围过大最多查询1年"
- **返回错误**`{"code": "DATE_RANGE_TOO_LARGE", "msg": "查询日期范围过大最多查询1年"}`
---
## 数据一致性保证
### 1. 事务边界
- **流量扣减 + 写入日记录**:使用单个事务(可选,根据业务需求)
- **查询日记录**:使用只读事务
### 2. 唯一索引
- **联合唯一索引**`UNIQUE INDEX idx_package_usage_daily_record (package_usage_id, date)`
- **确保同一套餐同一天只有一条记录**
### 3. UPSERT 幂等性
- **使用 ON CONFLICT UPDATE**:确保并发写入时累加流量而非覆盖
---
## 性能指标
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|------|-------------|---------|--------|
| 写入日记录UPSERT | < 10ms | 1000 QPS | 单条插入/更新 |
| 查询日记录(单套餐) | < 50ms | 100 QPS | 查询365条记录 |
| 查询日记录(日期范围) | < 100ms | 100 QPS | 查询指定范围 |
---
## 错误码定义
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| `DATE_RANGE_TOO_LARGE` | 400 | 查询日期范围过大最多查询1年 | 查询日记录日期范围超过365天 |
| `DAILY_RECORD_NOT_FOUND` | 404 | 未找到流量记录 | 查询不存在的日记录 |
---
## 数据迁移策略
**激进策略**(开发阶段,保证干净性):
### 1. ❌ 要删除的字段
目前 `package_usage_daily_record` 表中可能存在的冗余字段(需确认后删除):
- 如果有 `daily_increment` 字段(旧的增量字段) → **删除**,统一使用 `daily_usage_mb`
- 如果有 `total_usage` 字段(旧的累计字段) → **删除**,统一使用 `cumulative_usage_mb`
### 2. ✅ 新增的字段
在 `package_usage_daily_record` 表中确保有以下字段:
```sql
CREATE TABLE IF NOT EXISTS package_usage_daily_record (
id BIGSERIAL PRIMARY KEY,
package_usage_id BIGINT NOT NULL COMMENT '套餐使用记录ID',
date DATE NOT NULL COMMENT '日期',
daily_usage_mb INT DEFAULT 0 COMMENT '当日流量使用量MB',
cumulative_usage_mb BIGINT DEFAULT 0 COMMENT '截止当日的累计流量MB',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY idx_package_usage_daily_record (package_usage_id, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐流量日记录';
CREATE INDEX idx_date ON package_usage_daily_record(date);
```
### 3. ❌ 要废弃的逻辑
- **废弃旧的日记录写入逻辑**:如果代码中存在不使用 UPSERT 的写入逻辑,全部删除
- **废弃旧的日记录查询逻辑**:统一使用新的查询接口
### 4. ✅ 历史数据强制转换
```sql
-- Step 1: 如果有旧的字段名,重命名
-- ALTER TABLE package_usage_daily_record CHANGE daily_increment daily_usage_mb INT;
-- ALTER TABLE package_usage_daily_record CHANGE total_usage cumulative_usage_mb BIGINT;
-- Step 2: 修复 cumulative_usage_mb如果历史数据不准确
-- 重新计算每个套餐的 cumulative_usage_mb
-- 需要按套餐ID分组按日期排序累加 daily_usage_mb
-- Step 3: 确保唯一索引存在
CREATE UNIQUE INDEX IF NOT EXISTS idx_package_usage_daily_record
ON package_usage_daily_record(package_usage_id, date);
```
### 5. ❌ 删除遗留表/字段(确认后执行)
```sql
-- 如果存在旧的日记录表,删除
-- DROP TABLE IF EXISTS iot_card_usage_daily;
-- 如果存在旧的字段,删除
-- ALTER TABLE package_usage_daily_record DROP COLUMN IF EXISTS daily_increment;
-- ALTER TABLE package_usage_daily_record DROP COLUMN IF EXISTS total_usage;
```
### 6. 验证步骤
```sql
-- 验证1所有日记录都有 daily_usage_mb 和 cumulative_usage_mb
SELECT COUNT(*)
FROM package_usage_daily_record
WHERE daily_usage_mb IS NULL OR cumulative_usage_mb IS NULL;
-- 预期结果0
-- 验证2同一套餐同一天只有一条记录
SELECT package_usage_id, date, COUNT(*)
FROM package_usage_daily_record
GROUP BY package_usage_id, date
HAVING COUNT(*) > 1;
-- 预期结果0 rows
-- 验证3累计流量单调递增同一套餐
-- (需要编写复杂查询验证,略)
```
---
## 测试场景矩阵
| 场景分类 | 测试用例 | 预期结果 |
|---------|---------|---------|
| **写入日记录** | 首次记录当日流量 | 创建新记录 |
| | 同一天多次流量更新 | 更新已有记录UPSERT |
| | 跨天流量记录 | 创建多条记录 |
| **流量增量计算** | 计算每日流量增量 | daily_usage_mb = 今日累计 - 昨日累计 |
| | 上游周期重置后计算 | daily_usage_mb = 今日累计(重置后) |
| | 首日无昨日记录 | daily_usage_mb = 今日累计 |
| **查询日记录** | 查询套餐流量详单 | 返回按日期排序的记录列表 |
| | 查询指定日期范围 | 返回指定范围内的记录 |
| | 客户端查询自己的详单 | 校验归属后返回 |
| **索引和性能** | 同一套餐同一天只有一条记录 | 唯一约束保证 |
| | 查询365条记录 | 响应时间 < 50ms |
| **并发** | 并发写入同一天记录 | UPSERT 确保累加 |
| **异常** | 日记录写入失败 | 不回滚流量扣减,后台补录 |
| | 查询日记录超时 | 限制日期范围,返回错误 |
---
## 实现参考
### 写入日记录UPSERT
```go
// Service 层RecordDailyUsage
func (s *Service) RecordDailyUsage(ctx context.Context, usageID uint, date time.Time, dailyUsageMB int, cumulativeUsageMB int64) error {
record := &model.PackageUsageDailyRecord{
PackageUsageID: usageID,
Date: date,
DailyUsageMB: dailyUsageMB,
CumulativeUsageMB: cumulativeUsageMB,
}
if err := s.store.UpsertDailyRecord(ctx, record); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入流量日记录失败")
}
return nil
}
// Store 层UpsertDailyRecord
func (s *Store) UpsertDailyRecord(ctx context.Context, record *model.PackageUsageDailyRecord) error {
// PostgreSQL UPSERT
return s.db.WithContext(ctx).Exec(`
INSERT INTO package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at)
VALUES (?, ?, ?, ?, NOW(), NOW())
ON CONFLICT (package_usage_id, date)
DO UPDATE SET
daily_usage_mb = package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb,
cumulative_usage_mb = package_usage_daily_record.cumulative_usage_mb + (EXCLUDED.daily_usage_mb),
updated_at = NOW()
`, record.PackageUsageID, record.Date, record.DailyUsageMB, record.CumulativeUsageMB).Error
}
```
### 查询日记录
```go
// Handler: GetDailyRecords
func (h *Handler) GetDailyRecords(c *fiber.Ctx) error {
usageID, _ := c.ParamsInt("id")
startDate := c.Query("start_date", "")
endDate := c.Query("end_date", "")
// 查询日记录
records, err := h.service.GetDailyRecords(c.UserContext(), uint(usageID), startDate, endDate)
if err != nil {
return err
}
return response.Success(c, records)
}
// Service 层GetDailyRecords
func (s *Service) GetDailyRecords(ctx context.Context, usageID uint, startDate, endDate string) ([]*model.PackageUsageDailyRecord, error) {
// 参数校验
start, err := time.Parse("2006-01-02", startDate)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "起始日期格式错误")
}
end, err := time.Parse("2006-01-02", endDate)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "结束日期格式错误")
}
// 限制查询范围
if end.Sub(start).Hours() > 365*24 {
return nil, errors.New(errors.CodeInvalidParam, "查询日期范围过大最多查询1年")
}
// 查询日记录
return s.store.ListDailyRecords(ctx, usageID, start, end)
}
// Store 层ListDailyRecords
func (s *Store) ListDailyRecords(ctx context.Context, usageID uint, startDate, endDate time.Time) ([]*model.PackageUsageDailyRecord, error) {
var records []*model.PackageUsageDailyRecord
err := s.db.WithContext(ctx).
Where("package_usage_id = ? AND date >= ? AND date <= ?", usageID, startDate, endDate).
Order("date ASC").
Find(&records).Error
return records, err
}
```
---
**本 Spec 完成**,包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(写入、查询、增量计算)
- ✅ 边界条件和并发场景
- ✅ 异常处理和数据一致性保证
- ✅ 性能指标和错误码定义
-**激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
- ✅ 测试场景矩阵和实现参考
### Clarification: 套餐级日记录不受影响
`tb_package_usage_daily_record`(套餐级日记录)由 `UsageService.updateDailyRecord()` 在轮询将增量记录到套餐已用量时同步写入,与本次改造的卡级 `tb_card_daily_usage` 是两个完全不同的维度,互不影响。

View File

@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: 流量增量写入 Redis 缓冲
轮询检测到流量增量 > 0 时,系统 SHALL 使用 `INCRBYFLOAT` 将增量写入 Redis key格式 `traffic:daily:{card_id}:{YYYY-MM-DD}`TTL 48 小时。增量 <= 0 时 SHALL NOT 写入。**直接替换旧的 `insertDataUsageRecord()` 写 DB 路径,不双写。**
#### Scenario: 有流量增量时写 Redis
- **WHEN** 轮询检测到卡 ID=100 今日流量增量为 5.2 MB
- **THEN** 系统 SHALL 执行 `INCRBYFLOAT traffic:daily:100:2026-03-28 5.2`,并设置 48h TTL
#### Scenario: 无流量增量时跳过
- **WHEN** 轮询检测到流量增量 <= 0
- **THEN** 系统 SHALL NOT 执行任何 Redis 或 DB 写入
### Requirement: 每日落盘定时任务
系统 SHALL 每天凌晨 2 点Asia/Shanghai通过 Asynq Scheduler 触发落盘任务。
#### Scenario: 正常落盘(分批处理)
- **WHEN** 凌晨 2 点落盘任务执行
- **THEN** 系统 SHALL 分批 SCAN 所有 `traffic:daily:*:{昨日}` key每批 COUNT 500
- **AND** 分批 UPSERT 到 `tb_card_daily_usage`(每批 200 条,**覆盖语义**保证幂等:`ON CONFLICT DO UPDATE SET usage_mb = EXCLUDED.usage_mb`
- **AND** 分批 Pipeline 删除已落盘的 Redis key
- **AND** 记录日志:落盘条数、耗时
#### Scenario: 落盘失败重试
- **WHEN** 落盘任务执行失败
- **THEN** Asynq SHALL 自动重试MaxRetry=3超时 5 分钟Redis key 因 48h TTL 仍可用
- **AND** 因 UPSERT 使用覆盖语义,重试时不会导致数据翻倍
### Requirement: tb_card_daily_usage 表结构
`usage_mb` 字段 SHALL 使用 `NUMERIC(12,2)` 类型(匹配 Redis `INCRBYFLOAT` 的浮点精度和现有 `current_month_usage_mb``decimal(10,2)` 类型),不使用 BIGINT。

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: 统一流量查询服务
`TrafficQueryService.GetDailyUsage()` SHALL 合并 Redis今日和 DB历史两段数据返回指定日期范围内的每日流量列表。
#### Scenario: 查询含今日的日期范围
- **WHEN** 查询 2026-03-25 到 2026-03-28今天
- **THEN** 系统 SHALL 从 `tb_card_daily_usage` 查 3/25~3/27从 Redis 查 3/28合并排序后返回
#### Scenario: 查询纯历史日期范围
- **WHEN** 查询 2026-03-01 到 2026-03-20
- **THEN** 系统 SHALL 仅从 `tb_card_daily_usage` 查询,不访问 Redis
### Requirement: CardDailyUsageStore 共用
落盘任务(`daily_traffic_flush.go`)和查询服务(`TrafficQueryService`SHALL 共用同一个 `CardDailyUsageStore`,避免重复实现 UPSERT 和查询逻辑。