feat: 新增数据库迁移,重命名 device_no 为 virtual_no,新增 iot_card.virtual_no 和 package.virtual_ratio 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-14
|
||||
@@ -0,0 +1,121 @@
|
||||
## Context
|
||||
|
||||
当前系统中 IoT 卡和设备的详情体系存在三大问题:
|
||||
|
||||
1. **接口分散**:Admin/H5 两端各自实现了停复机接口,逻辑重复且行为不一致
|
||||
2. **数据贫血**:`IotCardDetailResponse` 是 `StandaloneIotCardResponse` 的空包装,`DeviceResponse` 仅返回 `BoundCardCount` 一个数字,无法支撑前端渲染完整详情页
|
||||
3. **网关裸透传**:`SyncCardStatusFromGateway`(改名后为 `RefreshCardDataFromGateway`)仅为示例方法,轮询系统的同步逻辑分散在 polling_handler.go 中,无统一入口
|
||||
|
||||
本次重构在 Admin 端建立统一的资产详情体系,含资产解析入口、状态查询、手动刷新、套餐查询、停复机接口,并完成数据模型的字段补全和改名。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 建立 `GET /api/admin/assets/resolve/:identifier` 作为唯一的资产查找入口
|
||||
- 卡和设备的停复机接口统一归入 `/api/admin/assets/` 路径,删除旧重复接口
|
||||
- 设备停复机引入 1 小时保护期机制,通过 Redis 存储保护期状态
|
||||
- IotCard 模型新增 `virtual_no` 字段,Device 模型 `device_no` 全量改名为 `virtual_no`
|
||||
- Package 模型新增 `virtual_ratio` 字段,套餐创建时自动计算
|
||||
- 轮询系统新增保护期一致性检查作为第四种独立任务类型
|
||||
|
||||
**Non-Goals:**
|
||||
- H5 端接口不在本次改造范围,旧 H5 接口保留(待后续单独处理)
|
||||
- 企业账号不支持 resolve 接口(未来单独开新接口)
|
||||
- 不引入新的外部依赖,不改变现有轮询系统的前三种任务类型内部逻辑
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:统一资产入口而非按类型分开
|
||||
|
||||
**选项 A(采用)**:单一入口 `GET /assets/resolve/:identifier`,返回响应中含 `asset_type` 字段区分
|
||||
**选项 B**:`GET /assets/card/:identifier` 和 `GET /assets/device/:identifier` 分开
|
||||
|
||||
选 A 的原因:虚拟号 identifier 在全局唯一时,前端无需预先知道是卡还是设备,一次请求拿到类型和 ID,后续调用才按类型路由。这符合"统一入口"的核心设计目标。
|
||||
|
||||
**查找顺序**:先查 Device(virtual_no / imei / sn),未命中再查 IotCard(virtual_no / iccid / msisdn)。设备优先因为设备标识符更具体。
|
||||
|
||||
### 决策 2:resolve 返回中等聚合版本,而非最小或最大版本
|
||||
|
||||
resolve 包含:基础信息 + 状态 + 当前套餐流量概况 + 保护期状态 + 绑定信息(卡←→设备)。
|
||||
|
||||
不做最小版本(仅返回 asset_type + id):前端页面需要立即展示套餐流量,避免二次请求。
|
||||
不做最大版本(返回全量历史套餐):套餐历史通过独立接口按需加载,避免 resolve 过重。
|
||||
|
||||
### 决策 3:realtime-status 不调网关
|
||||
|
||||
realtime-status 只读 DB/Redis 中已持久化的数据,"实时性"由轮询系统(5-10 分钟刷新一次)保证。需要最新数据时,先调 refresh 接口,再调此接口。
|
||||
|
||||
这个分工避免 realtime-status 因网关延迟而超时,保证轻量轮询性能。
|
||||
|
||||
### 决策 4:设备保护期存储在 Redis,而非数据库
|
||||
|
||||
保护期是临时状态,1 小时 TTL 后自然过期,无需持久化。Redis Key 格式:
|
||||
```
|
||||
protect:device:{device_id}:stop // 停机保护期
|
||||
protect:device:{device_id}:start // 复机保护期
|
||||
```
|
||||
这两个 key 互斥(发起 stop 时删除 start key,反之亦然)。
|
||||
|
||||
### 决策 5:新建 AssetService 而非扩展现有 Service
|
||||
|
||||
resolve 接口需要跨越 Device 和 IotCard 两张表,流量聚合逻辑来自 PackageUsage,保护期来自 Redis。
|
||||
如果分散在现有 Service 中会造成跨模块依赖混乱。新建 `internal/service/asset/service.go` 依赖注入 DeviceStore、IotCardStore、PackageUsageStore 和 Redis。
|
||||
|
||||
### 决策 6:RefreshCardDataFromGateway 增强现有方法而非新建
|
||||
|
||||
原 `SyncCardStatusFromGateway` 是示例实现,改名并增强为完整同步(NetworkStatus、RealNameStatus、CurrentMonthUsageMB、LastSyncTime)。轮询系统的 polling_handler.go 已有完整的同步逻辑,增强时参考该实现。
|
||||
|
||||
### 决策 7:卡 ICCID 导入新增可选 virtual_no 列
|
||||
|
||||
在现有导入模板中增加第 N+1 列 `virtual_no`(可选),导入时按规则处理:
|
||||
- 该行 virtual_no 不为空 + 数据库当前值为空 → 填入
|
||||
- 该行 virtual_no 不为空 + 数据库当前值不为空 → 跳过(不覆盖)
|
||||
- 批次中有任意 virtual_no 与数据库现存值重复(其他卡) → 整批失败,返回冲突列表
|
||||
|
||||
### 决策 8:虚流量比例 virtual_ratio 在套餐创建时计算并存储
|
||||
|
||||
不在查询时实时计算,原因:套餐参数确定后比例不变,存储避免每次查询重复除法,且支持未来通过 SQL 直接用于统计。
|
||||
```
|
||||
enable_virtual_data = true → virtual_ratio = real_data_mb / virtual_data_mb
|
||||
enable_virtual_data = false → virtual_ratio = 1.0
|
||||
```
|
||||
|
||||
### 决策 9:保护期一致性检查作为独立第四种轮询任务
|
||||
|
||||
不嵌入现有三种任务(实名/流量/套餐)中,原因:保护期检查的触发条件(设备有保护期)和处理逻辑完全不同,嵌入会破坏现有任务的单一职责。独立任务类型,Redis 队列 key:`polling:queue:protect`,与流量检查同频(10 分钟)。
|
||||
|
||||
### 决策 10:设备批量停机部分失败策略
|
||||
|
||||
部分卡调网关失败时:
|
||||
- 已成功停机的卡**不回滚**(回滚代价高且可能再次失败)
|
||||
- **仍设置** Redis 保护期(保护期从"发起操作"那一刻算起,而非"所有卡成功"后)
|
||||
- 失败卡记录 Error 日志,响应中携带失败列表
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[风险 1] device_no 全量改名影响范围广** → 缓解:先做数据库迁移,再用 `lsp_rename` 全量替换代码引用,最后运行编译检查确认无遗漏
|
||||
|
||||
**[风险 2] resolve 接口的套餐流量计算可能超过 50ms 目标** → 缓解:PackageUsage 查询已有 `iot_card_id` 和 `device_id` 索引,只查当前生效套餐(status=1);设备类型时只查 DeviceID,不逐卡汇总
|
||||
|
||||
**[风险 3] 保护期与轮询系统的竞争条件** → 缓解:轮询任务在处理卡状态前检查 Redis 保护期,保护期内强制按保护方向同步,不受卡自身状态影响
|
||||
|
||||
**[风险 4] 设备批量刷新打爆网关** → 缓解:Redis 限频(同一设备 30 秒冷却),Handler 层返回 HTTP 429
|
||||
|
||||
**[Trade-off] resolve 不支持企业账号** → 接受:企业账号的资产查询路径不同,未来单独开接口更合适,本次不过度设计
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **数据库迁移(先行)**:
|
||||
- Migration 1:`tb_device.device_no` → `virtual_no`,`tb_personal_customer_device.device_no` → `virtual_no`
|
||||
- Migration 2:`tb_iot_card` 新增 `virtual_no` 字段 + 唯一部分索引
|
||||
- Migration 3:`tb_package` 新增 `virtual_ratio` 字段,为现有数据回填(按 enable_virtual_data 计算)
|
||||
|
||||
2. **代码变更**:Model/Store/Service/Handler 按分层顺序依次实现
|
||||
|
||||
3. **废弃接口删除**:在新接口实现并验证后,删除旧停复机接口
|
||||
|
||||
4. **回滚**:数据库变更均可逆(改名可再改回,新增字段可删除);代码回滚通过 git revert
|
||||
|
||||
## Open Questions
|
||||
|
||||
(无——所有关键决策已在讨论纪要中确认)
|
||||
@@ -0,0 +1,72 @@
|
||||
## Why
|
||||
|
||||
卡和设备的详情体系严重割裂:查询入口散落三端(Admin/H5/Personal)、停复机实现重复三处、详情接口仅返回骨架数据、网关数据纯透传无业务聚合。前端无法依赖现有接口渲染完整的资产详情页,客服也因缺少虚拟号统一入口而无法快速定位资产。本次重构一次性完成资产详情体系的建设,引入统一资产入口、合并停复机接口、建立设备保护期机制。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **新增** 统一资产解析入口 `GET /api/admin/assets/resolve/:identifier`,支持通过虚拟号/ICCID/MSISDN/IMEI/SN 查找卡或设备
|
||||
- **新增** 资产轻量状态查询 `GET /api/admin/assets/:asset_type/:id/realtime-status`(只查持久化数据,不调网关)
|
||||
- **新增** 手动刷新接口 `POST /api/admin/assets/:asset_type/:id/refresh`(调网关写回 DB,设备类型含 Redis 限频)
|
||||
- **新增** 套餐历史列表 `GET /api/admin/assets/:asset_type/:id/packages`
|
||||
- **新增** 当前套餐查询 `GET /api/admin/assets/:asset_type/:id/current-package`
|
||||
- **新增** 设备停机/复机 `POST /api/admin/assets/device/:device_id/stop|start`(含 1 小时保护期机制)
|
||||
- **新增** 卡停机/复机 `POST /api/admin/assets/card/:iccid/stop|start`(含保护期感知)
|
||||
- **新增** 轮询系统第四种任务:保护期一致性检查(独立任务类型,与流量检查同频触发)
|
||||
- **数据库变更** `tb_device.device_no` → `virtual_no`(**BREAKING** 字段改名,全量更新代码)
|
||||
- **数据库变更** `tb_personal_customer_device.device_no` → `virtual_no`(同上)
|
||||
- **数据库变更** `tb_iot_card` 新增 `virtual_no` 字段(可空,全局唯一索引)
|
||||
- **数据库变更** `tb_package` 新增 `virtual_ratio` 字段(套餐创建时计算并存储)
|
||||
- **重命名** `SyncCardStatusFromGateway` → `RefreshCardDataFromGateway`,增强为完整同步 NetworkStatus/RealNameStatus/CurrentMonthUsageMB/LastSyncTime
|
||||
- **修改** 现有卡 ICCID 导入:模板新增可选 `virtual_no` 列(只补空白,重复则全批失败)
|
||||
- **修改** 现有设备导入:`device_no` 列头随字段改名同步更新为 `virtual_no`
|
||||
- **删除** 废弃停复机接口(Admin 企业卡端 suspend/resume、H5 企业设备端 suspend/resume、旧 Admin 按 ICCID 停复机)
|
||||
- 企业账号暂不支持 resolve 接口
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `asset-resolve`:统一资产解析入口,通过任意标识符查找卡或设备,返回资产类型、ID、虚拟号、状态、套餐流量概况、保护期状态、绑定信息等中等聚合数据
|
||||
- `asset-queries`:资产状态查询族,包含轻量实时状态查询(realtime-status)、手动刷新(refresh)、套餐历史列表(packages)、当前主套餐详情(current-package)
|
||||
- `asset-suspend-resume`:卡和设备的停复机统一接口,含设备 1 小时保护期机制(Redis 存储)、未实名卡跳过逻辑、批量停机部分失败策略
|
||||
- `polling-protect-consistency`:轮询系统新增的第四种任务,检查绑定设备保护期并强制同步卡的网络状态
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `iot-card`:新增 `virtual_no` 字段(可空,全局唯一索引);IotCard 导入模板新增可选 virtual_no 列
|
||||
- `device`:`device_no` 字段全量改名为 `virtual_no`(含 tb_personal_customer_device);设备导入模板同步更新
|
||||
- `iot-package`:`Package` 模型新增 `virtual_ratio` 字段,创建/更新套餐时自动计算存储
|
||||
|
||||
## Impact
|
||||
|
||||
**Handler 层**:
|
||||
- 新增 `internal/handler/admin/asset.go`(9 个新接口)
|
||||
- 删除 `internal/handler/admin/enterprise_card.go` 中的废弃停复机 Handler
|
||||
- 删除 `internal/handler/h5/enterprise_device.go` 中的废弃停复机 Handler
|
||||
|
||||
**Service 层**:
|
||||
- 新增 `internal/service/asset/service.go`(资产解析、状态聚合逻辑)
|
||||
- 修改 `internal/service/iot_card/service.go`:`SyncCardStatusFromGateway` → `RefreshCardDataFromGateway`,增强为完整同步
|
||||
- 修改 `internal/service/iot_card/stop_resume_service.go`:扩展手动停复机逻辑
|
||||
- 修改 `internal/service/device/service.go`:新增设备停复机 + 保护期逻辑
|
||||
- 修改 `internal/service/package/service.go`:创建/更新套餐时自动计算 virtual_ratio
|
||||
|
||||
**Store 层**:
|
||||
- 修改 `internal/store/postgres/device_store.go`:`GetByIdentifier` 改用 `virtual_no`
|
||||
- 修改 `internal/store/postgres/personal_customer_device_store.go`:`device_no` → `virtual_no`
|
||||
|
||||
**Model 层**:
|
||||
- `internal/model/iot_card.go`:新增 `VirtualNo` 字段
|
||||
- `internal/model/device.go`:`DeviceNo` → `VirtualNo`
|
||||
- `internal/model/package.go`:新增 `VirtualRatio` 字段
|
||||
- `internal/model/personal_customer_device.go`:`DeviceNo` → `VirtualNo`
|
||||
|
||||
**常量层**:
|
||||
- `pkg/constants/redis.go`:新增 `RedisDeviceProtectKey`、`RedisDeviceRefreshCooldownKey`
|
||||
|
||||
**轮询层**:
|
||||
- `internal/task/polling_handler.go`:新增保护期一致性检查任务处理函数
|
||||
|
||||
**数据库迁移**:3 张表的结构变更(device、iot_card、package)
|
||||
|
||||
**API 文档**:`cmd/api/docs.go` 和 `cmd/gendocs/main.go` 需同步更新
|
||||
@@ -0,0 +1,166 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 轻量实时状态查询
|
||||
|
||||
系统 SHALL 提供基于持久化数据的轻量状态查询接口,供前端在已知资产 ID 后进行快速轮询。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/:asset_type/:id/realtime-status`
|
||||
|
||||
**约束**:
|
||||
- `:asset_type` 取值为 `device` 或 `card`
|
||||
- 此接口**不调用网关**,仅读取 DB/Redis 中持久化的最新数据
|
||||
- 不包含套餐流量计算(与 resolve 的区别)
|
||||
- "实时性"依赖轮询系统定期刷新(实名状态约 5 分钟,流量约 10 分钟)
|
||||
|
||||
**card 类型响应字段**:
|
||||
- `network_status`: 网络状态(0-停机 1-开机)
|
||||
- `real_name_status`: 实名状态(0-未实名 1-已实名)
|
||||
- `current_month_usage_mb`: 本月已用流量(持久化缓存值)
|
||||
- `last_sync_at`: 最后与 Gateway 同步时间
|
||||
|
||||
**device 类型响应字段**:
|
||||
- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`)
|
||||
- `cards`: 所有绑定卡的状态列表(同 DeviceCardInfo 结构)
|
||||
|
||||
#### Scenario: 查询单卡实时状态
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/realtime-status`
|
||||
- **THEN** 系统返回该卡的 network_status、real_name_status、current_month_usage_mb、last_sync_at
|
||||
|
||||
#### Scenario: 查询设备实时状态
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/realtime-status`
|
||||
- **THEN** 系统返回设备的保护期状态及所有绑定卡的当前状态列表
|
||||
|
||||
#### Scenario: asset_type 参数非法
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/unknown-type/123/realtime-status`
|
||||
- **THEN** 系统返回 HTTP 400 参数错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 手动刷新接口
|
||||
|
||||
系统 SHALL 提供手动触发网关同步的接口,用于客服主动刷新资产最新状态。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/:asset_type/:id/refresh`
|
||||
|
||||
**行为规则**:
|
||||
- card 类型:直接调用 `RefreshCardDataFromGateway(iccid)` 同步网络状态、实名状态、本月流量、最后同步时间
|
||||
- device 类型:对该设备所有绑定卡遍历调用 `RefreshCardDataFromGateway`
|
||||
|
||||
**设备类型频率限制**:
|
||||
- 使用 Redis Key `RedisDeviceRefreshCooldownKey(deviceID)` 限频
|
||||
- 同一设备 30 秒冷却期内不允许重复触发
|
||||
- 冷却期内调用返回 HTTP 429
|
||||
|
||||
**响应**:
|
||||
- 刷新完成后返回刷新后的最新状态(与 realtime-status 响应结构相同)
|
||||
|
||||
#### Scenario: 刷新单卡状态
|
||||
|
||||
- **WHEN** 客服调用 `POST /api/admin/assets/card/123/refresh`
|
||||
- **THEN** 系统调用 RefreshCardDataFromGateway,更新 DB 中的卡状态字段,返回刷新后的最新状态
|
||||
|
||||
#### Scenario: 刷新设备状态(首次)
|
||||
|
||||
- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/refresh`,该设备有 3 张绑定卡
|
||||
- **THEN** 系统依次刷新 3 张卡,设置 30 秒冷却期,返回最新状态
|
||||
|
||||
#### Scenario: 设备刷新冷却期内重复触发
|
||||
|
||||
- **WHEN** 管理员在 30 秒冷却期内第二次调用 `POST /api/admin/assets/device/456/refresh`
|
||||
- **THEN** 系统返回 HTTP 429,提示"刷新过于频繁,请稍后再试"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐历史列表查询
|
||||
|
||||
系统 SHALL 提供资产的全量套餐记录查询接口,包含历史和当前生效套餐。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/:asset_type/:id/packages`
|
||||
|
||||
**排序**: 按 `created_at` 倒序(最新套餐在前)
|
||||
|
||||
**分页**: 不分页,全量返回
|
||||
|
||||
**范围**: 包含所有状态(含 status=4 已失效的历史套餐)
|
||||
|
||||
**按 asset_type 区分查询**:
|
||||
- card:查询 `PackageUsage.iot_card_id = :id`
|
||||
- device:查询 `PackageUsage.device_id = :id`
|
||||
|
||||
**每条记录响应字段**:
|
||||
- `package_usage_id`: 套餐使用记录 ID
|
||||
- `package_name`: 套餐名称
|
||||
- `package_type`: 套餐类型(formal/addon)
|
||||
- `master_usage_id`: 主套餐 ID(加油包时有值,主套餐时为 null)
|
||||
- `real_data_mb`: 真总流量(MB)
|
||||
- `virtual_data_mb`: 虚总流量/停机阈值(MB)
|
||||
- `package_used_mb`: 展示已使用流量(经虚流量换算)
|
||||
- `package_remain_mb`: 展示剩余流量
|
||||
- `activated_at`: 生效时间
|
||||
- `expires_at`: 过期时间
|
||||
- `status`: 套餐状态(0-待生效 1-生效中 2-已用完 3-已过期 4-已失效)
|
||||
|
||||
#### Scenario: 查询卡的套餐历史
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/packages`,该卡有 3 条套餐记录(含 1 条已失效)
|
||||
- **THEN** 系统返回全部 3 条记录,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 查询设备的套餐历史
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/packages`
|
||||
- **THEN** 系统返回该设备 device_id 下的所有套餐记录
|
||||
|
||||
#### Scenario: 资产无套餐记录
|
||||
|
||||
- **WHEN** 管理员查询一张从未购买过套餐的卡
|
||||
- **THEN** 系统返回空数组,不报错
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 当前主套餐详情查询
|
||||
|
||||
系统 SHALL 提供查询资产当前生效主套餐的接口,用于展示套餐详细信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/:asset_type/:id/current-package`
|
||||
|
||||
**查询条件**: `status = 1(生效中)AND master_usage_id IS NULL`
|
||||
|
||||
**多套餐同时生效时**:只返回主套餐(master_usage_id IS NULL),不返回加油包
|
||||
|
||||
**响应字段**:
|
||||
- 完整套餐信息(同套餐历史列表中的单条记录字段)
|
||||
- 当无生效主套餐时,返回 HTTP 404
|
||||
|
||||
#### Scenario: 返回当前主套餐
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/current-package`,该卡有 1 个生效主套餐和 1 个加油包
|
||||
- **THEN** 系统只返回主套餐信息,不包含加油包
|
||||
|
||||
#### Scenario: 无当前生效主套餐
|
||||
|
||||
- **WHEN** 管理员查询没有生效中主套餐的资产
|
||||
- **THEN** 系统返回 HTTP 404
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RefreshCardDataFromGateway 完整同步
|
||||
|
||||
系统 SHALL 提供从 Gateway 完整同步卡数据的方法,替代原 `SyncCardStatusFromGateway`(仅为示例实现)。
|
||||
|
||||
**方法签名**: `RefreshCardDataFromGateway(ctx context.Context, iccid string) error`
|
||||
|
||||
**同步字段**:
|
||||
- `network_status`: 网络状态(从网关卡状态映射)
|
||||
- `real_name_status`: 实名状态(从网关实名接口获取)
|
||||
- `current_month_usage_mb`: 本月已用流量(从网关流量接口获取)
|
||||
- `last_sync_time`: 更新为当前时间
|
||||
|
||||
**错误处理**: 网关调用失败时记录 Error 日志并返回错误,不更新 DB
|
||||
|
||||
#### Scenario: 完整同步卡数据
|
||||
|
||||
- **WHEN** 调用 `RefreshCardDataFromGateway(ctx, "89860123456789012345")`
|
||||
- **THEN** 系统调用网关接口,将 network_status、real_name_status、current_month_usage_mb、last_sync_time 写回 DB
|
||||
@@ -0,0 +1,132 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 统一资产解析入口
|
||||
|
||||
系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/resolve/:identifier`
|
||||
|
||||
**查找顺序**:
|
||||
1. 先在 `tb_device` 表查找(匹配 `virtual_no = ? OR imei = ? OR sn = ?`)
|
||||
2. 未命中则在 `tb_iot_card` 表查找(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`)
|
||||
3. 两表均未命中 → 返回 HTTP 404
|
||||
4. 找到后应用数据权限过滤,无权限 → 返回 HTTP 403
|
||||
|
||||
**数据权限规则**:
|
||||
- 代理用户:只能查看 `shop_id` 在自己及下级店铺范围内的资产
|
||||
- 平台用户(SuperAdmin/Platform):可查看所有资产
|
||||
- 企业账号:暂不支持此接口,调用时返回 HTTP 403
|
||||
|
||||
**响应结构(AssetResolveResponse)**:
|
||||
|
||||
*通用字段(device 和 card 均有)*:
|
||||
- `asset_type`: 资产类型(`"device"` 或 `"card"`)
|
||||
- `asset_id`: 资产主键 ID
|
||||
- `virtual_no`: 虚拟号(设备/卡均使用此字段)
|
||||
- `status`: 资产状态(整型)
|
||||
- `batch_no`: 批次号
|
||||
- `shop_id`: 所属店铺 ID(平台库存时为空)
|
||||
- `shop_name`: 所属店铺名称
|
||||
- `series_id`: 套餐系列 ID(未绑定时为空)
|
||||
- `series_name`: 套餐系列名称
|
||||
- `first_commission_paid`: 一次性佣金是否已发放
|
||||
- `accumulated_recharge`: 累计充值金额(分)
|
||||
- `activated_at`: 激活时间(未激活时为空)
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
|
||||
*状态与套餐字段(device 和 card 均有)*:
|
||||
- `real_name_status`: 实名状态(整型)
|
||||
- `current_package`: 当前套餐名称(无套餐时返回空字符串)
|
||||
- `package_total_mb`: 真总流量,即 RealDataMB(无套餐时返回 0)
|
||||
- `package_virtual_mb`: 虚总流量/停机阈值,即 VirtualDataMB(无套餐时返回 0)
|
||||
- `package_used_mb`: 客户端展示已使用流量(经虚流量换算,见流量计算规则)
|
||||
- `package_remain_mb`: 客户端展示剩余流量
|
||||
- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`);card 类型时若绑定的设备有保护期也返回该设备的保护期状态
|
||||
|
||||
*绑定关系字段*:
|
||||
- `iccid`: 仅 card 类型时有值,供前端调用停复机接口使用
|
||||
- `bound_device_id`: 仅 card 类型且卡绑定了设备时有值
|
||||
- `bound_device_no`: 绑定设备的虚拟号
|
||||
- `bound_device_name`: 绑定设备的名称
|
||||
- `bound_card_count`: 仅 device 类型时有值,绑定卡的总数量
|
||||
- `cards`: 仅 device 类型时有值,所有绑定卡列表(含未实名、已停用)
|
||||
|
||||
*设备专属档案字段(asset_type=device 时有值,card 类型时为空/零值)*:
|
||||
- `device_name`: 设备名称
|
||||
- `imei`: IMEI
|
||||
- `sn`: 序列号
|
||||
- `device_model`: 设备型号
|
||||
- `device_type`: 设备类型
|
||||
- `max_sim_slots`: 最大插槽数
|
||||
- `manufacturer`: 制造商
|
||||
|
||||
*卡专属档案字段(asset_type=card 时有值,device 类型时为空/零值)*:
|
||||
- `carrier_id`: 运营商 ID
|
||||
- `carrier_type`: 运营商类型(CMCC/CUCC/CTCC/CBN)
|
||||
- `carrier_name`: 运营商名称
|
||||
- `msisdn`: 卡接入号
|
||||
- `imsi`: IMSI
|
||||
- `card_category`: 卡业务类型(normal/industry)
|
||||
- `supplier`: 供应商
|
||||
- `activation_status`: 激活状态(0-未激活 1-已激活)
|
||||
- `enable_polling`: 是否参与轮询
|
||||
|
||||
**DeviceCardInfo 结构**:
|
||||
- `iot_card_id`: 卡 ID
|
||||
- `iccid`: ICCID
|
||||
- `virtual_no`: 卡的虚拟号
|
||||
- `real_name_status`: 实名状态
|
||||
- `network_status`: 网络状态
|
||||
- `current_month_usage_mb`: 本月已用流量(来自持久化缓存字段)
|
||||
- `last_sync_at`: 最后与 Gateway 同步时间
|
||||
|
||||
**流量展示计算规则**:
|
||||
- `package_used_mb = current_month_usage_mb × virtual_ratio`
|
||||
- `package_remain_mb = package_total_mb - package_used_mb`
|
||||
- 当 `enable_virtual_data = false` 时,`virtual_ratio = 1.0`(无换算)
|
||||
- 设备级套餐:`current_month_usage_mb` 为所有绑定卡本月用量之和
|
||||
|
||||
**特殊情况处理**:
|
||||
- 卡绑定的设备已被软删除:视为独立卡,不填充绑定信息
|
||||
- `cards` 列表包含所有状态的绑定卡,不过滤未实名或已停用的卡
|
||||
|
||||
#### Scenario: 通过 ICCID 找到卡
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/89860123456789012345`,ICCID 匹配到一张独立卡
|
||||
- **THEN** 系统返回 `asset_type="card"`,包含该卡的虚拟号、状态、套餐流量信息,`bound_device_id` 为空
|
||||
|
||||
#### Scenario: 通过虚拟号找到设备
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/GPS-001`,设备表中 `virtual_no = "GPS-001"` 存在
|
||||
- **THEN** 系统返回 `asset_type="device"`,包含该设备的绑定卡列表(DeviceCardInfo 数组),`bound_card_count` 为绑定卡总数
|
||||
|
||||
#### Scenario: 标识符同时命中设备和卡(设备优先)
|
||||
|
||||
- **WHEN** `GPS-001` 在 device 表和 iot_card 表均有匹配(virtual_no 相同)
|
||||
- **THEN** 系统返回设备信息(device 优先),不返回卡信息
|
||||
|
||||
#### Scenario: 标识符未命中任何资产
|
||||
|
||||
- **WHEN** 管理员查询不存在的标识符 `UNKNOWN-999`
|
||||
- **THEN** 系统返回 HTTP 404
|
||||
|
||||
#### Scenario: 代理用户查询无权限的资产
|
||||
|
||||
- **WHEN** 代理用户(shop_id=10)查询属于 shop_id=99(非下级)的设备
|
||||
- **THEN** 系统返回 HTTP 403,明确提示无权限
|
||||
|
||||
#### Scenario: 企业账号调用 resolve
|
||||
|
||||
- **WHEN** 企业账号调用 `GET /api/admin/assets/resolve/:identifier`
|
||||
- **THEN** 系统返回 HTTP 403,提示企业账号暂不支持此接口
|
||||
|
||||
#### Scenario: 卡绑定了有停机保护期的设备
|
||||
|
||||
- **WHEN** 管理员通过 ICCID 查询某张卡,该卡绑定的设备当前有 stop 保护期
|
||||
- **THEN** 响应中 `device_protect_status = "stop"`,反映所属设备的保护期状态
|
||||
|
||||
#### Scenario: 设备无当前生效套餐
|
||||
|
||||
- **WHEN** 管理员查询一台没有购买任何套餐的设备
|
||||
- **THEN** `current_package = ""`,`package_total_mb = 0`,`package_used_mb = 0`,`package_remain_mb = 0`
|
||||
@@ -0,0 +1,168 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设备停机接口
|
||||
|
||||
系统 SHALL 提供设备停机接口,批量停用设备下所有已实名卡,并建立停机保护期。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/device/:device_id/stop`
|
||||
|
||||
**执行流程**:
|
||||
1. 验证设备存在(不存在返回 HTTP 404)
|
||||
2. 检查设备是否在保护期(`RedisDeviceProtectKey(deviceID, "stop")` 或 `"start"` 存在则返回 HTTP 403)
|
||||
3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡
|
||||
4. 遍历调用网关停机接口(未实名卡跳过,永远是停机状态)
|
||||
5. 更新成功停机的卡的 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"`
|
||||
6. 在 Redis 中设置停机保护期:`RedisDeviceProtectKey(deviceID, "stop")`,TTL = 1 小时
|
||||
7. 响应:返回成功,附带失败卡列表(如有)
|
||||
|
||||
**保护期说明**:
|
||||
- 保护期时长:1 小时(常量 `DeviceProtectPeriodDuration = 1 * time.Hour`,定义在 `pkg/constants/`)
|
||||
- 停机保护期 key:`protect:device:{device_id}:stop`
|
||||
- 复机保护期 key:`protect:device:{device_id}:start`
|
||||
- 两个 key 互斥:设置 stop 保护期时删除 start 保护期,反之亦然
|
||||
|
||||
**批量部分失败策略**:
|
||||
- 部分卡调网关失败:**仍设置** Redis 保护期(保护期从发起操作时算起)
|
||||
- 已成功停机的卡**不回滚**
|
||||
- 失败的卡记录 Error 日志,响应体中携带失败列表
|
||||
|
||||
#### Scenario: 成功执行设备停机
|
||||
|
||||
- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/stop`,该设备有 3 张已实名卡
|
||||
- **THEN** 系统批量调网关停机,更新 3 张卡 network_status=0,设置 1 小时 stop 保护期,返回成功
|
||||
|
||||
#### Scenario: 设备存在保护期
|
||||
|
||||
- **WHEN** 管理员在设备已有 stop 保护期时再次调用停机接口
|
||||
- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作"
|
||||
|
||||
#### Scenario: 设备下无已实名卡
|
||||
|
||||
- **WHEN** 管理员对只有未实名卡的设备执行停机
|
||||
- **THEN** 系统返回成功(0 张卡操作),设置 stop 保护期
|
||||
|
||||
#### Scenario: 设备不存在
|
||||
|
||||
- **WHEN** 管理员调用不存在的设备 ID
|
||||
- **THEN** 系统返回 HTTP 404
|
||||
|
||||
#### Scenario: 部分卡停机失败
|
||||
|
||||
- **WHEN** 设备有 3 张卡,1 张网关调用失败
|
||||
- **THEN** 2 张成功停机,1 张失败记录日志,**仍设置** stop 保护期,响应中包含失败卡信息
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备复机接口
|
||||
|
||||
系统 SHALL 提供设备复机接口,批量恢复设备下所有已实名卡,并建立复机保护期。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/device/:device_id/start`
|
||||
|
||||
**执行流程**:
|
||||
1. 验证设备存在(不存在返回 HTTP 404)
|
||||
2. 检查设备是否在保护期(stop 或 start 保护期均存在时返回 HTTP 403)
|
||||
3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡
|
||||
4. 遍历调用网关复机接口
|
||||
5. 更新成功复机的卡的 `network_status = 1`,`resumed_at = now()`
|
||||
6. 设置复机保护期:`RedisDeviceProtectKey(deviceID, "start")`,TTL = 1 小时
|
||||
7. 响应:返回成功
|
||||
|
||||
#### Scenario: 成功执行设备复机
|
||||
|
||||
- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/start`,该设备有 2 张已实名卡
|
||||
- **THEN** 系统批量复机,更新卡状态,设置 1 小时 start 保护期,返回成功
|
||||
|
||||
#### Scenario: 设备在 start 保护期内再次复机
|
||||
|
||||
- **WHEN** 设备已有 start 保护期时再次调用复机接口
|
||||
- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡停机接口
|
||||
|
||||
系统 SHALL 提供单卡停机接口,含保护期感知逻辑。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/card/:iccid/stop`
|
||||
|
||||
**执行流程**:
|
||||
1. 通过 ICCID 查找卡(不存在返回 HTTP 404)
|
||||
2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403:未实名卡不允许停复机)
|
||||
3. 若卡绑定了设备,检查该设备的保护期:
|
||||
- 设备有 **stop 保护期**:允许停机(本已是停机方向,无冲突)
|
||||
- 设备有 **start 保护期**:允许停机(用户可主动停单张卡)
|
||||
- 设备无保护期:正常执行
|
||||
4. 调用网关停机接口
|
||||
5. 更新卡 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"`
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)停机
|
||||
|
||||
- **WHEN** 管理员对一张未绑定设备的已实名卡执行停机
|
||||
- **THEN** 系统正常调网关停机,更新卡状态
|
||||
|
||||
#### Scenario: 绑定设备且设备在 start 保护期内停机
|
||||
|
||||
- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行停机
|
||||
- **THEN** 系统允许执行(用户主动停单张卡不违反 start 保护期),正常停机
|
||||
|
||||
#### Scenario: 对未实名卡执行停机
|
||||
|
||||
- **WHEN** 管理员对 real_name_status=0(未实名)的卡执行停机
|
||||
- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡复机接口
|
||||
|
||||
系统 SHALL 提供单卡复机接口,含保护期感知逻辑。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/card/:iccid/start`
|
||||
|
||||
**执行流程**:
|
||||
1. 通过 ICCID 查找卡(不存在返回 HTTP 404)
|
||||
2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403)
|
||||
3. 若卡绑定了设备,检查该设备的保护期:
|
||||
- 设备有 **stop 保护期**:**不允许**手动复机,返回 HTTP 403(设备处于停机保护期)
|
||||
- 设备有 **start 保护期**:允许复机(本已是复机方向,无冲突)
|
||||
- 设备无保护期:正常执行
|
||||
4. 调用网关复机接口
|
||||
5. 更新卡 `network_status = 1`,`resumed_at = now()`,清空 `stop_reason`
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)复机
|
||||
|
||||
- **WHEN** 管理员对一张未绑定设备的已实名停机卡执行复机
|
||||
- **THEN** 系统正常调网关复机,更新卡状态
|
||||
|
||||
#### Scenario: 设备处于 stop 保护期时尝试复机
|
||||
|
||||
- **WHEN** 管理员对绑定了设备且设备有 stop 保护期的卡执行复机
|
||||
- **THEN** 系统返回 HTTP 403,提示"设备处于停机保护期,不允许手动复机"
|
||||
|
||||
#### Scenario: 设备在 start 保护期内复机
|
||||
|
||||
- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行复机
|
||||
- **THEN** 系统允许执行(本已是复机方向),正常复机
|
||||
|
||||
#### Scenario: 对未实名卡执行复机
|
||||
|
||||
- **WHEN** 管理员对 real_name_status=0 的卡执行复机
|
||||
- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 废弃旧停复机接口
|
||||
|
||||
系统 SHALL 删除以下重复的停复机接口,统一使用新的 `/api/admin/assets/` 路径。
|
||||
|
||||
**待删除接口**:
|
||||
- `POST /api/admin/enterprises/:id/cards/:card_id/suspend`
|
||||
- `POST /api/admin/enterprises/:id/cards/:card_id/resume`
|
||||
- `POST /h5/devices/:device_id/cards/:card_id/suspend`
|
||||
- `POST /h5/devices/:device_id/cards/:card_id/resume`
|
||||
- 旧 Admin 卡停复机接口(`POST /iot-cards/:iccid/suspend|resume`)
|
||||
|
||||
#### Scenario: 调用已删除的旧接口
|
||||
|
||||
- **WHEN** 前端调用 `POST /api/admin/enterprises/:id/cards/:card_id/suspend`
|
||||
- **THEN** 系统返回 HTTP 404(路由已不存在)
|
||||
@@ -0,0 +1,133 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 设备列表查询
|
||||
|
||||
系统 SHALL 提供设备列表查询功能,支持多维度筛选和分页。
|
||||
|
||||
**查询条件**:
|
||||
- `virtual_no`(可选): 设备虚拟号,支持模糊匹配(原 `device_no` 字段,已全量改名)
|
||||
- `device_name`(可选): 设备名称,支持模糊匹配
|
||||
- `status`(可选): 设备状态,枚举值 1-在库 | 2-已分销 | 3-已激活 | 4-已停用
|
||||
- `shop_id`(可选): 店铺 ID,NULL 表示平台库存
|
||||
- `batch_no`(可选): 批次号,精确匹配
|
||||
- `device_type`(可选): 设备类型
|
||||
- `manufacturer`(可选): 制造商,支持模糊匹配
|
||||
- `created_at_start`(可选): 创建时间起始
|
||||
- `created_at_end`(可选): 创建时间结束
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
- 返回总记录数和总页数
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有设备
|
||||
- 代理用户只能查看自己店铺及下级店铺的设备
|
||||
|
||||
**API 端点**: `GET /api/admin/devices`
|
||||
|
||||
**响应字段**:
|
||||
- `id`: 设备 ID
|
||||
- `virtual_no`: 设备虚拟号(原 `device_no`,已改名)
|
||||
- `device_name`: 设备名称
|
||||
- `device_model`: 设备型号
|
||||
- `device_type`: 设备类型
|
||||
- `max_sim_slots`: 最大插槽数
|
||||
- `manufacturer`: 制造商
|
||||
- `batch_no`: 批次号
|
||||
- `shop_id`: 店铺 ID
|
||||
- `shop_name`: 店铺名称
|
||||
- `status`: 状态
|
||||
- `status_name`: 状态名称
|
||||
- `bound_card_count`: 已绑定卡数量
|
||||
- `activated_at`: 激活时间
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
|
||||
#### Scenario: 平台查询所有设备
|
||||
|
||||
- **WHEN** 平台管理员查询设备列表,不带任何筛选条件
|
||||
- **THEN** 系统返回所有设备,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 按虚拟号模糊查询
|
||||
|
||||
- **WHEN** 管理员输入 virtual_no = "GPS"
|
||||
- **THEN** 系统返回虚拟号包含 "GPS" 的所有设备
|
||||
|
||||
#### Scenario: 按状态筛选设备
|
||||
|
||||
- **WHEN** 管理员查询状态为 1(在库)的设备
|
||||
- **THEN** 系统只返回在库状态的设备
|
||||
|
||||
#### Scenario: 代理查询自己店铺的设备
|
||||
|
||||
- **WHEN** 代理用户(店铺 ID=10)查询设备列表
|
||||
- **THEN** 系统只返回 shop_id 为 10 及其下级店铺的设备
|
||||
|
||||
#### Scenario: 查询平台库存设备
|
||||
|
||||
- **WHEN** 平台管理员查询 shop_id 为空的设备
|
||||
- **THEN** 系统返回所有平台库存设备(shop_id = NULL)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备详情查询
|
||||
|
||||
系统 SHALL 提供设备详情查询功能,返回设备的基本信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/devices/:id`
|
||||
|
||||
**响应字段**:
|
||||
- 包含设备的所有基本字段(含 `virtual_no`,不再有 `device_no`)
|
||||
- `shop_name`: 店铺名称(如果有)
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有设备
|
||||
- 代理用户只能查看自己店铺及下级店铺的设备
|
||||
|
||||
#### Scenario: 查询设备详情成功
|
||||
|
||||
- **WHEN** 管理员查询设备详情(ID=1)
|
||||
- **THEN** 系统返回该设备的完整基本信息,响应中含 `virtual_no` 字段,不含 `device_no`
|
||||
|
||||
#### Scenario: 查询不存在的设备
|
||||
|
||||
- **WHEN** 管理员查询不存在的设备(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"设备不存在"
|
||||
|
||||
#### Scenario: 代理查询无权限的设备
|
||||
|
||||
- **WHEN** 代理用户(店铺 ID=10)查询其他店铺的设备(shop_id=20,非下级)
|
||||
- **THEN** 系统返回 404 错误,提示"设备不存在"
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: device_no 全量改名为 virtual_no
|
||||
|
||||
系统 SHALL 将 `tb_device` 表和 `tb_personal_customer_device` 表中的 `device_no` 字段全量改名为 `virtual_no`,确保系统中不再有 `device_no` 的存在。
|
||||
|
||||
**数据库变更**:
|
||||
```sql
|
||||
ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no;
|
||||
ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no;
|
||||
```
|
||||
|
||||
**代码影响范围**:
|
||||
- `internal/model/device.go`:`DeviceNo` → `VirtualNo`,column tag 更新
|
||||
- `internal/model/personal_customer_device.go`:`DeviceNo` → `VirtualNo`,column tag 更新
|
||||
- `internal/model/dto/device_dto.go`:`DeviceResponse.DeviceNo` → `VirtualNo`,JSON tag 更新为 `"virtual_no"`
|
||||
- `internal/store/postgres/device_store.go`:`GetByIdentifier` 查询条件中 `device_no` → `virtual_no`
|
||||
- `internal/store/postgres/personal_customer_device_store.go`:所有 `device_no` 引用更新
|
||||
- 所有 Handler、Service 中引用 `DeviceNo` 字段的代码全量替换
|
||||
|
||||
**设备导入模板**:
|
||||
- 导入 Excel 模板中的列头从 `device_no` 更新为 `virtual_no`
|
||||
|
||||
#### Scenario: 改名后查询设备
|
||||
|
||||
- **WHEN** 改名迁移完成后,调用 `GetByIdentifier("GPS-001")`
|
||||
- **THEN** 系统在 `WHERE virtual_no = ? OR imei = ? OR sn = ?` 中正确匹配,与改名前行为一致
|
||||
|
||||
#### Scenario: 响应中字段名已更新
|
||||
|
||||
- **WHEN** 前端调用设备列表或详情接口
|
||||
- **THEN** 响应 JSON 中 key 为 `virtual_no`,不再有 `device_no`
|
||||
@@ -0,0 +1,81 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Excel 文件格式规范
|
||||
|
||||
系统 SHALL 要求 Excel 文件必须包含 ICCID 和 MSISDN 两列,并支持可选的 `virtual_no` 列。
|
||||
|
||||
**文件格式要求**:
|
||||
- **文件格式**: 仅支持 `.xlsx` (Excel 2007+)
|
||||
- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet
|
||||
- **表头行**: 第1行(可选,但建议包含)
|
||||
- **表头识别关键字**:
|
||||
- ICCID 列: iccid/ICCID/卡号/号码
|
||||
- MSISDN 列: msisdn/MSISDN/接入号/手机号/电话/号码
|
||||
- virtual_no 列(新增,可选): virtual_no/VirtualNo/虚拟号/设备号
|
||||
- **列数要求**: 至少 2 列(ICCID 和 MSISDN),virtual_no 为可选第三列
|
||||
- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法)
|
||||
|
||||
**解析规则**:
|
||||
- 自动检测表头(第1行包含识别关键字则跳过)
|
||||
- 自动去除单元格首尾空格
|
||||
- 跳过空行
|
||||
- ICCID 为空的行记录为失败
|
||||
- MSISDN 为空的行记录为失败
|
||||
- virtual_no 为空的行:跳过该列(不填入,保留原值)
|
||||
|
||||
**virtual_no 导入规则(只补空白)**:
|
||||
- 该行 virtual_no 不为空 + 数据库当前值为 NULL:填入新值
|
||||
- 该行 virtual_no 不为空 + 数据库当前值已有值:跳过(不覆盖)
|
||||
- **批次级唯一性检查**:在执行导入前,先检查整批中所有非空 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表
|
||||
|
||||
**示例 Excel 内容**:
|
||||
```
|
||||
| ICCID | MSISDN | 虚拟号 |
|
||||
|----------------------|-------------|-----------|
|
||||
| 89860012345678901234 | 13800000001 | CARD-001 |
|
||||
| 89860012345678901235 | 13800000002 | |
|
||||
```
|
||||
|
||||
#### Scenario: 解析标准双列 Excel 文件(无 virtual_no 列)
|
||||
|
||||
- **WHEN** Excel 文件只含 ICCID 和 MSISDN 两列,无虚拟号列
|
||||
- **THEN** 解析结果包含 2 条有效记录,virtual_no 字段为空,不影响导入逻辑
|
||||
|
||||
#### Scenario: 解析含 virtual_no 列的三列 Excel
|
||||
|
||||
- **WHEN** Excel 文件含 ICCID、MSISDN、虚拟号三列,某行 virtual_no = "CARD-001",对应卡当前 virtual_no 为 NULL
|
||||
- **THEN** 解析后该卡的 virtual_no 填入 "CARD-001"
|
||||
|
||||
#### Scenario: virtual_no 已有值时不覆盖
|
||||
|
||||
- **WHEN** Excel 中某行 virtual_no = "CARD-NEW",但该卡数据库中已有 virtual_no = "CARD-OLD"
|
||||
- **THEN** 该卡的 virtual_no 保持 "CARD-OLD" 不变,该行跳过(不报错,不计入失败)
|
||||
|
||||
#### Scenario: 批次中有 virtual_no 与现存数据重复
|
||||
|
||||
- **WHEN** Excel 中某行 virtual_no = "CARD-001",但数据库中另一张卡已有 virtual_no = "CARD-001"
|
||||
- **THEN** 系统拒绝整批导入,响应返回冲突的 virtual_no 值和行号,提示"虚拟号重复,整批导入已终止"
|
||||
|
||||
#### Scenario: 支持中文表头
|
||||
|
||||
- **GIVEN** Excel 文件表头为 `卡号 | 接入号 | 虚拟号`
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 系统正确识别三列,按规则处理 virtual_no
|
||||
|
||||
#### Scenario: 拒绝非Excel格式文件
|
||||
|
||||
- **GIVEN** 上传文件扩展名为 .csv
|
||||
- **WHEN** 系统尝试解析该文件
|
||||
- **THEN** 系统返回错误"不支持的文件格式 .csv,请上传Excel文件(.xlsx)"
|
||||
|
||||
#### Scenario: MSISDN 为空的行记录失败
|
||||
|
||||
- **GIVEN** Excel 文件第二行 MSISDN 为空
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为"MSISDN 不能为空"
|
||||
|
||||
#### Scenario: 长数字无损解析
|
||||
|
||||
- **GIVEN** Excel 文件中 ICCID 列设置为文本格式,包含 20 位数字 "89860012345678901234"
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法
|
||||
@@ -0,0 +1,34 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### 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 值)
|
||||
@@ -0,0 +1,63 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 套餐实体定义
|
||||
|
||||
系统 SHALL 定义套餐(Package)实体,包含套餐的基本属性、定价、流量配置,以及用于客户端展示流量换算的 `virtual_ratio` 字段。
|
||||
|
||||
**核心概念**: 套餐只适用于 IoT 卡(ICCID),用户可以为单张 IoT 卡购买套餐,也可以为设备购买套餐(套餐分配到设备绑定的所有 IoT 卡,流量设备级共享)。
|
||||
|
||||
**实体字段**:
|
||||
- `id`: 套餐 ID(主键,BIGINT)
|
||||
- `package_code`: 套餐编码(VARCHAR(50),唯一)
|
||||
- `package_name`: 套餐名称(VARCHAR(255))
|
||||
- `series_id`: 套餐系列 ID(BIGINT,关联 package_series 表,用于组织套餐分组和配置一次性分佣)
|
||||
- `package_type`: 套餐类型(VARCHAR(20),"formal"-正式套餐 | "addon"-加油包)
|
||||
- `duration_months`: 套餐时长(INT,月数,1-月套餐 12-年套餐,加油包为 0)
|
||||
- `real_data_mb`: 真流量额度(BIGINT,MB 为单位,套餐标称总流量)
|
||||
- `virtual_data_mb`: 虚流量额度(BIGINT,MB 为单位,停机阈值,始终小于或等于真流量)
|
||||
- `data_amount_mb`: 总流量额度(BIGINT,MB 为单位,real_data_mb + virtual_data_mb)
|
||||
- `virtual_ratio`: 虚流量换算比例(DECIMAL(10,6),套餐创建时计算并存储,用于客户端展示)
|
||||
- `enable_virtual_data`: 是否启用虚流量(BOOLEAN,false 时 virtual_ratio=1.0)
|
||||
- `price`: 套餐价格(DECIMAL(10,2),元)
|
||||
- `status`: 套餐状态(INT,1-上架 2-下架)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
**virtual_ratio 计算规则**:
|
||||
- `enable_virtual_data = true` 且 `virtual_data_mb > 0`:`virtual_ratio = real_data_mb / virtual_data_mb`
|
||||
- 其他情况(未启用虚流量):`virtual_ratio = 1.0`
|
||||
- 套餐创建或更新时由 Service 层自动计算并存储,不由调用方传入
|
||||
|
||||
**virtual_ratio 使用场景**(展示换算):
|
||||
- `展示已使用 = 真已使用 × virtual_ratio`
|
||||
- `展示剩余 = real_data_mb - 展示已使用`
|
||||
- 目的:当真用量达到停机阈值(virtual_data_mb)时,客户看到的展示用量恰好等于 real_data_mb(100% 已使用)
|
||||
|
||||
**套餐类型说明**:
|
||||
- **正式套餐(formal)**: 每张 IoT 卡只能有一个有效的正式套餐,购买新的正式套餐会替换旧的
|
||||
- **加油包(addon)**: 每张 IoT 卡可以购买多个加油包,与正式套餐共存
|
||||
|
||||
#### Scenario: 创建月套餐(未启用虚流量)
|
||||
|
||||
- **WHEN** 平台创建月套餐,套餐编码为 "PKG-M-001",`enable_virtual_data = false`,`real_data_mb = 10240`
|
||||
- **THEN** 系统创建套餐记录,`virtual_ratio = 1.0`(未启用虚流量时无换算)
|
||||
|
||||
#### Scenario: 创建启用虚流量的套餐
|
||||
|
||||
- **WHEN** 平台创建套餐,`enable_virtual_data = true`,`real_data_mb = 10240`(10G),`virtual_data_mb = 9216`(9G)
|
||||
- **THEN** 系统自动计算并存储 `virtual_ratio = 10240 / 9216 ≈ 1.111111`
|
||||
|
||||
#### Scenario: 展示流量换算正确
|
||||
|
||||
- **WHEN** 客户的卡真已使用 = 9216 MB(已达停机阈值),`real_data_mb = 10240`,`virtual_ratio = 1.111111`
|
||||
- **THEN** 展示已使用 = 9216 × 1.111111 ≈ 10240 MB,展示剩余 = 0 MB,客户看到"已用 10G / 共 10G"
|
||||
|
||||
#### Scenario: 创建年套餐
|
||||
|
||||
- **WHEN** 平台创建年套餐,套餐编码为 "PKG-Y-001",套餐名称为 "年套餐 120GB",套餐系列 ID 为 1,类型为正式套餐,时长为 12 个月,真流量为 122880 MB,虚流量为 0,价格为 300.00 元
|
||||
- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-Y-001",`series_id` 为 1,`package_type` 为 "formal",`duration_months` 为 12,`real_data_mb` 为 122880,`virtual_data_mb` 为 0,`data_amount_mb` 为 122880,`price` 为 300.00,`virtual_ratio` 为 1.0
|
||||
|
||||
#### Scenario: 创建流量加油包
|
||||
|
||||
- **WHEN** 平台创建加油包,套餐编码为 "PKG-ADD-001",套餐名称为 "流量包 5GB",套餐系列 ID 为 2,类型为加油包,时长为 0,真流量为 5120 MB,虚流量为 0,价格为 10.00 元
|
||||
- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-ADD-001",`series_id` 为 2,`package_type` 为 "addon",`duration_months` 为 0,`real_data_mb` 为 5120,`virtual_data_mb` 为 0,`data_amount_mb` 为 5120,`price` 为 10.00,`virtual_ratio` 为 1.0
|
||||
@@ -0,0 +1,46 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 保护期一致性检查轮询任务
|
||||
|
||||
系统 SHALL 新增第四种轮询任务类型(保护期一致性检查),作为独立任务处理器,不修改现有三种任务(实名检查/流量检查/套餐检查)的内部逻辑。
|
||||
|
||||
**任务类型标识**: `protect`(与现有 `realname`、`carddata`、`package` 并列)
|
||||
|
||||
**Redis 队列 Key**: `RedisPollingQueueProtectKey()` → `"polling:queue:protect"`
|
||||
|
||||
**触发频率**: 与流量检查任务同频(默认 10 分钟)
|
||||
|
||||
**任务范围**: 仅检查"已绑定设备且设备当前有保护期"的卡,范围小,不会对未绑定设备的卡产生影响
|
||||
|
||||
**处理逻辑**:
|
||||
1. 检查卡是否已实名(`real_name_status = 0` 则跳过,未实名卡不参与保护期逻辑)
|
||||
2. 检查卡是否绑定设备(`is_standalone = true` 则跳过)
|
||||
3. 读取设备保护期 Redis Key
|
||||
4. 若设备有 **stop 保护期**,且卡当前网络状态为**开机**:强制调网关停机,更新卡 `network_status = 0`
|
||||
5. 若设备有 **start 保护期**,且卡当前网络状态为**停机**:强制调网关复机,更新卡 `network_status = 1`
|
||||
6. 状态已一致(开机 + stop 保护期已停 / 停机 + start 保护期已开):跳过
|
||||
|
||||
#### Scenario: stop 保护期内卡状态异常(开机)
|
||||
|
||||
- **WHEN** 轮询任务检查一张已实名卡,发现绑定设备有 stop 保护期,但卡当前 network_status=1(开机)
|
||||
- **THEN** 任务强制调网关停机,更新卡 network_status=0,记录 Info 日志
|
||||
|
||||
#### Scenario: start 保护期内卡状态异常(停机)
|
||||
|
||||
- **WHEN** 轮询任务检查一张已实名卡,发现绑定设备有 start 保护期,但卡当前 network_status=0(停机)
|
||||
- **THEN** 任务强制调网关复机,更新卡 network_status=1,记录 Info 日志
|
||||
|
||||
#### Scenario: 状态已一致,跳过
|
||||
|
||||
- **WHEN** 轮询任务检查一张卡,设备有 stop 保护期,卡已是停机状态
|
||||
- **THEN** 任务跳过,不调网关,不更新 DB
|
||||
|
||||
#### Scenario: 未实名卡跳过保护期逻辑
|
||||
|
||||
- **WHEN** 轮询任务遇到 real_name_status=0 的卡
|
||||
- **THEN** 任务直接跳过,不检查保护期,不调网关
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)跳过
|
||||
|
||||
- **WHEN** 轮询任务遇到 is_standalone=true 的卡
|
||||
- **THEN** 任务直接跳过,不查询设备保护期
|
||||
@@ -0,0 +1,107 @@
|
||||
## 1. 数据库迁移(先行)
|
||||
|
||||
- [x] 1.1 创建迁移文件:`tb_device.device_no` → `virtual_no`,`tb_personal_customer_device.device_no` → `virtual_no`(两张表在同一个迁移文件中完成)
|
||||
- [x] 1.2 创建迁移文件:`tb_iot_card` 新增 `virtual_no VARCHAR(50)` 字段,创建部分唯一索引 `idx_iot_card_virtual_no`(`WHERE deleted_at IS NULL`)
|
||||
- [x] 1.3 创建迁移文件:`tb_package` 新增 `virtual_ratio DECIMAL(10,6) DEFAULT 1.0` 字段,回填现有数据(根据 `enable_virtual_data` 和 `real_data_mb / virtual_data_mb` 计算)
|
||||
- [x] 1.4 执行全部迁移,验证三张表结构变更成功(PostgreSQL MCP 确认)
|
||||
|
||||
## 2. 数据模型更新(device_no 全量改名)
|
||||
|
||||
- [x] 2.1 更新 `internal/model/device.go`:`DeviceNo` → `VirtualNo`,GORM column tag 从 `device_no` 改为 `virtual_no`,更新注释
|
||||
- [x] 2.2 更新 `internal/model/personal_customer_device.go`:`DeviceNo` → `VirtualNo`,column tag 同步更新
|
||||
- [x] 2.3 更新 `internal/model/dto/device_dto.go`:`DeviceResponse.DeviceNo` → `VirtualNo`,JSON tag 从 `"device_no"` 改为 `"virtual_no"`;`ListDeviceRequest.DeviceNo` → `VirtualNo`,query tag 同步更新;`AllocationDeviceFailedItem.DeviceNo` → `VirtualNo`;`DeviceSeriesBindngFailedItem.DeviceNo` → `VirtualNo`
|
||||
- [x] 2.4 全量搜索代码中所有 `.DeviceNo` 引用(Store/Service/Handler 层),逐一替换为 `.VirtualNo`(使用 lsp_rename 保证全量覆盖)
|
||||
- [x] 2.5 运行 `go build ./...` 确认无编译错误,运行 `lsp_diagnostics` 确认无类型错误
|
||||
|
||||
## 3. IotCard 模型更新(新增 virtual_no 字段)
|
||||
|
||||
- [x] 3.1 更新 `internal/model/iot_card.go`:新增 `VirtualNo string` 字段,GORM tag 包含 `column:virtual_no; type:varchar(50); uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL` 注释
|
||||
- [x] 3.2 运行 `lsp_diagnostics` 确认模型字段无错误
|
||||
|
||||
## 4. Package 模型更新(新增 virtual_ratio 字段)
|
||||
|
||||
- [x] 4.1 更新 `internal/model/package.go`:`Package` 结构体新增 `VirtualRatio float64` 字段,GORM tag `column:virtual_ratio; type:decimal(10,6); default:1.0`
|
||||
- [x] 4.2 更新 `internal/service/package/service.go`:在套餐创建和更新逻辑中自动计算并存储 `virtual_ratio`(`enable_virtual_data=true` 且 `virtual_data_mb>0` 时 = `real_data_mb/virtual_data_mb`,否则 = 1.0)
|
||||
- [x] 4.3 运行 `lsp_diagnostics` 确认无错误
|
||||
|
||||
## 5. Redis 常量新增
|
||||
|
||||
- [x] 5.1 在 `pkg/constants/redis.go` 新增 `RedisDeviceProtectKey(deviceID uint, action string) string`(格式:`protect:device:{id}:{action}`,TTL 注释:1 小时)
|
||||
- [x] 5.2 在 `pkg/constants/redis.go` 新增 `RedisDeviceRefreshCooldownKey(deviceID uint) string`(格式:`refresh:cooldown:device:{id}`,TTL 注释:冷却时长,建议 30 秒)
|
||||
- [x] 5.3 在 `pkg/constants/redis.go` 新增 `RedisPollingQueueProtectKey() string`(格式:`polling:queue:protect`)
|
||||
- [x] 5.4 在 `pkg/constants/` 新增设备保护期时长常量 `DeviceProtectPeriodDuration = 1 * time.Hour`,设备刷新冷却时长常量 `DeviceRefreshCooldownDuration = 30 * time.Second`
|
||||
|
||||
## 6. RefreshCardDataFromGateway 方法增强
|
||||
|
||||
- [x] 6.1 在 `internal/service/iot_card/service.go` 中将 `SyncCardStatusFromGateway` 改名为 `RefreshCardDataFromGateway`
|
||||
- [x] 6.2 增强方法实现:调用网关查询卡状态(网络状态)、实名状态、本月流量用量,将结果写回 DB:更新 `network_status`、`real_name_status`、`current_month_usage_mb`、`last_sync_time`(参考 polling_handler.go 中的完整同步逻辑)
|
||||
- [x] 6.3 更新所有调用 `SyncCardStatusFromGateway` 的地方改为 `RefreshCardDataFromGateway`(全局搜索替换)
|
||||
- [x] 6.4 运行 `go build ./...` 确认无编译错误
|
||||
|
||||
## 7. 新建 AssetService
|
||||
|
||||
- [x] 7.1 创建 `internal/service/asset/service.go`,定义 `Service` 结构体,依赖注入:DeviceStore、IotCardStore、PackageUsageStore、PackageStore、Redis(通过现有 bootstrap 体系接入)
|
||||
- [x] 7.2 实现 `Resolve(ctx, identifier string) (*dto.AssetResolveResponse, error)`:先查设备(virtual_no/imei/sn),再查卡(virtual_no/iccid/msisdn),应用数据权限过滤,聚合套餐流量(含 virtual_ratio 换算)、保护期状态、绑定信息
|
||||
- [x] 7.3 实现 `GetRealtimeStatus(ctx, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error)`:仅读 DB/Redis 持久化数据,不调网关;card 返回网络状态/实名/流量/最后同步;device 返回保护期状态+所有绑定卡状态
|
||||
- [x] 7.4 实现 `Refresh(ctx, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error)`:card 调 `RefreshCardDataFromGateway`;device 检查 Redis 冷却期(429),可刷新则遍历绑定卡调 `RefreshCardDataFromGateway`,设置冷却 Key
|
||||
- [x] 7.5 实现 `GetPackages(ctx, assetType string, id uint) ([]*dto.AssetPackageResponse, error)`:按 asset_type 查 PackageUsage(card→iot_card_id,device→device_id),全量返回按创建时间倒序,含 virtual_ratio 展示换算
|
||||
- [x] 7.6 实现 `GetCurrentPackage(ctx, assetType string, id uint) (*dto.AssetPackageResponse, error)`:查 status=1 且 master_usage_id IS NULL 的主套餐,无则返回 ErrNotFound
|
||||
|
||||
## 8. 设备停复机 Service
|
||||
|
||||
- [x] 8.1 在 `internal/service/device/service.go` 实现 `StopDevice(ctx, deviceID uint) (*dto.DeviceSuspendResponse, error)`:验证设备存在、检查保护期、获取已实名绑定卡、批量调网关停机、更新卡状态、设置 Redis stop 保护期(部分失败时仍设置)
|
||||
- [x] 8.2 实现 `StartDevice(ctx, deviceID uint) error`:验证设备存在、检查保护期、获取已实名绑定卡、批量调网关复机、更新卡状态、设置 Redis start 保护期
|
||||
|
||||
## 9. 卡停复机 Service 扩展
|
||||
|
||||
- [x] 9.1 在 `internal/service/iot_card/stop_resume_service.go` 实现 `ManualStopCard(ctx, iccid string) error`:通过 ICCID 查卡、验证已实名、检查绑定设备的保护期(stop 保护期允许、start 保护期允许)、调网关停机、更新卡状态
|
||||
- [x] 9.2 实现 `ManualStartCard(ctx, iccid string) error`:通过 ICCID 查卡、验证已实名、检查绑定设备的保护期(stop 保护期→拒绝 403、start 保护期→允许)、调网关复机、更新卡状态
|
||||
|
||||
## 10. DTO 新增
|
||||
|
||||
- [x] 10.1 在 `internal/model/dto/` 新增 `asset_dto.go`,定义以下 DTO(含所有字段及 description tag):
|
||||
- `AssetResolveResponse`、`BoundCardInfo`、`AssetRealtimeStatusResponse`、`AssetPackageResponse`
|
||||
- [x] 10.2 `DeviceSuspendResponse`(成功信息 + 失败卡列表,已在 device_dto.go 中)
|
||||
|
||||
## 11. 新建 AssetHandler 和路由注册
|
||||
|
||||
- [x] 11.1 创建 `internal/handler/admin/asset.go`,定义 `AssetHandler` 结构体和 9 个 Handler 方法(Resolve、RealtimeStatus、Refresh、Packages、CurrentPackage、StopDevice、StartDevice、StopCard、StartCard)
|
||||
- [x] 11.2 创建 `internal/routes/asset.go` 注册 `/api/admin/assets/*` 路由(9 个端点);企业账号访问 resolve 时在 Handler 层检查 user_type 返回 403
|
||||
- [x] 11.3 更新 `internal/bootstrap/types.go`、`services.go`、`handlers.go`,将 Asset、StopResumeService 加入 bootstrap 体系;更新 docs 文件
|
||||
|
||||
## 12. 轮询系统新增保护期一致性检查任务
|
||||
|
||||
- [x] 12.1 `RedisPollingQueueProtectKey()` 已存在(Task 5.3)
|
||||
- [x] 12.2 在 `internal/task/polling_handler.go` 新增 `HandleProtectConsistencyCheck`:检查 is_standalone、real_name_status、设备保护期 Redis Key,按规则强制同步网络状态
|
||||
- [x] 12.3 在 `pkg/constants/constants.go` 添加 `TaskTypePollingProtect`,在 `pkg/queue/handler.go` 注册处理器
|
||||
|
||||
## 13. 卡 ICCID 导入支持 virtual_no 列
|
||||
|
||||
- [x] 13.1 `pkg/utils/excel.go` 新增 virtual_no 列识别(关键字:virtual_no/VirtualNo/虚拟号/设备号)
|
||||
- [x] 13.2 `internal/task/iot_card_import.go` 实现 virtual_no 唯一性校验和写入逻辑;`IotCardStore` 新增 `ExistsByVirtualNoBatch`
|
||||
|
||||
## 14. 删除废弃接口
|
||||
|
||||
### 14a. 废弃停复机接口
|
||||
|
||||
- [x] 14.1 删除 `internal/handler/admin/enterprise_card.go` 中的 `SuspendCard` 和 `ResumeCard` Handler
|
||||
- [x] 14.2 删除 `internal/handler/h5/enterprise_device.go` 中的 `SuspendCard` 和 `ResumeCard` Handler
|
||||
- [x] 14.3 删除 `internal/handler/admin/iot_card.go` 中的 `StopCard` 和 `StartCard` Handler
|
||||
- [x] 14.4 清理对应路由注册,`go build ./...` 通过
|
||||
|
||||
### 14b. 废弃详情查询和网关直查接口
|
||||
|
||||
- [x] 14.5 删除 `internal/handler/admin/iot_card.go` 中的 `GetByICCID` Handler
|
||||
- [x] 14.6 删除 `internal/handler/admin/iot_card.go` 中的 `GetGatewayStatus`、`GetGatewayFlow`、`GetGatewayRealname` 三个 Handler
|
||||
- [x] 14.7 删除 `internal/handler/admin/device.go` 中的 `GetByID` Handler
|
||||
- [x] 14.8 删除 `internal/handler/admin/device.go` 中的 `GetByIdentifier` Handler
|
||||
- [x] 14.9 删除 `internal/handler/admin/device.go` 中的 `GetGatewayInfo` Handler
|
||||
- [x] 14.10 清理对应路由注册,`go build ./...` 通过
|
||||
|
||||
## 15. 文档和最终验收
|
||||
|
||||
- [x] 15.1 更新 API 文档生成器,运行 `go run cmd/gendocs/main.go` 确认 9 个新接口出现在文档中
|
||||
- [x] 15.2 使用 PostgreSQL MCP 验证三张表结构变更正确(tb_device.virtual_no 唯一索引、tb_iot_card.virtual_no 条件唯一索引、tb_package.virtual_ratio 字段 NOT NULL DEFAULT 1.0)
|
||||
- [x] 15.3 使用 PostgreSQL MCP 验证 Package 数据回填正确(enable_virtual_data=true 的套餐 virtual_ratio=930.9,非 1.0)
|
||||
- [x] 15.4 运行 `go build ./...` 全量检查无编译错误
|
||||
- [x] 15.5 tasks.md 全部任务标记完成
|
||||
172
openspec/specs/asset-queries/spec.md
Normal file
172
openspec/specs/asset-queries/spec.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# asset-queries Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
提供基于已知资产 ID 的轻量查询接口,包括实时状态查询、手动刷新、套餐历史列表和当前主套餐详情。供前端在 resolve 之后进行快速轮询和详情展示。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 轻量实时状态查询
|
||||
|
||||
系统 SHALL 提供基于持久化数据的轻量状态查询接口,供前端在已知资产 ID 后进行快速轮询。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/:asset_type/:id/realtime-status`
|
||||
|
||||
**约束**:
|
||||
- `:asset_type` 取值为 `device` 或 `card`
|
||||
- 此接口**不调用网关**,仅读取 DB/Redis 中持久化的最新数据
|
||||
- 不包含套餐流量计算(与 resolve 的区别)
|
||||
- "实时性"依赖轮询系统定期刷新(实名状态约 5 分钟,流量约 10 分钟)
|
||||
|
||||
**card 类型响应字段**:
|
||||
- `network_status`: 网络状态(0-停机 1-开机)
|
||||
- `real_name_status`: 实名状态(0-未实名 1-已实名)
|
||||
- `current_month_usage_mb`: 本月已用流量(持久化缓存值)
|
||||
- `last_sync_at`: 最后与 Gateway 同步时间
|
||||
|
||||
**device 类型响应字段**:
|
||||
- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`)
|
||||
- `cards`: 所有绑定卡的状态列表(同 DeviceCardInfo 结构)
|
||||
|
||||
#### Scenario: 查询单卡实时状态
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/realtime-status`
|
||||
- **THEN** 系统返回该卡的 network_status、real_name_status、current_month_usage_mb、last_sync_at
|
||||
|
||||
#### Scenario: 查询设备实时状态
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/realtime-status`
|
||||
- **THEN** 系统返回设备的保护期状态及所有绑定卡的当前状态列表
|
||||
|
||||
#### Scenario: asset_type 参数非法
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/unknown-type/123/realtime-status`
|
||||
- **THEN** 系统返回 HTTP 400 参数错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 手动刷新接口
|
||||
|
||||
系统 SHALL 提供手动触发网关同步的接口,用于客服主动刷新资产最新状态。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/:asset_type/:id/refresh`
|
||||
|
||||
**行为规则**:
|
||||
- card 类型:直接调用 `RefreshCardDataFromGateway(iccid)` 同步网络状态、实名状态、本月流量、最后同步时间
|
||||
- device 类型:对该设备所有绑定卡遍历调用 `RefreshCardDataFromGateway`
|
||||
|
||||
**设备类型频率限制**:
|
||||
- 使用 Redis Key `RedisDeviceRefreshCooldownKey(deviceID)` 限频
|
||||
- 同一设备 30 秒冷却期内不允许重复触发
|
||||
- 冷却期内调用返回 HTTP 429
|
||||
|
||||
**响应**:
|
||||
- 刷新完成后返回刷新后的最新状态(与 realtime-status 响应结构相同)
|
||||
|
||||
#### Scenario: 刷新单卡状态
|
||||
|
||||
- **WHEN** 客服调用 `POST /api/admin/assets/card/123/refresh`
|
||||
- **THEN** 系统调用 RefreshCardDataFromGateway,更新 DB 中的卡状态字段,返回刷新后的最新状态
|
||||
|
||||
#### Scenario: 刷新设备状态(首次)
|
||||
|
||||
- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/refresh`,该设备有 3 张绑定卡
|
||||
- **THEN** 系统依次刷新 3 张卡,设置 30 秒冷却期,返回最新状态
|
||||
|
||||
#### Scenario: 设备刷新冷却期内重复触发
|
||||
|
||||
- **WHEN** 管理员在 30 秒冷却期内第二次调用 `POST /api/admin/assets/device/456/refresh`
|
||||
- **THEN** 系统返回 HTTP 429,提示"刷新过于频繁,请稍后再试"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐历史列表查询
|
||||
|
||||
系统 SHALL 提供资产的全量套餐记录查询接口,包含历史和当前生效套餐。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/:asset_type/:id/packages`
|
||||
|
||||
**排序**: 按 `created_at` 倒序(最新套餐在前)
|
||||
|
||||
**分页**: 不分页,全量返回
|
||||
|
||||
**范围**: 包含所有状态(含 status=4 已失效的历史套餐)
|
||||
|
||||
**按 asset_type 区分查询**:
|
||||
- card:查询 `PackageUsage.iot_card_id = :id`
|
||||
- device:查询 `PackageUsage.device_id = :id`
|
||||
|
||||
**每条记录响应字段**:
|
||||
- `package_usage_id`: 套餐使用记录 ID
|
||||
- `package_name`: 套餐名称
|
||||
- `package_type`: 套餐类型(formal/addon)
|
||||
- `master_usage_id`: 主套餐 ID(加油包时有值,主套餐时为 null)
|
||||
- `real_data_mb`: 真总流量(MB)
|
||||
- `virtual_data_mb`: 虚总流量/停机阈值(MB)
|
||||
- `package_used_mb`: 展示已使用流量(经虚流量换算)
|
||||
- `package_remain_mb`: 展示剩余流量
|
||||
- `activated_at`: 生效时间
|
||||
- `expires_at`: 过期时间
|
||||
- `status`: 套餐状态(0-待生效 1-生效中 2-已用完 3-已过期 4-已失效)
|
||||
|
||||
#### Scenario: 查询卡的套餐历史
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/packages`,该卡有 3 条套餐记录(含 1 条已失效)
|
||||
- **THEN** 系统返回全部 3 条记录,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 查询设备的套餐历史
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/packages`
|
||||
- **THEN** 系统返回该设备 device_id 下的所有套餐记录
|
||||
|
||||
#### Scenario: 资产无套餐记录
|
||||
|
||||
- **WHEN** 管理员查询一张从未购买过套餐的卡
|
||||
- **THEN** 系统返回空数组,不报错
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 当前主套餐详情查询
|
||||
|
||||
系统 SHALL 提供查询资产当前生效主套餐的接口,用于展示套餐详细信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/:asset_type/:id/current-package`
|
||||
|
||||
**查询条件**: `status = 1(生效中)AND master_usage_id IS NULL`
|
||||
|
||||
**多套餐同时生效时**:只返回主套餐(master_usage_id IS NULL),不返回加油包
|
||||
|
||||
**响应字段**:
|
||||
- 完整套餐信息(同套餐历史列表中的单条记录字段)
|
||||
- 当无生效主套餐时,返回 HTTP 404
|
||||
|
||||
#### Scenario: 返回当前主套餐
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/current-package`,该卡有 1 个生效主套餐和 1 个加油包
|
||||
- **THEN** 系统只返回主套餐信息,不包含加油包
|
||||
|
||||
#### Scenario: 无当前生效主套餐
|
||||
|
||||
- **WHEN** 管理员查询没有生效中主套餐的资产
|
||||
- **THEN** 系统返回 HTTP 404
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RefreshCardDataFromGateway 完整同步
|
||||
|
||||
系统 SHALL 提供从 Gateway 完整同步卡数据的方法,替代原 `SyncCardStatusFromGateway`(仅为示例实现)。
|
||||
|
||||
**方法签名**: `RefreshCardDataFromGateway(ctx context.Context, iccid string) error`
|
||||
|
||||
**同步字段**:
|
||||
- `network_status`: 网络状态(从网关卡状态映射)
|
||||
- `real_name_status`: 实名状态(从网关实名接口获取)
|
||||
- `current_month_usage_mb`: 本月已用流量(从网关流量接口获取)
|
||||
- `last_sync_time`: 更新为当前时间
|
||||
|
||||
**错误处理**: 网关调用失败时记录 Error 日志并返回错误,不更新 DB
|
||||
|
||||
#### Scenario: 完整同步卡数据
|
||||
|
||||
- **WHEN** 调用 `RefreshCardDataFromGateway(ctx, "89860123456789012345")`
|
||||
- **THEN** 系统调用网关接口,将 network_status、real_name_status、current_month_usage_mb、last_sync_time 写回 DB
|
||||
138
openspec/specs/asset-resolve/spec.md
Normal file
138
openspec/specs/asset-resolve/spec.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# asset-resolve Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
提供统一的资产解析入口,通过任意标识符(虚拟号/ICCID/IMEI/SN/MSISDN)定位卡或设备,并返回该资产的中等聚合信息,包含套餐流量、保护期状态和绑定关系。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 统一资产解析入口
|
||||
|
||||
系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/resolve/:identifier`
|
||||
|
||||
**查找顺序**:
|
||||
1. 先在 `tb_device` 表查找(匹配 `virtual_no = ? OR imei = ? OR sn = ?`)
|
||||
2. 未命中则在 `tb_iot_card` 表查找(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`)
|
||||
3. 两表均未命中 → 返回 HTTP 404
|
||||
4. 找到后应用数据权限过滤,无权限 → 返回 HTTP 403
|
||||
|
||||
**数据权限规则**:
|
||||
- 代理用户:只能查看 `shop_id` 在自己及下级店铺范围内的资产
|
||||
- 平台用户(SuperAdmin/Platform):可查看所有资产
|
||||
- 企业账号:暂不支持此接口,调用时返回 HTTP 403
|
||||
|
||||
**响应结构(AssetResolveResponse)**:
|
||||
|
||||
*通用字段(device 和 card 均有)*:
|
||||
- `asset_type`: 资产类型(`"device"` 或 `"card"`)
|
||||
- `asset_id`: 资产主键 ID
|
||||
- `virtual_no`: 虚拟号(设备/卡均使用此字段)
|
||||
- `status`: 资产状态(整型)
|
||||
- `batch_no`: 批次号
|
||||
- `shop_id`: 所属店铺 ID(平台库存时为空)
|
||||
- `shop_name`: 所属店铺名称
|
||||
- `series_id`: 套餐系列 ID(未绑定时为空)
|
||||
- `series_name`: 套餐系列名称
|
||||
- `first_commission_paid`: 一次性佣金是否已发放
|
||||
- `accumulated_recharge`: 累计充值金额(分)
|
||||
- `activated_at`: 激活时间(未激活时为空)
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
|
||||
*状态与套餐字段(device 和 card 均有)*:
|
||||
- `real_name_status`: 实名状态(整型)
|
||||
- `current_package`: 当前套餐名称(无套餐时返回空字符串)
|
||||
- `package_total_mb`: 真总流量,即 RealDataMB(无套餐时返回 0)
|
||||
- `package_virtual_mb`: 虚总流量/停机阈值,即 VirtualDataMB(无套餐时返回 0)
|
||||
- `package_used_mb`: 客户端展示已使用流量(经虚流量换算,见流量计算规则)
|
||||
- `package_remain_mb`: 客户端展示剩余流量
|
||||
- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`);card 类型时若绑定的设备有保护期也返回该设备的保护期状态
|
||||
|
||||
*绑定关系字段*:
|
||||
- `iccid`: 仅 card 类型时有值,供前端调用停复机接口使用
|
||||
- `bound_device_id`: 仅 card 类型且卡绑定了设备时有值
|
||||
- `bound_device_no`: 绑定设备的虚拟号
|
||||
- `bound_device_name`: 绑定设备的名称
|
||||
- `bound_card_count`: 仅 device 类型时有值,绑定卡的总数量
|
||||
- `cards`: 仅 device 类型时有值,所有绑定卡列表(含未实名、已停用)
|
||||
|
||||
*设备专属档案字段(asset_type=device 时有值,card 类型时为空/零值)*:
|
||||
- `device_name`: 设备名称
|
||||
- `imei`: IMEI
|
||||
- `sn`: 序列号
|
||||
- `device_model`: 设备型号
|
||||
- `device_type`: 设备类型
|
||||
- `max_sim_slots`: 最大插槽数
|
||||
- `manufacturer`: 制造商
|
||||
|
||||
*卡专属档案字段(asset_type=card 时有值,device 类型时为空/零值)*:
|
||||
- `carrier_id`: 运营商 ID
|
||||
- `carrier_type`: 运营商类型(CMCC/CUCC/CTCC/CBN)
|
||||
- `carrier_name`: 运营商名称
|
||||
- `msisdn`: 卡接入号
|
||||
- `imsi`: IMSI
|
||||
- `card_category`: 卡业务类型(normal/industry)
|
||||
- `supplier`: 供应商
|
||||
- `activation_status`: 激活状态(0-未激活 1-已激活)
|
||||
- `enable_polling`: 是否参与轮询
|
||||
|
||||
**DeviceCardInfo 结构**:
|
||||
- `iot_card_id`: 卡 ID
|
||||
- `iccid`: ICCID
|
||||
- `virtual_no`: 卡的虚拟号
|
||||
- `real_name_status`: 实名状态
|
||||
- `network_status`: 网络状态
|
||||
- `current_month_usage_mb`: 本月已用流量(来自持久化缓存字段)
|
||||
- `last_sync_at`: 最后与 Gateway 同步时间
|
||||
|
||||
**流量展示计算规则**:
|
||||
- `package_used_mb = current_month_usage_mb × virtual_ratio`
|
||||
- `package_remain_mb = package_total_mb - package_used_mb`
|
||||
- 当 `enable_virtual_data = false` 时,`virtual_ratio = 1.0`(无换算)
|
||||
- 设备级套餐:`current_month_usage_mb` 为所有绑定卡本月用量之和
|
||||
|
||||
**特殊情况处理**:
|
||||
- 卡绑定的设备已被软删除:视为独立卡,不填充绑定信息
|
||||
- `cards` 列表包含所有状态的绑定卡,不过滤未实名或已停用的卡
|
||||
|
||||
#### Scenario: 通过 ICCID 找到卡
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/89860123456789012345`,ICCID 匹配到一张独立卡
|
||||
- **THEN** 系统返回 `asset_type="card"`,包含该卡的虚拟号、状态、套餐流量信息,`bound_device_id` 为空
|
||||
|
||||
#### Scenario: 通过虚拟号找到设备
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/GPS-001`,设备表中 `virtual_no = "GPS-001"` 存在
|
||||
- **THEN** 系统返回 `asset_type="device"`,包含该设备的绑定卡列表(DeviceCardInfo 数组),`bound_card_count` 为绑定卡总数
|
||||
|
||||
#### Scenario: 标识符同时命中设备和卡(设备优先)
|
||||
|
||||
- **WHEN** `GPS-001` 在 device 表和 iot_card 表均有匹配(virtual_no 相同)
|
||||
- **THEN** 系统返回设备信息(device 优先),不返回卡信息
|
||||
|
||||
#### Scenario: 标识符未命中任何资产
|
||||
|
||||
- **WHEN** 管理员查询不存在的标识符 `UNKNOWN-999`
|
||||
- **THEN** 系统返回 HTTP 404
|
||||
|
||||
#### Scenario: 代理用户查询无权限的资产
|
||||
|
||||
- **WHEN** 代理用户(shop_id=10)查询属于 shop_id=99(非下级)的设备
|
||||
- **THEN** 系统返回 HTTP 403,明确提示无权限
|
||||
|
||||
#### Scenario: 企业账号调用 resolve
|
||||
|
||||
- **WHEN** 企业账号调用 `GET /api/admin/assets/resolve/:identifier`
|
||||
- **THEN** 系统返回 HTTP 403,提示企业账号暂不支持此接口
|
||||
|
||||
#### Scenario: 卡绑定了有停机保护期的设备
|
||||
|
||||
- **WHEN** 管理员通过 ICCID 查询某张卡,该卡绑定的设备当前有 stop 保护期
|
||||
- **THEN** 响应中 `device_protect_status = "stop"`,反映所属设备的保护期状态
|
||||
|
||||
#### Scenario: 设备无当前生效套餐
|
||||
|
||||
- **WHEN** 管理员查询一台没有购买任何套餐的设备
|
||||
- **THEN** `current_package = ""`,`package_total_mb = 0`,`package_used_mb = 0`,`package_remain_mb = 0`
|
||||
174
openspec/specs/asset-suspend-resume/spec.md
Normal file
174
openspec/specs/asset-suspend-resume/spec.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# asset-suspend-resume Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
提供统一的资产停复机接口,包括设备级批量停复机和单卡停复机,含保护期感知逻辑。废弃原分散在各模块的旧停复机接口,统一使用 `/api/admin/assets/` 路径。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 设备停机接口
|
||||
|
||||
系统 SHALL 提供设备停机接口,批量停用设备下所有已实名卡,并建立停机保护期。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/device/:device_id/stop`
|
||||
|
||||
**执行流程**:
|
||||
1. 验证设备存在(不存在返回 HTTP 404)
|
||||
2. 检查设备是否在保护期(`RedisDeviceProtectKey(deviceID, "stop")` 或 `"start"` 存在则返回 HTTP 403)
|
||||
3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡
|
||||
4. 遍历调用网关停机接口(未实名卡跳过,永远是停机状态)
|
||||
5. 更新成功停机的卡的 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"`
|
||||
6. 在 Redis 中设置停机保护期:`RedisDeviceProtectKey(deviceID, "stop")`,TTL = 1 小时
|
||||
7. 响应:返回成功,附带失败卡列表(如有)
|
||||
|
||||
**保护期说明**:
|
||||
- 保护期时长:1 小时(常量 `DeviceProtectPeriodDuration = 1 * time.Hour`,定义在 `pkg/constants/`)
|
||||
- 停机保护期 key:`protect:device:{device_id}:stop`
|
||||
- 复机保护期 key:`protect:device:{device_id}:start`
|
||||
- 两个 key 互斥:设置 stop 保护期时删除 start 保护期,反之亦然
|
||||
|
||||
**批量部分失败策略**:
|
||||
- 部分卡调网关失败:**仍设置** Redis 保护期(保护期从发起操作时算起)
|
||||
- 已成功停机的卡**不回滚**
|
||||
- 失败的卡记录 Error 日志,响应体中携带失败列表
|
||||
|
||||
#### Scenario: 成功执行设备停机
|
||||
|
||||
- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/stop`,该设备有 3 张已实名卡
|
||||
- **THEN** 系统批量调网关停机,更新 3 张卡 network_status=0,设置 1 小时 stop 保护期,返回成功
|
||||
|
||||
#### Scenario: 设备存在保护期
|
||||
|
||||
- **WHEN** 管理员在设备已有 stop 保护期时再次调用停机接口
|
||||
- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作"
|
||||
|
||||
#### Scenario: 设备下无已实名卡
|
||||
|
||||
- **WHEN** 管理员对只有未实名卡的设备执行停机
|
||||
- **THEN** 系统返回成功(0 张卡操作),设置 stop 保护期
|
||||
|
||||
#### Scenario: 设备不存在
|
||||
|
||||
- **WHEN** 管理员调用不存在的设备 ID
|
||||
- **THEN** 系统返回 HTTP 404
|
||||
|
||||
#### Scenario: 部分卡停机失败
|
||||
|
||||
- **WHEN** 设备有 3 张卡,1 张网关调用失败
|
||||
- **THEN** 2 张成功停机,1 张失败记录日志,**仍设置** stop 保护期,响应中包含失败卡信息
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备复机接口
|
||||
|
||||
系统 SHALL 提供设备复机接口,批量恢复设备下所有已实名卡,并建立复机保护期。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/device/:device_id/start`
|
||||
|
||||
**执行流程**:
|
||||
1. 验证设备存在(不存在返回 HTTP 404)
|
||||
2. 检查设备是否在保护期(stop 或 start 保护期均存在时返回 HTTP 403)
|
||||
3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡
|
||||
4. 遍历调用网关复机接口
|
||||
5. 更新成功复机的卡的 `network_status = 1`,`resumed_at = now()`
|
||||
6. 设置复机保护期:`RedisDeviceProtectKey(deviceID, "start")`,TTL = 1 小时
|
||||
7. 响应:返回成功
|
||||
|
||||
#### Scenario: 成功执行设备复机
|
||||
|
||||
- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/start`,该设备有 2 张已实名卡
|
||||
- **THEN** 系统批量复机,更新卡状态,设置 1 小时 start 保护期,返回成功
|
||||
|
||||
#### Scenario: 设备在 start 保护期内再次复机
|
||||
|
||||
- **WHEN** 设备已有 start 保护期时再次调用复机接口
|
||||
- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡停机接口
|
||||
|
||||
系统 SHALL 提供单卡停机接口,含保护期感知逻辑。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/card/:iccid/stop`
|
||||
|
||||
**执行流程**:
|
||||
1. 通过 ICCID 查找卡(不存在返回 HTTP 404)
|
||||
2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403:未实名卡不允许停复机)
|
||||
3. 若卡绑定了设备,检查该设备的保护期:
|
||||
- 设备有 **stop 保护期**:允许停机(本已是停机方向,无冲突)
|
||||
- 设备有 **start 保护期**:允许停机(用户可主动停单张卡)
|
||||
- 设备无保护期:正常执行
|
||||
4. 调用网关停机接口
|
||||
5. 更新卡 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"`
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)停机
|
||||
|
||||
- **WHEN** 管理员对一张未绑定设备的已实名卡执行停机
|
||||
- **THEN** 系统正常调网关停机,更新卡状态
|
||||
|
||||
#### Scenario: 绑定设备且设备在 start 保护期内停机
|
||||
|
||||
- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行停机
|
||||
- **THEN** 系统允许执行(用户主动停单张卡不违反 start 保护期),正常停机
|
||||
|
||||
#### Scenario: 对未实名卡执行停机
|
||||
|
||||
- **WHEN** 管理员对 real_name_status=0(未实名)的卡执行停机
|
||||
- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡复机接口
|
||||
|
||||
系统 SHALL 提供单卡复机接口,含保护期感知逻辑。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/card/:iccid/start`
|
||||
|
||||
**执行流程**:
|
||||
1. 通过 ICCID 查找卡(不存在返回 HTTP 404)
|
||||
2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403)
|
||||
3. 若卡绑定了设备,检查该设备的保护期:
|
||||
- 设备有 **stop 保护期**:**不允许**手动复机,返回 HTTP 403(设备处于停机保护期)
|
||||
- 设备有 **start 保护期**:允许复机(本已是复机方向,无冲突)
|
||||
- 设备无保护期:正常执行
|
||||
4. 调用网关复机接口
|
||||
5. 更新卡 `network_status = 1`,`resumed_at = now()`,清空 `stop_reason`
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)复机
|
||||
|
||||
- **WHEN** 管理员对一张未绑定设备的已实名停机卡执行复机
|
||||
- **THEN** 系统正常调网关复机,更新卡状态
|
||||
|
||||
#### Scenario: 设备处于 stop 保护期时尝试复机
|
||||
|
||||
- **WHEN** 管理员对绑定了设备且设备有 stop 保护期的卡执行复机
|
||||
- **THEN** 系统返回 HTTP 403,提示"设备处于停机保护期,不允许手动复机"
|
||||
|
||||
#### Scenario: 设备在 start 保护期内复机
|
||||
|
||||
- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行复机
|
||||
- **THEN** 系统允许执行(本已是复机方向),正常复机
|
||||
|
||||
#### Scenario: 对未实名卡执行复机
|
||||
|
||||
- **WHEN** 管理员对 real_name_status=0 的卡执行复机
|
||||
- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 废弃旧停复机接口
|
||||
|
||||
系统 SHALL 删除以下重复的停复机接口,统一使用新的 `/api/admin/assets/` 路径。
|
||||
|
||||
**待删除接口**:
|
||||
- `POST /api/admin/enterprises/:id/cards/:card_id/suspend`
|
||||
- `POST /api/admin/enterprises/:id/cards/:card_id/resume`
|
||||
- `POST /h5/devices/:device_id/cards/:card_id/suspend`
|
||||
- `POST /h5/devices/:device_id/cards/:card_id/resume`
|
||||
- 旧 Admin 卡停复机接口(`POST /iot-cards/:iccid/suspend|resume`)
|
||||
|
||||
#### Scenario: 调用已删除的旧接口
|
||||
|
||||
- **WHEN** 前端调用 `POST /api/admin/enterprises/:id/cards/:card_id/suspend`
|
||||
- **THEN** 系统返回 HTTP 404(路由已不存在)
|
||||
@@ -8,7 +8,7 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
系统 SHALL 提供设备列表查询功能,支持多维度筛选和分页。
|
||||
|
||||
**查询条件**:
|
||||
- `device_no`(可选): 设备号,支持模糊匹配
|
||||
- `virtual_no`(可选): 设备虚拟号,支持模糊匹配(原 `device_no` 字段,已全量改名)
|
||||
- `device_name`(可选): 设备名称,支持模糊匹配
|
||||
- `status`(可选): 设备状态,枚举值 1-在库 | 2-已分销 | 3-已激活 | 4-已停用
|
||||
- `shop_id`(可选): 店铺 ID,NULL 表示平台库存
|
||||
@@ -30,7 +30,7 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
|
||||
**响应字段**:
|
||||
- `id`: 设备 ID
|
||||
- `device_no`: 设备号
|
||||
- `virtual_no`: 设备虚拟号(原 `device_no`,已改名)
|
||||
- `device_name`: 设备名称
|
||||
- `device_model`: 设备型号
|
||||
- `device_type`: 设备类型
|
||||
@@ -51,10 +51,10 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
- **WHEN** 平台管理员查询设备列表,不带任何筛选条件
|
||||
- **THEN** 系统返回所有设备,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 按设备号模糊查询
|
||||
#### Scenario: 按虚拟号模糊查询
|
||||
|
||||
- **WHEN** 管理员输入 device_no = "GPS"
|
||||
- **THEN** 系统返回设备号包含 "GPS" 的所有设备
|
||||
- **WHEN** 管理员输入 virtual_no = "GPS"
|
||||
- **THEN** 系统返回虚拟号包含 "GPS" 的所有设备
|
||||
|
||||
#### Scenario: 按状态筛选设备
|
||||
|
||||
@@ -80,7 +80,7 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
**API 端点**: `GET /api/admin/devices/:id`
|
||||
|
||||
**响应字段**:
|
||||
- 包含设备的所有基本字段
|
||||
- 包含设备的所有基本字段(含 `virtual_no`,不再有 `device_no`)
|
||||
- `shop_name`: 店铺名称(如果有)
|
||||
|
||||
**数据权限**:
|
||||
@@ -90,7 +90,7 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
#### Scenario: 查询设备详情成功
|
||||
|
||||
- **WHEN** 管理员查询设备详情(ID=1)
|
||||
- **THEN** 系统返回该设备的完整基本信息
|
||||
- **THEN** 系统返回该设备的完整基本信息,响应中含 `virtual_no` 字段,不含 `device_no`
|
||||
|
||||
#### Scenario: 查询不存在的设备
|
||||
|
||||
@@ -323,3 +323,36 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
- **WHEN** 代理(店铺 ID=10)尝试回收非直属下级的设备
|
||||
- **THEN** 系统返回错误,提示"只能回收直属下级店铺的设备"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: device_no 全量改名为 virtual_no
|
||||
|
||||
系统 SHALL 将 `tb_device` 表和 `tb_personal_customer_device` 表中的 `device_no` 字段全量改名为 `virtual_no`,确保系统中不再有 `device_no` 的存在。
|
||||
|
||||
**数据库变更**:
|
||||
```sql
|
||||
ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no;
|
||||
ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no;
|
||||
```
|
||||
|
||||
**代码影响范围**:
|
||||
- `internal/model/device.go`:`DeviceNo` → `VirtualNo`,column tag 更新
|
||||
- `internal/model/personal_customer_device.go`:`DeviceNo` → `VirtualNo`,column tag 更新
|
||||
- `internal/model/dto/device_dto.go`:`DeviceResponse.DeviceNo` → `VirtualNo`,JSON tag 更新为 `"virtual_no"`
|
||||
- `internal/store/postgres/device_store.go`:`GetByIdentifier` 查询条件中 `device_no` → `virtual_no`
|
||||
- `internal/store/postgres/personal_customer_device_store.go`:所有 `device_no` 引用更新
|
||||
- 所有 Handler、Service 中引用 `DeviceNo` 字段的代码全量替换
|
||||
|
||||
**设备导入模板**:
|
||||
- 导入 Excel 模板中的列头从 `device_no` 更新为 `virtual_no`
|
||||
|
||||
#### Scenario: 改名后查询设备
|
||||
|
||||
- **WHEN** 改名迁移完成后,调用 `GetByIdentifier("GPS-001")`
|
||||
- **THEN** 系统在 `WHERE virtual_no = ? OR imei = ? OR sn = ?` 中正确匹配,与改名前行为一致
|
||||
|
||||
#### Scenario: 响应中字段名已更新
|
||||
|
||||
- **WHEN** 前端调用设备列表或详情接口
|
||||
- **THEN** 响应 JSON 中 key 为 `virtual_no`,不再有 `device_no`
|
||||
|
||||
|
||||
@@ -192,93 +192,83 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
|
||||
### Requirement: Excel 文件格式规范
|
||||
|
||||
系统 SHALL 要求 Excel 文件必须包含 ICCID 和 MSISDN 两列。
|
||||
系统 SHALL 要求 Excel 文件必须包含 ICCID 和 MSISDN 两列,并支持可选的 `virtual_no` 列。
|
||||
|
||||
**文件格式要求**:
|
||||
- **文件格式**: 仅支持 `.xlsx` (Excel 2007+)
|
||||
- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet
|
||||
- **表头行**: 第1行(可选,但建议包含)
|
||||
- **表头识别关键字**:
|
||||
- ICCID列: iccid/ICCID/卡号/号码
|
||||
- MSISDN列: msisdn/MSISDN/接入号/手机号/电话/号码
|
||||
- **列数要求**: 至少2列(ICCID和MSISDN)
|
||||
- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法)
|
||||
- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet
|
||||
- **表头行**: 第1行(可选,但建议包含)
|
||||
- **表头识别关键字**:
|
||||
- ICCID 列: iccid/ICCID/卡号/号码
|
||||
- MSISDN 列: msisdn/MSISDN/接入号/手机号/电话/号码
|
||||
- virtual_no 列(新增,可选): virtual_no/VirtualNo/虚拟号/设备号
|
||||
- **列数要求**: 至少 2 列(ICCID 和 MSISDN),virtual_no 为可选第三列
|
||||
- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法)
|
||||
|
||||
**解析规则**:
|
||||
- 自动检测表头(第1行包含识别关键字则跳过)
|
||||
- 自动检测表头(第1行包含识别关键字则跳过)
|
||||
- 自动去除单元格首尾空格
|
||||
- 跳过空行
|
||||
- ICCID 为空的行记录为失败
|
||||
- MSISDN 为空的行记录为失败
|
||||
- virtual_no 为空的行:跳过该列(不填入,保留原值)
|
||||
|
||||
**示例Excel内容**:
|
||||
**virtual_no 导入规则(只补空白)**:
|
||||
- 该行 virtual_no 不为空 + 数据库当前值为 NULL:填入新值
|
||||
- 该行 virtual_no 不为空 + 数据库当前值已有值:跳过(不覆盖)
|
||||
- **批次级唯一性检查**:在执行导入前,先检查整批中所有非空 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表
|
||||
|
||||
**示例 Excel 内容**:
|
||||
```
|
||||
| ICCID | MSISDN |
|
||||
|----------------------|-------------|
|
||||
| 89860012345678901234 | 13800000001 |
|
||||
| 89860012345678901235 | 13800000002 |
|
||||
| ICCID | MSISDN | 虚拟号 |
|
||||
|----------------------|-------------|-----------|
|
||||
| 89860012345678901234 | 13800000001 | CARD-001 |
|
||||
| 89860012345678901235 | 13800000002 | |
|
||||
```
|
||||
|
||||
#### Scenario: 解析标准双列 Excel 文件
|
||||
#### Scenario: 解析标准双列 Excel 文件(无 virtual_no 列)
|
||||
|
||||
- **GIVEN** Excel 文件内容为:
|
||||
```
|
||||
| ICCID | MSISDN |
|
||||
| 89860012345678901234 | 13800000001 |
|
||||
| 89860012345678901235 | 13800000002 |
|
||||
```
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 解析结果包含 2 条有效记录,每条包含 ICCID 和 MSISDN
|
||||
- **WHEN** Excel 文件只含 ICCID 和 MSISDN 两列,无虚拟号列
|
||||
- **THEN** 解析结果包含 2 条有效记录,virtual_no 字段为空,不影响导入逻辑
|
||||
|
||||
#### Scenario: 解析含 virtual_no 列的三列 Excel
|
||||
|
||||
- **WHEN** Excel 文件含 ICCID、MSISDN、虚拟号三列,某行 virtual_no = "CARD-001",对应卡当前 virtual_no 为 NULL
|
||||
- **THEN** 解析后该卡的 virtual_no 填入 "CARD-001"
|
||||
|
||||
#### Scenario: virtual_no 已有值时不覆盖
|
||||
|
||||
- **WHEN** Excel 中某行 virtual_no = "CARD-NEW",但该卡数据库中已有 virtual_no = "CARD-OLD"
|
||||
- **THEN** 该卡的 virtual_no 保持 "CARD-OLD" 不变,该行跳过(不报错,不计入失败)
|
||||
|
||||
#### Scenario: 批次中有 virtual_no 与现存数据重复
|
||||
|
||||
- **WHEN** Excel 中某行 virtual_no = "CARD-001",但数据库中另一张卡已有 virtual_no = "CARD-001"
|
||||
- **THEN** 系统拒绝整批导入,响应返回冲突的 virtual_no 值和行号,提示"虚拟号重复,整批导入已终止"
|
||||
|
||||
#### Scenario: 支持中文表头
|
||||
|
||||
- **GIVEN** Excel 文件内容为:
|
||||
```
|
||||
| 卡号 | 接入号 |
|
||||
| 89860012345678901234 | 13800000001 |
|
||||
```
|
||||
- **GIVEN** Excel 文件表头为 `卡号 | 接入号 | 虚拟号`
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 系统正确识别列,解析结果包含 1 条有效记录
|
||||
- **THEN** 系统正确识别三列,按规则处理 virtual_no
|
||||
|
||||
#### Scenario: 拒绝非Excel格式文件
|
||||
|
||||
- **GIVEN** 上传文件扩展名为 .csv
|
||||
- **WHEN** 系统尝试解析该文件
|
||||
- **THEN** 系统返回错误 "不支持的文件格式 .csv,请上传Excel文件(.xlsx)"
|
||||
|
||||
#### Scenario: Excel文件无工作表
|
||||
|
||||
- **GIVEN** Excel 文件不包含任何工作表
|
||||
- **WHEN** 系统尝试解析该 Excel 文件
|
||||
- **THEN** 系统返回错误 "Excel文件无工作表"
|
||||
- **THEN** 系统返回错误"不支持的文件格式 .csv,请上传Excel文件(.xlsx)"
|
||||
|
||||
#### Scenario: MSISDN 为空的行记录失败
|
||||
|
||||
- **GIVEN** Excel 文件内容为:
|
||||
```
|
||||
| ICCID | MSISDN |
|
||||
| 89860012345678901234 | 13800000001 |
|
||||
| 89860012345678901235 | |
|
||||
```
|
||||
- **GIVEN** Excel 文件第二行 MSISDN 为空
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为 "MSISDN 不能为空"
|
||||
|
||||
#### Scenario: ICCID 为空的行记录失败
|
||||
|
||||
- **GIVEN** Excel 文件内容为:
|
||||
```
|
||||
| ICCID | MSISDN |
|
||||
| 89860012345678901234 | 13800000001 |
|
||||
| | 13800000002 |
|
||||
```
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为 "ICCID 不能为空"
|
||||
- **THEN** 第一条记录解析成功,第二条记录标记为失败,原因为"MSISDN 不能为空"
|
||||
|
||||
#### Scenario: 长数字无损解析
|
||||
|
||||
- **GIVEN** Excel 文件中ICCID列设置为文本格式,包含20位数字 "89860012345678901234"
|
||||
- **GIVEN** Excel 文件中 ICCID 列设置为文本格式,包含 20 位数字 "89860012345678901234"
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法
|
||||
- **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -714,3 +714,38 @@ IotCard Service SHALL 提供 Gateway API 的代理方法,封装权限检查和
|
||||
- **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 值)
|
||||
|
||||
@@ -16,7 +16,7 @@ This capability supports:
|
||||
|
||||
### Requirement: 套餐实体定义
|
||||
|
||||
系统 SHALL 定义套餐(Package)实体,包含套餐的基本属性、定价、流量配置。
|
||||
系统 SHALL 定义套餐(Package)实体,包含套餐的基本属性、定价、流量配置,以及用于客户端展示流量换算的 `virtual_ratio` 字段。
|
||||
|
||||
**核心概念**: 套餐只适用于 IoT 卡(ICCID),用户可以为单张 IoT 卡购买套餐,也可以为设备购买套餐(套餐分配到设备绑定的所有 IoT 卡,流量设备级共享)。
|
||||
|
||||
@@ -27,32 +27,54 @@ This capability supports:
|
||||
- `series_id`: 套餐系列 ID(BIGINT,关联 package_series 表,用于组织套餐分组和配置一次性分佣)
|
||||
- `package_type`: 套餐类型(VARCHAR(20),"formal"-正式套餐 | "addon"-加油包)
|
||||
- `duration_months`: 套餐时长(INT,月数,1-月套餐 12-年套餐,加油包为 0)
|
||||
- `real_data_mb`: 真流量额度(BIGINT,MB 为单位,可选)
|
||||
- `virtual_data_mb`: 虚流量额度(BIGINT,MB 为单位,用于停机判断,可选)
|
||||
- `real_data_mb`: 真流量额度(BIGINT,MB 为单位,套餐标称总流量)
|
||||
- `virtual_data_mb`: 虚流量额度(BIGINT,MB 为单位,停机阈值,始终小于或等于真流量)
|
||||
- `data_amount_mb`: 总流量额度(BIGINT,MB 为单位,real_data_mb + virtual_data_mb)
|
||||
- `virtual_ratio`: 虚流量换算比例(DECIMAL(10,6),套餐创建时计算并存储,用于客户端展示)
|
||||
- `enable_virtual_data`: 是否启用虚流量(BOOLEAN,false 时 virtual_ratio=1.0)
|
||||
- `price`: 套餐价格(DECIMAL(10,2),元)
|
||||
- `status`: 套餐状态(INT,1-上架 2-下架)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
**virtual_ratio 计算规则**:
|
||||
- `enable_virtual_data = true` 且 `virtual_data_mb > 0`:`virtual_ratio = real_data_mb / virtual_data_mb`
|
||||
- 其他情况(未启用虚流量):`virtual_ratio = 1.0`
|
||||
- 套餐创建或更新时由 Service 层自动计算并存储,不由调用方传入
|
||||
|
||||
**virtual_ratio 使用场景**(展示换算):
|
||||
- `展示已使用 = 真已使用 × virtual_ratio`
|
||||
- `展示剩余 = real_data_mb - 展示已使用`
|
||||
- 目的:当真用量达到停机阈值(virtual_data_mb)时,客户看到的展示用量恰好等于 real_data_mb(100% 已使用)
|
||||
|
||||
**套餐类型说明**:
|
||||
- **正式套餐(formal)**: 每张 IoT 卡只能有一个有效的正式套餐,购买新的正式套餐会替换旧的
|
||||
- **加油包(addon)**: 每张 IoT 卡可以购买多个加油包,与正式套餐共存
|
||||
|
||||
#### Scenario: 创建月套餐
|
||||
#### Scenario: 创建月套餐(未启用虚流量)
|
||||
|
||||
- **WHEN** 平台创建月套餐,套餐编码为 "PKG-M-001",套餐名称为 "月套餐 10GB",套餐系列 ID 为 1,类型为正式套餐,时长为 1 个月,真流量为 10240 MB,虚流量为 0,价格为 30.00 元
|
||||
- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-M-001",`series_id` 为 1,`package_type` 为 "formal",`duration_months` 为 1,`real_data_mb` 为 10240,`virtual_data_mb` 为 0,`data_amount_mb` 为 10240,`price` 为 30.00
|
||||
- **WHEN** 平台创建月套餐,套餐编码为 "PKG-M-001",`enable_virtual_data = false`,`real_data_mb = 10240`
|
||||
- **THEN** 系统创建套餐记录,`virtual_ratio = 1.0`(未启用虚流量时无换算)
|
||||
|
||||
#### Scenario: 创建启用虚流量的套餐
|
||||
|
||||
- **WHEN** 平台创建套餐,`enable_virtual_data = true`,`real_data_mb = 10240`(10G),`virtual_data_mb = 9216`(9G)
|
||||
- **THEN** 系统自动计算并存储 `virtual_ratio = 10240 / 9216 ≈ 1.111111`
|
||||
|
||||
#### Scenario: 展示流量换算正确
|
||||
|
||||
- **WHEN** 客户的卡真已使用 = 9216 MB(已达停机阈值),`real_data_mb = 10240`,`virtual_ratio = 1.111111`
|
||||
- **THEN** 展示已使用 = 9216 × 1.111111 ≈ 10240 MB,展示剩余 = 0 MB,客户看到"已用 10G / 共 10G"
|
||||
|
||||
#### Scenario: 创建年套餐
|
||||
|
||||
- **WHEN** 平台创建年套餐,套餐编码为 "PKG-Y-001",套餐名称为 "年套餐 120GB",套餐系列 ID 为 1,类型为正式套餐,时长为 12 个月,真流量为 122880 MB,虚流量为 0,价格为 300.00 元
|
||||
- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-Y-001",`series_id` 为 1,`package_type` 为 "formal",`duration_months` 为 12,`real_data_mb` 为 122880,`virtual_data_mb` 为 0,`data_amount_mb` 为 122880,`price` 为 300.00
|
||||
- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-Y-001",`series_id` 为 1,`package_type` 为 "formal",`duration_months` 为 12,`real_data_mb` 为 122880,`virtual_data_mb` 为 0,`data_amount_mb` 为 122880,`price` 为 300.00,`virtual_ratio` 为 1.0
|
||||
|
||||
#### Scenario: 创建流量加油包
|
||||
|
||||
- **WHEN** 平台创建加油包,套餐编码为 "PKG-ADD-001",套餐名称为 "流量包 5GB",套餐系列 ID 为 2,类型为加油包,时长为 0,真流量为 5120 MB,虚流量为 0,价格为 10.00 元
|
||||
- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-ADD-001",`series_id` 为 2,`package_type` 为 "addon",`duration_months` 为 0,`real_data_mb` 为 5120,`virtual_data_mb` 为 0,`data_amount_mb` 为 5120,`price` 为 10.00
|
||||
- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-ADD-001",`series_id` 为 2,`package_type` 为 "addon",`duration_months` 为 0,`real_data_mb` 为 5120,`virtual_data_mb` 为 0,`data_amount_mb` 为 5120,`price` 为 10.00,`virtual_ratio` 为 1.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
52
openspec/specs/polling-protect-consistency/spec.md
Normal file
52
openspec/specs/polling-protect-consistency/spec.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# polling-protect-consistency Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
新增第四种轮询任务类型(保护期一致性检查),用于确保设备保护期内绑定卡的网络状态与保护期方向保持一致,防止状态漂移。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 保护期一致性检查轮询任务
|
||||
|
||||
系统 SHALL 新增第四种轮询任务类型(保护期一致性检查),作为独立任务处理器,不修改现有三种任务(实名检查/流量检查/套餐检查)的内部逻辑。
|
||||
|
||||
**任务类型标识**: `protect`(与现有 `realname`、`carddata`、`package` 并列)
|
||||
|
||||
**Redis 队列 Key**: `RedisPollingQueueProtectKey()` → `"polling:queue:protect"`
|
||||
|
||||
**触发频率**: 与流量检查任务同频(默认 10 分钟)
|
||||
|
||||
**任务范围**: 仅检查"已绑定设备且设备当前有保护期"的卡,范围小,不会对未绑定设备的卡产生影响
|
||||
|
||||
**处理逻辑**:
|
||||
1. 检查卡是否已实名(`real_name_status = 0` 则跳过,未实名卡不参与保护期逻辑)
|
||||
2. 检查卡是否绑定设备(`is_standalone = true` 则跳过)
|
||||
3. 读取设备保护期 Redis Key
|
||||
4. 若设备有 **stop 保护期**,且卡当前网络状态为**开机**:强制调网关停机,更新卡 `network_status = 0`
|
||||
5. 若设备有 **start 保护期**,且卡当前网络状态为**停机**:强制调网关复机,更新卡 `network_status = 1`
|
||||
6. 状态已一致(开机 + stop 保护期已停 / 停机 + start 保护期已开):跳过
|
||||
|
||||
#### Scenario: stop 保护期内卡状态异常(开机)
|
||||
|
||||
- **WHEN** 轮询任务检查一张已实名卡,发现绑定设备有 stop 保护期,但卡当前 network_status=1(开机)
|
||||
- **THEN** 任务强制调网关停机,更新卡 network_status=0,记录 Info 日志
|
||||
|
||||
#### Scenario: start 保护期内卡状态异常(停机)
|
||||
|
||||
- **WHEN** 轮询任务检查一张已实名卡,发现绑定设备有 start 保护期,但卡当前 network_status=0(停机)
|
||||
- **THEN** 任务强制调网关复机,更新卡 network_status=1,记录 Info 日志
|
||||
|
||||
#### Scenario: 状态已一致,跳过
|
||||
|
||||
- **WHEN** 轮询任务检查一张卡,设备有 stop 保护期,卡已是停机状态
|
||||
- **THEN** 任务跳过,不调网关,不更新 DB
|
||||
|
||||
#### Scenario: 未实名卡跳过保护期逻辑
|
||||
|
||||
- **WHEN** 轮询任务遇到 real_name_status=0 的卡
|
||||
- **THEN** 任务直接跳过,不检查保护期,不调网关
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)跳过
|
||||
|
||||
- **WHEN** 轮询任务遇到 is_standalone=true 的卡
|
||||
- **THEN** 任务直接跳过,不查询设备保护期
|
||||
Reference in New Issue
Block a user