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:
@@ -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 更新相关文档(如有必要)
|
||||
- 本次实现无需额外文档更新
|
||||
Reference in New Issue
Block a user