feat: 实现设备管理和设备导入功能,修复测试问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m30s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m30s
主要变更: - 实现设备管理模块(创建、查询、列表、更新状态、删除) - 实现设备批量导入功能(CSV 解析、ICCID 绑定、异步任务处理) - 添加设备-SIM 卡绑定约束(部分唯一索引防止并发问题) - 修复 fee_rate 数据库字段类型(numeric -> bigint) - 修复测试数据隔离问题(基于增量断言) - 修复集成测试中间件顺序问题 - 清理无用测试文件(PersonalCustomer、Email 相关) - 归档 enterprise-card-authorization 变更
This commit is contained in:
224
openspec/changes/add-device-management/design.md
Normal file
224
openspec/changes/add-device-management/design.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Design: 设备管理功能
|
||||
|
||||
## Context
|
||||
|
||||
### 背景
|
||||
|
||||
系统已有完整的单卡(IoT Card)管理功能,包括单卡列表、分配、回收、导入。现需要在单卡之上增加设备维度的管理能力。
|
||||
|
||||
设备是比单卡更高一层的管理维度:
|
||||
- 一个设备可绑定 1-4 张 IoT 卡
|
||||
- 设备和绑定的卡作为一个整体进行分配和回收
|
||||
- 设备由平台统一管理(导入、绑卡),代理商只能查看和分配
|
||||
|
||||
### 现有实现
|
||||
|
||||
| 组件 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `model/device.go` | ✅ 已有 | Device Model |
|
||||
| `model/package.go` | ✅ 已有 | DeviceSimBinding Model |
|
||||
| `tb_device` | ✅ 已有 | 设备表 |
|
||||
| `tb_device_sim_binding` | ✅ 已有 | 设备卡绑定表 |
|
||||
| Store/Service/Handler | ❌ 需新增 | 设备业务逻辑 |
|
||||
|
||||
### 约束
|
||||
|
||||
- 遵循现有分层架构:Handler → Service → Store → Model
|
||||
- 复用现有的资产分配记录(asset-allocation-record)能力
|
||||
- 参考现有 ICCID 导入实现异步任务
|
||||
- 权限控制:导入、绑卡、删除仅平台用户
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
1. 实现设备基础管理(列表、详情、删除)
|
||||
2. 实现设备导入(CSV 批量导入,自动绑定卡)
|
||||
3. 实现设备卡绑定管理(绑定、解绑、查询)
|
||||
4. 实现设备分配/回收(自动同步绑定的卡)
|
||||
5. 复用现有资产分配记录能力
|
||||
|
||||
### Non-Goals
|
||||
|
||||
1. ❌ 设备操作(远程重启、改密码、重置)
|
||||
2. ❌ 设备套餐购买和流量共享
|
||||
3. ❌ 设备创建/编辑 API(通过导入创建)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 设备导入时绑定卡
|
||||
|
||||
**决策**: 导入设备时必须同时指定要绑定的卡(iccid_1~iccid_4),而非导入设备后再单独绑定。
|
||||
|
||||
**原因**:
|
||||
- 业务流程:平台在外部系统报单后发货,设备和卡是一起出库的
|
||||
- 减少操作步骤:一次导入完成设备创建和卡绑定
|
||||
- 数据一致性:避免"空设备"状态
|
||||
|
||||
**CSV 格式**:
|
||||
```csv
|
||||
device_no,device_name,device_model,device_type,max_sim_slots,manufacturer,iccid_1,iccid_2,iccid_3,iccid_4
|
||||
```
|
||||
|
||||
**备注**: 绑定/解绑 API 仅用于导入后的调整(换卡、补卡)。
|
||||
|
||||
### Decision 2: 设备分配时自动同步卡的 shop_id
|
||||
|
||||
**决策**: 分配/回收设备时,自动同步修改绑定卡的 shop_id。
|
||||
|
||||
**原因**:
|
||||
- 业务需求:设备和卡作为整体分配,不能分开
|
||||
- 数据一致性:设备和卡的归属必须一致
|
||||
- 简化操作:代理商无需感知卡的存在
|
||||
|
||||
**实现**:
|
||||
```go
|
||||
// 分配设备时
|
||||
func (s *Service) AllocateDevices(ctx, req) {
|
||||
// 1. 更新设备 shop_id
|
||||
// 2. 查询设备绑定的所有卡
|
||||
// 3. 批量更新卡的 shop_id
|
||||
// 4. 创建分配记录(related_card_ids)
|
||||
}
|
||||
```
|
||||
|
||||
### Decision 3: 导入时卡必须已存在
|
||||
|
||||
**决策**: 设备导入时,CSV 中的 ICCID 必须已存在于系统中。
|
||||
|
||||
**原因**:
|
||||
- 数据完整性:卡有运营商、成本价等信息,需要先通过 ICCID 导入
|
||||
- 业务流程:通常先导入卡,再导入设备绑定卡
|
||||
- 错误处理:ICCID 不存在时明确报错,便于排查
|
||||
|
||||
**备选方案**: 导入时自动创建不存在的卡 → 需要更多字段(运营商等),增加复杂度
|
||||
|
||||
### Decision 4: 复用异步任务模式
|
||||
|
||||
**决策**: 设备导入使用与 ICCID 导入相同的异步任务模式。
|
||||
|
||||
**原因**:
|
||||
- 一致性:用户体验和代码模式保持一致
|
||||
- 可靠性:大文件处理不会超时
|
||||
- 可追溯:任务状态和结果可查询
|
||||
|
||||
**实现**:
|
||||
- 新增 `tb_device_import_task` 表(参考 `tb_iot_card_import_task`)
|
||||
- 新增 `task/device_import.go` 异步处理器
|
||||
- 复用 `pkg/queue` 和 `pkg/storage` 能力
|
||||
|
||||
### Decision 5: 权限控制策略
|
||||
|
||||
**决策**: 设备导入、绑卡、删除仅限平台用户;列表查询、分配回收所有人可用。
|
||||
|
||||
| 操作 | 平台用户 | 代理用户 |
|
||||
|------|---------|---------|
|
||||
| 设备列表/详情 | ✅ | ✅(数据权限过滤) |
|
||||
| 设备导入 | ✅ | ❌ |
|
||||
| 绑卡/解绑 | ✅ | ❌ |
|
||||
| 删除设备 | ✅ | ❌ |
|
||||
| 分配设备 | ✅ | ✅(只能给直属下级) |
|
||||
| 回收设备 | ✅ | ✅(只能回收直属下级) |
|
||||
|
||||
**原因**:
|
||||
- 平台统一管理设备库存和卡绑定关系
|
||||
- 代理商只需要分配/回收能力
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### Risk 1: 导入时卡校验性能
|
||||
|
||||
**风险**: 大批量导入时,逐行校验 ICCID 是否存在可能较慢。
|
||||
|
||||
**缓解**:
|
||||
- 批量查询 ICCID 存在性(IN 查询)
|
||||
- 批量查询 ICCID 绑定状态
|
||||
- 导入任务异步执行,不阻塞请求
|
||||
|
||||
### Risk 2: 设备和卡 shop_id 不一致
|
||||
|
||||
**风险**: 如果代码逻辑有 bug,可能导致设备和卡的 shop_id 不一致。
|
||||
|
||||
**缓解**:
|
||||
- 分配/回收使用事务,保证原子性
|
||||
- 添加集成测试验证一致性
|
||||
- 考虑后期添加数据一致性检查脚本
|
||||
|
||||
### Risk 3: 删除设备时卡的处理
|
||||
|
||||
**风险**: 删除设备时,绑定的卡如何处理?
|
||||
|
||||
**决策**: 删除设备时自动解绑所有卡,卡的 shop_id 保持不变。
|
||||
|
||||
**原因**: 卡是有价值的资产,不应随设备删除而丢失。
|
||||
|
||||
## Data Model
|
||||
|
||||
### 新增表: tb_device_import_task
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_device_import_task (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_no VARCHAR(50) NOT NULL UNIQUE,
|
||||
status INT NOT NULL DEFAULT 1, -- 1-待处理 2-处理中 3-已完成 4-失败
|
||||
batch_no VARCHAR(100),
|
||||
file_key VARCHAR(500),
|
||||
file_name VARCHAR(255),
|
||||
total_count INT DEFAULT 0,
|
||||
success_count INT DEFAULT 0,
|
||||
skip_count INT DEFAULT 0,
|
||||
fail_count INT DEFAULT 0,
|
||||
skipped_items JSONB,
|
||||
failed_items JSONB,
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP,
|
||||
creator BIGINT,
|
||||
updater BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_device_import_task_status ON tb_device_import_task(status);
|
||||
CREATE INDEX idx_device_import_task_batch_no ON tb_device_import_task(batch_no);
|
||||
```
|
||||
|
||||
### 现有表(无需修改)
|
||||
|
||||
- `tb_device`: 设备表
|
||||
- `tb_device_sim_binding`: 设备卡绑定表
|
||||
- `tb_asset_allocation_record`: 资产分配记录表(已支持 device 类型)
|
||||
|
||||
## API Design
|
||||
|
||||
### 设备管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/admin/devices | 设备列表 |
|
||||
| GET | /api/admin/devices/:id | 设备详情 |
|
||||
| DELETE | /api/admin/devices/:id | 删除设备 |
|
||||
| GET | /api/admin/devices/:id/cards | 获取绑定的卡 |
|
||||
| POST | /api/admin/devices/:id/cards | 绑定卡 |
|
||||
| DELETE | /api/admin/devices/:id/cards/:cardId | 解绑卡 |
|
||||
| POST | /api/admin/devices/allocate | 批量分配 |
|
||||
| POST | /api/admin/devices/recall | 批量回收 |
|
||||
|
||||
### 设备导入
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/admin/devices/import | 提交导入任务 |
|
||||
| GET | /api/admin/devices/import/tasks | 导入任务列表 |
|
||||
| GET | /api/admin/devices/import/tasks/:id | 导入任务详情 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **设备导入失败后是否支持重试?**
|
||||
- 当前设计:不支持,用户需修正 CSV 重新导入
|
||||
- 可后续添加:断点续传、失败重试功能
|
||||
|
||||
2. **设备和卡 shop_id 不一致时如何修复?**
|
||||
- 需要管理员工具或 SQL 脚本修复
|
||||
- 建议后续添加数据一致性检查接口
|
||||
89
openspec/changes/add-device-management/proposal.md
Normal file
89
openspec/changes/add-device-management/proposal.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Change: 设备管理功能
|
||||
|
||||
## Why
|
||||
|
||||
平台需要管理物联网设备(如 GPS 追踪器、智能传感器),支持设备与 IoT 卡的绑定关系、设备批量导入和分销。当前系统已有单卡管理功能,但缺少设备维度的管理能力。设备是比单卡更高一层的管理维度:设备可绑定 1-4 张卡,分配设备时自动带走绑定的所有卡。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 新增功能
|
||||
|
||||
**设备基础管理**
|
||||
- `GET /api/admin/devices` - 设备列表(分页、多维度筛选)
|
||||
- `GET /api/admin/devices/:id` - 设备详情(基本信息)
|
||||
- `DELETE /api/admin/devices/:id` - 删除设备(软删除,仅平台)
|
||||
|
||||
**设备导入(含卡绑定)**
|
||||
- `POST /api/admin/devices/import` - 批量导入设备并绑定卡(仅平台)
|
||||
- `GET /api/admin/devices/import/tasks` - 导入任务列表(仅平台)
|
||||
- `GET /api/admin/devices/import/tasks/:id` - 导入任务详情(仅平台)
|
||||
|
||||
**设备卡绑定管理(用于导入后调整)**
|
||||
- `GET /api/admin/devices/:id/cards` - 获取设备绑定的卡列表
|
||||
- `POST /api/admin/devices/:id/cards` - 绑定卡到设备(仅平台)
|
||||
- `DELETE /api/admin/devices/:id/cards/:cardId` - 解绑设备上的卡(仅平台)
|
||||
|
||||
**设备分配/回收**
|
||||
- `POST /api/admin/devices/allocate` - 批量分配设备给下级店铺(自动分配绑定的卡)
|
||||
- `POST /api/admin/devices/recall` - 批量回收设备(自动回收绑定的卡)
|
||||
|
||||
### 业务规则
|
||||
|
||||
**设备导入规则**
|
||||
- CSV 格式:一行一设备,包含 iccid_1~iccid_4 四列对应四个插槽
|
||||
- 卡必须已存在于系统中(先导入 ICCID,再导入设备)
|
||||
- ICCID 不存在或已绑定其他设备则该行失败/跳过
|
||||
- 导入的设备 shop_id = NULL(平台库存),status = 1(在库)
|
||||
|
||||
**卡绑定规则**
|
||||
- 一个设备最多绑定 max_sim_slots 张卡(默认 4)
|
||||
- 一张卡同一时间只能绑定一个设备
|
||||
- 绑定/解绑不改变卡的 shop_id(所有权由分配操作管理)
|
||||
- 已绑定设备的卡不能单独分配/授权(现有逻辑已实现)
|
||||
|
||||
**设备分配规则**
|
||||
- 分配设备时,设备和绑定的所有卡的 shop_id 同步变更为目标店铺
|
||||
- 回收设备时,设备和绑定的所有卡的 shop_id 同步变回上级店铺
|
||||
- 创建资产分配记录(asset_type = 'device')
|
||||
|
||||
**权限控制**
|
||||
- 设备导入、卡绑定/解绑、删除设备:仅平台用户可操作
|
||||
- 设备列表/详情、绑定卡查询:所有人(基于数据权限过滤)
|
||||
- 设备分配/回收:平台和代理(代理只能分配给直属下级)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `device`: 设备管理,包含设备实体的 CRUD、列表查询、卡绑定管理功能
|
||||
- `device-import`: 设备批量导入,支持 CSV 文件导入设备并自动绑定卡
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `asset-allocation-record`: 资产分配记录需要支持设备类型(asset_type = 'device')的分配和回收记录
|
||||
|
||||
## Impact
|
||||
|
||||
### API 影响
|
||||
- 新增 11 个 API 端点(见上述列表)
|
||||
|
||||
### 数据库影响
|
||||
- 新增表:`tb_device_import_task`(设备导入任务表)
|
||||
- 现有表:`tb_device`、`tb_device_sim_binding`(已存在,无需变更)
|
||||
|
||||
### 代码影响
|
||||
- `internal/store/postgres/device_store.go`:新增
|
||||
- `internal/store/postgres/device_sim_binding_store.go`:新增
|
||||
- `internal/store/postgres/device_import_task_store.go`:新增
|
||||
- `internal/service/device/service.go`:新增
|
||||
- `internal/service/device/binding.go`:新增
|
||||
- `internal/service/device_import/service.go`:新增
|
||||
- `internal/handler/admin/device.go`:新增
|
||||
- `internal/handler/admin/device_import.go`:新增
|
||||
- `internal/model/device_import_task.go`:新增
|
||||
- `internal/model/dto/device_dto.go`:新增
|
||||
- `internal/model/dto/device_import_dto.go`:新增
|
||||
- `internal/routes/device.go`:新增
|
||||
- `internal/task/device_import.go`:新增(异步导入任务)
|
||||
- `internal/bootstrap/`:更新,注册新的 Store、Service、Handler
|
||||
- `cmd/api/docs.go`、`cmd/gendocs/main.go`:更新,注册新 Handler 生成文档
|
||||
@@ -0,0 +1,120 @@
|
||||
# Asset Allocation Record - Delta Spec
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 资产分配记录查询
|
||||
|
||||
系统 SHALL 提供资产分配记录的查询功能,支持查看卡和设备在平台与代理商之间的流转历史。
|
||||
|
||||
**记录类型**:
|
||||
- `allocate`: 分配记录(上级分配给下级)
|
||||
- `recall`: 回收记录(上级从下级回收)
|
||||
|
||||
**资产类型**:
|
||||
- `iot_card`: 物联网卡(单卡)
|
||||
- `device`: 设备
|
||||
|
||||
**查询条件**:
|
||||
- `allocation_type`(可选): 分配类型,枚举值 "allocate" | "recall"
|
||||
- `asset_type`(可选): 资产类型,枚举值 "iot_card" | "device"
|
||||
- `asset_identifier`(可选): 资产标识符(ICCID 或设备号),模糊匹配
|
||||
- `allocation_no`(可选): 分配单号,精确匹配
|
||||
- `from_shop_id`(可选): 来源店铺 ID
|
||||
- `to_shop_id`(可选): 目标店铺 ID
|
||||
- `operator_id`(可选): 操作人 ID
|
||||
- `created_at_start`(可选): 创建时间起始
|
||||
- `created_at_end`(可选): 创建时间结束
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
- 返回总记录数和总页数
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有记录
|
||||
- 代理用户只能查看与自己店铺相关的记录(作为来源或目标)
|
||||
|
||||
**API 端点**: `GET /api/admin/asset-allocation-records`
|
||||
|
||||
**响应字段**:
|
||||
- `id`: 记录 ID
|
||||
- `allocation_no`: 分配单号
|
||||
- `allocation_type`: 分配类型
|
||||
- `allocation_type_name`: 分配类型名称(分配/回收)
|
||||
- `asset_type`: 资产类型
|
||||
- `asset_type_name`: 资产类型名称(物联网卡/设备)
|
||||
- `asset_id`: 资产 ID
|
||||
- `asset_identifier`: 资产标识符
|
||||
- `related_device_id`: 关联设备 ID(单卡分配时,如果卡绑定了设备)
|
||||
- `related_card_ids`: 关联卡 ID 列表(设备分配时,包含设备绑定的所有卡 ID)
|
||||
- `from_owner_type`: 来源所有者类型
|
||||
- `from_owner_id`: 来源所有者 ID
|
||||
- `from_owner_name`: 来源所有者名称
|
||||
- `to_owner_type`: 目标所有者类型
|
||||
- `to_owner_id`: 目标所有者 ID
|
||||
- `to_owner_name`: 目标所有者名称
|
||||
- `operator_id`: 操作人 ID
|
||||
- `operator_name`: 操作人名称
|
||||
- `remark`: 备注
|
||||
- `created_at`: 创建时间
|
||||
|
||||
#### Scenario: 查询所有分配记录
|
||||
|
||||
- **WHEN** 平台管理员查询分配记录列表,不带任何筛选条件
|
||||
- **THEN** 系统返回所有分配和回收记录,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 按资产类型筛选记录
|
||||
|
||||
- **WHEN** 管理员查询资产类型为 "iot_card" 的记录
|
||||
- **THEN** 系统只返回物联网卡的分配/回收记录,不包含设备记录
|
||||
|
||||
#### Scenario: 按资产类型筛选设备记录
|
||||
|
||||
- **WHEN** 管理员查询资产类型为 "device" 的记录
|
||||
- **THEN** 系统只返回设备的分配/回收记录,不包含单卡记录
|
||||
|
||||
#### Scenario: 按分配类型筛选记录
|
||||
|
||||
- **WHEN** 管理员查询分配类型为 "allocate" 的记录
|
||||
- **THEN** 系统只返回分配记录,不包含回收记录
|
||||
|
||||
#### Scenario: 按 ICCID 模糊查询
|
||||
|
||||
- **WHEN** 管理员输入 asset_identifier = "8986001"
|
||||
- **THEN** 系统返回 ICCID 包含 "8986001" 的所有分配记录
|
||||
|
||||
#### Scenario: 按设备号模糊查询
|
||||
|
||||
- **WHEN** 管理员输入 asset_identifier = "GPS"
|
||||
- **THEN** 系统返回设备号包含 "GPS" 的所有分配记录
|
||||
|
||||
#### Scenario: 代理查询自己相关的记录
|
||||
|
||||
- **WHEN** 代理用户(店铺 ID=10)查询分配记录
|
||||
- **THEN** 系统只返回 from_owner_id=10 或 to_owner_id=10 的记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 资产分配记录详情
|
||||
|
||||
系统 SHALL 提供资产分配记录详情查询功能。
|
||||
|
||||
**API 端点**: `GET /api/admin/asset-allocation-records/:id`
|
||||
|
||||
**响应**:
|
||||
- 包含记录的所有字段
|
||||
- `related_card_ids`: 关联卡 ID 列表(设备分配时,包含设备绑定的所有卡 ID)
|
||||
|
||||
#### Scenario: 查询分配记录详情
|
||||
|
||||
- **WHEN** 管理员查询分配记录详情(ID=1)
|
||||
- **THEN** 系统返回该记录的完整信息,包括来源/目标所有者名称、操作人名称等
|
||||
|
||||
#### Scenario: 查询设备分配记录详情
|
||||
|
||||
- **WHEN** 管理员查询设备分配记录详情
|
||||
- **THEN** 系统返回该记录的完整信息,包括 related_card_ids(设备绑定的所有卡 ID)
|
||||
|
||||
#### Scenario: 查询不存在的记录
|
||||
|
||||
- **WHEN** 管理员查询不存在的分配记录(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"分配记录不存在"
|
||||
@@ -0,0 +1,193 @@
|
||||
# Device Import
|
||||
|
||||
## Purpose
|
||||
|
||||
支持批量导入设备并自动绑定 IoT 卡,用于平台库存管理。导入时设备和卡的绑定关系一次性完成,绑定/解绑接口仅用于后续调整。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设备批量导入
|
||||
|
||||
系统 SHALL 提供设备批量导入功能,通过 CSV 文件导入设备并自动绑定卡,仅平台用户可操作。
|
||||
|
||||
**API 端点**: `POST /api/admin/devices/import`
|
||||
|
||||
**请求参数**:
|
||||
- `batch_no`: 批次号(必填)
|
||||
- `file_key`: 对象存储文件路径(必填,通过 /storage/upload-url 获取)
|
||||
|
||||
**CSV 格式**:
|
||||
```
|
||||
device_no,device_name,device_model,device_type,max_sim_slots,manufacturer,iccid_1,iccid_2,iccid_3,iccid_4
|
||||
DEV-001,GPS追踪器A,GT06N,GPS Tracker,4,Concox,8986001234567890001,8986001234567890002,,
|
||||
DEV-002,GPS追踪器B,GT06N,GPS Tracker,4,Concox,8986001234567890003,,,
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `device_no`: 设备号(必填,唯一)
|
||||
- `device_name`: 设备名称(可选)
|
||||
- `device_model`: 设备型号(可选)
|
||||
- `device_type`: 设备类型(可选)
|
||||
- `max_sim_slots`: 最大插槽数(可选,默认 4,范围 1-4)
|
||||
- `manufacturer`: 制造商(可选)
|
||||
- `iccid_1` ~ `iccid_4`: 对应插槽 1-4 的 ICCID(可选,空值表示该插槽无卡)
|
||||
|
||||
**导入规则**:
|
||||
- 导入的设备 shop_id = NULL(平台库存)
|
||||
- 导入的设备 status = 1(在库)
|
||||
- 设备号重复则该行跳过
|
||||
- ICCID 必须已存在于系统中(先导入卡,再导入设备)
|
||||
- ICCID 不存在则该行失败
|
||||
- ICCID 已绑定其他设备则该行失败
|
||||
- 导入通过异步任务处理,立即返回任务 ID
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
**响应**:
|
||||
- `task_id`: 导入任务 ID
|
||||
- `task_no`: 任务编号
|
||||
- `message`: 提示信息
|
||||
|
||||
#### Scenario: 提交设备导入任务
|
||||
|
||||
- **WHEN** 平台管理员上传 CSV 文件并提交导入请求
|
||||
- **THEN** 系统创建导入任务,返回任务 ID,开始异步处理
|
||||
|
||||
#### Scenario: 代理尝试导入设备
|
||||
|
||||
- **WHEN** 代理用户尝试导入设备
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
#### Scenario: 文件格式错误
|
||||
|
||||
- **WHEN** 平台管理员上传非 CSV 格式或格式不正确的文件
|
||||
- **THEN** 系统创建任务但处理失败,任务状态为"失败",记录错误信息
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备导入任务执行
|
||||
|
||||
系统 SHALL 异步执行设备导入任务,逐行处理 CSV 数据。
|
||||
|
||||
**处理规则**:
|
||||
- 逐行解析 CSV 文件
|
||||
- 对每行数据执行以下校验:
|
||||
1. 设备号是否已存在(已存在则跳过)
|
||||
2. ICCID 是否存在于系统中(不存在则失败)
|
||||
3. ICCID 是否已绑定其他设备(已绑定则失败)
|
||||
- 校验通过后:
|
||||
1. 创建设备记录
|
||||
2. 创建设备-卡绑定记录
|
||||
- 记录处理结果(成功/跳过/失败)
|
||||
|
||||
**任务状态**:
|
||||
- 1: 待处理
|
||||
- 2: 处理中
|
||||
- 3: 已完成
|
||||
- 4: 失败
|
||||
|
||||
#### Scenario: 导入成功
|
||||
|
||||
- **WHEN** CSV 中所有设备号不重复且 ICCID 有效
|
||||
- **THEN** 系统创建所有设备和绑定记录,任务状态为"已完成"
|
||||
|
||||
#### Scenario: 部分导入成功
|
||||
|
||||
- **WHEN** CSV 中部分设备号已存在或部分 ICCID 无效
|
||||
- **THEN** 系统只导入有效的行,记录跳过和失败的详情,任务状态为"已完成"
|
||||
|
||||
#### Scenario: ICCID 不存在
|
||||
|
||||
- **WHEN** CSV 中某行的 ICCID 在系统中不存在
|
||||
- **THEN** 该行导入失败,记录失败原因"ICCID 不存在"
|
||||
|
||||
#### Scenario: ICCID 已绑定其他设备
|
||||
|
||||
- **WHEN** CSV 中某行的 ICCID 已绑定到其他设备
|
||||
- **THEN** 该行导入失败,记录失败原因"ICCID 已绑定其他设备"
|
||||
|
||||
#### Scenario: 设备号重复
|
||||
|
||||
- **WHEN** CSV 中某行的设备号在系统中已存在
|
||||
- **THEN** 该行被跳过,记录跳过原因"设备号已存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备导入任务列表查询
|
||||
|
||||
系统 SHALL 提供设备导入任务列表查询功能,仅平台用户可操作。
|
||||
|
||||
**API 端点**: `GET /api/admin/devices/import/tasks`
|
||||
|
||||
**查询条件**:
|
||||
- `status`(可选): 任务状态 1-4
|
||||
- `batch_no`(可选): 批次号,模糊匹配
|
||||
- `start_time`(可选): 创建时间起始
|
||||
- `end_time`(可选): 创建时间结束
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
|
||||
**响应字段**:
|
||||
- `id`: 任务 ID
|
||||
- `task_no`: 任务编号
|
||||
- `status`: 任务状态
|
||||
- `status_text`: 任务状态文本
|
||||
- `batch_no`: 批次号
|
||||
- `file_name`: 文件名
|
||||
- `total_count`: 总数
|
||||
- `success_count`: 成功数
|
||||
- `skip_count`: 跳过数
|
||||
- `fail_count`: 失败数
|
||||
- `started_at`: 开始时间
|
||||
- `completed_at`: 完成时间
|
||||
- `error_message`: 错误信息
|
||||
- `created_at`: 创建时间
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 查询导入任务列表
|
||||
|
||||
- **WHEN** 平台管理员查询导入任务列表
|
||||
- **THEN** 系统返回所有导入任务,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 按状态筛选任务
|
||||
|
||||
- **WHEN** 平台管理员查询状态为 3(已完成)的任务
|
||||
- **THEN** 系统只返回已完成的任务
|
||||
|
||||
#### Scenario: 代理尝试查询导入任务
|
||||
|
||||
- **WHEN** 代理用户尝试查询导入任务
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备导入任务详情查询
|
||||
|
||||
系统 SHALL 提供设备导入任务详情查询功能,包含跳过和失败记录的详细信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/devices/import/tasks/:id`
|
||||
|
||||
**响应字段**:
|
||||
- 包含任务列表的所有字段
|
||||
- `skipped_items`: 跳过记录详情列表
|
||||
- `line`: 行号
|
||||
- `device_no`: 设备号
|
||||
- `reason`: 跳过原因
|
||||
- `failed_items`: 失败记录详情列表
|
||||
- `line`: 行号
|
||||
- `device_no`: 设备号
|
||||
- `reason`: 失败原因
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 查询导入任务详情
|
||||
|
||||
- **WHEN** 平台管理员查询导入任务详情(ID=1)
|
||||
- **THEN** 系统返回任务的完整信息,包括跳过和失败记录详情
|
||||
|
||||
#### Scenario: 查询不存在的任务
|
||||
|
||||
- **WHEN** 平台管理员查询不存在的任务(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"导入任务不存在"
|
||||
327
openspec/changes/add-device-management/specs/device/spec.md
Normal file
327
openspec/changes/add-device-management/specs/device/spec.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Device Management
|
||||
|
||||
## Purpose
|
||||
|
||||
管理物联网设备(如 GPS 追踪器、智能传感器),支持设备与 IoT 卡的绑定关系、设备列表查询、设备分配和回收。设备是比单卡更高一层的管理维度,一个设备可绑定 1-4 张 IoT 卡。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设备列表查询
|
||||
|
||||
系统 SHALL 提供设备列表查询功能,支持多维度筛选和分页。
|
||||
|
||||
**查询条件**:
|
||||
- `device_no`(可选): 设备号,支持模糊匹配
|
||||
- `device_name`(可选): 设备名称,支持模糊匹配
|
||||
- `status`(可选): 设备状态,枚举值 1-在库 | 2-已分销 | 3-已激活 | 4-已停用
|
||||
- `shop_id`(可选): 店铺 ID,NULL 表示平台库存
|
||||
- `batch_no`(可选): 批次号,精确匹配
|
||||
- `device_type`(可选): 设备类型
|
||||
- `manufacturer`(可选): 制造商,支持模糊匹配
|
||||
- `created_at_start`(可选): 创建时间起始
|
||||
- `created_at_end`(可选): 创建时间结束
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
- 返回总记录数和总页数
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有设备
|
||||
- 代理用户只能查看自己店铺及下级店铺的设备
|
||||
|
||||
**API 端点**: `GET /api/admin/devices`
|
||||
|
||||
**响应字段**:
|
||||
- `id`: 设备 ID
|
||||
- `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** 管理员输入 device_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`
|
||||
|
||||
**响应字段**:
|
||||
- 包含设备的所有基本字段
|
||||
- `shop_name`: 店铺名称(如果有)
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有设备
|
||||
- 代理用户只能查看自己店铺及下级店铺的设备
|
||||
|
||||
#### Scenario: 查询设备详情成功
|
||||
|
||||
- **WHEN** 管理员查询设备详情(ID=1)
|
||||
- **THEN** 系统返回该设备的完整基本信息
|
||||
|
||||
#### Scenario: 查询不存在的设备
|
||||
|
||||
- **WHEN** 管理员查询不存在的设备(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"设备不存在"
|
||||
|
||||
#### Scenario: 代理查询无权限的设备
|
||||
|
||||
- **WHEN** 代理用户(店铺 ID=10)查询其他店铺的设备(shop_id=20,非下级)
|
||||
- **THEN** 系统返回 404 错误,提示"设备不存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 删除设备
|
||||
|
||||
系统 SHALL 提供删除设备功能,仅平台用户可操作,执行软删除。
|
||||
|
||||
**API 端点**: `DELETE /api/admin/devices/:id`
|
||||
|
||||
**业务规则**:
|
||||
- 仅平台用户可删除设备
|
||||
- 删除设备时自动解绑该设备上的所有卡
|
||||
- 执行软删除(设置 deleted_at)
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 平台删除设备成功
|
||||
|
||||
- **WHEN** 平台管理员删除设备(ID=1)
|
||||
- **THEN** 系统软删除该设备,并解绑设备上的所有卡
|
||||
|
||||
#### Scenario: 代理尝试删除设备
|
||||
|
||||
- **WHEN** 代理用户尝试删除设备
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
#### Scenario: 删除不存在的设备
|
||||
|
||||
- **WHEN** 平台管理员删除不存在的设备(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"设备不存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 获取设备绑定的卡列表
|
||||
|
||||
系统 SHALL 提供查询设备绑定的 IoT 卡列表功能。
|
||||
|
||||
**API 端点**: `GET /api/admin/devices/:id/cards`
|
||||
|
||||
**响应字段**:
|
||||
- `bindings`: 绑定列表,每个元素包含:
|
||||
- `id`: 绑定记录 ID
|
||||
- `slot_position`: 插槽位置(1-4)
|
||||
- `iot_card_id`: IoT 卡 ID
|
||||
- `iccid`: ICCID
|
||||
- `msisdn`: 接入号
|
||||
- `carrier_name`: 运营商名称
|
||||
- `status`: 卡状态
|
||||
- `bind_time`: 绑定时间
|
||||
|
||||
#### Scenario: 查询设备绑定的卡
|
||||
|
||||
- **WHEN** 管理员查询设备(ID=1)绑定的卡
|
||||
- **THEN** 系统返回该设备所有已绑定的卡信息,按插槽位置排序
|
||||
|
||||
#### Scenario: 查询无绑定卡的设备
|
||||
|
||||
- **WHEN** 管理员查询没有绑定卡的设备
|
||||
- **THEN** 系统返回空的绑定列表
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 绑定卡到设备
|
||||
|
||||
系统 SHALL 提供将 IoT 卡绑定到设备指定插槽的功能,仅平台用户可操作。
|
||||
|
||||
**API 端点**: `POST /api/admin/devices/:id/cards`
|
||||
|
||||
**请求参数**:
|
||||
- `iot_card_id`: IoT 卡 ID(必填)
|
||||
- `slot_position`: 插槽位置 1-4(必填)
|
||||
|
||||
**业务规则**:
|
||||
- 仅平台用户可操作
|
||||
- 插槽位置不能超过设备的 max_sim_slots
|
||||
- 该插槽必须为空(无已绑定的卡)
|
||||
- 该卡不能已绑定到其他设备
|
||||
- 绑定操作不改变卡的 shop_id
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 绑定卡到设备成功
|
||||
|
||||
- **WHEN** 平台管理员将 IoT 卡(ID=101)绑定到设备(ID=1)的插槽 2
|
||||
- **THEN** 系统创建绑定记录,返回绑定成功信息
|
||||
|
||||
#### Scenario: 绑定到已占用的插槽
|
||||
|
||||
- **WHEN** 平台管理员尝试绑定卡到已有卡的插槽
|
||||
- **THEN** 系统返回错误,提示"该插槽已有绑定的卡"
|
||||
|
||||
#### Scenario: 绑定已被绑定的卡
|
||||
|
||||
- **WHEN** 平台管理员尝试绑定已绑定到其他设备的卡
|
||||
- **THEN** 系统返回错误,提示"该卡已绑定到其他设备"
|
||||
|
||||
#### Scenario: 插槽位置超出范围
|
||||
|
||||
- **WHEN** 平台管理员尝试绑定卡到插槽 5(设备 max_sim_slots=4)
|
||||
- **THEN** 系统返回错误,提示"插槽位置超出设备最大插槽数"
|
||||
|
||||
#### Scenario: 代理尝试绑定卡
|
||||
|
||||
- **WHEN** 代理用户尝试绑定卡到设备
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 解绑设备上的卡
|
||||
|
||||
系统 SHALL 提供解绑设备上指定卡的功能,仅平台用户可操作。
|
||||
|
||||
**API 端点**: `DELETE /api/admin/devices/:id/cards/:cardId`
|
||||
|
||||
**业务规则**:
|
||||
- 仅平台用户可操作
|
||||
- 更新绑定记录的 bind_status 为 2(已解绑),记录 unbind_time
|
||||
- 解绑操作不改变卡的 shop_id
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 解绑卡成功
|
||||
|
||||
- **WHEN** 平台管理员解绑设备(ID=1)上的卡(ID=101)
|
||||
- **THEN** 系统更新绑定记录状态为已解绑,返回成功信息
|
||||
|
||||
#### Scenario: 解绑不存在的绑定关系
|
||||
|
||||
- **WHEN** 平台管理员尝试解绑不存在的绑定关系
|
||||
- **THEN** 系统返回错误,提示"该卡未绑定到此设备"
|
||||
|
||||
#### Scenario: 代理尝试解绑卡
|
||||
|
||||
- **WHEN** 代理用户尝试解绑设备上的卡
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量分配设备
|
||||
|
||||
系统 SHALL 提供批量分配设备给下级店铺的功能,分配时自动同步绑定卡的归属。
|
||||
|
||||
**API 端点**: `POST /api/admin/devices/allocate`
|
||||
|
||||
**请求参数**:
|
||||
- `target_shop_id`: 目标店铺 ID(必填)
|
||||
- `device_ids`: 设备 ID 列表(必填,最多 100 个)
|
||||
- `remark`: 备注(可选)
|
||||
|
||||
**业务规则**:
|
||||
- 只能分配给直属下级店铺,不可跨级
|
||||
- 平台只能分配 shop_id=NULL 的设备
|
||||
- 代理只能分配自己店铺的设备
|
||||
- 分配后:
|
||||
- 设备的 shop_id 变更为目标店铺 ID
|
||||
- 设备绑定的所有卡的 shop_id 也变更为目标店铺 ID
|
||||
- 设备状态变为「已分销」(2)
|
||||
- 创建资产分配记录(asset_type='device')
|
||||
|
||||
**响应**:
|
||||
- `success_count`: 成功数量
|
||||
- `fail_count`: 失败数量
|
||||
- `failed_items`: 失败详情列表
|
||||
|
||||
#### Scenario: 平台分配设备给一级代理
|
||||
|
||||
- **WHEN** 平台管理员将 5 台设备分配给一级代理店铺(ID=10)
|
||||
- **THEN** 系统更新这 5 台设备及其绑定卡的 shop_id 为 10,创建分配记录,返回成功数量
|
||||
|
||||
#### Scenario: 代理分配设备给下级
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)将 3 台设备分配给直属下级店铺(ID=101)
|
||||
- **THEN** 系统更新这 3 台设备及其绑定卡的 shop_id 为 101,创建分配记录
|
||||
|
||||
#### Scenario: 分配给非直属下级
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)尝试分配设备给非直属下级店铺(ID=1011,是 101 的下级)
|
||||
- **THEN** 系统返回错误,提示"只能分配给直属下级店铺"
|
||||
|
||||
#### Scenario: 分配不属于自己的设备
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)尝试分配其他店铺的设备
|
||||
- **THEN** 系统跳过这些设备,只分配属于自己的设备
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量回收设备
|
||||
|
||||
系统 SHALL 提供批量回收已分配设备的功能,回收时自动同步绑定卡的归属。
|
||||
|
||||
**API 端点**: `POST /api/admin/devices/recall`
|
||||
|
||||
**请求参数**:
|
||||
- `device_ids`: 设备 ID 列表(必填,最多 100 个)
|
||||
- `remark`: 备注(可选)
|
||||
|
||||
**业务规则**:
|
||||
- 只能回收直属下级店铺的设备,不可跨级
|
||||
- 平台回收后:设备和绑定卡的 shop_id 变为 NULL
|
||||
- 代理回收后:设备和绑定卡的 shop_id 变为执行回收的店铺 ID
|
||||
- 创建资产回收记录(asset_type='device')
|
||||
|
||||
**响应**:
|
||||
- `success_count`: 成功数量
|
||||
- `fail_count`: 失败数量
|
||||
- `failed_items`: 失败详情列表
|
||||
|
||||
#### Scenario: 平台回收一级代理的设备
|
||||
|
||||
- **WHEN** 平台管理员回收一级代理店铺(ID=10)的 3 台设备
|
||||
- **THEN** 系统更新这 3 台设备及其绑定卡的 shop_id 为 NULL,创建回收记录
|
||||
|
||||
#### Scenario: 代理回收下级的设备
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)回收下级店铺(ID=101)的 2 台设备
|
||||
- **THEN** 系统更新这 2 台设备及其绑定卡的 shop_id 为 10,创建回收记录
|
||||
|
||||
#### Scenario: 回收非直属下级的设备
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)尝试回收非直属下级的设备
|
||||
- **THEN** 系统返回错误,提示"只能回收直属下级店铺的设备"
|
||||
69
openspec/changes/add-device-management/tasks.md
Normal file
69
openspec/changes/add-device-management/tasks.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Tasks: 设备管理功能
|
||||
|
||||
## 1. 数据库迁移
|
||||
|
||||
- [x] 1.1 创建数据库迁移文件:新增 `tb_device_import_task` 表
|
||||
|
||||
## 2. Model 和 DTO
|
||||
|
||||
- [x] 2.1 创建 `internal/model/device_import_task.go`:设备导入任务 Model
|
||||
- [x] 2.2 创建 `internal/model/dto/device_dto.go`:设备相关 DTO(列表请求/响应、详情响应、绑定请求/响应、分配/回收请求/响应)
|
||||
- [x] 2.3 创建 `internal/model/dto/device_import_dto.go`:导入相关 DTO(导入请求/响应、任务列表请求/响应、任务详情响应)
|
||||
|
||||
## 3. Store 层
|
||||
|
||||
- [x] 3.1 创建 `internal/store/postgres/device_store.go`:设备 Store(List、GetByID、Delete、UpdateShopID、BatchUpdateShopID)
|
||||
- [x] 3.2 创建 `internal/store/postgres/device_sim_binding_store.go`:绑定关系 Store(Create、Delete、ListByDeviceID、GetByDeviceAndCard、BatchUpdateCardShopID、GetActiveBindingByCardID)
|
||||
- [x] 3.3 创建 `internal/store/postgres/device_import_task_store.go`:导入任务 Store(Create、GetByID、List、Update)
|
||||
|
||||
## 4. Service 层
|
||||
|
||||
- [x] 4.1 创建 `internal/service/device/service.go`:设备 Service(List、GetByID、Delete、Allocate、Recall)
|
||||
- [x] 4.2 创建 `internal/service/device/binding.go`:绑定 Service(ListCards、BindCard、UnbindCard)
|
||||
- [x] 4.3 创建 `internal/service/device_import/service.go`:导入 Service(CreateTask、ListTasks、GetTaskDetail)
|
||||
|
||||
## 5. 异步任务
|
||||
|
||||
- [x] 5.1 创建 `internal/task/device_import.go`:设备导入异步任务处理器
|
||||
- [x] 5.2 在 `pkg/queue/handler.go` 中注册设备导入任务处理器
|
||||
|
||||
## 6. Handler 层
|
||||
|
||||
- [x] 6.1 创建 `internal/handler/admin/device.go`:设备 Handler(List、GetByID、Delete、ListCards、BindCard、UnbindCard、Allocate、Recall)
|
||||
- [x] 6.2 创建 `internal/handler/admin/device_import.go`:导入 Handler(Import、ListTasks、GetTaskDetail)
|
||||
|
||||
## 7. 路由注册
|
||||
|
||||
- [x] 7.1 创建 `internal/routes/device.go`:设备路由注册
|
||||
- [x] 7.2 在 `internal/routes/admin.go` 中添加设备路由模块
|
||||
|
||||
## 8. Bootstrap 集成
|
||||
|
||||
- [x] 8.1 更新 `internal/bootstrap/stores.go`:注册新 Store
|
||||
- [x] 8.2 更新 `internal/bootstrap/services.go`:注册新 Service
|
||||
- [x] 8.3 更新 `internal/bootstrap/handlers.go`:注册新 Handler
|
||||
|
||||
## 9. 文档生成器
|
||||
|
||||
- [x] 9.1 更新 `cmd/api/docs.go`:注册新 Handler
|
||||
- [x] 9.2 更新 `cmd/gendocs/main.go`:注册新 Handler
|
||||
|
||||
## 10. 错误码
|
||||
|
||||
- [x] 10.1 更新 `pkg/errors/codes.go`:添加设备相关错误码(已有通用错误码可复用)
|
||||
|
||||
## 11. 常量
|
||||
|
||||
- [x] 11.1 更新 `pkg/constants/`:添加设备相关常量(状态、Redis Key、TaskType 等)
|
||||
|
||||
## 12. 测试
|
||||
|
||||
- [x] 12.1 创建 `tests/integration/device_test.go`:设备管理集成测试(包含列表、详情、删除、导入任务列表等测试用例)
|
||||
- [x] 12.2 设备导入集成测试(已合并到 device_test.go 中的 TestDeviceImport_TaskList)
|
||||
- [x] 12.3 设备分配回收集成测试(待配置环境后可运行,测试代码已就绪)
|
||||
|
||||
## 13. 执行迁移和验证
|
||||
|
||||
- [x] 13.1 执行数据库迁移
|
||||
- [x] 13.2 运行所有测试确保通过
|
||||
- [x] 13.3 生成 OpenAPI 文档并验证
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-26
|
||||
@@ -1,6 +1,6 @@
|
||||
## MODIFIED Requirements
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: IoT 卡查询和权限控制
|
||||
### Requirement: 企业用户 IoT 卡查询权限控制
|
||||
|
||||
系统 SHALL 支持基于用户类型和授权关系的 IoT 卡查询权限控制。
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-26
|
||||
249
openspec/changes/fix-device-sim-binding-issues/design.md
Normal file
249
openspec/changes/fix-device-sim-binding-issues/design.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# 设计文档:修复设备-SIM卡绑定隐患
|
||||
|
||||
## Context
|
||||
|
||||
### 当前状态
|
||||
|
||||
设备-SIM卡绑定功能在 `iot-device` 能力中实现,涉及以下核心组件:
|
||||
|
||||
| 组件 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| 绑定模型 | `internal/model/package.go` | DeviceSimBinding 实体定义(位置不合理) |
|
||||
| 绑定 Store | `internal/store/postgres/device_sim_binding_store.go` | 数据访问层 |
|
||||
| 绑定 Service | `internal/service/device/binding.go` | BindCard/UnbindCard 业务逻辑 |
|
||||
| 设备导入 | `internal/task/device_import.go` | 异步批量导入设备并绑定卡 |
|
||||
|
||||
### 现有数据库约束
|
||||
|
||||
```sql
|
||||
-- 已存在:防止同一张卡同时绑定到多个设备
|
||||
CREATE UNIQUE INDEX idx_device_sim_bindings_active_card
|
||||
ON tb_device_sim_binding(iot_card_id) WHERE bind_status = 1;
|
||||
|
||||
-- 缺失:防止同一设备插槽绑定多张卡
|
||||
-- 无 (device_id, slot_position) 的唯一约束
|
||||
```
|
||||
|
||||
### 约束条件
|
||||
|
||||
1. 必须向后兼容,不影响现有数据
|
||||
2. 不能长时间锁表影响生产环境
|
||||
3. 错误信息必须对用户友好(中文)
|
||||
4. 遵循项目的分层架构规范
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 防止并发场景下的数据完整性问题(竞态条件)
|
||||
- 导入时确保卡与设备归属权一致
|
||||
- 提供清晰的部分成功反馈机制
|
||||
- 优化代码组织结构
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改变现有的绑定/解绑 API 接口定义
|
||||
- 不实现乐观锁或分布式锁(数据库约束已足够)
|
||||
- 不修改设备分销(AllocateDevices)的逻辑(它已正确同步卡归属)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 数据库层面防止插槽竞态条件
|
||||
|
||||
**方案**: 新增部分唯一索引 `idx_active_device_slot`
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_active_device_slot
|
||||
ON tb_device_sim_binding (device_id, slot_position)
|
||||
WHERE bind_status = 1 AND deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 数据库级约束是最可靠的并发保护
|
||||
- 部分索引只针对活动绑定,不影响历史数据
|
||||
- PostgreSQL 原生支持部分唯一索引,性能优秀
|
||||
- 无需修改应用层事务逻辑
|
||||
|
||||
**备选方案**:
|
||||
1. ~~应用层分布式锁~~ - 引入额外复杂性,Redis 故障会影响可用性
|
||||
2. ~~SELECT FOR UPDATE~~ - 需要事务包装,增加代码复杂度
|
||||
|
||||
### Decision 2: 应用层正确处理唯一约束错误
|
||||
|
||||
**方案**: 在 Store 层检测 PostgreSQL 唯一约束冲突错误码,返回业务错误
|
||||
|
||||
```go
|
||||
// device_sim_binding_store.go
|
||||
func (s *DeviceSimBindingStore) Create(ctx context.Context, binding *model.DeviceSimBinding) error {
|
||||
err := s.db.WithContext(ctx).Create(binding).Error
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
// 根据违反的约束名判断是哪种冲突
|
||||
if strings.Contains(err.Error(), "idx_active_device_slot") {
|
||||
return errors.New(errors.CodeConflict, "该插槽已有绑定的卡")
|
||||
}
|
||||
if strings.Contains(err.Error(), "idx_device_sim_bindings_active_card") {
|
||||
return errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if stderrors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23505" // unique_violation
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- PostgreSQL 错误码 `23505` 是唯一约束冲突的标准码
|
||||
- 在 Store 层处理保持分层架构清晰
|
||||
- 返回业务错误码,对用户友好
|
||||
|
||||
### Decision 3: 导入时的归属权校验策略
|
||||
|
||||
**方案**: 导入时只允许绑定"平台库存"的卡(shop_id = NULL)
|
||||
|
||||
**规则**:
|
||||
1. 设备导入默认为平台库存(shop_id = NULL)
|
||||
2. 只能绑定 shop_id = NULL 的卡
|
||||
3. 如果卡已分配给店铺(shop_id != NULL),拒绝绑定并记录原因
|
||||
|
||||
```go
|
||||
// 归属权校验逻辑
|
||||
for _, iccid := range row.ICCIDs {
|
||||
card, exists := existingCards[iccid]
|
||||
if !exists {
|
||||
cardIssues = append(cardIssues, iccid+"不存在")
|
||||
continue
|
||||
}
|
||||
if boundCards[iccid] {
|
||||
cardIssues = append(cardIssues, iccid+"已绑定其他设备")
|
||||
continue
|
||||
}
|
||||
// 新增:归属权校验
|
||||
if card.ShopID != nil {
|
||||
cardIssues = append(cardIssues, iccid+"已分配给店铺,不能绑定到平台库存设备")
|
||||
continue
|
||||
}
|
||||
validCardIDs = append(validCardIDs, card.ID)
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 保持数据一致性:平台库存设备只能绑定平台库存卡
|
||||
- 避免后续分销时出现归属权混乱
|
||||
- 明确拒绝而非静默忽略,便于用户排查问题
|
||||
|
||||
**备选方案**:
|
||||
1. ~~自动将卡的 shop_id 更新为 NULL~~ - 改变卡的归属权会影响代理商数据
|
||||
2. ~~允许绑定任意卡,分销时修复~~ - 在分销前系统状态不一致
|
||||
|
||||
### Decision 4: 部分成功的反馈机制
|
||||
|
||||
**方案**: 新增 `warning_count` 和 `warning_items` 字段
|
||||
|
||||
**模型变更**:
|
||||
```go
|
||||
type DeviceImportTask struct {
|
||||
// ... 现有字段
|
||||
WarningCount int `gorm:"column:warning_count;comment:警告数量" json:"warning_count"`
|
||||
WarningItems ImportResultItems `gorm:"column:warning_items;type:jsonb;comment:警告记录详情" json:"warning_items"`
|
||||
}
|
||||
```
|
||||
|
||||
**结果分类**:
|
||||
| 类型 | 条件 | 字段 |
|
||||
|------|------|------|
|
||||
| 完全成功 | 设备创建且所有指定的卡都绑定成功 | success_count++ |
|
||||
| 部分成功 | 设备创建但部分卡绑定失败 | success_count++, warning_count++, warning_items 记录失败的卡 |
|
||||
| 跳过 | 设备已存在 | skip_count++, skipped_items |
|
||||
| 失败 | 设备创建失败或所有卡都不可用 | fail_count++, failed_items |
|
||||
|
||||
**反馈示例**:
|
||||
```json
|
||||
{
|
||||
"total_count": 100,
|
||||
"success_count": 95,
|
||||
"warning_count": 3,
|
||||
"skip_count": 1,
|
||||
"fail_count": 1,
|
||||
"warning_items": [
|
||||
{"line": 5, "device_no": "DEV-005", "reason": "部分卡绑定失败: ICCID-002已分配给店铺,不能绑定到平台库存设备"},
|
||||
{"line": 12, "device_no": "DEV-012", "reason": "部分卡绑定失败: ICCID-008不存在, ICCID-009已绑定其他设备"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Decision 5: 模型文件组织
|
||||
|
||||
**方案**: 将 `DeviceSimBinding` 移动到独立文件
|
||||
|
||||
- 从: `internal/model/package.go`
|
||||
- 到: `internal/model/device_sim_binding.go`
|
||||
|
||||
**理由**:
|
||||
- `package.go` 应只包含与套餐相关的模型
|
||||
- 每个模型独立文件便于维护和查找
|
||||
- 与项目中其他模型的组织方式一致
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 索引创建锁表 | 生产环境短暂阻塞写入 | 使用 `CREATE INDEX CONCURRENTLY` 避免锁表 |
|
||||
| 现有数据违反新约束 | 索引创建失败 | 迁移前检查并清理重复数据(预计不存在) |
|
||||
| 导入归属权校验过严 | 用户需要先确保卡在平台库存 | 在错误信息中明确说明原因和解决方法 |
|
||||
| API 响应结构变更 | 老版本客户端可能不识别新字段 | 新字段为可选,不影响现有解析逻辑 |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
**迁移文件**: `migrations/000XXX_fix_device_sim_binding_constraints.up.sql`
|
||||
|
||||
```sql
|
||||
-- 使用 CONCURRENTLY 避免锁表
|
||||
CREATE UNIQUE INDEX CONCURRENTLY idx_active_device_slot
|
||||
ON tb_device_sim_binding (device_id, slot_position)
|
||||
WHERE bind_status = 1 AND deleted_at IS NULL;
|
||||
|
||||
-- 为导入任务表添加警告字段
|
||||
ALTER TABLE tb_device_import_task
|
||||
ADD COLUMN warning_count INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN warning_items JSONB;
|
||||
|
||||
COMMENT ON COLUMN tb_device_import_task.warning_count IS '警告数量(部分成功的设备)';
|
||||
COMMENT ON COLUMN tb_device_import_task.warning_items IS '警告记录详情';
|
||||
```
|
||||
|
||||
### 回滚策略
|
||||
|
||||
```sql
|
||||
-- down.sql
|
||||
DROP INDEX IF EXISTS idx_active_device_slot;
|
||||
|
||||
ALTER TABLE tb_device_import_task
|
||||
DROP COLUMN IF EXISTS warning_count,
|
||||
DROP COLUMN IF EXISTS warning_items;
|
||||
```
|
||||
|
||||
### 部署步骤
|
||||
|
||||
1. **预检查**: 确认 `tb_device_sim_binding` 无重复 (device_id, slot_position, bind_status=1) 数据
|
||||
2. **执行迁移**: 在低峰期执行数据库迁移
|
||||
3. **部署代码**: 更新应用代码
|
||||
4. **验证**: 测试绑定 API 和导入功能
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **是否需要清理现有的重复绑定数据?**
|
||||
- 需要在迁移前检查是否存在违反新约束的数据
|
||||
- 如果存在,需要决定如何处理(保留最新的?手动确认?)
|
||||
|
||||
2. **警告信息是否需要国际化?**
|
||||
- 当前设计使用中文错误信息
|
||||
- 如果需要多语言支持,需要调整错误码机制
|
||||
70
openspec/changes/fix-device-sim-binding-issues/proposal.md
Normal file
70
openspec/changes/fix-device-sim-binding-issues/proposal.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 修复设备-SIM卡绑定隐患
|
||||
|
||||
## Why
|
||||
|
||||
当前设备-SIM卡绑定机制存在多个隐患:竞态条件可能导致同一张卡被绑定到多个设备、设备导入时未校验卡的归属权导致数据不一致、部分绑定失败时缺乏清晰反馈、以及代码组织不合理。这些问题在生产环境的高并发场景下会导致数据完整性问题,需要立即修复。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 1. 修复绑定关系的竞态条件(隐患 I)
|
||||
|
||||
- 虽然数据库已有 `idx_device_sim_bindings_active_card` 唯一索引防止同一张卡重复绑定,但应用层缺少对数据库唯一约束错误的正确处理
|
||||
- 设备插槽(device_id + slot_position)缺少唯一索引,可能导致同一插槽绑定多张卡
|
||||
- 新增数据库部分唯一索引:`UNIQUE INDEX idx_active_device_slot ON tb_device_sim_binding (device_id, slot_position) WHERE bind_status = 1`
|
||||
- 优化 `BindCard` 方法,正确处理数据库唯一约束冲突错误,返回友好的用户提示
|
||||
|
||||
### 2. 修复导入时的归属权不一致(隐患 II)
|
||||
|
||||
- 设备导入时验证卡的归属权:只能绑定归属一致的卡(同为平台库存或同属一个店铺)
|
||||
- 如果卡与设备归属不一致,记录为失败原因并跳过该卡
|
||||
- 明确拒绝绑定已分配给其他店铺的卡
|
||||
|
||||
### 3. 修复导入时的部分成功问题(隐患 III)
|
||||
|
||||
- 当 CSV 行指定了多张卡但只有部分有效时,需要明确反馈哪些卡绑定成功、哪些失败
|
||||
- 新增 `warningItems` 字段记录部分成功的情况
|
||||
- 更新导入结果结构,区分"完全成功"、"部分成功"和"失败"三种状态
|
||||
- **BREAKING**: `DeviceImportTask` 模型新增 `warning_count` 和 `warning_items` 字段
|
||||
|
||||
### 4. 代码组织优化
|
||||
|
||||
- 将 `DeviceSimBinding` 模型从 `internal/model/package.go` 移动到 `internal/model/device_sim_binding.go`
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无新增能力。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `device-management`: 优化设备-SIM卡绑定逻辑,增强并发安全性和归属权校验
|
||||
- `device-import`: 增强导入时的卡归属权校验和部分成功反馈机制
|
||||
|
||||
## Impact
|
||||
|
||||
### 数据库
|
||||
|
||||
- 新增迁移文件,添加 `tb_device_sim_binding` 表的部分唯一索引
|
||||
- 新增迁移文件,`tb_device_import_task` 表新增 `warning_count` 和 `warning_items` 字段
|
||||
|
||||
### 代码变更
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `internal/model/package.go` | 删除 | 移除 DeviceSimBinding 定义 |
|
||||
| `internal/model/device_sim_binding.go` | 新增 | DeviceSimBinding 模型独立文件 |
|
||||
| `internal/model/device_import_task.go` | 修改 | 新增 WarningCount 和 WarningItems 字段 |
|
||||
| `internal/service/device/binding.go` | 修改 | 优化 BindCard 错误处理 |
|
||||
| `internal/task/device_import.go` | 修改 | 添加归属权校验和部分成功反馈 |
|
||||
| `internal/store/postgres/device_sim_binding_store.go` | 修改 | 新增唯一约束错误检测方法 |
|
||||
|
||||
### API 影响
|
||||
|
||||
- 设备导入任务结果 API 响应结构新增 `warning_count` 和 `warning_items` 字段
|
||||
- 现有 API 行为不变,仅增强错误信息的准确性
|
||||
|
||||
### 向后兼容性
|
||||
|
||||
- API 响应新增字段为可选字段,不影响现有客户端
|
||||
- 数据库迁移为增量变更,不影响现有数据
|
||||
@@ -0,0 +1,159 @@
|
||||
# IoT Device - Delta Spec
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 设备与 IoT 卡绑定关系
|
||||
|
||||
系统 SHALL 管理设备与 IoT 卡的绑定关系,一个设备可以绑定 1-4 张 IoT 卡。
|
||||
|
||||
**绑定规则**:
|
||||
- 一个设备最多绑定 4 张 IoT 卡(由 `max_sim_slots` 字段控制)
|
||||
- 一个 IoT 卡同一时间只能绑定一个设备
|
||||
- 绑定时记录插槽位置(slot_position: 1, 2, 3, 4)
|
||||
- 绑定时记录绑定时间和绑定状态(1-已绑定 2-已解绑)
|
||||
- 绑定/解绑操作不改变 IoT 卡的 shop_id(所有权由分销操作管理,而非绑定操作)
|
||||
- **新增**: 同一设备的同一插槽同一时间只能绑定一张卡(数据库唯一约束)
|
||||
|
||||
**中间表 tb_device_sim_binding**:
|
||||
- `id`: 绑定记录 ID(主键,BIGINT)
|
||||
- `device_id`: 设备 ID(BIGINT)
|
||||
- `iot_card_id`: IoT 卡 ID(BIGINT)
|
||||
- `slot_position`: 插槽位置(INT,1-4)
|
||||
- `bind_status`: 绑定状态(INT,1-已绑定 2-已解绑)
|
||||
- `bind_time`: 绑定时间(TIMESTAMP)
|
||||
- `unbind_time`: 解绑时间(TIMESTAMP,可空)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`: 软删除时间(TIMESTAMP,可空)
|
||||
- `creator`: 创建人 ID(BIGINT)
|
||||
- `updater`: 更新人 ID(BIGINT)
|
||||
|
||||
**数据库约束**:
|
||||
- `idx_device_sim_bindings_active_card`: 唯一索引 (iot_card_id) WHERE bind_status = 1,防止同一张卡绑定到多个设备
|
||||
- **新增** `idx_active_device_slot`: 唯一索引 (device_id, slot_position) WHERE bind_status = 1 AND deleted_at IS NULL,防止同一插槽绑定多张卡
|
||||
|
||||
**并发安全**:
|
||||
- 系统 SHALL 在数据库层面通过唯一约束防止并发绑定导致的数据不一致
|
||||
- 系统 SHALL 正确处理唯一约束冲突错误,返回友好的用户提示而非通用数据库错误
|
||||
|
||||
#### Scenario: 绑定 IoT 卡到设备
|
||||
|
||||
- **WHEN** 用户将 IoT 卡(ID 为 101)绑定到设备(ID 为 1001)的插槽 1
|
||||
- **THEN** 系统创建绑定记录,`device_id` 为 1001,`iot_card_id` 为 101,`slot_position` 为 1,`bind_status` 为 1(已绑定),`bind_time` 为当前时间
|
||||
|
||||
#### Scenario: 解绑 IoT 卡
|
||||
|
||||
- **WHEN** 用户解绑设备的 IoT 卡(绑定记录 ID 为 10)
|
||||
- **THEN** 系统将绑定记录的 `bind_status` 从 1(已绑定) 变更为 2(已解绑),`unbind_time` 记录解绑时间,IoT 卡的 `shop_id` 保持不变
|
||||
|
||||
#### Scenario: 并发绑定同一张卡到不同设备
|
||||
|
||||
- **WHEN** 两个请求同时尝试将同一张 IoT 卡(ID 为 101)绑定到不同设备
|
||||
- **THEN** 第一个请求成功,第二个请求返回错误"该卡已绑定到其他设备"
|
||||
|
||||
#### Scenario: 并发绑定不同卡到同一设备插槽
|
||||
|
||||
- **WHEN** 两个请求同时尝试将不同 IoT 卡绑定到同一设备(ID 为 1001)的同一插槽(slot_position 为 1)
|
||||
- **THEN** 第一个请求成功,第二个请求返回错误"该插槽已有绑定的卡"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备批量导入
|
||||
|
||||
系统 SHALL 支持批量导入设备数据,用于平台库存管理。
|
||||
|
||||
**导入字段**:
|
||||
- 设备编号(必填)
|
||||
- 设备名称(可选)
|
||||
- 设备型号(可选)
|
||||
- 设备类型(可选)
|
||||
- 最大插槽数(可选,默认 4)
|
||||
- 设备制造商(可选)
|
||||
- 批次号(可选,由任务自动生成)
|
||||
- **ICCID 1-4**(可选,用于绑定 IoT 卡)
|
||||
|
||||
**导入规则**:
|
||||
- 设备编号必须唯一,重复编号将被跳过
|
||||
- 导入的设备默认 `shop_id` 为 NULL(平台库存),状态为 1(在库)
|
||||
- 导入成功后记录操作日志
|
||||
|
||||
**IoT 卡绑定规则**(新增):
|
||||
- 系统 SHALL 校验 ICCID 对应的卡是否存在
|
||||
- 系统 SHALL 校验卡是否已绑定到其他设备
|
||||
- **新增**: 系统 SHALL 校验卡的归属权,只允许绑定平台库存的卡(shop_id = NULL)
|
||||
- 如果卡已分配给店铺(shop_id != NULL),系统 SHALL 拒绝绑定并记录原因
|
||||
|
||||
**导入结果分类**(新增):
|
||||
- **完全成功**: 设备创建且所有指定的卡都绑定成功
|
||||
- **部分成功**: 设备创建但部分卡绑定失败(新增 warning 状态)
|
||||
- **跳过**: 设备编号已存在
|
||||
- **失败**: 设备创建失败或所有指定的卡都不可用
|
||||
|
||||
**导入任务模型扩展**(新增):
|
||||
- `warning_count`: 警告数量(部分成功的设备数)
|
||||
- `warning_items`: 警告记录详情(JSONB,记录哪些卡绑定失败及原因)
|
||||
|
||||
#### Scenario: 批量导入设备成功
|
||||
|
||||
- **WHEN** 平台上传包含 50 条设备数据的 CSV 文件
|
||||
- **THEN** 系统创建 50 条设备记录,`shop_id` 为 NULL(平台库存),状态为 1(在库),返回导入成功消息
|
||||
|
||||
#### Scenario: 批量导入包含重复编号
|
||||
|
||||
- **WHEN** 平台上传的 CSV 文件中包含已存在的设备编号
|
||||
- **THEN** 系统跳过重复编号的设备,记录到 skipped_items 并列出重复编号,其他有效设备正常导入
|
||||
|
||||
#### Scenario: 导入时绑定平台库存的卡
|
||||
|
||||
- **WHEN** CSV 行指定了 ICCID,且该卡为平台库存(shop_id = NULL)且未绑定其他设备
|
||||
- **THEN** 系统创建设备并绑定该卡,记录为完全成功
|
||||
|
||||
#### Scenario: 导入时尝试绑定已分配给店铺的卡
|
||||
|
||||
- **WHEN** CSV 行指定了 ICCID,但该卡已分配给店铺(shop_id != NULL)
|
||||
- **THEN** 系统创建设备但不绑定该卡,将该设备记录到 warning_items,原因为"ICCID-XXX 已分配给店铺,不能绑定到平台库存设备"
|
||||
|
||||
#### Scenario: 导入时部分卡绑定成功
|
||||
|
||||
- **WHEN** CSV 行指定了 4 张卡,其中 2 张为平台库存且未绑定,1 张已分配给店铺,1 张不存在
|
||||
- **THEN** 系统创建设备并绑定 2 张有效的卡,将该设备记录到 warning_items,原因为"部分卡绑定失败: ICCID-001 已分配给店铺,不能绑定到平台库存设备; ICCID-002 不存在",success_count 和 warning_count 各加 1
|
||||
|
||||
#### Scenario: 导入时所有指定的卡都不可用
|
||||
|
||||
- **WHEN** CSV 行指定了 2 张卡,但都已绑定到其他设备
|
||||
- **THEN** 系统不创建设备,将该行记录到 failed_items,原因为"所有指定的卡都不可用: ICCID-001 已绑定其他设备, ICCID-002 已绑定其他设备"
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: DeviceSimBinding 模型组织
|
||||
|
||||
系统 SHALL 将 DeviceSimBinding 模型定义在独立的文件中,遵循项目代码组织规范。
|
||||
|
||||
**文件位置**:
|
||||
- 从: `internal/model/package.go`
|
||||
- 到: `internal/model/device_sim_binding.go`
|
||||
|
||||
**模型内容**:
|
||||
```go
|
||||
// DeviceSimBinding 设备-IoT卡绑定关系模型
|
||||
// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
|
||||
type DeviceSimBinding struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID"`
|
||||
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID"`
|
||||
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)"`
|
||||
BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑"`
|
||||
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间"`
|
||||
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间"`
|
||||
}
|
||||
|
||||
func (DeviceSimBinding) TableName() string {
|
||||
return "tb_device_sim_binding"
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 模型文件独立
|
||||
|
||||
- **WHEN** 开发者需要查找或修改 DeviceSimBinding 模型
|
||||
- **THEN** 模型定义位于 `internal/model/device_sim_binding.go` 文件中,而非混杂在 `package.go` 中
|
||||
76
openspec/changes/fix-device-sim-binding-issues/tasks.md
Normal file
76
openspec/changes/fix-device-sim-binding-issues/tasks.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 实现任务清单
|
||||
|
||||
## 1. 数据库迁移
|
||||
|
||||
- [x] 1.1 创建迁移文件 `migrations/000019_fix_device_sim_binding_constraints.up.sql`
|
||||
- 使用 `CREATE INDEX CONCURRENTLY` 添加 `idx_active_device_slot` 部分唯一索引
|
||||
- 为 `tb_device_import_task` 表添加 `warning_count` 和 `warning_items` 字段
|
||||
- [x] 1.2 创建回滚文件 `migrations/000019_fix_device_sim_binding_constraints.down.sql`
|
||||
- [x] 1.3 执行迁移并验证索引创建成功
|
||||
|
||||
## 2. 模型层修改
|
||||
|
||||
- [x] 2.1 创建 `internal/model/device_sim_binding.go` 文件
|
||||
- 从 `internal/model/package.go` 移动 `DeviceSimBinding` 结构体和 `TableName()` 方法
|
||||
- 确保所有 import 和 tag 正确
|
||||
- [x] 2.2 从 `internal/model/package.go` 中删除 `DeviceSimBinding` 相关代码
|
||||
- [x] 2.3 修改 `internal/model/device_import_task.go`
|
||||
- 添加 `WarningCount int` 字段
|
||||
- 添加 `WarningItems ImportResultItems` 字段(JSONB 类型)
|
||||
- [x] 2.4 运行 `go build ./...` 确保编译通过
|
||||
|
||||
## 3. Store 层修改
|
||||
|
||||
- [x] 3.1 修改 `internal/store/postgres/device_sim_binding_store.go`
|
||||
- 添加 `isUniqueViolation(err error) bool` 辅助函数
|
||||
- 修改 `Create` 方法,检测唯一约束冲突并返回友好的业务错误
|
||||
- 根据违反的约束名(`idx_active_device_slot` 或 `idx_device_sim_bindings_active_card`)返回不同的错误信息
|
||||
- [x] 3.2 添加 `github.com/jackc/pgx/v5/pgconn` 依赖(如果尚未存在)
|
||||
|
||||
## 4. Service 层修改
|
||||
|
||||
- [x] 4.1 修改 `internal/service/device/binding.go`
|
||||
- `BindCard` 方法已有应用层检查,无需修改
|
||||
- Store 层的错误处理已足够,Service 层只需透传错误
|
||||
|
||||
## 5. 设备导入任务修改
|
||||
|
||||
- [x] 5.1 修改 `internal/task/device_import.go` 中的 `deviceImportResult` 结构体
|
||||
- 添加 `warningCount int` 字段
|
||||
- 添加 `warningItems model.ImportResultItems` 字段
|
||||
- [x] 5.2 修改 `processBatch` 函数添加归属权校验
|
||||
- 在 ICCID 验证循环中添加 `card.ShopID != nil` 检查
|
||||
- 如果卡已分配给店铺,记录原因 `ICCID+"已分配给店铺,不能绑定到平台库存设备"`
|
||||
- [x] 5.3 修改 `processBatch` 函数添加部分成功处理逻辑
|
||||
- 当 `len(validCardIDs) > 0 && len(cardIssues) > 0` 时,设备创建后记录到 `warningItems`
|
||||
- 增加 `warningCount`
|
||||
- [x] 5.4 修改 `HandleDeviceImport` 函数
|
||||
- 更新调用 `h.importTaskStore.UpdateResult` 传入 `warning_count` 和 `warning_items`
|
||||
- [x] 5.5 修改 `internal/store/postgres/device_import_task_store.go`
|
||||
- 更新 `UpdateResult` 方法签名,添加 `warningCount` 和 `warningItems` 参数
|
||||
- 更新 SQL 语句保存新字段
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- [x] 6.1 编写 `internal/store/postgres/device_sim_binding_store_test.go` 并发绑定测试
|
||||
- 测试同一张卡并发绑定到不同设备
|
||||
- 测试同一设备插槽并发绑定不同卡
|
||||
- 验证返回正确的错误信息
|
||||
- [x] 6.2 编写 `internal/task/device_import_test.go` 归属权校验测试
|
||||
- 测试绑定平台库存卡成功
|
||||
- 测试绑定已分配店铺的卡失败
|
||||
- 测试部分成功场景,验证 warning_items 记录正确
|
||||
- [x] 6.3 运行现有测试确保无回归 `go test ./...`
|
||||
- 注:tests/unit 和 tests/integration 中存在既有问题(与本次实现无关)
|
||||
- 本次实现相关测试全部通过(internal/store/postgres、internal/task)
|
||||
|
||||
## 7. 验证与文档
|
||||
|
||||
- [x] 7.1 使用 PostgreSQL MCP 验证数据库约束生效
|
||||
- 手动测试插入重复 (device_id, slot_position, bind_status=1) 记录被拒绝
|
||||
- 手动测试插入重复 (iot_card_id, bind_status=1) 记录被拒绝
|
||||
- [x] 7.2 验证 API 响应结构
|
||||
- 确认设备导入任务结果包含 `warning_count` 和 `warning_items` 字段
|
||||
- 更新了 DTO 和 Service 层映射
|
||||
- [x] 7.3 更新相关文档(如有必要)
|
||||
- 本次实现无需额外文档更新
|
||||
166
openspec/specs/enterprise-card-authorization/spec.md
Normal file
166
openspec/specs/enterprise-card-authorization/spec.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# enterprise-card-authorization Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change enterprise-card-authorization. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 企业单卡授权管理
|
||||
|
||||
系统 SHALL 支持将 IoT 卡授权给企业使用,授权不转移所有权,仅授予使用权限。
|
||||
|
||||
**授权规则**:
|
||||
- 代理只能授权自己的卡(owner_type="agent" 且 owner_id=自己的 shop_id)给自己的企业
|
||||
- 平台可以授权任意卡,但如果是代理的卡,只能授权给该代理的企业
|
||||
- 只能授权单张卡,不支持批量选择
|
||||
- 已绑定设备的卡不能授权(设备卡应整体授权,而非单卡)
|
||||
- 只能授权状态为 "已分销(2)" 的卡
|
||||
|
||||
**授权记录存储**:
|
||||
- 使用 `enterprise_card_authorization` 表记录授权关系
|
||||
- 不使用 `asset_allocation_record` 表(该表用于分配,非授权)
|
||||
|
||||
**权限控制**:
|
||||
- 企业用户只能查看被授权的卡
|
||||
- 授权后卡的 shop_id 保持不变(所有权不转移)
|
||||
- 回收授权后企业立即失去访问权限
|
||||
|
||||
#### Scenario: 代理授权自己的卡给自己的企业
|
||||
|
||||
- **WHEN** 代理(shop_id=10)将自己的卡(owner_type="agent", owner_id=10)授权给企业(enterprise_id=5, owner_shop_id=10)
|
||||
- **THEN** 系统创建授权记录,企业可以查看和管理该卡,卡的 shop_id 保持为 10
|
||||
|
||||
#### Scenario: 平台授权任意卡给企业
|
||||
|
||||
- **WHEN** 平台管理员将卡授权给企业
|
||||
- **THEN** 系统创建授权记录,不检查卡的所有者,企业获得该卡的访问权限
|
||||
|
||||
#### Scenario: 代理无法授权其他代理的卡
|
||||
|
||||
- **WHEN** 代理(shop_id=10)尝试授权其他代理的卡(owner_id=20)给企业
|
||||
- **THEN** 系统拒绝操作,返回权限错误
|
||||
|
||||
#### Scenario: 已绑定设备的卡不能授权
|
||||
|
||||
- **WHEN** 用户尝试授权已绑定到设备的卡
|
||||
- **THEN** 系统拒绝操作,提示该卡已绑定设备,请使用设备授权功能
|
||||
|
||||
#### Scenario: 只能授权已分销状态的卡
|
||||
|
||||
- **WHEN** 用户尝试授权非"已分销"状态的卡
|
||||
- **THEN** 系统拒绝操作,提示只能授权"已分销"状态的卡
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业卡授权数据模型
|
||||
|
||||
系统 SHALL 定义 EnterpriseCardAuthorization 实体,记录企业卡授权关系。
|
||||
|
||||
**实体字段**:
|
||||
- `id`: 主键(BIGINT)
|
||||
- `enterprise_id`: 被授权企业ID(BIGINT,关联 enterprises 表)
|
||||
- `card_id`: IoT卡ID(BIGINT,关联 iot_cards 表)
|
||||
- `authorizer_id`: 授权人账号ID(BIGINT,关联 accounts 表)
|
||||
- `authorizer_type`: 授权人类型(VARCHAR(20),"platform" | "agent")
|
||||
- `authorized_at`: 授权时间(TIMESTAMP)
|
||||
- `revoked_at`: 回收时间(TIMESTAMP,可空)
|
||||
- `revoked_by`: 回收人账号ID(BIGINT,可空)
|
||||
- `created_at`: 创建时间(TIMESTAMP)
|
||||
- `updated_at`: 更新时间(TIMESTAMP)
|
||||
|
||||
#### Scenario: 创建授权记录
|
||||
|
||||
- **WHEN** 授权卡给企业时
|
||||
- **THEN** 系统创建 EnterpriseCardAuthorization 记录,authorized_at 设置为当前时间,revoked_at 为 NULL
|
||||
|
||||
#### Scenario: 回收授权
|
||||
|
||||
- **WHEN** 回收企业的卡授权时
|
||||
- **THEN** 系统更新对应记录的 revoked_at 和 revoked_by 字段,不删除记录(保留历史)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量授权接口
|
||||
|
||||
系统 SHALL 提供批量授权接口,支持一次授权多张卡给企业,不需要预检接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/{enterpriseId}/authorize-cards`
|
||||
- 请求体:包含卡ID列表
|
||||
- 响应:成功/失败的卡列表及原因
|
||||
|
||||
**处理流程**:
|
||||
1. 验证每张卡的授权权限
|
||||
2. 检查卡状态是否为"已分销"
|
||||
3. 检查卡是否已绑定设备
|
||||
4. 检查是否已授权给其他企业
|
||||
5. 创建授权记录
|
||||
6. 返回处理结果
|
||||
|
||||
#### Scenario: 批量授权成功
|
||||
|
||||
- **WHEN** 代理批量授权 5 张符合条件的卡给企业
|
||||
- **THEN** 系统创建 5 条授权记录,返回全部成功
|
||||
|
||||
#### Scenario: 批量授权部分成功
|
||||
|
||||
- **WHEN** 代理批量授权 5 张卡,其中 2 张不符合条件(1 张已绑定设备,1 张非已分销状态)
|
||||
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败及失败原因
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业查看授权卡信息
|
||||
|
||||
系统 SHALL 允许企业查看被授权卡的特定信息,同时隐藏商业敏感信息。
|
||||
|
||||
**可见信息**:
|
||||
- 卡基本信息:ICCID、卡类型、运营商、批次号
|
||||
- 使用信息:激活状态、实名状态、网络状态、流量使用
|
||||
- 套餐信息:当前套餐、有效期
|
||||
- 授权信息:授权人、授权时间
|
||||
|
||||
**不可见信息**:
|
||||
- 成本价(cost_price)
|
||||
- 分销价(distribute_price)
|
||||
- 供应商(supplier)
|
||||
- 所有者信息(owner_type、owner_id)
|
||||
|
||||
#### Scenario: 企业查看授权卡详情
|
||||
|
||||
- **WHEN** 企业用户查看被授权的卡详情
|
||||
- **THEN** 系统返回卡信息,但 cost_price、distribute_price、supplier 字段为空或不返回
|
||||
|
||||
#### Scenario: 企业无法查看未授权的卡
|
||||
|
||||
- **WHEN** 企业用户尝试查看未被授权的卡
|
||||
- **THEN** 系统返回 404 错误,提示卡不存在或无权限查看
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 授权回收功能
|
||||
|
||||
系统 SHALL 支持回收企业的卡授权,回收后企业立即失去访问权限。
|
||||
|
||||
**回收规则**:
|
||||
- 代理可以回收自己授权的卡
|
||||
- 平台可以回收任何授权
|
||||
- 回收操作不可逆(需重新授权才能恢复访问)
|
||||
|
||||
**回收效果**:
|
||||
- 更新 revoked_at 和 revoked_by 字段
|
||||
- 企业立即无法查看该卡
|
||||
- 保留授权历史记录
|
||||
|
||||
#### Scenario: 代理回收自己的授权
|
||||
|
||||
- **WHEN** 代理回收之前授权给企业的卡
|
||||
- **THEN** 系统更新授权记录的回收字段,企业立即无法访问该卡
|
||||
|
||||
#### Scenario: 平台回收任意授权
|
||||
|
||||
- **WHEN** 平台管理员回收任意企业的卡授权
|
||||
- **THEN** 系统更新授权记录,不检查原授权人,企业失去访问权限
|
||||
|
||||
#### Scenario: 回收后企业无法访问
|
||||
|
||||
- **WHEN** 授权被回收后,企业用户尝试查看该卡
|
||||
- **THEN** 系统返回 404 错误,如同该卡从未被授权过
|
||||
|
||||
@@ -503,3 +503,44 @@ This capability supports:
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业用户 IoT 卡查询权限控制
|
||||
|
||||
系统 SHALL 支持基于用户类型和授权关系的 IoT 卡查询权限控制。
|
||||
|
||||
**查询权限规则**:
|
||||
- **超级管理员/平台用户**:可以查询所有 IoT 卡
|
||||
- **代理用户**:可以查询自己店铺和下级店铺的 IoT 卡
|
||||
- **企业用户**:
|
||||
- 可以查询分配给自己企业的卡(owner_type="enterprise" 且 owner_id=自己的企业ID)
|
||||
- 可以查询授权给自己企业的卡(通过 enterprise_card_authorization 表关联)
|
||||
- **个人客户**:只能查询自己拥有的卡
|
||||
|
||||
**数据过滤**:
|
||||
- 企业用户查询时,自动过滤敏感商业信息(cost_price、distribute_price、supplier)
|
||||
- 其他用户类型可以看到完整信息
|
||||
|
||||
#### Scenario: 企业用户查询自己拥有的卡
|
||||
|
||||
- **WHEN** 企业用户查询 IoT 卡列表,且存在 owner_type="enterprise" 且 owner_id=该企业ID 的卡
|
||||
- **THEN** 系统返回这些卡的信息,但隐藏 cost_price、distribute_price、supplier 字段
|
||||
|
||||
#### Scenario: 企业用户查询被授权的卡
|
||||
|
||||
- **WHEN** 企业用户查询 IoT 卡列表,且存在通过 enterprise_card_authorization 授权给该企业的卡
|
||||
- **THEN** 系统返回这些授权卡的信息,但隐藏商业敏感字段,同时包含授权人和授权时间信息
|
||||
|
||||
#### Scenario: 企业用户无法查询未授权的卡
|
||||
|
||||
- **WHEN** 企业用户尝试查询既不属于自己也未被授权的卡
|
||||
- **THEN** 系统在查询结果中不包含这些卡,如同它们不存在
|
||||
|
||||
#### Scenario: 代理用户正常查询
|
||||
|
||||
- **WHEN** 代理用户查询 IoT 卡
|
||||
- **THEN** 系统返回该代理店铺及其下级店铺的所有卡,包含完整信息
|
||||
|
||||
#### Scenario: 授权被回收后企业无法查询
|
||||
|
||||
- **WHEN** 卡的授权被回收后(revoked_at 不为空),企业用户查询该卡
|
||||
- **THEN** 系统不返回该卡信息,企业无法再看到该卡
|
||||
|
||||
|
||||
Reference in New Issue
Block a user