--- 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`