feat: 资产解析接口新增实名时间字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m5s

- AssetResolveResponse 新增 real_name_at 字段:card 类型取 first_realname_at,device 类型取绑定卡中最小的实名时间
- BoundCardInfo 新增 real_name_at 字段:各卡自己的 first_realname_at
- RefreshCardDataFromGateway 手动刷新路径补写实名时间:检测到 0→1 变化时同步写入 first_realname_at
- 同步更新 asset-resolve 规格文档

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 12:12:28 +08:00
parent fce35e8a2d
commit 6dfc5c0301
9 changed files with 230 additions and 8 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-09

View File

@@ -0,0 +1,45 @@
## Context
`IotCard` 表已有 `first_realname_at` 字段记录卡每次从未实名0变为已实名1的时间戳轮询路径在每次 `0→1` 时覆盖写入,字段名为"首次"但实际是"最近一次")。该字段目前仅用于内部业务触发(首次实名激活任务),未对外暴露。
**当前问题**
1. `AssetResolveResponse``BoundCardInfo` DTO 没有 `real_name_at` 字段
2. 手动刷新路径(`RefreshCardDataFromGateway`)在更新 `real_name_status` 时漏写 `first_realname_at`
3. `Device` model 无实名时间,设备视角需从绑定卡聚合
## Goals / Non-Goals
**Goals:**
-`GET /api/admin/assets/resolve/:identifier` 响应中暴露实名时间
- 卡视角直接返回 `IotCard.first_realname_at`
- 设备视角返回当前绑定卡 `first_realname_at` 的最小值
- `BoundCardInfo` 列表中每张卡返回各自的实名时间
- 修复手动刷新路径漏写 `first_realname_at` 的 bug
**Non-Goals:**
- 不新增数据库字段(`first_realname_at` 已存在)
- 不修改实名检查轮询逻辑
- 不对设备 model 新增字段(运行时聚合,不持久化)
- 不改变 `triggerFirstRealnameActivation` 的触发条件
## Decisions
### 决策1复用 `first_realname_at` 而非新增字段
`first_realname_at` 在每次 `0→1` 时覆盖写入,语义上等同于"最近一次实名时间"。新增字段需要迁移,收益不成比例。
**替代方案**:新增 `realname_at` 字段,明确"最近一次"语义。
**放弃原因**:需要数据库迁移,且历史卡数据无法回填,反而不如 `first_realname_at` 完整。
### 决策2设备实名时间运行时聚合不持久化
设备无实名时间字段,从绑定卡内存聚合(`buildDeviceResolveResponse` 已查询全部绑定卡),只需遍历取 `MIN(first_realname_at)` 即可,无需额外查询。
### 决策3手动刷新路径与轮询路径对齐
`RefreshCardDataFromGateway``real_name_status` 由非已实名变为已实名时,同步写入 `first_realname_at`,逻辑与 `polling_realname_handler.go``isFirstRealname` 条件完全一致。
## Risks / Trade-offs
- **字段名语义不对齐**`first_realname_at` 叫"首次"但实为"最近一次"。→ 通过 DTO 字段命名为 `real_name_at` 对外屏蔽内部字段名API 消费者不感知。
- **设备实名时间仅限当前绑定卡**:历史上曾绑定过的卡不计入。→ 与业务定义一致(设备换卡后旧卡脱离关系)。

View File

@@ -0,0 +1,31 @@
## Why
后台资产详情(卡视角和设备视角)缺少实名时间字段,运营人员无法直观判断资产何时完成实名,影响客服处理和问题排查效率。
## What Changes
- `GET /api/admin/assets/resolve/:identifier` 响应新增 `real_name_at` 字段(卡视角:该卡最近一次完成实名的时间;设备视角:当前所有绑定卡中最早完成实名的时间)
- 设备响应中 `cards` 数组内每个 `BoundCardInfo` 新增各卡自己的 `real_name_at`
- 修复手动刷新路径(`RefreshCardDataFromGateway`)漏写 `first_realname_at` 的 bug与轮询路径对齐
## Capabilities
### New Capabilities
(无新能力,仅扩展现有接口响应字段)
### Modified Capabilities
- `asset-resolve``AssetResolveResponse` 新增 `real_name_at` 字段;`BoundCardInfo` 新增 `real_name_at` 字段
## Impact
**受影响文件**
- `internal/model/dto/asset_dto.go`DTO 新增字段
- `internal/service/asset/service.go``buildCardResolveResponse` / `buildDeviceResolveResponse` 填充逻辑
- `internal/service/iot_card/service.go``RefreshCardDataFromGateway` 补写 `first_realname_at`
**不影响**
- 数据库结构(`first_realname_at` 字段已存在于 `tb_iot_card`
- 业务逻辑(`triggerFirstRealnameActivation``isFirstRealname` 条件驱动,不读取 `first_realname_at` 字段)
- 其他 API 接口

View File

@@ -0,0 +1,58 @@
## MODIFIED Requirements
### Requirement: 统一资产解析入口
系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。
**API 端点**: `GET /api/admin/assets/resolve/:identifier`
**响应结构AssetResolveResponse新增字段**
*通用字段device 和 card 均有)*
- `real_name_at``*time.Time`,可为 null最近一次完成实名的时间
- **card 类型**:直接取 `IotCard.first_realname_at`(未实名过则为 null
- **device 类型**:当前所有绑定卡 `first_realname_at` 中的最小值(无已实名卡则为 null
*绑定卡列表字段变更BoundCardInfo*
- `real_name_at``*time.Time`,可为 null该卡最近一次完成实名的时间`IotCard.first_realname_at`
#### Scenario: 已实名的卡查询实名时间
- **WHEN** 通过标识符解析一张 `real_name_status = 1` 的卡
- **THEN** 响应中 `real_name_at` 返回该卡最近一次 `0→1` 变化时的时间戳
#### Scenario: 未实名的卡查询实名时间
- **WHEN** 通过标识符解析一张 `real_name_status = 0` 的卡
- **THEN** 响应中 `real_name_at` 返回 null
#### Scenario: 设备视角查询实名时间(部分卡已实名)
- **WHEN** 通过标识符解析一台设备,其中绑定了多张卡,部分卡已实名
- **THEN** 响应中 `real_name_at` 返回所有已实名绑定卡中 `first_realname_at` 最小的时间戳
#### Scenario: 设备视角查询实名时间(无已实名卡)
- **WHEN** 通过标识符解析一台设备,其所有绑定卡均未实名
- **THEN** 响应中 `real_name_at` 返回 null
#### Scenario: 设备绑定卡列表实名时间
- **WHEN** 通过标识符解析一台设备,绑定卡列表非空
- **THEN** `cards` 数组中每个 `BoundCardInfo``real_name_at` 返回各卡自己的 `first_realname_at`(未实名则为 null
## ADDED Requirements
### Requirement: 手动刷新路径同步写入实名时间
系统 SHALL 在手动刷新路径(`RefreshCardDataFromGateway`)检测到实名状态由非已实名变为已实名(`0→1`)时,同步写入 `first_realname_at`,与轮询路径行为一致。
#### Scenario: 手动刷新触发实名状态变更
- **WHEN** 调用手动刷新接口,网关返回实名状态为已实名,且卡当前状态为未实名
- **THEN** `tb_iot_card.first_realname_at` 被更新为当前时间戳
#### Scenario: 手动刷新时实名状态无变化
- **WHEN** 调用手动刷新接口,网关返回实名状态与卡当前状态相同
- **THEN** `tb_iot_card.first_realname_at` 不被修改

View File

@@ -0,0 +1,20 @@
## 1. DTO 扩展
- [x] 1.1 在 `internal/model/dto/asset_dto.go``AssetResolveResponse` 中新增 `RealNameAt *time.Time` 字段json tag: `real_name_at`description: 最近一次完成实名的时间,未实名时为 null
- [x] 1.2 在 `internal/model/dto/asset_dto.go``BoundCardInfo` 中新增 `RealNameAt *time.Time` 字段json tag: `real_name_at`description: 该卡最近一次完成实名的时间,未实名时为 null
## 2. Service 填充逻辑
- [x] 2.1 在 `internal/service/asset/service.go``buildCardResolveResponse` 中填充 `resp.RealNameAt = card.FirstRealnameAt`
- [x] 2.2 在 `internal/service/asset/service.go``buildDeviceResolveResponse` 构建绑定卡列表时,为每个 `BoundCardInfo` 填充 `RealNameAt: c.FirstRealnameAt`
- [x] 2.3 在 `internal/service/asset/service.go``buildDeviceResolveResponse` 中,遍历绑定卡列表聚合设备级 `RealNameAt`:取所有卡 `FirstRealnameAt` 中的最小值(跳过 nil赋值给 `resp.RealNameAt`
## 3. Bug 修复:手动刷新路径补写实名时间
- [x] 3.1 在 `internal/service/iot_card/service.go``RefreshCardDataFromGateway` 中,在更新 `real_name_status` 后,检测 `0→1` 变化:若旧状态非已实名且新状态为已实名,则在 `updates` map 中同步写入 `first_realname_at = time.Now()`
## 4. 验证
- [x] 4.1 通过 PostgreSQL MCP 查询一张已知已实名的卡,确认 `first_realname_at` 不为 null调用 `/api/admin/assets/resolve/:identifier` 确认响应中 `real_name_at` 有值
- [x] 4.2 通过 PostgreSQL MCP 查询一台绑定了已实名卡的设备,调用 `/api/admin/assets/resolve/:identifier` 确认设备响应中 `real_name_at` = 最早绑定卡的实名时间,且 `cards` 列表中各卡 `real_name_at` 正确
- [x] 4.3 查询一张未实名的卡和一台所有卡均未实名的设备,确认 `real_name_at` 均为 null