Files
huang 477a9fc98d
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat: 添加设备IMEI和单卡ICCID查询接口
- 新增 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>
2026-01-27 09:59:54 +08:00

8.2 KiB
Raw Permalink Blame History

设计文档:修复设备-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 异步批量导入设备并绑定卡

现有数据库约束

-- 已存在:防止同一张卡同时绑定到多个设备
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

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 唯一约束冲突错误码,返回业务错误

// 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拒绝绑定并记录原因
// 归属权校验逻辑
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_countwarning_items 字段

模型变更:

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

反馈示例:

{
  "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

-- 使用 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 '警告记录详情';

回滚策略

-- 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. 警告信息是否需要国际化?

    • 当前设计使用中文错误信息
    • 如果需要多语言支持,需要调整错误码机制