diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index d4079a97..20d11965 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -241,13 +241,23 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. }) } - // RealNameStatus 以当前使用卡为准;无当前卡则视为未实名 + // RealNameStatus 以当前使用卡为准;无当前卡时,任一有效绑定卡已实名即视为已实名 + hasCurrentCard := false for _, c := range cards { if isCurrentMap[c.ID] { resp.RealNameStatus = c.RealNameStatus + hasCurrentCard = true break } } + if !hasCurrentCard { + for _, c := range cards { + if c.RealNameStatus == constants.RealNameStatusVerified { + resp.RealNameStatus = constants.RealNameStatusVerified + break + } + } + } // RealNameAt 取所有绑定卡中最早的实名时间 for _, c := range cards { if c.FirstRealnameAt == nil { diff --git a/internal/service/device/binding.go b/internal/service/device/binding.go index 803982b4..eb30eba1 100644 --- a/internal/service/device/binding.go +++ b/internal/service/device/binding.go @@ -146,12 +146,27 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := postgres.NewDeviceSimBindingStore(tx, nil).Create(ctx, binding); err != nil { + txBindingStore := postgres.NewDeviceSimBindingStore(tx, nil) + if err := txBindingStore.Create(ctx, binding); err != nil { return err } + // 单槽设备绑定后有效绑定唯一,当前使用卡不可能是其他卡,直接初始化为当前卡 + if device.MaxSimSlots == 1 { + activeCount, err := txBindingStore.CountByDeviceID(ctx, device.ID) + if err != nil { + return err + } + if activeCount == 1 { + if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Where("id = ?", binding.ID).Update("is_current", true).Error; err != nil { + return err + } + binding.IsCurrent = true + } + } item.Binding = binding item.BindingRole = constants.AuditResourceRoleDeviceCreatedBinding - item.BindingAfter = bindingStateData(binding, constants.BindStatusBound, false) + item.BindingAfter = bindingStateData(binding, constants.BindStatusBound, binding.IsCurrent) return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCardBound, "设备绑定 IoT 卡", constants.AuditResultSuccess, device, map[string]any{"slot_position": req.SlotPosition, "iot_card_id": nil}, diff --git a/internal/task/device_import.go b/internal/task/device_import.go index dbd6a168..104a08c6 100644 --- a/internal/task/device_import.go +++ b/internal/task/device_import.go @@ -372,6 +372,8 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi now := time.Now() validCardIDs := make([]uint, 0, len(validCards)) + // 单槽设备导入后有效绑定唯一,当前使用卡只能是该卡 + initializeCurrentCard := device.MaxSimSlots == 1 && len(validCards) == 1 for _, slotICCID := range validCards { cardID := existingCards[slotICCID.ICCID].ID validCardIDs = append(validCardIDs, cardID) @@ -382,6 +384,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi SlotPosition: slotICCID.SlotPosition, BindStatus: 1, BindTime: &now, + IsCurrent: initializeCurrentCard, } if err := txBindingStore.Create(ctx, binding); err != nil { return err diff --git a/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/.openspec.yaml b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/.openspec.yaml new file mode 100644 index 00000000..f2cbbe6a --- /dev/null +++ b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-18 diff --git a/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/design.md b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/design.md new file mode 100644 index 00000000..70bfe6cc --- /dev/null +++ b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/design.md @@ -0,0 +1,77 @@ +## Context + +- 设备资产解析在 `internal/service/asset` 内一次组装绑定卡、外层实名与套餐摘要;C 端资产信息 DTO 只暴露 `real_name_status` 一个外层实名信号(不含 `real_name_at`),逐卡实名由卡列表返回。 +- `is_current` 目前只在运营商设备观测、显式切卡、解绑与删设备路径写入;导入与手工绑定创建时不写,DB 默认 false。 +- 设备在运营商侧的识别标识为 IMEI 优先、空则用 SN,读取侧(资产信息实时状态)与观测侧(卡观测序列)口径一致。 +- 生产只读核对:有有效绑定的设备 19031 台,其中 639 台无任何当前卡标识;这 639 台里 76 台存在已实名绑定卡(16 台为单槽单卡,其余为多卡设备)。 +- 既有约束:自动化测试当前为 N/A(用户决策);生产写入由维护者按生产运行说明执行,Agent 只做只读诊断。 + +## Goals / Non-Goals + +**Goals:** + +- 让「设备没有任何当前卡标识」这一分支不再把已实名设备展示为未实名。 +- 让单槽单卡设备在绑定后即具备当前卡标识,消除「当前卡」展示为空的产生条件。 +- 给存量同类数据一个可复核、可回滚的核对与修复口径。 + +**Non-Goals:** + +- 不改变存在当前卡标识时的判定结果。 +- 不改变管理端设备列表既有的「任一有效绑定卡已实名」筛选,以及设备激活状态判定。 +- 不为多卡设备推断当前卡,不改变运营商观测/切卡对 `is_current` 的写入职责。 +- 不处理运营商侧设备登记(如 Gateway 返回设备号无效)的修复。 +- 不引入 Schema 变更、迁移、一次性常驻命令或自动化测试。 + +## Decisions + +### 1. 回退判定放在读取侧,不改写业务事实 + +无当前卡时,外层实名按有效绑定卡的实名状态取或(任一已实名即已实名),实现位置为设备资产解析中的外层实名判定。 + +- 理由:读取侧回退只影响展示,不把「哪张卡在用」变成猜测事实;多卡设备无当前卡写侧无法推断,回填会污染 `is_current` 语义。 +- 备选:在所有情况下都改为「任一有效绑定卡已实名」(否决,等于回退既有当前卡语义);在读取时按最大槽位推断当前卡(否决,会让 `is_current` 出现第二套判定来源)。 + +### 2. 保留「当前卡优先」分支,仅补分支缺口 + +存在 `is_current=true` 的有效绑定时沿用该卡实名状态;仅在一条当前卡标识都没有时进入回退。如此本 change 不改变有当前卡设备的既有返回。 + +### 3. 绑定创建时初始化单槽单卡的当前卡标识 + +判据:设备最大槽位为 1,且绑定创建后该设备的有效绑定唯一。此时设备只有唯一的可能当前卡,标记为当前使用卡是事实而非猜测。写入与绑定创建放在同一事务,保证绑定与当前卡标识一致可见;导入与手工绑定两条创建路径都要覆盖。 + +- 理由:多卡设备的当前卡必须来自运营商观测或显式切卡,绑定时不猜。 +- 备选:绑定后异步触发一次设备观测(否决,依赖外部调用且失败时仍留空)。 + +### 4. 存量数据走受控人工修复,不写一次性代码 + +只读核对口径(识别待修复绑定): + +```sql +SELECT d.id AS device_id, b.id AS binding_id, b.iot_card_id +FROM tb_device_sim_binding b +JOIN tb_device d ON d.id = b.device_id AND d.deleted_at IS NULL +WHERE b.bind_status = 1 + AND d.max_sim_slots = 1 + AND NOT EXISTS (SELECT 1 FROM tb_device_sim_binding x + WHERE x.device_id = b.device_id AND x.bind_status = 1 AND x.is_current) + AND (SELECT count(*) FROM tb_device_sim_binding y + WHERE y.device_id = b.device_id AND y.bind_status = 1) = 1; +``` + +受控修复:仅对上述结果集内的绑定置 `is_current = true`,同一设备只允许一条;执行前留存核对结果,执行后抽样比对设备卡列表与资产信息。 + +- 理由:生产写操作由维护者执行;一次性命令会长期留在仓库成为隐性维护面。 +- 备选:新增数据修复命令或迁移(否决,超出必要范围且规则与绑定创建规则重复)。 + +## Risks / Trade-offs + +- [无当前卡集合里包含「当前卡标识因观测缺失而丢失」的设备,回退可能把实际在用卡未实名的设备显示为已实名] → 以绑定卡本地事实为准,卡列表仍逐卡返回真实状态;上游设备登记类问题另行在运维侧处理,不在本 change。 +- [绑定即置当前卡可能让设备观测的期望值提前满足] → 判据限定最大槽位为 1 且有效绑定唯一,此时可被切换的目标只有同一张卡;多卡设备不写入。 +- [存量修复误伤多卡或重复当前卡] → 核对口径限定单槽且唯一有效绑定,修复语句限定 `bind_status = 1` 并保证同一设备至多一条 `is_current`;先只读核对,再小批执行。 +- [C 端展示口径变化无自动化测试覆盖] → 按用户既有决策不新增自动化测试,改为对案例设备(19090)与抽样设备做发布后实接口核对。 + +## Migration Plan + +1. 代码发布:无 Schema 变更,随常规二进制发布生效;展示口径回退只需回滚二进制,无数据残留。 +2. 存量修复:先在只读环境按核对口径确认结果集,再由维护者按生产运行说明执行修复;不随发布自动执行。 +3. 发布后核对:以案例设备 `90001595626` 与抽样多卡设备比对资产信息外层实名、卡列表当前卡标识。 diff --git a/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/proposal.md b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/proposal.md new file mode 100644 index 00000000..d2ccde38 --- /dev/null +++ b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/proposal.md @@ -0,0 +1,28 @@ +## Why + +C 端设备资产信息 `GET /api/c/v1/asset/info` 的外层 `real_name_status` 只取「当前使用卡」(`is_current=true` 的绑定),无当前卡即返回未实名。而 `is_current` 仅在 Gateway 观测成功后写入,导入与手工绑定创建时恒为 false。于是从未与 Gateway 建立有效观测的设备长期「无当前卡」,即使绑定卡已完成实名,对外仍显示未实名(生产确认:设备 19090 的绑定 36730 `is_current=false`、`tb_device.last_gateway_sync_at` 为空,卡 131609 `real_name_status=1`)。同口径下生产现有 76 台设备受此影响,且设备卡列表中的「当前卡」标识同样为空。 + +## What Changes + +- 设备资产信息外层 `real_name_status`:存在 `is_current=true` 的有效绑定时沿用该卡状态(不推翻既有当前卡语义);设备没有任何当前卡时,回退为「任一有效绑定卡已实名即视为设备已实名」,不再一律返回未实名。 +- 设备绑定卡创建:当设备最大槽位为 1 且创建后仅存在一条有效绑定时,将该绑定初始化为当前卡;多卡设备的当前卡判定仍由 Gateway 观测与切卡流程写入,不由绑定时猜测。 +- 提供只读核对口径与受控人工修复步骤,用于按上述规则修复存量单槽单卡设备的当前卡标识;生产写入由维护者按生产运行说明执行。 +- 不改变卡列表的逐卡实名返回、实名链接目标卡选择、设备激活状态判定,以及管理端设备列表既有的「任一有效绑定卡已实名」筛选口径。 +- 不改变有当前卡时的既有判定结果。 + +## Capabilities + +### New Capabilities + +- 无。 + +### Modified Capabilities + +- `personal-customer`: 设备资产信息在没有当前使用卡时的实名状态判定。 +- `asset-device`: 单槽单卡设备绑定时的当前卡标识初始化,以及存量当前卡标识缺失的核对与受控修复口径。 + +## Impact + +- 影响 `internal/service/asset`(设备资产解析的外层实名判定)与设备绑定创建路径 `internal/service/device`、`internal/task`(设备导入)。 +- 生产影响面(只读核对):有有效绑定的设备 19031 台,其中 639 台无当前卡;639 台里 76 台存在已实名绑定卡(16 台为单槽单卡),修复后其 C 端设备实名展示由未实名变为已实名。 +- 不涉及 Schema 改动、迁移、外部接口契约、金额、库存与状态机。 diff --git a/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/reconciliation.md b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/reconciliation.md new file mode 100644 index 00000000..687709d2 --- /dev/null +++ b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/reconciliation.md @@ -0,0 +1,153 @@ +# 存量当前卡标识核对与受控修复清单 + +本文件是 change `fix-device-realname-without-current-card` 任务 4.1 的只读核对产物,供维护者执行任务 4.2 的受控修复使用。 + +- 核对时间:2026-09-18 +- 核对环境:生产库只读查询(dbhub `pro_main`),全程未产生写入。 +- 核对口径:`design.md` 决策 4 的只读查询。 + +## 口径 SQL + +```sql +SELECT d.id AS device_id, b.id AS binding_id, b.iot_card_id +FROM tb_device_sim_binding b +JOIN tb_device d ON d.id = b.device_id AND d.deleted_at IS NULL +WHERE b.bind_status = 1 + AND d.max_sim_slots = 1 + AND NOT EXISTS (SELECT 1 FROM tb_device_sim_binding x + WHERE x.device_id = b.device_id AND x.bind_status = 1 AND x.is_current) + AND (SELECT count(*) FROM tb_device_sim_binding y + WHERE y.device_id = b.device_id AND y.bind_status = 1) = 1; +``` + +## 结果统计 + +| 指标 | 数量 | +| --- | --- | +| 待修复绑定(=待修复设备) | 75 | +| 其中绑定卡已实名(`real_name_status = 1`) | 16 | +| 其中绑定卡未实名 | 59 | + +影响面参考(同库只读统计): + +| 指标 | 数量 | +| --- | --- | +| 存在有效绑定卡的设备 | 19031 | +| 其中没有任何当前卡标识 | 638 | +| 其中存在已实名绑定卡 | 76 | +| 其中单槽唯一有效绑定(本清单范围) | 75(已实名 16) | + +## 待修复绑定清单 + +| device_no | device_id | binding_id | iot_card_id | iccid | real_name_status | +| --- | --- | --- | --- | --- | --- | +| 60002846141 | 6 | 11 | 5113 | 89860624660028461414 | 1 | +| 60002846880 | 11 | 16 | 5118 | 89860624660028468807 | 1 | +| 60002817472 | 13 | 18 | 5120 | 89860624660028174728 | 1 | +| 60002817732 | 14 | 19 | 5121 | 89860624660028177325 | 1 | +| 60002842491 | 16 | 21 | 5123 | 89860624660028424917 | 1 | +| 90001595835 | 369 | 974 | 86943 | 89860624590015958355 | 1 | +| 60003081045 | 18909 | 36349 | 131202 | 89860624660030810459 | 1 | +| 90001595210 | 18912 | 36356 | 131211 | 89860624590015952101 | 1 | +| 90001595801 | 18921 | 36373 | 131227 | 89860624590015958017 | 1 | +| 90001595438 | 18942 | 36414 | 131355 | 89860624590015954388 | 1 | +| 60003080864 | 19081 | 36711 | 131611 | 89860624660030808644 | 1 | +| 90001595459 | 19082 | 36712 | 131226 | 89860624590015954594 | 1 | +| 90001595626 | 19090 | 36730 | 131609 | 89860624590015956268 | 1 | +| 60003080488 | 19091 | 36731 | 131610 | 89860624660030804882 | 1 | +| 90001595086 | 19092 | 36732 | 131679 | 89860624590015950865 | 1 | +| 60003081496 | 19128 | 36784 | 131780 | 89860624660030814964 | 1 | +| 60002842359 | 7 | 12 | 5114 | 89860624660028423596 | 0 | +| 60002846803 | 8 | 13 | 5115 | 89860624660028468039 | 0 | +| 60002842779 | 9 | 14 | 5116 | 89860624660028427795 | 0 | +| 60002842976 | 10 | 15 | 5117 | 89860624660028429767 | 0 | +| 60002817595 | 12 | 17 | 5119 | 89860624660028175956 | 0 | +| 60002817949 | 15 | 20 | 5122 | 89860624660028179495 | 0 | +| 60003080109 | 368 | 973 | 86942 | 89860624660030801094 | 0 | +| 60003081398 | 370 | 975 | 86944 | 89860624660030813982 | 0 | +| 90001595523 | 371 | 976 | 86945 | 89860624590015955237 | 0 | +| 60003081183 | 372 | 977 | 86946 | 89860624660030811838 | 0 | +| 60003081174 | 373 | 978 | 86947 | 89860624660030811747 | 0 | +| 60003081651 | 374 | 979 | 86948 | 89860624660030816514 | 0 | +| 90001595896 | 375 | 980 | 86949 | 89860624590015958967 | 0 | +| 60003080879 | 376 | 981 | 86950 | 89860624660030808792 | 0 | +| 90001595588 | 377 | 982 | 86951 | 89860624590015955880 | 0 | +| 60003081516 | 378 | 983 | 86952 | 89860624660030815169 | 0 | +| 60003081645 | 379 | 984 | 86953 | 89860624660030816456 | 0 | +| 60003081643 | 380 | 985 | 86954 | 89860624660030816431 | 0 | +| 60003080711 | 381 | 986 | 86955 | 89860624660030807117 | 0 | +| 60003080190 | 382 | 987 | 86956 | 89860624660030801904 | 0 | +| 90001595805 | 383 | 988 | 86957 | 89860624590015958058 | 0 | +| 60003080161 | 384 | 989 | 86958 | 89860624660030801615 | 0 | +| 90001595810 | 385 | 990 | 86959 | 89860624590015958108 | 0 | +| 60003081366 | 386 | 991 | 86960 | 89860624660030813669 | 0 | +| 60003081251 | 387 | 992 | 86961 | 89860624660030812513 | 0 | +| 60003081350 | 388 | 993 | 86962 | 89860624660030813503 | 0 | +| 60003080752 | 389 | 994 | 86963 | 89860624660030807521 | 0 | +| 60003081310 | 390 | 995 | 86964 | 89860624660030813107 | 0 | +| 60003080791 | 391 | 996 | 86965 | 89860624660030807919 | 0 | +| 60003081268 | 392 | 997 | 86966 | 89860624660030812687 | 0 | +| 90001595763 | 18913 | 36357 | 131212 | 89860624590015957639 | 0 | +| 90001595413 | 18914 | 36358 | 131213 | 89860624590015954131 | 0 | +| 60003080907 | 19093 | 36733 | 131680 | 89860624660030809071 | 0 | +| 60002817276 | 19101 | 36756 | 112006 | 89860624660028172763 | 0 | +| 60003080052 | 19102 | 36757 | 131715 | 89860624660030800526 | 0 | +| 90001595253 | 19103 | 36758 | 131716 | 89860624590015952531 | 0 | +| 90001595502 | 19104 | 36759 | 131717 | 89860624590015955021 | 0 | +| 60003081741 | 19105 | 36760 | 131718 | 89860624660030817413 | 0 | +| 90001595886 | 19107 | 36763 | 131719 | 89860624590015958868 | 0 | +| 90001595902 | 19108 | 36764 | 131720 | 89860624590015959023 | 0 | +| 90001595553 | 19109 | 36765 | 131721 | 89860624590015955534 | 0 | +| 90001595460 | 19110 | 36766 | 131722 | 89860624590015954602 | 0 | +| 60003081979 | 19111 | 36767 | 131723 | 89860624660030819799 | 0 | +| 60003081938 | 19112 | 36768 | 131724 | 89860624660030819385 | 0 | +| 60003081807 | 19113 | 36769 | 131725 | 89860624660030818072 | 0 | +| 90001595761 | 19114 | 36770 | 131726 | 89860624590015957613 | 0 | +| 90001595422 | 19115 | 36771 | 131727 | 89860624590015954222 | 0 | +| 60003081975 | 19116 | 36772 | 131728 | 89860624660030819757 | 0 | +| 60003080037 | 19117 | 36773 | 131729 | 89860624660030800377 | 0 | +| 90001595510 | 19118 | 36774 | 131730 | 89860624590015955104 | 0 | +| 60003080038 | 19119 | 36775 | 131731 | 89860624660030800385 | 0 | +| 60003080062 | 19120 | 36776 | 131732 | 89860624660030800625 | 0 | +| 60002817135 | 19121 | 36777 | 111363 | 89860624660028171351 | 0 | +| 60003080264 | 19122 | 36778 | 131745 | 89860624660030802647 | 0 | +| 90001595473 | 19123 | 36779 | 131746 | 89860624590015954735 | 0 | +| 60003080128 | 19124 | 36780 | 131776 | 89860624660030801284 | 0 | +| 90001595873 | 19125 | 36781 | 131777 | 89860624590015958736 | 0 | +| 60003081839 | 19126 | 36782 | 131778 | 89860624660030818395 | 0 | +| 60003081051 | 19127 | 36783 | 131779 | 89860624660030810517 | 0 | + +## 受控修复(由维护者执行,不随发布自动执行) + +修复语句使用与核对完全相同的口径,且限定 `bind_status = 1`,同一设备至多命中一条: + +```sql +UPDATE tb_device_sim_binding b +SET is_current = true, updated_at = now() +WHERE b.bind_status = 1 + AND b.id IN ( + SELECT b2.id + FROM tb_device_sim_binding b2 + JOIN tb_device d ON d.id = b2.device_id AND d.deleted_at IS NULL + WHERE b2.bind_status = 1 + AND d.max_sim_slots = 1 + AND NOT EXISTS (SELECT 1 FROM tb_device_sim_binding x + WHERE x.device_id = b2.device_id AND x.bind_status = 1 AND x.is_current) + AND (SELECT count(*) FROM tb_device_sim_binding y + WHERE y.device_id = b2.device_id AND y.bind_status = 1) = 1 + ); +``` + +执行前后核对: + +1. 执行前重跑本文件口径 SQL,与清单行数(75)比对;不一致则以最新结果为准。 +2. 执行后重跑口径 SQL,结果应为 0 行。 +3. 校验同一设备不存在多条 `is_current = true`: + ```sql + SELECT device_id, count(*) FROM tb_device_sim_binding + WHERE bind_status = 1 AND is_current GROUP BY device_id HAVING count(*) > 1; + ``` + 应返回 0 行。 +4. 抽样实接口核对资产信息外层实名与卡列表当前卡标识(含设备 `90001595626`)。 + +回滚:`UPDATE tb_device_sim_binding SET is_current = false WHERE id IN (<清单 binding_id>)`。 diff --git a/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/specs/asset-device/spec.md b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/specs/asset-device/spec.md new file mode 100644 index 00000000..fbbe21f5 --- /dev/null +++ b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/specs/asset-device/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: 单槽单卡设备绑定时初始化当前卡标识 + +系统 SHALL 在设备最大槽位为 1 且该设备绑定的有效卡唯一时,将该绑定标记为当前使用卡;绑定成功后的设备卡列表 SHALL 把该卡作为当前卡对外返回。对多卡设备,系统 MUST NOT 在绑定时推断当前使用卡,其当前卡标识仍由运营商设备观测或显式切卡结果决定。系统 MUST 保证同一设备同一时刻至多一条有效绑定被标记为当前使用卡;解绑 SHALL 清除该绑定的当前卡标识。 + +#### Scenario: 单槽设备绑定唯一卡 + +- **GIVEN** 设备最大槽位为 1 且当前没有有效绑定卡 +- **WHEN** 管理员为该设备绑定一张有效卡 +- **THEN** 该绑定被标记为当前使用卡 +- **AND** 后续设备卡列表中该卡返回为当前卡 + +#### Scenario: 多卡设备绑定不推断当前卡 + +- **GIVEN** 设备最大槽位大于 1 +- **WHEN** 管理员为该设备绑定一张卡 +- **THEN** 绑定结果不改变该设备既有的当前使用卡标识 + +#### Scenario: 解绑清除当前卡标识 + +- **GIVEN** 某有效绑定被标记为当前使用卡 +- **WHEN** 该绑定被解绑 +- **THEN** 该设备不再存在被标记为当前使用卡的有效绑定 + +### Requirement: 存量当前卡标识的核对与受控修复 + +系统 SHALL 提供只读核对口径,用于按「设备最大槽位为 1、有效绑定唯一、当前无任何当前卡标识」识别需要修复的存量绑定。受控修复 SHALL 仅将符合该口径的绑定标记为当前使用卡,MUST NOT 修改其他绑定、MUST NOT 使同一设备出现多条当前使用卡。修复后设备卡列表 SHALL 反映该当前卡标识,设备实名判定 SHALL 按绑定卡事实返回。 + +#### Scenario: 只读核对识别待修复设备 + +- **GIVEN** 设备最大槽位为 1、存在唯一有效绑定且没有任何当前卡标识 +- **WHEN** 维护者按核对口径查询 +- **THEN** 该设备出现在待修复结果中,且查询本身不产生任何写入 + +#### Scenario: 受控修复后当前卡可见 + +- **GIVEN** 已按核对口径确认的设备 +- **WHEN** 维护者执行受控修复 +- **THEN** 该设备的唯一有效绑定被标记为当前使用卡 +- **AND** 该设备的卡列表与资产信息按最新绑定事实返回 + +#### Scenario: 多卡设备不在修复范围 + +- **GIVEN** 设备最大槽位大于 1 或有效绑定多于一条 +- **WHEN** 维护者按核对口径查询 +- **THEN** 该设备不出现在待修复结果中 diff --git a/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/specs/personal-customer/spec.md b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/specs/personal-customer/spec.md new file mode 100644 index 00000000..0c9c3db6 --- /dev/null +++ b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/specs/personal-customer/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: 缺少当前使用卡时的设备实名判定 + +系统 SHALL 在个人客户查询设备资产 `GET /api/c/v1/asset/info` 时按下述规则返回外层 `real_name_status`:存在有效绑定卡的当前使用标识时,返回该卡的实名状态;不存在任何当前使用卡时,只要该设备存在任一已实名(`real_name_status=1`)的有效绑定卡,SHALL 返回已实名,否则返回未实名。返回的 `real_name_status_name` SHALL 与 `real_name_status` 保持一致。卡列表中的逐卡实名状态 MUST 保持按卡返回,不受本判定影响。该判定 MUST NOT 因运营商实时接口调用失败或超时而改变。 + +#### Scenario: 无当前使用卡但绑定卡已实名 + +- **GIVEN** 设备存在有效绑定卡且其中至少一张已实名,且该设备没有任何 `is_current=true` 的有效绑定卡 +- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info` +- **THEN** 响应外层 `real_name_status` 返回 `1` 且 `real_name_status_name` 为已实名 +- **AND** 卡列表中该已实名卡仍按其自身实名状态返回 + +#### Scenario: 无当前使用卡且绑定卡均未实名 + +- **GIVEN** 设备存在有效绑定卡且全部未实名,且该设备没有任何 `is_current=true` 的有效绑定卡 +- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info` +- **THEN** 响应外层 `real_name_status` 返回 `0` 且 `real_name_status_name` 为未实名 + +#### Scenario: 有当前使用卡时沿用该卡状态 + +- **GIVEN** 设备存在一张 `is_current=true` 的未实名绑定卡,同时存在另一张已实名绑定卡 +- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info` +- **THEN** 响应外层 `real_name_status` 返回该当前使用卡的未实名状态 + +#### Scenario: 运营商实时查询失败不改变实名判定 + +- **GIVEN** 设备的运营商实时状态查询失败或超时 +- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info` +- **THEN** 响应仍按绑定卡事实返回外层实名状态,不因实时查询失败而返回未实名 diff --git a/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/tasks.md b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/tasks.md new file mode 100644 index 00000000..9cd330b8 --- /dev/null +++ b/openspec/changes/archive/2026-09-18-fix-device-realname-without-current-card/tasks.md @@ -0,0 +1,22 @@ +## 1. 设备实名回退判定 + +- [x] 1.1 在设备资产解析的外层实名判定中保留「有当前使用卡时沿用该卡状态」分支,补上「无任何当前使用卡时按有效绑定卡实名状态取或」的回退。 +- [x] 1.2 确认回退不改变逐卡实名状态、实名策略、激活状态、套餐摘要与运营商实时字段的既有返回。 + +## 2. 绑定创建初始化当前卡标识 + +- [x] 2.1 手工绑定路径:设备最大槽位为 1 且绑定后有效绑定唯一时,在与绑定创建同一事务内将该绑定标记为当前使用卡。 +- [x] 2.2 设备导入路径:同一判据在导入创建绑定的事务内生效。 +- [x] 2.3 确认多卡设备绑定不写入当前卡标识,既有切卡、运营商观测与解绑对当前卡标识的写入行为不变。 + +## 3. 交付验证(临时手段,不留产物) + +- [x] 3.1 用隔离数据或临时调用验证三类返回:无当前卡且存在已实名绑定卡时为已实名;无当前卡且绑定卡均未实名时为未实名;存在当前卡时沿用该卡状态。 +- [x] 3.2 验证单槽单卡设备绑定后设备卡列表返回该卡为当前卡,且多卡设备绑定后当前卡标识不变。 +- [x] 3.3 运行 gofmt、`go build ./cmd/api ./cmd/worker`、`openspec validate --all` 与 `./scripts/context-health.sh`;未新增路由,按需确认 OpenAPI 描述是否需要重新生成;删除临时验证产物。 + +## 4. 存量核对与发布后验证 + +- [x] 4.1 按 design.md 的只读核对口径在只读环境产出待修复绑定清单并留存证据。 +- [ ] 4.2 由维护者按生产运行说明执行受控修复,仅覆盖核对清单内的单槽唯一绑定,不随发布自动执行。 +- [ ] 4.3 发布后以设备 `90001595626` 及抽样多卡设备实接口核对资产信息外层实名与卡列表当前卡标识。 diff --git a/openspec/specs/asset-device/spec.md b/openspec/specs/asset-device/spec.md index 9c73a1b1..6781d129 100644 --- a/openspec/specs/asset-device/spec.md +++ b/openspec/specs/asset-device/spec.md @@ -68,6 +68,52 @@ - **WHEN** 生产者向公共可靠事件存储提交超过字段上限的事件标识或父事件标识 - **THEN** 系统在持久化边界返回明确的参数错误而不是数据库字段错误 +### Requirement: 单槽单卡设备绑定时初始化当前卡标识 + +系统 SHALL 在设备最大槽位为 1 且该设备绑定的有效卡唯一时,将该绑定标记为当前使用卡;绑定成功后的设备卡列表 SHALL 把该卡作为当前卡对外返回。对多卡设备,系统 MUST NOT 在绑定时推断当前使用卡,其当前卡标识仍由运营商设备观测或显式切卡结果决定。系统 MUST 保证同一设备同一时刻至多一条有效绑定被标记为当前使用卡;解绑 SHALL 清除该绑定的当前卡标识。 + +#### Scenario: 单槽设备绑定唯一卡 + +- **GIVEN** 设备最大槽位为 1 且当前没有有效绑定卡 +- **WHEN** 管理员为该设备绑定一张有效卡 +- **THEN** 该绑定被标记为当前使用卡 +- **AND** 后续设备卡列表中该卡返回为当前卡 + +#### Scenario: 多卡设备绑定不推断当前卡 + +- **GIVEN** 设备最大槽位大于 1 +- **WHEN** 管理员为该设备绑定一张卡 +- **THEN** 绑定结果不改变该设备既有的当前使用卡标识 + +#### Scenario: 解绑清除当前卡标识 + +- **GIVEN** 某有效绑定被标记为当前使用卡 +- **WHEN** 该绑定被解绑 +- **THEN** 该设备不再存在被标记为当前使用卡的有效绑定 + +### Requirement: 存量当前卡标识的核对与受控修复 + +系统 SHALL 提供只读核对口径,用于按「设备最大槽位为 1、有效绑定唯一、当前无任何当前卡标识」识别需要修复的存量绑定。受控修复 SHALL 仅将符合该口径的绑定标记为当前使用卡,MUST NOT 修改其他绑定、MUST NOT 使同一设备出现多条当前使用卡。修复后设备卡列表 SHALL 反映该当前卡标识,设备实名判定 SHALL 按绑定卡事实返回。 + +#### Scenario: 只读核对识别待修复设备 + +- **GIVEN** 设备最大槽位为 1、存在唯一有效绑定且没有任何当前卡标识 +- **WHEN** 维护者按核对口径查询 +- **THEN** 该设备出现在待修复结果中,且查询本身不产生任何写入 + +#### Scenario: 受控修复后当前卡可见 + +- **GIVEN** 已按核对口径确认的设备 +- **WHEN** 维护者执行受控修复 +- **THEN** 该设备的唯一有效绑定被标记为当前使用卡 +- **AND** 该设备的卡列表与资产信息按最新绑定事实返回 + +#### Scenario: 多卡设备不在修复范围 + +- **GIVEN** 设备最大槽位大于 1 或有效绑定多于一条 +- **WHEN** 维护者按核对口径查询 +- **THEN** 该设备不出现在待修复结果中 + ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 diff --git a/openspec/specs/personal-customer/spec.md b/openspec/specs/personal-customer/spec.md index 6e32a635..5326371e 100644 --- a/openspec/specs/personal-customer/spec.md +++ b/openspec/specs/personal-customer/spec.md @@ -32,6 +32,35 @@ - **WHEN** 查询资产列表或详情 - **THEN** 返回该客户可见的资产而不包含其他客户资产 +### Requirement: 缺少当前使用卡时的设备实名判定 + +系统 SHALL 在个人客户查询设备资产 `GET /api/c/v1/asset/info` 时按下述规则返回外层 `real_name_status`:存在有效绑定卡的当前使用标识时,返回该卡的实名状态;不存在任何当前使用卡时,只要该设备存在任一已实名(`real_name_status=1`)的有效绑定卡,SHALL 返回已实名,否则返回未实名。返回的 `real_name_status_name` SHALL 与 `real_name_status` 保持一致。卡列表中的逐卡实名状态 MUST 保持按卡返回,不受本判定影响。该判定 MUST NOT 因运营商实时接口调用失败或超时而改变。 + +#### Scenario: 无当前使用卡但绑定卡已实名 + +- **GIVEN** 设备存在有效绑定卡且其中至少一张已实名,且该设备没有任何 `is_current=true` 的有效绑定卡 +- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info` +- **THEN** 响应外层 `real_name_status` 返回 `1` 且 `real_name_status_name` 为已实名 +- **AND** 卡列表中该已实名卡仍按其自身实名状态返回 + +#### Scenario: 无当前使用卡且绑定卡均未实名 + +- **GIVEN** 设备存在有效绑定卡且全部未实名,且该设备没有任何 `is_current=true` 的有效绑定卡 +- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info` +- **THEN** 响应外层 `real_name_status` 返回 `0` 且 `real_name_status_name` 为未实名 + +#### Scenario: 有当前使用卡时沿用该卡状态 + +- **GIVEN** 设备存在一张 `is_current=true` 的未实名绑定卡,同时存在另一张已实名绑定卡 +- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info` +- **THEN** 响应外层 `real_name_status` 返回该当前使用卡的未实名状态 + +#### Scenario: 运营商实时查询失败不改变实名判定 + +- **GIVEN** 设备的运营商实时状态查询失败或超时 +- **WHEN** 已认证个人客户请求该设备的 `GET /api/c/v1/asset/info` +- **THEN** 响应仍按绑定卡事实返回外层实名状态,不因实时查询失败而返回未实名 + ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。