分裂iccid长度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m47s

This commit is contained in:
2026-04-21 10:55:12 +08:00
parent 9942bbc74e
commit e049080f6c
18 changed files with 883 additions and 60 deletions

View File

@@ -0,0 +1,73 @@
## MODIFIED Requirements
### Requirement: 导入任务实体定义
系统 SHALL 定义 IoT 卡导入任务(IotCardImportTask)实体,用于跟踪 IoT 卡批量导入的进度和结果。
**实体字段**:
**任务信息**:
- `id`: 任务 ID主键BIGINT
- `task_no`: 任务编号VARCHAR(50),唯一,格式: IMP-YYYYMMDD-XXXXXX
- `status`: 任务状态INT1-待处理 2-处理中 3-已完成 4-失败)
**导入参数**:
- `carrier_id`: 运营商 IDBIGINT必填
- `carrier_type`: 运营商类型VARCHAR(10)CMCC/CUCC/CTCC/CBN
- `batch_no`: 批次号VARCHAR(100),可选)
- `file_name`: 原始文件名VARCHAR(255),可选)
**待导入数据**:
- `card_list`: 待导入卡列表JSONB结构: [{iccid, msisdn}],替代原 iccid_list
**进度统计**:
- `total_count`: 总数INTCSV 文件总行数)
- `success_count`: 成功数INT成功导入的卡数量
- `skip_count`: 跳过数INT因重复等原因跳过的数量
- `fail_count`: 失败数INT因格式错误等原因失败的数量
**结果详情**:
- `skipped_items`: 跳过记录详情JSONB结构: [{line, iccid, msisdn, reason}]
- `failed_items`: 失败记录详情JSONB结构: [{line, iccid, msisdn, reason}]
**时间和错误**:
- `started_at`: 开始处理时间TIMESTAMP可空
- `completed_at`: 完成时间TIMESTAMP可空
- `error_message`: 任务级错误信息TEXT可空如文件解析失败等
**系统字段**:
- `shop_id`: 店铺 IDBIGINT可空记录发起导入的店铺
- `created_at`: 创建时间TIMESTAMP自动填充
- `updated_at`: 更新时间TIMESTAMP自动填充
- `creator`: 创建人 IDBIGINT
- `updater`: 更新人 IDBIGINT
#### Scenario: 创建导入任务
- **GIVEN** 管理员上传包含 ICCID 和 MSISDN 两列的 CSV 文件
- **WHEN** 系统解析 CSV 并创建导入任务
- **THEN** 系统创建导入任务记录,`card_list` 包含 [{iccid, msisdn}] 结构,`status` 为 1待处理
---
### Requirement: IotCard 写入时同步填充 ICCID 双列
`processBatch()` 在初始化 `model.IotCard`SHALL 调用 `utils.SplitICCID` 赋值 `ICCID19``ICCID20` 两个字段。
- `ICCID19 string` = `iccid19``SplitICCID` 返回值第一个,所有合法卡必填)
- `ICCID20 *string` = `iccid20``SplitICCID` 返回值第二个,**指针类型**19 位卡返回 `nil`GORM 写入 SQL NULL禁止赋空字符串 `""`
-`SplitICCID` 返回 `iccid19 == ""`(异常长度 ICCID不写入数据库标记为 fail此情况在 `ValidateICCID` 长度校验后理论上不应出现,作防御性保留)
`SplitICCID` 函数签名:
```go
func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
// 19 位:返回 (iccid, nil)
// 20 位:返回 (iccid[:19], &iccid)
// 其他:返回 ("", nil)
```
#### Scenario: 导入 19 位卡时填充双列
- **WHEN** 批量导入时某张卡的 ICCID 长度为 19 位
- **THEN** 写入数据库时 `iccid_19` = ICCID 原值,`iccid_20`**SQL NULL**(通过 `*string` 赋值 `nil` 实现)
#### Scenario: 导入 20 位卡时填充双列
- **WHEN** 批量导入时某张卡的 ICCID 长度为 20 位
- **THEN** 写入数据库时 `iccid_19` = ICCID 前 19 位,`iccid_20` = ICCID 原值(通过 `*string` 赋非 nil 指针实现)

View File

@@ -0,0 +1,121 @@
## ADDED Requirements
### Requirement: ICCID 双列存储
`tb_iot_card` SHALL 新增 `iccid_19 varchar(19)``iccid_20 varchar(20)` 两列。
- `iccid_19`:存储 ICCID 前 19 位(所有卡均有值,对应 19 位运营商卡即为完整 ICCID
- `iccid_20`:存储完整 20 位 ICCID仅 20 位运营商卡有值19 位运营商卡为 **SQL NULL**
- 两列均建立 Partial Index过滤 `deleted_at IS NULL``iccid_20` 额外过滤 NULL
**Go Model 类型约束**
- `ICCID19` 字段类型为 `string`(非指针,所有卡必填)
- `ICCID20` 字段类型为 **`*string`**指针19 位卡赋值 `nil` → GORM 写入 NULL禁止赋空字符串 `""`GORM 不会将其转为 NULL会破坏 Partial Index 语义)
#### Scenario: 导入 19 位运营商卡
- **WHEN** 导入 ICCID 长度为 19 位的卡
- **THEN** `iccid_19` = ICCID 原值,`iccid_20` = NULL
#### Scenario: 导入 20 位运营商卡
- **WHEN** 导入 ICCID 长度为 20 位的卡
- **THEN** `iccid_19` = ICCID 前 19 位,`iccid_20` = ICCID 原值
---
### Requirement: ICCID 按长度路由查询
系统 SHALL 根据上游传入 ICCID 的字符串长度,路由到对应列进行精确查询。
- 上游传入 19 位 → 查询 `WHERE iccid_19 = ?`
- 上游传入 20 位 → 查询 `WHERE iccid_20 = ?`
- 上游传入其他长度 → 记录 Error 日志,返回未找到,不执行查询
- 禁止跨列降级:任意列 Miss 时直接记录 Warn 日志,不再查另一列
#### Scenario: 上游传入 19 位命中 19 位卡
- **WHEN** 上游传入 19 位 ICCID数据库存在对应 19 位卡
- **THEN** 系统查询 `iccid_19` 列,精确命中,返回卡记录
#### Scenario: 上游传入 20 位命中 20 位卡
- **WHEN** 上游传入 20 位 ICCID数据库存在对应 20 位卡
- **THEN** 系统查询 `iccid_20` 列,精确命中,返回卡记录
#### Scenario: 上游传入 19 位但数据库无对应记录
- **WHEN** 上游传入 19 位 ICCID数据库中不存在匹配的 `iccid_19`
- **THEN** 系统记录 Warn 日志(含 ICCID 和 deviceID返回未找到不修改任何数据
#### Scenario: 上游传入 20 位但数据库无对应记录
- **WHEN** 上游传入 20 位 ICCID数据库中不存在匹配的 `iccid_20`
- **THEN** 系统记录 Warn 日志(含 ICCID 和 deviceID返回未找到不修改任何数据
#### Scenario: 上游传入异常长度 ICCID
- **WHEN** 上游传入长度既非 19 也非 20 的 ICCID
- **THEN** 系统记录 Error 日志,直接返回未找到,不执行数据库查询
---
### Requirement: IotCard 精确查询方法适配
`IotCardStore` 的精确查询方法 SHALL 全部适配双列查询路由策略。
受影响方法:`GetByICCID``GetByICCIDs``ExistsByICCID``ExistsByICCIDBatch`
#### Scenario: GetByICCID 单卡精确查询
- **WHEN** 调用 `GetByICCID(ctx, iccid)` 且 ICCID 长度为 19 或 20 位
- **THEN** 系统 SHALL 根据长度路由到 `iccid_19``iccid_20` 列查询,走 Partial Index
#### Scenario: GetByICCIDs 批量精确查询
- **WHEN** 调用 `GetByICCIDs(ctx, iccids)` 且 iccids 中所有 ICCID 长度一致(均为 19 或均为 20
- **THEN** 系统 SHALL 路由到对应列使用 `IN ?` 查询
#### Scenario: GetByICCIDs 混合长度输入
- **WHEN** 调用 `GetByICCIDs(ctx, iccids)` 且 iccids 中同时包含 19 位和 20 位 ICCID
- **THEN** 系统 SHALL 按长度分组,分别查询 `iccid_19 IN ?``iccid_20 IN ?`,合并结果返回
---
### Requirement: DeviceSimBinding 按 ICCID 查询适配
`DeviceSimBindingStore` 中涉及 ICCID 查询的方法 SHALL 适配双列路由策略。
#### Scenario: UpdateIsCurrentByDeviceID 子查询适配
- **WHEN** 调用 `UpdateIsCurrentByDeviceID(ctx, deviceID, currentIccid)` 且 currentIccid 非空
- **THEN** 子查询 SHALL 根据 currentIccid 长度路由到 `iccid_19``iccid_20` 列查询 iot_card_id
- **AND** Miss 时记录 Warn 日志,不更新 is_current不降级
#### Scenario: GetBoundICCIDs JOIN 查询适配
- **WHEN** 调用 `GetBoundICCIDs(ctx, iccids)` 查询已绑定设备的 ICCID 列表
- **THEN** JOIN 条件 SHALL 按传入 iccids 长度路由到对应列
---
### Requirement: PersonalCustomerICCIDStore 查询适配
`PersonalCustomerICCIDStore` 的精确查询方法 SHALL 适配双列查询路由策略。
`tb_personal_customer_iccid` 表 SHALL 新增 `iccid_19 varchar(19)` 列及对应索引。
受影响方法:`GetByICCID``GetByCustomerAndICCID``ExistsByCustomerAndICCID``CreateOrUpdateLastUsed`
#### Scenario: 个人客户 ICCID 记录写入时同步回填
- **WHEN** 系统创建或更新 PersonalCustomerICCID 记录
- **THEN** `iccid_19` SHALL 同步赋值为 ICCID 前 19 位
#### Scenario: 个人客户 ICCID 查询按长度路由
- **WHEN** 调用 `GetByICCID(ctx, iccid)``GetByCustomerAndICCID(ctx, customerID, iccid)`
- **THEN** 系统 SHALL 根据 iccid 长度路由到 `iccid_19`19 位)或原 `iccid`20 位)查询
#### Scenario: 个人客户 ICCID 绑定存在性校验按长度路由
- **WHEN** 调用 `ExistsByCustomerAndICCID(ctx, customerID, iccid)` 检查客户是否已绑定该 ICCID
- **THEN** 系统 SHALL 根据 iccid 长度路由到 `iccid_19`19 位)或原 `iccid`20 位)查询,与 `GetByCustomerAndICCID` 保持一致的路由策略
- **AND** 若路由结果 Miss返回 `false`(未绑定),不降级查另一列
> **设计说明**`tb_personal_customer_iccid` 仅新增 `iccid_19` 列而不添加 `iccid_20` 列。原因:该表的 ICCID 来自**个人客户手动输入**(扫码/手动填写),而非上游 IoT 平台回调。上游平台才是 19/20 位格式不一致问题的来源;个人客户输入的格式与数据库存储格式一致,因此 20 位查询走原 `iccid` 列即可,无需双列适配。
---
### Requirement: 内联 ICCID SQL 归还 Store 层
`enterprise_card/service.go` 中两处直接拼写 `WHERE iccid IN ?` 的内联 SQL SHALL 重构为调用 `IotCardStore.GetByICCIDs()`,消除架构违规并恢复数据权限过滤。
该重构需要同步变更:
1. `enterprise_card.Service` struct 新增 `iotCardStore *postgres.IotCardStore` 字段
2. `New()` 函数签名增加 `iotCardStore *postgres.IotCardStore` 参数
3. `internal/bootstrap/services.go` 调用处传入 `s.IotCard`
#### Scenario: 企业卡分配预览调用 Store 层查询
- **WHEN** 执行企业卡分配预览或分配操作,需要按 ICCIDs 查询卡列表
- **THEN** 系统 SHALL 通过 `IotCardStore.GetByICCIDs()` 查询,不允许 Service 层直接拼写 ICCID 过滤 SQL