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`
+
+
+
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 主流程
+
+
+