Compare commits
24 Commits
4ff5dcf55e
...
e81d3a58ad
| Author | SHA1 | Date | |
|---|---|---|---|
| e81d3a58ad | |||
| da8aedc96b | |||
| 9995dcd0a3 | |||
| f0f21e52f1 | |||
| a8becf55ff | |||
| f9da1a937f | |||
| a202b2da8a | |||
| 3b54850f1b | |||
| 00dba42a63 | |||
| 3450c20236 | |||
| debe8f6edc | |||
| d0ef3bb712 | |||
| 8826d95516 | |||
| 8af5d1a045 | |||
| ff62ea457a | |||
| 71b948f22b | |||
| 129a38f942 | |||
| 15dbf8dc66 | |||
| ba1886c314 | |||
| 8819c94322 | |||
| 825def88a4 | |||
| 42b884aaf8 | |||
| 8a4c6f3cee | |||
| ce6aa62fd6 |
@@ -14,6 +14,15 @@
|
||||
|
||||
*以下功能已在代码库中实现,视为已验证的基础能力:*
|
||||
|
||||
**Validated in Phase 03.1: 设备 sync-info 读取链路修复**
|
||||
- ✓ 设备管理列表/详情返回 online_status/last_online_time/software_version/switch_mode/last_gateway_sync_at — DEVICE-02/03
|
||||
- ✓ 设备绑卡列表(ListBindings)返回 is_current 字段 — DEVICE-03
|
||||
- ✓ 资产 Resolve/Refresh BoundCardInfo 包含 is_current(isCurrentMap 模式) — DEVICE-03
|
||||
- ✓ admin 资产解析接口(AssetResolveResponse)返回 5 个 DB 缓存字段 — DEVICE-02/03
|
||||
- ✓ GetRealtimeStatus device 类型实时调用 Gateway sync-info,返回 DeviceGatewayInfo — DEVICE-04
|
||||
- ✓ C 端 GetAssetInfo device_realtime 从真实 Gateway 数据填充(删除 buildMockDeviceRealtime)— DEVICE-04
|
||||
- ✓ Gateway 失败降级:IMEI 为空不发起调用,失败只记 Warn,device_realtime 返回 null — DEVICE-04
|
||||
|
||||
**Validated in Phase 2: PERM/FIN/REALNAME/POLL 修复**
|
||||
- ✓ 企业账号 Resolve 接口移除错误拦截,Refresh 接口新增拦截(只读)— PERM-01/02
|
||||
- ✓ 提现审批人字段补全(approved_by/approved_at)— FIN-01
|
||||
@@ -77,11 +86,12 @@
|
||||
- [x] C-2:提现拒绝 remark 必填校验(Reject 缺少 Validate 调用)
|
||||
- [x] C-3:激活配置并发保护(两步 UPDATE 无锁,可出现两条 active=true)
|
||||
|
||||
**方案 D — 设备体系完善**
|
||||
- [ ] D-0:设备模型字段扩展(online_status / last_online_time / software_version 等)
|
||||
- [ ] D-1:Gateway sync-info 同步接口对接(CurrentIccid 预留结构待填充)
|
||||
- [ ] D-2:device_sim_binding 新增 is_current 字段(当前使用卡标识)
|
||||
- [ ] D-3:设备 Refresh + 详情接入 sync-info(在线状态/固件版本/当前卡实时更新)
|
||||
**方案 D — 设备体系完善** ✅ *Validated in Phase 3 + Phase 03.1*
|
||||
- [x] D-0:设备模型字段扩展(online_status / last_online_time / software_version 等)
|
||||
- [x] D-1:Gateway sync-info 同步接口对接(CurrentIccid 预留结构待填充)
|
||||
- [x] D-2:device_sim_binding 新增 is_current 字段(当前使用卡标识)
|
||||
- [x] D-3:设备 Refresh + 详情接入 sync-info(在线状态/固件版本/当前卡实时更新)
|
||||
- [x] D-读取侧:DTO 字段映射补全(5个DB缓存字段 + 2处IsCurrent)+ GetRealtimeStatus Gateway 实时调用 + C端 mock 替换
|
||||
|
||||
**方案 E — 流量体系改革(破坏性,低峰期)**
|
||||
- [ ] E-1:流量详单改为日粒度缓冲(新建 tb_card_daily_usage,Redis 增量缓存)
|
||||
@@ -157,7 +167,7 @@
|
||||
| 流量体系改革独立发布 | 破坏性变更(DB 迁移 + Redis 架构),风险隔离 | — Pending |
|
||||
|
||||
---
|
||||
*Last updated: 2026-03-28 after Phase 2 (perm-fin-realname-poll) completion — PERM-01/02, FIN-01/02/03, REALNAME-01~05, POLL-01~04 all validated*
|
||||
*Last updated: 2026-03-28 after Phase 03.1 (sync-info-phase-3-dto) completion — device reading-side gap fully closed*
|
||||
|
||||
## Evolution
|
||||
|
||||
|
||||
@@ -47,10 +47,10 @@
|
||||
|
||||
### DEVICE — 设备体系完善(方案 D)
|
||||
|
||||
- [ ] **DEVICE-01**: 设备模型字段扩展 — DB 迁移新增 `online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at`
|
||||
- [ ] **DEVICE-02**: Gateway sync-info 同步接口对接 — `internal/gateway/device.go` 新增 `SyncDeviceInfo()` 方法
|
||||
- [ ] **DEVICE-03**: `tb_device_sim_binding` 新增 `is_current` 字段 — DB 迁移 + Model + 更新逻辑
|
||||
- [ ] **DEVICE-04**: 设备 Refresh + 详情接入 sync-info — `RefreshDevice()` 在刷新卡数据后调用 `SyncDeviceInfo()`,更新设备在线状态/固件版本/当前卡标识
|
||||
- [x] **DEVICE-01**: 设备模型字段扩展 — DB 迁移新增 `online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at`
|
||||
- [x] **DEVICE-02**: Gateway sync-info 同步接口对接 — `internal/gateway/device.go` 新增 `SyncDeviceInfo()` 方法
|
||||
- [x] **DEVICE-03**: `tb_device_sim_binding` 新增 `is_current` 字段 — DB 迁移 + Model + 更新逻辑
|
||||
- [x] **DEVICE-04**: 设备 Refresh + 详情接入 sync-info — `RefreshDevice()` 在刷新卡数据后调用 `SyncDeviceInfo()`,更新设备在线状态/固件版本/当前卡标识
|
||||
|
||||
### REFUND — 退款完整功能(方案 I)
|
||||
|
||||
@@ -142,10 +142,10 @@
|
||||
| POLL-02 | Phase 2 | Complete |
|
||||
| POLL-03 | Phase 2 | Complete |
|
||||
| POLL-04 | Phase 2 | Complete |
|
||||
| DEVICE-01 | Phase 3 | Pending |
|
||||
| DEVICE-02 | Phase 3 | Pending |
|
||||
| DEVICE-03 | Phase 3 | Pending |
|
||||
| DEVICE-04 | Phase 3 | Pending |
|
||||
| DEVICE-01 | Phase 3 | Complete |
|
||||
| DEVICE-02 | Phase 3 | Complete |
|
||||
| DEVICE-03 | Phase 3 | Complete |
|
||||
| DEVICE-04 | Phase 3 | Complete |
|
||||
| REFUND-01 | Phase 4 | Pending |
|
||||
| REFUND-02 | Phase 4 | Pending |
|
||||
| REFUND-03 | Phase 4 | Pending |
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
- [x] **Phase 1: P0 紧急修复** — 修复所有阻塞主链路的 P0 Bug,使充值→购包→佣金→实名激活全链路可跑通 (completed 2026-03-27)
|
||||
- [x] **Phase 2: 权限/财务/实名/轮询修复** — 补全企业权限、提现财务流程、实名激活架构重构、轮询系统小修 (completed 2026-03-28)
|
||||
- [ ] **Phase 3: 设备体系完善** — 扩展设备模型字段、对接 Gateway sync-info、完善绑卡标识
|
||||
- [x] **Phase 3: 设备体系完善** — 扩展设备模型字段、对接 Gateway sync-info、完善绑卡标识 (completed 2026-03-28)
|
||||
- [ ] **Phase 4: 退款 + 支付 + 运营修复** — 实现退款完整流程、补全富友支付接口、修复运营逻辑
|
||||
- [ ] **Phase 5: 代码质量清理** — 消除技术债务:佣金断链可观测、删废弃代码、修复权限不一致、迁移分层
|
||||
- [ ] **Phase 6: 流量体系改革(低峰期)** — 破坏性变更:日粒度流量表 + Redis 增量缓存 + 每日落盘任务
|
||||
@@ -70,10 +70,24 @@ Plans:
|
||||
2. 调用设备刷新接口后,设备的在线状态、固件版本、当前使用卡(is_current)从 Gateway 实时更新
|
||||
3. tb_device_sim_binding 表含 is_current 字段,同一设备同一时刻只有一条 is_current=true 的绑定记录
|
||||
4. 设备详情接口返回数据包含 online_status、software_version 等字段,值与 Gateway 同步结果一致
|
||||
**Plans**: TBD
|
||||
**Plans**: 2 plans
|
||||
|
||||
Plans:
|
||||
- [x] 03-01-PLAN.md — DEVICE-01/02/03:DB 迁移 + 模型扩展 + Gateway SyncDeviceInfo + DTO 字段(Wave 1)
|
||||
- [x] 03-02-PLAN.md — DEVICE-04:Asset Service 接入 sync-info,Refresh 追加设备状态同步(Wave 2)
|
||||
|
||||
---
|
||||
|
||||
### Phase 03.1: 设备 sync-info 读取链路修复:补全 Phase 3 新增字段在查询接口的 DTO 映射缺口 (INSERTED)
|
||||
|
||||
**Goal:** 补全 Phase 3 完成的设备数据写入链路在读取侧的缺口:修复 DTO 字段映射遗漏(5个 DB 缓存字段 + 2处 IsCurrent)+ 建立设备实时状态的 Gateway 查询链路(替换 mock → 真实 Gateway 数据)
|
||||
**Requirements**: DEVICE-02, DEVICE-03, DEVICE-04
|
||||
**Depends on:** Phase 3
|
||||
**Plans:** 1/1 plans complete
|
||||
|
||||
Plans:
|
||||
- [ ] 03.1-01-PLAN.md — 补全静态 DTO 映射缺口 + 建立 GetRealtimeStatus Gateway 实时调用 + C 端 stub 替换(Wave 1)
|
||||
|
||||
### Phase 4: 退款 + 支付 + 运营修复
|
||||
**Goal**: 实现完整退款流程(申请→审批→佣金回扣),补全富友支付 JSAPI/小程序接口(替换留桩),修复运营逻辑断点(卡状态 3/4 触发、payment_status 枚举统一)
|
||||
**Depends on**: Phase 2(佣金链路稳定后再做退款佣金回扣)
|
||||
@@ -124,7 +138,7 @@ Plans:
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. P0 紧急修复 | 5/5 | Complete | 2026-03-27 |
|
||||
| 2. 权限/财务/实名/轮询修复 | 2/2 | Complete | 2026-03-28 |
|
||||
| 3. 设备体系完善 | 0/TBD | Not started | - |
|
||||
| 3. 设备体系完善 | 2/2 | Complete | 2026-03-28 |
|
||||
| 4. 退款 + 支付 + 运营修复 | 0/TBD | Not started | - |
|
||||
| 5. 代码质量清理 | 0/TBD | Not started | - |
|
||||
| 6. 流量体系改革(低峰期)| 0/TBD | Not started | - |
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: active
|
||||
last_updated: "2026-03-28T02:52:41.931Z"
|
||||
status: unknown
|
||||
last_updated: "2026-03-28T05:41:21.983Z"
|
||||
progress:
|
||||
total_phases: 6
|
||||
completed_phases: 2
|
||||
total_plans: 7
|
||||
completed_plans: 7
|
||||
total_phases: 7
|
||||
completed_phases: 4
|
||||
total_plans: 10
|
||||
completed_plans: 10
|
||||
---
|
||||
|
||||
# Project State — 君鸿卡管系统
|
||||
@@ -21,6 +21,8 @@ progress:
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 4
|
||||
Plan: Not started
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Current Phase** | 3 — 设备体系完善 |
|
||||
@@ -40,7 +42,7 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
|
||||
**Core Value:** 业务链路完整可用——代理能采购分配卡/设备,C端客户能充值购包激活套餐,佣金能正确计算并可提现。这条主链路必须端到端跑通。
|
||||
|
||||
**Current Focus:** Phase 03 — 设备体系完善
|
||||
**Current Focus:** Phase 03 — device-system
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +52,8 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
|-------|------|--------------|--------|
|
||||
| 1 | P0 紧急修复 | CRITICAL-01~08(8项) | ✓ Complete (2026-03-28) |
|
||||
| 2 | 权限/财务/实名/轮询修复 | PERM-01~02, FIN-01~03, REALNAME-01~05, POLL-01~04(14项) | ✓ Complete (2026-03-28) |
|
||||
| 3 | 设备体系完善 | DEVICE-01~04(4项) | ○ Not started |
|
||||
| 3 | 设备体系完善 | DEVICE-01~04(4项) | ✓ Complete (2026-03-28) |
|
||||
| 3.1 | 设备 sync-info 读取链路修复 | DEVICE-02~04 读取侧 | ✓ Complete (2026-03-28) |
|
||||
| 4 | 退款 + 支付 + 运营修复 | REFUND-01~03, PAY-01~04, OPS-01~02(9项) | ○ Not started |
|
||||
| 5 | 代码质量清理 | CLEAN-01~06(6项) | ○ Not started |
|
||||
| 6 | 流量体系改革(低峰期)| TRAFFIC-01~05(5项) | ○ Not started |
|
||||
@@ -69,6 +72,9 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
| Phases complete | 2/6 |
|
||||
|
||||
---
|
||||
| Phase 03-device-system P01 | 8min | 2 tasks | 10 files |
|
||||
| Phase 03-device-system P02 | 10min | 2 tasks | 3 files |
|
||||
| Phase 03.1-sync-info-phase-3-dto P01 | 5min | 3 tasks | 6 files |
|
||||
|
||||
## Execution Log
|
||||
|
||||
@@ -81,6 +87,7 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
| Phase 01 P05 | 8min | 2 tasks | 3 files |
|
||||
| Phase 02 P01 | 10min | 2 tasks | 14 files |
|
||||
| Phase 02 P02 | 25min | 2 tasks | 8 files |
|
||||
| Phase 03.1 P01 | 5min | 3 tasks | 6 files |
|
||||
|
||||
---
|
||||
|
||||
@@ -92,8 +99,11 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
- REALNAME-01~05 是原子重构,必须在一个 plan 内完整执行,不可拆分
|
||||
- REFUND 退款佣金回扣(REFUND-03)必须使用整数算术,禁用 float64
|
||||
- CRITICAL-02 依赖 CRITICAL-01,CRITICAL-05 依赖 CRITICAL-04
|
||||
- DEVICE-02/03/04 依赖 DEVICE-01(字段扩展先行)
|
||||
- DEVICE-02/03/04 依赖 DEVICE-01(字段扩展先行)✓ Phase 03 完成
|
||||
- REFUND-02/03 依赖 REFUND-01(数据模型先行)
|
||||
- DEVICE-04: gatewayClient nil guard 可选注入,sync-info 失败仅记 Warn 不阻断 Refresh 主流程
|
||||
- UpdateIsCurrentByDeviceID 事务两步原子更新 is_current,保证同一设备唯一 is_current=true
|
||||
- DeviceGatewayInfo(B端)与 DeviceRealtimeInfo(C端)独立结构体,SwitchMode 字符串→整型转换(Phase 03.1 决策)
|
||||
|
||||
### Architecture Notes
|
||||
|
||||
@@ -103,6 +113,10 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
- 流量体系当前用直接覆盖写,Phase 6 改为增量累加 + 日粒度 DB
|
||||
- expiry_base 枚举已替换 enable_realname_activation,激活决策为三维(卡类型+购买路径+expiry_base)
|
||||
|
||||
### Roadmap Evolution
|
||||
|
||||
- Phase 03.1 inserted after Phase 3: 设备 sync-info 读取链路修复:补全 Phase 3 新增字段在查询接口的 DTO 映射缺口 (URGENT)
|
||||
|
||||
### Known Risks
|
||||
|
||||
- sellerCostPrice 错误赋值可能不止场景 5(CRITICAL-06 已处理 6 处)
|
||||
@@ -114,7 +128,7 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
|
||||
## Session Continuity
|
||||
|
||||
**Next Action:** Run `/gsd-plan-phase 3` to create detailed plan for Phase 3 (设备体系完善)
|
||||
**Next Action:** Run `/gsd-plan-phase 4` to create detailed plan for Phase 4 (退款 + 支付 + 运营修复)
|
||||
|
||||
**Context Files:**
|
||||
|
||||
@@ -125,4 +139,4 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 每个 Bug 的完整修复规格(含代码片段)
|
||||
|
||||
---
|
||||
*State initialized: 2026-03-27 | Last phase completed: Phase 2 (2026-03-28)*
|
||||
*State initialized: 2026-03-27 | Last phase completed: Phase 03.1 (2026-03-28)*
|
||||
|
||||
403
.planning/phases/03-device-system/03-01-PLAN.md
Normal file
403
.planning/phases/03-device-system/03-01-PLAN.md
Normal file
@@ -0,0 +1,403 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
plan: "01"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- migrations/000090_device_fields_extension.up.sql
|
||||
- migrations/000090_device_fields_extension.down.sql
|
||||
- migrations/000091_device_sim_binding_is_current.up.sql
|
||||
- migrations/000091_device_sim_binding_is_current.down.sql
|
||||
- internal/model/device.go
|
||||
- internal/model/device_sim_binding.go
|
||||
- internal/gateway/models.go
|
||||
- internal/gateway/device.go
|
||||
- internal/model/dto/device_dto.go
|
||||
- internal/model/dto/asset_dto.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DEVICE-01
|
||||
- DEVICE-02
|
||||
- DEVICE-03
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "tb_device 表含 online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at 字段"
|
||||
- "tb_device_sim_binding 表含 is_current 字段"
|
||||
- "Gateway Client 具备 SyncDeviceInfo() 方法"
|
||||
- "DeviceResponse DTO 新增 5 个字段;BoundCardInfo + DeviceCardBindingResponse 新增 is_current"
|
||||
- "make migrate-up 执行成功,go build ./... 编译通过"
|
||||
artifacts:
|
||||
- path: "migrations/000090_device_fields_extension.up.sql"
|
||||
provides: "tb_device 字段扩展迁移"
|
||||
- path: "migrations/000091_device_sim_binding_is_current.up.sql"
|
||||
provides: "tb_device_sim_binding.is_current 迁移"
|
||||
- path: "internal/model/device.go"
|
||||
provides: "Device 模型新增 5 字段"
|
||||
- path: "internal/model/device_sim_binding.go"
|
||||
provides: "DeviceSimBinding 模型新增 is_current"
|
||||
- path: "internal/gateway/models.go"
|
||||
provides: "SyncDeviceInfoReq / SyncDeviceInfoResp 结构定义"
|
||||
- path: "internal/gateway/device.go"
|
||||
provides: "SyncDeviceInfo() 方法"
|
||||
- path: "internal/model/dto/device_dto.go"
|
||||
provides: "DeviceResponse + DeviceCardBindingResponse 字段扩展"
|
||||
- path: "internal/model/dto/asset_dto.go"
|
||||
provides: "BoundCardInfo.IsCurrent 字段扩展"
|
||||
key_links:
|
||||
- from: "internal/gateway/device.go:SyncDeviceInfo"
|
||||
to: "internal/gateway/client.go:doRequestWithResponse"
|
||||
via: "泛型调用"
|
||||
pattern: "doRequestWithResponse\\[SyncDeviceInfoResp\\]"
|
||||
- from: "internal/model/device.go:Device"
|
||||
to: "migrations/000090"
|
||||
via: "字段对应迁移"
|
||||
pattern: "OnlineStatus.*online_status"
|
||||
---
|
||||
|
||||
<objective>
|
||||
建立设备体系的数据基础层:扩展 DB 字段(DEVICE-01)、新增 is_current 绑定标识(DEVICE-03)、对接 Gateway sync-info 接口(DEVICE-02)。
|
||||
|
||||
Purpose: Plan 2 的 Refresh 接入依赖本 Plan 的全部产出(模型字段 + Gateway 方法 + DTO 定义)
|
||||
Output: 2 个迁移对、Device/DeviceSimBinding 模型更新、Gateway SyncDeviceInfo 方法、DTO 扩展
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-device-system/03-CONTEXT.md
|
||||
|
||||
<interfaces>
|
||||
<!-- 现有代码契约,executor 直接使用,无需探索代码库 -->
|
||||
|
||||
**internal/gateway/device.go(现有方法):**
|
||||
```go
|
||||
func (c *Client) GetDeviceInfo(ctx context.Context, req *DeviceInfoReq) (*DeviceInfoResp, error)
|
||||
func (c *Client) GetSlotInfo(ctx context.Context, req *DeviceInfoReq) (*SlotInfoResp, error)
|
||||
// 其他方法略
|
||||
```
|
||||
|
||||
**internal/gateway/client.go(泛型请求方法):**
|
||||
```go
|
||||
// 签名:
|
||||
func doRequestWithResponse[T any](c *Client, ctx context.Context, path string, params interface{}) (*T, error)
|
||||
// 用法(参考 GetDeviceInfo):
|
||||
return doRequestWithResponse[DeviceInfoResp](c, ctx, "/device/info", req)
|
||||
```
|
||||
|
||||
**internal/gateway/models.go 中已有的设备 DTO(参考风格):**
|
||||
```go
|
||||
type DeviceInfoReq struct {
|
||||
CardNo string `json:"cardNo,omitempty" description:"..."`
|
||||
DeviceID string `json:"deviceId,omitempty" description:"..."`
|
||||
}
|
||||
type DeviceInfoResp struct {
|
||||
IMEI string `json:"imei" description:"..."`
|
||||
OnlineStatus int `json:"onlineStatus" description:"..."`
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**internal/model/device.go 现有字段(末尾追加新字段):**
|
||||
```go
|
||||
type Device struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
VirtualNo string `gorm:"column:virtual_no;..."`
|
||||
IMEI string `gorm:"column:imei;type:varchar(20);..."`
|
||||
SN string `gorm:"column:sn;type:varchar(100);..."`
|
||||
// ... 其余字段 ...
|
||||
Generation int `gorm:"column:generation;type:int;not null;default:1;..."`
|
||||
// 在 Generation 之后追加 5 个新字段
|
||||
}
|
||||
```
|
||||
|
||||
**internal/model/device_sim_binding.go 现有字段(末尾追加 is_current):**
|
||||
```go
|
||||
type DeviceSimBinding struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
DeviceID uint `gorm:"column:device_id;..."`
|
||||
IotCardID uint `gorm:"column:iot_card_id;..."`
|
||||
SlotPosition int `gorm:"column:slot_position;..."`
|
||||
BindStatus int `gorm:"column:bind_status;..."`
|
||||
BindTime *time.Time `gorm:"column:bind_time;..."`
|
||||
UnbindTime *time.Time `gorm:"column:unbind_time;..."`
|
||||
// 追加 IsCurrent
|
||||
}
|
||||
```
|
||||
|
||||
**internal/model/dto/device_dto.go 中 DeviceResponse 和 DeviceCardBindingResponse:**
|
||||
```go
|
||||
type DeviceResponse struct {
|
||||
// ... 现有字段 ...
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
// 在 UpdatedAt 之后追加 5 个新字段
|
||||
}
|
||||
|
||||
type DeviceCardBindingResponse struct {
|
||||
// ... 现有字段(末尾是 BindTime)...
|
||||
BindTime *time.Time `json:"bind_time,omitempty" description:"绑定时间"`
|
||||
// 追加 IsCurrent
|
||||
}
|
||||
```
|
||||
|
||||
**internal/model/dto/asset_dto.go 中 BoundCardInfo:**
|
||||
```go
|
||||
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:"实名状态"`
|
||||
SlotPosition int `json:"slot_position,omitempty" description:"插槽位置"`
|
||||
// 追加 IsCurrent
|
||||
}
|
||||
```
|
||||
|
||||
**Gateway sync-info 接口:**
|
||||
- URL:`POST /v1/iot/openApi/device/sync-info`(业务路径参数传 `/device/sync-info`)
|
||||
- cardNo:ICCID(>15位)/ IMEI(=15位)/ SN(=11位)
|
||||
- 关键响应字段:current_iccid, software_version, switch_mode, online_status, last_online_time(ISO 8601 string), last_update_time
|
||||
|
||||
**最新迁移编号**:`000089_realname_expiry_base`,新迁移从 `000090` 开始
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: DB 迁移 + 模型字段扩展(D-0 + D-2 DB 层)</name>
|
||||
<files>
|
||||
migrations/000090_device_fields_extension.up.sql,
|
||||
migrations/000090_device_fields_extension.down.sql,
|
||||
migrations/000091_device_sim_binding_is_current.up.sql,
|
||||
migrations/000091_device_sim_binding_is_current.down.sql,
|
||||
internal/model/device.go,
|
||||
internal/model/device_sim_binding.go
|
||||
</files>
|
||||
<action>
|
||||
## 迁移文件 000090(tb_device 字段扩展)
|
||||
|
||||
创建 `migrations/000090_device_fields_extension.up.sql`:
|
||||
```sql
|
||||
-- 扩展 tb_device 设备表,新增 Gateway sync-info 同步的存储字段
|
||||
-- 这些字段有"离线意义"(设备下线后仍需保留最后状态),非实时字段(rssi/battery 等)不入库
|
||||
ALTER TABLE tb_device
|
||||
ADD COLUMN online_status INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN last_online_time TIMESTAMP NULL,
|
||||
ADD COLUMN software_version VARCHAR(100) NOT NULL DEFAULT '',
|
||||
ADD COLUMN switch_mode VARCHAR(10) NOT NULL DEFAULT '0',
|
||||
ADD COLUMN last_gateway_sync_at TIMESTAMP NULL;
|
||||
|
||||
COMMENT ON COLUMN tb_device.online_status IS '在线状态:0未知,1在线,2离线';
|
||||
COMMENT ON COLUMN tb_device.last_online_time IS '最后在线时间(来自 Gateway sync-info)';
|
||||
COMMENT ON COLUMN tb_device.software_version IS '固件版本号';
|
||||
COMMENT ON COLUMN tb_device.switch_mode IS '切卡模式:0=自动,1=手动';
|
||||
COMMENT ON COLUMN tb_device.last_gateway_sync_at IS '最后一次 sync-info 同步时间';
|
||||
```
|
||||
|
||||
创建 `migrations/000090_device_fields_extension.down.sql`:
|
||||
```sql
|
||||
ALTER TABLE tb_device
|
||||
DROP COLUMN IF EXISTS online_status,
|
||||
DROP COLUMN IF EXISTS last_online_time,
|
||||
DROP COLUMN IF EXISTS software_version,
|
||||
DROP COLUMN IF EXISTS switch_mode,
|
||||
DROP COLUMN IF EXISTS last_gateway_sync_at;
|
||||
```
|
||||
|
||||
## 迁移文件 000091(tb_device_sim_binding 新增 is_current)
|
||||
|
||||
创建 `migrations/000091_device_sim_binding_is_current.up.sql`:
|
||||
```sql
|
||||
-- 新增 is_current 字段,标识设备当前正在使用的卡(由 sync-info current_iccid 字段决定)
|
||||
ALTER TABLE tb_device_sim_binding
|
||||
ADD COLUMN is_current BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
COMMENT ON COLUMN tb_device_sim_binding.is_current IS '是否为当前使用的卡(同一设备同一时刻最多一条为 true)';
|
||||
```
|
||||
|
||||
创建 `migrations/000091_device_sim_binding_is_current.down.sql`:
|
||||
```sql
|
||||
ALTER TABLE tb_device_sim_binding
|
||||
DROP COLUMN IF EXISTS is_current;
|
||||
```
|
||||
|
||||
## 执行迁移
|
||||
|
||||
执行 `make migrate-up` 并确认:
|
||||
- `make migrate-version` 输出版本为 91,dirty=false
|
||||
- 使用 PostgreSQL MCP 验证 tb_device 新增 5 列、tb_device_sim_binding 新增 is_current
|
||||
|
||||
## 更新 internal/model/device.go
|
||||
|
||||
在 `Generation` 字段之后追加 5 个字段(保持与其他字段相同的标签格式):
|
||||
```go
|
||||
// 以下字段由 Gateway sync-info 接口同步,有离线存储意义
|
||||
OnlineStatus int `gorm:"column:online_status;type:int;not null;default:0;comment:在线状态:0未知 1在线 2离线" json:"online_status"`
|
||||
LastOnlineTime *time.Time `gorm:"column:last_online_time;comment:最后在线时间(来自 Gateway sync-info)" json:"last_online_time,omitempty"`
|
||||
SoftwareVersion string `gorm:"column:software_version;type:varchar(100);not null;default:'';comment:固件版本号" json:"software_version"`
|
||||
SwitchMode string `gorm:"column:switch_mode;type:varchar(10);not null;default:'0';comment:切卡模式:0=自动 1=手动" json:"switch_mode"`
|
||||
LastGatewaySyncAt *time.Time `gorm:"column:last_gateway_sync_at;comment:最后一次 sync-info 同步时间" json:"last_gateway_sync_at,omitempty"`
|
||||
```
|
||||
|
||||
## 更新 internal/model/device_sim_binding.go
|
||||
|
||||
在 `UnbindTime` 字段之后追加:
|
||||
```go
|
||||
IsCurrent bool `gorm:"column:is_current;type:boolean;not null;default:false;comment:是否为当前使用的卡" json:"is_current"`
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>make migrate-up && make migrate-version && go build ./...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- migrate-version 显示版本 91,dirty=false
|
||||
- go build ./... 编译无新增错误
|
||||
- tb_device 含 5 个新字段,tb_device_sim_binding 含 is_current(可用 MCP dbhub_search_objects 验证)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Gateway SyncDeviceInfo 方法 + DTO 字段扩展(D-1 + D-2 DTO 层)</name>
|
||||
<files>
|
||||
internal/gateway/models.go,
|
||||
internal/gateway/device.go,
|
||||
internal/model/dto/device_dto.go,
|
||||
internal/model/dto/asset_dto.go
|
||||
</files>
|
||||
<action>
|
||||
## internal/gateway/models.go — 新增 SyncDeviceInfo 请求/响应结构
|
||||
|
||||
在文件末尾(`// ============ 结束 ============` 之前或文件末)追加,放在「设备相关 DTO」分组下:
|
||||
|
||||
```go
|
||||
// SyncDeviceInfoReq sync-info 同步查询设备信息请求
|
||||
// cardNo 规则:>15 位用 ICCID,=15 位用 IMEI,=11 位用 SN
|
||||
type SyncDeviceInfoReq struct {
|
||||
CardNo string `json:"card_no" description:"设备标识(ICCID/IMEI/SN)"`
|
||||
}
|
||||
|
||||
// SyncDeviceInfoResp sync-info 同步查询设备信息响应(对应 Gateway data 字段)
|
||||
// 注意:所有字段均可能为 null/零值,表示暂无数据
|
||||
type SyncDeviceInfoResp struct {
|
||||
DeviceID string `json:"device_id" description:"设备ID(IMEI/SN)"`
|
||||
DeviceName string `json:"device_name" description:"设备名称"`
|
||||
IMEI string `json:"imei" description:"IMEI号"`
|
||||
CurrentIccid string `json:"current_iccid" description:"当前使用的ICCID"`
|
||||
DeviceType string `json:"device_type" description:"设备类型:1=诺行,2=玺龙,3=迎势达"`
|
||||
SoftwareVersion string `json:"software_version" description:"软件版本号"`
|
||||
MacAddress string `json:"mac_address" description:"MAC地址"`
|
||||
SSID string `json:"ssid" description:"WiFi热点名称"`
|
||||
WifiEnabled bool `json:"wifi_enabled" description:"WiFi开关状态"`
|
||||
SwitchMode string `json:"switch_mode" description:"切卡模式:0=自动,1=手动"`
|
||||
RSSI string `json:"rssi" description:"接收信号强度"`
|
||||
BatteryLevel *int `json:"battery_level" description:"电池电量百分比(无电池时为null)"`
|
||||
OnlineStatus int `json:"online_status" description:"在线状态:1=在线,2=离线"`
|
||||
LastUpdateTime string `json:"last_update_time" description:"设备信息最后更新时间(ISO 8601)"`
|
||||
LastOnlineTime string `json:"last_online_time" description:"设备最后在线时间(ISO 8601)"`
|
||||
RunTime string `json:"run_time" description:"本次开机运行时间(秒)"`
|
||||
DailyUsage string `json:"daily_usage" description:"日使用流量(字节)"`
|
||||
}
|
||||
```
|
||||
|
||||
## internal/gateway/device.go — 新增 SyncDeviceInfo 方法
|
||||
|
||||
在文件末尾追加:
|
||||
|
||||
```go
|
||||
// SyncDeviceInfo 同步查询设备信息(直接返回,无需回调)
|
||||
// 与异步 /device/info 的区别:直接返回全量数据,无需 callbackUrl
|
||||
// POST /device/sync-info
|
||||
func (c *Client) SyncDeviceInfo(ctx context.Context, req *SyncDeviceInfoReq) (*SyncDeviceInfoResp, error) {
|
||||
if req.CardNo == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "cardNo 不能为空")
|
||||
}
|
||||
return doRequestWithResponse[SyncDeviceInfoResp](c, ctx, "/device/sync-info", req)
|
||||
}
|
||||
```
|
||||
|
||||
更新文件顶部包注释:「提供设备相关的 8 个 API 方法封装」(从 7 改为 8)
|
||||
|
||||
## internal/model/dto/device_dto.go — DeviceResponse 和 DeviceCardBindingResponse 字段扩展
|
||||
|
||||
**DeviceResponse** — 在 `UpdatedAt` 字段之后追加(per D-0):
|
||||
```go
|
||||
// 设备实时同步字段(由 sync-info 接口写入)
|
||||
OnlineStatus int `json:"online_status" description:"在线状态:0未知 1在线 2离线"`
|
||||
LastOnlineTime *time.Time `json:"last_online_time,omitempty" description:"最后在线时间"`
|
||||
SoftwareVersion string `json:"software_version" description:"固件版本号"`
|
||||
SwitchMode string `json:"switch_mode" description:"切卡模式:0=自动,1=手动"`
|
||||
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty" description:"最后 sync-info 同步时间"`
|
||||
```
|
||||
|
||||
**DeviceCardBindingResponse** — 在 `BindTime` 字段之后追加(per D-2):
|
||||
```go
|
||||
IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"`
|
||||
```
|
||||
|
||||
## internal/model/dto/asset_dto.go — BoundCardInfo 字段扩展
|
||||
|
||||
在 `SlotPosition` 字段之后追加(per D-2,D-11 决策):
|
||||
```go
|
||||
IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"`
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
执行 `go build ./...`,确认无编译错误。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- SyncDeviceInfoReq / SyncDeviceInfoResp 在 internal/gateway/models.go 中定义
|
||||
- SyncDeviceInfo() 方法在 internal/gateway/device.go 中实现
|
||||
- DeviceResponse 含 5 个新字段,DeviceCardBindingResponse 含 is_current
|
||||
- BoundCardInfo 含 is_current
|
||||
- go build ./... 零新增错误
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# 1. 迁移正常
|
||||
make migrate-version
|
||||
# 期望:version=91, dirty=false
|
||||
|
||||
# 2. 编译通过
|
||||
go build ./...
|
||||
|
||||
# 3. 字段验证(通过 PostgreSQL MCP)
|
||||
# dbhub_search_objects: object_type=column, table=tb_device
|
||||
# 确认:online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at
|
||||
# dbhub_search_objects: object_type=column, table=tb_device_sim_binding
|
||||
# 确认:is_current
|
||||
|
||||
# 4. Gateway 方法搜索
|
||||
grep -r "SyncDeviceInfo" internal/gateway/
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] `make migrate-version` 输出 version=91, dirty=false
|
||||
- [ ] `go build ./...` 零新增编译错误
|
||||
- [ ] `internal/gateway/device.go` 含 `SyncDeviceInfo()` 方法
|
||||
- [ ] `internal/gateway/models.go` 含 `SyncDeviceInfoReq` / `SyncDeviceInfoResp`
|
||||
- [ ] `DeviceResponse` 含 5 个新字段,`DeviceCardBindingResponse` 含 `is_current`
|
||||
- [ ] `BoundCardInfo` 含 `is_current`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/03-device-system/03-01-SUMMARY.md`
|
||||
</output>
|
||||
93
.planning/phases/03-device-system/03-01-SUMMARY.md
Normal file
93
.planning/phases/03-device-system/03-01-SUMMARY.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
plan: "01"
|
||||
subsystem: device
|
||||
tags: [migration, model, gateway, dto]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- tb_device 5个 sync-info 存储字段
|
||||
- tb_device_sim_binding.is_current 字段
|
||||
- Gateway SyncDeviceInfo() 方法
|
||||
- DeviceResponse 5个新字段
|
||||
- DeviceCardBindingResponse.IsCurrent
|
||||
- BoundCardInfo.IsCurrent
|
||||
affects:
|
||||
- Plan 03-02(依赖本 Plan 全部产出:模型字段 + Gateway 方法 + DTO 定义)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- golang-migrate 迁移文件(up/down 对)
|
||||
- GORM 模型字段追加(保持 gorm tag 格式一致性)
|
||||
- Gateway 泛型 doRequestWithResponse[T] 调用模式
|
||||
key_files:
|
||||
created:
|
||||
- migrations/000090_device_fields_extension.up.sql
|
||||
- migrations/000090_device_fields_extension.down.sql
|
||||
- migrations/000091_device_sim_binding_is_current.up.sql
|
||||
- migrations/000091_device_sim_binding_is_current.down.sql
|
||||
modified:
|
||||
- internal/model/device.go
|
||||
- internal/model/device_sim_binding.go
|
||||
- internal/gateway/models.go
|
||||
- internal/gateway/device.go
|
||||
- internal/model/dto/device_dto.go
|
||||
- internal/model/dto/asset_dto.go
|
||||
decisions:
|
||||
- "5 个 sync-info 字段有离线存储意义(设备下线后保留最后状态),非实时字段(rssi/battery)不入库"
|
||||
- "SyncDeviceInfo 使用同步 doRequestWithResponse[T] 泛型调用,与现有 GetDeviceInfo 区分(async vs sync)"
|
||||
- "is_current 字段位于 tb_device_sim_binding,由 sync-info current_iccid 决定"
|
||||
metrics:
|
||||
duration: "8min"
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 2
|
||||
files: 10
|
||||
---
|
||||
|
||||
# Phase 03 Plan 01: 设备体系数据基础层 Summary
|
||||
|
||||
**一句话总结:** 新增 DB 迁移扩展 tb_device(5字段)和 tb_device_sim_binding(is_current),对接 Gateway sync-info 接口,并同步扩展 Device/DeviceSimBinding 模型及 DTO。
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | DB 迁移 + 模型字段扩展(D-0 + D-2 DB 层) | `42b884a` | 4 迁移文件 + 2 模型文件 |
|
||||
| 2 | Gateway SyncDeviceInfo 方法 + DTO 字段扩展(D-1 + D-2 DTO 层) | `825def8` | gateway/models.go, gateway/device.go, device_dto.go, asset_dto.go |
|
||||
|
||||
## Artifacts Delivered
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
- **000090_device_fields_extension**:tb_device 新增 `online_status`(INT DEFAULT 0)、`last_online_time`(TIMESTAMP NULL)、`software_version`(VARCHAR(100) DEFAULT '')、`switch_mode`(VARCHAR(10) DEFAULT '0')、`last_gateway_sync_at`(TIMESTAMP NULL)
|
||||
- **000091_device_sim_binding_is_current**:tb_device_sim_binding 新增 `is_current`(BOOLEAN DEFAULT FALSE)
|
||||
|
||||
### 模型更新
|
||||
|
||||
- **Device 模型**:追加 5 个字段(OnlineStatus / LastOnlineTime / SoftwareVersion / SwitchMode / LastGatewaySyncAt)
|
||||
- **DeviceSimBinding 模型**:追加 IsCurrent 字段
|
||||
|
||||
### Gateway 层
|
||||
|
||||
- **SyncDeviceInfoReq / SyncDeviceInfoResp**:18 字段的完整响应结构,支持 current_iccid / online_status / software_version / switch_mode / last_online_time 等关键字段
|
||||
- **SyncDeviceInfo() 方法**:POST /device/sync-info,同步返回全量数据(区别于异步 /device/info)
|
||||
|
||||
### DTO 扩展
|
||||
|
||||
- **DeviceResponse**:新增 5 个 sync-info 字段(与模型一一对应)
|
||||
- **DeviceCardBindingResponse**:新增 `IsCurrent bool`
|
||||
- **BoundCardInfo**:新增 `IsCurrent bool`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — 计划完全按预期执行。
|
||||
|
||||
## Verification
|
||||
|
||||
- ✅ `make migrate-version` 输出版本 91,dirty=false
|
||||
- ✅ `go build ./...` 零新增编译错误
|
||||
- ✅ PostgreSQL MCP 验证 tb_device 含 5 个新列
|
||||
- ✅ PostgreSQL MCP 验证 tb_device_sim_binding 含 is_current
|
||||
- ✅ `SyncDeviceInfo` 方法在 internal/gateway/device.go 中实现
|
||||
|
||||
## Self-Check: PASSED
|
||||
405
.planning/phases/03-device-system/03-02-PLAN.md
Normal file
405
.planning/phases/03-device-system/03-02-PLAN.md
Normal file
@@ -0,0 +1,405 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
plan: "02"
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "03-01"
|
||||
files_modified:
|
||||
- internal/service/asset/service.go
|
||||
- internal/bootstrap/services.go
|
||||
- internal/store/postgres/device_sim_binding_store.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DEVICE-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "调用设备刷新接口后,设备的在线状态、固件版本、当前使用卡从 Gateway 实时更新"
|
||||
- "同一设备同一时刻只有一条 is_current=true 的绑定记录(事务原子更新)"
|
||||
- "sync-info 调用失败时不阻断刷新流程,仅记录 Warn 日志"
|
||||
- "go build ./... 编译通过"
|
||||
artifacts:
|
||||
- path: "internal/service/asset/service.go"
|
||||
provides: "updateDeviceFromSyncInfo 私有函数 + Refresh device 分支 sync-info 调用"
|
||||
contains: "updateDeviceFromSyncInfo"
|
||||
- path: "internal/bootstrap/services.go"
|
||||
provides: "asset.Service 注入 gatewayClient"
|
||||
- path: "internal/store/postgres/device_sim_binding_store.go"
|
||||
provides: "UpdateIsCurrentByDeviceID Store 方法(事务两步更新)"
|
||||
key_links:
|
||||
- from: "internal/service/asset/service.go:Refresh"
|
||||
to: "internal/gateway/device.go:SyncDeviceInfo"
|
||||
via: "s.gatewayClient.SyncDeviceInfo()"
|
||||
pattern: "gatewayClient\\.SyncDeviceInfo"
|
||||
- from: "internal/service/asset/service.go:updateDeviceFromSyncInfo"
|
||||
to: "internal/store/postgres/device_sim_binding_store.go:UpdateIsCurrentByDeviceID"
|
||||
via: "事务原子更新 is_current"
|
||||
pattern: "UpdateIsCurrentByDeviceID"
|
||||
---
|
||||
|
||||
<objective>
|
||||
将 Gateway sync-info 接口接入设备 Refresh 流程(DEVICE-04):在设备刷新完成绑定卡后,调用 SyncDeviceInfo 同步设备自身状态(在线状态、固件版本、is_current 当前卡标识)。
|
||||
|
||||
Purpose: 使设备详情页展示真实的 online_status、software_version、is_current 等字段(Phase 3 成功标准 2、4)
|
||||
Output: updateDeviceFromSyncInfo 私有函数 + Refresh device 分支追加 sync-info 调用
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-device-system/03-CONTEXT.md
|
||||
@.planning/phases/03-device-system/03-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- 本 Plan 依赖 Plan 01 产出的接口,executor 直接使用 -->
|
||||
|
||||
**Plan 01 产出的类型(internal/gateway/models.go):**
|
||||
```go
|
||||
type SyncDeviceInfoReq struct {
|
||||
CardNo string `json:"card_no"`
|
||||
}
|
||||
type SyncDeviceInfoResp struct {
|
||||
CurrentIccid string `json:"current_iccid"`
|
||||
SoftwareVersion string `json:"software_version"`
|
||||
SwitchMode string `json:"switch_mode"`
|
||||
OnlineStatus int `json:"online_status"` // 1=在线, 2=离线
|
||||
LastOnlineTime string `json:"last_online_time"` // ISO 8601 字符串
|
||||
LastUpdateTime string `json:"last_update_time"` // ISO 8601 字符串
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
**Plan 01 产出的方法(internal/gateway/device.go):**
|
||||
```go
|
||||
func (c *Client) SyncDeviceInfo(ctx context.Context, req *SyncDeviceInfoReq) (*SyncDeviceInfoResp, error)
|
||||
```
|
||||
|
||||
**Plan 01 产出的模型字段(internal/model/device.go):**
|
||||
```go
|
||||
OnlineStatus int // gorm:"column:online_status"
|
||||
LastOnlineTime *time.Time // gorm:"column:last_online_time"
|
||||
SoftwareVersion string // gorm:"column:software_version"
|
||||
SwitchMode string // gorm:"column:switch_mode"
|
||||
LastGatewaySyncAt *time.Time // gorm:"column:last_gateway_sync_at"
|
||||
```
|
||||
|
||||
**Plan 01 产出的模型字段(internal/model/device_sim_binding.go):**
|
||||
```go
|
||||
IsCurrent bool // gorm:"column:is_current"
|
||||
```
|
||||
|
||||
**internal/service/asset/service.go 现有 Refresh device 分支(约 295-340 行):**
|
||||
```go
|
||||
case "device":
|
||||
// 检查冷却期
|
||||
cooldownKey := constants.RedisDeviceRefreshCooldownKey(id)
|
||||
if s.redis.Exists(ctx, cooldownKey).Val() > 0 {
|
||||
return nil, errors.New(errors.CodeTooManyRequests, "刷新过于频繁,请30秒后再试")
|
||||
}
|
||||
|
||||
// 查所有绑定卡,逐一刷新
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡失败")
|
||||
}
|
||||
for _, b := range bindings {
|
||||
card, cardErr := s.iotCardStore.GetByID(ctx, b.IotCardID)
|
||||
if cardErr != nil {
|
||||
logger.GetAppLogger().Warn("刷新设备绑定卡失败:查卡失败", ...)
|
||||
continue
|
||||
}
|
||||
if refreshErr := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); refreshErr != nil {
|
||||
logger.GetAppLogger().Warn("刷新设备绑定卡失败:网关调用失败", ...)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置冷却 Key
|
||||
s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration)
|
||||
|
||||
return s.GetRealtimeStatus(ctx, "device", id)
|
||||
```
|
||||
|
||||
**internal/service/asset/service.go 现有 Service 结构体(需要追加 gatewayClient):**
|
||||
```go
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
deviceStore *postgres.DeviceStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
packageStore *postgres.PackageStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
shopStore *postgres.ShopStore
|
||||
redis *redis.Client
|
||||
iotCardService IotCardRefresher
|
||||
// 追加:
|
||||
// gatewayClient *gateway.Client
|
||||
}
|
||||
|
||||
// New() 函数目前签名(需要追加 gatewayClient 参数):
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
packageStore *postgres.PackageStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
redisClient *redis.Client,
|
||||
iotCardService IotCardRefresher,
|
||||
) *Service
|
||||
```
|
||||
|
||||
**internal/bootstrap/services.go 现有 asset.New 调用(需要追加 deps.GatewayClient):**
|
||||
```go
|
||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
|
||||
```
|
||||
|
||||
**内置 import 路径参考:**
|
||||
- `"github.com/break/junhong_cmp_fiber/internal/gateway"`
|
||||
- `"time"` 包用于 Parse ISO 8601 字符串
|
||||
|
||||
**ISO 8601 时间格式参考(来自 Gateway 文档示例):**
|
||||
- `last_online_time: "2026-03-23T16:53:18+08:00"`
|
||||
- Parse 用 `time.Parse(time.RFC3339, str)` 即可处理带时区的 ISO 8601
|
||||
|
||||
**DeviceSimBindingStore 现有方法(store 层,需新增 UpdateIsCurrentByDeviceID):**
|
||||
```go
|
||||
func (s *DeviceSimBindingStore) ListByDeviceID(ctx context.Context, deviceID uint) ([]*model.DeviceSimBinding, error)
|
||||
// 其他方法:Create, GetByID, GetByDeviceAndCard, Unbind, CountByDeviceID...
|
||||
// 无 UpdateIsCurrentByDeviceID,需要新增
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: DeviceSimBindingStore 新增 UpdateIsCurrentByDeviceID 方法</name>
|
||||
<files>
|
||||
internal/store/postgres/device_sim_binding_store.go
|
||||
</files>
|
||||
<action>
|
||||
在 `internal/store/postgres/device_sim_binding_store.go` 末尾追加以下方法:
|
||||
|
||||
```go
|
||||
// UpdateIsCurrentByDeviceID 原子更新设备当前使用的卡标识
|
||||
// 使用事务两步操作:先全部设为 false,再将 currentIccid 对应行设为 true
|
||||
// currentIccid 为空字符串时,仅执行全部清空(无当前卡的情况)
|
||||
func (s *DeviceSimBindingStore) UpdateIsCurrentByDeviceID(ctx context.Context, deviceID uint, currentIccid string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 第一步:将该设备所有活跃绑定的 is_current 设为 false
|
||||
if err := tx.Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND bind_status = 1", deviceID).
|
||||
Update("is_current", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 第二步:将当前 ICCID 对应的绑定记录设为 is_current = true
|
||||
if currentIccid == "" {
|
||||
return nil
|
||||
}
|
||||
// 通过子查询找到对应的 iot_card_id(避免跨表 JOIN 更新)
|
||||
var iotCardID uint
|
||||
if err := tx.Table("tb_iot_card").
|
||||
Select("id").
|
||||
Where("iccid = ? AND deleted_at IS NULL", currentIccid).
|
||||
Scan(&iotCardID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if iotCardID == 0 {
|
||||
// 未找到对应卡记录,不更新(可能卡尚未入库)
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND iot_card_id = ? AND bind_status = 1", deviceID, iotCardID).
|
||||
Update("is_current", true).Error
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
确保文件顶部已有 `"gorm.io/gorm"` import(已有,无需新增)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./internal/store/postgres/...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- `UpdateIsCurrentByDeviceID` 方法存在于 device_sim_binding_store.go
|
||||
- 事务内两步操作:先全清 false,再按 ICCID 设 true
|
||||
- go build 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Asset Service 注入 gatewayClient + 实现 updateDeviceFromSyncInfo + Refresh 接入 sync-info</name>
|
||||
<files>
|
||||
internal/service/asset/service.go,
|
||||
internal/bootstrap/services.go
|
||||
</files>
|
||||
<action>
|
||||
## 步骤 1:internal/service/asset/service.go — 结构体 + 构造函数 + Refresh 分支 + 新函数
|
||||
|
||||
**① import 块追加**(在现有 import 中添加):
|
||||
```go
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"time"
|
||||
```
|
||||
注意:`time` 可能已有,检查后选择性添加。
|
||||
|
||||
**② Service 结构体追加字段**(per D-13,D-15 决策):
|
||||
在 `iotCardService IotCardRefresher` 行之后追加:
|
||||
```go
|
||||
gatewayClient *gateway.Client // 用于调用 sync-info 同步设备信息(可为 nil,失败时仅记录日志)
|
||||
```
|
||||
|
||||
**③ New() 函数签名追加参数**:
|
||||
在 `iotCardService IotCardRefresher,` 参数之后追加:
|
||||
```go
|
||||
gatewayClient *gateway.Client,
|
||||
```
|
||||
并在 return 的结构体字面量中追加:
|
||||
```go
|
||||
gatewayClient: gatewayClient,
|
||||
```
|
||||
|
||||
**④ Refresh device 分支追加 sync-info 调用**(per D-3,D-15 决策):
|
||||
在 `s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration)` 之后、`return s.GetRealtimeStatus(ctx, "device", id)` 之前插入:
|
||||
|
||||
```go
|
||||
// 刷新设备自身信息(在线状态、当前卡、固件版本等),失败不阻断刷新流程(per D-15)
|
||||
if s.gatewayClient != nil {
|
||||
device, devErr := s.deviceStore.GetByID(ctx, id)
|
||||
if devErr == nil {
|
||||
// IMEI 优先,无则用 SN(per D-3 注释说明)
|
||||
cardNo := device.IMEI
|
||||
if cardNo == "" {
|
||||
cardNo = device.SN
|
||||
}
|
||||
if cardNo != "" {
|
||||
if syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
|
||||
CardNo: cardNo,
|
||||
}); syncErr == nil {
|
||||
s.updateDeviceFromSyncInfo(ctx, id, syncResp)
|
||||
} else {
|
||||
logger.GetAppLogger().Warn("sync-info 调用失败",
|
||||
zap.Uint("device_id", id),
|
||||
zap.Error(syncErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**⑤ 新增 updateDeviceFromSyncInfo 私有函数**(per D-13,D-14 决策):
|
||||
在 `Refresh` 函数之后追加:
|
||||
|
||||
```go
|
||||
// updateDeviceFromSyncInfo 根据 Gateway sync-info 响应更新设备状态字段和当前卡标识
|
||||
// 失败时只记录日志,不返回错误(非关键步骤)
|
||||
func (s *Service) updateDeviceFromSyncInfo(ctx context.Context, deviceID uint, resp *gateway.SyncDeviceInfoResp) {
|
||||
now := time.Now()
|
||||
|
||||
// 解析 LastOnlineTime(ISO 8601 字符串 → *time.Time)
|
||||
var lastOnlineTime *time.Time
|
||||
if resp.LastOnlineTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, resp.LastOnlineTime); err == nil {
|
||||
lastOnlineTime = &t
|
||||
}
|
||||
}
|
||||
|
||||
// 更新设备 5 个存储字段
|
||||
updates := map[string]any{
|
||||
"online_status": resp.OnlineStatus,
|
||||
"software_version": resp.SoftwareVersion,
|
||||
"switch_mode": resp.SwitchMode,
|
||||
"last_gateway_sync_at": now,
|
||||
}
|
||||
if lastOnlineTime != nil {
|
||||
updates["last_online_time"] = lastOnlineTime
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.Device{}).
|
||||
Where("id = ?", deviceID).
|
||||
Updates(updates).Error; err != nil {
|
||||
logger.GetAppLogger().Warn("sync-info 更新设备字段失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新 is_current 标识(事务原子操作,per D-14 决策)
|
||||
if err := s.deviceSimBindingStore.UpdateIsCurrentByDeviceID(ctx, deviceID, resp.CurrentIccid); err != nil {
|
||||
logger.GetAppLogger().Warn("sync-info 更新 is_current 失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 步骤 2:internal/bootstrap/services.go — Asset.New 追加 deps.GatewayClient
|
||||
|
||||
找到以下行:
|
||||
```go
|
||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
|
||||
```
|
||||
|
||||
替换为:
|
||||
```go
|
||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient),
|
||||
```
|
||||
|
||||
## 最终验证
|
||||
|
||||
执行 `go build ./...` 确认编译无错误。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- `asset.Service` 含 `gatewayClient *gateway.Client` 字段
|
||||
- `asset.New()` 新增 `gatewayClient` 参数,bootstrap 调用处已追加 `deps.GatewayClient`
|
||||
- `updateDeviceFromSyncInfo` 函数存在:更新 5 个 device 字段 + 调用 UpdateIsCurrentByDeviceID
|
||||
- `Refresh` device 分支在绑定卡刷新后追加 sync-info 调用(nil guard + Warn 日志)
|
||||
- `go build ./...` 零新增编译错误
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# 1. 编译验证
|
||||
go build ./...
|
||||
|
||||
# 2. Gateway 方法确认
|
||||
grep -n "SyncDeviceInfo\|updateDeviceFromSyncInfo" internal/service/asset/service.go
|
||||
|
||||
# 3. Store 方法确认
|
||||
grep -n "UpdateIsCurrentByDeviceID" internal/store/postgres/device_sim_binding_store.go
|
||||
|
||||
# 4. Bootstrap 注入确认
|
||||
grep -n "GatewayClient" internal/bootstrap/services.go | grep -i asset
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] `go build ./...` 零新增编译错误
|
||||
- [ ] `internal/service/asset/service.go` 含 `updateDeviceFromSyncInfo` 函数
|
||||
- [ ] `internal/service/asset/service.go` Refresh device 分支含 `gatewayClient.SyncDeviceInfo()` 调用
|
||||
- [ ] `internal/store/postgres/device_sim_binding_store.go` 含 `UpdateIsCurrentByDeviceID` 方法
|
||||
- [ ] `internal/bootstrap/services.go` asset.New() 调用传入了 `deps.GatewayClient`
|
||||
- [ ] sync-info 失败时只记录 Warn,不影响 Refresh 主流程
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/03-device-system/03-02-SUMMARY.md`
|
||||
</output>
|
||||
83
.planning/phases/03-device-system/03-02-SUMMARY.md
Normal file
83
.planning/phases/03-device-system/03-02-SUMMARY.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
plan: "02"
|
||||
subsystem: device
|
||||
tags: [service, gateway, sync-info, store, bootstrap]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- Plan 03-01(tb_device 5字段 + tb_device_sim_binding.is_current + Gateway SyncDeviceInfo 方法)
|
||||
provides:
|
||||
- DeviceSimBindingStore.UpdateIsCurrentByDeviceID(事务原子更新 is_current)
|
||||
- asset.Service.updateDeviceFromSyncInfo(私有函数,同步 5 个设备字段 + is_current)
|
||||
- asset.Service.Refresh device 分支接入 SyncDeviceInfo(nil guard + Warn 日志)
|
||||
- asset.Service 注入 gatewayClient
|
||||
affects:
|
||||
- DEVICE-04 需求(设备 Refresh + 详情接入 sync-info)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- GORM Transaction 两步原子更新模式
|
||||
- Service nil guard 可选依赖注入模式
|
||||
- ISO 8601 字符串 → time.Time(time.RFC3339)
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- internal/store/postgres/device_sim_binding_store.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/bootstrap/services.go
|
||||
decisions:
|
||||
- "gatewayClient 使用 nil guard(而非断言非 nil),允许 bootstrap 传入 nil 依然安全运行"
|
||||
- "sync-info 失败只记 Warn 日志,不阻断 Refresh 主流程(非关键步骤,D-15 决策)"
|
||||
- "UpdateIsCurrentByDeviceID 事务两步:先全清 false,再按 ICCID 设 true(原子保证唯一 is_current)"
|
||||
metrics:
|
||||
duration: "10min"
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 2
|
||||
files: 3
|
||||
---
|
||||
|
||||
# Phase 03 Plan 02: Gateway sync-info 接入 Refresh 流程 Summary
|
||||
|
||||
**一句话总结:** 在设备 Refresh 流程末尾接入 Gateway SyncDeviceInfo,通过 updateDeviceFromSyncInfo 原子更新在线状态、固件版本等 5 个设备字段及 is_current 当前卡标识(DEVICE-04)。
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | DeviceSimBindingStore 新增 UpdateIsCurrentByDeviceID 方法 | `ba1886c` | device_sim_binding_store.go |
|
||||
| 2 | Asset Service 注入 gatewayClient + 实现 updateDeviceFromSyncInfo + Refresh 接入 sync-info | `15dbf8d` | service.go, services.go |
|
||||
|
||||
## Artifacts Delivered
|
||||
|
||||
### Store 层
|
||||
|
||||
- **UpdateIsCurrentByDeviceID**:事务两步原子更新。第一步将设备所有活跃绑定的 `is_current` 清为 false;第二步通过子查询定位 `current_iccid` 对应的 `iot_card_id`,将该绑定设为 `is_current = true`。`currentIccid` 为空时仅清空。
|
||||
|
||||
### Service 层
|
||||
|
||||
- **Service 结构体**:新增 `gatewayClient *gateway.Client` 字段(允许 nil)
|
||||
- **New() 构造函数**:新增 `gatewayClient *gateway.Client` 参数
|
||||
- **Refresh device 分支**:在设置冷却 Key 之后、`GetRealtimeStatus` 返回之前,增加 sync-info 调用逻辑(nil guard 保护,失败只记 Warn 日志)
|
||||
- **updateDeviceFromSyncInfo 私有函数**:
|
||||
1. 解析 `last_online_time`(ISO 8601 → `*time.Time`,`time.RFC3339`)
|
||||
2. GORM Updates 更新 5 个字段:`online_status`、`software_version`、`switch_mode`、`last_gateway_sync_at`、`last_online_time`(非空时)
|
||||
3. 调用 `UpdateIsCurrentByDeviceID` 原子更新 `is_current`
|
||||
|
||||
### Bootstrap 层
|
||||
|
||||
- **services.go**:`asset.New()` 调用末尾追加 `deps.GatewayClient` 参数
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — 计划完全按预期执行。
|
||||
|
||||
## Verification
|
||||
|
||||
- ✅ `go build ./...` 零新增编译错误
|
||||
- ✅ `updateDeviceFromSyncInfo` 方法存在于 `internal/service/asset/service.go`
|
||||
- ✅ `Refresh` device 分支含 `gatewayClient.SyncDeviceInfo()` 调用(nil guard + Warn 日志)
|
||||
- ✅ `UpdateIsCurrentByDeviceID` 存在于 `internal/store/postgres/device_sim_binding_store.go`
|
||||
- ✅ `internal/bootstrap/services.go` asset.New() 传入了 `deps.GatewayClient`
|
||||
- ✅ sync-info 失败时只记录 Warn,不影响 Refresh 主流程
|
||||
|
||||
## Self-Check: PASSED
|
||||
132
.planning/phases/03-device-system/03-CONTEXT.md
Normal file
132
.planning/phases/03-device-system/03-CONTEXT.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Phase 3: 设备体系完善 - Context
|
||||
|
||||
**Gathered:** 2026-03-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
扩展设备数据模型(D-0)、对接 Gateway sync-info 同步接口(D-1)、建立"当前使用卡"标识(D-2)、将刷新接口接入 sync-info 完成端到端数据更新(D-3)。
|
||||
|
||||
全部为修复性/完善性变更,无新业务模块。仅影响设备相关模型、Gateway 客户端、资产刷新服务。
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Plan 分组策略
|
||||
|
||||
- **D-01:** 拆分为 **2 个 Plan**:
|
||||
- **Plan 1(基础层)**:D-0(迁移+模型+DTO)+ D-1(Gateway SyncDeviceInfo 方法)+ D-2(is_current 字段)
|
||||
- **Plan 2(功能层)**:D-3(Refresh 接入 sync-info + updateDeviceFromSyncInfo)
|
||||
- **D-02:** Plan 1 内部执行顺序:D-0 先行(DB 迁移、Device Model、DeviceSimBinding Model、DTO 全部到位)→ D-1 + D-2 并行(都依赖 D-0,互不依赖)
|
||||
- **D-03:** 每个子任务独立一个 commit(延续 Phase 1/2 粒度规范)
|
||||
|
||||
### DB 迁移
|
||||
|
||||
- **D-04:** 迁移编号从 `000090` 起,拆为 2 个迁移文件:
|
||||
- `000090_device_fields_extension`:tb_device 新增 5 个字段
|
||||
- `000091_device_sim_binding_is_current`:tb_device_sim_binding 新增 is_current 字段
|
||||
- **D-05:** 使用 `.up.sql` / `.down.sql` 双文件形式(与现有迁移规范一致)
|
||||
|
||||
### Gateway 类型定义位置
|
||||
|
||||
- **D-06:** `SyncDeviceInfoReq` / `SyncDeviceInfoResp` 放在 **`internal/gateway/models.go`**(与现有 DeviceInfoReq / SlotInfoResp 等结构保持风格一致)
|
||||
- **D-07:** `SyncDeviceInfo()` 方法放在 `internal/gateway/device.go`(与现有 GetDeviceInfo / GetSlotInfo 等方法一起)
|
||||
- 修正文档中将类型嵌入 device.go 代码片段属于示意,不代表最终位置
|
||||
|
||||
### 实时字段暴露范围
|
||||
|
||||
- **D-08:** Refresh 响应中**不暴露**非存储实时字段(rssi / battery_level / ssid / wifi_enabled 等)
|
||||
- **D-09:** 实时字段仅在调用 sync-info 时**写入 DB 存储字段**(online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at)
|
||||
- **D-10:** 这满足所有成功标准(标准 2、4 只要求存储字段同步)
|
||||
|
||||
### is_current 字段的 DTO 同步范围
|
||||
|
||||
- **D-11:** `is_current` 同步到**两处 DTO**:
|
||||
- `BoundCardInfo`(`internal/model/dto/asset_dto.go`)—— 资产实时状态 Refresh 响应中的绑定卡列表
|
||||
- `DeviceCardBindingResponse`(`internal/model/dto/device_dto.go`)—— 设备管理页 ListDeviceCards 接口
|
||||
- **D-12:** 两处同步,保持前端两个场景下的数据一致
|
||||
|
||||
### updateDeviceFromSyncInfo 实现细节
|
||||
|
||||
- **D-13:** `updateDeviceFromSyncInfo` 为私有函数,位于 `internal/service/asset/service.go`
|
||||
- **D-14:** is_current 更新使用**事务**:先全部设为 false,再把 current_iccid 对应行设为 true(原子操作,防止中间状态)
|
||||
- **D-15:** sync-info 调用失败时**不阻断刷新流程**,仅记录 Warn 日志(`sync-info 调用失败`)
|
||||
|
||||
### Agent's Discretion
|
||||
|
||||
- `last_online_time` / `last_gateway_sync_at` 字段 parsing(sync-info 返回字符串,需转换为 time.Time)的具体格式,agent 参考 Gateway 文档中的 `last_update_time` 示例格式
|
||||
- `DeviceRealtimeInfo` 独立 DTO 结构**不创建**(不暴露实时字段,无需此结构)
|
||||
- `updateDeviceFromSyncInfo` 的 DB 更新使用 GORM `Updates(map[string]any{...})`,不用 `Save()`(避免全字段覆写)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### 修复规格(核心参考)
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 完整修复规格书,含每个需求的代码片段和人工验收清单:
|
||||
- 方案 D(DEVICE-01~04):约第 955-1130 行(D-0 字段策略 + D-1 Gateway 接口 + D-2 is_current + D-3 Refresh 接入)
|
||||
|
||||
### Gateway 接口文档
|
||||
- `docs/第三方文档/gateway设备详情同步接口.md` — sync-info 接口完整字段说明(data 字段映射表、cardNo 规则、认证方式)
|
||||
|
||||
### 需要修改的核心文件
|
||||
- `internal/model/device.go` — D-0:新增 5 个 DB 字段
|
||||
- `internal/model/device_sim_binding.go` — D-2:新增 is_current 字段
|
||||
- `internal/model/dto/asset_dto.go` — D-2:BoundCardInfo 新增 is_current
|
||||
- `internal/model/dto/device_dto.go` — D-0:DeviceResponse 新增 5 个字段;D-2:DeviceCardBindingResponse 新增 is_current
|
||||
- `internal/gateway/models.go` — D-1:新增 SyncDeviceInfoReq / SyncDeviceInfoResp
|
||||
- `internal/gateway/device.go` — D-1:新增 SyncDeviceInfo() 方法
|
||||
- `internal/service/asset/service.go` — D-3:Refresh device 分支追加 sync-info 调用;新增 updateDeviceFromSyncInfo()
|
||||
- `migrations/` — D-0:000090_device_fields_extension;D-2:000091_device_sim_binding_is_current
|
||||
|
||||
### 现有参考实现
|
||||
- `internal/service/asset/service.go:Refresh()` — 约第 295 行,现有 device 刷新分支(追加 sync-info 调用入口)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `internal/gateway/client.go:doRequestWithResponse[T]()` — 泛型请求方法,SyncDeviceInfo 直接复用(与 GetDeviceInfo 相同调用模式)
|
||||
- `internal/service/asset/service.go:Refresh()` — 已有 device 冷却 Key + 绑定卡遍历框架,D-3 在此追加 sync-info 调用
|
||||
- `internal/service/asset/service.go:deviceSimBindingStore` — 已注入,D-3 的 updateDeviceFromSyncInfo 可直接使用
|
||||
|
||||
### Established Patterns
|
||||
- Gateway 类型分离:结构体在 `models.go`,方法在对应领域文件(device.go / flow_card.go 等)
|
||||
- DB 更新:使用 `Updates(map[string]any{...})` + `Model(&T{})` + `Where`,不用 `Save()`
|
||||
- 错误处理:非关键步骤失败用 `logger.Warn`,关键步骤用 `errors.Wrap`
|
||||
|
||||
### Integration Points
|
||||
- `internal/gateway/device.go` — 新增 SyncDeviceInfo 方法
|
||||
- `internal/service/asset/service.go:Refresh()` device 分支 —— sync-info 调用入口(冷却 Key 设置之前,已遍历完绑定卡之后)
|
||||
- `BoundCardInfo` 在 `asset_dto.go`(被 AssetRealtimeStatusResponse 引用,多个 C 端和 B 端接口共用)
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- 修正文档中 D-3 的代码片段提供了 `updateDeviceFromSyncInfo` 的函数骨架,agent 应直接参考(文件:`.sisyphus/plans/修正业务-完整方案.md` 方案 D-3 段落)
|
||||
- `SyncDeviceInfoResp.LastOnlineTime` 字段类型用 `string`(Gateway 返回字符串),在 updateDeviceFromSyncInfo 内按需 Parse
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- 设备实时字段(rssi / battery_level / ssid 等)暴露到 Refresh 响应 —— 超出当前成功标准,考虑 Phase 4+ 或按需补充
|
||||
- DeviceRealtimeInfo 独立 DTO 结构 —— 若未来暴露实时字段时再创建
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 03-device-system*
|
||||
*Context gathered: 2026-03-28*
|
||||
70
.planning/phases/03-device-system/03-DISCUSSION-LOG.md
Normal file
70
.planning/phases/03-device-system/03-DISCUSSION-LOG.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Phase 3: 设备体系完善 - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-03-28
|
||||
**Phase:** 03-device-system
|
||||
**Areas discussed:** Plan 分组策略, 实时字段暴露范围, is_current DTO 同步范围, Gateway 类型定义位置
|
||||
|
||||
---
|
||||
|
||||
## Plan 分组策略
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 2 个 Plan(推荐) | Plan 1: D-0+D-1+D-2(基础层),Plan 2: D-3(功能层)—— 与 Phase 2 分法类似 | ✓ |
|
||||
| 1 个 Plan | D-0~D-3 全在一个 Plan 里,顶序执行,简单直接但回滚粒度较粗 | |
|
||||
| 3 个 Plan | Plan 1: D-0,Plan 2: D-1+D-2(并行),Plan 3: D-3 —— 粒度最细 | |
|
||||
|
||||
**User's choice:** 2 个 Plan(推荐)
|
||||
**Notes:** 与 Phase 2 的"小修合并 + REALNAME 独立"分法保持风格一致
|
||||
|
||||
---
|
||||
|
||||
## 实时字段暴露范围
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 不暴露(推荐) | 实时字段仅写入 DB,响应只返回存储字段,满足所有成功标准 | ✓ |
|
||||
| 暴露实时字段 | 在 AssetRealtimeStatusResponse 新增嵌套 DeviceRealtimeInfo 结构(rssi / battery / ssid 等),需较大 DTO 改动 | |
|
||||
|
||||
**User's choice:** 不暴露(推荐)
|
||||
**Notes:** 成功标准 2/4 只要求 online_status / software_version 等存储字段同步,不特别要求实时字段。实时字段暴露推迟到后续阶段按需决策。
|
||||
|
||||
---
|
||||
|
||||
## is_current DTO 同步范围
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 两处都加(推荐) | BoundCardInfo(asset_dto.go)+ DeviceCardBindingResponse(device_dto.go)同时新增 is_current | ✓ |
|
||||
| 仅 BoundCardInfo | 只改 BoundCardInfo,设备管理页绑定列表看不到当前卡标识 | |
|
||||
|
||||
**User's choice:** 两处都加(推荐)
|
||||
**Notes:** 保持前端两个场景下的数据一致性
|
||||
|
||||
---
|
||||
|
||||
## Gateway 类型定义位置
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 放 models.go(推荐) | 与现有 DeviceInfoReq / SlotInfoResp 等结构保持一致,Gateway 类型集中在一个文件 | ✓ |
|
||||
| 放 device.go | 跟修正文档建议,类型和方法在同一文件,但与其他 Gateway 结构不一致 | |
|
||||
|
||||
**User's choice:** 放 models.go(推荐)
|
||||
**Notes:** 修正文档将类型嵌入 device.go 代码片段属于示意用法,不代表最终放置位置,遵循现有项目分离风格
|
||||
|
||||
---
|
||||
|
||||
## Agent's Discretion
|
||||
|
||||
- `updateDeviceFromSyncInfo` 内 is_current 更新的事务策略(先全 false 再设 true)
|
||||
- `last_online_time` 字符串 → time.Time 的 Parse 格式
|
||||
- GORM Updates 而非 Save 的具体写法
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- 设备实时字段(rssi / battery / ssid 等)暴露到 Refresh 响应 —— 超出当前成功标准
|
||||
- DeviceRealtimeInfo 独立 DTO 结构 —— 等实时字段暴露需求时再建
|
||||
141
.planning/phases/03-device-system/03-VERIFICATION.md
Normal file
141
.planning/phases/03-device-system/03-VERIFICATION.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
verified: 2026-03-28T10:00:00Z
|
||||
status: passed
|
||||
score: 9/9 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 03: 设备体系完善 Verification Report
|
||||
|
||||
**Phase Goal:** 扩展设备体系,接入 Gateway sync-info 接口,实现设备实时状态同步(在线状态、固件版本、当前卡标识)
|
||||
**Verified:** 2026-03-28
|
||||
**Status:** ✅ PASSED
|
||||
**Re-verification:** No — 初始验证
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|----|-----------------------------------------------------------------------------------------------|-------------|-------------------------------------------------------------------------------------------|
|
||||
| 1 | tb_device 表含 online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at 字段 | ✓ VERIFIED | PostgreSQL MCP 确认 5 列全部存在;migration 000090 执行;DB version=92(>91) |
|
||||
| 2 | tb_device_sim_binding 表含 is_current 字段 | ✓ VERIFIED | PostgreSQL MCP 确认 is_current 列存在;migration 000091 执行 |
|
||||
| 3 | Gateway Client 具备 SyncDeviceInfo() 方法 | ✓ VERIFIED | internal/gateway/device.go:75 实现完整方法,调用 doRequestWithResponse[SyncDeviceInfoResp] |
|
||||
| 4 | DeviceResponse DTO 新增 5 个字段;BoundCardInfo + DeviceCardBindingResponse 新增 is_current | ✓ VERIFIED | device_dto.go:44-48 含5字段,device_dto.go:85 含IsCurrent,asset_dto.go:64 含IsCurrent |
|
||||
| 5 | make migrate-up 执行成功,go build ./... 编译通过 | ✓ VERIFIED | migrate-version=92, go build ./... 零输出(无错误) |
|
||||
| 6 | 调用设备刷新接口后,设备状态从 Gateway 实时更新 | ✓ VERIFIED | service.go:343-364:nil guard + SyncDeviceInfo 调用 + updateDeviceFromSyncInfo 写库 |
|
||||
| 7 | 同一设备同一时刻只有一条 is_current=true(事务原子更新) | ✓ VERIFIED | device_sim_binding_store.go:208-235:Transaction 两步操作(先全清 false,再按 ICCID 设 true) |
|
||||
| 8 | sync-info 调用失败时不阻断刷新流程,仅记录 Warn 日志 | ✓ VERIFIED | service.go:357-362:syncErr 走 Warn 分支,不 return error,流程继续到 GetRealtimeStatus |
|
||||
| 9 | go build ./... 编译通过 | ✓ VERIFIED | go build ./... 零错误输出 |
|
||||
|
||||
**Score:** 9/9 truths verified
|
||||
|
||||
---
|
||||
|
||||
## Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|------------------------------------------------------------------|---------------------------------------------------|-------------|--------------------------------------------------------------------------|
|
||||
| `migrations/000090_device_fields_extension.up.sql` | tb_device 字段扩展迁移 | ✓ VERIFIED | 文件存在,含 5 个 ALTER TABLE ADD COLUMN + COMMENT 语句 |
|
||||
| `migrations/000091_device_sim_binding_is_current.up.sql` | tb_device_sim_binding.is_current 迁移 | ✓ VERIFIED | 文件存在,含 BOOLEAN NOT NULL DEFAULT FALSE + COMMENT |
|
||||
| `internal/model/device.go` | Device 模型新增 5 字段 | ✓ VERIFIED | 第 41-45 行含 OnlineStatus/LastOnlineTime/SoftwareVersion/SwitchMode/LastGatewaySyncAt |
|
||||
| `internal/model/device_sim_binding.go` | DeviceSimBinding 模型新增 is_current | ✓ VERIFIED | 第 20 行含 IsCurrent bool,带完整 gorm tag |
|
||||
| `internal/gateway/models.go` | SyncDeviceInfoReq / SyncDeviceInfoResp 结构定义 | ✓ VERIFIED | 第 135-160 行,SyncDeviceInfoResp 含 18 个字段(包含关键的 CurrentIccid) |
|
||||
| `internal/gateway/device.go` | SyncDeviceInfo() 方法 | ✓ VERIFIED | 第 72-79 行,有 cardNo 空值检查,调用泛型 doRequestWithResponse[SyncDeviceInfoResp] |
|
||||
| `internal/model/dto/device_dto.go` | DeviceResponse + DeviceCardBindingResponse 字段扩展 | ✓ VERIFIED | DeviceResponse 第 44-48 行含5字段,DeviceCardBindingResponse 第 85 行含 IsCurrent |
|
||||
| `internal/model/dto/asset_dto.go` | BoundCardInfo.IsCurrent 字段扩展 | ✓ VERIFIED | 第 64 行含 IsCurrent bool + description tag |
|
||||
| `internal/store/postgres/device_sim_binding_store.go` | UpdateIsCurrentByDeviceID 方法(事务两步更新) | ✓ VERIFIED | 第 204-235 行,Transaction 两步原子操作,currentIccid 为空时安全返回 nil |
|
||||
| `internal/service/asset/service.go` | gatewayClient 字段 + updateDeviceFromSyncInfo + Refresh 接入 | ✓ VERIFIED | gatewayClient 字段第 41 行,updateDeviceFromSyncInfo 第 373 行,Refresh 接入第 343-364 行 |
|
||||
| `internal/bootstrap/services.go` | asset.New() 传入 deps.GatewayClient | ✓ VERIFIED | 第 180 行 asset.New 末尾参数为 deps.GatewayClient |
|
||||
|
||||
---
|
||||
|
||||
## Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|-------------------------------------------------|-----------------------------------------------------------|----------------------------------------|------------|------------------------------------------------------------------|
|
||||
| `internal/gateway/device.go:SyncDeviceInfo` | `internal/gateway/client.go:doRequestWithResponse` | 泛型调用 | ✓ WIRED | device.go:79 `doRequestWithResponse[SyncDeviceInfoResp](c, ctx, "/device/sync-info", req)` |
|
||||
| `internal/model/device.go:Device` | `migrations/000090` | 字段对应迁移 | ✓ WIRED | OnlineStatus gorm:"column:online_status" ↔ 迁移 ADD COLUMN online_status |
|
||||
| `internal/service/asset/service.go:Refresh` | `internal/gateway/device.go:SyncDeviceInfo` | s.gatewayClient.SyncDeviceInfo() | ✓ WIRED | service.go:353 `s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: cardNo})` |
|
||||
| `internal/service/asset/service.go:updateDeviceFromSyncInfo` | `device_sim_binding_store.go:UpdateIsCurrentByDeviceID` | 事务原子更新 is_current | ✓ WIRED | service.go:408 `s.deviceSimBindingStore.UpdateIsCurrentByDeviceID(ctx, deviceID, resp.CurrentIccid)` |
|
||||
| `internal/bootstrap/services.go` | `internal/service/asset/service.go:New` | deps.GatewayClient 注入 | ✓ WIRED | services.go:180 asset.New(... deps.Redis, iotCard, deps.GatewayClient) |
|
||||
|
||||
---
|
||||
|
||||
## Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|---------------------------------------|------------------------|------------------------------------|--------------------|-------------|
|
||||
| `internal/service/asset/service.go` | syncResp (SyncDeviceInfoResp) | s.gatewayClient.SyncDeviceInfo() | ✓ 真实 Gateway 调用 | ✓ FLOWING |
|
||||
| `device_sim_binding_store.go` | UpdateIsCurrentByDeviceID | GORM 事务 UPDATE 语句 | ✓ 实际 DB 写入 | ✓ FLOWING |
|
||||
| `internal/model/device.go` | updates (map[string]any) | GORM Model.Updates() | ✓ 实际 DB 写入 | ✓ FLOWING |
|
||||
|
||||
---
|
||||
|
||||
## Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|-------------------------------------------|---------------------------------------------------|-------------|----------|
|
||||
| 代码编译无错误 | `go build ./...` | 零输出 | ✓ PASS |
|
||||
| SyncDeviceInfo 方法存在 | grep -n "SyncDeviceInfo" internal/gateway/device.go | 第 72、79 行 | ✓ PASS |
|
||||
| updateDeviceFromSyncInfo 存在于 service | grep -n "updateDeviceFromSyncInfo" service.go | 第 373、375 行 | ✓ PASS |
|
||||
| UpdateIsCurrentByDeviceID 存在于 store | grep -n "UpdateIsCurrentByDeviceID" device_sim_binding_store.go | 第 204、207 行 | ✓ PASS |
|
||||
| bootstrap 注入 GatewayClient | grep -n "GatewayClient" services.go | 第 180 行 | ✓ PASS |
|
||||
| DB 迁移版本 ≥ 91 | make migrate-version | 92 | ✓ PASS |
|
||||
| tb_device.online_status 字段存在 | PostgreSQL MCP dbhub_search_objects | 1 result | ✓ PASS |
|
||||
| tb_device_sim_binding.is_current 字段存在 | PostgreSQL MCP dbhub_search_objects | 1 result | ✓ PASS |
|
||||
|
||||
---
|
||||
|
||||
## Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|------------------------------------------------------------------------------|-------------|-------------------------------------------------------------------|
|
||||
| DEVICE-01 | 03-01 | 设备模型字段扩展 — DB 迁移新增 5 个 sync-info 字段 | ✓ SATISFIED | migration 000090 执行,5 个 DB 列存在,model/device.go 5 个新字段 |
|
||||
| DEVICE-02 | 03-01 | Gateway sync-info 同步接口对接 — SyncDeviceInfo() 方法 | ✓ SATISFIED | internal/gateway/device.go:75 完整实现,泛型调用 doRequestWithResponse |
|
||||
| DEVICE-03 | 03-01 | tb_device_sim_binding 新增 is_current 字段 — DB 迁移 + Model + 更新逻辑 | ✓ SATISFIED | migration 000091,model is_current 字段,UpdateIsCurrentByDeviceID 事务更新 |
|
||||
| DEVICE-04 | 03-02 | 设备 Refresh + 详情接入 sync-info — 刷新卡数据后调用 SyncDeviceInfo | ✓ SATISFIED | service.go Refresh device 分支 343-364 行,updateDeviceFromSyncInfo 完整实现 |
|
||||
|
||||
**Requirements 追踪表状态(REQUIREMENTS.md):** DEVICE-01 ~ DEVICE-04 均标记为 Complete ✅
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| — | — | — | — | 无发现 |
|
||||
|
||||
扫描了以下文件的 TODO/FIXME/PLACEHOLDER/空返回模式:
|
||||
- `internal/service/asset/service.go` — 无问题(`return nil` 均为合法的辅助函数返回)
|
||||
- `internal/store/postgres/device_sim_binding_store.go` — 无问题(`return nil` 为事务内幂等安全分支)
|
||||
- `internal/gateway/device.go` — 无问题
|
||||
- `internal/gateway/models.go` — 无问题
|
||||
- `internal/bootstrap/services.go` — 无问题
|
||||
|
||||
---
|
||||
|
||||
## Human Verification Required
|
||||
|
||||
无需人工验证。所有行为均可通过代码检查 + DB 验证 + 编译检查自动确认。
|
||||
|
||||
以下场景为运行时行为,在实际调用时自然验证:
|
||||
1. **实际 Gateway 连通性** — sync-info 调用需真实 Gateway 服务,本次编译验证代码路径正确,运行时验证留给集成测试
|
||||
2. **is_current 原子性** — 并发场景下的唯一性由 GORM Transaction 保证,代码逻辑已验证
|
||||
|
||||
---
|
||||
|
||||
## Gaps Summary
|
||||
|
||||
无 gaps。Phase 03 全部产出均已实现并验证:
|
||||
|
||||
- **Plan 01**(数据基础层):2 个迁移对(000090/000091)已执行,DB 列已确认存在;Device/DeviceSimBinding 模型更新完整;Gateway SyncDeviceInfo 方法实现正确;DTO 扩展(DeviceResponse 5字段 + IsCurrent 2处)完整
|
||||
- **Plan 02**(Service 层接入):UpdateIsCurrentByDeviceID 事务两步原子更新已实现;asset.Service 注入 gatewayClient;updateDeviceFromSyncInfo 私有函数完整(5字段更新 + is_current 原子更新);Refresh device 分支接入 sync-info(nil guard + Warn 日志,不阻断主流程);bootstrap 正确传入 deps.GatewayClient
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-28_
|
||||
_Verifier: gsd-verifier (claude-sonnet-4-6)_
|
||||
637
.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-PLAN.md
Normal file
637
.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-PLAN.md
Normal file
@@ -0,0 +1,637 @@
|
||||
---
|
||||
phase: 03.1-sync-info-phase-3-dto
|
||||
plan: "01"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/gateway/models.go
|
||||
- internal/model/dto/asset_dto.go
|
||||
- internal/service/device/service.go
|
||||
- internal/service/device/binding.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/handler/app/client_asset.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DEVICE-02
|
||||
- DEVICE-03
|
||||
- DEVICE-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "设备管理列表/详情接口返回 online_status、last_online_time、software_version、switch_mode、last_gateway_sync_at 字段(Phase 3 已写入 DB,现在需要映射到 DTO)"
|
||||
- "设备绑卡列表接口(ListBindings)返回 is_current 字段,标识当前使用的卡"
|
||||
- "资产 Resolve/Refresh 接口的 BoundCardInfo 包含 is_current 字段"
|
||||
- "admin 资产解析接口(AssetResolveResponse)返回 5 个 DB 缓存字段:online_status/last_online_time/software_version/switch_mode/last_gateway_sync_at"
|
||||
- "资产实时状态接口(GetRealtimeStatus)对 device 类型实时调用 Gateway sync-info,返回 DeviceRealtime 字段"
|
||||
- "C 端 GetAsset 接口的 device_realtime 字段从真实 Gateway 数据填充,不再是 mock 数据"
|
||||
- "Gateway 调用失败时(IMEI 为空/超时/离线)device_realtime 返回 null,资产基础信息正常返回"
|
||||
artifacts:
|
||||
- path: "internal/gateway/models.go"
|
||||
provides: "SyncDeviceInfoResp 补全缺失字段(Rsrp/Rsrq/Sinr/WifiPassword/IMSI/LANIP/WANIP/MaxClients/ConnectTime/ULStats/DLStats/LimitSpeed/SyncInterval/MACAddress/Status)"
|
||||
- path: "internal/model/dto/asset_dto.go"
|
||||
provides: "DeviceGatewayInfo 新 B 端实时状态结构体;AssetRealtimeStatusResponse.DeviceRealtime 字段;AssetResolveResponse 5个 DB 缓存字段"
|
||||
- path: "internal/service/device/service.go"
|
||||
provides: "toDeviceResponse() 补全 OnlineStatus/LastOnlineTime/SoftwareVersion/SwitchMode/LastGatewaySyncAt 5个字段映射"
|
||||
- path: "internal/service/device/binding.go"
|
||||
provides: "ListBindings() 补全 IsCurrent 字段映射"
|
||||
- path: "internal/service/asset/service.go"
|
||||
provides: "buildDeviceResolveResponse() 补全 IsCurrent + 5个DB缓存字段;GetRealtimeStatus() device case 追加 Gateway 实时调用"
|
||||
- path: "internal/handler/app/client_asset.go"
|
||||
provides: "GetAsset() 删除 buildMockDeviceRealtime(),改为调用 assetService.GetRealtimeStatus() 并映射到 DeviceRealtimeInfo"
|
||||
key_links:
|
||||
- from: "internal/service/asset/service.go:GetRealtimeStatus()"
|
||||
to: "internal/gateway/device.go:SyncDeviceInfo()"
|
||||
via: "s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: device.IMEI})"
|
||||
pattern: "gatewayClient.*SyncDeviceInfo"
|
||||
- from: "internal/handler/app/client_asset.go:GetAsset()"
|
||||
to: "internal/service/asset/service.go:GetRealtimeStatus()"
|
||||
via: "h.assetService.GetRealtimeStatus(\"device\", deviceID)"
|
||||
pattern: "assetService.GetRealtimeStatus"
|
||||
- from: "internal/service/asset/service.go:buildDeviceResolveResponse()"
|
||||
to: "dto.BoundCardInfo.IsCurrent"
|
||||
via: "isCurrentMap[b.IotCardID] = b.IsCurrent"
|
||||
pattern: "isCurrentMap"
|
||||
---
|
||||
|
||||
<objective>
|
||||
补全 Phase 3 完成的设备数据写入链路在读取侧的缺口:修复 DTO 字段映射遗漏(静态缺口)+ 建立设备实时状态的 Gateway 查询链路(架构对齐)。
|
||||
|
||||
Purpose: Phase 3 已实现 sync-info 调用和 DB 字段写入,但 API 接口未返回这些字段,且 C 端设备实时状态仍是 mock 数据。本 Phase 完成"读取侧"闭环。
|
||||
Output: 6 个文件修改,设备管理/资产解析/C 端资产接口全部返回真实数据。
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/03.1-sync-info-phase-3-dto/03.1-CONTEXT.md
|
||||
|
||||
@internal/gateway/models.go
|
||||
@internal/model/dto/asset_dto.go
|
||||
@internal/model/dto/device_dto.go
|
||||
@internal/model/dto/client_asset_dto.go
|
||||
@internal/service/asset/service.go
|
||||
@internal/service/device/service.go
|
||||
@internal/service/device/binding.go
|
||||
@internal/handler/app/client_asset.go
|
||||
|
||||
<interfaces>
|
||||
<!-- 执行本 Plan 前必须读取以下接口定义,无需探索代码库 -->
|
||||
|
||||
<!-- gateway/models.go 当前 SyncDeviceInfoResp(缺失字段需补全)-->
|
||||
```go
|
||||
type SyncDeviceInfoResp struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
IMEI string `json:"imei"`
|
||||
CurrentIccid string `json:"current_iccid"`
|
||||
DeviceType string `json:"device_type"`
|
||||
SoftwareVersion string `json:"software_version"`
|
||||
MacAddress string `json:"mac_address"`
|
||||
SSID string `json:"ssid"`
|
||||
WifiEnabled bool `json:"wifi_enabled"`
|
||||
SwitchMode string `json:"switch_mode"`
|
||||
RSSI string `json:"rssi"`
|
||||
BatteryLevel *int `json:"battery_level"`
|
||||
OnlineStatus int `json:"online_status"`
|
||||
LastUpdateTime string `json:"last_update_time"`
|
||||
LastOnlineTime string `json:"last_online_time"`
|
||||
RunTime string `json:"run_time"`
|
||||
DailyUsage string `json:"daily_usage"`
|
||||
// 以下字段 D-10 要求补全:
|
||||
// Rsrp int, Rsrq int, Sinr int, WifiPassword string
|
||||
// IPAddress string, WANIP string, LANIP string
|
||||
// MaxClients int, ConnectTime string, Status int
|
||||
// IMSI string, ULStats string, DLStats string
|
||||
// LimitSpeed int, SyncInterval int, MACAddress string(已有 MacAddress,需确认 json tag)
|
||||
}
|
||||
```
|
||||
|
||||
<!-- asset_dto.go 当前 BoundCardInfo(已有 IsCurrent,但 buildDeviceResolveResponse 未填充)-->
|
||||
```go
|
||||
type BoundCardInfo struct {
|
||||
CardID uint `json:"card_id"`
|
||||
ICCID string `json:"iccid"`
|
||||
MSISDN string `json:"msisdn,omitempty"`
|
||||
NetworkStatus int `json:"network_status"`
|
||||
RealNameStatus int `json:"real_name_status"`
|
||||
SlotPosition int `json:"slot_position,omitempty"`
|
||||
IsCurrent bool `json:"is_current"` // ← 已定义,但 buildDeviceResolveResponse 未赋值
|
||||
}
|
||||
```
|
||||
|
||||
<!-- asset_dto.go 当前 AssetRealtimeStatusResponse(需新增 DeviceRealtime 字段)-->
|
||||
```go
|
||||
type AssetRealtimeStatusResponse struct {
|
||||
AssetType string `json:"asset_type"`
|
||||
AssetID uint `json:"asset_id"`
|
||||
NetworkStatus int `json:"network_status,omitempty"`
|
||||
RealNameStatus int `json:"real_name_status,omitempty"`
|
||||
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty"`
|
||||
LastSyncTime *time.Time `json:"last_sync_time,omitempty"`
|
||||
DeviceProtectStatus string `json:"device_protect_status,omitempty"`
|
||||
Cards []BoundCardInfo `json:"cards,omitempty"`
|
||||
// 需新增:DeviceRealtime *DeviceGatewayInfo `json:"device_realtime,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
<!-- device_dto.go 当前 DeviceResponse(已有字段,但 toDeviceResponse 未映射)-->
|
||||
```go
|
||||
type DeviceResponse struct {
|
||||
// ... 其他字段已有
|
||||
OnlineStatus int `json:"online_status"`
|
||||
LastOnlineTime *time.Time `json:"last_online_time,omitempty"`
|
||||
SoftwareVersion string `json:"software_version"`
|
||||
SwitchMode string `json:"switch_mode"`
|
||||
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty"`
|
||||
// ↑ 以上字段已在 device_dto.go 中定义(Phase 3 完成),但 toDeviceResponse() 未赋值
|
||||
}
|
||||
```
|
||||
|
||||
<!-- device/binding.go DeviceCardBindingResponse(已有 IsCurrent,但 ListBindings 未填充)-->
|
||||
```go
|
||||
type DeviceCardBindingResponse struct {
|
||||
ID uint
|
||||
SlotPosition int
|
||||
IotCardID uint
|
||||
ICCID string
|
||||
MSISDN string
|
||||
CarrierName string
|
||||
Status int
|
||||
BindTime *time.Time
|
||||
IsCurrent bool // ← 已定义,但 ListBindings() 未赋值
|
||||
}
|
||||
```
|
||||
|
||||
<!-- asset/service.go 关键模式(isCurrentMap 构建方式)-->
|
||||
```go
|
||||
// buildDeviceResolveResponse 中现有 slotMap 构建模式,isCurrentMap 同构:
|
||||
slotMap := make(map[uint]int, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
slotMap[b.IotCardID] = b.SlotPosition
|
||||
}
|
||||
// 新增同构的 isCurrentMap:
|
||||
// isCurrentMap := make(map[uint]bool, len(bindings))
|
||||
// isCurrentMap[b.IotCardID] = b.IsCurrent
|
||||
|
||||
// GetRealtimeStatus device case 中现有 slotMap 构建模式,isCurrentMap 同构补充
|
||||
```
|
||||
|
||||
<!-- client_asset_dto.go DeviceRealtimeInfo 完整结构(B 端 DeviceGatewayInfo 的字段参考)-->
|
||||
```go
|
||||
type DeviceRealtimeInfo struct {
|
||||
OnlineStatus *int64; BatteryLevel *int64; Status *int64
|
||||
RunTime *string; ConnectTime *string; LastOnlineTime *string; LastUpdateTime *string
|
||||
Rsrp *int64; Rsrq *int64; Rssi *string; Sinr *int64
|
||||
SSID *string; WifiEnabled *bool; WifiPassword *string
|
||||
IPAddress *string; WANIP *string; LANIP *string; MACAddress *string
|
||||
DailyUsage *string; DLStats *string; ULStats *string; LimitSpeed *int64
|
||||
CurrentIccid *string; MaxClients *int64; SoftwareVersion *string
|
||||
SwitchMode *int; SyncInterval *int64
|
||||
DeviceID *string; DeviceName *string; DeviceType *string
|
||||
Imei *string; Imsi *string; CreatedAt *int64; UpdatedAt *int64
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: 补全 Gateway 模型和 B 端 DTO 契约</name>
|
||||
<files>internal/gateway/models.go, internal/model/dto/asset_dto.go</files>
|
||||
<action>
|
||||
**A. 补全 `internal/gateway/models.go` 的 `SyncDeviceInfoResp`(per D-10):**
|
||||
|
||||
在现有 `DailyUsage string` 字段之后,追加以下缺失字段(参考 Gateway 接口文档 `docs/第三方文档/gateway设备详情同步接口.md`):
|
||||
|
||||
```go
|
||||
// 信号相关(补全)
|
||||
Rsrp int `json:"rsrp" description:"参考信号接收功率(dBm)"`
|
||||
Rsrq int `json:"rsrq" description:"参考信号接收质量(dB)"`
|
||||
Sinr int `json:"sinr" description:"信噪比(dB)"`
|
||||
|
||||
// WiFi 相关(补全)
|
||||
WifiPassword string `json:"wifi_password" description:"WiFi密码"`
|
||||
|
||||
// 网络相关(补全)
|
||||
IPAddress string `json:"ip_address" description:"IP地址"`
|
||||
WANIP string `json:"wan_ip" description:"基站分配IPv4地址"`
|
||||
LANIP string `json:"lan_ip" description:"局域网网关IP地址"`
|
||||
|
||||
// 连接相关(补全)
|
||||
MaxClients int `json:"max_clients" description:"最大连接客户端数"`
|
||||
ConnectTime string `json:"connect_time" description:"设备本次联网时间(秒)"`
|
||||
|
||||
// 设备属性(补全)
|
||||
Status int `json:"status" description:"设备状态:1=正常,0=禁用"`
|
||||
IMSI string `json:"imsi" description:"IMSI用户标识码"`
|
||||
ULStats string `json:"ul_stats" description:"本次开机上传流量(字节)"`
|
||||
DLStats string `json:"dl_stats" description:"本次开机下载流量(字节)"`
|
||||
LimitSpeed int `json:"limit_speed" description:"限速速率(KB/s)"`
|
||||
SyncInterval int `json:"sync_interval" description:"信息上报周期(秒)"`
|
||||
```
|
||||
|
||||
注意:`MacAddress` 字段已存在(json tag 为 `mac_address`),**不要重复添加**。
|
||||
|
||||
**B. 在 `internal/model/dto/asset_dto.go` 中新增 `DeviceGatewayInfo` 结构体(per D-08/D-09):**
|
||||
|
||||
在文件末尾追加(在 `CardICCIDRequest` 之后),B 端 DTO 独立结构,全字段带 `description` tag:
|
||||
|
||||
```go
|
||||
// DeviceGatewayInfo B端设备实时状态信息(来自 Gateway sync-info)
|
||||
// 与 C 端 DeviceRealtimeInfo 独立,未来可展示更多技术字段
|
||||
// 所有字段均为可选,Gateway 调用失败时整体为 null
|
||||
type DeviceGatewayInfo struct {
|
||||
// 设备状态
|
||||
OnlineStatus *int `json:"online_status,omitempty" description:"在线状态:1=在线,2=离线"`
|
||||
BatteryLevel *int `json:"battery_level,omitempty" description:"电池电量百分比(无电池时为null)"`
|
||||
Status *int `json:"status,omitempty" description:"设备状态:1=正常,0=禁用"`
|
||||
RunTime *string `json:"run_time,omitempty" description:"本次开机运行时间(秒)"`
|
||||
ConnectTime *string `json:"connect_time,omitempty" description:"本次联网时间(秒)"`
|
||||
LastOnlineTime *string `json:"last_online_time,omitempty" description:"设备最后在线时间"`
|
||||
LastUpdateTime *string `json:"last_update_time,omitempty" description:"设备信息最后更新时间"`
|
||||
|
||||
// 信号相关
|
||||
Rsrp *int `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
|
||||
Rsrq *int `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
|
||||
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
|
||||
Sinr *int `json:"sinr,omitempty" description:"信噪比(dB)"`
|
||||
|
||||
// WiFi 相关
|
||||
SSID *string `json:"ssid,omitempty" description:"WiFi热点名称"`
|
||||
WifiEnabled *bool `json:"wifi_enabled,omitempty" description:"WiFi开关状态"`
|
||||
WifiPassword *string `json:"wifi_password,omitempty" description:"WiFi密码"`
|
||||
|
||||
// 网络相关
|
||||
IPAddress *string `json:"ip_address,omitempty" description:"IP地址"`
|
||||
WANIP *string `json:"wan_ip,omitempty" description:"基站分配IPv4地址"`
|
||||
LANIP *string `json:"lan_ip,omitempty" description:"局域网网关IP地址"`
|
||||
MACAddress *string `json:"mac_address,omitempty" description:"MAC地址"`
|
||||
|
||||
// 流量与速率
|
||||
DailyUsage *string `json:"daily_usage,omitempty" description:"日使用流量(字节)"`
|
||||
DLStats *string `json:"dl_stats,omitempty" description:"本次开机下载流量(字节)"`
|
||||
ULStats *string `json:"ul_stats,omitempty" description:"本次开机上传流量(字节)"`
|
||||
LimitSpeed *int `json:"limit_speed,omitempty" description:"限速速率(KB/s)"`
|
||||
|
||||
// 设备属性
|
||||
CurrentIccid *string `json:"current_iccid,omitempty" description:"当前使用的ICCID"`
|
||||
MaxClients *int `json:"max_clients,omitempty" description:"最大连接客户端数"`
|
||||
SoftwareVersion *string `json:"software_version,omitempty" description:"软件版本号"`
|
||||
SwitchMode *string `json:"switch_mode,omitempty" description:"切卡模式:0=自动,1=手动"`
|
||||
SyncInterval *int `json:"sync_interval,omitempty" description:"信息上报周期(秒)"`
|
||||
|
||||
// Gateway 标识字段
|
||||
DeviceID *string `json:"device_id,omitempty" description:"Gateway设备ID(IMEI/SN)"`
|
||||
DeviceName *string `json:"device_name,omitempty" description:"Gateway返回的设备名称"`
|
||||
DeviceType *string `json:"device_type,omitempty" description:"Gateway返回的设备类型"`
|
||||
IMEI *string `json:"imei,omitempty" description:"IMEI号"`
|
||||
IMSI *string `json:"imsi,omitempty" description:"IMSI用户标识码"`
|
||||
}
|
||||
```
|
||||
|
||||
**C. 修改 `AssetRealtimeStatusResponse`(per D-07/D-08):**
|
||||
|
||||
在 `Cards []BoundCardInfo` 字段之后追加:
|
||||
```go
|
||||
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime,omitempty" description:"设备实时状态(来自 Gateway sync-info,Gateway 失败时为 null)"`
|
||||
```
|
||||
|
||||
**D. 修改 `AssetResolveResponse`(per D-12):**
|
||||
|
||||
在 `Manufacturer string` 字段之后、`// 卡专属字段` 注释之前,追加 5 个 DB 缓存字段:
|
||||
```go
|
||||
// 设备 DB 缓存字段(Phase 3 sync-info 写入,展示上次同步快照)
|
||||
OnlineStatus int `json:"online_status,omitempty" description:"在线状态:0未知 1在线 2离线(设备类型时有效)"`
|
||||
LastOnlineTime *time.Time `json:"last_online_time,omitempty" description:"最后在线时间(设备类型时有效)"`
|
||||
SoftwareVersion string `json:"software_version,omitempty" description:"固件版本号(设备类型时有效)"`
|
||||
SwitchMode string `json:"switch_mode,omitempty" description:"切卡模式:0=自动,1=手动(设备类型时有效)"`
|
||||
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty" description:"最后 sync-info 同步时间(设备类型时有效)"`
|
||||
```
|
||||
</action>
|
||||
<verify>go build ./internal/gateway/... && go build ./internal/model/dto/...</verify>
|
||||
<done>
|
||||
- `SyncDeviceInfoResp` 包含所有 Gateway 文档字段(包括 Rsrp/Rsrq/Sinr/WifiPassword/IMSI/LANIP/WANIP/MaxClients/ConnectTime/Status/ULStats/DLStats/LimitSpeed/SyncInterval)
|
||||
- `asset_dto.go` 中 `DeviceGatewayInfo` 结构体存在,字段带 `description` tag
|
||||
- `AssetRealtimeStatusResponse` 含 `DeviceRealtime *DeviceGatewayInfo`
|
||||
- `AssetResolveResponse` 含 5 个 DB 缓存设备字段
|
||||
- `go build` 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: 修复静态 DTO 映射缺口(5 个文件映射补全)</name>
|
||||
<files>internal/service/device/service.go, internal/service/device/binding.go, internal/service/asset/service.go</files>
|
||||
<action>
|
||||
**A. `internal/service/device/service.go:toDeviceResponse()`(per D-02):**
|
||||
|
||||
在现有 `resp := &dto.DeviceResponse{...}` 初始化块中,追加 5 个 DB 缓存字段映射:
|
||||
```go
|
||||
OnlineStatus: device.OnlineStatus,
|
||||
LastOnlineTime: device.LastOnlineTime,
|
||||
SoftwareVersion: device.SoftwareVersion,
|
||||
SwitchMode: device.SwitchMode,
|
||||
LastGatewaySyncAt: device.LastGatewaySyncAt,
|
||||
```
|
||||
|
||||
**B. `internal/service/device/binding.go:ListBindings()`(per D-03):**
|
||||
|
||||
在构建 `resp := &dto.DeviceCardBindingResponse{...}` 时,追加:
|
||||
```go
|
||||
IsCurrent: binding.IsCurrent,
|
||||
```
|
||||
|
||||
**C. `internal/service/asset/service.go:buildDeviceResolveResponse()`(per D-04 + D-12):**
|
||||
|
||||
1. **补全 `BoundCardInfo.IsCurrent`(per D-04):**
|
||||
|
||||
在现有 `slotMap` 构建循环中,同构添加 `isCurrentMap`:
|
||||
```go
|
||||
slotMap := make(map[uint]int, len(bindings))
|
||||
isCurrentMap := make(map[uint]bool, len(bindings)) // 新增
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
slotMap[b.IotCardID] = b.SlotPosition
|
||||
isCurrentMap[b.IotCardID] = b.IsCurrent // 新增
|
||||
}
|
||||
```
|
||||
|
||||
在构建 `dto.BoundCardInfo{...}` 时追加:
|
||||
```go
|
||||
IsCurrent: isCurrentMap[c.ID], // 新增
|
||||
```
|
||||
|
||||
2. **补全 AssetResolveResponse 的 5 个 DB 缓存字段(per D-12):**
|
||||
|
||||
在 `resp := &dto.AssetResolveResponse{...}` 初始化块中追加:
|
||||
```go
|
||||
OnlineStatus: device.OnlineStatus,
|
||||
LastOnlineTime: device.LastOnlineTime,
|
||||
SoftwareVersion: device.SoftwareVersion,
|
||||
SwitchMode: device.SwitchMode,
|
||||
LastGatewaySyncAt: device.LastGatewaySyncAt,
|
||||
```
|
||||
|
||||
**D. `internal/service/asset/service.go:GetRealtimeStatus()` device case 中 `BoundCardInfo`(per D-04):**
|
||||
|
||||
同 C 步骤,在 `GetRealtimeStatus` device case 中也需补全 `isCurrentMap`:
|
||||
|
||||
在 `slotMap` 构建循环中追加 `isCurrentMap`,在 `dto.BoundCardInfo{...}` 中追加 `IsCurrent: isCurrentMap[c.ID]`。
|
||||
|
||||
注意:`Refresh()` 内部会调用 `GetRealtimeStatus()`,`buildDeviceResolveResponse()` 是独立的两处代码路径,都需要修复。
|
||||
</action>
|
||||
<verify>go build ./internal/service/device/... && go build ./internal/service/asset/...</verify>
|
||||
<done>
|
||||
- `toDeviceResponse()` 初始化块中含 5 个新字段赋值
|
||||
- `ListBindings()` 的 `DeviceCardBindingResponse` 含 `IsCurrent: binding.IsCurrent`
|
||||
- `buildDeviceResolveResponse()` 中含 `isCurrentMap` + `BoundCardInfo.IsCurrent` + 5 个 DB 缓存字段
|
||||
- `GetRealtimeStatus()` device case 中含 `isCurrentMap` + `BoundCardInfo.IsCurrent`
|
||||
- `go build` 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: 建立设备实时 Gateway 调用链路(GetRealtimeStatus + C 端 stub 替换)</name>
|
||||
<files>internal/service/asset/service.go, internal/handler/app/client_asset.go</files>
|
||||
<action>
|
||||
**A. `internal/service/asset/service.go:GetRealtimeStatus()` device case 追加 Gateway 实时调用(per D-07):**
|
||||
|
||||
在现有 `resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id)` 之后追加:
|
||||
|
||||
```go
|
||||
// 实时查询 Gateway 设备状态(in-line,不缓存,per D-05)
|
||||
// Gateway 失败不阻断主流程,DeviceRealtime 返回 null(per D-06)
|
||||
if s.gatewayClient != nil {
|
||||
device, devErr := s.deviceStore.GetByID(ctx, id)
|
||||
if devErr == nil {
|
||||
// IMEI 优先,无则用 SN(与 Refresh 中 updateDeviceFromSyncInfo 保持一致)
|
||||
cardNo := device.IMEI
|
||||
if cardNo == "" {
|
||||
cardNo = device.SN
|
||||
}
|
||||
if cardNo != "" {
|
||||
if syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
|
||||
CardNo: cardNo,
|
||||
}); syncErr == nil {
|
||||
resp.DeviceRealtime = mapSyncRespToDeviceGatewayInfo(syncResp)
|
||||
} else {
|
||||
logger.GetAppLogger().Warn("GetRealtimeStatus: sync-info 调用失败,DeviceRealtime 返回 null",
|
||||
zap.Uint("device_id", id),
|
||||
zap.Error(syncErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 `GetRealtimeStatus()` 函数之后(同一文件,`service.go` 中),新增私有转换函数:
|
||||
|
||||
```go
|
||||
// mapSyncRespToDeviceGatewayInfo 将 Gateway SyncDeviceInfoResp 映射为 B 端 DeviceGatewayInfo DTO
|
||||
// 使用指针字段,未赋值的字段保持 nil(omitempty)
|
||||
func mapSyncRespToDeviceGatewayInfo(r *gateway.SyncDeviceInfoResp) *dto.DeviceGatewayInfo {
|
||||
info := &dto.DeviceGatewayInfo{
|
||||
OnlineStatus: &r.OnlineStatus,
|
||||
RunTime: strPtr(r.RunTime),
|
||||
ConnectTime: strPtr(r.ConnectTime),
|
||||
LastOnlineTime: strPtr(r.LastOnlineTime),
|
||||
LastUpdateTime: strPtr(r.LastUpdateTime),
|
||||
Rsrp: &r.Rsrp,
|
||||
Rsrq: &r.Rsrq,
|
||||
Rssi: strPtr(r.RSSI),
|
||||
Sinr: &r.Sinr,
|
||||
SSID: strPtr(r.SSID),
|
||||
WifiEnabled: &r.WifiEnabled,
|
||||
WifiPassword: strPtr(r.WifiPassword),
|
||||
IPAddress: strPtr(r.IPAddress),
|
||||
WANIP: strPtr(r.WANIP),
|
||||
LANIP: strPtr(r.LANIP),
|
||||
MACAddress: strPtr(r.MacAddress),
|
||||
DailyUsage: strPtr(r.DailyUsage),
|
||||
DLStats: strPtr(r.DLStats),
|
||||
ULStats: strPtr(r.ULStats),
|
||||
LimitSpeed: &r.LimitSpeed,
|
||||
CurrentIccid: strPtr(r.CurrentIccid),
|
||||
MaxClients: &r.MaxClients,
|
||||
SoftwareVersion: strPtr(r.SoftwareVersion),
|
||||
SwitchMode: strPtr(r.SwitchMode),
|
||||
SyncInterval: &r.SyncInterval,
|
||||
DeviceID: strPtr(r.DeviceID),
|
||||
DeviceName: strPtr(r.DeviceName),
|
||||
DeviceType: strPtr(r.DeviceType),
|
||||
IMEI: strPtr(r.IMEI),
|
||||
IMSI: strPtr(r.IMSI),
|
||||
Status: &r.Status,
|
||||
}
|
||||
if r.BatteryLevel != nil {
|
||||
info.BatteryLevel = r.BatteryLevel
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// strPtr 将空字符串转为 nil,非空字符串返回指针
|
||||
// 避免 JSON 响应中出现大量空字符串字段
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
```
|
||||
|
||||
注意:若 `strPtr` 函数已在其他地方定义,复用现有实现,不要重复定义。
|
||||
|
||||
**B. `internal/handler/app/client_asset.go:GetAsset()` 替换 mock(per D-11):**
|
||||
|
||||
定位 `handler/app/client_asset.go` 约 187-191 行的 mock 调用:
|
||||
```go
|
||||
// 旧代码(删除):
|
||||
if resp.AssetType == "device" {
|
||||
resp.DeviceRealtime = buildMockDeviceRealtime()
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
```go
|
||||
// 调用服务层获取设备实时 Gateway 数据(per D-11)
|
||||
// Gateway 失败时 DeviceRealtime 为 null,不阻断主流程(per D-06)
|
||||
if resp.AssetType == "device" {
|
||||
realtimeResp, realtimeErr := h.assetService.GetRealtimeStatus(
|
||||
c.UserContext(), "device", resp.AssetID,
|
||||
)
|
||||
if realtimeErr == nil && realtimeResp.DeviceRealtime != nil {
|
||||
// 将 B 端 DeviceGatewayInfo 映射到 C 端 DeviceRealtimeInfo
|
||||
resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在同一文件中,新增私有映射函数(替换被删除的 `buildMockDeviceRealtime()`):
|
||||
|
||||
```go
|
||||
// mapDeviceGatewayInfoToClientInfo 将 B 端 DeviceGatewayInfo 映射为 C 端 DeviceRealtimeInfo
|
||||
// B 端和 C 端结构独立,通过映射函数转换(per D-08)
|
||||
func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealtimeInfo {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
info := &dto.DeviceRealtimeInfo{}
|
||||
if g.OnlineStatus != nil {
|
||||
v := int64(*g.OnlineStatus)
|
||||
info.OnlineStatus = &v
|
||||
}
|
||||
if g.BatteryLevel != nil {
|
||||
v := int64(*g.BatteryLevel)
|
||||
info.BatteryLevel = &v
|
||||
}
|
||||
if g.Status != nil {
|
||||
v := int64(*g.Status)
|
||||
info.Status = &v
|
||||
}
|
||||
info.RunTime = g.RunTime
|
||||
info.ConnectTime = g.ConnectTime
|
||||
info.LastOnlineTime = g.LastOnlineTime
|
||||
info.LastUpdateTime = g.LastUpdateTime
|
||||
if g.Rsrp != nil {
|
||||
v := int64(*g.Rsrp)
|
||||
info.Rsrp = &v
|
||||
}
|
||||
if g.Rsrq != nil {
|
||||
v := int64(*g.Rsrq)
|
||||
info.Rsrq = &v
|
||||
}
|
||||
info.Rssi = g.Rssi
|
||||
if g.Sinr != nil {
|
||||
v := int64(*g.Sinr)
|
||||
info.Sinr = &v
|
||||
}
|
||||
info.SSID = g.SSID
|
||||
info.WifiEnabled = g.WifiEnabled
|
||||
info.WifiPassword = g.WifiPassword
|
||||
info.IPAddress = g.IPAddress
|
||||
info.WANIP = g.WANIP
|
||||
info.LANIP = g.LANIP
|
||||
info.MACAddress = g.MACAddress
|
||||
info.DailyUsage = g.DailyUsage
|
||||
info.DLStats = g.DLStats
|
||||
info.ULStats = g.ULStats
|
||||
if g.LimitSpeed != nil {
|
||||
v := int64(*g.LimitSpeed)
|
||||
info.LimitSpeed = &v
|
||||
}
|
||||
info.CurrentIccid = g.CurrentIccid
|
||||
if g.MaxClients != nil {
|
||||
v := int64(*g.MaxClients)
|
||||
info.MaxClients = &v
|
||||
}
|
||||
info.SoftwareVersion = g.SoftwareVersion
|
||||
if g.SwitchMode != nil {
|
||||
v := int(*g.SwitchMode) // C端 SwitchMode 是 *int
|
||||
info.SwitchMode = &v
|
||||
}
|
||||
if g.SyncInterval != nil {
|
||||
v := int64(*g.SyncInterval)
|
||||
info.SyncInterval = &v
|
||||
}
|
||||
info.DeviceID = g.DeviceID
|
||||
info.DeviceName = g.DeviceName
|
||||
info.DeviceType = g.DeviceType
|
||||
info.Imei = g.IMEI
|
||||
info.Imsi = g.IMSI
|
||||
return info
|
||||
}
|
||||
```
|
||||
|
||||
最后,**删除** `buildMockDeviceRealtime()` 函数(约 595-640 行),该函数不再需要。
|
||||
</action>
|
||||
<verify>go build ./internal/service/asset/... && go build ./internal/handler/app/...</verify>
|
||||
<done>
|
||||
- `GetRealtimeStatus()` device case 末尾有 Gateway 调用 + `mapSyncRespToDeviceGatewayInfo` 函数存在
|
||||
- `GetAsset()` 中 `buildMockDeviceRealtime()` 调用已删除,改为调用 `h.assetService.GetRealtimeStatus()` + `mapDeviceGatewayInfoToClientInfo()`
|
||||
- `buildMockDeviceRealtime()` 函数已删除
|
||||
- `go build ./...` 全量编译通过(无编译错误)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# 全量编译验证(必须通过)
|
||||
go build ./...
|
||||
|
||||
# 静态检查(无新增错误)
|
||||
go vet ./internal/gateway/... ./internal/model/dto/... ./internal/service/asset/... ./internal/service/device/... ./internal/handler/app/...
|
||||
```
|
||||
|
||||
**手动验证清单(通过 DB 和 API 确认):**
|
||||
|
||||
1. 设备管理列表接口(GET /api/admin/devices)— 响应中含 `online_status`、`software_version` 等字段
|
||||
2. 设备绑卡列表接口(GET /api/admin/devices/:id/cards)— 响应中 `bindings[].is_current` 有值
|
||||
3. 资产解析接口(GET /api/admin/assets/resolve/:identifier,标识符为设备号)— 响应中含 `online_status`、`last_gateway_sync_at` 等字段,`cards[].is_current` 有值
|
||||
4. 资产实时状态接口(GET /api/admin/assets/:type/:id/realtime,type=device)— 响应中含 `device_realtime`(若 Gateway 可达则有值,不可达则为 null);`cards[].is_current` 有值
|
||||
5. C 端资产信息接口(GET /api/c/v1/asset,identifier=设备IMEI/SN)— 响应中 `device_realtime` 非 mock 数据(含真实 `online_status`/`battery_level` 等字段)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `go build ./...` 编译通过,无错误无 warning
|
||||
- 所有 DTO 静态映射缺口已补全(5 处字段 + 2 处 IsCurrent + 5 处 DB 缓存字段)
|
||||
- `GetRealtimeStatus("device", id)` 调用 Gateway sync-info,失败时 `DeviceRealtime` 为 null
|
||||
- C 端 `GetAsset` device 类型不再返回 mock 数据
|
||||
- 代码符合项目规范:中文注释、分层架构、错误只记 Warn 不阻断
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-SUMMARY.md`,记录:
|
||||
- 实际修改的文件和行号
|
||||
- `mapSyncRespToDeviceGatewayInfo` 和 `mapDeviceGatewayInfoToClientInfo` 的实现位置
|
||||
- 编译通过的确认
|
||||
- 任何与计划不符的实现调整(并说明原因)
|
||||
</output>
|
||||
185
.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-SUMMARY.md
Normal file
185
.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-SUMMARY.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
phase: 03.1-sync-info-phase-3-dto
|
||||
plan: "01"
|
||||
subsystem: asset/device
|
||||
tags:
|
||||
- gateway
|
||||
- dto
|
||||
- device
|
||||
- realtime
|
||||
dependency_graph:
|
||||
requires:
|
||||
- Phase 3 DEVICE-01~04(DB 字段写入已完成)
|
||||
provides:
|
||||
- 设备管理列表/详情返回 5 个 DB 缓存字段
|
||||
- 设备绑卡列表返回 is_current 字段
|
||||
- 资产 Resolve/Refresh BoundCardInfo 含 is_current
|
||||
- admin 资产解析返回 5 个 DB 缓存字段
|
||||
- GetRealtimeStatus device 类型实时调用 Gateway
|
||||
- C 端 GetAssetInfo device_realtime 为真实 Gateway 数据
|
||||
affects:
|
||||
- GET /api/admin/devices(新增 5 字段)
|
||||
- GET /api/admin/devices/:id/cards(新增 is_current)
|
||||
- GET /api/admin/assets/resolve/:identifier(新增 5 字段 + is_current)
|
||||
- GET /api/admin/assets/:type/:id/realtime(新增 device_realtime)
|
||||
- GET /api/c/v1/asset/info(device_realtime 从 mock 改为真实数据)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- isCurrentMap 与 slotMap 同构模式(asset/service.go)
|
||||
- strPtr() 辅助函数(空字符串→nil,防止 JSON 冗余字段)
|
||||
- mapSyncRespToDeviceGatewayInfo() Gateway DTO → B端 DTO 映射
|
||||
- mapDeviceGatewayInfoToClientInfo() B端 DTO → C端 DTO 映射
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- internal/gateway/models.go(+16 行:SyncDeviceInfoResp 补全 15 个字段)
|
||||
- internal/model/dto/asset_dto.go(+77 行:DeviceGatewayInfo 新增;AssetRealtimeStatusResponse.DeviceRealtime 新增;AssetResolveResponse 5 字段新增)
|
||||
- internal/service/device/service.go(+5 行:toDeviceResponse 补全 5 字段)
|
||||
- internal/service/device/binding.go(+1 行:ListBindings IsCurrent 补全)
|
||||
- internal/service/asset/service.go(+84 行:isCurrentMap 两处补全;Gateway 实时调用;mapSyncRespToDeviceGatewayInfo;strPtr)
|
||||
- internal/handler/app/client_asset.go(+113/-41 行:mock 替换为真实 Gateway 调用;mapDeviceGatewayInfoToClientInfo)
|
||||
decisions:
|
||||
- DeviceGatewayInfo(B端)与 DeviceRealtimeInfo(C端)独立结构,通过映射函数转换(per D-08)
|
||||
- SwitchMode 在 B端为 *string(Gateway 原始),C端为 *int,映射时使用 strconv.Atoi 转换
|
||||
- strPtr() 在 asset/service.go 包内独立定义(order/service.go 已有同名函数但不同包)
|
||||
metrics:
|
||||
duration: 5min
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 3
|
||||
files_modified: 6
|
||||
---
|
||||
|
||||
# Phase 03.1 Plan 01: 设备读取链路修复 + DTO 映射补全 Summary
|
||||
|
||||
**一句话总结:** 补全 Phase 3 设备数据写入链路的读取侧缺口——6 个字段映射缺口修复 + Gateway sync-info 实时调用链路建立 + C端 mock 数据替换。
|
||||
|
||||
---
|
||||
|
||||
## 完成的任务
|
||||
|
||||
### Task 1:补全 Gateway 模型和 B 端 DTO 契约
|
||||
|
||||
**`internal/gateway/models.go`(第 162-183 行):**
|
||||
|
||||
`SyncDeviceInfoResp` 新增 15 个缺失字段:
|
||||
- 信号:`Rsrp`、`Rsrq`、`Sinr`
|
||||
- WiFi:`WifiPassword`
|
||||
- 网络:`IPAddress`、`WANIP`、`LANIP`
|
||||
- 连接:`MaxClients`、`ConnectTime`
|
||||
- 设备:`Status`、`IMSI`、`ULStats`、`DLStats`、`LimitSpeed`、`SyncInterval`
|
||||
|
||||
**`internal/model/dto/asset_dto.go`:**
|
||||
|
||||
1. 新增 `DeviceGatewayInfo` 结构体(第 138-188 行),49 字段,全部含 `description` tag(per D-08/D-09)
|
||||
2. `AssetRealtimeStatusResponse` 新增 `DeviceRealtime *DeviceGatewayInfo` 字段(第 77 行)(per D-07/D-08)
|
||||
3. `AssetResolveResponse` 新增 5 个设备 DB 缓存字段(第 44-49 行)(per D-12):
|
||||
- `OnlineStatus`、`LastOnlineTime`、`SoftwareVersion`、`SwitchMode`、`LastGatewaySyncAt`
|
||||
|
||||
---
|
||||
|
||||
### Task 2:修复静态 DTO 映射缺口
|
||||
|
||||
**`internal/service/device/service.go`(`toDeviceResponse()`,第 559-563 行):**
|
||||
|
||||
补全 5 个 DB 缓存字段赋值:
|
||||
```go
|
||||
OnlineStatus: device.OnlineStatus,
|
||||
LastOnlineTime: device.LastOnlineTime,
|
||||
SoftwareVersion: device.SoftwareVersion,
|
||||
SwitchMode: device.SwitchMode,
|
||||
LastGatewaySyncAt: device.LastGatewaySyncAt,
|
||||
```
|
||||
|
||||
**`internal/service/device/binding.go`(`ListBindings()`,第 60 行):**
|
||||
|
||||
```go
|
||||
IsCurrent: binding.IsCurrent,
|
||||
```
|
||||
|
||||
**`internal/service/asset/service.go`(两处修复):**
|
||||
|
||||
1. `buildDeviceResolveResponse()`(第 124-128 行):新增 `isCurrentMap` + `IsCurrent` + 5 个 DB 缓存字段
|
||||
2. `GetRealtimeStatus()` device case(第 274-279 行):新增 `isCurrentMap` + `IsCurrent`
|
||||
|
||||
两处均采用与现有 `slotMap` 同构的 `isCurrentMap` 模式:
|
||||
```go
|
||||
isCurrentMap := make(map[uint]bool, len(bindings))
|
||||
// ...
|
||||
isCurrentMap[b.IotCardID] = b.IsCurrent
|
||||
// ...
|
||||
IsCurrent: isCurrentMap[c.ID],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3:建立设备实时 Gateway 调用链路
|
||||
|
||||
**`internal/service/asset/service.go`:**
|
||||
|
||||
1. `GetRealtimeStatus()` device case 末尾(第 291-312 行)新增 Gateway 实时调用:
|
||||
- `gatewayClient != nil` 安全检查
|
||||
- IMEI 优先,无则用 SN(与 Refresh 逻辑一致)
|
||||
- 调用 `s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: cardNo})`
|
||||
- 成功时调用 `mapSyncRespToDeviceGatewayInfo(syncResp)` 填充 `resp.DeviceRealtime`
|
||||
- 失败时仅记录 Warn 日志,`DeviceRealtime` 保持 nil(per D-06)
|
||||
|
||||
2. 新增 `mapSyncRespToDeviceGatewayInfo()`(第 330-369 行):Gateway resp → B端 DTO 映射
|
||||
|
||||
3. 新增 `strPtr()`(第 371-378 行):辅助函数,将空字符串转为 nil 指针
|
||||
|
||||
**`internal/handler/app/client_asset.go`:**
|
||||
|
||||
1. `GetAssetInfo()`(第 188-197 行):删除 `buildMockDeviceRealtime()` 调用,替换为:
|
||||
```go
|
||||
realtimeResp, realtimeErr := h.assetService.GetRealtimeStatus(
|
||||
c.UserContext(), "device", resp.AssetID,
|
||||
)
|
||||
if realtimeErr == nil && realtimeResp.DeviceRealtime != nil {
|
||||
resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime)
|
||||
}
|
||||
```
|
||||
|
||||
2. 删除 `buildMockDeviceRealtime()` 函数(原第 595-635 行)
|
||||
|
||||
3. 新增 `mapDeviceGatewayInfoToClientInfo()`(第 595-660 行):B端 DeviceGatewayInfo → C端 DeviceRealtimeInfo 映射
|
||||
|
||||
---
|
||||
|
||||
## 编译验证
|
||||
|
||||
```
|
||||
go build ./... → 全量编译通过
|
||||
go vet ./internal/gateway/... ./internal/model/dto/... ./internal/service/asset/... ./internal/service/device/... ./internal/handler/app/... → 无新增错误
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] SwitchMode 类型不匹配**
|
||||
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** 计划中 `mapDeviceGatewayInfoToClientInfo` 的 `SwitchMode` 转换代码为 `v := int(*g.SwitchMode)`,但 `g.SwitchMode` 是 `*string`(B端 Gateway DTO 定义),`DeviceRealtimeInfo.SwitchMode` 是 `*int`(C端 DTO),直接转换会编译报错
|
||||
- **Fix:** 使用 `strconv.Atoi(*g.SwitchMode)` 进行字符串→整型安全转换,解析失败时忽略(`SwitchMode` 保持 nil)
|
||||
- **Files modified:** `internal/handler/app/client_asset.go`
|
||||
- **Commit:** `a202b2d`
|
||||
|
||||
---
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `internal/gateway/models.go` 存在 | ✅ FOUND |
|
||||
| `internal/model/dto/asset_dto.go` 存在 | ✅ FOUND |
|
||||
| `internal/service/device/service.go` 存在 | ✅ FOUND |
|
||||
| `internal/service/device/binding.go` 存在 | ✅ FOUND |
|
||||
| `internal/service/asset/service.go` 存在 | ✅ FOUND |
|
||||
| `internal/handler/app/client_asset.go` 存在 | ✅ FOUND |
|
||||
| 提交 `00dba42` 存在 | ✅ FOUND |
|
||||
| 提交 `3b54850` 存在 | ✅ FOUND |
|
||||
| 提交 `a202b2d` 存在 | ✅ FOUND |
|
||||
| `go build ./...` 编译通过 | ✅ 通过 |
|
||||
170
.planning/phases/03.1-sync-info-phase-3-dto/03.1-CONTEXT.md
Normal file
170
.planning/phases/03.1-sync-info-phase-3-dto/03.1-CONTEXT.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Phase 03.1: 设备 sync-info 读取链路修复 - Context
|
||||
|
||||
**Gathered:** 2026-03-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Phase 3 完成了设备数据的**写入侧**(sync-info 调用 + DB 字段写入 + is_current 更新),但遗漏了**读取侧**的 DTO 映射和设备实时数据的架构对齐。
|
||||
|
||||
本 Phase 修复两类缺口:
|
||||
|
||||
1. **静态 DTO 映射缺口**(DB 字段已存储但未通过 API 返回)
|
||||
- `toDeviceResponse` 5 个字段未映射 → 影响设备管理列表/详情
|
||||
- `ListBindings` IsCurrent 未映射 → 影响设备绑卡列表接口
|
||||
- `BoundCardInfo` IsCurrent 未映射(2 处)→ 影响资产 Resolve/Refresh 响应
|
||||
- `AssetResolveResponse` 缺 5 个 DB 缓存字段 → 影响 admin 资产解析接口
|
||||
|
||||
2. **设备实时数据架构对齐**(统一视图的上游属性必须实时查询)
|
||||
- 设备上游属性(在线状态/信号/电量/WiFi/当前卡)具有实时特性,存 DB 无意义,必须实时从 Gateway 拉取
|
||||
- `GetRealtimeStatus` 对 device 类型实时调用 Gateway sync-info
|
||||
- C 端 `GetAsset` 的 `DeviceRealtimeInfo` stub 替换为真实 Gateway 数据
|
||||
|
||||
**不在本 Phase 范围:**
|
||||
- 卡资产的上游属性(走轮询系统,已由 DB 存储,无需改动)
|
||||
- 设备管理列表(展示 DB 缓存字段即可,不需要实时 Gateway 调用)
|
||||
- 设备 Refresh 流程(已有 updateDeviceFromSyncInfo,保持现状)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Plan 分组
|
||||
- **D-01:** 全部修复合并为 **1 个 Plan**。所有变更同属"DTO 映射缺口 + 实时数据架构对齐",无依赖关系需拆分。
|
||||
|
||||
### 静态 DTO 缺口修复
|
||||
|
||||
- **D-02:** `device/service.go:toDeviceResponse()` 补全 5 个 DB 缓存字段:
|
||||
- `OnlineStatus`、`LastOnlineTime`、`SoftwareVersion`、`SwitchMode`、`LastGatewaySyncAt`
|
||||
- 这些字段对设备管理列表(List)仍有意义(展示 DB 缓存值),保留此映射
|
||||
|
||||
- **D-03:** `device/binding.go:ListBindings()` 补全 `IsCurrent` 字段映射
|
||||
- `DeviceCardBindingResponse.IsCurrent` 直接从 `binding.IsCurrent` 取值
|
||||
|
||||
- **D-04:** `asset/service.go` 中 2 处 `BoundCardInfo` 构建(Resolve path + Refresh path)补全 `IsCurrent`
|
||||
- 实现方式:在 `bindings` 遍历中同时构建 `isCurrentMap[b.IotCardID] = b.IsCurrent`(与 `slotMap` 同构)
|
||||
- 构建 `BoundCardInfo` 时追加 `IsCurrent: isCurrentMap[c.ID]`
|
||||
|
||||
- **D-12:** `asset_dto.go:AssetResolveResponse` 新增 5 个 DB 缓存字段,同步更新 `asset/service.go:buildDeviceResolveResponse()` 映射
|
||||
- 新增字段:`OnlineStatus int`、`LastOnlineTime *time.Time`、`SoftwareVersion string`、`SwitchMode string`、`LastGatewaySyncAt *time.Time`
|
||||
- 这些是 DB 存储值(Phase 3 写入,上次 sync-info 的快照),不触发 Gateway 调用
|
||||
- 适用场景:admin 资产 Resolve 接口(`/api/admin/assets/resolve/:identifier`)快速展示设备上次已知状态
|
||||
- 与 D-07(GetRealtimeStatus 实时 Gateway 调用)互补:Resolve = 快速快照,Realtime = 精确实时
|
||||
|
||||
### 设备实时数据架构原则
|
||||
|
||||
- **D-05:** 设备上游属性(在线/信号/电量/WiFi/当前卡)**不缓存,必须实时查询 Gateway**。原因:设备具有在线/离线实时特性,存 DB 的值在设备离线后立即过时,无法代表真实状态。
|
||||
- 对比:卡资产的上游属性通过轮询系统有规律地同步到 DB,查询 DB 有意义。
|
||||
|
||||
- **D-06:** Gateway 调用失败时(设备离线/超时/IMEI 为空)**返回部分数据**:`DeviceRealtime` 字段为 null,资产基础信息正常返回。不抛错误,与卡资产处理保持一致。
|
||||
|
||||
### GetRealtimeStatus 改造
|
||||
|
||||
- **D-07:** `asset/service.go:GetRealtimeStatus()` 对 device 类型新增 Gateway 实时调用:
|
||||
- 调用 `s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{IMEI: device.IMEI})`(gatewayClient 已注入)
|
||||
- 成功:将响应映射到新增的 `AssetRealtimeStatusResponse.DeviceRealtime *DeviceGatewayInfo` 字段
|
||||
- 失败:仅记录 Warn 日志,`DeviceRealtime` 返回 null
|
||||
|
||||
### DTO 结构
|
||||
|
||||
- **D-08:** B 端和 C 端使用**独立结构体**,不共用。原因:未来管理端可能展示 C 端不展示的字段(如 IMSI、DL/UL stats、LAN/WAN IP 等技术字段)。
|
||||
- **B 端**:新建 `DeviceGatewayInfo` struct,位于 `internal/model/dto/asset_dto.go`
|
||||
- **C 端**:保留现有 `DeviceRealtimeInfo` struct,位于 `internal/model/dto/client_asset_dto.go`
|
||||
- `AssetRealtimeStatusResponse` 新增 `DeviceRealtime *DeviceGatewayInfo` 字段
|
||||
|
||||
- **D-09:** `DeviceGatewayInfo`(B 端)至少包含以下字段(参考 Gateway sync-info 文档完整映射):
|
||||
- 关键:`OnlineStatus`、`BatteryLevel`、`Rsrp/Rsrq/Rssi/Sinr`(信号)、`MaxClients`(连接数)
|
||||
- WiFi:`SSID`、`WifiPassword`
|
||||
- 当前卡:`CurrentIccid`
|
||||
- 附加:`SoftwareVersion`、`SwitchMode`、`RunTime`、`DailyUsage`、`LastOnlineTime`
|
||||
|
||||
- **D-10:** `gateway.SyncDeviceInfoResp` 当前缺少字段,需补全:
|
||||
- 补充:`Rsrp int`、`Rsrq int`、`Sinr int`、`WifiPassword string`、`IPAddress string`
|
||||
- 补充:`WANIP string`、`LANIP string`、`MaxClients int`、`ConnectTime string`
|
||||
- 补充:`Status int`(设备状态)、`IMSI string`、`ULStats string`、`DLStats string`
|
||||
- 补充:`LimitSpeed int`、`SyncInterval int`、`MACAddress string`
|
||||
|
||||
### C 端 DeviceRealtime stub 替换
|
||||
|
||||
- **D-11:** C 端 `client_asset.go:GetAsset()` 中对 device 类型的处理:
|
||||
- 删除 `buildMockDeviceRealtime()` 调用
|
||||
- 改为调用 `h.assetService.GetRealtimeStatus("device", deviceID)` 获取实时数据
|
||||
- 将返回的 `AssetRealtimeStatusResponse.DeviceRealtime (*DeviceGatewayInfo)` 映射到 `ClientAssetResponse.DeviceRealtime (*DeviceRealtimeInfo)`
|
||||
- Gateway 失败时 `resp.DeviceRealtime = nil`(已处理,不额外报错)
|
||||
|
||||
### Agent's Discretion
|
||||
|
||||
- `DeviceGatewayInfo` B 端结构体的字段是否包含所有 Gateway 字段或只包含关键字段,agent 可参考 `DeviceRealtimeInfo`(C 端)的完整字段集合决定
|
||||
- `toDeviceResponse` 中 `GetRealtimeStatus` 调用的冷却 Key 策略(是否需要防止高频调用)由 agent 参考现有 Refresh 冷却 Key 逻辑自行决定
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Gateway 接口文档
|
||||
- `docs/第三方文档/gateway设备详情同步接口.md` — sync-info 完整字段说明(data 字段映射表,包含 rsrp/rsrq/sinr/wifi_password 等当前 SyncDeviceInfoResp 缺失的字段)
|
||||
|
||||
### Phase 3 上下文(继承决策)
|
||||
- `.planning/phases/03-device-system/03-CONTEXT.md` — Phase 3 所有决策(D-06:结构体位置;D-13/14/15:updateDeviceFromSyncInfo 实现细节;D-08/09:存储字段范围)
|
||||
|
||||
### 核心修改文件
|
||||
- `internal/service/device/service.go` — `toDeviceResponse()`:补全 5 个字段
|
||||
- `internal/service/device/binding.go` — `ListBindings()`:补全 IsCurrent
|
||||
- `internal/service/asset/service.go` — `GetRealtimeStatus()`:新增 Gateway 调用;Resolve/Refresh 两处 BoundCardInfo:补全 IsCurrent
|
||||
- `internal/model/dto/asset_dto.go` — 新增 `DeviceGatewayInfo`;`AssetRealtimeStatusResponse` 新增 `DeviceRealtime` 字段;`AssetResolveResponse` 新增 5 个 DB 缓存字段(D-12)
|
||||
- `internal/model/dto/device_dto.go` — 已有字段,无需新增(确认映射即可)
|
||||
- `internal/gateway/models.go` — `SyncDeviceInfoResp` 补全缺失字段
|
||||
- `internal/handler/app/client_asset.go` — 删除 `buildMockDeviceRealtime()` stub,改为调用服务 + 映射
|
||||
|
||||
### 当前 SyncDeviceInfoResp 位置
|
||||
- `internal/gateway/models.go:147` — 当前结构(缺 Rsrp/Rsrq/Sinr/WifiPassword/LANIP/MaxClients 等)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `internal/service/asset/service.go:gatewayClient` — 已注入,`GetRealtimeStatus` 可直接使用
|
||||
- `internal/service/asset/service.go:updateDeviceFromSyncInfo()` — Phase 3 已实现,展示了 SyncDeviceInfoResp → DB 的映射模式,可参考作为 Gateway 响应 → DTO 映射的参考
|
||||
- `internal/gateway/device.go:SyncDeviceInfo()` — 已实现(Phase 3),直接复用
|
||||
- `internal/model/dto/client_asset_dto.go:DeviceRealtimeInfo` — 完整的 C 端实时信息结构(46 字段),对应所有 Gateway 字段,作为 `DeviceGatewayInfo` 的参考
|
||||
|
||||
### Established Patterns
|
||||
- isCurrentMap 模式:参考 `slotMap` 在 `asset/service.go` 的构建方式,`isCurrentMap[b.IotCardID] = b.IsCurrent`
|
||||
- Gateway 失败不阻断主流程:`logger.Warn("...")`,继续返回部分数据
|
||||
- B 端 DTO 字段均需 `description` tag(参考 `asset_dto.go` 现有字段)
|
||||
|
||||
### Integration Points
|
||||
- `asset/service.go:GetRealtimeStatus()` — device case 追加 Gateway 调用入口(现只有 `getDeviceProtectStatus` 和 bindings 查询)
|
||||
- `handler/app/client_asset.go:GetAsset()` — 约第 187-191 行,device 类型的 `resp.DeviceRealtime = buildMockDeviceRealtime()` 替换入口
|
||||
- `internal/model/dto/asset_dto.go:AssetRealtimeStatusResponse` — 新增 `DeviceRealtime *DeviceGatewayInfo` 字段位置
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- 用户明确:设备上游属性(在线/信号/电量/WiFi/当前卡)**不应存 DB**,必须实时查询。现有的 `online_status` 等 DB 字段对于设备管理列表展示仍有价值(缓存上次同步值)。
|
||||
- 用户明确:关键实时字段优先级 — 在线状态 > 信号强度 > 连接数 > 电量 > WiFi 名称 + WiFi 密码 > 当前使用卡 ICCID
|
||||
- `DeviceGatewayInfo`(B 端)与 `DeviceRealtimeInfo`(C 端)结构独立,为未来管理端展示更多技术字段(IMSI、LAN/WAN IP、DL/UL stats 等)预留空间
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- 设备 Refresh 接口的语义重新评估(用户提到"刷新接口对设备而言意义不大")——当前 Refresh 仍保留(写回 DB 供管理列表使用),但未来可能弃用 —— Phase 4+ 评估
|
||||
- `GetRealtimeStatus` 的防高频调用冷却 Key(若 C 端每次进入详情页都触发 Gateway 调用,需评估频率)—— 留待 Phase 4 运营优化
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 03.1-sync-info-phase-3-dto*
|
||||
*Context gathered: 2026-03-28 (updated: 2026-03-28, added D-12)*
|
||||
@@ -0,0 +1,147 @@
|
||||
# Phase 03.1: 设备 sync-info 读取链路修复 - Discussion Log
|
||||
|
||||
> **仅供审计参考。** 不用于规划、研究或执行 Agent 的输入。
|
||||
> 决策结论见 CONTEXT.md — 本日志保留了讨论过程中的备选项。
|
||||
|
||||
**Date:** 2026-03-28
|
||||
**Phase:** 03.1-sync-info-phase-3-dto
|
||||
**Areas discussed:** Plan 分组策略、IsCurrent 实现细节、作用域完整性确认(C 端 stub + 实时数据架构)
|
||||
|
||||
---
|
||||
|
||||
## Plan 分组策略
|
||||
|
||||
| 选项 | 说明 | 选中 |
|
||||
|------|------|------|
|
||||
| 1 Plan 合并 | 3 修复点同属 DTO 映射缺口,统一处理 | ✓ |
|
||||
| 2 Plans 分离 | device/service 系 + asset/service 系拆分 | |
|
||||
|
||||
**用户选择:** 1 Plan 合并
|
||||
**备注:** 修复性质单一(DTO 映射缺落),无依赖分拆理由
|
||||
|
||||
---
|
||||
|
||||
## IsCurrent 实现方式
|
||||
|
||||
| 选项 | 说明 | 选中 |
|
||||
|------|------|------|
|
||||
| isCurrentMap 标准扩展 | 与 slotMap 同构,在 bindings 遍历时同时构建 | ✓ |
|
||||
| 直接引用 binding.IsCurrent | 无 map,在 BoundCardInfo 构建时直接访问外层 binding | |
|
||||
|
||||
**用户选择:** isCurrentMap 标准扩展
|
||||
**备注:** 与 asset/service.go 的 slotMap 保持风格一致
|
||||
|
||||
---
|
||||
|
||||
## 作用域完整性:C 端 stub
|
||||
|
||||
| 选项 | 说明 | 选中 |
|
||||
|------|------|------|
|
||||
| 一并修复 C 端 stub | 替换 buildMockDeviceRealtime() 为真实 Gateway 数据 | ✓ |
|
||||
| 仅修复管理端 | C 端 stub 留待后续 Phase | |
|
||||
|
||||
**用户选择:** 一并修复
|
||||
|
||||
---
|
||||
|
||||
## C 端 DeviceRealtimeInfo 修复粒度
|
||||
|
||||
| 选项 | 说明 | 选中 |
|
||||
|------|------|------|
|
||||
| 只补 DB 存储字段 (3 个) | OnlineStatus/SwitchMode/SoftwareVersion,其余 nil | |
|
||||
| 完整替换:调用 Gateway | 实时调用 sync-info,返回所有可用字段 | ✓ |
|
||||
| 推迟到独立 Phase | C 端 stub 修复是独立需求 | |
|
||||
|
||||
**用户选择:** 完整替换,调用 Gateway
|
||||
|
||||
---
|
||||
|
||||
## 设备实时数据架构(新增议题)
|
||||
|
||||
**用户补充说明(原文):**
|
||||
> "不管是C端还是后台管理或者哪里,查询设备资产的时候其实都是需要实时数据的,这部分实时数据是很关键的...设备不一样,设备他具有离线,在线实时的特性,所以我们直接存没有什么意义,必然只能实时查询"
|
||||
|
||||
**架构原则确认:**
|
||||
- 单卡资产上游属性 → 轮询系统同步到 DB → 查询 DB
|
||||
- 设备资产上游属性 → 实时从 Gateway 查询(不缓存)
|
||||
- 设备管理列表 → DB 缓存字段(不需要实时)
|
||||
- 设备资产详情(B 端 + C 端)→ 实时 Gateway
|
||||
|
||||
---
|
||||
|
||||
## 关键实时字段优先级
|
||||
|
||||
**用户原文:**
|
||||
> "实时状态要的东西也不多,主要是在线状态,连接设备数,网络信号强度,设备电量"
|
||||
> "哦还有Wi-Fi密码跟Wi-Fi名称"
|
||||
|
||||
**关键字段列表:**
|
||||
1. 在线状态 (OnlineStatus)
|
||||
2. 网络信号强度 (Rsrp/Rsrq/Rssi/Sinr)
|
||||
3. 连接设备数 (MaxClients)
|
||||
4. 设备电量 (BatteryLevel)
|
||||
5. WiFi 名称 (SSID)
|
||||
6. WiFi 密码 (WifiPassword)
|
||||
7. 当前使用卡 ICCID (CurrentIccid)
|
||||
|
||||
---
|
||||
|
||||
## Gateway 失败处理
|
||||
|
||||
| 选项 | 说明 | 选中 |
|
||||
|------|------|------|
|
||||
| 返回部分数据 | DeviceRealtime = null,其他资产信息正常返回 | ✓ |
|
||||
| 返回错误 | 设备离线/Gateway 超时直接报错 | |
|
||||
|
||||
**用户选择:** 返回部分数据
|
||||
**备注:** 与现有卡资产处理逻辑一致
|
||||
|
||||
---
|
||||
|
||||
## 查询范围(设备列表 vs 详情)
|
||||
|
||||
**用户原文:**
|
||||
> "除了列表,因为列表要实时状态没有什么意义,但是像详情跟C端我就很需要实时数据了"
|
||||
|
||||
**决策:**
|
||||
- 设备管理列表 → DB 缓存字段(toDeviceResponse 补全 5 字段即可)
|
||||
- 设备资产详情(GetRealtimeStatus)→ 实时 Gateway
|
||||
|
||||
---
|
||||
|
||||
## 统一资产视图架构
|
||||
|
||||
**用户原文(核心说明):**
|
||||
> "我们有设备资产跟单卡资产两种,按照之前的做法其实是大家各有各的详情,我嫌弃麻烦而且不符合业务的要求,所以我们做成统一的口子,这个口子可以看到任意资产的完整生态信息"
|
||||
|
||||
**影响:**
|
||||
- 统一口子 = `asset/service.GetRealtimeStatus()`(管理端)+ `assetService.Resolve()`(C 端)
|
||||
- 设备详情不走 `device/service.Get()`,走资产视图
|
||||
|
||||
---
|
||||
|
||||
## B 端 vs C 端 DTO 结构
|
||||
|
||||
| 选项 | 说明 | 选中 |
|
||||
|------|------|------|
|
||||
| 共享同一结构体 | B/C 端复用 DeviceRealtimeInfo | |
|
||||
| 独立结构体 | B 端 DeviceGatewayInfo + C 端 DeviceRealtimeInfo | ✓ |
|
||||
|
||||
**用户原文:**
|
||||
> "不能是同一个结构体,要考虑到未来后台管理可能展示的信息在C端不展示的情况"
|
||||
|
||||
**决策:** 独立结构体
|
||||
- B 端:`DeviceGatewayInfo`(asset_dto.go)
|
||||
- C 端:`DeviceRealtimeInfo`(client_asset_dto.go,已存在)
|
||||
|
||||
---
|
||||
|
||||
## Agent's Discretion
|
||||
|
||||
- `DeviceGatewayInfo` B 端字段集合的最终范围(包含全量还是仅关键字段)
|
||||
- 防高频调用冷却 Key 策略(C 端每次进详情页都调 Gateway)
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- 设备 Refresh 接口语义重评("刷新接口对设备意义不大")→ Phase 4+ 评估
|
||||
- 防高频调用优化 → Phase 4 运营阶段评估
|
||||
160
.planning/phases/03.1-sync-info-phase-3-dto/03.1-VERIFICATION.md
Normal file
160
.planning/phases/03.1-sync-info-phase-3-dto/03.1-VERIFICATION.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
phase: 03.1-sync-info-phase-3-dto
|
||||
verified: 2026-03-28T06:30:00Z
|
||||
status: passed
|
||||
score: 7/7 must-haves verified
|
||||
re_verification: false
|
||||
human_verification:
|
||||
- test: "设备实时状态接口(GET /api/admin/assets/device/:id/realtime)——在 Gateway 可达时验证 device_realtime 字段含真实数据"
|
||||
expected: "device_realtime 字段非 null,包含 online_status/battery_level/software_version 等真实 Gateway 返回值"
|
||||
why_human: "需要运行中的服务和真实 Gateway 连接才能验证数据链路完整性"
|
||||
- test: "C 端资产信息接口(GET /api/c/v1/asset/info?identifier=<IMEI>)——验证 device_realtime 为真实 Gateway 数据而非 mock"
|
||||
expected: "device_realtime 字段含真实设备状态,online_status 与设备实际在线状态一致"
|
||||
why_human: "需要 C 端 JWT 认证 + 真实设备 IMEI + Gateway 连接"
|
||||
- test: "Gateway 失败场景——当 IMEI 为空或 Gateway 不可达时 device_realtime 为 null"
|
||||
expected: "资产基础信息正常返回,device_realtime 字段为 null(非 500 错误)"
|
||||
why_human: "需要模拟 Gateway 超时/离线场景"
|
||||
---
|
||||
|
||||
# Phase 03.1: 设备读取链路修复 + DTO 映射补全 Verification Report
|
||||
|
||||
**Phase Goal:** 补全 Phase 3 完成的设备数据写入链路在读取侧的缺口:修复 DTO 字段映射遗漏(5个 DB 缓存字段 + 2处 IsCurrent)+ 建立设备实时状态的 Gateway 查询链路(替换 mock → 真实 Gateway 数据)
|
||||
**Verified:** 2026-03-28T06:30:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | 设备管理列表/详情接口返回 online_status、last_online_time、software_version、switch_mode、last_gateway_sync_at 字段 | ✓ VERIFIED | `service.go:559-563` 中 `toDeviceResponse()` 含 5 个字段赋值;`device_dto.go:44-48` 含字段定义 |
|
||||
| 2 | 设备绑卡列表接口(ListBindings)返回 is_current 字段 | ✓ VERIFIED | `binding.go:60` 含 `IsCurrent: binding.IsCurrent`;`device_dto.go:85` 含 IsCurrent 字段定义 |
|
||||
| 3 | 资产 Resolve/Refresh 接口的 BoundCardInfo 包含 is_current 字段 | ✓ VERIFIED | `asset/service.go:130-145`(buildDeviceResolveResponse)和 `282-297`(GetRealtimeStatus)均含 `isCurrentMap` + `IsCurrent: isCurrentMap[c.ID]` |
|
||||
| 4 | admin 资产解析接口(AssetResolveResponse)返回 5 个 DB 缓存字段 | ✓ VERIFIED | `asset_dto.go:44-48` 含 5 字段定义;`asset/service.go:117-121` 含映射赋值 |
|
||||
| 5 | 资产实时状态接口(GetRealtimeStatus)对 device 类型实时调用 Gateway sync-info | ✓ VERIFIED | `asset/service.go:303-325` 含完整 Gateway 调用链路,`gatewayClient != nil` 安全检查,IMEI 优先/SN 备用 |
|
||||
| 6 | C 端 GetAsset 接口的 device_realtime 字段从真实 Gateway 数据填充,不再是 mock 数据 | ✓ VERIFIED | `client_asset.go:188-196` 删除了 `buildMockDeviceRealtime()` 调用,替换为 `h.assetService.GetRealtimeStatus()` + `mapDeviceGatewayInfoToClientInfo()` |
|
||||
| 7 | Gateway 调用失败时(IMEI 为空/超时/离线)device_realtime 返回 null,资产基础信息正常返回 | ✓ VERIFIED | `asset/service.go:318-322` 失败时仅记录 Warn 日志,`resp.DeviceRealtime` 保持 nil;`cardNo == ""` 分支不发起调用 |
|
||||
|
||||
**Score:** 7/7 truths verified
|
||||
|
||||
---
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `internal/gateway/models.go` | SyncDeviceInfoResp 补全 15 个缺失字段 | ✓ VERIFIED | 行 162-185 含 Rsrp/Rsrq/Sinr/WifiPassword/IPAddress/WANIP/LANIP/MaxClients/ConnectTime/Status/IMSI/ULStats/DLStats/LimitSpeed/SyncInterval |
|
||||
| `internal/model/dto/asset_dto.go` | DeviceGatewayInfo 新结构体;AssetRealtimeStatusResponse.DeviceRealtime;AssetResolveResponse 5 字段 | ✓ VERIFIED | 行 145-193 含 DeviceGatewayInfo(49 字段,全部含 description tag);行 83 含 DeviceRealtime;行 43-48 含 5 个 DB 缓存字段 |
|
||||
| `internal/service/device/service.go` | toDeviceResponse() 补全 5 字段映射 | ✓ VERIFIED | 行 559-563 含 OnlineStatus/LastOnlineTime/SoftwareVersion/SwitchMode/LastGatewaySyncAt 赋值 |
|
||||
| `internal/service/device/binding.go` | ListBindings() 补全 IsCurrent 字段映射 | ✓ VERIFIED | 行 60 含 `IsCurrent: binding.IsCurrent` |
|
||||
| `internal/service/asset/service.go` | buildDeviceResolveResponse() 补全 IsCurrent + 5个DB缓存字段;GetRealtimeStatus() device case 追加 Gateway 实时调用 | ✓ VERIFIED | 行 130-145 含 isCurrentMap + IsCurrent(buildDeviceResolveResponse);行 282-297 含 isCurrentMap + IsCurrent(GetRealtimeStatus);行 303-325 含 Gateway 调用;行 334-383 含 mapSyncRespToDeviceGatewayInfo + strPtr |
|
||||
| `internal/handler/app/client_asset.go` | GetAsset() 删除 buildMockDeviceRealtime(),改为调用 assetService.GetRealtimeStatus() | ✓ VERIFIED | 行 188-196 含真实 Gateway 调用逻辑;行 601-673 含 mapDeviceGatewayInfoToClientInfo;buildMockDeviceRealtime 已删除(全文搜索无匹配) |
|
||||
|
||||
---
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `internal/service/asset/service.go:GetRealtimeStatus()` | `internal/gateway/device.go:SyncDeviceInfo()` | `s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: cardNo})` | ✓ WIRED | 行 314 确认调用;gatewayClient nil guard 在行 305 |
|
||||
| `internal/handler/app/client_asset.go:GetAsset()` | `internal/service/asset/service.go:GetRealtimeStatus()` | `h.assetService.GetRealtimeStatus("device", resp.AssetID)` | ✓ WIRED | 行 191-196 确认调用及响应处理 |
|
||||
| `internal/service/asset/service.go:buildDeviceResolveResponse()` | `dto.BoundCardInfo.IsCurrent` | `isCurrentMap[b.IotCardID] = b.IsCurrent` | ✓ WIRED | 行 130-145:isCurrentMap 构建 + IsCurrent 赋值均在函数内 |
|
||||
|
||||
---
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|---------------|--------|-------------------|--------|
|
||||
| `client_asset.go:GetAsset()` | `resp.DeviceRealtime` | `h.assetService.GetRealtimeStatus()` → `s.gatewayClient.SyncDeviceInfo()` → Gateway HTTP 调用 | ✓ 调用真实 Gateway `/device/sync-info` 接口 | ✓ FLOWING |
|
||||
| `asset/service.go:GetRealtimeStatus()` | `resp.DeviceRealtime` | `s.gatewayClient.SyncDeviceInfo()` 返回 `*SyncDeviceInfoResp` | ✓ 从真实外部 Gateway 获取,mapSyncRespToDeviceGatewayInfo 映射全部字段 | ✓ FLOWING |
|
||||
| `device/service.go:toDeviceResponse()` | `OnlineStatus` 等 5 字段 | `device.OnlineStatus` 等来自 DB(Phase 3 写入的 sync-info 快照) | ✓ 直接来自 GORM model 字段 | ✓ FLOWING |
|
||||
| `asset/service.go:buildDeviceResolveResponse()` | `BoundCardInfo.IsCurrent` | `isCurrentMap[c.ID]`,来自 `deviceSimBindingStore.ListByDeviceID` 查询 | ✓ 来自 DB 绑定记录中的 `is_current` 字段 | ✓ FLOWING |
|
||||
|
||||
---
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| 全量编译通过 | `go build ./...` | 无错误 | ✓ PASS |
|
||||
| go vet 无新增警告 | `go vet ./internal/gateway/... ./internal/model/dto/... ./internal/service/asset/... ./internal/service/device/... ./internal/handler/app/...` | 无警告 | ✓ PASS |
|
||||
| buildMockDeviceRealtime 已删除 | `grep -n "buildMockDeviceRealtime" client_asset.go` | 无匹配 | ✓ PASS |
|
||||
| Gateway 调用 nil guard 存在 | `grep -n "gatewayClient != nil"` in asset/service.go | 行 305 存在 | ✓ PASS |
|
||||
| 3 个提交均已存在 | `git show --stat 00dba42 3b54850 a202b2d` | 3 个提交均已找到 | ✓ PASS |
|
||||
| C 端 device_realtime 调用真实服务 | `grep -n "assetService.GetRealtimeStatus"` in client_asset.go | 行 191 存在 | ✓ PASS |
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|----------|
|
||||
| DEVICE-02 | 03.1-01-PLAN.md | Gateway sync-info 同步接口对接 — `internal/gateway/device.go` 新增 `SyncDeviceInfo()` 方法 | ✓ SATISFIED | `gateway/device.go:72-79` 含 SyncDeviceInfo 方法;Phase 03.1 扩展了其 Gateway 调用用途(AssetService 集成);`gateway/models.go` 补全了 SyncDeviceInfoResp 的 15 个缺失字段 |
|
||||
| DEVICE-03 | 03.1-01-PLAN.md | `tb_device_sim_binding` 新增 `is_current` 字段 — DB 迁移 + Model + 更新逻辑 | ✓ SATISFIED | Phase 3 已完成 DB 迁移;Phase 03.1 补全了读取侧的 `IsCurrent` 映射(binding.go:60,asset/service.go:145,297) |
|
||||
| DEVICE-04 | 03.1-01-PLAN.md | 设备 Refresh + 详情接入 sync-info — `RefreshDevice()` 调用后更新设备状态 | ✓ SATISFIED | Phase 3 完成写入侧;Phase 03.1 补全读取侧:5 个 DB 缓存字段映射到 DTO,GetRealtimeStatus 实时 Gateway 调用链路 |
|
||||
|
||||
**注意:** DEVICE-02/03/04 在 REQUIREMENTS.md Traceability 表中映射到 Phase 3,标记为 Complete。Phase 03.1 是这些需求的读取侧补全(写入侧在 Phase 3,读取侧在 Phase 03.1),需求已完全交付。
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| `internal/model/dto/client_asset_dto.go` | 62, 67 | 残留过期注释:"当前为 null" / "尚未对接" / "待后续填充" | ℹ️ Info | 代码功能正确,仅 description tag 和注释描述未更新;不影响运行时行为;由于 description 是标签值非代码逻辑,不影响 API 正确性 |
|
||||
|
||||
**说明:** `client_asset_dto.go` 中的 `description:"设备实时状态(Gateway 同步接口对接后填充,当前为 null)"` 是 DTO 结构体中的文档标签,已过期(实际已对接)。这属于文档/注释层面的 Info 级别问题,不是代码功能缺陷,不阻断 Phase 目标达成。
|
||||
|
||||
---
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. 设备实时状态接口真实数据验证
|
||||
|
||||
**Test:** 启动服务,调用 `GET /api/admin/assets/device/:id/realtime`(其中 `:id` 为有效 IMEI 的设备 ID),使用管理员 Token
|
||||
**Expected:** 响应中 `device_realtime` 字段非 null,包含 `online_status`、`software_version`、`rssi` 等真实 Gateway 返回值
|
||||
**Why human:** 需要运行中的服务、有效的管理员 Token 和真实 Gateway 连接
|
||||
|
||||
#### 2. C 端资产接口 device_realtime 真实数据
|
||||
|
||||
**Test:** 以 C 端用户身份,调用 `GET /api/c/v1/asset/info?identifier=<IMEI>`
|
||||
**Expected:** 响应中 `device_realtime` 含真实设备状态(`online_status` 与设备实际在线状态一致),而非 mock 数据
|
||||
**Why human:** 需要 C 端 JWT 认证 + 有效 IMEI + Gateway 连接
|
||||
|
||||
#### 3. Gateway 失败降级行为
|
||||
|
||||
**Test:** 断开 Gateway 连接或使用无 IMEI 的设备 ID,调用 `GET /api/admin/assets/device/:id/realtime`
|
||||
**Expected:** 接口返回 200,`device_realtime` 为 null,其他字段(`cards`、`device_protect_status`)正常返回;服务日志中出现 Warn 级别的 "sync-info 调用失败" 日志
|
||||
**Why human:** 需要模拟 Gateway 故障场景
|
||||
|
||||
---
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
无 gaps。所有 7 个 must-have truths 均已验证通过。
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 03.1 完整实现了计划目标:
|
||||
|
||||
1. **DTO 契约层**:`gateway/models.go` 补全 15 个 SyncDeviceInfoResp 缺失字段;`asset_dto.go` 新增 `DeviceGatewayInfo` B 端结构体、`AssetRealtimeStatusResponse.DeviceRealtime` 字段、`AssetResolveResponse` 5 个 DB 缓存字段。
|
||||
|
||||
2. **静态映射缺口修复**:`device/service.go:toDeviceResponse()` 补全 5 字段;`device/binding.go:ListBindings()` 补全 `IsCurrent`;`asset/service.go` 在 `buildDeviceResolveResponse()` 和 `GetRealtimeStatus()` 两处都补全了 `isCurrentMap` + `IsCurrent`。
|
||||
|
||||
3. **Gateway 实时调用链路**:`asset/service.go:GetRealtimeStatus()` device case 新增完整 Gateway 调用链路(nil guard + IMEI/SN 优先 + 失败只记 Warn);`client_asset.go:GetAsset()` 删除 `buildMockDeviceRealtime()`,改用真实服务调用。
|
||||
|
||||
4. **编译验证**:`go build ./...` 和 `go vet` 全量通过,3 个提交均已确认存在。
|
||||
|
||||
唯一的 Info 级别发现是 `client_asset_dto.go` 中两处过期注释("当前为 null"/"尚未对接"),不影响功能。
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-28T06:30:00Z_
|
||||
_Verifier: the agent (gsd-verifier)_
|
||||
164
.planning/phases/04-refund-pay-ops/04-CONTEXT.md
Normal file
164
.planning/phases/04-refund-pay-ops/04-CONTEXT.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Phase 4: 退款 + 支付 + 运营修复 - Context
|
||||
|
||||
**Gathered:** 2026-03-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
实现完整退款流程(REFUND-01~03)、补全富友支付 JSAPI/小程序接口(PAY-01~04)、修复运营逻辑断点(OPS-01~02)。共 9 个需求:
|
||||
- **REFUND**:新功能模块(退款申请→审批→佣金回扣)
|
||||
- **PAY**:留桩替换(富友支付 JSAPI/小程序,平台代购场景修复)
|
||||
- **OPS**:业务逻辑修复(卡/设备 status 3/4 触发、payment_status 枚举统一)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Plan 分组策略
|
||||
|
||||
- **D-01:** 拆分为 **2 个 Plan**:
|
||||
- **Plan 1(REFUND)**:REFUND-01 先行(DB 迁移 + Model + Store),然后串行完成 REFUND-02(7 个接口)+ REFUND-03(佣金扣减逻辑嵌入 Approve)
|
||||
- **Plan 2(PAY + OPS 合并)**:PAY-01~04 + OPS-01~02 共 6 个需求,两者互相独立可并行编排
|
||||
- **D-02:** REFUND-01 必须先于 REFUND-02/03 执行(DB 迁移和模型是所有上层代码的基础)
|
||||
- **D-03:** 每个子任务独立一个 commit(延续 Phase 1/2/3 粒度规范)
|
||||
|
||||
### 退款状态机(REFUND)
|
||||
|
||||
- **D-04:** 退款单号生成规则:`{order_no}-RF{seq}`(同一订单第 1 次退款为 RF1,第 2 次为 RF2,以此类推)。Create 时查询该订单现有退款数量 +1 作为序号
|
||||
- **D-05:** 同一订单同时只允许一个"待处理"状态的退款申请(status=pending/returned),防止重复退款
|
||||
- **D-06:** 退款状态流转:
|
||||
- `pending` → `approved`(审批通过,触发佣金扣减 + 更新订单 payment_status=4)
|
||||
- `pending` → `rejected`(审批拒绝,必填拒绝原因)
|
||||
- `pending` → `returned`(审批人退回,申请人可重提)
|
||||
- `returned` → `pending`(申请人 Resubmit 重提)
|
||||
- **D-07:** **Return(退回)由审批人操作**,退回后状态变为 `returned`,申请人可调用 Resubmit 重新提交变回 `pending`
|
||||
- **D-08:** **审批人可修改退款金额**(approved_refund_amount 可与 requested_refund_amount 不同)。佣金扣减以 approved_refund_amount 为准
|
||||
- **D-09:** 审批通过后**同步更新订单 payment_status = 4**(已退款),与 OPS-02 的 payment_status 枚举统一对齐
|
||||
|
||||
### 退款佣金扣减(REFUND-03)
|
||||
|
||||
- **D-10:** 佣金扣减**内嵌在 Approve() 事务内同步执行**(不走 Asynq 异步任务)。原子性:退款审批通过、佣金扣减、订单状态更新在同一事务中完成,失败可回滚
|
||||
- **D-11:** 佣金扣减算法(**禁用 float64,使用整数算术**):
|
||||
```go
|
||||
deductAmount := commission.Amount * refund.ApprovedRefundAmount / refund.ActualReceivedAmount
|
||||
```
|
||||
先乘后除,全程 int64,防止整数除法精度丢失。对该订单所有佣金记录逐一计算扣减额,写负向交易流水
|
||||
- **D-12:** 佣金扣减允许余额变为负数(规格书明确:允许为负数)
|
||||
|
||||
### 富友支付(PAY)
|
||||
|
||||
- **D-13:** `FuiouPayJSAPI` 和 `FuiouPayMiniApp` 函数签名需从 `error` 改为 `(*dto.FuiouPayJSAPIResponse, error)`,参考 `WechatPayJSAPI` 的返回结构
|
||||
- **D-14:** 新建 `FuiouPayJSAPIResponse` DTO,字段从 `pkg/fuiou/types.go` 的 `WxPreCreateResponse` SDK 字段映射(SdkAppid, SdkTimestamp, SdkNoncestr, SdkPrepayid, SdkPackage, SdkSigntype, SdkPaysign),与微信官方 `wx.requestPayment()` 参数对齐
|
||||
- **D-15:** JSAPI(公众号)tradeType = `JSAPI`;小程序 tradeType = `LETPAY`(两者逻辑相同,仅此字段不同)
|
||||
- **D-16:** 已有富友 SDK 实现(`pkg/fuiou/client.go` + `wxprecreate.go`),直接调用 `client.WxPreCreate()` 方法
|
||||
|
||||
### 平台代理钱包代购(PAY-04)
|
||||
|
||||
- **D-17:** 修改 **`CreateAdminOrder`**(**不是 CreateLegacy**,CreateLegacy 已废弃,Handler 实际调用的是 CreateAdminOrder)
|
||||
- **D-18:** 触发条件:`buyerType == ""` && `resourceShopID != nil`(平台用户在 Handler 层传入 buyerType="" buyerID=0)
|
||||
- **D-19:** 钱包和成本价均来自**资产所属代理(*resourceShopID)**的钱包和成本价(等同于代理自购场景)
|
||||
- **D-20:** 订单标记:`purchaseRole = purchased_by_platform`,`operatorType = "platform"`
|
||||
- **D-21:** `sellerCostPrice = resourceShop 代理的成本价`(与代理自购相同,上下级佣金链照常计算)
|
||||
|
||||
### 卡/设备 status 3/4 触发(OPS-01)
|
||||
|
||||
- **D-22:** **status=3(已激活)**:在 `activation_service.go` 套餐激活事务内同步更新卡/设备表的 status=3(不走 goroutine,保证原子性)
|
||||
- **D-23:** **status=4(已停用)**:在 `activation_service.go` 的套餐到期检测逻辑中,调用 `activateNextMainPackage` 后,若无法找到下一个待激活套餐(即无剩余 active/pending 套餐),则同步更新卡/设备 status=4
|
||||
- **D-24:** 卡和设备**分开更新两张表**(tb_iot_card 和 tb_device),根据 carrierType 分支处理,无需统一封装
|
||||
- **D-25:** status 更新在已有套餐激活/到期事务内完成,失败可回滚
|
||||
|
||||
### payment_status 枚举统一(OPS-02)
|
||||
|
||||
- **D-26:** **删除所有 C 端映射函数**:
|
||||
- `internal/service/client_order/service.go` 中的 `orderStatusToClientStatus()`
|
||||
- `internal/handler/app/client_order.go` 中的 `orderStatusToClientPaymentStatus()` 和 `clientPaymentStatusToOrderStatus()`
|
||||
- **D-27:** C 端直接输出 DB 原始 `payment_status` 字段(1=待支付, 2=已支付, 3=已取消, 4=已退款),与管理端完全一致
|
||||
|
||||
### the Agent's Discretion
|
||||
|
||||
- 富友支付 `WxPreCreate` 调用时的 `goodsDesc` 字段内容(可从 order.OrderNo 或套餐名称组合)
|
||||
- 退款 Store 层具体查询方法(参考现有 commission_withdrawal_request_store.go 风格)
|
||||
- status=3/4 更新时的日志内容(按项目中文日志规范)
|
||||
- `getCostPrice` 函数参数(已有现成封装,直接复用)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### 修复规格(核心参考)
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 完整修复规格书:
|
||||
- 方案 I(REFUND-01~03):第 1716 行起(退款表结构、7 个接口清单、佣金扣减逻辑)
|
||||
- 方案 J(PAY + OPS):第 1862 行起(J-1/J-2/J-3/J-4 各子方案代码片段、人工验收清单)
|
||||
- J-1:富友支付 JSAPI/小程序(PAY-01/02/03)
|
||||
- J-2:平台钱包代购(PAY-04)—— **注意:规格书错误引用了 CreateLegacy(已废弃),实际修改目标是 CreateAdminOrder**
|
||||
- J-3:status 3/4 触发逻辑(OPS-01)
|
||||
- J-4:payment_status 枚举统一(OPS-02)
|
||||
|
||||
### 核心修改文件
|
||||
- `internal/service/order/service.go` — FuiouPayJSAPI/FuiouPayMiniApp 留桩替换;CreateAdminOrder 钱包支付 platform 分支(PAY)
|
||||
- `pkg/fuiou/client.go` + `pkg/fuiou/wxprecreate.go` — 富友 SDK(直接调用 WxPreCreate)
|
||||
- `pkg/fuiou/types.go` — WxPreCreateResponse 字段(FuiouPayJSAPIResponse DTO 来源)
|
||||
- `internal/service/package/activation_service.go` — 套餐激活/到期逻辑(OPS-01 插入点)
|
||||
- `internal/service/client_order/service.go` — orderStatusToClientStatus() 删除(OPS-02)
|
||||
- `internal/handler/app/client_order.go` — orderStatusToClientPaymentStatus() / clientPaymentStatusToOrderStatus() 删除(OPS-02)
|
||||
|
||||
### 参考结构(REFUND 设计参照)
|
||||
- `internal/service/commission_withdrawal/service.go` — Approve/Reject 风格参考(REFUND-02 接口设计)
|
||||
- `internal/model/dto/commission_withdrawal_dto.go` — DTO 字段风格参考(REFUND DTO 设计)
|
||||
- `internal/store/postgres/commission_withdrawal_request_store.go` — Store 层风格参考(REFUND Store)
|
||||
|
||||
### 依赖项与约束
|
||||
- `.planning/REQUIREMENTS.md` — REFUND-01~03, PAY-01~04, OPS-01~02 完整需求定义
|
||||
- `internal/handler/admin/order.go` — PAY-04:Handler 层传入 buyerType="" buyerID=0 的逻辑(了解上下游)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `pkg/fuiou/client.go:WxPreCreate()` — 富友预下单接口,PAY-01/02 直接调用,无需重写
|
||||
- `internal/service/order/service.go:WechatPayJSAPI()` — 参考模板:验证 wechatPayment != nil → 查订单 → 调用 SDK → 返回 DTO
|
||||
- `internal/service/commission_withdrawal/service.go:Approve()` — REFUND Approve 逻辑的最佳参考
|
||||
- `internal/service/package/activation_service.go` — status=3/4 的插入位置:activateMainPackage(激活)+ 到期检测循环(到期)
|
||||
- `internal/model/order.go:PurchaseRolePurchasedByPlatform` — PAY-04 购买角色常量已存在
|
||||
|
||||
### Established Patterns
|
||||
- 佣金扣减:参考 `internal/service/commission_withdrawal/service.go` 的钱包扣款方式(使用 version 乐观锁)
|
||||
- DB 迁移:双文件形式(`.up.sql` / `.down.sql`),编号接续现有最大值(当前 Phase 3 使用了 000090/91)
|
||||
- 退款单号:参考提现单号的生成逻辑(WD+时间戳+随机数 → 改为 {order_no}-RF{seq})
|
||||
- 路由注册:所有新接口通过 `internal/routes/registry.go` 的 `Register()` 注册,并同步更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`
|
||||
|
||||
### Integration Points
|
||||
- REFUND Handler 注册到 `/api/admin/refunds` 路由组(参考 commission_withdrawal 的路由注册方式)
|
||||
- PAY-04 修改 `CreateAdminOrder` 的 wallet 分支,Handler 层无需改动
|
||||
- OPS-01/02 在 activation_service.go 和 client_order 两个文件内修改,无新增路由
|
||||
- 富友支付 JSAPI/小程序的 Handler 已存在留桩(`internal/handler/app/`),替换 Service 层即可
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- **退款佣金允许负数**:规格书明确允许,代理佣金钱包余额可降至负值(为了确保退款公平性)
|
||||
- **CreateLegacy 废弃问题**:规格书 J-2 错误引用了已废弃的 CreateLegacy,实际修改目标是 CreateAdminOrder(这一决策在讨论中由用户确认)
|
||||
- **富友 SDK 已完整**:`pkg/fuiou/` 包含完整 client + types + notify + wxprecreate,PAY 工作量主要是替换留桩和编写 DTO
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 04-refund-pay-ops*
|
||||
*Context gathered: 2026-03-28*
|
||||
200
.planning/phases/04-refund-pay-ops/04-DISCUSSION-LOG.md
Normal file
200
.planning/phases/04-refund-pay-ops/04-DISCUSSION-LOG.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Phase 4: 退款 + 支付 + 运营修复 - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-03-28
|
||||
**Phase:** 04-退款-支付-运营修复
|
||||
**Areas discussed:** Plan 分组策略, 退款状态机细节, OPS-01 卡/设备 status=4 触发路径, PAY-04 平台代购的具体场景
|
||||
|
||||
---
|
||||
|
||||
## Plan 分组策略
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| REFUND 一个 Plan | DB迁移+Model+Store+Service+Handler+路由全部一次完成 | ✓ |
|
||||
| REFUND 拆两个 Plan | Plan A: DB迁移+基础层;Plan B: 功能层 | |
|
||||
|
||||
**User's choice:** 一个 Plan(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| PAY + OPS 合并一个 Plan | PAY 和 OPS 彼此独立,合并一个 Plan 减少上下文切换 | ✓ |
|
||||
| PAY 和 OPS 各自独立 | 两个 Plan,粒度更细 | |
|
||||
|
||||
**User's choice:** PAY + OPS 合并一个 Plan(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 内嵌同步(Approve 事务内) | 退款审批和佣金扣减原子,失败可回滚 | ✓ |
|
||||
| 异步 Asynq 任务 | 入队 TaskTypeRefundCommissionDeduct,审批响应快 | |
|
||||
|
||||
**User's choice:** 内嵌同步(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| REFUND-01 先行,再做 REFUND-02+03 | 与 Phase 3 DEVICE 基础层先行规范一致 | ✓ |
|
||||
| 一次性全部完成 | DB迁移先执行,所有上层代码同时完成 | |
|
||||
|
||||
**User's choice:** REFUND-01 先行,再做 REFUND-02+03(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 不需要,OPS-01 独立实现 | OPS-01 是套餐激活/到期直接更新 status,不经过 StopResumeCallback | ✓ |
|
||||
| 需要,提前将 CLEAN-06 并入本阶段 | 一并修复 bootstrap 注入 | |
|
||||
|
||||
**Notes:** 用户对 CLEAN-06 和 OPS-01 的关系有疑问,确认两者独立后选择不提前。CLEAN-06 保留在 Phase 5。
|
||||
|
||||
---
|
||||
|
||||
## 退款状态机细节
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 审批人退回 | 审批人退回申请人(信息不全/待补充等),返回 returned 状态,申请人可重提 | ✓ |
|
||||
| 申请人自撤回 | 申请人主动撤销,运营后可重提 | |
|
||||
|
||||
**User's choice:** 审批人退回(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| RF + 时间戳 + 随机数 | 独立生成,不关联订单号 | |
|
||||
| 基于订单号迭代 | {order_no}-RF{seq},关联清晰,seq 解决冲突 | ✓ |
|
||||
|
||||
**User's choice:** 基于订单号迭代,冲突解法用 {order_no}-RF{seq}
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 允许多次退款 | 同一订单可有多条退款记录,审批退款总额不超过实收金额 | |
|
||||
| 同时只有一个待处理申请 | 上一次退款完成前不允许新申请 | ✓ |
|
||||
|
||||
**User's choice:** 同一订单同时只能有一个待处理的退款申请(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 可修改金额 | 审批人可调整 approved_refund_amount,以此为佣金扣减基准 | ✓ |
|
||||
| 只能审批全额 | 简化 Approve 逻辑 | |
|
||||
|
||||
**User's choice:** 可修改金额(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| amount * approvedRefund / actualReceived(int64) | 先乘后除,禁用 float64 | ✓ |
|
||||
| 先确认字段含义再决定 | — | |
|
||||
|
||||
**Notes:** 用户初始想先确认字段含义。解释了 actual_received_amount(实收)/ approved_refund_amount(审批退款额)以及"先乘后除"的整数精度含义后确认使用 int64 算法。
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 是,更新 payment_status=4 | 审批通过同时更新订单状态 | ✓ |
|
||||
| 不更新订单状态 | 退款表独立记录 | |
|
||||
|
||||
**User's choice:** 是,更新 payment_status=4
|
||||
|
||||
---
|
||||
|
||||
## OPS-01 卡/设备 status=4 触发路径
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 事务内同步更新 | 套餐激活和卡状态更新原子,失败可回滚 | ✓ |
|
||||
| 异步 goroutine | 套餐激活后异步更新卡/设备状态 | |
|
||||
|
||||
**User's choice:** 事务内同步更新(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| activateNextMainPackage 后检查 active/pending count | 在现有到期处理循环内,激活下一个失败时检查剩余套餐数量 | ✓ |
|
||||
| 独立轮询任务定期检查 | 新建 Asynq 定时任务扫描无 active 套餐的卡/设备 | |
|
||||
|
||||
**User's choice:** 调用 activateNextMainPackage 后检查无剩余 active/pending 套餐(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 分开更新两张表 | 根据 carrierType 分支到 iot_card/device 各自的 Store | ✓ |
|
||||
| 统一封装 UpdateCarrierStatus() | 新建封装方法处理两种类型 | |
|
||||
|
||||
**User's choice:** 分开更新两张表(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 直接输出 DB 字段,删除所有映射函数 | DB 存的 payment_status 与管理端完全一致(1/2/3/4) | ✓ |
|
||||
| 先确认 DB 存在的实际值 | 不确定 DB 值是否真的 1/2/3/4 | |
|
||||
|
||||
**User's choice:** 是,直接输出 DB 字段,删除所有映射函数
|
||||
|
||||
---
|
||||
|
||||
## PAY-04 平台代购的具体场景
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 仅 CreateLegacy | 规格书指定 | |
|
||||
| 仅 CreateAdminOrder | Handler 实际调用的是 CreateAdminOrder,CreateLegacy 已废弃 | ✓ |
|
||||
|
||||
**Notes:** 用户指出 CreateLegacy 已废弃,讨论后确认修改目标为 CreateAdminOrder。规格书中的引用有误,这是本次讨论中最重要的一个勘误。
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 资产所属代理的钱包(*resourceShopID) | 平台帮代理买,用代理自己的钱包 | ✓ |
|
||||
| 平台自己的测试钱包 | 平台先垫付 | |
|
||||
|
||||
**User's choice:** 资产所属代理的钱包(*resourceShopID)(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| purchaseRole=purchased_by_platform, operatorType=platform | 使用已有枚举 | ✓ |
|
||||
| purchaseRole=self_purchase | 按代理自购处理 | |
|
||||
|
||||
**User's choice:** purchaseRole=purchased_by_platform, operatorType=platform(推荐)
|
||||
|
||||
---
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 与代理自购相同(sellerCostPrice=资产所属代理成本价) | 上下级佣金链照常计算 | ✓ |
|
||||
| sellerCostPrice=0(不计佣金差) | 平台代购无差价 | |
|
||||
|
||||
**Notes:** 用户初始对 sellerCostPrice 含义不清楚,解释了佣金差价逻辑后确认:平台代购等同于代理自购,佣金链正常计算。
|
||||
|
||||
---
|
||||
|
||||
## the Agent's Discretion
|
||||
|
||||
- 富友支付 WxPreCreate 调用时的 goodsDesc 字段内容
|
||||
- 退款 Store 层具体查询方法实现
|
||||
- status=3/4 更新时的日志内容
|
||||
- 富友 DTO 字段命名(参考 WechatPayJSAPIResponse 风格)
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope.
|
||||
@@ -581,27 +581,111 @@ components:
|
||||
type: object
|
||||
DtoAssetInfoResponse:
|
||||
properties:
|
||||
activated_at:
|
||||
description: 激活时间
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
activation_status:
|
||||
description: 激活状态(0:未激活, 1:已激活)
|
||||
type: integer
|
||||
asset_id:
|
||||
description: 资产ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
asset_type:
|
||||
description: 资产类型 (card:卡, device:设备)
|
||||
description: 资产类型(card:卡, device:设备)
|
||||
type: string
|
||||
bound_card_count:
|
||||
description: 绑定卡数量
|
||||
type: integer
|
||||
bound_device_id:
|
||||
description: 绑定的设备ID
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
bound_device_name:
|
||||
description: 绑定的设备名称
|
||||
type: string
|
||||
bound_device_no:
|
||||
description: 绑定的设备虚拟号
|
||||
type: string
|
||||
card_category:
|
||||
description: 卡业务类型(normal:普通卡, industry:行业卡)
|
||||
type: string
|
||||
cards:
|
||||
description: 绑定卡列表(含每张卡的ICCID/MSISDN/网络状态/实名状态/插槽位置)
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoBoundCardInfo'
|
||||
type: array
|
||||
carrier_id:
|
||||
description: 运营商ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
carrier_name:
|
||||
description: 运营商名称
|
||||
type: string
|
||||
carrier_type:
|
||||
description: 运营商类型(CMCC/CUCC/CTCC/CBN)
|
||||
type: string
|
||||
current_package:
|
||||
description: 当前套餐名称(无套餐时为空)
|
||||
type: string
|
||||
device_model:
|
||||
description: 设备型号
|
||||
type: string
|
||||
device_name:
|
||||
description: 设备名称
|
||||
type: string
|
||||
device_protect_status:
|
||||
description: 设备保护期状态(none:无, stop:停机保护, start:开机保护)
|
||||
type: string
|
||||
device_realtime:
|
||||
$ref: '#/components/schemas/DtoDeviceRealtimeInfo'
|
||||
device_type:
|
||||
description: 设备类型
|
||||
type: string
|
||||
generation:
|
||||
description: 制式
|
||||
description: 世代
|
||||
type: string
|
||||
iccid:
|
||||
description: 卡ICCID
|
||||
type: string
|
||||
identifier:
|
||||
description: 资产标识符
|
||||
type: string
|
||||
real_name_status:
|
||||
description: 实名状态 (0:未实名, 1:已实名)
|
||||
imei:
|
||||
description: 设备IMEI
|
||||
type: string
|
||||
manufacturer:
|
||||
description: 制造商
|
||||
type: string
|
||||
max_sim_slots:
|
||||
description: 最大插槽数
|
||||
type: integer
|
||||
msisdn:
|
||||
description: 手机号
|
||||
type: string
|
||||
network_status:
|
||||
description: 网络状态(0:停机, 1:开机)
|
||||
type: integer
|
||||
package_remain_mb:
|
||||
description: 当前剩余虚流量(MB),已按虚流量比例换算
|
||||
type: number
|
||||
package_total_mb:
|
||||
description: 当前套餐总虚流量(MB),已按虚流量比例换算
|
||||
type: integer
|
||||
package_used_mb:
|
||||
description: 当前已用虚流量(MB),已按虚流量比例换算
|
||||
type: number
|
||||
real_name_status:
|
||||
description: 实名状态(0:未实名, 1:已实名)
|
||||
type: integer
|
||||
sn:
|
||||
description: 设备序列号
|
||||
type: string
|
||||
status:
|
||||
description: 状态 (0:禁用, 1:启用)
|
||||
description: 状态(1:在库, 2:已分销, 3:已激活, 4:已停用)
|
||||
type: integer
|
||||
virtual_no:
|
||||
description: 虚拟号
|
||||
@@ -703,6 +787,24 @@ components:
|
||||
description: 已用虚流量(MB),按virtual_ratio换算
|
||||
type: number
|
||||
type: object
|
||||
DtoAssetPackagesResult:
|
||||
properties:
|
||||
items:
|
||||
description: 套餐列表
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoAssetPackageResponse'
|
||||
nullable: true
|
||||
type: array
|
||||
page:
|
||||
description: 当前页码
|
||||
type: integer
|
||||
page_size:
|
||||
description: 每页条数
|
||||
type: integer
|
||||
total:
|
||||
description: 总条数
|
||||
type: integer
|
||||
type: object
|
||||
DtoAssetRealtimeStatusResponse:
|
||||
properties:
|
||||
asset_id:
|
||||
@@ -723,6 +825,8 @@ components:
|
||||
device_protect_status:
|
||||
description: 保护期状态(asset_type=device时有效):none/stop/start
|
||||
type: string
|
||||
device_realtime:
|
||||
$ref: '#/components/schemas/DtoDeviceGatewayInfo'
|
||||
last_sync_time:
|
||||
description: 最后同步时间(asset_type=card时有效)
|
||||
format: date-time
|
||||
@@ -732,7 +836,7 @@ components:
|
||||
description: 网络状态(asset_type=card时有效):0停机 1开机
|
||||
type: integer
|
||||
real_name_status:
|
||||
description: 实名状态(asset_type=card时有效)
|
||||
description: 实名状态(asset_type=card时有效):0未实名 1已实名
|
||||
type: integer
|
||||
type: object
|
||||
DtoAssetRefreshRequest:
|
||||
@@ -846,6 +950,16 @@ components:
|
||||
imsi:
|
||||
description: IMSI
|
||||
type: string
|
||||
last_gateway_sync_at:
|
||||
description: 最后 sync-info 同步时间(设备类型时有效)
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
last_online_time:
|
||||
description: 最后在线时间(设备类型时有效)
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
manufacturer:
|
||||
description: 制造商
|
||||
type: string
|
||||
@@ -858,6 +972,9 @@ components:
|
||||
network_status:
|
||||
description: 网络状态:0停机 1开机(asset_type=card时有效)
|
||||
type: integer
|
||||
online_status:
|
||||
description: 在线状态:0未知 1在线 2离线(设备类型时有效)
|
||||
type: integer
|
||||
package_remain_mb:
|
||||
description: 当前套餐剩余虚流量(MB),已按virtual_ratio换算
|
||||
type: number
|
||||
@@ -868,7 +985,7 @@ components:
|
||||
description: 当前已用虚流量(MB),已按virtual_ratio换算
|
||||
type: number
|
||||
real_name_status:
|
||||
description: 实名状态:0未实名 1实名中 2已实名
|
||||
description: 实名状态:0未实名 1已实名
|
||||
type: integer
|
||||
series_id:
|
||||
description: 套餐系列ID
|
||||
@@ -889,12 +1006,18 @@ components:
|
||||
sn:
|
||||
description: 设备序列号
|
||||
type: string
|
||||
software_version:
|
||||
description: 固件版本号(设备类型时有效)
|
||||
type: string
|
||||
status:
|
||||
description: 资产状态
|
||||
type: integer
|
||||
supplier:
|
||||
description: 供应商
|
||||
type: string
|
||||
switch_mode:
|
||||
description: 切卡模式:0=自动,1=手动(设备类型时有效)
|
||||
type: string
|
||||
updated_at:
|
||||
description: 更新时间
|
||||
format: date-time
|
||||
@@ -1229,9 +1352,6 @@ components:
|
||||
type: string
|
||||
price_adjustment:
|
||||
$ref: '#/components/schemas/DtoPriceAdjustment'
|
||||
pricing_target:
|
||||
description: 调价目标 cost_price-成本价(默认) retail_price-零售价
|
||||
type: string
|
||||
series_id:
|
||||
description: 套餐系列ID(可选,不填则调整所有)
|
||||
minimum: 0
|
||||
@@ -1322,6 +1442,9 @@ components:
|
||||
iccid:
|
||||
description: ICCID
|
||||
type: string
|
||||
is_current:
|
||||
description: 是否为当前使用的卡
|
||||
type: boolean
|
||||
msisdn:
|
||||
description: 手机号
|
||||
type: string
|
||||
@@ -1329,7 +1452,7 @@ components:
|
||||
description: 网络状态:0停机 1开机
|
||||
type: integer
|
||||
real_name_status:
|
||||
description: 实名状态
|
||||
description: 实名状态:0未实名 1已实名
|
||||
type: integer
|
||||
slot_position:
|
||||
description: 插槽位置
|
||||
@@ -2241,13 +2364,13 @@ components:
|
||||
maximum: 120
|
||||
minimum: 1
|
||||
type: integer
|
||||
enable_realname_activation:
|
||||
description: 是否启用实名激活 (true:需实名后激活, false:立即激活)
|
||||
nullable: true
|
||||
type: boolean
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
type: boolean
|
||||
expiry_base:
|
||||
description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)
|
||||
example: from_activation
|
||||
type: string
|
||||
package_code:
|
||||
description: 套餐编码
|
||||
maxLength: 100
|
||||
@@ -2855,6 +2978,9 @@ components:
|
||||
description: IoT卡ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
is_current:
|
||||
description: 是否为当前使用的卡
|
||||
type: boolean
|
||||
msisdn:
|
||||
description: 接入号
|
||||
type: string
|
||||
@@ -2912,6 +3038,137 @@ components:
|
||||
required:
|
||||
- identifier
|
||||
type: object
|
||||
DtoDeviceGatewayInfo:
|
||||
properties:
|
||||
battery_level:
|
||||
description: 电池电量百分比(无电池时为null)
|
||||
nullable: true
|
||||
type: integer
|
||||
connect_time:
|
||||
description: 本次联网时间(秒)
|
||||
nullable: true
|
||||
type: string
|
||||
current_iccid:
|
||||
description: 当前使用的ICCID
|
||||
nullable: true
|
||||
type: string
|
||||
daily_usage:
|
||||
description: 日使用流量(字节)
|
||||
nullable: true
|
||||
type: string
|
||||
device_id:
|
||||
description: Gateway设备ID(IMEI/SN)
|
||||
nullable: true
|
||||
type: string
|
||||
device_name:
|
||||
description: Gateway返回的设备名称
|
||||
nullable: true
|
||||
type: string
|
||||
device_type:
|
||||
description: Gateway返回的设备类型
|
||||
nullable: true
|
||||
type: string
|
||||
dl_stats:
|
||||
description: 本次开机下载流量(字节)
|
||||
nullable: true
|
||||
type: string
|
||||
imei:
|
||||
description: IMEI号
|
||||
nullable: true
|
||||
type: string
|
||||
imsi:
|
||||
description: IMSI用户标识码
|
||||
nullable: true
|
||||
type: string
|
||||
ip_address:
|
||||
description: IP地址
|
||||
nullable: true
|
||||
type: string
|
||||
lan_ip:
|
||||
description: 局域网网关IP地址
|
||||
nullable: true
|
||||
type: string
|
||||
last_online_time:
|
||||
description: 设备最后在线时间
|
||||
nullable: true
|
||||
type: string
|
||||
last_update_time:
|
||||
description: 设备信息最后更新时间
|
||||
nullable: true
|
||||
type: string
|
||||
limit_speed:
|
||||
description: 限速速率(KB/s)
|
||||
nullable: true
|
||||
type: integer
|
||||
mac_address:
|
||||
description: MAC地址
|
||||
nullable: true
|
||||
type: string
|
||||
max_clients:
|
||||
description: 最大连接客户端数
|
||||
nullable: true
|
||||
type: integer
|
||||
online_status:
|
||||
description: 在线状态:1=在线,2=离线
|
||||
nullable: true
|
||||
type: integer
|
||||
rsrp:
|
||||
description: 参考信号接收功率(dBm)
|
||||
nullable: true
|
||||
type: integer
|
||||
rsrq:
|
||||
description: 参考信号接收质量(dB)
|
||||
nullable: true
|
||||
type: integer
|
||||
rssi:
|
||||
description: 接收信号强度
|
||||
nullable: true
|
||||
type: string
|
||||
run_time:
|
||||
description: 本次开机运行时间(秒)
|
||||
nullable: true
|
||||
type: string
|
||||
sinr:
|
||||
description: 信噪比(dB)
|
||||
nullable: true
|
||||
type: integer
|
||||
software_version:
|
||||
description: 软件版本号
|
||||
nullable: true
|
||||
type: string
|
||||
ssid:
|
||||
description: WiFi热点名称
|
||||
nullable: true
|
||||
type: string
|
||||
status:
|
||||
description: 设备状态:1=正常,0=禁用
|
||||
nullable: true
|
||||
type: integer
|
||||
switch_mode:
|
||||
description: 切卡模式:0=自动,1=手动
|
||||
nullable: true
|
||||
type: string
|
||||
sync_interval:
|
||||
description: 信息上报周期(秒)
|
||||
nullable: true
|
||||
type: integer
|
||||
ul_stats:
|
||||
description: 本次开机上传流量(字节)
|
||||
nullable: true
|
||||
type: string
|
||||
wan_ip:
|
||||
description: 基站分配IPv4地址
|
||||
nullable: true
|
||||
type: string
|
||||
wifi_enabled:
|
||||
description: WiFi开关状态
|
||||
nullable: true
|
||||
type: boolean
|
||||
wifi_password:
|
||||
description: WiFi密码
|
||||
nullable: true
|
||||
type: string
|
||||
type: object
|
||||
DtoDeviceImportResultItemDTO:
|
||||
properties:
|
||||
line:
|
||||
@@ -3059,6 +3316,145 @@ components:
|
||||
description: 请求ID
|
||||
type: string
|
||||
type: object
|
||||
DtoDeviceRealtimeInfo:
|
||||
properties:
|
||||
battery_level:
|
||||
description: 电池电量百分比
|
||||
nullable: true
|
||||
type: integer
|
||||
connect_time:
|
||||
description: 设备本次联网时间(秒)
|
||||
nullable: true
|
||||
type: string
|
||||
created_at:
|
||||
description: Gateway创建时间(Unix时间戳)
|
||||
nullable: true
|
||||
type: integer
|
||||
current_iccid:
|
||||
description: 当前使用的ICCID
|
||||
nullable: true
|
||||
type: string
|
||||
daily_usage:
|
||||
description: 日使用流量(字节)
|
||||
nullable: true
|
||||
type: string
|
||||
device_id:
|
||||
description: Gateway设备ID(IMEI/SN)
|
||||
nullable: true
|
||||
type: string
|
||||
device_name:
|
||||
description: Gateway返回的设备名称
|
||||
nullable: true
|
||||
type: string
|
||||
device_type:
|
||||
description: Gateway返回的设备型号
|
||||
nullable: true
|
||||
type: string
|
||||
dl_stats:
|
||||
description: 本次开机下载流量(字节)
|
||||
nullable: true
|
||||
type: string
|
||||
imei:
|
||||
description: Gateway返回的IMEI号
|
||||
nullable: true
|
||||
type: string
|
||||
imsi:
|
||||
description: Gateway返回的IMSI
|
||||
nullable: true
|
||||
type: string
|
||||
ip_address:
|
||||
description: IP地址
|
||||
nullable: true
|
||||
type: string
|
||||
lan_ip:
|
||||
description: 局域网网关IP地址
|
||||
nullable: true
|
||||
type: string
|
||||
last_online_time:
|
||||
description: 设备最后在线时间
|
||||
nullable: true
|
||||
type: string
|
||||
last_update_time:
|
||||
description: 设备信息最后更新时间
|
||||
nullable: true
|
||||
type: string
|
||||
limit_speed:
|
||||
description: 限速速率(KB/s)
|
||||
nullable: true
|
||||
type: integer
|
||||
mac_address:
|
||||
description: MAC地址
|
||||
nullable: true
|
||||
type: string
|
||||
max_clients:
|
||||
description: 最大连接客户端数
|
||||
nullable: true
|
||||
type: integer
|
||||
online_status:
|
||||
description: 在线状态(1:在线, 2:离线)
|
||||
nullable: true
|
||||
type: integer
|
||||
rsrp:
|
||||
description: 参考信号接收功率(dBm)
|
||||
nullable: true
|
||||
type: integer
|
||||
rsrq:
|
||||
description: 参考信号接收质量(dB)
|
||||
nullable: true
|
||||
type: integer
|
||||
rssi:
|
||||
description: 接收信号强度
|
||||
nullable: true
|
||||
type: string
|
||||
run_time:
|
||||
description: 设备本次开机运行时间(秒)
|
||||
nullable: true
|
||||
type: string
|
||||
sinr:
|
||||
description: 信噪比(dB)
|
||||
nullable: true
|
||||
type: integer
|
||||
software_version:
|
||||
description: 软件版本号
|
||||
nullable: true
|
||||
type: string
|
||||
ssid:
|
||||
description: WiFi热点名称
|
||||
nullable: true
|
||||
type: string
|
||||
status:
|
||||
description: 设备状态(1:正常, 0:禁用)
|
||||
nullable: true
|
||||
type: integer
|
||||
switch_mode:
|
||||
description: 切卡模式(0:自动, 1:手动)
|
||||
nullable: true
|
||||
type: integer
|
||||
sync_interval:
|
||||
description: 信息上报周期(秒)
|
||||
nullable: true
|
||||
type: integer
|
||||
ul_stats:
|
||||
description: 本次开机上传流量(字节)
|
||||
nullable: true
|
||||
type: string
|
||||
updated_at:
|
||||
description: Gateway更新时间(Unix时间戳)
|
||||
nullable: true
|
||||
type: integer
|
||||
wan_ip:
|
||||
description: 基站分配IPv4地址
|
||||
nullable: true
|
||||
type: string
|
||||
wifi_enabled:
|
||||
description: WiFi开关状态
|
||||
nullable: true
|
||||
type: boolean
|
||||
wifi_password:
|
||||
description: WiFi密码
|
||||
nullable: true
|
||||
type: string
|
||||
type: object
|
||||
DtoDeviceRebootRequest:
|
||||
properties:
|
||||
identifier:
|
||||
@@ -3108,12 +3504,25 @@ components:
|
||||
imei:
|
||||
description: 设备IMEI
|
||||
type: string
|
||||
last_gateway_sync_at:
|
||||
description: 最后 sync-info 同步时间
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
last_online_time:
|
||||
description: 最后在线时间
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
manufacturer:
|
||||
description: 制造商
|
||||
type: string
|
||||
max_sim_slots:
|
||||
description: 最大插槽数
|
||||
type: integer
|
||||
online_status:
|
||||
description: 在线状态:0未知 1在线 2离线
|
||||
type: integer
|
||||
series_id:
|
||||
description: 套餐系列ID
|
||||
minimum: 0
|
||||
@@ -3133,12 +3542,18 @@ components:
|
||||
sn:
|
||||
description: 设备序列号
|
||||
type: string
|
||||
software_version:
|
||||
description: 固件版本号
|
||||
type: string
|
||||
status:
|
||||
description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)
|
||||
type: integer
|
||||
status_name:
|
||||
description: 状态名称
|
||||
type: string
|
||||
switch_mode:
|
||||
description: 切卡模式:0=自动,1=手动
|
||||
type: string
|
||||
updated_at:
|
||||
description: 更新时间
|
||||
format: date-time
|
||||
@@ -4458,12 +4873,12 @@ components:
|
||||
duration_months:
|
||||
description: 套餐时长(月数)
|
||||
type: integer
|
||||
enable_realname_activation:
|
||||
description: 是否启用实名激活 (true:需实名后激活, false:立即激活)
|
||||
type: boolean
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
type: boolean
|
||||
expiry_base:
|
||||
description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)
|
||||
type: string
|
||||
id:
|
||||
description: 套餐ID
|
||||
minimum: 0
|
||||
@@ -6275,14 +6690,14 @@ components:
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
enable_realname_activation:
|
||||
description: 是否启用实名激活 (true:需实名后激活, false:立即激活)
|
||||
nullable: true
|
||||
type: boolean
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
nullable: true
|
||||
type: boolean
|
||||
expiry_base:
|
||||
description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)
|
||||
nullable: true
|
||||
type: string
|
||||
package_name:
|
||||
description: 套餐名称
|
||||
maxLength: 255
|
||||
@@ -6497,6 +6912,15 @@ components:
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
DtoUpdateRetailPriceParams:
|
||||
properties:
|
||||
retail_price:
|
||||
description: 零售价(单位:分)
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- retail_price
|
||||
type: object
|
||||
DtoUpdateRoleParams:
|
||||
properties:
|
||||
role_desc:
|
||||
@@ -8467,8 +8891,23 @@ paths:
|
||||
- 资产管理
|
||||
/api/admin/assets/{asset_type}/{id}/packages:
|
||||
get:
|
||||
description: 查询该资产所有套餐记录,含虚流量换算结果。
|
||||
description: 查询该资产所有套餐记录,含虚流量换算结果。支持分页(默认 page=1, page_size=50, 最大100)。
|
||||
parameters:
|
||||
- description: 页码(默认1)
|
||||
in: query
|
||||
name: page
|
||||
schema:
|
||||
description: 页码(默认1)
|
||||
minimum: 1
|
||||
type: integer
|
||||
- description: 每页条数(默认50,最大100)
|
||||
in: query
|
||||
name: page_size
|
||||
schema:
|
||||
description: 每页条数(默认50,最大100)
|
||||
maximum: 100
|
||||
minimum: 1
|
||||
type: integer
|
||||
- description: 资产类型:card 或 device
|
||||
in: path
|
||||
name: asset_type
|
||||
@@ -8495,9 +8934,7 @@ paths:
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoAssetPackageResponse'
|
||||
type: array
|
||||
$ref: '#/components/schemas/DtoAssetPackagesResult'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
@@ -15680,6 +16117,52 @@ paths:
|
||||
summary: 更新套餐
|
||||
tags:
|
||||
- 套餐管理
|
||||
/api/admin/packages/{id}/retail-price:
|
||||
patch:
|
||||
parameters:
|
||||
- description: ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
description: ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DtoUpdateRetailPriceParams'
|
||||
responses:
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 修改零售价(代理)
|
||||
tags:
|
||||
- 套餐管理
|
||||
/api/admin/packages/{id}/shelf:
|
||||
patch:
|
||||
parameters:
|
||||
@@ -20574,6 +21057,22 @@ paths:
|
||||
maxLength: 50
|
||||
minLength: 1
|
||||
type: string
|
||||
- description: 套餐类型 (formal:正式套餐, addon:加油包)
|
||||
in: query
|
||||
name: package_type
|
||||
schema:
|
||||
description: 套餐类型 (formal:正式套餐, addon:加油包)
|
||||
nullable: true
|
||||
type: string
|
||||
- description: 套餐状态 (0:待生效, 1:生效中, 2:已用完, 3:已过期, 4:已失效)
|
||||
in: query
|
||||
name: status
|
||||
schema:
|
||||
description: 套餐状态 (0:待生效, 1:生效中, 2:已用完, 3:已过期, 4:已失效)
|
||||
maximum: 4
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: 页码
|
||||
in: query
|
||||
name: page
|
||||
|
||||
@@ -177,7 +177,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
|
||||
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
||||
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
|
||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
|
||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient),
|
||||
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device),
|
||||
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
|
||||
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package gateway 提供设备相关的 7 个 API 方法封装
|
||||
// Package gateway 提供设备相关的 8 个 API 方法封装
|
||||
package gateway
|
||||
|
||||
import (
|
||||
@@ -68,3 +68,13 @@ func (c *Client) RebootDevice(ctx context.Context, req *DeviceOperationReq) erro
|
||||
_, err := c.doRequest(ctx, "/device/restart", req)
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncDeviceInfo 同步查询设备信息(直接返回,无需回调)
|
||||
// 与异步 /device/info 的区别:直接返回全量数据,无需 callbackUrl
|
||||
// POST /device/sync-info
|
||||
func (c *Client) SyncDeviceInfo(ctx context.Context, req *SyncDeviceInfoReq) (*SyncDeviceInfoResp, error) {
|
||||
if req.CardNo == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "cardNo 不能为空")
|
||||
}
|
||||
return doRequestWithResponse[SyncDeviceInfoResp](c, ctx, "/device/sync-info", req)
|
||||
}
|
||||
|
||||
@@ -131,3 +131,56 @@ type SlotInfoResp struct {
|
||||
Slots []SlotInfo `json:"slots" description:"卡槽信息列表"`
|
||||
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
|
||||
}
|
||||
|
||||
// SyncDeviceInfoReq sync-info 同步查询设备信息请求
|
||||
// cardNo 规则:>15 位用 ICCID,=15 位用 IMEI,=11 位用 SN
|
||||
type SyncDeviceInfoReq struct {
|
||||
CardNo string `json:"card_no" description:"设备标识(ICCID/IMEI/SN)"`
|
||||
}
|
||||
|
||||
// SyncDeviceInfoResp sync-info 同步查询设备信息响应(对应 Gateway data 字段)
|
||||
// 注意:所有字段均可能为 null/零值,表示暂无数据
|
||||
type SyncDeviceInfoResp struct {
|
||||
DeviceID string `json:"device_id" description:"设备ID(IMEI/SN)"`
|
||||
DeviceName string `json:"device_name" description:"设备名称"`
|
||||
IMEI string `json:"imei" description:"IMEI号"`
|
||||
CurrentIccid string `json:"current_iccid" description:"当前使用的ICCID"`
|
||||
DeviceType string `json:"device_type" description:"设备类型:1=诺行,2=玺龙,3=迎势达"`
|
||||
SoftwareVersion string `json:"software_version" description:"软件版本号"`
|
||||
MacAddress string `json:"mac_address" description:"MAC地址"`
|
||||
SSID string `json:"ssid" description:"WiFi热点名称"`
|
||||
WifiEnabled bool `json:"wifi_enabled" description:"WiFi开关状态"`
|
||||
SwitchMode string `json:"switch_mode" description:"切卡模式:0=自动,1=手动"`
|
||||
RSSI string `json:"rssi" description:"接收信号强度"`
|
||||
BatteryLevel *int `json:"battery_level" description:"电池电量百分比(无电池时为null)"`
|
||||
OnlineStatus int `json:"online_status" description:"在线状态:1=在线,2=离线"`
|
||||
LastUpdateTime string `json:"last_update_time" description:"设备信息最后更新时间(ISO 8601)"`
|
||||
LastOnlineTime string `json:"last_online_time" description:"设备最后在线时间(ISO 8601)"`
|
||||
RunTime string `json:"run_time" description:"本次开机运行时间(秒)"`
|
||||
DailyUsage string `json:"daily_usage" description:"日使用流量(字节)"`
|
||||
|
||||
// 信号相关(D-10 补全)
|
||||
Rsrp int `json:"rsrp" description:"参考信号接收功率(dBm)"`
|
||||
Rsrq int `json:"rsrq" description:"参考信号接收质量(dB)"`
|
||||
Sinr int `json:"sinr" description:"信噪比(dB)"`
|
||||
|
||||
// WiFi 相关(D-10 补全)
|
||||
WifiPassword string `json:"wifi_password" description:"WiFi密码"`
|
||||
|
||||
// 网络相关(D-10 补全)
|
||||
IPAddress string `json:"ip_address" description:"IP地址"`
|
||||
WANIP string `json:"wan_ip" description:"基站分配IPv4地址"`
|
||||
LANIP string `json:"lan_ip" description:"局域网网关IP地址"`
|
||||
|
||||
// 连接相关(D-10 补全)
|
||||
MaxClients int `json:"max_clients" description:"最大连接客户端数"`
|
||||
ConnectTime string `json:"connect_time" description:"设备本次联网时间(秒)"`
|
||||
|
||||
// 设备属性(D-10 补全)
|
||||
Status int `json:"status" description:"设备状态:1=正常,0=禁用"`
|
||||
IMSI string `json:"imsi" description:"IMSI用户标识码"`
|
||||
ULStats string `json:"ul_stats" description:"本次开机上传流量(字节)"`
|
||||
DLStats string `json:"dl_stats" description:"本次开机下载流量(字节)"`
|
||||
LimitSpeed int `json:"limit_speed" description:"限速速率(KB/s)"`
|
||||
SyncInterval int `json:"sync_interval" description:"信息上报周期(秒)"`
|
||||
}
|
||||
|
||||
@@ -185,9 +185,15 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
|
||||
BoundDeviceName: resolved.Asset.BoundDeviceName,
|
||||
}
|
||||
|
||||
// TODO: Gateway 同步接口对接后,替换为真实设备实时数据
|
||||
// 调用服务层获取设备实时 Gateway 数据(per D-11)
|
||||
// Gateway 失败时 DeviceRealtime 为 null,不阻断主流程(per D-06)
|
||||
if resp.AssetType == "device" {
|
||||
resp.DeviceRealtime = buildMockDeviceRealtime()
|
||||
realtimeResp, realtimeErr := h.assetService.GetRealtimeStatus(
|
||||
c.UserContext(), "device", resp.AssetID,
|
||||
)
|
||||
if realtimeErr == nil && realtimeResp.DeviceRealtime != nil {
|
||||
resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime)
|
||||
}
|
||||
}
|
||||
|
||||
return response.Success(c, resp)
|
||||
@@ -592,44 +598,76 @@ func packageStatusName(status int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// buildMockDeviceRealtime 构建设备实时状态假数据
|
||||
// TODO: Gateway 同步接口对接后移除此函数,改为调用 Gateway 接口获取真实数据
|
||||
func buildMockDeviceRealtime() *dto.DeviceRealtimeInfo {
|
||||
onlineStatus := int64(1)
|
||||
batteryLevel := int64(85)
|
||||
deviceStatus := int64(1)
|
||||
runTime := "3600"
|
||||
connectTime := "3500"
|
||||
rsrp := int64(-80)
|
||||
rsrq := int64(-10)
|
||||
rssi := "-65"
|
||||
sinr := int64(15)
|
||||
ssid := "JunHong-WiFi"
|
||||
wifiEnabled := true
|
||||
wifiPassword := "12345678"
|
||||
ipAddress := "192.168.1.1"
|
||||
lanIP := "192.168.1.1"
|
||||
dailyUsage := "0"
|
||||
maxClients := int64(32)
|
||||
switchMode := 0
|
||||
|
||||
return &dto.DeviceRealtimeInfo{
|
||||
OnlineStatus: &onlineStatus,
|
||||
BatteryLevel: &batteryLevel,
|
||||
Status: &deviceStatus,
|
||||
RunTime: &runTime,
|
||||
ConnectTime: &connectTime,
|
||||
Rsrp: &rsrp,
|
||||
Rsrq: &rsrq,
|
||||
Rssi: &rssi,
|
||||
Sinr: &sinr,
|
||||
SSID: &ssid,
|
||||
WifiEnabled: &wifiEnabled,
|
||||
WifiPassword: &wifiPassword,
|
||||
IPAddress: &ipAddress,
|
||||
LANIP: &lanIP,
|
||||
DailyUsage: &dailyUsage,
|
||||
MaxClients: &maxClients,
|
||||
SwitchMode: &switchMode,
|
||||
// mapDeviceGatewayInfoToClientInfo 将 B 端 DeviceGatewayInfo 映射为 C 端 DeviceRealtimeInfo
|
||||
// B 端和 C 端结构独立,通过映射函数转换(per D-08)
|
||||
func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealtimeInfo {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
info := &dto.DeviceRealtimeInfo{}
|
||||
if g.OnlineStatus != nil {
|
||||
v := int64(*g.OnlineStatus)
|
||||
info.OnlineStatus = &v
|
||||
}
|
||||
if g.BatteryLevel != nil {
|
||||
v := int64(*g.BatteryLevel)
|
||||
info.BatteryLevel = &v
|
||||
}
|
||||
if g.Status != nil {
|
||||
v := int64(*g.Status)
|
||||
info.Status = &v
|
||||
}
|
||||
info.RunTime = g.RunTime
|
||||
info.ConnectTime = g.ConnectTime
|
||||
info.LastOnlineTime = g.LastOnlineTime
|
||||
info.LastUpdateTime = g.LastUpdateTime
|
||||
if g.Rsrp != nil {
|
||||
v := int64(*g.Rsrp)
|
||||
info.Rsrp = &v
|
||||
}
|
||||
if g.Rsrq != nil {
|
||||
v := int64(*g.Rsrq)
|
||||
info.Rsrq = &v
|
||||
}
|
||||
info.Rssi = g.Rssi
|
||||
if g.Sinr != nil {
|
||||
v := int64(*g.Sinr)
|
||||
info.Sinr = &v
|
||||
}
|
||||
info.SSID = g.SSID
|
||||
info.WifiEnabled = g.WifiEnabled
|
||||
info.WifiPassword = g.WifiPassword
|
||||
info.IPAddress = g.IPAddress
|
||||
info.WANIP = g.WANIP
|
||||
info.LANIP = g.LANIP
|
||||
info.MACAddress = g.MACAddress
|
||||
info.DailyUsage = g.DailyUsage
|
||||
info.DLStats = g.DLStats
|
||||
info.ULStats = g.ULStats
|
||||
if g.LimitSpeed != nil {
|
||||
v := int64(*g.LimitSpeed)
|
||||
info.LimitSpeed = &v
|
||||
}
|
||||
info.CurrentIccid = g.CurrentIccid
|
||||
if g.MaxClients != nil {
|
||||
v := int64(*g.MaxClients)
|
||||
info.MaxClients = &v
|
||||
}
|
||||
info.SoftwareVersion = g.SoftwareVersion
|
||||
if g.SwitchMode != nil {
|
||||
// SwitchMode 在 B 端为字符串("0"/"1"),C 端为整型(0/1),需解析转换
|
||||
if n, convErr := strconv.Atoi(*g.SwitchMode); convErr == nil {
|
||||
info.SwitchMode = &n
|
||||
}
|
||||
}
|
||||
if g.SyncInterval != nil {
|
||||
v := int64(*g.SyncInterval)
|
||||
info.SyncInterval = &v
|
||||
}
|
||||
info.DeviceID = g.DeviceID
|
||||
info.DeviceName = g.DeviceName
|
||||
info.DeviceType = g.DeviceType
|
||||
info.Imei = g.IMEI
|
||||
info.Imsi = g.IMSI
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ type Device struct {
|
||||
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
|
||||
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
|
||||
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
|
||||
// 以下字段由 Gateway sync-info 接口同步,有离线存储意义
|
||||
OnlineStatus int `gorm:"column:online_status;type:int;not null;default:0;comment:在线状态:0未知 1在线 2离线" json:"online_status"`
|
||||
LastOnlineTime *time.Time `gorm:"column:last_online_time;comment:最后在线时间(来自 Gateway sync-info)" json:"last_online_time,omitempty"`
|
||||
SoftwareVersion string `gorm:"column:software_version;type:varchar(100);not null;default:'';comment:固件版本号" json:"software_version"`
|
||||
SwitchMode string `gorm:"column:switch_mode;type:varchar(10);not null;default:'0';comment:切卡模式:0=自动 1=手动" json:"switch_mode"`
|
||||
LastGatewaySyncAt *time.Time `gorm:"column:last_gateway_sync_at;comment:最后一次 sync-info 同步时间" json:"last_gateway_sync_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -17,6 +17,7 @@ type DeviceSimBinding struct {
|
||||
BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"`
|
||||
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time"`
|
||||
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time"`
|
||||
IsCurrent bool `gorm:"column:is_current;type:boolean;not null;default:false;comment:是否为当前使用的卡(同一设备同一时刻最多一条为 true)" json:"is_current"`
|
||||
}
|
||||
|
||||
func (DeviceSimBinding) TableName() string {
|
||||
|
||||
@@ -40,6 +40,12 @@ type AssetResolveResponse struct {
|
||||
DeviceType string `json:"device_type,omitempty" description:"设备类型"`
|
||||
MaxSimSlots int `json:"max_sim_slots,omitempty" description:"最大插槽数"`
|
||||
Manufacturer string `json:"manufacturer,omitempty" description:"制造商"`
|
||||
// 设备 DB 缓存字段(Phase 3 sync-info 写入,展示上次同步快照)
|
||||
OnlineStatus int `json:"online_status,omitempty" description:"在线状态:0未知 1在线 2离线(设备类型时有效)"`
|
||||
LastOnlineTime *time.Time `json:"last_online_time,omitempty" description:"最后在线时间(设备类型时有效)"`
|
||||
SoftwareVersion string `json:"software_version,omitempty" description:"固件版本号(设备类型时有效)"`
|
||||
SwitchMode string `json:"switch_mode,omitempty" description:"切卡模式:0=自动,1=手动(设备类型时有效)"`
|
||||
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty" description:"最后 sync-info 同步时间(设备类型时有效)"`
|
||||
// 卡专属字段(device类型时为零值)
|
||||
CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"`
|
||||
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型"`
|
||||
@@ -59,20 +65,22 @@ type BoundCardInfo struct {
|
||||
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:"实名状态"`
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"`
|
||||
SlotPosition int `json:"slot_position,omitempty" description:"插槽位置"`
|
||||
IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"`
|
||||
}
|
||||
|
||||
// AssetRealtimeStatusResponse 资产实时状态(只读DB/Redis)
|
||||
type AssetRealtimeStatusResponse struct {
|
||||
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
|
||||
AssetID uint `json:"asset_id" description:"资产ID"`
|
||||
NetworkStatus int `json:"network_status,omitempty" description:"网络状态(asset_type=card时有效):0停机 1开机"`
|
||||
RealNameStatus int `json:"real_name_status,omitempty" description:"实名状态(asset_type=card时有效)"`
|
||||
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"本月已用流量MB(asset_type=card时有效)"`
|
||||
LastSyncTime *time.Time `json:"last_sync_time,omitempty" description:"最后同步时间(asset_type=card时有效)"`
|
||||
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"保护期状态(asset_type=device时有效):none/stop/start"`
|
||||
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"`
|
||||
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
|
||||
AssetID uint `json:"asset_id" description:"资产ID"`
|
||||
NetworkStatus int `json:"network_status,omitempty" description:"网络状态(asset_type=card时有效):0停机 1开机"`
|
||||
RealNameStatus int `json:"real_name_status,omitempty" description:"实名状态(asset_type=card时有效):0未实名 1已实名"`
|
||||
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"本月已用流量MB(asset_type=card时有效)"`
|
||||
LastSyncTime *time.Time `json:"last_sync_time,omitempty" description:"最后同步时间(asset_type=card时有效)"`
|
||||
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"保护期状态(asset_type=device时有效):none/stop/start"`
|
||||
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"`
|
||||
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime,omitempty" description:"设备实时状态(来自 Gateway sync-info,Gateway 失败时为 null)"`
|
||||
}
|
||||
|
||||
// AssetPackageResponse 资产套餐信息
|
||||
@@ -116,6 +124,14 @@ type AssetTypeIDRequest struct {
|
||||
ID uint `path:"id" description:"资产ID" required:"true"`
|
||||
}
|
||||
|
||||
// AssetPackagesRequest 资产套餐列表请求(路径参数 + 分页)
|
||||
type AssetPackagesRequest struct {
|
||||
AssetType string `path:"asset_type" description:"资产类型:card 或 device" required:"true"`
|
||||
ID uint `path:"id" description:"资产ID" required:"true"`
|
||||
Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"`
|
||||
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数(默认50,最大100)"`
|
||||
}
|
||||
|
||||
// DeviceIDRequest 设备ID请求(路径参数)
|
||||
type DeviceIDRequest struct {
|
||||
DeviceID uint `path:"device_id" description:"设备ID" required:"true"`
|
||||
@@ -125,3 +141,54 @@ type DeviceIDRequest struct {
|
||||
type CardICCIDRequest struct {
|
||||
ICCID string `path:"iccid" description:"卡ICCID" required:"true"`
|
||||
}
|
||||
|
||||
// DeviceGatewayInfo B端设备实时状态信息(来自 Gateway sync-info)
|
||||
// 与 C 端 DeviceRealtimeInfo 独立,未来可展示更多技术字段
|
||||
// 所有字段均为可选,Gateway 调用失败时整体为 null
|
||||
type DeviceGatewayInfo struct {
|
||||
// 设备状态
|
||||
OnlineStatus *int `json:"online_status,omitempty" description:"在线状态:1=在线,2=离线"`
|
||||
BatteryLevel *int `json:"battery_level,omitempty" description:"电池电量百分比(无电池时为null)"`
|
||||
Status *int `json:"status,omitempty" description:"设备状态:1=正常,0=禁用"`
|
||||
RunTime *string `json:"run_time,omitempty" description:"本次开机运行时间(秒)"`
|
||||
ConnectTime *string `json:"connect_time,omitempty" description:"本次联网时间(秒)"`
|
||||
LastOnlineTime *string `json:"last_online_time,omitempty" description:"设备最后在线时间"`
|
||||
LastUpdateTime *string `json:"last_update_time,omitempty" description:"设备信息最后更新时间"`
|
||||
|
||||
// 信号相关
|
||||
Rsrp *int `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
|
||||
Rsrq *int `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
|
||||
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
|
||||
Sinr *int `json:"sinr,omitempty" description:"信噪比(dB)"`
|
||||
|
||||
// WiFi 相关
|
||||
SSID *string `json:"ssid,omitempty" description:"WiFi热点名称"`
|
||||
WifiEnabled *bool `json:"wifi_enabled,omitempty" description:"WiFi开关状态"`
|
||||
WifiPassword *string `json:"wifi_password,omitempty" description:"WiFi密码"`
|
||||
|
||||
// 网络相关
|
||||
IPAddress *string `json:"ip_address,omitempty" description:"IP地址"`
|
||||
WANIP *string `json:"wan_ip,omitempty" description:"基站分配IPv4地址"`
|
||||
LANIP *string `json:"lan_ip,omitempty" description:"局域网网关IP地址"`
|
||||
MACAddress *string `json:"mac_address,omitempty" description:"MAC地址"`
|
||||
|
||||
// 流量与速率
|
||||
DailyUsage *string `json:"daily_usage,omitempty" description:"日使用流量(字节)"`
|
||||
DLStats *string `json:"dl_stats,omitempty" description:"本次开机下载流量(字节)"`
|
||||
ULStats *string `json:"ul_stats,omitempty" description:"本次开机上传流量(字节)"`
|
||||
LimitSpeed *int `json:"limit_speed,omitempty" description:"限速速率(KB/s)"`
|
||||
|
||||
// 设备属性
|
||||
CurrentIccid *string `json:"current_iccid,omitempty" description:"当前使用的ICCID"`
|
||||
MaxClients *int `json:"max_clients,omitempty" description:"最大连接客户端数"`
|
||||
SoftwareVersion *string `json:"software_version,omitempty" description:"软件版本号"`
|
||||
SwitchMode *string `json:"switch_mode,omitempty" description:"切卡模式:0=自动,1=手动"`
|
||||
SyncInterval *int `json:"sync_interval,omitempty" description:"信息上报周期(秒)"`
|
||||
|
||||
// Gateway 标识字段
|
||||
DeviceID *string `json:"device_id,omitempty" description:"Gateway设备ID(IMEI/SN)"`
|
||||
DeviceName *string `json:"device_name,omitempty" description:"Gateway返回的设备名称"`
|
||||
DeviceType *string `json:"device_type,omitempty" description:"Gateway返回的设备类型"`
|
||||
IMEI *string `json:"imei,omitempty" description:"IMEI号"`
|
||||
IMSI *string `json:"imsi,omitempty" description:"IMSI用户标识码"`
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ type DeviceResponse struct {
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
// 设备实时同步字段(由 sync-info 接口写入)
|
||||
OnlineStatus int `json:"online_status" description:"在线状态:0未知 1在线 2离线"`
|
||||
LastOnlineTime *time.Time `json:"last_online_time,omitempty" description:"最后在线时间"`
|
||||
SoftwareVersion string `json:"software_version" description:"固件版本号"`
|
||||
SwitchMode string `json:"switch_mode" description:"切卡模式:0=自动,1=手动"`
|
||||
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty" description:"最后 sync-info 同步时间"`
|
||||
}
|
||||
|
||||
type ListDeviceResponse struct {
|
||||
@@ -76,6 +82,7 @@ type DeviceCardBindingResponse struct {
|
||||
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
|
||||
Status int `json:"status" description:"卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
|
||||
BindTime *time.Time `json:"bind_time,omitempty" description:"绑定时间"`
|
||||
IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"`
|
||||
}
|
||||
|
||||
type ListDeviceCardsResponse struct {
|
||||
|
||||
@@ -24,8 +24,6 @@ type CommissionWithdrawalRequest struct {
|
||||
PaymentType string `gorm:"column:payment_type;type:varchar(20);default:'manual';comment:放款类型(manual=人工打款)" json:"payment_type"`
|
||||
AccountInfo datatypes.JSON `gorm:"column:account_info;type:jsonb;comment:收款账户信息(姓名、账号等)" json:"account_info"`
|
||||
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-待审核 2-已通过 3-已拒绝 4-已到账" json:"status"`
|
||||
ApprovedBy uint `gorm:"column:approved_by;index;comment:审批人用户ID" json:"approved_by"`
|
||||
ApprovedAt *time.Time `gorm:"column:approved_at;comment:审批时间" json:"approved_at"`
|
||||
ProcessorID uint `gorm:"column:processor_id;index;comment:处理人ID" json:"processor_id"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at;comment:处理时间" json:"processed_at"`
|
||||
PaidAt *time.Time `gorm:"column:paid_at;comment:到账时间" json:"paid_at"`
|
||||
|
||||
@@ -41,10 +41,10 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/packages", handler.Packages, RouteSpec{
|
||||
Summary: "资产套餐列表",
|
||||
Description: "查询该资产所有套餐记录,含虚流量换算结果。",
|
||||
Description: "查询该资产所有套餐记录,含虚流量换算结果。支持分页(默认 page=1, page_size=50, 最大100)。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetTypeIDRequest),
|
||||
Output: new([]dto.AssetPackageResponse),
|
||||
Input: new(dto.AssetPackagesRequest),
|
||||
Output: new(dto.AssetPackagesResult),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
@@ -37,6 +39,7 @@ type Service struct {
|
||||
shopStore *postgres.ShopStore
|
||||
redis *redis.Client
|
||||
iotCardService IotCardRefresher
|
||||
gatewayClient *gateway.Client // 用于调用 sync-info 同步设备信息(可为 nil,失败时仅记录日志)
|
||||
}
|
||||
|
||||
// New 创建资产服务实例
|
||||
@@ -51,6 +54,7 @@ func New(
|
||||
shopStore *postgres.ShopStore,
|
||||
redisClient *redis.Client,
|
||||
iotCardService IotCardRefresher,
|
||||
gatewayClient *gateway.Client,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -63,6 +67,7 @@ func New(
|
||||
shopStore: shopStore,
|
||||
redis: redisClient,
|
||||
iotCardService: iotCardService,
|
||||
gatewayClient: gatewayClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +114,11 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.
|
||||
DeviceType: device.DeviceType,
|
||||
MaxSimSlots: device.MaxSimSlots,
|
||||
Manufacturer: device.Manufacturer,
|
||||
OnlineStatus: device.OnlineStatus,
|
||||
LastOnlineTime: device.LastOnlineTime,
|
||||
SoftwareVersion: device.SoftwareVersion,
|
||||
SwitchMode: device.SwitchMode,
|
||||
LastGatewaySyncAt: device.LastGatewaySyncAt,
|
||||
}
|
||||
|
||||
// 查绑定卡
|
||||
@@ -117,9 +127,11 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.
|
||||
resp.BoundCardCount = len(bindings)
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
slotMap := make(map[uint]int, len(bindings))
|
||||
isCurrentMap := make(map[uint]bool, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
slotMap[b.IotCardID] = b.SlotPosition
|
||||
isCurrentMap[b.IotCardID] = b.IsCurrent
|
||||
}
|
||||
cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
for _, c := range cards {
|
||||
@@ -130,6 +142,7 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.
|
||||
NetworkStatus: c.NetworkStatus,
|
||||
RealNameStatus: c.RealNameStatus,
|
||||
SlotPosition: slotMap[c.ID],
|
||||
IsCurrent: isCurrentMap[c.ID],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -266,9 +279,11 @@ func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id ui
|
||||
if err == nil && len(bindings) > 0 {
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
slotMap := make(map[uint]int, len(bindings))
|
||||
isCurrentMap := make(map[uint]bool, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
slotMap[b.IotCardID] = b.SlotPosition
|
||||
isCurrentMap[b.IotCardID] = b.IsCurrent
|
||||
}
|
||||
cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
for _, c := range cards {
|
||||
@@ -279,11 +294,36 @@ func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id ui
|
||||
NetworkStatus: c.NetworkStatus,
|
||||
RealNameStatus: c.RealNameStatus,
|
||||
SlotPosition: slotMap[c.ID],
|
||||
IsCurrent: isCurrentMap[c.ID],
|
||||
})
|
||||
}
|
||||
}
|
||||
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id)
|
||||
|
||||
// 实时查询 Gateway 设备状态(不缓存,per D-05)
|
||||
// Gateway 失败不阻断主流程,DeviceRealtime 返回 null(per D-06)
|
||||
if s.gatewayClient != nil {
|
||||
device, devErr := s.deviceStore.GetByID(ctx, id)
|
||||
if devErr == nil {
|
||||
// IMEI 优先,无则用 SN(与 Refresh 中 updateDeviceFromSyncInfo 保持一致)
|
||||
cardNo := device.IMEI
|
||||
if cardNo == "" {
|
||||
cardNo = device.SN
|
||||
}
|
||||
if cardNo != "" {
|
||||
if syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
|
||||
CardNo: cardNo,
|
||||
}); syncErr == nil {
|
||||
resp.DeviceRealtime = mapSyncRespToDeviceGatewayInfo(syncResp)
|
||||
} else {
|
||||
logger.GetAppLogger().Warn("GetRealtimeStatus: sync-info 调用失败,DeviceRealtime 返回 null",
|
||||
zap.Uint("device_id", id),
|
||||
zap.Error(syncErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device")
|
||||
}
|
||||
@@ -291,6 +331,57 @@ func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id ui
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// mapSyncRespToDeviceGatewayInfo 将 Gateway SyncDeviceInfoResp 映射为 B 端 DeviceGatewayInfo DTO
|
||||
// 使用指针字段,未赋值的字段保持 nil(omitempty)
|
||||
func mapSyncRespToDeviceGatewayInfo(r *gateway.SyncDeviceInfoResp) *dto.DeviceGatewayInfo {
|
||||
info := &dto.DeviceGatewayInfo{
|
||||
OnlineStatus: &r.OnlineStatus,
|
||||
RunTime: strPtr(r.RunTime),
|
||||
ConnectTime: strPtr(r.ConnectTime),
|
||||
LastOnlineTime: strPtr(r.LastOnlineTime),
|
||||
LastUpdateTime: strPtr(r.LastUpdateTime),
|
||||
Rsrp: &r.Rsrp,
|
||||
Rsrq: &r.Rsrq,
|
||||
Rssi: strPtr(r.RSSI),
|
||||
Sinr: &r.Sinr,
|
||||
SSID: strPtr(r.SSID),
|
||||
WifiEnabled: &r.WifiEnabled,
|
||||
WifiPassword: strPtr(r.WifiPassword),
|
||||
IPAddress: strPtr(r.IPAddress),
|
||||
WANIP: strPtr(r.WANIP),
|
||||
LANIP: strPtr(r.LANIP),
|
||||
MACAddress: strPtr(r.MacAddress),
|
||||
DailyUsage: strPtr(r.DailyUsage),
|
||||
DLStats: strPtr(r.DLStats),
|
||||
ULStats: strPtr(r.ULStats),
|
||||
LimitSpeed: &r.LimitSpeed,
|
||||
CurrentIccid: strPtr(r.CurrentIccid),
|
||||
MaxClients: &r.MaxClients,
|
||||
SoftwareVersion: strPtr(r.SoftwareVersion),
|
||||
SwitchMode: strPtr(r.SwitchMode),
|
||||
SyncInterval: &r.SyncInterval,
|
||||
DeviceID: strPtr(r.DeviceID),
|
||||
DeviceName: strPtr(r.DeviceName),
|
||||
DeviceType: strPtr(r.DeviceType),
|
||||
IMEI: strPtr(r.IMEI),
|
||||
IMSI: strPtr(r.IMSI),
|
||||
Status: &r.Status,
|
||||
}
|
||||
if r.BatteryLevel != nil {
|
||||
info.BatteryLevel = r.BatteryLevel
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// strPtr 将空字符串转为 nil,非空字符串返回指针
|
||||
// 避免 JSON 响应中出现大量空字符串字段(omitempty 对空字符串无效)
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// Refresh 刷新资产数据(调网关同步)
|
||||
func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) {
|
||||
switch assetType {
|
||||
@@ -336,6 +427,29 @@ func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.
|
||||
// 设置冷却 Key
|
||||
s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration)
|
||||
|
||||
// 刷新设备自身信息(在线状态、当前卡、固件版本等),失败不阻断刷新流程(per D-15)
|
||||
if s.gatewayClient != nil {
|
||||
device, devErr := s.deviceStore.GetByID(ctx, id)
|
||||
if devErr == nil {
|
||||
// IMEI 优先,无则用 SN(per D-3 注释说明)
|
||||
cardNo := device.IMEI
|
||||
if cardNo == "" {
|
||||
cardNo = device.SN
|
||||
}
|
||||
if cardNo != "" {
|
||||
if syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
|
||||
CardNo: cardNo,
|
||||
}); syncErr == nil {
|
||||
s.updateDeviceFromSyncInfo(ctx, id, syncResp)
|
||||
} else {
|
||||
logger.GetAppLogger().Warn("sync-info 调用失败",
|
||||
zap.Uint("device_id", id),
|
||||
zap.Error(syncErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.GetRealtimeStatus(ctx, "device", id)
|
||||
|
||||
default:
|
||||
@@ -343,6 +457,48 @@ func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.
|
||||
}
|
||||
}
|
||||
|
||||
// updateDeviceFromSyncInfo 根据 Gateway sync-info 响应更新设备状态字段和当前卡标识
|
||||
// 失败时只记录日志,不返回错误(非关键步骤)
|
||||
func (s *Service) updateDeviceFromSyncInfo(ctx context.Context, deviceID uint, resp *gateway.SyncDeviceInfoResp) {
|
||||
now := time.Now()
|
||||
|
||||
// 解析 LastOnlineTime(ISO 8601 字符串 → *time.Time)
|
||||
var lastOnlineTime *time.Time
|
||||
if resp.LastOnlineTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, resp.LastOnlineTime); err == nil {
|
||||
lastOnlineTime = &t
|
||||
}
|
||||
}
|
||||
|
||||
// 更新设备 5 个存储字段
|
||||
updates := map[string]any{
|
||||
"online_status": resp.OnlineStatus,
|
||||
"software_version": resp.SoftwareVersion,
|
||||
"switch_mode": resp.SwitchMode,
|
||||
"last_gateway_sync_at": now,
|
||||
}
|
||||
if lastOnlineTime != nil {
|
||||
updates["last_online_time"] = lastOnlineTime
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.Device{}).
|
||||
Where("id = ?", deviceID).
|
||||
Updates(updates).Error; err != nil {
|
||||
logger.GetAppLogger().Warn("sync-info 更新设备字段失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新 is_current 标识(事务原子操作,per D-14 决策)
|
||||
if err := s.deviceSimBindingStore.UpdateIsCurrentByDeviceID(ctx, deviceID, resp.CurrentIccid); err != nil {
|
||||
logger.GetAppLogger().Warn("sync-info 更新 is_current 失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// GetPackages 获取资产的所有套餐列表(支持分页)
|
||||
// page 默认 1,pageSize 默认 50,pageSize 最大 100
|
||||
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int) (*dto.AssetPackagesResult, error) {
|
||||
@@ -451,7 +607,12 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui
|
||||
|
||||
usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, id)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "当前无生效套餐")
|
||||
// 记录不存在表示当前无生效套餐,属于正常业务状态,返回 nil 而非错误
|
||||
// 其他 DB 错误才作为服务端错误处理
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询当前生效套餐失败")
|
||||
}
|
||||
|
||||
pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID)
|
||||
|
||||
@@ -35,7 +35,8 @@ func (s *Service) GetWallet(ctx context.Context, assetType string, assetID uint)
|
||||
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "该资产暂无钱包记录")
|
||||
// 暂无钱包记录属于正常业务状态(企业卡/未充值资产),返回 nil 而非错误
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||||
}
|
||||
|
||||
@@ -206,8 +206,6 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
|
||||
"processed_at": now,
|
||||
"payment_type": req.PaymentType,
|
||||
"remark": req.Remark,
|
||||
"approved_by": currentUserID,
|
||||
"approved_at": now,
|
||||
}
|
||||
|
||||
if req.Amount != nil {
|
||||
|
||||
@@ -57,6 +57,7 @@ func (s *Service) ListBindings(ctx context.Context, deviceID uint) (*dto.ListDev
|
||||
CarrierName: card.CarrierName, // 直接使用 IotCard 的冗余字段
|
||||
Status: card.Status,
|
||||
BindTime: binding.BindTime,
|
||||
IsCurrent: binding.IsCurrent,
|
||||
}
|
||||
responses = append(responses, resp)
|
||||
}
|
||||
|
||||
@@ -556,6 +556,11 @@ func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string
|
||||
ActivatedAt: device.ActivatedAt,
|
||||
CreatedAt: device.CreatedAt,
|
||||
UpdatedAt: device.UpdatedAt,
|
||||
OnlineStatus: device.OnlineStatus,
|
||||
LastOnlineTime: device.LastOnlineTime,
|
||||
SoftwareVersion: device.SoftwareVersion,
|
||||
SwitchMode: device.SwitchMode,
|
||||
LastGatewaySyncAt: device.LastGatewaySyncAt,
|
||||
}
|
||||
|
||||
if device.ShopID != nil && *device.ShopID > 0 {
|
||||
|
||||
@@ -200,3 +200,37 @@ func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []str
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateIsCurrentByDeviceID 原子更新设备当前使用的卡标识
|
||||
// 使用事务两步操作:先全部设为 false,再将 currentIccid 对应行设为 true
|
||||
// currentIccid 为空字符串时,仅执行全部清空(无当前卡的情况)
|
||||
func (s *DeviceSimBindingStore) UpdateIsCurrentByDeviceID(ctx context.Context, deviceID uint, currentIccid string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 第一步:将该设备所有活跃绑定的 is_current 设为 false
|
||||
if err := tx.Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND bind_status = 1", deviceID).
|
||||
Update("is_current", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 第二步:将当前 ICCID 对应的绑定记录设为 is_current = true
|
||||
if currentIccid == "" {
|
||||
return nil
|
||||
}
|
||||
// 通过子查询找到对应的 iot_card_id(避免跨表 JOIN 更新)
|
||||
var iotCardID uint
|
||||
if err := tx.Table("tb_iot_card").
|
||||
Select("id").
|
||||
Where("iccid = ? AND deleted_at IS NULL", currentIccid).
|
||||
Scan(&iotCardID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if iotCardID == 0 {
|
||||
// 未找到对应卡记录,不更新(可能卡尚未入库)
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND iot_card_id = ? AND bind_status = 1", deviceID, iotCardID).
|
||||
Update("is_current", true).Error
|
||||
})
|
||||
}
|
||||
|
||||
6
migrations/000090_device_fields_extension.down.sql
Normal file
6
migrations/000090_device_fields_extension.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE tb_device
|
||||
DROP COLUMN IF EXISTS online_status,
|
||||
DROP COLUMN IF EXISTS last_online_time,
|
||||
DROP COLUMN IF EXISTS software_version,
|
||||
DROP COLUMN IF EXISTS switch_mode,
|
||||
DROP COLUMN IF EXISTS last_gateway_sync_at;
|
||||
14
migrations/000090_device_fields_extension.up.sql
Normal file
14
migrations/000090_device_fields_extension.up.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 扩展 tb_device 设备表,新增 Gateway sync-info 同步的存储字段
|
||||
-- 这些字段有"离线意义"(设备下线后仍需保留最后状态),非实时字段(rssi/battery 等)不入库
|
||||
ALTER TABLE tb_device
|
||||
ADD COLUMN online_status INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN last_online_time TIMESTAMP NULL,
|
||||
ADD COLUMN software_version VARCHAR(100) NOT NULL DEFAULT '',
|
||||
ADD COLUMN switch_mode VARCHAR(10) NOT NULL DEFAULT '0',
|
||||
ADD COLUMN last_gateway_sync_at TIMESTAMP NULL;
|
||||
|
||||
COMMENT ON COLUMN tb_device.online_status IS '在线状态:0未知,1在线,2离线';
|
||||
COMMENT ON COLUMN tb_device.last_online_time IS '最后在线时间(来自 Gateway sync-info)';
|
||||
COMMENT ON COLUMN tb_device.software_version IS '固件版本号';
|
||||
COMMENT ON COLUMN tb_device.switch_mode IS '切卡模式:0=自动,1=手动';
|
||||
COMMENT ON COLUMN tb_device.last_gateway_sync_at IS '最后一次 sync-info 同步时间';
|
||||
2
migrations/000091_device_sim_binding_is_current.down.sql
Normal file
2
migrations/000091_device_sim_binding_is_current.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tb_device_sim_binding
|
||||
DROP COLUMN IF EXISTS is_current;
|
||||
5
migrations/000091_device_sim_binding_is_current.up.sql
Normal file
5
migrations/000091_device_sim_binding_is_current.up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 新增 is_current 字段,标识设备当前正在使用的卡(由 sync-info current_iccid 字段决定)
|
||||
ALTER TABLE tb_device_sim_binding
|
||||
ADD COLUMN is_current BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
COMMENT ON COLUMN tb_device_sim_binding.is_current IS '是否为当前使用的卡(同一设备同一时刻最多一条为 true)';
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tb_commission_withdrawal_request
|
||||
ADD COLUMN IF NOT EXISTS approved_by BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 删除 tb_commission_withdrawal_request 表中的冗余审批人字段
|
||||
-- approved_by / approved_at 与 processor_id / processed_at 完全等价:
|
||||
-- Approve 时两组字段写同一个值,Reject 时只写 processor_id。
|
||||
-- processor_id / processed_at 已覆盖全部场景,approved_* 为历史遗留字段。
|
||||
ALTER TABLE tb_commission_withdrawal_request
|
||||
DROP COLUMN IF EXISTS approved_by,
|
||||
DROP COLUMN IF EXISTS approved_at;
|
||||
Reference in New Issue
Block a user