feat: IoT卡新增绑定设备虚拟号快照字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s
- tb_iot_card 新增 device_virtual_no 字段,存储绑定设备的虚拟号快照 - 列表/详情接口响应新增 device_virtual_no 字段 - 列表接口 is_standalone 改为可选参数(不传返回全部卡) - 移除列表接口 virtual_no 查询参数 - 绑卡/解绑时同步更新 device_virtual_no 快照 - 设备导入时批量写入 device_virtual_no 快照 - 归档 feat-iot-card-device-virtual-no 变更提案 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-09
|
||||
@@ -0,0 +1,92 @@
|
||||
## Context
|
||||
|
||||
当前 `tb_iot_card` 表有 `is_standalone` 物化列(由 DB 触发器维护),`ListStandalone` 查询硬编码 `WHERE is_standalone = true`,导致绑定设备的卡不可见。设备表 `tb_device` 有 `virtual_no` 字段,设备与卡的绑定关系存于 `tb_device_sim_binding`。
|
||||
|
||||
`is_standalone` 的引入背景是:30M 行表上 `NOT EXISTS` 子查询造成 9s+ 延迟,物化为布尔列后降至 <500ms。本次新增 `device_virtual_no` 采用相同的物化快照思路,避免 JOIN `tb_device_sim_binding` + `tb_device` 带来的性能回退。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 列表/详情接口展示绑定设备的 IoT 卡
|
||||
- 列表/详情响应新增 `device_virtual_no` 字段
|
||||
- `is_standalone` 作为可选查询参数(不传返回全部卡)
|
||||
- 移除列表接口的 `virtual_no` 查询参数
|
||||
- 绑定/解绑/设备导入时同步维护 `device_virtual_no` 快照
|
||||
|
||||
**Non-Goals:**
|
||||
- 修改路由名称(保持 `/api/admin/iot-cards/standalone`)
|
||||
- 改变已绑定设备的卡不可单独分配/回收的限制
|
||||
- 支持通过设备虚拟号搜索卡(`device_virtual_no` 不作为搜索条件)
|
||||
- 使用 DB 触发器维护 `device_virtual_no`(应用层维护)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:快照字段存在 `tb_iot_card`,由应用层维护
|
||||
|
||||
**选择:** 在 `tb_iot_card` 新增 `device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''`,应用层在 BindCard / UnbindCard / device_import 时写入/清空。
|
||||
|
||||
**备选方案:**
|
||||
- **DB 触发器**:`is_standalone` 使用触发器因为纯粹基于 `tb_device_sim_binding` 状态,`device_virtual_no` 需要跨表读 `tb_device.virtual_no`,触发器可以做但调试难度高、出错难追溯。
|
||||
- **每次 JOIN 查询**:不引入新列,查询时 JOIN。百万级数据下分页列表性能不可接受,违背 `is_standalone` 的设计初衷。
|
||||
|
||||
**结论:** 应用层快照,写入路径明确(3 处),可读性和可维护性优于触发器。
|
||||
|
||||
### 决策 2:`is_standalone` 改为可选过滤参数,Store 层支持
|
||||
|
||||
**选择:** `ListStandaloneIotCardRequest` 新增 `IsStandalone *bool`,Store 层 `ListStandalone` 仅在非 nil 时加条件,不传则不过滤。
|
||||
|
||||
**理由:** 最小改动,前端可按需传 `is_standalone=true` 保留原有"只看独立卡"场景。
|
||||
|
||||
### 决策 3:写入时机覆盖全部 3 个入口
|
||||
|
||||
| 入口 | 文件 | 操作 |
|
||||
|------|------|------|
|
||||
| `BindCard` | `internal/service/device/binding.go` | 事务外:`UPDATE tb_iot_card SET device_virtual_no = ? WHERE id = ?` |
|
||||
| `UnbindCard` | `internal/service/device/binding.go` | 事务外:`UPDATE tb_iot_card SET device_virtual_no = '' WHERE id = ?` |
|
||||
| 设备导入 | `internal/task/device_import.go` | 事务内:批量 `UPDATE tb_iot_card SET device_virtual_no = ? WHERE id IN (?)` |
|
||||
|
||||
**说明:** BindCard / UnbindCard 中更新卡字段无需在事务内(绑定记录已提交),失败仅影响展示字段,不影响业务一致性;设备导入在事务内批量更新效率更高。
|
||||
|
||||
### 决策 4:Store 层新增 `UpdateDeviceVirtualNo` 方法
|
||||
|
||||
在 `IotCardStore` 新增:
|
||||
```go
|
||||
// UpdateDeviceVirtualNo 更新卡的设备虚拟号快照
|
||||
func (s *IotCardStore) UpdateDeviceVirtualNo(ctx context.Context, cardID uint, deviceVirtualNo string) error
|
||||
// BatchUpdateDeviceVirtualNo 批量更新卡的设备虚拟号快照(设备导入时使用)
|
||||
func (s *IotCardStore) BatchUpdateDeviceVirtualNo(ctx context.Context, cardIDs []uint, deviceVirtualNo string) error
|
||||
```
|
||||
|
||||
Device Service 需要注入 `IotCardStore`(目前已有),通过该方法更新,不直接操作 GORM。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[快照不一致]** 极端情况下(写 BindCard 成功但更新 `device_virtual_no` 失败)会出现短暂不一致。
|
||||
→ 缓解:日志记录失败,不 panic;最终一致性由运维工具或重新触发绑定修复。实际概率极低(同一进程内两次 DB 写入)。
|
||||
|
||||
**[历史数据回填]** 迁移 SQL 执行期间可能短暂锁表(大表 UPDATE)。
|
||||
→ 缓解:使用 `UPDATE ... WHERE device_virtual_no = ''` 分批或一次性(数据量视实际情况),低峰期执行。
|
||||
|
||||
**[Device Service 依赖 IotCardStore]** device binding.go 需要调用 IotCardStore 方法,形成跨 Service 包的 Store 依赖。
|
||||
→ 接受:当前 `device.Service` 已注入 `iotCardStore`(用于绑定验证),添加新方法调用不引入新依赖。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 执行新迁移:`ALTER TABLE tb_iot_card ADD COLUMN device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''`
|
||||
2. 回填数据:
|
||||
```sql
|
||||
UPDATE tb_iot_card c
|
||||
SET device_virtual_no = d.virtual_no
|
||||
FROM tb_device_sim_binding b
|
||||
JOIN tb_device d ON d.id = b.device_id
|
||||
WHERE b.iot_card_id = c.id
|
||||
AND b.bind_status = 1
|
||||
AND b.deleted_at IS NULL
|
||||
AND d.deleted_at IS NULL;
|
||||
```
|
||||
3. 部署新代码(BindCard / UnbindCard / device_import 均写入快照)
|
||||
4. 回滚:删除列即可(`ALTER TABLE tb_iot_card DROP COLUMN device_virtual_no`),代码回滚到旧版本
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。所有关键决策已在探索阶段与用户确认。
|
||||
@@ -0,0 +1,42 @@
|
||||
## Why
|
||||
|
||||
`/api/admin/iot-cards/standalone` 当前使用硬编码 `WHERE is_standalone = true` 过滤,导致已绑定设备的 IoT 卡完全不可见。运营人员无法在卡管列表中看到这些卡,也无法通过列表查询某张卡目前归属于哪台设备,造成管理盲区。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **移除硬编码 `is_standalone = true` 过滤**:`is_standalone` 改为可选查询参数,不传时返回全部卡(含绑定设备的卡)
|
||||
- **移除 `virtual_no` 列表过滤参数**:不再支持按卡虚拟号模糊搜索
|
||||
- **新增 `device_virtual_no` 冗余快照字段**:在 `tb_iot_card` 表新增 `device_virtual_no` 列,记录当前绑定设备的虚拟号(无绑定时为空字符串)
|
||||
- **响应新增 `device_virtual_no` 字段**:列表和详情接口均返回对应的设备虚拟号
|
||||
- **写入时机快照**:BindCard / UnbindCard / 设备导入(device_import)时同步写入或清空该字段
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `iot-card-device-snapshot`:IoT 卡列表展示绑定设备虚拟号的能力——包括 `device_virtual_no` 快照字段的数据库维护、列表/详情响应扩展、以及 `is_standalone` 可选过滤。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(无现有 spec 级别的行为变更,仅实现层调整)
|
||||
|
||||
## Impact
|
||||
|
||||
**数据库**
|
||||
- `tb_iot_card` 新增列 `device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''`
|
||||
- 迁移时回填现有绑定数据
|
||||
|
||||
**API**
|
||||
- `GET /api/admin/iot-cards/standalone`
|
||||
- 查询参数:移除 `virtual_no`,新增 `is_standalone`(可选布尔)
|
||||
- 响应:新增 `device_virtual_no` 字段
|
||||
- `GET /api/admin/iot-cards/standalone/:iccid`(详情)
|
||||
- 响应:新增 `device_virtual_no` 字段
|
||||
|
||||
**代码**
|
||||
- `internal/model/iot_card.go`:新增字段
|
||||
- `internal/model/dto/iot_card_dto.go`:修改 Request / Response DTO
|
||||
- `internal/store/postgres/iot_card_store.go`:移除硬编码过滤,支持可选 `is_standalone` 条件
|
||||
- `internal/service/iot_card/service.go`:传递新过滤参数,填充响应字段
|
||||
- `internal/service/device/binding.go`:BindCard / UnbindCard 时更新卡字段
|
||||
- `internal/task/device_import.go`:导入时批量写入 `device_virtual_no`
|
||||
@@ -0,0 +1,59 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: IoT 卡列表展示绑定设备的虚拟号
|
||||
`tb_iot_card` 表 SHALL 新增 `device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''` 列,存储当前绑定设备的虚拟号快照。未绑定设备时该字段为空字符串。列表和详情接口 SHALL 在响应中返回 `device_virtual_no` 字段。
|
||||
|
||||
#### Scenario: 未绑定设备的卡响应 device_virtual_no 为空
|
||||
- **WHEN** 请求 `GET /api/admin/iot-cards/standalone` 列表
|
||||
- **THEN** `is_standalone = true` 的卡响应中 `device_virtual_no` 为空字符串 `""`
|
||||
|
||||
#### Scenario: 已绑定设备的卡响应包含设备虚拟号
|
||||
- **WHEN** 请求 `GET /api/admin/iot-cards/standalone` 列表(不带 `is_standalone` 参数)
|
||||
- **THEN** `is_standalone = false` 的卡也出现在结果中,且 `device_virtual_no` 等于绑定设备的 `virtual_no`
|
||||
|
||||
#### Scenario: 详情接口包含设备虚拟号
|
||||
- **WHEN** 请求 `GET /api/admin/iot-cards/standalone/:iccid` 且该卡绑定了设备
|
||||
- **THEN** 响应中 `device_virtual_no` 等于绑定设备的 `virtual_no`
|
||||
|
||||
### Requirement: `is_standalone` 作为可选查询参数
|
||||
列表接口 `GET /api/admin/iot-cards/standalone` SHALL 接受可选布尔参数 `is_standalone`。不传时 SHALL 返回全部卡(含绑定设备的卡);传 `true` 时仅返回未绑定设备的卡;传 `false` 时仅返回已绑定设备的卡。
|
||||
|
||||
#### Scenario: 不传 is_standalone 返回全部卡
|
||||
- **WHEN** 请求列表且不携带 `is_standalone` 参数
|
||||
- **THEN** 返回结果包含独立卡和绑定设备的卡
|
||||
|
||||
#### Scenario: 传 is_standalone=true 仅返回独立卡
|
||||
- **WHEN** 请求列表携带 `is_standalone=true`
|
||||
- **THEN** 返回结果只包含 `is_standalone = true` 的卡
|
||||
|
||||
#### Scenario: 传 is_standalone=false 仅返回绑定卡
|
||||
- **WHEN** 请求列表携带 `is_standalone=false`
|
||||
- **THEN** 返回结果只包含 `is_standalone = false` 的卡
|
||||
|
||||
### Requirement: 移除列表接口的 virtual_no 查询参数
|
||||
`GET /api/admin/iot-cards/standalone` 请求 SHALL NOT 再接受 `virtual_no` 查询参数。
|
||||
|
||||
#### Scenario: 传入 virtual_no 参数不产生过滤效果
|
||||
- **WHEN** 请求列表携带 `virtual_no=xxx`
|
||||
- **THEN** 参数被忽略,接口正常返回全部卡(不报错,不过滤)
|
||||
|
||||
### Requirement: 绑定设备时快照写入 device_virtual_no
|
||||
执行 `BindCard`(POST `/api/admin/devices/:virtual_no/cards`)成功后,系统 SHALL 将被绑定卡的 `device_virtual_no` 更新为设备的 `virtual_no`。
|
||||
|
||||
#### Scenario: 绑卡后卡的 device_virtual_no 被写入
|
||||
- **WHEN** 成功调用 BindCard 将 IoT 卡绑定到设备
|
||||
- **THEN** `tb_iot_card.device_virtual_no` = 对应设备的 `virtual_no`
|
||||
|
||||
### Requirement: 解绑设备时清空 device_virtual_no
|
||||
执行 `UnbindCard`(DELETE `/api/admin/devices/:virtual_no/cards/:iccid`)成功后,系统 SHALL 将被解绑卡的 `device_virtual_no` 清空为空字符串。
|
||||
|
||||
#### Scenario: 解绑后卡的 device_virtual_no 被清空
|
||||
- **WHEN** 成功调用 UnbindCard 将 IoT 卡从设备解绑
|
||||
- **THEN** `tb_iot_card.device_virtual_no` = `""`
|
||||
|
||||
### Requirement: 设备导入时批量快照 device_virtual_no
|
||||
设备导入任务处理成功绑定关系后,系统 SHALL 批量将被绑定卡的 `device_virtual_no` 更新为该设备的 `virtual_no`。
|
||||
|
||||
#### Scenario: 设备导入时绑定的卡 device_virtual_no 被写入
|
||||
- **WHEN** 设备导入 Excel 中某行包含设备虚拟号和一组 ICCID,导入任务处理成功
|
||||
- **THEN** 该组 ICCID 对应卡的 `device_virtual_no` = 设备的 `virtual_no`
|
||||
@@ -0,0 +1,39 @@
|
||||
## 1. 数据库迁移
|
||||
|
||||
- [x] 1.1 新建迁移文件 `migrations/000110_add_device_virtual_no_to_iot_card.up.sql`,添加 `device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''` 列,并附回填 SQL(JOIN `tb_device_sim_binding` + `tb_device` 更新现有绑定数据)
|
||||
- [x] 1.2 新建对应 down 文件 `migrations/000110_add_device_virtual_no_to_iot_card.down.sql`,执行 `DROP COLUMN device_virtual_no`
|
||||
- [x] 1.3 执行迁移:`make migrate-up`,确认列存在且回填数据正确
|
||||
|
||||
## 2. Model 层
|
||||
|
||||
- [x] 2.1 在 `internal/model/iot_card.go` 的 `IotCard` 结构体新增字段 `DeviceVirtualNo string`,添加正确的 GORM tag 和中文注释
|
||||
|
||||
## 3. Store 层
|
||||
|
||||
- [x] 3.1 在 `internal/store/postgres/iot_card_store.go` 的 `ListStandalone` 方法中,将硬编码的 `WHERE is_standalone = true` 改为:仅当 filters["is_standalone"] 非 nil 时才拼接该条件
|
||||
- [x] 3.2 在 `IotCardStore` 新增方法 `UpdateDeviceVirtualNo(ctx, cardID uint, deviceVirtualNo string) error`,使用 GORM `Updates` 单卡更新
|
||||
- [x] 3.3 在 `IotCardStore` 新增方法 `BatchUpdateDeviceVirtualNo(ctx, cardIDs []uint, deviceVirtualNo string) error`,使用 GORM `WHERE id IN (?)` 批量更新
|
||||
|
||||
## 4. DTO 层
|
||||
|
||||
- [x] 4.1 在 `internal/model/dto/iot_card_dto.go` 的 `ListStandaloneIotCardRequest` 中:删除 `VirtualNo` 字段,新增 `IsStandalone *bool` 字段(含 query tag 和 description)
|
||||
- [x] 4.2 在 `StandaloneIotCardResponse` 中新增 `DeviceVirtualNo string` 字段(含 json tag 和 description)
|
||||
|
||||
## 5. Service 层 - IoT 卡列表
|
||||
|
||||
- [x] 5.1 在 `internal/service/iot_card/service.go` 的 `ListStandalone` 方法中:删除对 `req.VirtualNo` 的处理,改为读取 `req.IsStandalone` 并写入 `filters["is_standalone"]`
|
||||
- [x] 5.2 在 `toStandaloneResponse` 方法中新增 `DeviceVirtualNo: card.DeviceVirtualNo` 字段赋值
|
||||
|
||||
## 6. Service 层 - 绑定/解绑快照
|
||||
|
||||
- [x] 6.1 在 `internal/service/device/binding.go` 的 `BindCard` 方法中,绑定记录创建成功后,调用 `s.iotCardStore.UpdateDeviceVirtualNo(ctx, card.ID, device.VirtualNo)`;失败仅记录 warn 日志,不阻断主流程
|
||||
- [x] 6.2 在 `internal/service/device/binding.go` 的 `UnbindCard` 方法中,解绑成功后,调用 `s.iotCardStore.UpdateDeviceVirtualNo(ctx, cardID, "")`;失败仅记录 warn 日志,不阻断主流程
|
||||
|
||||
## 7. Task 层 - 设备导入快照
|
||||
|
||||
- [x] 7.1 在 `internal/task/device_import.go` 的事务块内,创建绑定记录后,收集所有 `validCardIDs`,调用 `h.iotCardStore.BatchUpdateDeviceVirtualNo(ctx, validCardIDs, device.VirtualNo)`(在同一事务中执行)
|
||||
|
||||
## 8. 构建验证
|
||||
|
||||
- [x] 8.1 运行 `go build ./...` 确认无编译错误
|
||||
- [x] 8.2 运行 `bash scripts/check-all.sh` 确认无规范违规(check 失败项为已有历史问题,本次变更未引入新违规)
|
||||
59
openspec/specs/iot-card-device-snapshot/spec.md
Normal file
59
openspec/specs/iot-card-device-snapshot/spec.md
Normal file
@@ -0,0 +1,59 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: IoT 卡列表展示绑定设备的虚拟号
|
||||
`tb_iot_card` 表 SHALL 新增 `device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''` 列,存储当前绑定设备的虚拟号快照。未绑定设备时该字段为空字符串。列表和详情接口 SHALL 在响应中返回 `device_virtual_no` 字段。
|
||||
|
||||
#### Scenario: 未绑定设备的卡响应 device_virtual_no 为空
|
||||
- **WHEN** 请求 `GET /api/admin/iot-cards/standalone` 列表
|
||||
- **THEN** `is_standalone = true` 的卡响应中 `device_virtual_no` 为空字符串 `""`
|
||||
|
||||
#### Scenario: 已绑定设备的卡响应包含设备虚拟号
|
||||
- **WHEN** 请求 `GET /api/admin/iot-cards/standalone` 列表(不带 `is_standalone` 参数)
|
||||
- **THEN** `is_standalone = false` 的卡也出现在结果中,且 `device_virtual_no` 等于绑定设备的 `virtual_no`
|
||||
|
||||
#### Scenario: 详情接口包含设备虚拟号
|
||||
- **WHEN** 请求 `GET /api/admin/iot-cards/standalone/:iccid` 且该卡绑定了设备
|
||||
- **THEN** 响应中 `device_virtual_no` 等于绑定设备的 `virtual_no`
|
||||
|
||||
### Requirement: `is_standalone` 作为可选查询参数
|
||||
列表接口 `GET /api/admin/iot-cards/standalone` SHALL 接受可选布尔参数 `is_standalone`。不传时 SHALL 返回全部卡(含绑定设备的卡);传 `true` 时仅返回未绑定设备的卡;传 `false` 时仅返回已绑定设备的卡。
|
||||
|
||||
#### Scenario: 不传 is_standalone 返回全部卡
|
||||
- **WHEN** 请求列表且不携带 `is_standalone` 参数
|
||||
- **THEN** 返回结果包含独立卡和绑定设备的卡
|
||||
|
||||
#### Scenario: 传 is_standalone=true 仅返回独立卡
|
||||
- **WHEN** 请求列表携带 `is_standalone=true`
|
||||
- **THEN** 返回结果只包含 `is_standalone = true` 的卡
|
||||
|
||||
#### Scenario: 传 is_standalone=false 仅返回绑定卡
|
||||
- **WHEN** 请求列表携带 `is_standalone=false`
|
||||
- **THEN** 返回结果只包含 `is_standalone = false` 的卡
|
||||
|
||||
### Requirement: 移除列表接口的 virtual_no 查询参数
|
||||
`GET /api/admin/iot-cards/standalone` 请求 SHALL NOT 再接受 `virtual_no` 查询参数。
|
||||
|
||||
#### Scenario: 传入 virtual_no 参数不产生过滤效果
|
||||
- **WHEN** 请求列表携带 `virtual_no=xxx`
|
||||
- **THEN** 参数被忽略,接口正常返回全部卡(不报错,不过滤)
|
||||
|
||||
### Requirement: 绑定设备时快照写入 device_virtual_no
|
||||
执行 `BindCard`(POST `/api/admin/devices/:virtual_no/cards`)成功后,系统 SHALL 将被绑定卡的 `device_virtual_no` 更新为设备的 `virtual_no`。
|
||||
|
||||
#### Scenario: 绑卡后卡的 device_virtual_no 被写入
|
||||
- **WHEN** 成功调用 BindCard 将 IoT 卡绑定到设备
|
||||
- **THEN** `tb_iot_card.device_virtual_no` = 对应设备的 `virtual_no`
|
||||
|
||||
### Requirement: 解绑设备时清空 device_virtual_no
|
||||
执行 `UnbindCard`(DELETE `/api/admin/devices/:virtual_no/cards/:iccid`)成功后,系统 SHALL 将被解绑卡的 `device_virtual_no` 清空为空字符串。
|
||||
|
||||
#### Scenario: 解绑后卡的 device_virtual_no 被清空
|
||||
- **WHEN** 成功调用 UnbindCard 将 IoT 卡从设备解绑
|
||||
- **THEN** `tb_iot_card.device_virtual_no` = `""`
|
||||
|
||||
### Requirement: 设备导入时批量快照 device_virtual_no
|
||||
设备导入任务处理成功绑定关系后,系统 SHALL 批量将被绑定卡的 `device_virtual_no` 更新为该设备的 `virtual_no`。
|
||||
|
||||
#### Scenario: 设备导入时绑定的卡 device_virtual_no 被写入
|
||||
- **WHEN** 设备导入 Excel 中某行包含设备虚拟号和一组 ICCID,导入任务处理成功
|
||||
- **THEN** 该组 ICCID 对应卡的 `device_virtual_no` = 设备的 `virtual_no`
|
||||
Reference in New Issue
Block a user