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