Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增 GET /api/admin/devices/by-imei/:imei 接口,支持通过设备号查询设备详情 - 新增 GET /api/admin/iot-cards/by-iccid/:iccid 接口,支持通过ICCID查询单卡详情 - 添加对应的 Service 层方法和 Handler - 更新 OpenAPI 文档 - 添加集成测试并修复测试环境配置(使用环境变量) - 归档已完成的 OpenSpec 变更记录 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
250 lines
8.2 KiB
Markdown
250 lines
8.2 KiB
Markdown
# 设计文档:修复设备-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. **警告信息是否需要国际化?**
|
||
- 当前设计使用中文错误信息
|
||
- 如果需要多语言支持,需要调整错误码机制
|