From 8a4c6f3cee3eb266047b837950a739c15afaccdf Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 28 Mar 2026 11:24:31 +0800 Subject: [PATCH] =?UTF-8?q?docs(03-device-system):=20=E5=88=9B=E5=BB=BA=20?= =?UTF-8?q?Phase=203=20=E8=AE=BE=E5=A4=87=E4=BD=93=E7=B3=BB=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E8=AE=A1=E5=88=92=EF=BC=882=E4=B8=AAPlan=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 6 +- .../phases/03-device-system/03-01-PLAN.md | 403 +++++++++++++++++ .../phases/03-device-system/03-02-PLAN.md | 405 ++++++++++++++++++ 3 files changed, 813 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/03-device-system/03-01-PLAN.md create mode 100644 .planning/phases/03-device-system/03-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 77deb31..ec6f899 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -70,7 +70,11 @@ 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: +- [ ] 03-01-PLAN.md — DEVICE-01/02/03:DB 迁移 + 模型扩展 + Gateway SyncDeviceInfo + DTO 字段(Wave 1) +- [ ] 03-02-PLAN.md — DEVICE-04:Asset Service 接入 sync-info,Refresh 追加设备状态同步(Wave 2) --- diff --git a/.planning/phases/03-device-system/03-01-PLAN.md b/.planning/phases/03-device-system/03-01-PLAN.md new file mode 100644 index 0000000..3ae3564 --- /dev/null +++ b/.planning/phases/03-device-system/03-01-PLAN.md @@ -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" +--- + + +建立设备体系的数据基础层:扩展 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 扩展 + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/03-device-system/03-CONTEXT.md + + + + +**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` 开始 + + + + + + + Task 1: DB 迁移 + 模型字段扩展(D-0 + D-2 DB 层) + + 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 + + +## 迁移文件 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"` +``` + + + make migrate-up && make migrate-version && go build ./... + + + - migrate-version 显示版本 91,dirty=false + - go build ./... 编译无新增错误 + - tb_device 含 5 个新字段,tb_device_sim_binding 含 is_current(可用 MCP dbhub_search_objects 验证) + + + + + Task 2: Gateway SyncDeviceInfo 方法 + DTO 字段扩展(D-1 + D-2 DTO 层) + + internal/gateway/models.go, + internal/gateway/device.go, + internal/model/dto/device_dto.go, + internal/model/dto/asset_dto.go + + +## 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 ./...`,确认无编译错误。 + + + go build ./... + + + - SyncDeviceInfoReq / SyncDeviceInfoResp 在 internal/gateway/models.go 中定义 + - SyncDeviceInfo() 方法在 internal/gateway/device.go 中实现 + - DeviceResponse 含 5 个新字段,DeviceCardBindingResponse 含 is_current + - BoundCardInfo 含 is_current + - go build ./... 零新增错误 + + + + + + +```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/ +``` + + + +- [ ] `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` + + + +完成后创建 `.planning/phases/03-device-system/03-01-SUMMARY.md` + diff --git a/.planning/phases/03-device-system/03-02-PLAN.md b/.planning/phases/03-device-system/03-02-PLAN.md new file mode 100644 index 0000000..315d043 --- /dev/null +++ b/.planning/phases/03-device-system/03-02-PLAN.md @@ -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" +--- + + +将 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 调用 + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + + +**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,需要新增 +``` + + + + + + + Task 1: DeviceSimBindingStore 新增 UpdateIsCurrentByDeviceID 方法 + + internal/store/postgres/device_sim_binding_store.go + + +在 `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(已有,无需新增)。 + + + go build ./internal/store/postgres/... + + + - `UpdateIsCurrentByDeviceID` 方法存在于 device_sim_binding_store.go + - 事务内两步操作:先全清 false,再按 ICCID 设 true + - go build 编译通过 + + + + + Task 2: Asset Service 注入 gatewayClient + 实现 updateDeviceFromSyncInfo + Refresh 接入 sync-info + + internal/service/asset/service.go, + internal/bootstrap/services.go + + +## 步骤 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 ./...` 确认编译无错误。 + + + go build ./... + + + - `asset.Service` 含 `gatewayClient *gateway.Client` 字段 + - `asset.New()` 新增 `gatewayClient` 参数,bootstrap 调用处已追加 `deps.GatewayClient` + - `updateDeviceFromSyncInfo` 函数存在:更新 5 个 device 字段 + 调用 UpdateIsCurrentByDeviceID + - `Refresh` device 分支在绑定卡刷新后追加 sync-info 调用(nil guard + Warn 日志) + - `go build ./...` 零新增编译错误 + + + + + + +```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 +``` + + + +- [ ] `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 主流程 + + + +完成后创建 `.planning/phases/03-device-system/03-02-SUMMARY.md` +