refactor: 流量系统重构 — 增量累加算法 + 日粒度缓冲 + 旧详单清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m16s
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:
@@ -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 层继续处理钱包扣款逻辑
|
||||
|
||||
@@ -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),继续返回已有数据,不返回错误给调用方
|
||||
|
||||
@@ -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 自动触发套餐暂停联动逻辑
|
||||
|
||||
@@ -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`:状态(INT,1-启用 0-禁用)
|
||||
- `creator`:创建人 ID(BIGINT)
|
||||
- `updater`:更新人 ID(BIGINT)
|
||||
- `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 日志并丢弃异常值
|
||||
|
||||
@@ -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=已退款。
|
||||
|
||||
@@ -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 更新为 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 使该卡进入新代际并以在库状态重新销售
|
||||
### Requirement: 新卡首次轮询
|
||||
新入库的卡 `last_gateway_reading_mb` 默认为 0,首次轮询的增量 = 网关返回的全量值。这是预期行为。批量导入已有使用量的卡时,导入脚本应同步设置 `last_gateway_reading_mb`。
|
||||
|
||||
### Requirement: 增量函数合并
|
||||
`calculateFlowUpdates()` 和 `calculateFlowIncrement()` SHALL 合并为一个函数,返回 `(updates map[string]any, increment float64)`。消除两个独立增量计算函数的不一致风险。
|
||||
|
||||
@@ -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. 使用 UPSERT(ON 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=1536(1GB+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=3GB(13GB - 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. 并发写入同一天记录
|
||||
|
||||
- **场景**:同一套餐在同一天有多个并发流量扣减请求
|
||||
- **处理**:使用 UPSERT(ON CONFLICT UPDATE)确保幂等性
|
||||
|
||||
### 3. 跨月查询日记录
|
||||
|
||||
- **场景**:查询 1月15日 至 2月15日 的日记录(跨月)
|
||||
- **处理**:按日期范围查询,返回跨月数据
|
||||
|
||||
---
|
||||
|
||||
## 并发场景
|
||||
|
||||
### Scenario: 并发写入同一天记录
|
||||
- **GIVEN** 套餐 ID=123 在 2026-02-10 10:00:00 和 10:00:01 同时扣减流量
|
||||
- **WHEN** 两个请求同时写入日记录
|
||||
- **THEN** 使用 UPSERT(ON 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` 是两个完全不同的维度,互不影响。
|
||||
|
||||
30
openspec/specs/traffic-daily-buffer/spec.md
Normal file
30
openspec/specs/traffic-daily-buffer/spec.md
Normal 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。
|
||||
15
openspec/specs/traffic-query-service/spec.md
Normal file
15
openspec/specs/traffic-query-service/spec.md
Normal 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 和查询逻辑。
|
||||
Reference in New Issue
Block a user