Files

15 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-device-system 02 execute 2
03-01
internal/service/asset/service.go
internal/bootstrap/services.go
internal/store/postgres/device_sim_binding_store.go
true
DEVICE-04
truths artifacts key_links
调用设备刷新接口后,设备的在线状态、固件版本、当前使用卡从 Gateway 实时更新
同一设备同一时刻只有一条 is_current=true 的绑定记录(事务原子更新)
sync-info 调用失败时不阻断刷新流程,仅记录 Warn 日志
go build ./... 编译通过
path provides contains
internal/service/asset/service.go updateDeviceFromSyncInfo 私有函数 + Refresh device 分支 sync-info 调用 updateDeviceFromSyncInfo
path provides
internal/bootstrap/services.go asset.Service 注入 gatewayClient
path provides
internal/store/postgres/device_sim_binding_store.go UpdateIsCurrentByDeviceID Store 方法(事务两步更新)
from to via pattern
internal/service/asset/service.go:Refresh internal/gateway/device.go:SyncDeviceInfo s.gatewayClient.SyncDeviceInfo() gatewayClient.SyncDeviceInfo
from to via pattern
internal/service/asset/service.go:updateDeviceFromSyncInfo internal/store/postgres/device_sim_binding_store.go:UpdateIsCurrentByDeviceID 事务原子更新 is_current 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 调用

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_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

Plan 01 产出的类型internal/gateway/models.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

func (c *Client) SyncDeviceInfo(ctx context.Context, req *SyncDeviceInfoReq) (*SyncDeviceInfoResp, error)

Plan 01 产出的模型字段internal/model/device.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

IsCurrent bool // gorm:"column:is_current"

internal/service/asset/service.go 现有 Refresh device 分支(约 295-340 行):

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

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

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

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` 末尾追加以下方法:
// 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 ## 步骤 1internal/service/asset/service.go — 结构体 + 构造函数 + Refresh 分支 + 新函数

① import 块追加(在现有 import 中添加):

"github.com/break/junhong_cmp_fiber/internal/gateway"
"time"

注意:time 可能已有,检查后选择性添加。

② Service 结构体追加字段per D-13D-15 决策): 在 iotCardService IotCardRefresher 行之后追加:

gatewayClient *gateway.Client // 用于调用 sync-info 同步设备信息(可为 nil失败时仅记录日志

③ New() 函数签名追加参数iotCardService IotCardRefresher, 参数之后追加:

gatewayClient *gateway.Client,

并在 return 的结构体字面量中追加:

gatewayClient: gatewayClient,

④ Refresh device 分支追加 sync-info 调用per D-3D-15 决策): 在 s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration) 之后、return s.GetRealtimeStatus(ctx, "device", id) 之前插入:

// 刷新设备自身信息在线状态、当前卡、固件版本等失败不阻断刷新流程per D-15
if s.gatewayClient != nil {
    device, devErr := s.deviceStore.GetByID(ctx, id)
    if devErr == nil {
        // IMEI 优先,无则用 SNper 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-13D-14 决策): 在 Refresh 函数之后追加:

// updateDeviceFromSyncInfo 根据 Gateway sync-info 响应更新设备状态字段和当前卡标识
// 失败时只记录日志,不返回错误(非关键步骤)
func (s *Service) updateDeviceFromSyncInfo(ctx context.Context, deviceID uint, resp *gateway.SyncDeviceInfoResp) {
    now := time.Now()

    // 解析 LastOnlineTimeISO 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))
    }
}

步骤 2internal/bootstrap/services.go — Asset.New 追加 deps.GatewayClient

找到以下行:

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

最终验证

执行 go build ./... 确认编译无错误。 go build ./... - asset.ServicegatewayClient *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

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