Files
junhong_cmp_fiber/.planning/phases/03-device-system/03-02-PLAN.md

406 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
---
<objective>
将 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 调用
</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
@.planning/phases/03-device-system/03-01-SUMMARY.md
<interfaces>
<!-- 本 Plan 依赖 Plan 01 产出的接口executor 直接使用 -->
**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需要新增
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: DeviceSimBindingStore 新增 UpdateIsCurrentByDeviceID 方法</name>
<files>
internal/store/postgres/device_sim_binding_store.go
</files>
<action>
`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已有无需新增
</action>
<verify>
<automated>go build ./internal/store/postgres/...</automated>
</verify>
<done>
- `UpdateIsCurrentByDeviceID` 方法存在于 device_sim_binding_store.go
- 事务内两步操作:先全清 false再按 ICCID 设 true
- go build 编译通过
</done>
</task>
<task type="auto">
<name>Task 2: Asset Service 注入 gatewayClient + 实现 updateDeviceFromSyncInfo + Refresh 接入 sync-info</name>
<files>
internal/service/asset/service.go,
internal/bootstrap/services.go
</files>
<action>
## 步骤 1internal/service/asset/service.go — 结构体 + 构造函数 + Refresh 分支 + 新函数
**① import 块追加**(在现有 import 中添加):
```go
"github.com/break/junhong_cmp_fiber/internal/gateway"
"time"
```
注意:`time` 可能已有,检查后选择性添加。
**② Service 结构体追加字段**per D-13D-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-3D-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 优先,无则用 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` 函数之后追加:
```go
// 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
找到以下行:
```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 ./...` 确认编译无错误。
</action>
<verify>
<automated>go build ./...</automated>
</verify>
<done>
- `asset.Service``gatewayClient *gateway.Client` 字段
- `asset.New()` 新增 `gatewayClient` 参数bootstrap 调用处已追加 `deps.GatewayClient`
- `updateDeviceFromSyncInfo` 函数存在:更新 5 个 device 字段 + 调用 UpdateIsCurrentByDeviceID
- `Refresh` device 分支在绑定卡刷新后追加 sync-info 调用nil guard + Warn 日志)
- `go build ./...` 零新增编译错误
</done>
</task>
</tasks>
<verification>
```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>