issue-18 优化代码(只保证编译通过没检查)
This commit is contained in:
@@ -117,10 +117,11 @@ type HeartbeatConfig struct {
|
||||
|
||||
// ChirpStackConfig 代表 ChirpStack API 配置
|
||||
type ChirpStackConfig struct {
|
||||
APIHost string `yaml:"api_host"`
|
||||
APIToken string `yaml:"api_token"`
|
||||
FPort int `yaml:"fport"`
|
||||
APITimeout int `yaml:"api_timeout"`
|
||||
APIHost string `yaml:"api_host"`
|
||||
APIToken string `yaml:"api_token"`
|
||||
FPort int `yaml:"fport"`
|
||||
APITimeout int `yaml:"api_timeout"`
|
||||
CollectionRequestTimeout int `yaml:"collection_request_timeout"`
|
||||
}
|
||||
|
||||
// TaskConfig 代表任务调度配置
|
||||
|
||||
@@ -87,6 +87,10 @@ type Device struct {
|
||||
// Location 描述了设备的物理安装位置,例如 "1号猪舍东侧",方便运维。建立索引以优化按位置查询。
|
||||
Location string `gorm:"index" json:"location"`
|
||||
|
||||
// Command 存储了与设备交互所需的具体指令。
|
||||
// 例如,对于传感器,这里存储 Modbus 采集指令;对于开关和区域主控,这里可以为空。
|
||||
Command string `gorm:"type:varchar(255)" json:"command"`
|
||||
|
||||
// Properties 用于存储特定类型设备的独有属性,采用JSON格式。
|
||||
// 建议在应用层为不同子类型的设备定义专用的属性结构体(如 LoraProperties, BusProperties),以保证数据一致性。
|
||||
Properties datatypes.JSON `json:"properties"`
|
||||
@@ -114,26 +118,48 @@ func (d *Device) ParseProperties(v interface{}) error {
|
||||
// 方法会根据自身类型进行参数检查, 参数不全时返回false
|
||||
// TODO 没写单测
|
||||
func (d *Device) SelfCheck() bool {
|
||||
|
||||
properties := make(map[string]interface{})
|
||||
if err := d.ParseProperties(&properties); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
has := func(key string) bool {
|
||||
_, ok := properties[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
switch d.SubType {
|
||||
case SubTypeFan:
|
||||
if !has(BusNumber) || !has(BusAddress) || !has(RelayChannel) {
|
||||
// 使用清晰的 switch 结构,确保所有情况都被覆盖
|
||||
switch d.Type {
|
||||
case DeviceTypeAreaController:
|
||||
props := make(map[string]interface{})
|
||||
if err := d.ParseProperties(&props); err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := props[LoRaAddress].(string)
|
||||
return ok
|
||||
|
||||
case DeviceTypeDevice:
|
||||
// 所有普通设备都必须有父级
|
||||
if d.ParentID == nil || *d.ParentID == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
props := make(map[string]interface{})
|
||||
if err := d.ParseProperties(&props); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查通用属性是否存在
|
||||
has := func(key string) bool {
|
||||
_, ok := props[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// 根据子类型进行具体校验
|
||||
switch d.SubType {
|
||||
// 所有传感器类型都必须有 Command 和总线信息
|
||||
case SubTypeSensorTemp, SubTypeSensorHumidity, SubTypeSensorWeight, SubTypeSensorAmmonia:
|
||||
return d.Command != "" && has(BusNumber) && has(BusAddress)
|
||||
// 所有开关类型都必须有继电器和总线信息
|
||||
case SubTypeFan, SubTypeWaterCurtain, SubTypeValveFeed:
|
||||
return has(BusNumber) && has(BusAddress) && has(RelayChannel)
|
||||
// 如果是未知的子类型,或者没有子类型,则认为自检失败
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果设备类型不是已知的任何一种,则自检失败
|
||||
default:
|
||||
// 不应该有类型未知的设备
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeviceCommandLog 记录下行任务的下发情况和设备确认状态
|
||||
type DeviceCommandLog struct {
|
||||
// MessageID 是下行消息的唯一标识符。
|
||||
// 可以是 ChirpStack 的 DeduplicationID 或其他系统生成的ID。
|
||||
MessageID string `gorm:"primaryKey" json:"message_id"`
|
||||
|
||||
// DeviceID 是接收此下行任务的设备的ID。
|
||||
// 对于 LoRaWAN,这通常是区域主控设备的ID。
|
||||
DeviceID uint `gorm:"not null;index" json:"device_id"`
|
||||
|
||||
// SentAt 记录下行任务最初发送的时间。
|
||||
SentAt time.Time `gorm:"not null" json:"sent_at"`
|
||||
|
||||
// AcknowledgedAt 记录设备确认收到下行消息的时间。
|
||||
// 如果设备未确认,则为零值或 NULL。使用指针类型 *time.Time 允许 NULL 值。
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at"`
|
||||
|
||||
// ReceivedSuccess 表示设备是否成功接收到下行消息。
|
||||
// true 表示设备已确认收到,false 表示设备未确认收到或下发失败。
|
||||
ReceivedSuccess bool `gorm:"not null" json:"received_success"`
|
||||
}
|
||||
|
||||
// TableName 自定义 GORM 使用的数据库表名
|
||||
func (DeviceCommandLog) TableName() string {
|
||||
return "device_command_log"
|
||||
}
|
||||
@@ -73,3 +73,70 @@ func (log *TaskExecutionLog) AfterFind(tx *gorm.DB) (err error) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- 指令与采集 ---
|
||||
|
||||
// PendingCollectionStatus 定义了待采集请求的状态
|
||||
type PendingCollectionStatus string
|
||||
|
||||
const (
|
||||
PendingStatusPending PendingCollectionStatus = "pending" // 请求已发送,等待设备响应
|
||||
PendingStatusFulfilled PendingCollectionStatus = "fulfilled" // 已收到设备响应并成功处理
|
||||
PendingStatusTimedOut PendingCollectionStatus = "timed_out" // 请求超时,未收到设备响应
|
||||
)
|
||||
|
||||
// DeviceCommandLog 记录所有“发后即忘”的下行指令日志。
|
||||
// 这张表主要用于追踪指令是否被网关成功发送 (ack)。
|
||||
type DeviceCommandLog struct {
|
||||
// MessageID 是下行消息的唯一标识符。
|
||||
// 可以是 ChirpStack 的 DeduplicationID 或其他系统生成的ID。
|
||||
MessageID string `gorm:"primaryKey" json:"message_id"`
|
||||
|
||||
// DeviceID 是接收此下行任务的设备的ID。
|
||||
// 对于 LoRaWAN,这通常是区域主控设备的ID。
|
||||
DeviceID uint `gorm:"not null;index" json:"device_id"`
|
||||
|
||||
// SentAt 记录下行任务最初发送的时间。
|
||||
SentAt time.Time `gorm:"not null" json:"sent_at"`
|
||||
|
||||
// AcknowledgedAt 记录设备确认收到下行消息的时间。
|
||||
// 如果设备未确认,则为零值或 NULL。使用指针类型 *time.Time 允许 NULL 值。
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at"`
|
||||
|
||||
// ReceivedSuccess 表示设备是否成功接收到下行消息。
|
||||
// true 表示设备已确认收到,false 表示设备未确认收到或下发失败。
|
||||
ReceivedSuccess bool `gorm:"not null" json:"received_success"`
|
||||
}
|
||||
|
||||
// TableName 自定义 GORM 使用的数据库表名
|
||||
func (DeviceCommandLog) TableName() string {
|
||||
return "device_command_log"
|
||||
}
|
||||
|
||||
// PendingCollection 记录所有需要设备响应的“待采集请求”。
|
||||
// 这是一张状态机表,追踪从请求发送到收到响应的整个生命周期。
|
||||
type PendingCollection struct {
|
||||
// CorrelationID 是由平台生成的、在请求和响应之间全局唯一的关联ID,作为主键。
|
||||
CorrelationID string `gorm:"primaryKey"`
|
||||
|
||||
// DeviceID 是接收此任务的设备ID
|
||||
// 对于 LoRaWAN,这通常是区域主控设备的ID。
|
||||
DeviceID uint `gorm:"index"`
|
||||
|
||||
// CommandMetadata 存储了此次采集任务对应的设备ID列表,顺序与设备响应值的顺序一致。
|
||||
CommandMetadata UintArray `gorm:"type:bigint[]"`
|
||||
|
||||
// Status 是该请求的当前状态,用于状态机管理和超时处理。
|
||||
Status PendingCollectionStatus `gorm:"index"`
|
||||
|
||||
// FulfilledAt 是收到设备响应并成功处理的时间。使用指针以允许 NULL 值。
|
||||
FulfilledAt *time.Time
|
||||
|
||||
// CreatedAt 是 GORM 的标准字段,记录请求创建时间。
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName 自定义 GORM 使用的数据库表名
|
||||
func (PendingCollection) TableName() string {
|
||||
return "pending_collections"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetAllModels 返回一个包含所有数据库模型实例的切片。
|
||||
// 这个函数用于在数据库初始化时自动迁移所有的表结构。
|
||||
func GetAllModels() []interface{} {
|
||||
@@ -14,5 +22,70 @@ func GetAllModels() []interface{} {
|
||||
&PendingTask{},
|
||||
&SensorData{},
|
||||
&DeviceCommandLog{},
|
||||
&PendingCollection{},
|
||||
}
|
||||
}
|
||||
|
||||
// UintArray 是一个自定义类型,代表 uint 的切片。
|
||||
// 它实现了 gorm.Scanner 和 driver.Valuer 接口,
|
||||
// 以便能与数据库的 bigint[] 类型进行原生映射。
|
||||
type UintArray []uint
|
||||
|
||||
// Value 实现了 driver.Valuer 接口。
|
||||
// 它告诉 GORM 如何将 UintArray ([]) 转换为数据库能够理解的格式。
|
||||
func (a UintArray) Value() (driver.Value, error) {
|
||||
if a == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("{")
|
||||
for i, v := range a {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
b.WriteString(strconv.FormatUint(uint64(v), 10))
|
||||
}
|
||||
b.WriteString("}")
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// Scan 实现了 gorm.Scanner 接口。
|
||||
// 它告诉 GORM 如何将从数据库读取的数据转换为我们的 UintArray ([])。
|
||||
func (a *UintArray) Scan(src interface{}) error {
|
||||
if src == nil {
|
||||
*a = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
var srcStr string
|
||||
switch v := src.(type) {
|
||||
case []byte:
|
||||
srcStr = string(v)
|
||||
case string:
|
||||
srcStr = v
|
||||
default:
|
||||
return errors.New("无法扫描非字符串或字节类型的源到 UintArray")
|
||||
}
|
||||
|
||||
// 去掉花括号
|
||||
srcStr = strings.Trim(srcStr, "{}")
|
||||
if srcStr == "" {
|
||||
*a = []uint{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按逗号分割
|
||||
parts := strings.Split(srcStr, ",")
|
||||
arr := make([]uint, len(parts))
|
||||
for i, p := range parts {
|
||||
val, err := strconv.ParseUint(p, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 UintArray 元素失败: %w", err)
|
||||
}
|
||||
arr[i] = uint(val)
|
||||
}
|
||||
|
||||
*a = arr
|
||||
return nil
|
||||
}
|
||||
|
||||
67
internal/infra/repository/pending_collection_repository.go
Normal file
67
internal/infra/repository/pending_collection_repository.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PendingCollectionRepository 定义了与待采集请求相关的数据库操作接口。
|
||||
type PendingCollectionRepository interface {
|
||||
// Create 创建一个新的待采集请求。
|
||||
Create(req *models.PendingCollection) error
|
||||
|
||||
// FindByCorrelationID 根据关联ID查找一个待采集请求。
|
||||
FindByCorrelationID(correlationID string) (*models.PendingCollection, error)
|
||||
|
||||
// UpdateStatusToFulfilled 将指定关联ID的请求状态更新为“已完成”。
|
||||
UpdateStatusToFulfilled(correlationID string, fulfilledAt time.Time) error
|
||||
|
||||
// MarkAllPendingAsTimedOut 将所有“待处理”请求更新为“已超时”。
|
||||
MarkAllPendingAsTimedOut() (int64, error)
|
||||
}
|
||||
|
||||
// gormPendingCollectionRepository 是 PendingCollectionRepository 的 GORM 实现。
|
||||
type gormPendingCollectionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewGormPendingCollectionRepository 创建一个新的 PendingCollectionRepository GORM 实现实例。
|
||||
func NewGormPendingCollectionRepository(db *gorm.DB) PendingCollectionRepository {
|
||||
return &gormPendingCollectionRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建一个新的待采集请求。
|
||||
func (r *gormPendingCollectionRepository) Create(req *models.PendingCollection) error {
|
||||
return r.db.Create(req).Error
|
||||
}
|
||||
|
||||
// FindByCorrelationID 根据关联ID查找一个待采集请求。
|
||||
func (r *gormPendingCollectionRepository) FindByCorrelationID(correlationID string) (*models.PendingCollection, error) {
|
||||
var req models.PendingCollection
|
||||
if err := r.db.First(&req, "correlation_id = ?", correlationID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
// UpdateStatusToFulfilled 将指定关联ID的请求状态更新为“已完成”。
|
||||
func (r *gormPendingCollectionRepository) UpdateStatusToFulfilled(correlationID string, fulfilledAt time.Time) error {
|
||||
return r.db.Model(&models.PendingCollection{}).
|
||||
Where("correlation_id = ?", correlationID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": models.PendingStatusFulfilled,
|
||||
"fulfilled_at": &fulfilledAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// MarkAllPendingAsTimedOut 将所有状态为 'pending' 的记录更新为 'timed_out'。
|
||||
// 返回被更新的记录数量和错误。
|
||||
func (r *gormPendingCollectionRepository) MarkAllPendingAsTimedOut() (int64, error) {
|
||||
result := r.db.Model(&models.PendingCollection{}).
|
||||
Where("status = ?", models.PendingStatusPending).
|
||||
Update("status", models.PendingStatusTimedOut)
|
||||
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package lora
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/lora/chirp_stack_proto/client/device_service"
|
||||
"github.com/go-openapi/runtime"
|
||||
httptransport "github.com/go-openapi/runtime/client"
|
||||
@@ -20,19 +20,13 @@ type ChirpStackTransport struct {
|
||||
client *client.ChirpStackRESTAPI
|
||||
authInfo runtime.ClientAuthInfoWriter
|
||||
config config.ChirpStackConfig
|
||||
|
||||
deviceCommandLogRepo repository.DeviceCommandLogRepository
|
||||
deviceRepo repository.DeviceRepository
|
||||
|
||||
logger *logs.Logger
|
||||
logger *logs.Logger
|
||||
}
|
||||
|
||||
// NewChirpStackTransport 创建一个新的通信实例,用于与 ChirpStack 通信。
|
||||
func NewChirpStackTransport(
|
||||
config config.ChirpStackConfig,
|
||||
logger *logs.Logger,
|
||||
deviceCommandLogRepo repository.DeviceCommandLogRepository,
|
||||
deviceRepo repository.DeviceRepository,
|
||||
) *ChirpStackTransport {
|
||||
// 使用配置中的服务器地址创建一个 HTTP transport。
|
||||
// 它会使用生成的客户端中定义的默认 base path 和 schemes。
|
||||
@@ -45,16 +39,14 @@ func NewChirpStackTransport(
|
||||
authInfo := httptransport.APIKeyAuth("grpc-metadata-authorization", "header", config.GenerateAPIKey())
|
||||
|
||||
return &ChirpStackTransport{
|
||||
client: apiClient,
|
||||
authInfo: authInfo,
|
||||
config: config,
|
||||
logger: logger,
|
||||
deviceCommandLogRepo: deviceCommandLogRepo,
|
||||
deviceRepo: deviceRepo,
|
||||
client: apiClient,
|
||||
authInfo: authInfo,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ChirpStackTransport) Send(address string, payload []byte) error {
|
||||
func (c *ChirpStackTransport) Send(address string, payload []byte) (*transport.SendResult, error) {
|
||||
// 1. 构建 API 请求体。
|
||||
// - Confirmed: true 表示确认消息, 设为false将不保证消息送达(但可以节约下行容量)。
|
||||
// - Data: 经过 Base64 编码的数据。
|
||||
@@ -72,7 +64,7 @@ func (c *ChirpStackTransport) Send(address string, payload []byte) error {
|
||||
// - WithQueueItemDevEui 指定目标设备的 EUI。
|
||||
// - WithBody 设置请求体。
|
||||
params := device_service.NewDeviceServiceEnqueueParams().
|
||||
WithTimeout(10 * time.Second).
|
||||
WithTimeout(5 * time.Second). // TODO 这里应该从配置文件里读
|
||||
WithQueueItemDevEui(address).
|
||||
WithBody(body)
|
||||
|
||||
@@ -81,53 +73,23 @@ func (c *ChirpStackTransport) Send(address string, payload []byte) error {
|
||||
resp, err := c.client.DeviceService.DeviceServiceEnqueue(params, c.authInfo)
|
||||
if err != nil {
|
||||
c.logger.Errorf("设备 %s 调用ChirpStack Enqueue失败: %v", address, err)
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. 成功发送后,尝试记录下行任务
|
||||
messageID := ""
|
||||
if resp != nil && resp.Payload != nil && resp.Payload.ID != "" { // 根据实际结构,使用 resp.Payload.ID
|
||||
messageID = resp.Payload.ID
|
||||
} else {
|
||||
c.logger.Warnf("ChirpStack Enqueue 响应未包含 MessageID (ID),无法记录下行任务。设备: %s", address)
|
||||
// 即使无法获取 MessageID,也认为发送成功,因为 ChirpStack Enqueue 成功了
|
||||
return nil
|
||||
if resp == nil || resp.Payload == nil || resp.Payload.ID == "" {
|
||||
// 这是一个需要明确处理的错误情况,因为调用方依赖 MessageID。
|
||||
errMsg := "ChirpStack Enqueue 响应未包含 MessageID (ID)"
|
||||
c.logger.Errorf(errMsg)
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
|
||||
// 调用私有方法记录下行任务
|
||||
if err := c.recordDownlinkTask(address, messageID); err != nil {
|
||||
// 记录失败不影响下行命令的发送成功
|
||||
c.logger.Errorf("记录下行任务失败 (MessageID: %s, DevEui: %s): %v", messageID, address, err)
|
||||
return nil
|
||||
c.logger.Infof("成功将 payload 发送到设备 %s 的队列 (MessageID: %s)", address, resp.Payload.ID)
|
||||
|
||||
// 将 MessageID 包装在 SendResult 中返回
|
||||
result := &transport.SendResult{
|
||||
MessageID: resp.Payload.ID,
|
||||
}
|
||||
|
||||
c.logger.Infof("设备 %s 调用ChirpStack Enqueue成功,并创建下行任务记录 (MessageID: %s)", address, messageID)
|
||||
return result, nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordDownlinkTask 记录下行任务到数据库
|
||||
func (c *ChirpStackTransport) recordDownlinkTask(devEui string, messageID string) error {
|
||||
// 获取区域主控的内部 DeviceID
|
||||
regionalController, err := c.deviceRepo.FindByDevEui(devEui)
|
||||
if err != nil {
|
||||
c.logger.Errorf("记录下行任务失败:无法通过 DevEui '%s' 找到区域主控设备: %v", devEui, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建 DeviceCommandLog
|
||||
record := &models.DeviceCommandLog{
|
||||
MessageID: messageID,
|
||||
DeviceID: regionalController.ID,
|
||||
SentAt: time.Now(),
|
||||
AcknowledgedAt: nil, // 初始状态为未确认
|
||||
}
|
||||
|
||||
if err := c.deviceCommandLogRepo.Create(record); err != nil {
|
||||
c.logger.Errorf("创建下行任务记录失败 (MessageID: %s, DeviceID: %d): %v", messageID, regionalController.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
c.logger.Infof("成功创建下行任务记录 (MessageID: %s, DeviceID: %d)", messageID, regionalController.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,5 +3,13 @@ package transport
|
||||
// Communicator 用于其他设备通信
|
||||
type Communicator interface {
|
||||
// Send 用于发送一条单向数据(不等待回信)
|
||||
Send(address string, payload []byte) error
|
||||
// 成功时,它返回一个包含 MessageID 的 SendResult,以便调用方追踪。
|
||||
Send(address string, payload []byte) (*SendResult, error)
|
||||
}
|
||||
|
||||
// SendResult 包含了 SendGo 方法成功执行后返回的结果。
|
||||
type SendResult struct {
|
||||
// MessageID 是通信服务为此次发送分配的唯一标识符。
|
||||
// 调用方需要保存此 ID,以便后续关联 ACK 等事件。
|
||||
MessageID string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user