feat: replace iot adapter with mqtt command pipeline
This commit is contained in:
@@ -47,3 +47,7 @@ Payment:
|
||||
OfficialAccountAppID: ""
|
||||
MiniProgramAppID: ""
|
||||
NotifyURL: "https://example.invalid/heqi/payment-return/v1/wechat/notify"
|
||||
|
||||
IoT:
|
||||
InternalServiceToken: change-me-iot-internal-token
|
||||
ClientBaseURL: http://127.0.0.1:12429
|
||||
|
||||
@@ -66,6 +66,12 @@ type PaymentConfig struct {
|
||||
Wechat WechatPayConfig `yaml:"Wechat"`
|
||||
}
|
||||
|
||||
// IoTConfig 保存 API、Worker 与 IoT Client 间的内部认证和地址。
|
||||
type IoTConfig struct {
|
||||
InternalServiceToken string `yaml:"InternalServiceToken"`
|
||||
ClientBaseURL string `yaml:"ClientBaseURL"`
|
||||
}
|
||||
|
||||
// SrvConfig 与仓库现有进程的配置结构保持一致。
|
||||
type SrvConfig struct {
|
||||
conf.Base `yaml:",inline"`
|
||||
@@ -75,6 +81,7 @@ type SrvConfig struct {
|
||||
Global GlobalConfig `yaml:"Global"`
|
||||
Wallet WalletConfig `yaml:"-"`
|
||||
Payment PaymentConfig `yaml:"Payment"`
|
||||
IoT IoTConfig `yaml:"IoT"`
|
||||
}
|
||||
|
||||
// New 初始化 BSM 配置并校验服务监听地址。
|
||||
@@ -103,6 +110,9 @@ func New(srvKey string) {
|
||||
if Spec.Payment.ExpireMinutes <= 0 || Spec.Payment.RefundWindowDays <= 0 {
|
||||
panic("Payment expiration and refund window must be greater than zero")
|
||||
}
|
||||
if len(strings.TrimSpace(Spec.IoT.InternalServiceToken)) < 16 || !strings.HasPrefix(Spec.IoT.ClientBaseURL, "http") {
|
||||
panic("IoT internal token and client base URL are required")
|
||||
}
|
||||
registerURL, err := url.ParseRequestURI(Spec.Global.UserRegisterURL)
|
||||
if err != nil || (registerURL.Scheme != "http" && registerURL.Scheme != "https") || registerURL.Host == "" {
|
||||
panic("Global.UserRegisterURL must be a valid HTTP or HTTPS URL")
|
||||
|
||||
197
backend/api/internal/logic/iot/iot.go
Normal file
197
backend/api/internal/logic/iot/iot.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Package iot 实现设备命令、Outbox 认领和上行报文持久化。
|
||||
package iot
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type commandRequest struct {
|
||||
DeviceIdentity string `json:"device_identity"`
|
||||
DeviceID string `json:"device_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Action string `json:"action"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
KeyID byte `json:"key_id"`
|
||||
DeviceKind byte `json:"device_kind"`
|
||||
DeviceType byte `json:"device_type"`
|
||||
DeviceModel [3]byte `json:"device_model"`
|
||||
Controller byte `json:"controller"`
|
||||
Loop byte `json:"loop"`
|
||||
Component byte `json:"component"`
|
||||
}
|
||||
|
||||
func RequireInternalToken() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
actual := ctx.GetHeader("X-Heqi-Iot-Token")
|
||||
expected := config.Spec.IoT.InternalServiceToken
|
||||
if len(actual) != len(expected) || subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 {
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": "IOT_UNAUTHORIZED"})
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func CreateCommand(ctx *gin.Context) {
|
||||
var request commandRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil || request.DeviceIdentity == "" || len(request.DeviceID) != 16 || request.IdempotencyKey == "" || (request.Action != "open_valve" && request.Action != "close_valve") {
|
||||
ctx.JSON(422, gin.H{"code": "IOT_COMMAND_INVALID"})
|
||||
return
|
||||
}
|
||||
if request.ExpiresAt.IsZero() {
|
||||
request.ExpiresAt = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
var existing models.IotCommand
|
||||
if err := impl.DBService.Where("idempotency_key = ?", request.IdempotencyKey).First(&existing).Error; err == nil {
|
||||
ctx.JSON(200, existing)
|
||||
return
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
ctx.JSON(500, gin.H{"code": "IOT_COMMAND_QUERY_FAILED"})
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(request)
|
||||
command := models.IotCommand{Entity: models.Entity{Identity: models.NewIdentity()}, DeviceIdentity: request.DeviceIdentity, DeviceID: request.DeviceID, IdempotencyKey: request.IdempotencyKey, Action: request.Action, RequestPayload: string(payload), CommandStatus: "accepted", ExpiresAt: request.ExpiresAt}
|
||||
outbox := models.IotOutbox{Identity: models.NewIdentity(), CommandIdentity: command.Identity, EventType: "iot.command.accepted", Payload: string(payload), OutboxStatus: "pending", AvailableAt: time.Now()}
|
||||
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&command).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&outbox).Error
|
||||
})
|
||||
if err != nil {
|
||||
ctx.JSON(500, gin.H{"code": "IOT_COMMAND_CREATE_FAILED"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusAccepted, command)
|
||||
}
|
||||
|
||||
func GetCommand(ctx *gin.Context) {
|
||||
var command models.IotCommand
|
||||
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&command).Error; err != nil {
|
||||
ctx.JSON(404, gin.H{"code": "IOT_COMMAND_NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(200, command)
|
||||
}
|
||||
|
||||
func ClaimOutbox(ctx *gin.Context) {
|
||||
var outbox models.IotOutbox
|
||||
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("(outbox_status IN ? AND available_at <= ?) OR (outbox_status = ? AND updated_at < ?)", []string{"pending", "retry"}, now, "processing", now.Add(-time.Minute)).Order("id").First(&outbox).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&outbox).Updates(map[string]any{"outbox_status": "processing", "attempts": gorm.Expr("attempts + 1"), "updated_at": time.Now()}).Error
|
||||
})
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ctx.JSON(500, gin.H{"code": "IOT_OUTBOX_CLAIM_FAILED"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(200, outbox)
|
||||
}
|
||||
|
||||
func CompleteOutbox(ctx *gin.Context) {
|
||||
var request struct {
|
||||
Success bool `json:"success"`
|
||||
PacketNumber uint16 `json:"packet_number"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
}
|
||||
if ctx.ShouldBindJSON(&request) != nil {
|
||||
ctx.JSON(400, gin.H{"code": "IOT_OUTBOX_RESULT_INVALID"})
|
||||
return
|
||||
}
|
||||
var outbox models.IotOutbox
|
||||
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&outbox).Error; err != nil {
|
||||
ctx.JSON(404, gin.H{"code": "IOT_OUTBOX_NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
if request.Success {
|
||||
if err := tx.Model(&outbox).Updates(map[string]any{"outbox_status": "published", "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&models.IotCommand{}).Where("identity = ?", outbox.CommandIdentity).Updates(map[string]any{"command_status": "pending_confirmation", "packet_number": request.PacketNumber, "dispatched_at": now, "updated_at": now}).Error
|
||||
}
|
||||
delay := time.Duration(outbox.Attempts+1) * time.Second
|
||||
if delay > time.Minute {
|
||||
delay = time.Minute
|
||||
}
|
||||
if err := tx.Model(&outbox).Updates(map[string]any{"outbox_status": "retry", "available_at": now.Add(delay), "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&models.IotCommand{}).Where("identity = ?", outbox.CommandIdentity).Updates(map[string]any{"command_status": "dispatch_failed", "error_code": request.ErrorCode, "updated_at": now}).Error
|
||||
})
|
||||
if err != nil {
|
||||
ctx.JSON(500, gin.H{"code": "IOT_OUTBOX_RESULT_FAILED"})
|
||||
return
|
||||
}
|
||||
ctx.Status(204)
|
||||
}
|
||||
|
||||
func SaveDeviceMessage(ctx *gin.Context) {
|
||||
data, err := ctx.GetRawData()
|
||||
if err != nil || len(data) == 0 {
|
||||
ctx.JSON(400, gin.H{"code": "IOT_MESSAGE_INVALID"})
|
||||
return
|
||||
}
|
||||
var envelope struct {
|
||||
Type, Topic, DeviceID, ReceivedAt, PayloadHex string
|
||||
Frame json.RawMessage `json:"frame"`
|
||||
}
|
||||
if json.Unmarshal(data, &envelope) != nil || envelope.ReceivedAt == "" {
|
||||
ctx.JSON(422, gin.H{"code": "IOT_MESSAGE_INVALID"})
|
||||
return
|
||||
}
|
||||
received, err := time.Parse(time.RFC3339Nano, envelope.ReceivedAt)
|
||||
if err != nil {
|
||||
ctx.JSON(422, gin.H{"code": "IOT_RECEIVED_AT_INVALID"})
|
||||
return
|
||||
}
|
||||
message := models.IotDeviceMessage{Identity: models.NewIdentity(), DeviceID: envelope.DeviceID, Topic: envelope.Topic, MessageType: envelope.Type, PayloadHex: strings.ToLower(envelope.PayloadHex), DecodedFrame: string(envelope.Frame), ReceivedAt: received}
|
||||
var frame struct {
|
||||
Control byte `json:"Control"`
|
||||
PacketNumber uint16 `json:"PacketNumber"`
|
||||
DeviceTime string `json:"DeviceTime"`
|
||||
}
|
||||
_ = json.Unmarshal(envelope.Frame, &frame)
|
||||
if frame.DeviceTime != "" {
|
||||
if occurred, parseErr := time.Parse(time.RFC3339, frame.DeviceTime); parseErr == nil {
|
||||
message.DeviceOccurredAt = &occurred
|
||||
}
|
||||
}
|
||||
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
if createErr := tx.Create(&message).Error; createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if envelope.DeviceID == "" || frame.PacketNumber == 0 || frame.Control&0x08 == 0 {
|
||||
return nil
|
||||
}
|
||||
status, errorCode := "succeeded", ""
|
||||
if frame.Control&0x04 != 0 {
|
||||
status, errorCode = "failed", "DEVICE_REPORTED_FAILURE"
|
||||
}
|
||||
return tx.Model(&models.IotCommand{}).Where("device_id = ? AND packet_number = ? AND command_status IN ?", envelope.DeviceID, frame.PacketNumber, []string{"dispatched", "pending_confirmation"}).Updates(map[string]any{"command_status": status, "error_code": errorCode, "acknowledged_at": received, "updated_at": received}).Error
|
||||
})
|
||||
if err != nil {
|
||||
ctx.JSON(500, gin.H{"code": "IOT_MESSAGE_SAVE_FAILED"})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusAccepted, gin.H{"identity": message.Identity})
|
||||
}
|
||||
64
backend/api/internal/models/iot_command.go
Normal file
64
backend/api/internal/models/iot_command.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/database"
|
||||
)
|
||||
|
||||
// IotCommand 是设备下行命令事实;受理、投递与设备执行状态必须分离。
|
||||
type IotCommand struct {
|
||||
Entity `gorm:"embedded;comment:设备命令公共实体字段"` // 命令公共实体字段
|
||||
DeviceIdentity string `gorm:"column:device_identity;type:varchar(36);not null;index" json:"device_identity"` // 平台设备 identity
|
||||
DeviceID string `gorm:"column:device_id;type:varchar(16);not null;index" json:"device_id"` // 厂商 8-byte BCD 标识
|
||||
IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(128);not null;uniqueIndex;comment:命令幂等键" json:"idempotency_key"` // 命令幂等键
|
||||
Action string `gorm:"column:action;type:varchar(32);not null;comment:设备控制动作" json:"action"` // 设备控制动作
|
||||
RequestPayload string `gorm:"column:request_payload;type:text;not null;comment:原始命令请求快照" json:"request_payload"` // 原始请求快照
|
||||
CommandStatus string `gorm:"column:command_status;type:varchar(32);not null;index;comment:命令受理投递执行状态" json:"command_status"` // 命令处理状态
|
||||
PacketNumber uint16 `gorm:"column:packet_number;not null;default:0;comment:厂商协议包号" json:"packet_number"` // 厂商协议包号
|
||||
ErrorCode string `gorm:"column:error_code;type:varchar(64);not null;default:'';comment:稳定错误码" json:"error_code"` // 稳定错误码
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;type:timestamptz;not null;index;comment:命令失效时间" json:"expires_at"` // 命令失效时间
|
||||
DispatchedAt *time.Time `gorm:"column:dispatched_at;type:timestamptz;comment:下发到消息代理时间" json:"dispatched_at"` // 下发时间
|
||||
AcknowledgedAt *time.Time `gorm:"column:acknowledged_at;type:timestamptz;comment:设备回执时间" json:"acknowledged_at"` // 设备回执时间
|
||||
}
|
||||
|
||||
func (*IotCommand) TableName() string { return "iot_command" }
|
||||
|
||||
// IotOutbox 保证命令事实与待投递事件在同一数据库事务提交。
|
||||
type IotOutbox struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement;comment:数据库自增主键" json:"-"` // 数据库主键
|
||||
Identity string `gorm:"column:identity;type:varchar(36);not null;uniqueIndex;comment:Outbox业务标识" json:"identity"` // Outbox业务标识
|
||||
CommandIdentity string `gorm:"column:command_identity;type:varchar(36);not null;uniqueIndex;comment:设备命令业务标识" json:"command_identity"` // 命令业务标识
|
||||
EventType string `gorm:"column:event_type;type:varchar(64);not null;comment:事件类型" json:"event_type"` // 事件类型
|
||||
Payload string `gorm:"column:payload;type:text;not null;comment:待投递事件载荷" json:"payload"` // 事件载荷
|
||||
OutboxStatus string `gorm:"column:outbox_status;type:varchar(24);not null;index;comment:Outbox投递状态" json:"outbox_status"` // 投递状态
|
||||
Attempts int `gorm:"column:attempts;not null;default:0;comment:投递尝试次数" json:"attempts"` // 尝试次数
|
||||
AvailableAt time.Time `gorm:"column:available_at;type:timestamptz;not null;index;comment:下次可投递时间" json:"available_at"` // 可投递时间
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;index;comment:创建时间" json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;comment:更新时间" json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
func (*IotOutbox) TableName() string { return "iot_outbox" }
|
||||
|
||||
// IotDeviceMessage 保存原始上行/回执和服务端接收时间,原始事实不可由前端修改。
|
||||
type IotDeviceMessage struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement;comment:数据库自增主键" json:"-"` // 数据库主键
|
||||
Identity string `gorm:"column:identity;type:varchar(36);not null;uniqueIndex;comment:上行消息业务标识" json:"identity"` // 上行消息标识
|
||||
DeviceID string `gorm:"column:device_id;type:varchar(16);not null;index;comment:厂商设备标识" json:"device_id"` // 厂商设备标识
|
||||
Topic string `gorm:"column:topic;type:varchar(255);not null;comment:接收消息主题" json:"topic"` // MQTT主题
|
||||
MessageType string `gorm:"column:message_type;type:varchar(32);not null;index;comment:上行或回执消息类型" json:"message_type"` // 消息类型
|
||||
PayloadHex string `gorm:"column:payload_hex;type:text;not null;comment:原始报文十六进制" json:"payload_hex"` // 原始报文
|
||||
DecodedFrame string `gorm:"column:decoded_frame;type:text;not null;comment:协议解析结果快照" json:"decoded_frame"` // 解析快照
|
||||
DeviceOccurredAt *time.Time `gorm:"column:device_occurred_at;type:timestamptz;comment:设备原始采集时间" json:"device_occurred_at"` // 设备采集时间
|
||||
ReceivedAt time.Time `gorm:"column:received_at;type:timestamptz;not null;index;comment:服务端接收时间" json:"received_at"` // 服务端接收时间
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;index;comment:创建时间" json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;comment:更新时间" json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
func (*IotDeviceMessage) TableName() string { return "iot_device_message" }
|
||||
|
||||
func init() {
|
||||
database.AppendMigrate(&IotCommand{})
|
||||
database.AppendMigrate(&IotOutbox{})
|
||||
database.AppendMigrate(&IotDeviceMessage{})
|
||||
}
|
||||
17
backend/api/internal/routers/iot.go
Normal file
17
backend/api/internal/routers/iot.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
iotlogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/iot"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterIoT(serviceKey string, engine *gin.Engine) {
|
||||
group := engine.Group(fmt.Sprintf("/%s/internal/v1/iot", serviceKey))
|
||||
group.Use(iotlogic.RequireInternalToken())
|
||||
group.POST("/commands", iotlogic.CreateCommand)
|
||||
group.GET("/commands/:identity", iotlogic.GetCommand)
|
||||
group.GET("/outbox/next", iotlogic.ClaimOutbox)
|
||||
group.POST("/outbox/:identity/result", iotlogic.CompleteOutbox)
|
||||
group.POST("/device-messages", iotlogic.SaveDeviceMessage)
|
||||
}
|
||||
@@ -10,5 +10,6 @@ func Register(serviceKey string, engine *gin.Engine) {
|
||||
RegisterDelivery(serviceKey, engine)
|
||||
RegisterClient(serviceKey, engine)
|
||||
RegisterPaymentReturn(serviceKey, engine)
|
||||
RegisterIoT(serviceKey, engine)
|
||||
registerUploadRoute(serviceKey, engine)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user