Files
junhong_cmp_fiber/openspec/changes/fix-device-sim-binding-issues/design.md
huang ce0783f96e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m30s
feat: 实现设备管理和设备导入功能,修复测试问题
主要变更:
- 实现设备管理模块(创建、查询、列表、更新状态、删除)
- 实现设备批量导入功能(CSV 解析、ICCID 绑定、异步任务处理)
- 添加设备-SIM 卡绑定约束(部分唯一索引防止并发问题)
- 修复 fee_rate 数据库字段类型(numeric -> bigint)
- 修复测试数据隔离问题(基于增量断言)
- 修复集成测试中间件顺序问题
- 清理无用测试文件(PersonalCustomer、Email 相关)
- 归档 enterprise-card-authorization 变更
2026-01-26 18:05:12 +08:00

250 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档:修复设备-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. **警告信息是否需要国际化?**
- 当前设计使用中文错误信息
- 如果需要多语言支持,需要调整错误码机制