15 KiB
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 |
|
|
true |
|
|
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.mdPlan 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,需要新增
// 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 编译通过
① import 块追加(在现有 import 中添加):
"github.com/break/junhong_cmp_fiber/internal/gateway"
"time"
注意:time 可能已有,检查后选择性添加。
② Service 结构体追加字段(per D-13,D-15 决策):
在 iotCardService IotCardRefresher 行之后追加:
gatewayClient *gateway.Client // 用于调用 sync-info 同步设备信息(可为 nil,失败时仅记录日志)
③ New() 函数签名追加参数:
在 iotCardService IotCardRefresher, 参数之后追加:
gatewayClient *gateway.Client,
并在 return 的结构体字面量中追加:
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) 之前插入:
// 刷新设备自身信息(在线状态、当前卡、固件版本等),失败不阻断刷新流程(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 函数之后追加:
// 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
找到以下行:
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.Service 含 gatewayClient *gateway.Client 字段
- asset.New() 新增 gatewayClient 参数,bootstrap 调用处已追加 deps.GatewayClient
- updateDeviceFromSyncInfo 函数存在:更新 5 个 device 字段 + 调用 UpdateIsCurrentByDeviceID
- Refresh device 分支在绑定卡刷新后追加 sync-info 调用(nil guard + Warn 日志)
- 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>