feat: 新增数据库迁移,重命名 device_no 为 virtual_no,新增 iot_card.virtual_no 和 package.virtual_ratio 字段
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:
2026-03-14 18:27:28 +08:00
parent b5147d1acb
commit b9c3875c08
77 changed files with 5832 additions and 2393 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
# 资产详情重构 API 变更说明
> 适用版本asset-detail-refactor 提案上线后
> 文档更新2026-03-14
---
## 一、现有接口字段变更
### 1. `device_no` 重命名为 `virtual_no`
所有涉及设备标识符的接口,响应中的 `device_no` 字段已统一改名为 `virtual_no`**JSON key 同步变更**,前端需全局替换。
受影响接口:
| 接口 | 变更字段 |
|------|---------|
| `GET /api/admin/devices`(列表/详情响应) | `device_no``virtual_no` |
| `GET /api/admin/devices/import/tasks/:id` | `failed_items[].device_no``virtual_no` |
| `GET /api/admin/enterprises/:id/devices`(企业设备列表) | `device_no``virtual_no` |
| `GET /api/admin/shop-commission/records` | `device_no``virtual_no` |
| `GET /api/admin/my-commission/records` | `device_no``virtual_no` |
| 企业卡授权相关响应中的设备字段 | `device_no``virtual_no` |
---
### 2. 套餐接口新增 `virtual_ratio` 字段
`GET /api/admin/packages` 及套餐详情响应新增:
| 新增字段 | 类型 | 说明 |
|---------|------|------|
| `virtual_ratio` | float64 | 虚流量比例real_data_mb / virtual_data_mb。启用虚流量时计算否则为 1.0 |
---
### 3. IoT 卡接口新增 `virtual_no` 字段
卡列表/详情响应新增:
| 新增字段 | 类型 | 说明 |
|---------|------|------|
| `virtual_no` | string | 虚拟号(可空) |
---
## 二、新增接口
### 基础说明
- 路径参数 `asset_type` 取值:`card`(卡)或 `device`(设备)
- 企业账号调用 `resolve` 接口会返回 403
---
### `GET /api/admin/assets/resolve/:identifier`
通过任意标识符查询设备或卡的完整详情。支持虚拟号、ICCID、IMEI、SN、MSISDN。
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `asset_type` | string | `card``device` |
| `asset_id` | uint | 数据库 ID |
| `virtual_no` | string | 虚拟号 |
| `status` | int | 资产状态 |
| `batch_no` | string | 批次号 |
| `shop_id` | uint | 所属店铺 ID |
| `shop_name` | string | 所属店铺名称 |
| `series_id` | uint | 套餐系列 ID |
| `series_name` | string | 套餐系列名称 |
| `real_name_status` | int | 实名状态0 未实名 / 1 实名中 / 2 已实名 |
| `network_status` | int | 网络状态0 停机 / 1 开机(仅 card |
| `current_package` | string | 当前套餐名称(无则空) |
| `package_total_mb` | int64 | 当前套餐总虚流量 MB |
| `package_used_mb` | float64 | 已用虚流量 MB |
| `package_remain_mb` | float64 | 剩余虚流量 MB |
| `device_protect_status` | string | 保护期状态:`none` / `stop` / `start`(仅 device |
| `activated_at` | time | 激活时间 |
| `created_at` | time | 创建时间 |
| `updated_at` | time | 更新时间 |
| **绑定关系card 时)** | | |
| `iccid` | string | 卡 ICCID |
| `bound_device_id` | uint | 绑定设备 ID |
| `bound_device_no` | string | 绑定设备虚拟号 |
| `bound_device_name` | string | 绑定设备名称 |
| **绑定关系device 时)** | | |
| `bound_card_count` | int | 绑定卡数量 |
| `cards[]` | array | 绑定卡列表,每项含:`card_id` / `iccid` / `msisdn` / `network_status` / `real_name_status` / `slot_position` |
| **设备专属字段card 时为空)** | | |
| `device_name` | string | 设备名称 |
| `imei` | string | IMEI |
| `sn` | string | 序列号 |
| `device_model` | string | 设备型号 |
| `device_type` | string | 设备类型 |
| `max_sim_slots` | int | 最大插槽数 |
| `manufacturer` | string | 制造商 |
| **卡专属字段device 时为空)** | | |
| `carrier_type` | string | 运营商类型 |
| `carrier_name` | string | 运营商名称 |
| `msisdn` | string | 手机号 |
| `imsi` | string | IMSI |
| `card_category` | string | 卡业务类型 |
| `supplier` | string | 供应商 |
| `activation_status` | int | 激活状态 |
| `enable_polling` | bool | 是否参与轮询 |
---
### `GET /api/admin/assets/:asset_type/:id/realtime-status`
读取资产实时状态(直接读 DB/Redis不调网关
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `asset_type` | string | `card``device` |
| `asset_id` | uint | 资产 ID |
| `network_status` | int | 网络状态(仅 card |
| `real_name_status` | int | 实名状态(仅 card |
| `current_month_usage_mb` | float64 | 本月已用流量 MB仅 card |
| `last_sync_time` | time | 最后同步时间(仅 card |
| `device_protect_status` | string | 保护期:`none` / `stop` / `start`(仅 device |
| `cards[]` | array | 所有绑定卡的状态(仅 device同 resolve 的 cards 结构 |
---
### `POST /api/admin/assets/:asset_type/:id/refresh`
主动调网关拉取最新数据后返回,响应结构与 `realtime-status` 完全相同。
> 设备有 **30 秒冷却期**,冷却中调用返回 429。
---
### `GET /api/admin/assets/:asset_type/:id/packages`
查询该资产所有套餐记录,含虚流量换算字段。
**响应为数组,每项字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `package_usage_id` | uint | 套餐使用记录 ID |
| `package_id` | uint | 套餐 ID |
| `package_name` | string | 套餐名称 |
| `package_type` | string | `formal`(正式套餐)/ `addon`(加油包) |
| `status` | int | 0 待生效 / 1 生效中 / 2 已用完 / 3 已过期 / 4 已失效 |
| `status_name` | string | 状态中文名 |
| `data_limit_mb` | int64 | 真流量总量 MB |
| `virtual_limit_mb` | int64 | 虚流量总量 MB已按 virtual_ratio 换算) |
| `data_usage_mb` | int64 | 已用真流量 MB |
| `virtual_used_mb` | float64 | 已用虚流量 MB |
| `virtual_remain_mb` | float64 | 剩余虚流量 MB |
| `virtual_ratio` | float64 | 虚流量比例 |
| `activated_at` | time | 激活时间 |
| `expires_at` | time | 到期时间 |
| `master_usage_id` | uint | 主套餐 ID加油包时有值 |
| `priority` | int | 优先级 |
| `created_at` | time | 创建时间 |
---
### `GET /api/admin/assets/:asset_type/:id/current-package`
查询当前生效中的主套餐,响应结构同 `packages` 数组的单项。无生效套餐时返回 404。
---
### `POST /api/admin/assets/device/:device_id/stop`
批量停机设备下所有已实名卡,停机成功后设置 **1 小时停机保护期**(保护期内禁止复机)。
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `message` | string | 操作结果描述 |
| `success_count` | int | 成功停机的卡数量 |
| `failed_cards[]` | array | 停机失败列表,每项含 `iccid``reason` |
---
### `POST /api/admin/assets/device/:device_id/start`
批量复机设备下所有已实名卡,复机成功后设置 **1 小时复机保护期**(保护期内禁止停机)。
无响应 bodyHTTP 200 即成功。
---
### `POST /api/admin/assets/card/:iccid/stop`
手动停机单张卡(通过 ICCID。若卡绑定的设备在**复机保护期**内,返回 403。
无响应 bodyHTTP 200 即成功。
---
### `POST /api/admin/assets/card/:iccid/start`
手动复机单张卡(通过 ICCID。若卡绑定的设备在**停机保护期**内,返回 403。
无响应 bodyHTTP 200 即成功。
---
## 三、删除的接口
### IoT 卡
| 删除的接口 | 替代接口 |
|-----------|---------|
| `GET /api/admin/iot-cards/by-iccid/:iccid` | `GET /api/admin/assets/resolve/:iccid` |
| `GET /api/admin/iot-cards/:iccid/gateway-status` | `GET /api/admin/assets/card/:id/realtime-status` |
| `GET /api/admin/iot-cards/:iccid/gateway-flow` | `GET /api/admin/assets/card/:id/realtime-status` |
| `GET /api/admin/iot-cards/:iccid/gateway-realname` | `GET /api/admin/assets/card/:id/realtime-status` |
| `POST /api/admin/iot-cards/:iccid/stop` | `POST /api/admin/assets/card/:iccid/stop` |
| `POST /api/admin/iot-cards/:iccid/start` | `POST /api/admin/assets/card/:iccid/start` |
### 设备
| 删除的接口 | 替代接口 |
|-----------|---------|
| `GET /api/admin/devices/:id` | `GET /api/admin/assets/resolve/:virtual_no` |
| `GET /api/admin/devices/by-identifier/:identifier` | `GET /api/admin/assets/resolve/:identifier` |
| `GET /api/admin/devices/by-identifier/:identifier/gateway-info` | `GET /api/admin/assets/device/:id/realtime-status` |
### 企业卡Admin
| 删除的接口 | 替代接口 |
|-----------|---------|
| `POST /api/admin/enterprises/:id/cards/:card_id/suspend` | `POST /api/admin/assets/card/:iccid/stop` |
| `POST /api/admin/enterprises/:id/cards/:card_id/resume` | `POST /api/admin/assets/card/:iccid/start` |
### 企业设备H5
| 删除的接口 | 替代接口 |
|-----------|---------|
| `POST /api/h5/enterprise/devices/:device_id/suspend-card` | `POST /api/admin/assets/device/:device_id/stop` |
| `POST /api/h5/enterprise/devices/:device_id/resume-card` | `POST /api/admin/assets/device/:device_id/start` |
---
## 四、新增错误码说明
| HTTP 状态码 | 触发场景 |
|------------|---------|
| 403 | 设备在保护期内(停机 1h 内禁止复机,反之亦然);企业账号调用 resolve 接口 |
| 404 | 标识符未匹配到任何资产;当前无生效套餐 |
| 429 | 设备刷新冷却中30 秒内只能主动刷新一次) |

View File

@@ -0,0 +1,821 @@
# 君鸿卡管系统资产详情体系重构 - 讨论纪要
> 创建时间2026-03-12
> 最后更新2026-03-14
> 当前阶段:设计讨论(尚未进入 openspec 提案)
> 目的:保留完整上下文,供未来继续
---
## 一、背景与需求来源
### 1.1 项目背景
君鸿卡管系统junhong_cmp_fiber是一个面向代理/企业的物联网卡管理平台,核心资产有两类:
- **IoT 卡IotCard**:纯卡资源,含 ICCID、MSISDN、流量套餐
- **设备Device**:带卡的硬件设备,一个设备可绑定多张卡,设备级套餐
### 1.2 需求触发点
核心痛点:
1. **接口分散且重复** - 卡和设备的查询散落在多处H5/Admin/Personal 三端各有一套
2. **详情信息严重缺失** - 现有的详情接口返回数据太少,前端无法据此渲染完整页面
3. **网关裸数据透传** - 封装程度不够,没有业务层的聚合和处理
4. **虚拟号只存在于设备** - 卡的查询只能靠 ICCID/MSISDN不方便
### 1.3 已确认的核心决策
-**多接口组合** - 不做单一聚合大接口,前端按需调用
-**统一入口** - 一个接口告诉前端查的是"卡"还是"设备"
-**设备优先查找** - 统一入口先查设备表,再查卡表
-**卡加虚拟号** - 虚拟号概念延伸到卡,与设备的 virtual_no 对等
-**全部一步到位** - 改造不分期,一次性完成
-**resolve 返回中等版本** - 包含资产类型、ID、虚拟号、状态、实名状态、套餐概况、流量使用、所属设备如果绑定等关键信息
-**资产类型只有卡和设备两种** - 未来路由器也归属设备,无需预留更多类型
-**虚拟号客服和客户都要用** - 不是只有内部人员用
-**H5 端接口暂时不需要提供** - 后续做到时再删除旧接口
-**套餐查询看历史记录** - 通过套餐记录/订单记录页面查看历史,同时提供当前套餐接口
-**手动刷新接口复用 SyncCardStatusFromGateway** - 无需重新实现,设备时批量刷新所有绑定卡
-**权限不足返回 403** - 明确告知无权限,不假装资产不存在
-**虚拟号人工填写/批量导入** - 无格式规范,允许修改,重复时全批失败并告知原因
-**device_no 字段全量改名为 virtual_no** - 数据库+代码全部更新,不保留旧字段
-**设备停复机有保护期机制** - 保护状态一致性,时长 1 小时,存储在 Redis
-**realtime-status 只查持久化数据** - 不调用网关,刷新用 refresh 接口
-**未实名的卡不参与停复机** - 未实名卡永远是停机状态,保护期逻辑跳过
-**企业账号 resolve 接口** - 企业账号暂不支持 resolve未来单独开新接口
-**resolve 响应含卡 ICCID** - card 类型时在响应中返回 ICCID供前端调用停复机接口
-**批量停机部分失败仍设保护期** - 部分卡停机失败时也设置 Redis 保护期,已停机的卡不回滚,失败的卡记录日志
-**流量汇总逻辑统一** - 整个系统使用统一的流量汇总逻辑;设备级套餐从 PackageUsage 汇总多卡用量
-**套餐历史列表规则** - 按创建时间倒序,不分页,包含所有状态(含已失效)
-**current-package 返回主套餐** - 多套餐同时生效时只返回主套餐master_usage_id IS NULL
-**轮询系统新增第四种任务** - 保护期一致性检查封装为独立轮询任务类型,不修改现有三种任务
-**卡虚拟号导入只补空白** - 只允许为现有空白虚拟号的卡填入,不支持覆盖更新;与数据库现存数据重复则全批失败
-**设备批量刷新需限频** - Redis 限频保护,同一设备冷却期内(建议 30 秒)不允许重复触发
-**PersonalCustomerDevice 统一改名** - tb_personal_customer_device 表的 device_no 字段一并改为 virtual_no
-**realtime-status 与 resolve 分工明确** - resolve 用于初始加载含查找realtime-status 用于已知 ID 的轻量状态轮询(不含套餐流量计算)
---
## 二、现有系统审计结果
### 2.1 接口现状(三端盘点)
| 端 | 卡接口数 | 设备接口数 | 重复停复机 | 套餐接口 |
|---|---------|-----------|-----------|---------|
| Admin | 9 | 14 | 3处 | 仅流量详单 |
| H5 | 4 | 7 | 1处 | 有套餐聚合 |
| Personal | 2 | 0 | 无 | 无 |
**重复停复机的三处实现:**
1. Admin 卡端:`POST /iot-cards/:iccid/suspend|resume`(按 ICCID
2. Admin 企业卡端:`POST /enterprises/:id/cards/:card_id/suspend|resume`(按 card_id
3. H5 企业设备端:`POST /h5/devices/:device_id/cards/:card_id/suspend|resume`(按 card_id
### 2.2 DTO 缺失分析
#### 卡详情IotCardDetailResponse
```go
// 当前实现 (iot_card_dto.go:134-136)
type IotCardDetailResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *StandaloneIotCardResponse `json:"data"` // 只是空壳嵌套!
}
```
**问题**:详情响应只是列表响应的空包装,完全没有额外信息。无套餐、无所属设备、无聚合流量。
#### 设备详情DeviceResponse
```go
// 当前实现 (device_dto.go:20)
type DeviceResponse struct {
// ... 基本字段
BoundCardCount int `json:"bound_card_count"` // 只有一个数字!
}
```
**问题**:只返回绑定卡数量,看不到每张卡的实名状态、卡状态、流量使用。
#### H5 端已有参考实现
`EnterpriseDeviceDetailResp`enterprise_device_authorization_dto.go是目前唯一有"设备+绑定卡列表"聚合的 DTO可作为 admin 端改造的参考。
### 2.3 网关接口问题
**6 个网关查询接口全部是纯透传**
- `gateway.GetCardStatus`
- `gateway.GetFlowUsage`
- `gateway.GetRealNameStatus`
- `gateway.GetDeviceInfo`
- `gateway.GetSlotInfo`
- `gateway.GetDeviceFlowUsage`
**问题**:只读不写,不更新 DB 缓存,无业务封装。
### 2.4 数据模型现状
| 模型 | 虚拟号 | 缓存字段 | 套餐载体 |
|-----|-------|---------|---------|
| IotCard | ❌ 无(需新增) | CurrentMonthUsageMB, NetworkStatus, RealNameStatus, LastDataCheckAt | IotCardID |
| Device | ✅ device_no需改名为 virtual_no | 无 | DeviceID |
**关键发现**
- `PackageUsage` 模型已支持两种载体:`IotCardID`(单卡)和 `DeviceID`(设备级)
- `IotCard.IsStandalone` 字段由触发器维护,标识卡是否绑定到设备
- `DeviceStore.GetByIdentifier` 已实现多字段匹配:`WHERE device_no = ? OR imei = ? OR sn = ?`(改造后改为 virtual_no
---
## 三、设计方向(已确认)
### 3.1 统一资产入口resolve
**接口**`GET /api/admin/assets/resolve/:identifier`
**查找逻辑**
```
1. 先查 device 表virtual_no / imei / sn
2. 未命中则查 iot_card 表virtual_no / iccid / msisdn
3. 应用数据权限过滤:代理只能看自己及下级店铺的资产,平台账号看所有
4. 有权限 → 返回资产数据(中等版本)
5. 无权限 → 返回 HTTP 403
6. 未找到 → 返回 HTTP 404
```
**响应结构(已确认)**
```go
// AssetResolveResponse 资产解析响应
type AssetResolveResponse struct {
// 基础信息
AssetType string `json:"asset_type"` // "device" 或 "card"
AssetID uint `json:"asset_id"` // 对应表的主键
VirtualNo string `json:"virtual_no"` // 统一虚拟号字段(设备/卡均用此字段)
ICCID string `json:"iccid,omitempty"` // 仅 card 类型时有值,供前端调用停复机接口使用
// 状态信息
Status int `json:"status"` // 资产状态
RealNameStatus int `json:"real_name_status"` // 实名状态
// 套餐和流量信息(无套餐时返回空字符串/0
CurrentPackage string `json:"current_package"` // 当前套餐名称
PackageTotalMB float64 `json:"package_total_mb"` // 真总流量套餐标称RealDataMB
PackageVirtualMB float64 `json:"package_virtual_mb"` // 虚总流量停机阈值VirtualDataMB
PackageUsedMB float64 `json:"package_used_mb"` // 客户端展示已使用流量(经虚流量换算)
PackageRemainMB float64 `json:"package_remain_mb"` // 客户端展示剩余流量
// 保护期状态(设备类型,以及绑定该设备的卡均返回)
DeviceProtectStatus string `json:"device_protect_status"` // "none" / "stop" / "start"
// 绑定信息(仅 card 类型,且卡绑定了设备时才有值)
BoundDeviceID *uint `json:"bound_device_id,omitempty"`
BoundDeviceNo string `json:"bound_device_no,omitempty"`
BoundDeviceName string `json:"bound_device_name,omitempty"`
// 设备类型特有:绑定卡信息
BoundCardCount int `json:"bound_card_count"`
Cards []DeviceCardInfo `json:"cards,omitempty"` // 包含所有状态的卡(含未实名)
}
// DeviceCardInfo 设备下绑定卡信息
type DeviceCardInfo struct {
IotCardID uint `json:"iot_card_id"`
ICCID string `json:"iccid"`
VirtualNo string `json:"virtual_no"`
RealNameStatus int `json:"real_name_status"`
NetworkStatus int `json:"network_status"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb"`
LastSyncAt *time.Time `json:"last_sync_at"` // 最后与 Gateway 同步时间
}
```
**说明**
- 卡绑定的设备被软删除时,该卡视为独立卡,不填充绑定信息
- 设备下的 `cards` 列表包含所有绑定卡(含未实名、已停用)
### 3.2 套餐查询接口
**接口一**`GET /api/admin/assets/:asset_type/:id/packages`
- 返回所有套餐记录(含历史和当前生效套餐)
- 按 asset_type 区分查 PackageUsage.IotCardID 还是 PackageUsage.DeviceID
- 每条记录包含:套餐名称、真总流量、虚总流量、展示已使用、展示剩余、有效期、状态
- **排序**:按创建时间倒序(最新套餐在前)
- **分页**:不分页,全量返回
- **范围**:包含所有状态(含 status=4 已失效的历史套餐)
**接口二**`GET /api/admin/assets/:asset_type/:id/current-package`
- 返回当前生效的**主套餐**status=1 且 master_usage_id IS NULL的详细信息
- 当同时有主套餐 + 加油包生效时,只返回主套餐;需要查看加油包通过接口一的列表查看
- 包含完整流量明细:真总量、虚总量、展示已使用、展示剩余
### 3.3 实时状态查询接口
**接口**`GET /api/admin/assets/:asset_type/:id/realtime-status`
**与 resolve 的定位分工**
> **resolve**:初始加载使用,包含查找逻辑 + 全量聚合数据(套餐/流量/绑定信息),数据较重。
> **realtime-status**:已知资产 ID 后的轻量状态轮询,**不含套餐流量计算**,专注于网络/实名/保护期状态的快速刷新。
**说明**
- **只查询持久化数据DB/Redis不调用网关**
- 返回最近一次轮询/刷新同步到系统的状态
- "实时性"依赖轮询系统保持数据新鲜(实名 5 分钟,流量/套餐 10 分钟)
- 需要最新数据时,先调用 refresh 接口手动刷新,再查此接口
- 设备类型返回:保护期状态 + 每张绑定卡的状态(网络/实名/流量/最后同步时间)
- 卡类型返回:网络状态 + 实名状态 + 流量使用 + 最后同步时间
### 3.4 手动刷新接口
**接口**`POST /api/admin/assets/:asset_type/:id/refresh`
**说明**
- 调用网关获取最新数据,写回 DB 更新缓存字段,返回刷新后的最新状态
- 卡类型:调用已有的 `SyncCardStatusFromGateway(iccid)` 方法
- 设备类型:批量刷新所有绑定卡(遍历调用 `SyncCardStatusFromGateway`
- **设备类型需要频率限制**:通过 Redis 记录最后刷新时间,同一设备冷却期内(建议 30 秒)不允许重复触发,防止前端多次快速点击打爆网关
### 3.5 设备停复机保护期机制
**背景**
设备本身没有停机/复机概念,对设备停机 = 批量停用其下所有已实名卡。保护期机制确保操作期间所有卡的状态一致性,防止单卡被误操作破坏整体状态。
**接口**
- `POST /api/admin/assets/device/:device_id/stop`
- `POST /api/admin/assets/device/:device_id/start`
**保护期规则**
| 规则 | 说明 |
|------|------|
| 保护期时长 | **1 小时**(硬编码在代码常量中) |
| 存储方式 | Redis Key `protect:device:{device_id}:stop``protect:device:{device_id}:start`TTL=1小时 |
| 未实名的卡 | **不参与停复机操作**,未实名卡永远是停机状态,跳过不处理 |
| 重叠操作 | 设备在保护期内不允许再次发起相同或相反的停复机操作,返回 HTTP 403 |
| 批量停机部分失败 | 部分卡调网关失败时,**仍设置 Redis 保护期**;已成功停机的卡不回滚;失败的卡记录错误日志 |
**stop 保护期(设备停机后 1 小时内)**
- 对某张已实名卡手动发起复机 → **不允许**HTTP 403设备处于停机保护期
- 对某张已实名卡手动发起停机 → 允许(本已是停机,无冲突)
- 轮询系统发现某张已实名卡处于开机状态 → **强制调网关停机**,保持一致
**start 保护期(设备复机后 1 小时内)**
- 对某张已实名卡手动发起停机 → **允许**(用户可主动停单张卡)
- 对某张已实名卡手动发起复机 → 允许(本已是复机,无冲突)
- 轮询系统发现某张已实名卡处于停机状态 → **强制调网关复机**,保持一致
**保护期状态对外暴露**
- resolve 接口的 `device_protect_status` 字段返回当前保护期状态
- 卡绑定的设备有保护期时,该卡的 resolve 结果也返回 `device_protect_status`
### 3.6 接口去重(废弃清单)
**废弃接口**(直接删除,不保留向后兼容):
| 废弃接口 | 替代接口 |
|---------|---------|
| `POST /enterprises/:id/cards/:card_id/suspend` | `POST /api/admin/assets/card/:iccid/stop` |
| `POST /enterprises/:id/cards/:card_id/resume` | `POST /api/admin/assets/card/:iccid/start` |
| `POST /h5/devices/:device_id/cards/:card_id/suspend` | `POST /api/admin/assets/device/:device_id/stop` |
| `POST /h5/devices/:device_id/cards/:card_id/resume` | `POST /api/admin/assets/device/:device_id/start` |
| 旧 Admin 卡停复机接口(按 ICCID | `POST /api/admin/assets/card/:iccid/stop|start` |
| `GET /devices/:id` | `GET /api/admin/assets/device/:id` |
### 3.7 数据层变更
**变更一:设备表字段改名(全量重构)**
```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;
```
涉及改动范围Model 定义、DTO 响应、Store 查询、所有引用 `device_no` 的代码,以及 `tb_personal_customer_device` 表的 `device_no` 字段(一并改名为 `virtual_no`),确保系统中不再有 `device_no` 的身影。
**变更二:卡表新增 virtual_no 字段**
```sql
ALTER TABLE tb_iot_card ADD COLUMN virtual_no VARCHAR(50);
CREATE UNIQUE INDEX idx_iot_card_virtual_no
ON tb_iot_card (virtual_no) WHERE deleted_at IS NULL;
```
- 允许为空(老数据无虚拟号)
- 允许手动修改
- 全局唯一(导入时检测重复,重复则全批失败并告知具体冲突数据)
**变更三:套餐表新增 virtual_ratio 字段**
```sql
ALTER TABLE tb_package ADD COLUMN virtual_ratio DECIMAL(10,6) DEFAULT 1.0;
```
- 创建套餐时计算并存储:`virtual_ratio = real_data_mb / virtual_data_mb`
- 用于客户端展示的流量换算(见第六节)
- 未启用虚流量时(`enable_virtual_data=false`virtual_ratio = 1.0
---
## 四、完整接口清单
| # | 方法 | 路径 | 说明 |
|---|------|------|------|
| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析(通过任意标识符) |
| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析(通过任意标识符) |
| 2 | GET | `/api/admin/assets/:asset_type/:id/packages` | 套餐记录(历史+当前) |
| 3 | GET | `/api/admin/assets/:asset_type/:id/current-package` | 当前生效主套餐详情 |
| 4 | GET | `/api/admin/assets/:asset_type/:id/realtime-status` | 当前持久化状态查询(轻量) |
| 5 | POST | `/api/admin/assets/:asset_type/:id/refresh` | 手动刷新(调网关写回 DB |
| 6 | POST | `/api/admin/assets/device/:device_id/stop` | 设备停机(批量停所有已实名卡) |
| 7 | POST | `/api/admin/assets/device/:device_id/start` | 设备复机(批量开所有已实名卡) |
| 8 | POST | `/api/admin/assets/card/:iccid/stop` | 卡停机 |
| 9 | POST | `/api/admin/assets/card/:iccid/start` | 卡复机 |
> `:asset_type` 取值:`device` 或 `card`
---
## 五、流程图
### 5.1 资产查找resolve流程
```mermaid
flowchart TD
A["GET /api/admin/assets/resolve/:identifier"] --> B{"查询设备表\nvirtual_no / imei / sn"}
B -->|找到| C{"应用数据权限过滤\n代理:仅自己及下级店铺\n平台:所有资产"}
B -->|未找到| D{"查询卡表\nvirtual_no / iccid / msisdn"}
D -->|找到| C
D -->|未找到| E["返回 HTTP 404\n资产不存在"]
C -->|有权限| F["聚合资产数据\n基础信息 + 状态 + 套餐流量 + 保护期 + 绑定信息"]
C -->|无权限| G["返回 HTTP 403\n无权限查看该资产"]
F --> H["返回 AssetResolveResponse"]
```
### 5.2 设备停机/复机流程
```mermaid
flowchart TD
subgraph 设备停机
A1["POST /assets/device/:id/stop"] --> B1{"设备是否存在?"}
B1 -->|否| C1["HTTP 404"]
B1 -->|是| D1{"设备是否在保护期?"}
D1 -->|是| E1["HTTP 403\n设备处于保护期不允许操作"]
D1 -->|否| F1["获取所有已实名下属卡"]
F1 --> G1["批量调网关停机"]
G1 --> H1["更新各卡 NetworkStatus=停机\n部分失败时已成功的卡不回滚"]
H1 --> I1["Redis SET protect:device:id:stop\nTTL = 1 小时(部分失败时仍设置)"]
I1 --> J1["返回成功(附带失败卡日志)"]
end
subgraph 设备复机
A2["POST /assets/device/:id/start"] --> B2{"设备是否存在?"}
B2 -->|否| C2["HTTP 404"]
B2 -->|是| D2{"设备是否在保护期?"}
D2 -->|是| E2["HTTP 403\n设备处于保护期不允许操作"]
D2 -->|否| F2["获取所有已实名下属卡"]
F2 --> G2["批量调网关复机"]
G2 --> H2["更新各卡 NetworkStatus=开机"]
H2 --> I2["Redis SET protect:device:id:start\nTTL = 1 小时"]
I2 --> J2["返回成功"]
end
```
### 5.3 手动操作单卡 + 保护期检查
```mermaid
flowchart TD
subgraph 手动停机单卡
A1["POST /assets/card/:iccid/stop"] --> B1{"卡是否存在?"}
B1 -->|否| C1["HTTP 404"]
B1 -->|是| D1{"卡是否已实名?"}
D1 -->|未实名| E1["HTTP 403\n未实名卡不允许停复机"]
D1 -->|已实名| F1{"卡是否绑定设备?"}
F1 -->|未绑定| G1["正常执行停机"]
F1 -->|已绑定| H1{"设备有 start 保护期?"}
H1 -->|是| I1["允许停机\n与 start 保护期方向一致"]
H1 -->|否| G1
end
subgraph 手动复机单卡
A2["POST /assets/card/:iccid/start"] --> B2{"卡是否存在?"}
B2 -->|否| C2["HTTP 404"]
B2 -->|是| D2{"卡是否已实名?"}
D2 -->|未实名| E2["HTTP 403\n未实名卡不允许停复机"]
D2 -->|已实名| F2{"卡是否绑定设备?"}
F2 -->|未绑定| G2["正常执行复机"]
F2 -->|已绑定| H2{"设备有 stop 保护期?"}
H2 -->|是| I2["HTTP 403\n设备处于停机保护期\n不允许手动复机"]
H2 -->|否| G2
end
```
### 5.4 轮询系统与保护期交互
```mermaid
flowchart TD
A["轮询任务触发:检查卡状态"] --> B{"卡是否已实名?"}
B -->|未实名| C["跳过,未实名卡不参与停复机逻辑"]
B -->|已实名| D{"卡是否绑定设备?"}
D -->|未绑定| E["按卡自身逻辑正常处理"]
D -->|已绑定| F{"设备是否有保护期?"}
F -->|无保护期| E
F -->|"stop 保护期"| G{"卡当前网络状态?"}
G -->|开机| H["强制调网关停机\n保持与设备保护期一致"]
G -->|停机| I["已一致,跳过"]
F -->|"start 保护期"| J{"卡当前网络状态?"}
J -->|停机| K["强制调网关复机\n保持与设备保护期一致"]
J -->|开机| L["已一致,跳过"]
```
### 5.5 手动刷新refresh流程
```mermaid
flowchart TD
A["POST /api/admin/assets/:type/:id/refresh"] --> B{"资产类型"}
B -->|card| C["调用 SyncCardStatusFromGateway(iccid)"]
C --> D["更新 iot_card 表\nNetworkStatus / RealNameStatus\nCurrentMonthUsageMB / LastSyncTime"]
D --> H["返回刷新后的最新状态"]
B -->|device| E["检查 Redis 限频(冷却期 30 秒)"]
E -->|冷却中| Z["HTTP 429 请勿频繁刷新"]
E -->|可刷新| F["查询所有绑定卡列表"]
F --> G["遍历每张卡\n调用 SyncCardStatusFromGateway"]
G --> H
```
### 5.6 实时状态查询realtime-status流程
```mermaid
flowchart TD
A["GET /api/admin/assets/:type/:id/realtime-status"] --> B{"资产类型"}
B -->|card| C["从 DB/Redis 读取持久化的卡状态"]
C --> D["返回卡状态\n网络状态 / 实名状态 / 本月已用流量\n最后同步时间"]
B -->|device| E["从 DB/Redis 读取持久化的设备数据"]
E --> F["读取所有绑定卡的持久化状态"]
F --> G["返回设备状态\n保护期状态 + 各绑定卡当前状态 + 最后同步时间"]
```
> **注意**:此接口**不调用网关**,展示的是最近一次轮询/刷新写入的持久化数据。
> 如需获取最新数据,请先调用 `POST /refresh` 接口,再查询此接口。
### 5.7 虚流量计算规则
```mermaid
flowchart TD
subgraph 创建["套餐创建时 - 存储比例"]
A1["RealDataMB = 10G 真总流量"] --> C1
A2["VirtualDataMB = 9G 虚总流量/停机阈值"] --> C1
C1["virtual_ratio = RealDataMB / VirtualDataMB\n= 10 / 9 ≈ 1.111\n存储到 tb_package.virtual_ratio"]
end
subgraph 停机["系统内部 - 停机判断"]
D1["真已使用\nCurrentMonthUsageMB"] --> E1{"真已使用 >= VirtualDataMB?"}
D2["VirtualDataMB = 9G"] --> E1
E1 -->|是| F1["触发停机"]
E1 -->|否| F2["正常运行"]
end
subgraph 展示["客户端展示 - 流量换算"]
G1["真已使用 = 9G"] --> H1
H1["展示已使用 = 真已使用 x virtual_ratio\n= 9G x 1.111 = 10G"]
G2["展示总量 = RealDataMB = 10G"]
H1 --> I1["客户看到 已用10G/共10G = 100% 已停机"]
end
```
---
## 六、虚流量计算规则详解
### 6.1 概念说明
| 字段 | 含义 | 来源 |
|------|------|------|
| 真总流量RealDataMB | 套餐标称总流量,用户购买的名义流量 | `Package.real_data_mb` |
| 虚总流量VirtualDataMB | 停机阈值,始终小于真总流量 | `Package.virtual_data_mb` |
| virtual_ratio | 换算比例 = RealDataMB / VirtualDataMB | `Package.virtual_ratio`(套餐创建时存储) |
| 真已使用 | 网关报告的实际用量 | `IotCard.current_month_usage_mb` |
| 展示已使用 | 客户看到的用量 = 真已使用 × virtual_ratio | 计算得出 |
| 展示剩余 | 客户看到的剩余 = 真总流量 展示已使用 | 计算得出 |
### 6.2 设计意图
虚总流量VirtualDataMB是系统内部的停机保护阈值。由于网关数据同步存在延迟若以真总流量作为停机阈值客户可能在用完 10G 后继续用到 10.5G 才被停机,产生超用。因此系统设置一个比真总流量略小的虚总流量(如 9G作为实际停机阈值保证不超用。
客户端展示时,系统将真实用量按比例换算回真总流量的尺度,使客户的体感与购买的套餐一致:
- 当真用量达到 9GVirtualDataMB卡被停机
- 此时展示用量 = 9G × (10G/9G) = 10G客户看到"已用 10G / 共 10G = 100%"
### 6.3 计算示例
| 场景 | 真总 | 虚总(停机阈值) | 真已使用 | 展示已使用 | 展示剩余 | 是否停机 |
|------|------|----------------|---------|-----------|---------|---------|
| 刚开始 | 10G | 9G | 0G | 0G | 10G | 否 |
| 用了一半 | 10G | 9G | 4.5G | 5G | 5G | 否 |
| 接近阈值 | 10G | 9G | 8G | ≈8.89G | ≈1.11G | 否 |
| 触发停机 | 10G | 9G | 9G | 10G | 0G | **是** |
### 6.4 未启用虚流量时
`Package.enable_virtual_data = false` 时:
- `virtual_ratio = 1.0`
- 停机阈值 = 真总流量RealDataMB
- 展示已使用 = 真已使用(无换算)
---
## 七、用户的思考与担忧(已全部解决)
### 7.1 关于接口粒度
**已确认**resolve 返回中等版本,多接口组合,前端按需调用。
### 7.2 关于网关封装程度
**已确认**
- realtime-status只查持久化数据不调用网关
- refresh调用网关并写回 DB更新缓存字段
### 7.3 关于停复机去重
**已确认**:所有停复机统一迁移到 assets 路径,旧接口直接删除。
### 7.4 关于虚拟号
**已确认**
- 卡的虚拟号给客服和客户用
- 人工填写/批量导入,无格式规范,允许修改
- 设备 device_no 全量重命名为 virtual_no
- 导入重复时全批失败,告知具体冲突数据
### 7.5 关于套餐查询
**已确认**:套餐查询分两个接口,历史套餐接口包含当前套餐,同时单独提供当前套餐接口。
### 7.6 关于停复机保护期
**已确认**:保护期 1 小时Redis 存储未实名卡不参与stop 保护期内禁止手动复机start 保护期内允许手动停机。
---
## 八、设计决策确认清单
| 序号 | 问题 | 确认结果 |
|-----|------|---------|
| 1 | resolve 返回数据范围 | 中等版本,含状态/套餐/流量/绑定信息/保护期 |
| 2 | realtime-status 和 refresh 区别 | realtime-status=查持久化数据轻量refresh=调网关写回DB |
| 3 | 实时状态封装 | 持久化数据展示,不调网关 |
| 4 | 手动刷新复用 SyncCardStatusFromGateway | 是,设备时批量刷新所有绑定卡 |
| 5 | 停复机统一 | 统一迁移到 /assets 路径,旧接口直接删除 |
| 6 | 卡虚拟号生成方式 | 人工填写/批量导入,无格式规范 |
| 7 | 废弃接口处理 | 直接删除 |
| 8 | 套餐查询接口 | 两个接口:历史套餐列表 + 当前套餐详情 |
| 9 | 权限不足的返回 | HTTP 403明确告知无权限 |
| 10 | 保护期时长 | 1 小时,硬编码常量 |
| 11 | 虚流量计算 | virtual_ratio=RealDataMB/VirtualDataMB套餐创建时存储 |
| 12 | device_no 改名 | 全量改为 virtual_no数据库+代码全部更新 |
| 13 | 设备下卡列表 | 包含所有状态的卡(含未实名、已停用) |
| 14 | 卡绑定设备被软删除时 | 视为独立卡,不填充绑定信息 |
| 15 | 未实名卡参与停复机 | 不参与,永远是停机状态,保护期跳过 |
| 16 | 数据权限规则 | 代理:仅自己及下级店铺,平台账号:所有资产 |
| 17 | 查找失败 404 还是 403 | 资产不存在=404有资产但无权限=403 |
| 18 | 设备卡列表排序 | 无要求 |
| 19 | resolve 中 current_package 无套餐时 | 返回空字符串/0 |
| 20 | 虚拟号唯一索引 | 需要,允许为空,允许手动修改 |
| 21 | 企业账号能否用 resolve | 暂不支持;企业账号未来开新接口 |
| 22 | 接口 #2(按主键查详情)的设计 | 已确认删除,与 resolve 功能重叠,无独立价值 |
| 23 | resolve 响应是否含 ICCID | 是card 类型时返回 ICCID供停复机接口使用 |
| 24 | 设备批量停机部分失败策略 | 仍设置 Redis 保护期;已成功停机的卡不回滚;失败的卡记录日志 |
| 25 | 流量数据汇总逻辑 | 统一用专门汇总逻辑,从 PackageUsage 读取;设备级套餐汇总所有绑定卡 |
| 26 | 套餐历史列表排序和范围 | 按创建时间倒序,不分页,包含所有状态(含 status=4 已失效) |
| 27 | current-package 多套餐时返回哪个 | 返回主套餐master_usage_id IS NULL |
| 28 | 轮询系统保护期检查实现方式 | 新增独立的第四种轮询任务类型,不修改现有三种任务 |
| 29 | 卡虚拟号导入规则 | 只允许为空白虚拟号的卡填入;与现存数据重复则全批失败 |
| 30 | 设备批量刷新频率限制 | 需要Redis 限频,同一设备冷却期(建议 30 秒)内不允许重复触发 |
| 31 | PersonalCustomerDevice.device_no 改名 | 是,统一改为 virtual_no与 tb_device 保持语义一致 |
| 32 | DeviceCardInfo 需要 last_sync_time | 是,添加 last_sync_at 字段 |
---
## 九、轮询系统补充说明
### 9.1 整体架构
轮询系统是君鸿卡管系统维护卡数据实时性的核心机制:
```
┌─────────────────────────────────────────────────────────────────────┐
│ Worker 服务(后台) │
├─────────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Scheduler │────▶│ Asynq 队列 │────▶│ Handler │ │
│ │ (调度器) │ │ (任务队列) │ │ (处理器) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │ 定时循环 (每秒) │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Redis Sorted Set 轮询队列 │ │
│ │ - polling:queue:realname (实名检查) │ │
│ │ - polling:queue:carddata (流量检查) │ │
│ │ - polling:queue:package (套餐检查) │ │
│ │ - polling:queue:protect (保护期一致性检查) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ 调用网关 API
┌──────────────────────┐
│ Gateway 网关 │
│ (第三方运营商) │
└──────────────────────┘
```
### 9.2 四种轮询任务
| 任务类型 | 触发频率 | 作用 | 更新字段 |
|---------|---------|------|---------|
| **实名检查** | 默认 5 分钟 | 调用网关查实名状态 | real_name_status |
| **流量检查** | 默认 10 分钟 | 调用网关查流量,更新套餐 | current_month_usage_mb |
| **套餐检查** | 默认 10 分钟 | 检查是否超额,触发停机 | network_status |
| **保护期检查** | 同流量检查频率 | 检查绑定设备保护期,强制同步卡的网络状态 | network_status |
> **第四种任务设计说明**:保护期一致性检查封装为独立任务类型,不嵌入现有三种任务内部。只检查"已绑定设备且设备当前有保护期"的卡,范围小,可与流量检查同频触发。
### 9.3 关键特点
1. **启动时渐进式初始化**:系统启动时把卡分批加载到 Redis 队列(每批 10 万张)
2. **按时间排序**Redis Sorted Set 的 score 是下次检查的时间戳,到期自动被调度器取出
3. **并发控制**:通过 Redis 信号量限制并发数(默认 50防止打爆网关
4. **失败重试**:任务失败后重新入队
5. **缓存优化**:优先从 Redis 读取卡信息,避免频繁查 DB
### 9.4 与手动刷新接口的关系
- **轮询是后台自动跑**:所有卡都会按配置的时间间隔被检查,保证日常数据更新
- **手动刷新是前台客服主动用**:只更新这一张卡(或设备的所有绑定卡),满足客户急用场景
- **两者是互补关系**:轮询保证数据不会太旧,手动刷新满足实时性要求高的场景
### 9.5 与设备保护期的交互
轮询系统在处理设备的绑定卡时,需要检查设备是否有保护期(见 5.4 流程图):
- 发现设备有 stop 保护期,且卡为开机状态 → 强制调网关停机
- 发现设备有 start 保护期,且卡为停机状态 → 强制调网关复机
- 未实名的卡跳过,不参与保护期逻辑
关键代码位置:
- `internal/task/polling_handler.go` - 轮询任务处理器(需新增独立的第四种任务:保护期一致性检查处理函数)
- `pkg/constants/redis.go` - 需新增 `RedisDeviceProtectKey()` 函数
### 9.6 涉及的关键代码
- `internal/polling/scheduler.go` - 轮询调度器(把卡加入队列)
- `internal/task/polling_handler.go` - 任务处理器(实际调网关更新数据)
- `internal/service/iot_card/service.go:799` - SyncCardStatusFromGateway 方法
---
## 十、下一步行动
### 10.1 当前阶段
**设计讨论** - 已完成,所有关键决策已确认,可进入 openspec 提案阶段
### 10.2 进入 openspec 提案后的任务拆分建议
**数据层(优先)**
1. 数据库迁移:设备表 `device_no``virtual_no`(同步更新 `tb_personal_customer_device.device_no``virtual_no`
2. 数据库迁移:卡表新增 `virtual_no` 字段(唯一索引,允许空)
3. 数据库迁移:套餐表新增 `virtual_ratio` 字段
4. 更新 Device Model 和所有引用 `device_no` 的代码(全量替换,含 PersonalCustomerDevice
5. 更新 Package Service创建/更新套餐时自动计算并存储 `virtual_ratio`
**接口层(依次实现)**
6. 实现资产入口 `GET /assets/resolve/:identifier`
7. 实现当前状态查询 `GET /assets/:type/:id/realtime-status`
8. 实现手动刷新 `POST /assets/:type/:id/refresh`(含设备批量刷新 + Redis 限频)
9. 实现套餐记录查询 `GET /assets/:type/:id/packages`
10. 实现当前套餐查询 `GET /assets/:type/:id/current-package`
11. 实现设备停机 `POST /assets/device/:id/stop`(含保护期逻辑 + 部分失败策略)
12. 实现设备复机 `POST /assets/device/:id/start`(含保护期逻辑)
13. 实现卡停机 `POST /assets/card/:iccid/stop`(含保护期检查)
14. 实现卡复机 `POST /assets/card/:iccid/start`(含保护期检查)
**轮询系统**
15. 新增第四种轮询任务:保护期一致性检查(独立任务类型,不修改现有三种任务内部逻辑)
**清理**
16. 删除废弃的停复机接口(见 3.6 废弃清单)
17. 丰富现有卡/设备 DTOIotCardDetailResponse、DeviceResponse
18. 更新 API 文档生成器docs.go 和 gendocs/main.go
### 10.3 涉及的关键代码文件
**Handler 层**
- `internal/handler/admin/iot_card.go`
- `internal/handler/admin/device.go`
- `internal/handler/h5/enterprise_device.go`(待删除的废弃接口)
**Service 层**
- `internal/service/iot_card/service.go`(含 SyncCardStatusFromGateway:799
- `internal/service/iot_card/stop_resume_service.go`(停复机逻辑,需扩展)
- `internal/service/device/service.go`(含 GetByIdentifier:177
- `internal/service/package/customer_view_service.go`(套餐聚合,需复用)
- `internal/service/package/service.go`(创建套餐时存储 virtual_ratio
**Store 层**
- `internal/store/postgres/device_store.go`GetByIdentifier:62改用 virtual_no
- `internal/store/postgres/iot_card_store.go`
- `internal/store/postgres/personal_customer_device_store.go`device_no → virtual_no
**Model 层**
- `internal/model/iot_card.go`(新增 virtual_no 字段)
- `internal/model/device.go`device_no → virtual_no
- `internal/model/package.go`(新增 virtual_ratio 字段)
- `internal/model/personal_customer_device.go`device_no → virtual_no
**DTO 层**
- `internal/model/dto/iot_card_dto.go`(需重构)
- `internal/model/dto/device_dto.go`(需丰富)
**常量层**
- `pkg/constants/redis.go`(新增 `RedisDeviceProtectKey()` 函数)
**轮询层**
- `internal/task/polling_handler.go`(新增保护期一致性检查独立任务处理函数)
---
## 十一、附录:关键代码片段
### 11.1 现有空壳详情 DTO
```go
// internal/model/dto/iot_card_dto.go:134-136
type IotCardDetailResponse struct {
StandaloneIotCardResponse // 只是列表响应的空包装
}
```
### 11.2 设备详情 DTO
```go
// internal/model/dto/device_dto.go:20
type DeviceResponse struct {
ID uint `json:"id"`
DeviceNo string `json:"device_no"` // 改名为 virtual_no
// ...
BoundCardCount int `json:"bound_card_count"` // 只有数字,需丰富
}
```
### 11.3 设备多字段查找 Store
```go
// internal/store/postgres/device_store.go:62
// 改造后device_no → virtual_no
func (s *Store) GetByIdentifier(db *gorm.DB, identifier string) (*model.Device, error) {
var device model.Device
err := db.Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier).
First(&device).Error
return &device, err
}
```
### 11.4 手动刷新方法(待暴露为接口)
```go
// internal/service/iot_card/service.go:799
func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
// 已有实现,需作为接口暴露,并支持设备批量刷新
}
```
### 11.5 新增 Redis Key 常量
```go
// pkg/constants/redis.go
// RedisDeviceProtectKey 设备停复机保护期 Key
// action: "stop" 或 "start"TTL = 1 小时
func RedisDeviceProtectKey(deviceID uint, action string) string {
return fmt.Sprintf("protect:device:%d:%s", deviceID, action)
}
// RedisDeviceRefreshCooldownKey 设备手动刷新冷却期 KeyTTL = 冷却时长(建议 30 秒)
func RedisDeviceRefreshCooldownKey(deviceID uint) string {
return fmt.Sprintf("refresh:cooldown:device:%d", deviceID)
}
```
### 11.6 virtual_ratio 计算位置
```go
// internal/service/package/service.go
// 创建/更新套餐时计算并存储 virtual_ratio
if pkg.EnableVirtualData && pkg.VirtualDataMB > 0 {
pkg.VirtualRatio = float64(pkg.RealDataMB) / float64(pkg.VirtualDataMB)
} else {
pkg.VirtualRatio = 1.0
}
```
---
> **文档结束**
>
> 所有设计决策已确认,可进入 openspec 提案阶段。

View File

@@ -55,5 +55,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
}
}

View File

@@ -12,6 +12,7 @@ import (
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import"
enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise"
@@ -77,6 +78,8 @@ type services struct {
PollingAlert *pollingSvc.AlertService
PollingCleanup *pollingSvc.CleanupService
PollingManualTrigger *pollingSvc.ManualTriggerService
Asset *assetSvc.Service
StopResumeService *iotCardSvc.StopResumeService
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -124,7 +127,7 @@ func initServices(s *stores, deps *Dependencies) *services {
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient),
Device: deviceSvc.New(deps.DB, deps.Redis, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient),
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier),
@@ -145,5 +148,7 @@ func initServices(s *stores, deps *Dependencies) *services {
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
}
}

View File

@@ -53,6 +53,7 @@ type Handlers struct {
PollingAlert *admin.PollingAlertHandler
PollingCleanup *admin.PollingCleanupHandler
PollingManualTrigger *admin.PollingManualTriggerHandler
Asset *admin.AssetHandler
}
// Middlewares 封装所有中间件

View File

@@ -0,0 +1,186 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AssetHandler 资产管理处理器
// 提供统一的资产解析、实时状态、套餐查询、停复机等接口
type AssetHandler struct {
assetService *assetService.Service
deviceService *deviceService.Service
iotCardStopResume *iotCardService.StopResumeService
}
// NewAssetHandler 创建资产管理处理器
func NewAssetHandler(
assetSvc *assetService.Service,
deviceSvc *deviceService.Service,
iotCardStopResume *iotCardService.StopResumeService,
) *AssetHandler {
return &AssetHandler{
assetService: assetSvc,
deviceService: deviceSvc,
iotCardStopResume: iotCardStopResume,
}
}
// Resolve 通过任意标识符解析资产(设备或卡)
// GET /api/admin/assets/resolve/:identifier
func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
}
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
}
result, err := h.assetService.Resolve(c.UserContext(), identifier)
if err != nil {
return err
}
return response.Success(c, result)
}
// RealtimeStatus 获取资产实时状态
// GET /api/admin/assets/:asset_type/:id/realtime-status
func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
assetType := c.Params("asset_type")
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
}
result, err := h.assetService.GetRealtimeStatus(c.UserContext(), assetType, uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// Refresh 刷新资产状态(调网关同步)
// POST /api/admin/assets/:asset_type/:id/refresh
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
assetType := c.Params("asset_type")
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
}
result, err := h.assetService.Refresh(c.UserContext(), assetType, uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// Packages 获取资产所有套餐列表
// GET /api/admin/assets/:asset_type/:id/packages
func (h *AssetHandler) Packages(c *fiber.Ctx) error {
assetType := c.Params("asset_type")
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
}
result, err := h.assetService.GetPackages(c.UserContext(), assetType, uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// CurrentPackage 获取资产当前生效套餐
// GET /api/admin/assets/:asset_type/:id/current-package
func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error {
assetType := c.Params("asset_type")
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
}
result, err := h.assetService.GetCurrentPackage(c.UserContext(), assetType, uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// StopDevice 设备停机(批量停机设备下所有已实名卡)
// POST /api/admin/assets/device/:device_id/stop
func (h *AssetHandler) StopDevice(c *fiber.Ctx) error {
deviceID, err := strconv.ParseUint(c.Params("device_id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
}
result, err := h.deviceService.StopDevice(c.UserContext(), uint(deviceID))
if err != nil {
return err
}
return response.Success(c, result)
}
// StartDevice 设备复机(批量复机设备下所有已实名卡)
// POST /api/admin/assets/device/:device_id/start
func (h *AssetHandler) StartDevice(c *fiber.Ctx) error {
deviceID, err := strconv.ParseUint(c.Params("device_id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
}
if err := h.deviceService.StartDevice(c.UserContext(), uint(deviceID)); err != nil {
return err
}
return response.Success(c, nil)
}
// StopCard 单卡停机通过ICCID
// POST /api/admin/assets/card/:iccid/stop
func (h *AssetHandler) StopCard(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
}
if err := h.iotCardStopResume.ManualStopCard(c.UserContext(), iccid); err != nil {
return err
}
return response.Success(c, nil)
}
// StartCard 单卡复机通过ICCID
// POST /api/admin/assets/card/:iccid/start
func (h *AssetHandler) StartCard(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
}
if err := h.iotCardStopResume.ManualStartCard(c.UserContext(), iccid); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -37,37 +37,6 @@ func (h *DeviceHandler) List(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
func (h *DeviceHandler) GetByID(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
}
result, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// GetByIdentifier 通过标识符查询设备详情
// GET /api/admin/devices/by-identifier/:identifier
func (h *DeviceHandler) GetByIdentifier(c *fiber.Ctx) error {
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
}
result, err := h.service.GetByIdentifier(c.UserContext(), identifier)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
@@ -223,22 +192,6 @@ func (h *DeviceHandler) BatchSetSeriesBinding(c *fiber.Ctx) error {
return response.Success(c, result)
}
// GetGatewayInfo 查询设备信息
// GET /api/admin/devices/by-identifier/:identifier/gateway-info
func (h *DeviceHandler) GetGatewayInfo(c *fiber.Ctx) error {
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
}
resp, err := h.service.GatewayGetDeviceInfo(c.UserContext(), identifier)
if err != nil {
return err
}
return response.Success(c, resp)
}
// GetGatewaySlots 查询设备卡槽信息
// GET /api/admin/devices/by-identifier/:identifier/gateway-slots
func (h *DeviceHandler) GetGatewaySlots(c *fiber.Ctx) error {

View File

@@ -78,43 +78,3 @@ func (h *EnterpriseCardHandler) ListCards(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
func (h *EnterpriseCardHandler) SuspendCard(c *fiber.Ctx) error {
enterpriseIDStr := c.Params("id")
enterpriseID, err := strconv.ParseUint(enterpriseIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的企业ID")
}
cardIDStr := c.Params("card_id")
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
}
if err := h.service.SuspendCard(c.UserContext(), uint(enterpriseID), uint(cardID)); err != nil {
return err
}
return response.Success(c, nil)
}
func (h *EnterpriseCardHandler) ResumeCard(c *fiber.Ctx) error {
enterpriseIDStr := c.Params("id")
enterpriseID, err := strconv.ParseUint(enterpriseIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的企业ID")
}
cardIDStr := c.Params("card_id")
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
}
if err := h.service.ResumeCard(c.UserContext(), uint(enterpriseID), uint(cardID)); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -35,20 +35,6 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
func (h *IotCardHandler) GetByICCID(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
}
result, err := h.service.GetByICCID(c.UserContext(), iccid)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error {
var req dto.AllocateStandaloneCardsRequest
if err := c.BodyParser(&req); err != nil {
@@ -126,51 +112,6 @@ func (h *IotCardHandler) BatchSetSeriesBinding(c *fiber.Ctx) error {
return response.Success(c, result)
}
// GetGatewayStatus 查询卡实时状态
func (h *IotCardHandler) GetGatewayStatus(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
}
resp, err := h.service.GatewayQueryCardStatus(c.UserContext(), iccid)
if err != nil {
return err
}
return response.Success(c, resp)
}
// GetGatewayFlow 查询流量使用情况
func (h *IotCardHandler) GetGatewayFlow(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
}
resp, err := h.service.GatewayQueryFlow(c.UserContext(), iccid)
if err != nil {
return err
}
return response.Success(c, resp)
}
// GetGatewayRealname 查询实名认证状态
func (h *IotCardHandler) GetGatewayRealname(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
}
resp, err := h.service.GatewayQueryRealnameStatus(c.UserContext(), iccid)
if err != nil {
return err
}
return response.Success(c, resp)
}
// GetRealnameLink 获取实名认证链接
func (h *IotCardHandler) GetRealnameLink(c *fiber.Ctx) error {
iccid := c.Params("iccid")
@@ -185,31 +126,3 @@ func (h *IotCardHandler) GetRealnameLink(c *fiber.Ctx) error {
return response.Success(c, link)
}
// StopCard 停止卡服务
func (h *IotCardHandler) StopCard(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
}
if err := h.service.GatewayStopCard(c.UserContext(), iccid); err != nil {
return err
}
return response.Success(c, nil)
}
// StartCard 恢复卡服务
func (h *IotCardHandler) StartCard(c *fiber.Ctx) error {
iccid := c.Params("iccid")
if iccid == "" {
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
}
if err := h.service.GatewayStartCard(c.UserContext(), iccid); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -26,9 +26,9 @@ func (h *EnterpriseDeviceHandler) ListDevices(c *fiber.Ctx) error {
}
serviceReq := &dto.EnterpriseDeviceListReq{
Page: req.Page,
PageSize: req.PageSize,
DeviceNo: req.DeviceNo,
Page: req.Page,
PageSize: req.PageSize,
VirtualNo: req.VirtualNo,
}
result, err := h.service.ListDevicesForEnterprise(c.UserContext(), serviceReq)
@@ -53,55 +53,3 @@ func (h *EnterpriseDeviceHandler) GetDeviceDetail(c *fiber.Ctx) error {
return response.Success(c, result)
}
func (h *EnterpriseDeviceHandler) SuspendCard(c *fiber.Ctx) error {
deviceIDStr := c.Params("device_id")
deviceID, err := strconv.ParseUint(deviceIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "设备ID格式错误")
}
cardIDStr := c.Params("card_id")
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "卡ID格式错误")
}
var req dto.DeviceCardOperationReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.SuspendCard(c.UserContext(), uint(deviceID), uint(cardID), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *EnterpriseDeviceHandler) ResumeCard(c *fiber.Ctx) error {
deviceIDStr := c.Params("device_id")
deviceID, err := strconv.ParseUint(deviceIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "设备ID格式错误")
}
cardIDStr := c.Params("card_id")
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "卡ID格式错误")
}
var req dto.DeviceCardOperationReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.ResumeCard(c.UserContext(), uint(deviceID), uint(cardID), &req)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -11,11 +11,11 @@ import (
// Device 设备模型
// 物联网设备(如 GPS 追踪器、智能传感器)
// 通过 shop_id 区分所有权NULL=平台库存,有值=店铺所有
// 标识符说明:device_no 为虚拟号/别名imei/sn 为设备真实标识
// 标识符说明:virtual_no 为虚拟号/别名imei/sn 为设备真实标识
type Device struct {
gorm.Model
BaseModel `gorm:"embedded"`
DeviceNo string `gorm:"column:device_no;type:varchar(100);uniqueIndex:idx_device_no,where:deleted_at IS NULL;not null;comment:设备虚拟号/别名(用户友好的短标识)" json:"device_no"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(100);uniqueIndex:idx_device_virtual_no,where:deleted_at IS NULL;not null;comment:设备虚拟号/别名(用户友好的短标识)" json:"virtual_no"`
IMEI string `gorm:"column:imei;type:varchar(20);comment:设备IMEI(有蜂窝网络的设备标识,用于Gateway API调用)" json:"imei"`
SN string `gorm:"column:sn;type:varchar(100);comment:设备序列号(厂商唯一标识,预留字段)" json:"sn"`
DeviceName string `gorm:"column:device_name;type:varchar(255);comment:设备名称" json:"device_name"`

View File

@@ -37,7 +37,7 @@ func (DeviceImportTask) TableName() string {
// DeviceImportResultItem 设备导入结果项
type DeviceImportResultItem struct {
Line int `json:"line"`
DeviceNo string `json:"device_no"`
Reason string `json:"reason"`
Line int `json:"line"`
VirtualNo string `json:"virtual_no"`
Reason string `json:"reason"`
}

View File

@@ -0,0 +1,119 @@
package dto
import "time"
// AssetResolveResponse 统一资产解析响应
type AssetResolveResponse struct {
AssetType string `json:"asset_type" description:"资产类型card 或 device"`
AssetID uint `json:"asset_id" description:"资产数据库ID"`
VirtualNo string `json:"virtual_no" description:"虚拟号"`
Status int `json:"status" description:"资产状态"`
BatchNo string `json:"batch_no" description:"批次号"`
ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"`
ShopName string `json:"shop_name,omitempty" description:"所属店铺名称"`
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"`
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
// 状态聚合字段
RealNameStatus int `json:"real_name_status" description:"实名状态0未实名 1实名中 2已实名"`
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB)已按virtual_ratio换算"`
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB)已按virtual_ratio换算"`
PackageRemainMB float64 `json:"package_remain_mb" description:"当前套餐剩余虚流量(MB)已按virtual_ratio换算"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态none/stop/start仅asset_type=device时有效"`
// 绑定关系字段
ICCID string `json:"iccid,omitempty" description:"卡ICCIDasset_type=card时有效"`
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备IDasset_type=card时有效"`
BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号asset_type=card时有效"`
BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称asset_type=card时有效"`
BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定的卡数量asset_type=device时有效"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定的卡列表asset_type=device时有效"`
// 设备专属字段card类型时为零值
DeviceName string `json:"device_name,omitempty" description:"设备名称"`
IMEI string `json:"imei,omitempty" description:"设备IMEI"`
SN string `json:"sn,omitempty" description:"设备序列号"`
DeviceModel string `json:"device_model,omitempty" description:"设备型号"`
DeviceType string `json:"device_type,omitempty" description:"设备类型"`
MaxSimSlots int `json:"max_sim_slots,omitempty" description:"最大插槽数"`
Manufacturer string `json:"manufacturer,omitempty" description:"制造商"`
// 卡专属字段device类型时为零值
CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"`
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型"`
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
MSISDN string `json:"msisdn,omitempty" description:"手机号"`
IMSI string `json:"imsi,omitempty" description:"IMSI"`
CardCategory string `json:"card_category,omitempty" description:"卡业务类型"`
Supplier string `json:"supplier,omitempty" description:"供应商"`
ActivationStatus int `json:"activation_status,omitempty" description:"激活状态"`
EnablePolling bool `json:"enable_polling,omitempty" description:"是否参与轮询"`
NetworkStatus int `json:"network_status,omitempty" description:"网络状态0停机 1开机asset_type=card时有效"`
}
// BoundCardInfo 设备绑定的卡信息
type BoundCardInfo struct {
CardID uint `json:"card_id" description:"卡ID"`
ICCID string `json:"iccid" description:"ICCID"`
MSISDN string `json:"msisdn,omitempty" description:"手机号"`
NetworkStatus int `json:"network_status" description:"网络状态0停机 1开机"`
RealNameStatus int `json:"real_name_status" description:"实名状态"`
SlotPosition int `json:"slot_position,omitempty" description:"插槽位置"`
}
// AssetRealtimeStatusResponse 资产实时状态只读DB/Redis
type AssetRealtimeStatusResponse struct {
AssetType string `json:"asset_type" description:"资产类型card 或 device"`
AssetID uint `json:"asset_id" description:"资产ID"`
NetworkStatus int `json:"network_status,omitempty" description:"网络状态asset_type=card时有效0停机 1开机"`
RealNameStatus int `json:"real_name_status,omitempty" description:"实名状态asset_type=card时有效"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"本月已用流量MBasset_type=card时有效"`
LastSyncTime *time.Time `json:"last_sync_time,omitempty" description:"最后同步时间asset_type=card时有效"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"保护期状态asset_type=device时有效none/stop/start"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表asset_type=device时有效"`
}
// AssetPackageResponse 资产套餐信息
type AssetPackageResponse struct {
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageType string `json:"package_type" description:"套餐类型formal/addon"`
UsageType string `json:"usage_type" description:"使用类型single_card/device"`
Status int `json:"status" description:"状态0待生效 1生效中 2已用完 3已过期 4已失效"`
StatusName string `json:"status_name" description:"状态名称"`
DataLimitMB int64 `json:"data_limit_mb" description:"套餐真流量总量(MB)"`
VirtualLimitMB int64 `json:"virtual_limit_mb" description:"套餐虚流量总量(MB)按virtual_ratio换算"`
DataUsageMB int64 `json:"data_usage_mb" description:"已用真流量(MB)"`
VirtualUsedMB float64 `json:"virtual_used_mb" description:"已用虚流量(MB)按virtual_ratio换算"`
VirtualRemainMB float64 `json:"virtual_remain_mb" description:"剩余虚流量(MB)按virtual_ratio换算"`
VirtualRatio float64 `json:"virtual_ratio" description:"虚流量比例(real/virtual)"`
ActivatedAt time.Time `json:"activated_at" description:"激活时间"`
ExpiresAt time.Time `json:"expires_at" description:"到期时间"`
MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID加油包时有值"`
Priority int `json:"priority" description:"优先级"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// AssetResolveRequest 资产解析请求(路径参数)
type AssetResolveRequest struct {
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN" required:"true"`
}
// AssetTypeIDRequest 资产类型+ID请求路径参数
type AssetTypeIDRequest struct {
AssetType string `path:"asset_type" description:"资产类型card 或 device" required:"true"`
ID uint `path:"id" description:"资产ID" required:"true"`
}
// DeviceIDRequest 设备ID请求路径参数
type DeviceIDRequest struct {
DeviceID uint `path:"device_id" description:"设备ID" required:"true"`
}
// CardICCIDRequest 卡ICCID请求路径参数
type CardICCIDRequest struct {
ICCID string `path:"iccid" description:"卡ICCID" required:"true"`
}

View File

@@ -10,7 +10,7 @@ type CommissionRecordResponse struct {
IotCardID *uint `json:"iot_card_id" description:"关联卡ID"`
IotCardICCID string `json:"iot_card_iccid" description:"卡ICCID"`
DeviceID *uint `json:"device_id" description:"关联设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
CommissionSource string `json:"commission_source" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金)"`
Amount int64 `json:"amount" description:"佣金金额(分)"`
BalanceAfter int64 `json:"balance_after" description:"入账后钱包余额(分)"`

View File

@@ -5,7 +5,7 @@ import "time"
type ListDeviceRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
DeviceNo string `json:"device_no" query:"device_no" validate:"omitempty,max=100" maxLength:"100" description:"设备号(模糊查询)"`
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"虚拟号(模糊查询)"`
DeviceName string `json:"device_name" query:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称(模糊查询)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID (NULL表示平台库存)"`
@@ -19,7 +19,7 @@ type ListDeviceRequest struct {
type DeviceResponse struct {
ID uint `json:"id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备虚拟号/别名"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号/别名"`
IMEI string `json:"imei" description:"设备IMEI"`
SN string `json:"sn" description:"设备序列号"`
DeviceName string `json:"device_name" description:"设备名称"`
@@ -109,9 +109,9 @@ type AllocateDevicesRequest struct {
}
type AllocationDeviceFailedItem struct {
DeviceID uint `json:"device_id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
Reason string `json:"reason" description:"失败原因"`
DeviceID uint `json:"device_id" description:"设备ID"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
Reason string `json:"reason" description:"失败原因"`
}
type AllocateDevicesResponse struct {
@@ -139,9 +139,9 @@ type BatchSetDeviceSeriesBindngRequest struct {
// DeviceSeriesBindngFailedItem 设备系列绑定失败项
type DeviceSeriesBindngFailedItem struct {
DeviceID uint `json:"device_id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
Reason string `json:"reason" description:"失败原因"`
DeviceID uint `json:"device_id" description:"设备ID"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
Reason string `json:"reason" description:"失败原因"`
}
// BatchSetDeviceSeriesBindngResponse 批量设置设备的套餐系列绑定响应
@@ -175,3 +175,17 @@ type SwitchCardRequest struct {
type EmptyResponse struct {
Message string `json:"message,omitempty" description:"提示信息"`
}
// DeviceSuspendResponse 设备停机响应
type DeviceSuspendResponse struct {
SuccessCount int `json:"success_count" description:"成功停机卡数"`
FailCount int `json:"fail_count" description:"失败卡数"`
SkipCount int `json:"skip_count" description:"跳过卡数(未实名或已停机)"`
FailedItems []DeviceSuspendFailItem `json:"failed_items,omitempty" description:"失败详情"`
}
// DeviceSuspendFailItem 设备停机失败项
type DeviceSuspendFailItem struct {
ICCID string `json:"iccid" description:"卡ICCID"`
Reason string `json:"reason" description:"失败原因"`
}

View File

@@ -49,9 +49,9 @@ type ListDeviceImportTaskResponse struct {
}
type DeviceImportResultItemDTO struct {
Line int `json:"line" description:"行号"`
DeviceNo string `json:"device_no" description:"设备号"`
Reason string `json:"reason" description:"原因"`
Line int `json:"line" description:"行号"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
Reason string `json:"reason" description:"原因"`
}
type GetDeviceImportTaskRequest struct {

View File

@@ -16,7 +16,7 @@ type StandaloneCard struct {
// Deprecated: 已废弃,不再支持通过单卡授权接口授权设备卡,请使用设备授权接口
type DeviceBundle struct {
DeviceID uint `json:"device_id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
TriggerCard DeviceBundleCard `json:"trigger_card" description:"触发卡(用户选择的卡)"`
BundleCards []DeviceBundleCard `json:"bundle_cards" description:"连带卡(同设备的其他卡)"`
}
@@ -31,7 +31,7 @@ type DeviceBundleCard struct {
// Deprecated: 已废弃,不再支持通过单卡授权接口授权设备卡,请使用设备授权接口
type AllocatedDevice struct {
DeviceID uint `json:"device_id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
CardCount int `json:"card_count" description:"卡数量"`
ICCIDs []string `json:"iccids" description:"卡ICCID列表"`
}
@@ -74,7 +74,7 @@ type RecallCardsReq struct {
type RecalledDevice struct {
DeviceID uint `json:"device_id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
CardCount int `json:"card_count" description:"卡数量"`
ICCIDs []string `json:"iccids" description:"卡ICCID列表"`
}
@@ -93,7 +93,7 @@ type EnterpriseCardListReq struct {
Status *int `json:"status" query:"status" description:"卡状态"`
CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"`
ICCID string `json:"iccid" query:"iccid" description:"ICCID模糊查询"`
DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊查询)"`
VirtualNo string `json:"virtual_no" query:"virtual_no" description:"虚拟号(模糊查询)"`
}
type EnterpriseCardItem struct {
@@ -101,7 +101,7 @@ type EnterpriseCardItem struct {
ICCID string `json:"iccid" description:"ICCID"`
MSISDN string `json:"msisdn" description:"手机号"`
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
CarrierID uint `json:"carrier_id" description:"运营商ID"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
PackageID *uint `json:"package_id,omitempty" description:"套餐ID"`

View File

@@ -16,13 +16,13 @@ type AllocateDevicesResp struct {
}
type FailedDeviceItem struct {
DeviceNo string `json:"device_no" description:"设备号"`
Reason string `json:"reason" description:"失败原因"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
Reason string `json:"reason" description:"失败原因"`
}
type AuthorizedDeviceItem struct {
DeviceID uint `json:"device_id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
CardCount int `json:"card_count" description:"绑定卡数量"`
}
@@ -38,16 +38,16 @@ type RecallDevicesResp struct {
}
type EnterpriseDeviceListReq struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"`
Page int `json:"page" query:"page" validate:"required,min=1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" description:"每页数量"`
DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊搜索)"`
ID uint `json:"-" params:"id" path:"id" validate:"required" required:"true" description:"企业ID"`
Page int `json:"page" query:"page" validate:"required,min=1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" description:"每页数量"`
VirtualNo string `json:"virtual_no" query:"virtual_no" description:"虚拟号(模糊搜索)"`
}
type H5EnterpriseDeviceListReq struct {
Page int `json:"page" query:"page" validate:"required,min=1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" description:"每页数量"`
DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊搜索)"`
Page int `json:"page" query:"page" validate:"required,min=1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" description:"每页数量"`
VirtualNo string `json:"virtual_no" query:"virtual_no" description:"虚拟号(模糊搜索)"`
}
type EnterpriseDeviceListResp struct {
@@ -57,7 +57,7 @@ type EnterpriseDeviceListResp struct {
type EnterpriseDeviceItem struct {
DeviceID uint `json:"device_id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
DeviceName string `json:"device_name" description:"设备名称"`
DeviceModel string `json:"device_model" description:"设备型号"`
CardCount int `json:"card_count" description:"绑定卡数量"`
@@ -71,7 +71,7 @@ type EnterpriseDeviceDetailResp struct {
type EnterpriseDeviceInfo struct {
DeviceID uint `json:"device_id" description:"设备ID"`
DeviceNo string `json:"device_no" description:"设备号"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号"`
DeviceName string `json:"device_name" description:"设备名称"`
DeviceModel string `json:"device_model" description:"设备型号"`
DeviceType string `json:"device_type" description:"设备类型"`

View File

@@ -43,7 +43,7 @@ type MyCommissionRecordListReq struct {
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
CommissionSource *string `json:"commission_source" query:"commission_source" validate:"omitempty,oneof=cost_diff one_time tier_bonus" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus(已废弃):梯度奖励)"`
ICCID string `json:"iccid" query:"iccid" description:"ICCID模糊查询"`
DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊查询)"`
VirtualNo string `json:"virtual_no" query:"virtual_no" description:"设备虚拟号(模糊查询)"`
OrderNo string `json:"order_no" query:"order_no" description:"订单号(模糊查询)"`
}

View File

@@ -98,7 +98,7 @@ type ShopCommissionRecordListReq struct {
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
CommissionSource string `json:"commission_source" query:"commission_source" validate:"omitempty,oneof=cost_diff one_time tier_bonus" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus(已废弃):梯度奖励)"`
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=50" maxLength:"50" description:"ICCID模糊查询"`
DeviceNo string `json:"device_no" query:"device_no" validate:"omitempty,max=50" maxLength:"50" description:"设备号(模糊查询)"`
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=50" maxLength:"50" description:"设备虚拟号(模糊查询)"`
OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=50" maxLength:"50" description:"订单号(模糊查询)"`
}
@@ -112,7 +112,7 @@ type ShopCommissionRecordItem struct {
StatusName string `json:"status_name" description:"状态名称"`
OrderID uint `json:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
DeviceNo string `json:"device_no,omitempty" description:"设备号"`
VirtualNo string `json:"virtual_no,omitempty" description:"设备虚拟号"`
ICCID string `json:"iccid,omitempty" description:"ICCID"`
OrderCreatedAt string `json:"order_created_at" description:"订单创建时间"`
CreatedAt string `json:"created_at" description:"佣金入账时间"`

View File

@@ -49,6 +49,7 @@ type IotCard struct {
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可空,全局唯一)" json:"virtual_no,omitempty"`
}
// TableName 指定表名

View File

@@ -33,10 +33,11 @@ type IotCardImportTask struct {
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"`
}
// CardItem 卡信息ICCID + MSISDN
// CardItem 卡信息ICCID + MSISDN + VirtualNo
type CardItem struct {
ICCID string `json:"iccid"`
MSISDN string `json:"msisdn"`
ICCID string `json:"iccid"`
MSISDN string `json:"msisdn"`
VirtualNo string `json:"virtual_no,omitempty"`
}
type CardListJSON []CardItem

View File

@@ -30,22 +30,23 @@ func (PackageSeries) TableName() string {
type Package struct {
gorm.Model
BaseModel `gorm:"embedded"`
PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"`
PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"`
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"`
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
EnableVirtualData bool `gorm:"column:enable_virtual_data;type:boolean;default:false;not null;comment:是否启用虚流量" json:"enable_virtual_data"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
CalendarType string `gorm:"column:calendar_type;type:varchar(20);default:'by_day';comment:套餐周期类型 natural_month-自然月 by_day-按天" json:"calendar_type"`
DurationDays int `gorm:"column:duration_days;type:int;comment:套餐天数(calendar_type=by_day时必填)" json:"duration_days"`
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);default:'monthly';comment:流量重置周期 daily-每日 monthly-每月 yearly-每年 none-不重置" json:"data_reset_cycle"`
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;default:true;comment:是否启用实名激活 true-需实名后激活 false-立即激活" json:"enable_realname_activation"`
PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"`
PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"`
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"`
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
EnableVirtualData bool `gorm:"column:enable_virtual_data;type:boolean;default:false;not null;comment:是否启用虚流量" json:"enable_virtual_data"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
CalendarType string `gorm:"column:calendar_type;type:varchar(20);default:'by_day';comment:套餐周期类型 natural_month-自然月 by_day-按天" json:"calendar_type"`
DurationDays int `gorm:"column:duration_days;type:int;comment:套餐天数(calendar_type=by_day时必填)" json:"duration_days"`
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);default:'monthly';comment:流量重置周期 daily-每日 monthly-每月 yearly-每年 none-不重置" json:"data_reset_cycle"`
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;default:true;comment:是否启用实名激活 true-需实名后激活 false-立即激活" json:"enable_realname_activation"`
VirtualRatio float64 `gorm:"column:virtual_ratio;type:decimal(18,6);default:1.0;comment:虚流量比例(real_data_mb/virtual_data_mb),创建套餐时计算存储" json:"virtual_ratio"`
}
// TableName 指定表名
@@ -119,7 +120,7 @@ type OneTimeCommissionConfig struct {
// OneTimeCommissionTier 一次性佣金梯度配置
type OneTimeCommissionTier struct {
Operator string `json:"operator"` // 阈值比较运算符:>、>=、<、<=,空值默认 >=
Operator string `json:"operator"` // 阈值比较运算符:>、>=、<、<=,空值默认 >=
Dimension string `json:"dimension"`
StatScope string `json:"stat_scope"`
Threshold int64 `json:"threshold"`

View File

@@ -11,7 +11,7 @@ import (
type PersonalCustomerDevice struct {
gorm.Model
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
DeviceNo string `gorm:"column:device_no;type:varchar(50);not null;comment:设备号/IMEI" json:"device_no"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);not null;comment:设备虚拟号/IMEI" json:"virtual_no"`
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
LastUsedAt time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`

View File

@@ -107,4 +107,7 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.PollingManualTrigger != nil {
registerPollingManualTriggerRoutes(authGroup, handlers.PollingManualTrigger, doc, basePath)
}
if handlers.Asset != nil {
registerAssetRoutes(authGroup, handlers.Asset, doc, basePath)
}
}

94
internal/routes/asset.go Normal file
View File

@@ -0,0 +1,94 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, doc *openapi.Generator, basePath string) {
assets := router.Group("/assets")
groupPath := basePath + "/assets"
Register(assets, doc, groupPath, "GET", "/resolve/:identifier", handler.Resolve, RouteSpec{
Summary: "解析资产",
Description: "通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。企业账号禁止调用。",
Tags: []string{"资产管理"},
Input: new(dto.AssetResolveRequest),
Output: new(dto.AssetResolveResponse),
Auth: true,
})
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/realtime-status", handler.RealtimeStatus, RouteSpec{
Summary: "资产实时状态",
Description: "读取 DB/Redis 中的持久化状态不调网关。asset_type 为 card 或 device。",
Tags: []string{"资产管理"},
Input: new(dto.AssetTypeIDRequest),
Output: new(dto.AssetRealtimeStatusResponse),
Auth: true,
})
Register(assets, doc, groupPath, "POST", "/:asset_type/:id/refresh", handler.Refresh, RouteSpec{
Summary: "刷新资产状态",
Description: "主动调网关同步最新状态。设备有30秒冷却期。",
Tags: []string{"资产管理"},
Input: new(dto.AssetTypeIDRequest),
Output: new(dto.AssetRealtimeStatusResponse),
Auth: true,
})
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/packages", handler.Packages, RouteSpec{
Summary: "资产套餐列表",
Description: "查询该资产所有套餐记录,含虚流量换算结果。",
Tags: []string{"资产管理"},
Input: new(dto.AssetTypeIDRequest),
Output: new([]dto.AssetPackageResponse),
Auth: true,
})
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/current-package", handler.CurrentPackage, RouteSpec{
Summary: "当前生效套餐",
Tags: []string{"资产管理"},
Input: new(dto.AssetTypeIDRequest),
Output: new(dto.AssetPackageResponse),
Auth: true,
})
Register(assets, doc, groupPath, "POST", "/device/:device_id/stop", handler.StopDevice, RouteSpec{
Summary: "设备停机",
Description: "批量停机设备下所有已实名卡。设置1小时停机保护期。",
Tags: []string{"资产管理"},
Input: new(dto.DeviceIDRequest),
Output: new(dto.DeviceSuspendResponse),
Auth: true,
})
Register(assets, doc, groupPath, "POST", "/device/:device_id/start", handler.StartDevice, RouteSpec{
Summary: "设备复机",
Description: "批量复机设备下所有已实名卡。设置1小时复机保护期。",
Tags: []string{"资产管理"},
Input: new(dto.DeviceIDRequest),
Output: nil,
Auth: true,
})
Register(assets, doc, groupPath, "POST", "/card/:iccid/stop", handler.StopCard, RouteSpec{
Summary: "单卡停机",
Description: "手动停机单张卡通过ICCID。受设备保护期约束。",
Tags: []string{"资产管理"},
Input: new(dto.CardICCIDRequest),
Output: nil,
Auth: true,
})
Register(assets, doc, groupPath, "POST", "/card/:iccid/start", handler.StartCard, RouteSpec{
Summary: "单卡复机",
Description: "手动复机单张卡通过ICCID。受设备保护期约束。",
Tags: []string{"资产管理"},
Input: new(dto.CardICCIDRequest),
Output: nil,
Auth: true,
})
}

View File

@@ -21,23 +21,6 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
Summary: "设备详情",
Tags: []string{"设备管理"},
Input: new(dto.GetDeviceRequest),
Output: new(dto.DeviceResponse),
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/by-identifier/:identifier", handler.GetByIdentifier, RouteSpec{
Summary: "通过标识符查询设备详情",
Description: "支持通过虚拟号(device_no)、IMEI、SN 任意一个标识符查询设备。",
Tags: []string{"设备管理"},
Input: new(dto.GetDeviceByIdentifierRequest),
Output: new(dto.DeviceResponse),
Auth: true,
})
Register(devices, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
Summary: "删除设备",
Description: "仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。",
@@ -146,15 +129,6 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/by-identifier/:identifier/gateway-info", handler.GetGatewayInfo, RouteSpec{
Summary: "查询设备信息",
Description: "通过虚拟号/IMEI/SN 查询设备网关信息。设备必须已配置 IMEI。",
Tags: []string{"设备管理"},
Input: new(dto.GetDeviceByIdentifierRequest),
Output: new(gateway.DeviceInfoResp),
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/by-identifier/:identifier/gateway-slots", handler.GetGatewaySlots, RouteSpec{
Summary: "查询卡槽信息",
Description: "通过虚拟号/IMEI/SN 查询设备卡槽信息。设备必须已配置 IMEI。",

View File

@@ -36,19 +36,4 @@ func registerEnterpriseCardRoutes(router fiber.Router, handler *admin.Enterprise
Auth: true,
})
Register(enterprises, doc, groupPath, "POST", "/:id/cards/:card_id/suspend", handler.SuspendCard, RouteSpec{
Summary: "停机卡",
Tags: []string{"企业卡授权"},
Input: new(dto.SuspendCardReq),
Output: nil,
Auth: true,
})
Register(enterprises, doc, groupPath, "POST", "/:id/cards/:card_id/resume", handler.ResumeCard, RouteSpec{
Summary: "复机卡",
Tags: []string{"企业卡授权"},
Input: new(dto.ResumeCardReq),
Output: nil,
Auth: true,
})
}

View File

@@ -28,19 +28,4 @@ func registerH5EnterpriseDeviceRoutes(router fiber.Router, handler *h5.Enterpris
Auth: true,
})
Register(devices, doc, groupPath, "POST", "/:device_id/cards/:card_id/suspend", handler.SuspendCard, RouteSpec{
Summary: "停机卡H5",
Tags: []string{"H5-企业设备"},
Input: new(dto.DeviceCardOperationReq),
Output: new(dto.DeviceCardOperationResp),
Auth: true,
})
Register(devices, doc, groupPath, "POST", "/:device_id/cards/:card_id/resume", handler.ResumeCard, RouteSpec{
Summary: "复机卡H5",
Tags: []string{"H5-企业设备"},
Input: new(dto.DeviceCardOperationReq),
Output: new(dto.DeviceCardOperationResp),
Auth: true,
})
}

View File

@@ -21,14 +21,6 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i
Auth: true,
})
Register(iotCards, doc, groupPath, "GET", "/by-iccid/:iccid", handler.GetByICCID, RouteSpec{
Summary: "通过ICCID查询单卡详情",
Tags: []string{"IoT卡管理"},
Input: new(dto.GetIotCardByICCIDRequest),
Output: new(dto.IotCardDetailResponse),
Auth: true,
})
Register(iotCards, doc, groupPath, "POST", "/import", importHandler.Import, RouteSpec{
Summary: "批量导入IoT卡ICCID+MSISDN",
Description: `仅平台用户可操作。
@@ -109,30 +101,6 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i
Auth: true,
})
Register(iotCards, doc, groupPath, "GET", "/:iccid/gateway-status", handler.GetGatewayStatus, RouteSpec{
Summary: "查询卡实时状态",
Tags: []string{"IoT卡管理"},
Input: new(dto.GetIotCardByICCIDRequest),
Output: new(gateway.CardStatusResp),
Auth: true,
})
Register(iotCards, doc, groupPath, "GET", "/:iccid/gateway-flow", handler.GetGatewayFlow, RouteSpec{
Summary: "查询流量使用",
Tags: []string{"IoT卡管理"},
Input: new(dto.GetIotCardByICCIDRequest),
Output: new(gateway.FlowUsageResp),
Auth: true,
})
Register(iotCards, doc, groupPath, "GET", "/:iccid/gateway-realname", handler.GetGatewayRealname, RouteSpec{
Summary: "查询实名认证状态",
Tags: []string{"IoT卡管理"},
Input: new(dto.GetIotCardByICCIDRequest),
Output: new(gateway.RealnameStatusResp),
Auth: true,
})
Register(iotCards, doc, groupPath, "GET", "/:iccid/realname-link", handler.GetRealnameLink, RouteSpec{
Summary: "获取实名认证链接",
Tags: []string{"IoT卡管理"},
@@ -141,19 +109,4 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i
Auth: true,
})
Register(iotCards, doc, groupPath, "POST", "/:iccid/stop", handler.StopCard, RouteSpec{
Summary: "停机",
Tags: []string{"IoT卡管理"},
Input: new(dto.GetIotCardByICCIDRequest),
Output: nil,
Auth: true,
})
Register(iotCards, doc, groupPath, "POST", "/:iccid/start", handler.StartCard, RouteSpec{
Summary: "复机",
Tags: []string{"IoT卡管理"},
Input: new(dto.GetIotCardByICCIDRequest),
Output: nil,
Auth: true,
})
}

View File

@@ -0,0 +1,477 @@
// Package asset 提供统一的资产查询与操作服务
// 资产包含两种类型IoT卡(card)和设备(device)
// 支持资产解析、实时状态查询、网关刷新、套餐查询等功能
package asset
import (
"context"
"sort"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
// IotCardRefresher 用于调用 RefreshCardDataFromGateway避免循环依赖
type IotCardRefresher interface {
RefreshCardDataFromGateway(ctx context.Context, iccid string) error
}
// Service 资产查询与操作服务
type Service struct {
db *gorm.DB
deviceStore *postgres.DeviceStore
iotCardStore *postgres.IotCardStore
packageUsageStore *postgres.PackageUsageStore
packageStore *postgres.PackageStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
shopStore *postgres.ShopStore
redis *redis.Client
iotCardService IotCardRefresher
}
// New 创建资产服务实例
func New(
db *gorm.DB,
deviceStore *postgres.DeviceStore,
iotCardStore *postgres.IotCardStore,
packageUsageStore *postgres.PackageUsageStore,
packageStore *postgres.PackageStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
shopStore *postgres.ShopStore,
redisClient *redis.Client,
iotCardService IotCardRefresher,
) *Service {
return &Service{
db: db,
deviceStore: deviceStore,
iotCardStore: iotCardStore,
packageUsageStore: packageUsageStore,
packageStore: packageStore,
deviceSimBindingStore: deviceSimBindingStore,
shopStore: shopStore,
redis: redisClient,
iotCardService: iotCardService,
}
}
// Resolve 通过任意标识符解析资产
// 优先匹配设备virtual_no/imei/sn未命中则匹配卡virtual_no/iccid/msisdn
func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetResolveResponse, error) {
// 先查 Device
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
if err == nil && device != nil {
return s.buildDeviceResolveResponse(ctx, device)
}
// 未找到设备,查 IotCardvirtual_no/iccid/msisdn
var card model.IotCard
query := s.db.WithContext(ctx).
Where("virtual_no = ? OR iccid = ? OR msisdn = ?", identifier, identifier, identifier)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&card).Error; err == nil {
return s.buildCardResolveResponse(ctx, &card)
}
return nil, errors.New(errors.CodeNotFound, "未找到匹配的资产")
}
// buildDeviceResolveResponse 构建设备类型的资产解析响应
func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.Device) (*dto.AssetResolveResponse, error) {
resp := &dto.AssetResolveResponse{
AssetType: "device",
AssetID: device.ID,
VirtualNo: device.VirtualNo,
Status: device.Status,
BatchNo: device.BatchNo,
ShopID: device.ShopID,
SeriesID: device.SeriesID,
FirstCommissionPaid: device.FirstCommissionPaid,
AccumulatedRecharge: device.AccumulatedRecharge,
ActivatedAt: device.ActivatedAt,
CreatedAt: device.CreatedAt,
UpdatedAt: device.UpdatedAt,
DeviceName: device.DeviceName,
IMEI: device.IMEI,
SN: device.SN,
DeviceModel: device.DeviceModel,
DeviceType: device.DeviceType,
MaxSimSlots: device.MaxSimSlots,
Manufacturer: device.Manufacturer,
}
// 查绑定卡
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, device.ID)
if err == nil && len(bindings) > 0 {
resp.BoundCardCount = len(bindings)
cardIDs := make([]uint, 0, len(bindings))
slotMap := make(map[uint]int, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
slotMap[b.IotCardID] = b.SlotPosition
}
cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)
for _, c := range cards {
resp.Cards = append(resp.Cards, dto.BoundCardInfo{
CardID: c.ID,
ICCID: c.ICCID,
MSISDN: c.MSISDN,
NetworkStatus: c.NetworkStatus,
RealNameStatus: c.RealNameStatus,
SlotPosition: slotMap[c.ID],
})
}
}
// 查当前主套餐
s.fillPackageInfo(ctx, resp, "device", device.ID)
// 查 shop 名称
s.fillShopName(ctx, resp)
// 查 Redis 保护期
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, device.ID)
return resp, nil
}
// buildCardResolveResponse 构建卡类型的资产解析响应
func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotCard) (*dto.AssetResolveResponse, error) {
resp := &dto.AssetResolveResponse{
AssetType: "card",
AssetID: card.ID,
VirtualNo: card.VirtualNo,
Status: card.Status,
BatchNo: card.BatchNo,
ShopID: card.ShopID,
SeriesID: card.SeriesID,
FirstCommissionPaid: card.FirstCommissionPaid,
AccumulatedRecharge: card.AccumulatedRecharge,
ActivatedAt: card.ActivatedAt,
CreatedAt: card.CreatedAt,
UpdatedAt: card.UpdatedAt,
RealNameStatus: card.RealNameStatus,
NetworkStatus: card.NetworkStatus,
ICCID: card.ICCID,
CarrierID: card.CarrierID,
CarrierType: card.CarrierType,
CarrierName: card.CarrierName,
MSISDN: card.MSISDN,
IMSI: card.IMSI,
CardCategory: card.CardCategory,
Supplier: card.Supplier,
ActivationStatus: card.ActivationStatus,
EnablePolling: card.EnablePolling,
}
// 查绑定设备
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err == nil && binding != nil {
resp.BoundDeviceID = &binding.DeviceID
device, devErr := s.deviceStore.GetByID(ctx, binding.DeviceID)
if devErr == nil && device != nil {
resp.BoundDeviceNo = device.VirtualNo
resp.BoundDeviceName = device.DeviceName
}
}
// 查当前主套餐
s.fillPackageInfo(ctx, resp, "iot_card", card.ID)
// 查 shop 名称
s.fillShopName(ctx, resp)
return resp, nil
}
// fillPackageInfo 填充当前主套餐信息到响应中
func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) {
usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, carrierID)
if err != nil || usage == nil {
return
}
pkg, err := s.packageStore.GetByID(ctx, usage.PackageID)
if err != nil || pkg == nil {
return
}
resp.CurrentPackage = pkg.PackageName
ratio := safeVirtualRatio(pkg.VirtualRatio)
resp.PackageTotalMB = int64(float64(usage.DataLimitMB) / ratio)
resp.PackageUsedMB = float64(usage.DataUsageMB) / ratio
resp.PackageRemainMB = float64(usage.DataLimitMB-usage.DataUsageMB) / ratio
}
// fillShopName 填充店铺名称
func (s *Service) fillShopName(ctx context.Context, resp *dto.AssetResolveResponse) {
if resp.ShopID == nil || *resp.ShopID == 0 {
return
}
shop, err := s.shopStore.GetByID(ctx, *resp.ShopID)
if err == nil && shop != nil {
resp.ShopName = shop.ShopName
}
}
// GetRealtimeStatus 获取资产实时状态只读DB/Redis
func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) {
resp := &dto.AssetRealtimeStatusResponse{
AssetType: assetType,
AssetID: id,
}
switch assetType {
case "card":
card, err := s.iotCardStore.GetByID(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在")
}
resp.NetworkStatus = card.NetworkStatus
resp.RealNameStatus = card.RealNameStatus
resp.CurrentMonthUsageMB = card.CurrentMonthUsageMB
resp.LastSyncTime = card.LastSyncTime
case "device":
// 查绑定卡状态列表
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id)
if err == nil && len(bindings) > 0 {
cardIDs := make([]uint, 0, len(bindings))
slotMap := make(map[uint]int, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
slotMap[b.IotCardID] = b.SlotPosition
}
cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)
for _, c := range cards {
resp.Cards = append(resp.Cards, dto.BoundCardInfo{
CardID: c.ID,
ICCID: c.ICCID,
MSISDN: c.MSISDN,
NetworkStatus: c.NetworkStatus,
RealNameStatus: c.RealNameStatus,
SlotPosition: slotMap[c.ID],
})
}
}
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device")
}
return resp, nil
}
// Refresh 刷新资产数据(调网关同步)
func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) {
switch assetType {
case "card":
card, err := s.iotCardStore.GetByID(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在")
}
if err := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "刷新卡数据失败")
}
return s.GetRealtimeStatus(ctx, "card", id)
case "device":
// 检查冷却期
cooldownKey := constants.RedisDeviceRefreshCooldownKey(id)
if s.redis.Exists(ctx, cooldownKey).Val() > 0 {
return nil, errors.New(errors.CodeTooManyRequests, "刷新过于频繁请30秒后再试")
}
// 查所有绑定卡,逐一刷新
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡失败")
}
for _, b := range bindings {
card, cardErr := s.iotCardStore.GetByID(ctx, b.IotCardID)
if cardErr != nil {
logger.GetAppLogger().Warn("刷新设备绑定卡失败:查卡失败",
zap.Uint("device_id", id),
zap.Uint("card_id", b.IotCardID),
zap.Error(cardErr))
continue
}
if refreshErr := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); refreshErr != nil {
logger.GetAppLogger().Warn("刷新设备绑定卡失败:网关调用失败",
zap.Uint("device_id", id),
zap.String("iccid", card.ICCID),
zap.Error(refreshErr))
}
}
// 设置冷却 Key
s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration)
return s.GetRealtimeStatus(ctx, "device", id)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device")
}
}
// GetPackages 获取资产的所有套餐列表
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([]*dto.AssetPackageResponse, error) {
// assetType 对应 Store 中的 carrierTypecard→iot_card, device→device
carrierType := assetType
if assetType == "card" {
carrierType = "iot_card"
}
usages, err := s.packageUsageStore.ListByCarrier(ctx, carrierType, id)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐使用记录失败")
}
// 收集所有 PackageID 并批量查询
pkgIDSet := make(map[uint]struct{}, len(usages))
for _, u := range usages {
pkgIDSet[u.PackageID] = struct{}{}
}
pkgIDs := make([]uint, 0, len(pkgIDSet))
for id := range pkgIDSet {
pkgIDs = append(pkgIDs, id)
}
packages, _ := s.packageStore.GetByIDsUnscoped(ctx, pkgIDs)
pkgMap := make(map[uint]*model.Package, len(packages))
for _, p := range packages {
pkgMap[p.ID] = p
}
result := make([]*dto.AssetPackageResponse, 0, len(usages))
for _, u := range usages {
pkg := pkgMap[u.PackageID]
ratio := 1.0
pkgName := ""
pkgType := ""
if pkg != nil {
ratio = safeVirtualRatio(pkg.VirtualRatio)
pkgName = pkg.PackageName
pkgType = pkg.PackageType
}
item := &dto.AssetPackageResponse{
PackageUsageID: u.ID,
PackageID: u.PackageID,
PackageName: pkgName,
PackageType: pkgType,
UsageType: u.UsageType,
Status: u.Status,
StatusName: packageStatusName(u.Status),
DataLimitMB: u.DataLimitMB,
VirtualLimitMB: int64(float64(u.DataLimitMB) / ratio),
DataUsageMB: u.DataUsageMB,
VirtualUsedMB: float64(u.DataUsageMB) / ratio,
VirtualRemainMB: float64(u.DataLimitMB-u.DataUsageMB) / ratio,
VirtualRatio: ratio,
ActivatedAt: u.ActivatedAt,
ExpiresAt: u.ExpiresAt,
MasterUsageID: u.MasterUsageID,
Priority: u.Priority,
CreatedAt: u.CreatedAt,
}
result = append(result, item)
}
// 按 created_at DESC 排序
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt.After(result[j].CreatedAt)
})
return result, nil
}
// GetCurrentPackage 获取资产当前生效的主套餐
func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint) (*dto.AssetPackageResponse, error) {
carrierType := assetType
if assetType == "card" {
carrierType = "iot_card"
}
usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "当前无生效套餐")
}
pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID)
ratio := 1.0
pkgName := ""
pkgType := ""
if pkgErr == nil && pkg != nil {
ratio = safeVirtualRatio(pkg.VirtualRatio)
pkgName = pkg.PackageName
pkgType = pkg.PackageType
}
return &dto.AssetPackageResponse{
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: pkgName,
PackageType: pkgType,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
DataLimitMB: usage.DataLimitMB,
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
DataUsageMB: usage.DataUsageMB,
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
VirtualRatio: ratio,
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
CreatedAt: usage.CreatedAt,
}, nil
}
// getDeviceProtectStatus 查询设备保护期状态
func (s *Service) getDeviceProtectStatus(ctx context.Context, deviceID uint) string {
stopKey := constants.RedisDeviceProtectKey(deviceID, "stop")
startKey := constants.RedisDeviceProtectKey(deviceID, "start")
if s.redis.Exists(ctx, stopKey).Val() > 0 {
return "stop"
}
if s.redis.Exists(ctx, startKey).Val() > 0 {
return "start"
}
return "none"
}
// safeVirtualRatio 安全获取虚流量比例,避免除零
func safeVirtualRatio(ratio float64) float64 {
if ratio <= 0 {
return 1.0
}
return ratio
}
// packageStatusName 套餐状态码转中文名称
func packageStatusName(status int) string {
switch status {
case 0:
return "待生效"
case 1:
return "生效中"
case 2:
return "已用完"
case 3:
return "已过期"
case 4:
return "已失效"
default:
return "未知"
}
}

View File

@@ -2,6 +2,11 @@ package device
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -10,11 +15,13 @@ import (
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
type Service struct {
db *gorm.DB
redis *redis.Client
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
@@ -28,6 +35,7 @@ type Service struct {
func New(
db *gorm.DB,
rds *redis.Client,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
@@ -40,6 +48,7 @@ func New(
) *Service {
return &Service{
db: db,
redis: rds,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
@@ -69,8 +78,8 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
}
filters := make(map[string]interface{})
if req.DeviceNo != "" {
filters["device_no"] = req.DeviceNo
if req.VirtualNo != "" {
filters["virtual_no"] = req.VirtualNo
}
if req.DeviceName != "" {
filters["device_name"] = req.DeviceName
@@ -251,9 +260,9 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
// 平台只能分配 shop_id=NULL 的设备
if isPlatform && device.ShopID != nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "平台只能分配库存设备",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "平台只能分配库存设备",
})
continue
}
@@ -261,9 +270,9 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
// 代理只能分配自己店铺的设备
if !isPlatform && (device.ShopID == nil || *device.ShopID != *operatorShopID) {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "设备不属于当前店铺",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "设备不属于当前店铺",
})
continue
}
@@ -342,9 +351,9 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
// 验证设备所属店铺是否为直属下级
if device.ShopID == nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "设备已在平台库存中",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "设备已在平台库存中",
})
continue
}
@@ -353,9 +362,9 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
if !isPlatform {
if err := s.validateDirectSubordinate(ctx, operatorShopID, *device.ShopID); err != nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "只能回收直属下级店铺的设备",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "只能回收直属下级店铺的设备",
})
continue
}
@@ -528,7 +537,7 @@ func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, seriesMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
resp := &dto.DeviceResponse{
ID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
IMEI: device.IMEI,
SN: device.SN,
DeviceName: device.DeviceName,
@@ -592,7 +601,7 @@ func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceI
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeDevice,
AssetID: device.ID,
AssetIdentifier: device.DeviceNo,
AssetIdentifier: device.VirtualNo,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: toShopID,
OperatorID: operatorID,
@@ -630,7 +639,7 @@ func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs [
AllocationType: constants.AssetAllocationTypeRecall,
AssetType: constants.AssetTypeDevice,
AssetID: device.ID,
AssetIdentifier: device.DeviceNo,
AssetIdentifier: device.VirtualNo,
OperatorID: operatorID,
Remark: remark,
}
@@ -699,9 +708,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
device, exists := deviceMap[deviceID]
if !exists {
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
DeviceID: deviceID,
DeviceNo: "",
Reason: "设备不存在",
DeviceID: deviceID,
VirtualNo: "",
Reason: "设备不存在",
})
continue
}
@@ -721,9 +730,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
}
if !hasSeriesAllocation {
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
DeviceID: deviceID,
DeviceNo: device.DeviceNo,
Reason: "您没有权限分配该套餐系列",
DeviceID: deviceID,
VirtualNo: device.VirtualNo,
Reason: "您没有权限分配该套餐系列",
})
continue
}
@@ -733,9 +742,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
if operatorShopID != nil {
if device.ShopID == nil || *device.ShopID != *operatorShopID {
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "无权操作此设备",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "无权操作此设备",
})
continue
}
@@ -765,10 +774,207 @@ func (s *Service) buildDeviceNotFoundFailedItems(deviceIDs []uint) []dto.DeviceS
items := make([]dto.DeviceSeriesBindngFailedItem, len(deviceIDs))
for i, id := range deviceIDs {
items[i] = dto.DeviceSeriesBindngFailedItem{
DeviceID: id,
DeviceNo: "",
Reason: "设备不存在",
DeviceID: id,
VirtualNo: "",
Reason: "设备不存在",
}
}
return items
}
// StopDevice 设备停机
// POST /api/admin/assets/device/:device_id/stop
// 查找设备绑定的所有已实名且已开机的卡,逐一调网关停机
func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSuspendResponse, error) {
log := logger.GetAppLogger()
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
_ = device
// 复机保护期内禁止停机
if s.redis != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Result()
if exists > 0 {
return nil, errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
}
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
}
if len(bindings) == 0 {
return &dto.DeviceSuspendResponse{}, nil
}
cardIDs := make([]uint, 0, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
}
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
}
var successCount, skipCount int
var failedItems []dto.DeviceSuspendFailItem
for _, card := range cards {
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOnline {
skipCount++
continue
}
if s.gatewayClient != nil {
if gwErr := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil {
log.Error("设备停机-调网关停机失败",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID),
zap.Error(gwErr))
failedItems = append(failedItems, dto.DeviceSuspendFailItem{
ICCID: card.ICCID,
Reason: "网关停机失败",
})
continue
}
}
now := time.Now()
if dbErr := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ?", card.ID).
Updates(map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonManual,
}).Error; dbErr != nil {
log.Error("设备停机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
failedItems = append(failedItems, dto.DeviceSuspendFailItem{
ICCID: card.ICCID,
Reason: "更新卡状态失败",
})
continue
}
successCount++
}
// 成功停机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), 1, constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start"))
}
return &dto.DeviceSuspendResponse{
SuccessCount: successCount,
FailCount: len(failedItems),
SkipCount: skipCount,
FailedItems: failedItems,
}, nil
}
// StartDevice 设备复机
// POST /api/admin/assets/device/:device_id/start
// 查找设备绑定的所有已实名且已停机的卡,逐一调网关复机
func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
log := logger.GetAppLogger()
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
return errors.New(errors.CodeNotFound, "设备不存在")
}
_ = device
// 停机保护期内禁止复机
if s.redis != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Result()
if exists > 0 {
return errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
}
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
}
if len(bindings) == 0 {
return nil
}
cardIDs := make([]uint, 0, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
}
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
}
var successCount int
var lastErr error
for _, card := range cards {
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOffline {
continue
}
if s.gatewayClient != nil {
if gwErr := s.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil {
log.Error("设备复机-调网关复机失败",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID),
zap.Error(gwErr))
lastErr = gwErr
continue
}
}
now := time.Now()
if dbErr := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ?", card.ID).
Updates(map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}).Error; dbErr != nil {
log.Error("设备复机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
lastErr = dbErr
continue
}
successCount++
}
// 成功复机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), 1, constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"))
}
// 全部失败时返回 error
if successCount == 0 && lastErr != nil {
return errors.Wrap(errors.CodeInternalError, lastErr, "设备复机失败")
}
return nil
}

View File

@@ -139,25 +139,25 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.DeviceImportTaskDe
for _, item := range task.SkippedItems {
resp.SkippedItems = append(resp.SkippedItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
DeviceNo: item.ICCID,
Reason: item.Reason,
Line: item.Line,
VirtualNo: item.ICCID,
Reason: item.Reason,
})
}
for _, item := range task.FailedItems {
resp.FailedItems = append(resp.FailedItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
DeviceNo: item.ICCID,
Reason: item.Reason,
Line: item.Line,
VirtualNo: item.ICCID,
Reason: item.Reason,
})
}
for _, item := range task.WarningItems {
resp.WarningItems = append(resp.WarningItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
DeviceNo: item.ICCID,
Reason: item.Reason,
Line: item.Line,
VirtualNo: item.ICCID,
Reason: item.Reason,
})
}

View File

@@ -59,14 +59,14 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
// 查询所有设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("device_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
deviceMap := make(map[string]*model.Device)
deviceIDs := make([]uint, 0, len(devices))
for i := range devices {
deviceMap[devices[i].DeviceNo] = &devices[i]
deviceMap[devices[i].VirtualNo] = &devices[i]
deviceIDs = append(deviceIDs, devices[i].ID)
}
@@ -90,8 +90,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备不存在",
VirtualNo: deviceNo,
Reason: "设备不存在",
})
continue
}
@@ -99,8 +99,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
// 验证设备状态(必须是"已分销"状态)
if device.Status != 2 {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备状态不正确,必须是已分销状态",
VirtualNo: deviceNo,
Reason: "设备状态不正确,必须是已分销状态",
})
continue
}
@@ -109,8 +109,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
if userType == constants.UserTypeAgent {
if device.ShopID == nil || *device.ShopID != currentShopID {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "无权操作此设备",
VirtualNo: deviceNo,
Reason: "无权操作此设备",
})
continue
}
@@ -119,8 +119,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
// 检查是否已授权
if existingAuths[device.ID] {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备已授权给此企业",
VirtualNo: deviceNo,
Reason: "设备已授权给此企业",
})
continue
}
@@ -199,7 +199,7 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
for _, device := range devicesToAllocate {
resp.AuthorizedDevices = append(resp.AuthorizedDevices, dto.AuthorizedDeviceItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
CardCount: deviceCardCount[device.ID],
})
}
@@ -232,14 +232,14 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
// 查询设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("device_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
deviceMap := make(map[string]*model.Device)
deviceIDs := make([]uint, 0, len(devices))
for i := range devices {
deviceMap[devices[i].DeviceNo] = &devices[i]
deviceMap[devices[i].VirtualNo] = &devices[i]
deviceIDs = append(deviceIDs, devices[i].ID)
}
@@ -258,16 +258,16 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备不存在",
VirtualNo: deviceNo,
Reason: "设备不存在",
})
continue
}
if !existingAuths[device.ID] {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备未授权给此企业",
VirtualNo: deviceNo,
Reason: "设备未授权给此企业",
})
continue
}
@@ -276,8 +276,8 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
auth, err := s.enterpriseDeviceAuthStore.GetByDeviceID(ctx, device.ID)
if err != nil || auth.EnterpriseID != enterpriseID {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "授权记录不存在",
VirtualNo: deviceNo,
Reason: "授权记录不存在",
})
continue
}
@@ -352,8 +352,8 @@ func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.E
// 查询设备信息
var devices []model.Device
query := s.db.WithContext(ctx).Where("id IN ?", deviceIDs)
if req.DeviceNo != "" {
query = query.Where("device_no LIKE ?", "%"+req.DeviceNo+"%")
if req.VirtualNo != "" {
query = query.Where("virtual_no LIKE ?", "%"+req.VirtualNo+"%")
}
if err := query.Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
@@ -378,7 +378,7 @@ func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.E
auth := authMap[device.ID]
items = append(items, dto.EnterpriseDeviceItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
DeviceName: device.DeviceName,
DeviceModel: device.DeviceModel,
CardCount: cardCountMap[device.ID],
@@ -427,8 +427,8 @@ func (s *Service) ListDevicesForEnterprise(ctx context.Context, req *dto.Enterpr
var devices []model.Device
query := s.db.WithContext(ctx).Where("id IN ?", deviceIDs)
if req.DeviceNo != "" {
query = query.Where("device_no LIKE ?", "%"+req.DeviceNo+"%")
if req.VirtualNo != "" {
query = query.Where("virtual_no LIKE ?", "%"+req.VirtualNo+"%")
}
if err := query.Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
@@ -451,7 +451,7 @@ func (s *Service) ListDevicesForEnterprise(ctx context.Context, req *dto.Enterpr
auth := authMap[device.ID]
items = append(items, dto.EnterpriseDeviceItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
DeviceName: device.DeviceName,
DeviceModel: device.DeviceModel,
CardCount: cardCountMap[device.ID],
@@ -531,7 +531,7 @@ func (s *Service) GetDeviceDetail(ctx context.Context, deviceID uint) (*dto.Ente
return &dto.EnterpriseDeviceDetailResp{
Device: dto.EnterpriseDeviceInfo{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
DeviceName: device.DeviceName,
DeviceModel: device.DeviceModel,
DeviceType: device.DeviceType,

View File

@@ -2,6 +2,7 @@ package iot_card
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -795,20 +796,9 @@ func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeries
return items
}
// SyncCardStatusFromGateway 从 Gateway 同步卡状态(示例方法)
func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
if s.gatewayClient == nil {
return errors.New(errors.CodeGatewayError, "Gateway 客户端未配置")
}
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Error("查询卡状态失败", zap.String("iccid", iccid), zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
}
// RefreshCardDataFromGateway 从 Gateway 完整同步卡数据
// 调用网关查询网络状态、实名状态、本月流量,并写回数据库
func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) error {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -817,40 +807,73 @@ func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) e
return err
}
var newStatus int
switch resp.CardStatus {
case "准备":
newStatus = constants.IotCardStatusInStock
case "正常":
newStatus = constants.IotCardStatusDistributed
case "停机":
newStatus = constants.IotCardStatusSuspended
default:
s.logger.Warn("未知的卡状态", zap.String("cardStatus", resp.CardStatus))
return nil
syncTime := time.Now()
updates := map[string]any{
"last_sync_time": syncTime,
}
if card.Status != newStatus {
oldStatus := card.Status
card.Status = newStatus
if err := s.iotCardStore.Update(ctx, card); err != nil {
return err
if s.gatewayClient != nil {
// 1. 查询网络状态(卡的开/停机状态)
statusResp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(err))
} else {
networkStatus := parseNetworkStatus(statusResp.CardStatus)
updates["network_status"] = networkStatus
}
s.logger.Info("同步卡状态成功",
zap.String("iccid", iccid),
zap.Int("oldStatus", oldStatus),
zap.Int("newStatus", newStatus),
)
// 通知轮询调度器状态变化
if s.pollingCallback != nil {
s.pollingCallback.OnCardStatusChanged(ctx, card.ID)
// 2. 查询实名状态
realnameResp, err := s.gatewayClient.QueryRealnameStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Warn("刷新卡数据:查询实名状态失败", zap.String("iccid", iccid), zap.Error(err))
} else {
realNameStatus := parseGatewayRealnameStatus(realnameResp.RealStatus)
updates["real_name_status"] = realNameStatus
}
// 3. 查询本月流量用量
flowResp, err := s.gatewayClient.QueryFlow(ctx, &gateway.FlowQueryReq{
CardNo: iccid,
})
if err != nil {
s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(err))
} else {
updates["current_month_usage_mb"] = flowResp.Used
}
}
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ?", card.ID).
Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败")
}
s.logger.Info("刷新卡数据成功", zap.String("iccid", iccid), zap.Uint("card_id", card.ID))
return nil
}
// parseNetworkStatus 将网关返回的卡状态字符串转换为 network_status 数值
// 停机→0其他准备/正常→1
func parseNetworkStatus(cardStatus string) int {
if cardStatus == "停机" {
return 0
}
return 1
}
// parseGatewayRealnameStatus 将网关返回的实名状态布尔值转换为 real_name_status 数值
// true=已实名(2)false=未实名(0)
func parseGatewayRealnameStatus(realStatus bool) int {
if realStatus {
return 2
}
return 0
}
// UpdatePollingStatus 更新卡的轮询状态
// 启用或禁用卡的轮询功能
func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePolling bool) error {

View File

@@ -8,22 +8,25 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
stderrors "errors"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// StopResumeService 停复机服务
// 任务 24.2: 处理 IoT 卡的自动停机复机逻辑
// 处理 IoT 卡的自动停机、复机和手动停复机逻辑
type StopResumeService struct {
db *gorm.DB
redis *redis.Client
iotCardStore *postgres.IotCardStore
gatewayClient *gateway.Client
logger *zap.Logger
db *gorm.DB
redis *redis.Client
iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
gatewayClient *gateway.Client
logger *zap.Logger
// 重试配置
maxRetries int
retryInterval time.Duration
}
@@ -33,17 +36,19 @@ func NewStopResumeService(
db *gorm.DB,
redis *redis.Client,
iotCardStore *postgres.IotCardStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *StopResumeService {
return &StopResumeService{
db: db,
redis: redis,
iotCardStore: iotCardStore,
gatewayClient: gatewayClient,
logger: logger,
maxRetries: 3, // 默认最多重试 3 次
retryInterval: 2 * time.Second, // 默认重试间隔 2 秒
db: db,
redis: redis,
iotCardStore: iotCardStore,
deviceSimBindingStore: deviceSimBindingStore,
gatewayClient: gatewayClient,
logger: logger,
maxRetries: 3,
retryInterval: 2 * time.Second,
}
}
@@ -233,3 +238,75 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
return lastErr
}
// ManualStopCard 手动停机单张卡通过ICCID
func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) error {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
return errors.New(errors.CodeNotFound, "卡不存在")
}
if card.RealNameStatus != constants.RealNameStatusVerified {
return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
}
// 检查绑定设备是否在复机保护期
if s.deviceSimBindingStore != nil && s.redis != nil {
binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if bindErr == nil && binding != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "start")).Result()
if exists > 0 {
return errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
}
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
return errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
}
}
if err := s.stopCardWithRetry(ctx, card); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "调网关停机失败")
}
now := time.Now()
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonManual,
}).Error
}
// ManualStartCard 手动复机单张卡通过ICCID
func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) error {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
return errors.New(errors.CodeNotFound, "卡不存在")
}
if card.RealNameStatus != constants.RealNameStatusVerified {
return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
}
// 检查绑定设备是否在停机保护期
if s.deviceSimBindingStore != nil && s.redis != nil {
binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if bindErr == nil && binding != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "stop")).Result()
if exists > 0 {
return errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
}
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
return errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
}
}
if err := s.resumeCardWithRetry(ctx, card); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "调网关复机失败")
}
now := time.Now()
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}).Error
}

View File

@@ -214,7 +214,6 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy
return nil
})
if err != nil {
return nil, err
}

View File

@@ -126,9 +126,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
if req.EnableRealnameActivation != nil {
pkg.EnableRealnameActivation = *req.EnableRealnameActivation
} else {
// 默认启用实名激活
pkg.EnableRealnameActivation = true
}
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
pkg.Creator = currentUserID
if err := s.packageStore.Create(ctx, pkg); err != nil {
@@ -250,6 +250,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
}
}
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
pkg.Updater = currentUserID
if err := s.packageStore.Update(ctx, pkg); err != nil {
@@ -673,3 +674,12 @@ func formatAmount(amountFen int64) string {
}
return fmt.Sprintf("%.2f元/张", yuan)
}
// calculateVirtualRatio 计算虚流量比例
// enable_virtual_data=true 且 virtual_data_mb>0 时 = real_data_mb/virtual_data_mb否则 = 1.0
func calculateVirtualRatio(enableVirtualData bool, realDataMB, virtualDataMB int64) float64 {
if enableVirtualData && virtualDataMB > 0 {
return float64(realDataMB) / float64(virtualDataMB)
}
return 1.0
}

View File

@@ -347,7 +347,7 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
ShopID: shopID,
CommissionSource: req.CommissionSource,
ICCID: req.ICCID,
DeviceNo: req.DeviceNo,
DeviceNo: req.VirtualNo,
OrderNo: req.OrderNo,
}
@@ -367,7 +367,7 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
StatusName: getCommissionStatusName(r.Status),
OrderID: r.OrderID,
OrderNo: "",
DeviceNo: "",
VirtualNo: "",
ICCID: "",
OrderCreatedAt: "",
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),

View File

@@ -48,7 +48,7 @@ func (s *DeviceStore) GetByID(ctx context.Context, id uint) (*model.Device, erro
func (s *DeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) (*model.Device, error) {
var device model.Device
query := s.db.WithContext(ctx).Where("device_no = ?", deviceNo)
query := s.db.WithContext(ctx).Where("virtual_no = ?", deviceNo)
// 应用数据权限过滤NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&device).Error; err != nil {
@@ -58,10 +58,10 @@ func (s *DeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) (*mode
}
// GetByIdentifier 通过任意标识符查找设备
// 支持 device_no虚拟号、imei、sn 三个字段的自动匹配
// 支持 virtual_no虚拟号、imei、sn 三个字段的自动匹配
func (s *DeviceStore) GetByIdentifier(ctx context.Context, identifier string) (*model.Device, error) {
var device model.Device
query := s.db.WithContext(ctx).Where("device_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier)
query := s.db.WithContext(ctx).Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier)
// 应用数据权限过滤NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&device).Error; err != nil {
@@ -100,8 +100,8 @@ func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filter
// 应用数据权限过滤NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if deviceNo, ok := filters["device_no"].(string); ok && deviceNo != "" {
query = query.Where("device_no LIKE ?", "%"+deviceNo+"%")
if virtualNo, ok := filters["virtual_no"].(string); ok && virtualNo != "" {
query = query.Where("virtual_no LIKE ?", "%"+virtualNo+"%")
}
if deviceName, ok := filters["device_name"].(string); ok && deviceName != "" {
query = query.Where("device_name LIKE ?", "%"+deviceName+"%")
@@ -184,17 +184,17 @@ func (s *DeviceStore) ExistsByDeviceNoBatch(ctx context.Context, deviceNos []str
}
var existingDevices []struct {
DeviceNo string
VirtualNo string
}
if err := s.db.WithContext(ctx).Model(&model.Device{}).
Select("device_no").
Where("device_no IN ?", deviceNos).
Select("virtual_no").
Where("virtual_no IN ?", deviceNos).
Find(&existingDevices).Error; err != nil {
return nil, err
}
for _, d := range existingDevices {
result[d.DeviceNo] = true
result[d.VirtualNo] = true
}
return result, nil
}
@@ -204,7 +204,7 @@ func (s *DeviceStore) GetByDeviceNos(ctx context.Context, deviceNos []string) ([
if len(deviceNos) == 0 {
return devices, nil
}
query := s.db.WithContext(ctx).Where("device_no IN ?", deviceNos)
query := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos)
// 应用数据权限过滤NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Find(&devices).Error; err != nil {

View File

@@ -903,6 +903,26 @@ func (s *IotCardStore) BatchDelete(ctx context.Context, cardIDs []uint) error {
Delete(&model.IotCard{}).Error
}
// ExistsByVirtualNoBatch 批量检查 virtual_no 是否已存在
func (s *IotCardStore) ExistsByVirtualNoBatch(ctx context.Context, virtualNos []string) (map[string]bool, error) {
result := make(map[string]bool)
if len(virtualNos) == 0 {
return result, nil
}
var existingNos []string
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("virtual_no IN ? AND virtual_no <> ''", virtualNos).
Pluck("virtual_no", &existingNos).Error; err != nil {
return nil, err
}
for _, no := range existingNos {
result[no] = true
}
return result, nil
}
// ==================== 列表计数缓存 ====================
func (s *IotCardStore) getCachedCount(ctx context.Context, table string, filters map[string]any) (int64, bool) {

View File

@@ -104,7 +104,7 @@ func (s *PersonalCustomerDeviceStore) CreateOrUpdateLastUsed(ctx context.Context
// 不存在,创建新记录
newRecord := &model.PersonalCustomerDevice{
CustomerID: customerID,
DeviceNo: deviceNo,
VirtualNo: deviceNo,
Status: 1, // 启用
}
return s.Create(ctx, newRecord)

View File

@@ -180,7 +180,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
allICCIDs := make([]string, 0)
for _, row := range batch {
deviceNos = append(deviceNos, row.DeviceNo)
deviceNos = append(deviceNos, row.VirtualNo)
allICCIDs = append(allICCIDs, row.ICCIDs...)
}
@@ -190,7 +190,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
for _, row := range batch {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
ICCID: row.VirtualNo,
Reason: "数据库查询失败",
})
result.failCount++
@@ -218,10 +218,10 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
}
for _, row := range batch {
if existingDevices[row.DeviceNo] {
if existingDevices[row.VirtualNo] {
result.skippedItems = append(result.skippedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
ICCID: row.VirtualNo,
Reason: "设备号已存在",
})
result.skipCount++
@@ -251,7 +251,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
if len(row.ICCIDs) > 0 && len(cardIssues) > 0 {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
ICCID: row.VirtualNo,
Reason: "卡验证失败: " + strings.Join(cardIssues, ", "),
})
result.failCount++
@@ -263,7 +263,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
txBindingStore := postgres.NewDeviceSimBindingStore(tx, nil)
device := &model.Device{
DeviceNo: row.DeviceNo,
VirtualNo: row.VirtualNo,
DeviceName: row.DeviceName,
DeviceModel: row.DeviceModel,
DeviceType: row.DeviceType,
@@ -298,12 +298,12 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
if err != nil {
h.logger.Error("创建设备失败",
zap.String("device_no", row.DeviceNo),
zap.String("virtual_no", row.VirtualNo),
zap.Error(err),
)
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
ICCID: row.VirtualNo,
Reason: "数据库写入失败: " + err.Error(),
})
result.failCount++
@@ -320,4 +320,4 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
}
}
var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 device_no 列")
var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 virtual_no 列")

View File

@@ -167,8 +167,9 @@ func (h *IotCardImportHandler) downloadAndParse(ctx context.Context, task *model
cards := make(model.CardListJSON, 0, len(parseResult.Cards))
for _, card := range parseResult.Cards {
cards = append(cards, model.CardItem{
ICCID: card.ICCID,
MSISDN: card.MSISDN,
ICCID: card.ICCID,
MSISDN: card.MSISDN,
VirtualNo: card.VirtualNo,
})
}
@@ -210,15 +211,16 @@ func (h *IotCardImportHandler) getCardsFromTask(task *model.IotCardImportTask) [
func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.IotCardImportTask, batch []model.CardItem, startLine int, result *importResult) {
type cardMeta struct {
line int
msisdn string
line int
msisdn string
virtualNo string
}
validCards := make([]model.CardItem, 0)
cardMetaMap := make(map[string]cardMeta)
for i, card := range batch {
line := startLine + i
cardMetaMap[card.ICCID] = cardMeta{line: line, msisdn: card.MSISDN}
cardMetaMap[card.ICCID] = cardMeta{line: line, msisdn: card.MSISDN, virtualNo: card.VirtualNo}
validationResult := validator.ValidateICCID(card.ICCID, task.CarrierType)
if !validationResult.Valid {
@@ -282,12 +284,56 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
return
}
iotCards := make([]*model.IotCard, 0, len(newCards))
now := time.Now()
// 批量检查 virtual_no 唯一性
virtualNos := make([]string, 0)
for _, card := range newCards {
if card.VirtualNo != "" {
virtualNos = append(virtualNos, card.VirtualNo)
}
}
existingVirtualNos := make(map[string]bool)
if len(virtualNos) > 0 {
existingVirtualNos, err = h.iotCardStore.ExistsByVirtualNoBatch(ctx, virtualNos)
if err != nil {
h.logger.Error("批量检查 virtual_no 是否存在失败",
zap.Error(err),
zap.Int("batch_size", len(virtualNos)),
)
}
}
// 批内去重:记录本批次已分配的 virtual_no
batchUsedVirtualNos := make(map[string]bool)
finalCards := make([]model.CardItem, 0, len(newCards))
for _, card := range newCards {
meta := cardMetaMap[card.ICCID]
if card.VirtualNo != "" {
if existingVirtualNos[card.VirtualNo] || batchUsedVirtualNos[card.VirtualNo] {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: meta.line,
ICCID: card.ICCID,
MSISDN: meta.msisdn,
Reason: "virtual_no 已被占用: " + card.VirtualNo,
})
result.failCount++
continue
}
batchUsedVirtualNos[card.VirtualNo] = true
}
finalCards = append(finalCards, card)
}
if len(finalCards) == 0 {
return
}
iotCards := make([]*model.IotCard, 0, len(finalCards))
now := time.Now()
for _, card := range finalCards {
iotCard := &model.IotCard{
ICCID: card.ICCID,
MSISDN: card.MSISDN,
VirtualNo: card.VirtualNo,
CarrierID: task.CarrierID,
BatchNo: task.BatchNo,
Status: constants.IotCardStatusInStock,
@@ -308,7 +354,7 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
zap.Error(err),
zap.Int("batch_size", len(iotCards)),
)
for _, card := range newCards {
for _, card := range finalCards {
meta := cardMetaMap[card.ICCID]
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: meta.line,
@@ -321,9 +367,8 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
return
}
result.successCount += len(newCards)
result.successCount += len(finalCards)
// 通知轮询系统:批量卡已创建
if h.pollingCallback != nil {
h.pollingCallback.OnBatchCardsCreated(ctx, iotCards)
}

View File

@@ -757,6 +757,8 @@ func (h *PollingHandler) requeueCard(ctx context.Context, cardID uint, taskType
intervalSeconds = 600 // 默认 10 分钟
case constants.TaskTypePollingPackage:
intervalSeconds = 600 // 默认 10 分钟
case constants.TaskTypePollingProtect:
intervalSeconds = 300 // 默认 5 分钟
default:
return nil
}
@@ -773,6 +775,8 @@ func (h *PollingHandler) requeueCard(ctx context.Context, cardID uint, taskType
queueKey = constants.RedisPollingQueueCarddataKey()
case constants.TaskTypePollingPackage:
queueKey = constants.RedisPollingQueuePackageKey()
case constants.TaskTypePollingProtect:
queueKey = constants.RedisPollingQueueProtectKey()
}
// 添加到队列
@@ -943,6 +947,103 @@ func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*mo
return card, nil
}
// HandleProtectConsistencyCheck 保护期一致性检查
// 检查绑定设备is_standalone=false且已实名real_name_status=2的卡
// stop 保护期 + 开机 → 调网关停机start 保护期 + 停机 → 调网关复机
func (h *PollingHandler) HandleProtectConsistencyCheck(ctx context.Context, t *asynq.Task) error {
var payload PollingTaskPayload
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
h.logger.Error("解析保护期检查任务载荷失败", zap.Error(err))
return nil
}
cardID, err := strconv.ParseUint(payload.CardID, 10, 64)
if err != nil {
h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err))
return nil
}
// 查询卡信息
var card model.IotCard
if err := h.db.WithContext(ctx).Where("id = ?", cardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
h.logger.Error("查询卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err))
return nil
}
// 未绑设备(独立卡),跳过
if card.IsStandalone {
return nil
}
// 未实名,跳过
if card.RealNameStatus != constants.RealNameStatusVerified {
return nil
}
// 查绑定设备
binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err != nil || binding == nil {
return nil
}
deviceID := binding.DeviceID
// 检查 stop 保护期:设备处于停机保护期,但卡是开机状态 → 需要停机
stopProtect := h.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() > 0
if stopProtect && card.NetworkStatus == constants.NetworkStatusOnline {
h.logger.Info("保护期一致性检查:停机保护期内发现开机卡,执行停机",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Uint("device_id", deviceID))
if h.gatewayClient != nil {
if err := h.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil {
h.logger.Error("保护期一致性停机失败",
zap.Uint("card_id", card.ID),
zap.Error(err))
return nil
}
}
h.db.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": time.Now(),
"stop_reason": "保护期一致性检查自动停机",
})
h.updateCardCache(ctx, card.ID, map[string]any{"network_status": constants.NetworkStatusOffline})
return nil
}
// 检查 start 保护期:设备处于复机保护期,但卡是停机状态 → 需要复机
startProtect := h.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() > 0
if startProtect && card.NetworkStatus == constants.NetworkStatusOffline {
h.logger.Info("保护期一致性检查:复机保护期内发现停机卡,执行复机",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Uint("device_id", deviceID))
if h.gatewayClient != nil {
if err := h.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil {
h.logger.Error("保护期一致性复机失败",
zap.Uint("card_id", card.ID),
zap.Error(err))
return nil
}
}
h.db.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(),
"stop_reason": "",
})
h.updateCardCache(ctx, card.ID, map[string]any{"network_status": constants.NetworkStatusOnline})
}
return nil
}
// triggerFirstRealnameActivation 任务 21.3-21.4: 首次实名后触发套餐激活
func (h *PollingHandler) triggerFirstRealnameActivation(ctx context.Context, cardID uint) {
// 任务 21.3: 查询该卡是否有待激活套餐

View File

@@ -0,0 +1,7 @@
-- 回滚:将 virtual_no 改回 device_no
ALTER TABLE tb_device RENAME COLUMN virtual_no TO device_no;
ALTER INDEX IF EXISTS idx_device_virtual_no RENAME TO idx_device_no;
ALTER TABLE tb_personal_customer_device RENAME COLUMN virtual_no TO device_no;

View File

@@ -0,0 +1,11 @@
-- 将 tb_device 和 tb_personal_customer_device 中的 device_no 字段改名为 virtual_no
-- 原因:统一"虚拟号"的字段命名,与 tb_iot_card.virtual_no 保持一致
-- 重命名 tb_device.device_no → virtual_no
ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no;
-- 同步重命名唯一索引(可选,保持命名一致性)
ALTER INDEX IF EXISTS idx_device_no RENAME TO idx_device_virtual_no;
-- 重命名 tb_personal_customer_device.device_no → virtual_no
ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no;

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_iot_card_virtual_no;
ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS virtual_no;

View File

@@ -0,0 +1,7 @@
ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS virtual_no VARCHAR(50);
CREATE UNIQUE INDEX IF NOT EXISTS idx_iot_card_virtual_no
ON tb_iot_card(virtual_no)
WHERE deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';
COMMENT ON COLUMN tb_iot_card.virtual_no IS '虚拟号(可空,全局唯一)';

View File

@@ -0,0 +1 @@
ALTER TABLE tb_package DROP COLUMN IF EXISTS virtual_ratio;

View File

@@ -0,0 +1,11 @@
ALTER TABLE tb_package ADD COLUMN IF NOT EXISTS virtual_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.0;
COMMENT ON COLUMN tb_package.virtual_ratio IS '虚流量比例(real_data_mb/virtual_data_mb)创建套餐时计算存储默认1.0';
UPDATE tb_package
SET virtual_ratio = CASE
WHEN enable_virtual_data = true AND virtual_data_mb > 0
THEN (real_data_mb::DECIMAL / virtual_data_mb::DECIMAL)
ELSE 1.0
END
WHERE deleted_at IS NULL;

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-14

View File

@@ -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后续调用才按类型路由。这符合"统一入口"的核心设计目标。
**查找顺序**:先查 Devicevirtual_no / imei / sn未命中再查 IotCardvirtual_no / iccid / msisdn。设备优先因为设备标识符更具体。
### 决策 2resolve 返回中等聚合版本,而非最小或最大版本
resolve 包含:基础信息 + 状态 + 当前套餐流量概况 + 保护期状态 + 绑定信息(卡←→设备)。
不做最小版本(仅返回 asset_type + id前端页面需要立即展示套餐流量避免二次请求。
不做最大版本(返回全量历史套餐):套餐历史通过独立接口按需加载,避免 resolve 过重。
### 决策 3realtime-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。
### 决策 6RefreshCardDataFromGateway 增强现有方法而非新建
`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
(无——所有关键决策已在讨论纪要中确认)

View File

@@ -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` 需同步更新

View File

@@ -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

View File

@@ -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`

View File

@@ -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路由已不存在

View File

@@ -0,0 +1,133 @@
## MODIFIED Requirements
### Requirement: 设备列表查询
系统 SHALL 提供设备列表查询功能,支持多维度筛选和分页。
**查询条件**:
- `virtual_no`(可选): 设备虚拟号,支持模糊匹配(原 `device_no` 字段,已全量改名)
- `device_name`(可选): 设备名称,支持模糊匹配
- `status`(可选): 设备状态,枚举值 1-在库 | 2-已分销 | 3-已激活 | 4-已停用
- `shop_id`(可选): 店铺 IDNULL 表示平台库存
- `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`

View File

@@ -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 和 MSISDNvirtual_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",无精度损失,无科学记数法

View File

@@ -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 值)

View File

@@ -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_mb100% 已使用)
**套餐类型说明**:
- **正式套餐(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

View File

@@ -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** 任务直接跳过,不查询设备保护期

View File

@@ -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 查 PackageUsagecard→iot_card_iddevice→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 全部任务标记完成

View 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

View 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`

View 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路由已不存在

View File

@@ -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`(可选): 店铺 IDNULL 表示平台库存
@@ -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`

View File

@@ -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 和 MSISDNvirtual_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"无精度损失无科学记数法
---

View File

@@ -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 值)

View File

@@ -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_mb100% 已使用)
**套餐类型说明**:
- **正式套餐(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
---

View 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** 任务直接跳过,不查询设备保护期

View File

@@ -56,6 +56,7 @@ const (
TaskTypePollingRealname = "polling:realname" // 实名状态检查
TaskTypePollingCarddata = "polling:carddata" // 卡流量检查
TaskTypePollingPackage = "polling:package" // 套餐流量检查
TaskTypePollingProtect = "polling:protect" // 保护期一致性检查
// 套餐激活任务类型
TaskTypePackageFirstActivation = "package:first:activation" // 首次实名激活
@@ -203,3 +204,9 @@ const (
AuthorizerTypePlatform = UserTypePlatform // 平台用户授权(2)
AuthorizerTypeAgent = UserTypeAgent // 代理账号授权(3)
)
// 设备保护期相关时长常量
const (
DeviceProtectPeriodDuration = 1 * time.Hour // 设备停/复机保护期时长1小时
DeviceRefreshCooldownDuration = 30 * time.Second // 设备网关刷新冷却时长30秒
)

View File

@@ -285,3 +285,31 @@ func RedisOrderIdempotencyKey(businessKey string) string {
func RedisOrderCreateLockKey(carrierType string, carrierID uint) string {
return fmt.Sprintf("order:create:lock:%s:%d", carrierType, carrierID)
}
// ========================================
// 设备保护期相关 Redis Key
// ========================================
// RedisDeviceProtectKey 生成设备保护期 Redis 键
// action: "stop"(停机保护期)或 "start"(复机保护期),两者互斥
// 过期时间DeviceProtectPeriodDuration1 小时)
func RedisDeviceProtectKey(deviceID uint, action string) string {
return fmt.Sprintf("protect:device:%d:%s", deviceID, action)
}
// RedisDeviceRefreshCooldownKey 生成设备刷新冷却期 Redis 键
// 用途:防止同一设备短时间内多次调网关刷新(限频)
// 过期时间DeviceRefreshCooldownDuration30 秒)
func RedisDeviceRefreshCooldownKey(deviceID uint) string {
return fmt.Sprintf("refresh:cooldown:device:%d", deviceID)
}
// ========================================
// 轮询保护期队列 Redis Key
// ========================================
// RedisPollingQueueProtectKey 保护期一致性检查轮询队列键
// 用途:存储需要进行保护期一致性检查的卡 IDZSet按时间排序
func RedisPollingQueueProtectKey() string {
return "polling:queue:protect"
}

View File

@@ -52,5 +52,6 @@ func BuildDocHandlers() *bootstrap.Handlers {
PollingAlert: admin.NewPollingAlertHandler(nil),
PollingCleanup: admin.NewPollingCleanupHandler(nil),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil),
Asset: admin.NewAssetHandler(nil, nil, nil),
}
}

View File

@@ -164,6 +164,9 @@ func (h *Handler) registerPollingHandlers() {
h.mux.HandleFunc(constants.TaskTypePollingPackage, pollingHandler.HandlePackageCheck)
h.logger.Info("注册套餐检查任务处理器", zap.String("task_type", constants.TaskTypePollingPackage))
h.mux.HandleFunc(constants.TaskTypePollingProtect, pollingHandler.HandleProtectConsistencyCheck)
h.logger.Info("注册保护期一致性检查任务处理器", zap.String("task_type", constants.TaskTypePollingProtect))
}
func (h *Handler) registerPackageActivationHandlers() {

View File

@@ -9,10 +9,11 @@ import (
"github.com/xuri/excelize/v2"
)
// CardInfo 卡信息(ICCID + MSISDN)
// CardInfo 卡信息(ICCID + MSISDN + VirtualNo)
type CardInfo struct {
ICCID string
MSISDN string
ICCID string
MSISDN string
VirtualNo string
}
// CSVParseResult Excel/CSV 解析结果
@@ -33,7 +34,7 @@ type CSVParseError struct {
// DeviceRow 设备导入数据行
type DeviceRow struct {
Line int
DeviceNo string
VirtualNo string
DeviceName string
DeviceModel string
DeviceType string
@@ -128,8 +129,8 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
row := DeviceRow{Line: lineNum}
// 提取各字段
if idx := colIndex["device_no"]; idx >= 0 && idx < len(record) {
row.DeviceNo = strings.TrimSpace(record[idx])
if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) {
row.VirtualNo = strings.TrimSpace(record[idx])
}
if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) {
row.DeviceName = strings.TrimSpace(record[idx])
@@ -161,8 +162,8 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
}
}
// 跳过设备号为空的行
if row.DeviceNo == "" {
// 跳过虚拟号为空的行
if row.VirtualNo == "" {
continue
}
@@ -204,47 +205,44 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
ParseErrors: make([]CSVParseError, 0),
}
// 检测表头 (第1行)
headerSkipped := false
iccidCol, msisdnCol := -1, -1
iccidCol, msisdnCol, virtualNoCol := -1, -1, -1
if len(rows) > 0 {
iccidCol, msisdnCol = findCardColumns(rows[0])
iccidCol, msisdnCol, virtualNoCol = findCardColumns(rows[0])
if iccidCol >= 0 && msisdnCol >= 0 {
headerSkipped = true
}
}
// 确定数据开始行
startLine := 0
if headerSkipped {
startLine = 1
}
// 解析数据行
for i := startLine; i < len(rows); i++ {
row := rows[i]
lineNum := i + 1 // Excel行号从1开始
lineNum := i + 1
// 跳过空行
if len(row) == 0 {
continue
}
// 提取字段
iccid := ""
msisdn := ""
virtualNo := ""
if iccidCol >= 0 {
// 有表头,使用列索引
if iccidCol < len(row) {
iccid = strings.TrimSpace(row[iccidCol])
}
if msisdnCol < len(row) {
msisdn = strings.TrimSpace(row[msisdnCol])
}
if virtualNoCol >= 0 && virtualNoCol < len(row) {
virtualNo = strings.TrimSpace(row[virtualNoCol])
}
} else {
// 无表头,假设第一列ICCID,第二列MSISDN
if len(row) >= 1 {
iccid = strings.TrimSpace(row[0])
}
@@ -253,11 +251,9 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
}
}
// 验证
result.TotalCount++
if iccid == "" && msisdn == "" {
// 空行,跳过
continue
}
@@ -280,45 +276,50 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
}
result.Cards = append(result.Cards, CardInfo{
ICCID: iccid,
MSISDN: msisdn,
ICCID: iccid,
MSISDN: msisdn,
VirtualNo: virtualNo,
})
}
return result, nil
}
// findCardColumns 查找ICCIDMSISDN列索引
// findCardColumns 查找ICCIDMSISDN和VirtualNo列索引
// 支持中英文列名识别
func findCardColumns(header []string) (iccidCol, msisdnCol int) {
iccidCol, msisdnCol = -1, -1
func findCardColumns(header []string) (iccidCol, msisdnCol, virtualNoCol int) {
iccidCol, msisdnCol, virtualNoCol = -1, -1, -1
for i, col := range header {
colLower := strings.ToLower(strings.TrimSpace(col))
// 识别ICCID列
if colLower == "iccid" || colLower == "卡号" || colLower == "号码" {
if iccidCol == -1 { // 只取第一个匹配
if iccidCol == -1 {
iccidCol = i
}
}
// 识别MSISDN列
if colLower == "msisdn" || colLower == "接入号" || colLower == "手机号" || colLower == "电话" {
if msisdnCol == -1 { // 只取第一个匹配
if msisdnCol == -1 {
msisdnCol = i
}
}
if colLower == "virtual_no" || colLower == "virtualno" || colLower == "虚拟号" || colLower == "设备号" {
if virtualNoCol == -1 {
virtualNoCol = i
}
}
}
return iccidCol, msisdnCol
return iccidCol, msisdnCol, virtualNoCol
}
// buildDeviceColumnIndex 构建设备导入列索引
// 识别表头中的列名,返回列名到列索引的映射
func buildDeviceColumnIndex(header []string) map[string]int {
index := map[string]int{
"device_no": -1,
"virtual_no": -1,
"device_name": -1,
"device_model": -1,
"device_type": -1,