diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index c7a979a..9f8b688 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -20,7 +20,8 @@ type AssetResolveResponse struct { CreatedAt time.Time `json:"created_at" description:"创建时间"` UpdatedAt time.Time `json:"updated_at" description:"更新时间"` // 状态聚合字段 - RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"` + RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"` + RealNameAt *time.Time `json:"real_name_at" description:"最近一次完成实名的时间,未实名时为 null"` CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"` PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB),已按virtual_ratio换算"` PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB),已按virtual_ratio换算"` @@ -62,13 +63,14 @@ type AssetResolveResponse struct { // BoundCardInfo 设备绑定的卡信息 type BoundCardInfo struct { - CardID uint `json:"card_id" description:"卡ID"` - ICCID string `json:"iccid" description:"ICCID"` - MSISDN string `json:"msisdn,omitempty" description:"手机号"` - NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机"` - RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"` - SlotPosition int `json:"slot_position,omitempty" description:"插槽位置"` - IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"` + CardID uint `json:"card_id" description:"卡ID"` + ICCID string `json:"iccid" description:"ICCID"` + MSISDN string `json:"msisdn,omitempty" description:"手机号"` + NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机"` + RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"` + RealNameAt *time.Time `json:"real_name_at" description:"该卡最近一次完成实名的时间,未实名时为 null"` + SlotPosition int `json:"slot_position,omitempty" description:"插槽位置"` + IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"` } // AssetRealtimeStatusResponse 资产实时状态(只读DB/Redis) diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 0006d47..db1a2e0 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -191,10 +191,24 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. MSISDN: c.MSISDN, NetworkStatus: c.NetworkStatus, RealNameStatus: c.RealNameStatus, + RealNameAt: c.FirstRealnameAt, SlotPosition: slotMap[c.ID], IsCurrent: isCurrentMap[c.ID], }) } + + // 聚合设备级 RealNameAt:取所有绑定卡 FirstRealnameAt 中的最小值(跳过 nil) + var minRealNameAt *time.Time + for _, c := range cards { + if c.FirstRealnameAt == nil { + continue + } + if minRealNameAt == nil || c.FirstRealnameAt.Before(*minRealNameAt) { + t := *c.FirstRealnameAt + minRealNameAt = &t + } + } + resp.RealNameAt = minRealNameAt } // 查当前主套餐 @@ -228,6 +242,7 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC CreatedAt: card.CreatedAt, UpdatedAt: card.UpdatedAt, RealNameStatus: card.RealNameStatus, + RealNameAt: card.FirstRealnameAt, NetworkStatus: card.NetworkStatus, ICCID: card.ICCID, CarrierID: card.CarrierID, diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 483a3ec..bd4e5b1 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -853,6 +853,10 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) } else { realNameStatus := parseGatewayRealnameStatus(realnameResp.RealStatus) updates["real_name_status"] = realNameStatus + // 检测 0→1 变化:旧状态非已实名且新状态为已实名,补写 first_realname_at + if card.RealNameStatus != constants.RealNameStatusVerified && realNameStatus == constants.RealNameStatusVerified { + updates["first_realname_at"] = syncTime + } } // 3. 查询本月流量用量 — 使用增量计算(与轮询 calculateFlowUpdates 逻辑一致) diff --git a/openspec/changes/archive/2026-04-09-add-asset-realname-time/.openspec.yaml b/openspec/changes/archive/2026-04-09-add-asset-realname-time/.openspec.yaml new file mode 100644 index 0000000..98d7681 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-add-asset-realname-time/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-09 diff --git a/openspec/changes/archive/2026-04-09-add-asset-realname-time/design.md b/openspec/changes/archive/2026-04-09-add-asset-realname-time/design.md new file mode 100644 index 0000000..0f60f11 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-add-asset-realname-time/design.md @@ -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 消费者不感知。 +- **设备实名时间仅限当前绑定卡**:历史上曾绑定过的卡不计入。→ 与业务定义一致(设备换卡后旧卡脱离关系)。 diff --git a/openspec/changes/archive/2026-04-09-add-asset-realname-time/proposal.md b/openspec/changes/archive/2026-04-09-add-asset-realname-time/proposal.md new file mode 100644 index 0000000..dba369b --- /dev/null +++ b/openspec/changes/archive/2026-04-09-add-asset-realname-time/proposal.md @@ -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 接口 diff --git a/openspec/changes/archive/2026-04-09-add-asset-realname-time/specs/asset-resolve/spec.md b/openspec/changes/archive/2026-04-09-add-asset-realname-time/specs/asset-resolve/spec.md new file mode 100644 index 0000000..a8df723 --- /dev/null +++ b/openspec/changes/archive/2026-04-09-add-asset-realname-time/specs/asset-resolve/spec.md @@ -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` 不被修改 diff --git a/openspec/changes/archive/2026-04-09-add-asset-realname-time/tasks.md b/openspec/changes/archive/2026-04-09-add-asset-realname-time/tasks.md new file mode 100644 index 0000000..3adc74a --- /dev/null +++ b/openspec/changes/archive/2026-04-09-add-asset-realname-time/tasks.md @@ -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 diff --git a/openspec/specs/asset-resolve/spec.md b/openspec/specs/asset-resolve/spec.md index fd8b616..2e17b1c 100644 --- a/openspec/specs/asset-resolve/spec.md +++ b/openspec/specs/asset-resolve/spec.md @@ -45,6 +45,9 @@ - `activated_at`: 激活时间(未激活时为空) - `created_at`: 创建时间 - `updated_at`: 更新时间 +- `real_name_at`(`*time.Time`,可为 null):最近一次完成实名的时间 + - **card 类型**:直接取 `IotCard.first_realname_at`(未实名过则为 null) + - **device 类型**:当前所有绑定卡 `first_realname_at` 中的最小值(无已实名卡则为 null) *状态与套餐字段(device 和 card 均有)*: - `real_name_status`: 实名状态(整型) @@ -91,6 +94,7 @@ - `network_status`: 网络状态 - `current_month_usage_mb`: 本月已用流量(来自持久化缓存字段) - `last_sync_at`: 最后与 Gateway 同步时间 +- `real_name_at`(`*time.Time`,可为 null):该卡最近一次完成实名的时间,取 `IotCard.first_realname_at` **流量展示计算规则**: - `package_used_mb = current_month_usage_mb × virtual_ratio` @@ -158,3 +162,44 @@ - **WHEN** 管理员输入 identifier 为 IoT 卡的手机号(MSISDN) - **THEN** 注册表未命中,fallback 查 tb_iot_card 的 msisdn 字段 - **THEN** 若存在多张卡的 MSISDN 相同,返回第一条匹配记录(MSISDN 非唯一,存在歧义,记录 warn 日志) + +#### 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` 数组中每个 `DeviceCardInfo` 的 `real_name_at` 返回各卡自己的 `first_realname_at`(未实名则为 null) + +--- + +### 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` 不被修改