feat: replace iot adapter with mqtt command pipeline

This commit is contained in:
2026-08-03 13:18:11 +08:00
parent 6ed417c8c1
commit fefdba470a
48 changed files with 1620 additions and 338 deletions

View File

@@ -0,0 +1,5 @@
# IoT Client
管理系统与 App 的上下行设备接口边界。外部业务请求应先经过 Platform API 的 JWT、角色、对象归属和安全状态校验API/Worker 使用内部令牌调用本服务。命令要求 `identity``idempotency_key`、过期时间,并返回可查询状态,不能把 HTTP 受理视为设备执行成功。
设备上行报文由 IoT Server 回调本服务,再转交 Platform API 持久化和审计。

View File

@@ -0,0 +1,25 @@
package main
import (
"context"
"flag"
"git.apinb.com/heqiapp/platforms/backend/iot-client/internal/config"
"git.apinb.com/heqiapp/platforms/backend/iot-client/internal/service"
"log"
"os/signal"
"syscall"
)
func main() {
path := flag.String("config", "etc/platform_iot_client_dev.yaml", "YAML 配置文件")
flag.Parse()
cfg, err := config.Load(*path)
if err != nil {
log.Fatal(err)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err = service.New(cfg).Run(ctx); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,8 @@
Service: platform-iot-client
HTTP:
Address: 127.0.0.1:12429
InternalToken: change-me-iot-internal-token
Upstream:
IoTServerURL: http://127.0.0.1:12428
PlatformAPIURL: http://127.0.0.1:12426
Token: change-me-iot-internal-token

View File

@@ -0,0 +1,5 @@
module git.apinb.com/heqiapp/platforms/backend/iot-client
go 1.26.1
require gopkg.in/yaml.v3 v3.0.1

View File

@@ -0,0 +1,4 @@
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,44 @@
package config
import (
"fmt"
"gopkg.in/yaml.v3"
"os"
)
type HTTP struct {
Address string `yaml:"Address"`
InternalToken string `yaml:"InternalToken"`
}
type Upstream struct {
IoTServerURL string `yaml:"IoTServerURL"`
PlatformAPIURL string `yaml:"PlatformAPIURL"`
Token string `yaml:"Token"`
}
type Config struct {
Service string `yaml:"Service"`
HTTP HTTP `yaml:"HTTP"`
Upstream Upstream `yaml:"Upstream"`
}
func Load(path string) (Config, error) {
var cfg Config
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err = yaml.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
override(&cfg.HTTP.InternalToken, "HEQI_IOT_INTERNAL_TOKEN")
override(&cfg.Upstream.Token, "HEQI_IOT_INTERNAL_TOKEN")
if cfg.HTTP.Address == "" || cfg.HTTP.InternalToken == "" || cfg.Upstream.IoTServerURL == "" || cfg.Upstream.PlatformAPIURL == "" {
return cfg, fmt.Errorf("HTTP 和 Upstream 配置不完整")
}
return cfg, nil
}
func override(target *string, name string) {
if value := os.Getenv(name); value != "" {
*target = value
}
}

View File

@@ -0,0 +1,218 @@
// Package service 提供管理系统/App 与 IoT 设备链路之间的稳定上下行 HTTP 边界。
package service
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"git.apinb.com/heqiapp/platforms/backend/iot-client/internal/config"
)
type Service struct {
cfg config.Config
client *http.Client
mu sync.RWMutex
byIdentity map[string]Command
byIdempotency map[string]string
http *http.Server
}
type Command struct {
Identity string `json:"identity"`
IdempotencyKey string `json:"idempotency_key"`
DeviceID string `json:"device_id"`
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"`
Status string `json:"status"`
PacketNumber uint16 `json:"packet_number,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type deviceEnvelope struct {
Type, Topic, DeviceID, ReceivedAt, PayloadHex string
Frame json.RawMessage `json:"frame,omitempty"`
}
func New(cfg config.Config) *Service {
return &Service{cfg: cfg, client: &http.Client{Timeout: 12 * time.Second}, byIdentity: map[string]Command{}, byIdempotency: map[string]string{}}
}
func (s *Service) Run(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
mux.HandleFunc("/v1/device-commands", s.commands)
mux.HandleFunc("/v1/device-commands/", s.commandStatus)
mux.HandleFunc("/internal/v1/device-messages", s.deviceMessage)
s.http = &http.Server{Addr: s.cfg.HTTP.Address, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = s.http.Shutdown(shutdown)
}()
err := s.http.ListenAndServe()
if err == http.ErrServerClosed {
return nil
}
return err
}
func (s *Service) commands(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
if !s.authorized(r) {
http.Error(w, "unauthorized", 401)
return
}
var cmd Command
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&cmd); err != nil {
http.Error(w, "invalid json", 400)
return
}
if cmd.Identity == "" || cmd.IdempotencyKey == "" || cmd.DeviceID == "" || (cmd.Action != "open_valve" && cmd.Action != "close_valve") {
http.Error(w, "invalid command", 422)
return
}
if cmd.ExpiresAt.IsZero() {
cmd.ExpiresAt = time.Now().Add(30 * time.Second)
}
if time.Now().After(cmd.ExpiresAt) {
http.Error(w, "command expired", 422)
return
}
s.mu.Lock()
if existingID, ok := s.byIdempotency[cmd.IdempotencyKey]; ok {
existing := s.byIdentity[existingID]
s.mu.Unlock()
writeJSON(w, http.StatusOK, existing)
return
}
cmd.Status = "accepted"
cmd.CreatedAt = time.Now().UTC()
cmd.UpdatedAt = cmd.CreatedAt
s.byIdentity[cmd.Identity] = cmd
s.byIdempotency[cmd.IdempotencyKey] = cmd.Identity
s.mu.Unlock()
status, packet, err := s.dispatch(r.Context(), cmd)
s.mu.Lock()
current := s.byIdentity[cmd.Identity]
current.UpdatedAt = time.Now().UTC()
if err != nil {
current.Status = "dispatch_failed"
current.ErrorCode = "IOT_DISPATCH_FAILED"
} else {
current.Status = status
current.PacketNumber = packet
}
s.byIdentity[cmd.Identity] = current
s.mu.Unlock()
if err != nil {
writeJSON(w, http.StatusBadGateway, current)
return
}
writeJSON(w, http.StatusAccepted, current)
}
func (s *Service) commandStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", 405)
return
}
if !s.authorized(r) {
http.Error(w, "unauthorized", 401)
return
}
identity := strings.TrimPrefix(r.URL.Path, "/v1/device-commands/")
s.mu.RLock()
cmd, ok := s.byIdentity[identity]
s.mu.RUnlock()
if !ok {
http.Error(w, "not found", 404)
return
}
writeJSON(w, 200, cmd)
}
func (s *Service) deviceMessage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !s.authorized(r) {
http.Error(w, "unauthorized", 401)
return
}
data, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "invalid body", 400)
return
}
var envelope deviceEnvelope
if json.Unmarshal(data, &envelope) != nil || envelope.ReceivedAt == "" {
http.Error(w, "invalid envelope", 422)
return
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodPost, s.cfg.Upstream.PlatformAPIURL+"/heqi/internal/v1/iot/device-messages", bytes.NewReader(data))
if err != nil {
http.Error(w, "upstream request failed", 502)
return
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Heqi-Iot-Token", s.cfg.Upstream.Token)
response, err := s.client.Do(request)
if err != nil {
http.Error(w, "platform api unavailable", 502)
return
}
defer response.Body.Close()
if response.StatusCode >= 300 {
http.Error(w, "platform api rejected message", 502)
return
}
w.WriteHeader(http.StatusAccepted)
}
func (s *Service) dispatch(ctx context.Context, cmd Command) (string, uint16, error) {
data, _ := json.Marshal(cmd)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.Upstream.IoTServerURL+"/internal/v1/commands", bytes.NewReader(data))
if err != nil {
return "", 0, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Heqi-Iot-Token", s.cfg.Upstream.Token)
response, err := s.client.Do(request)
if err != nil {
return "", 0, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusAccepted {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return "", 0, fmt.Errorf("iot server %d: %s", response.StatusCode, body)
}
var result struct {
Status string `json:"status"`
PacketNumber uint16 `json:"packet_number"`
}
if err = json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", 0, err
}
return result.Status, result.PacketNumber, nil
}
func (s *Service) authorized(r *http.Request) bool {
value := r.Header.Get("X-Heqi-Iot-Token")
expected := s.cfg.HTTP.InternalToken
return len(value) == len(expected) && subtle.ConstantTimeCompare([]byte(value), []byte(expected)) == 1
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}

View File

@@ -0,0 +1,54 @@
package service
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.apinb.com/heqiapp/platforms/backend/iot-client/internal/config"
)
func TestDispatchPreservesIdempotency(t *testing.T) {
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"status":"dispatched","packet_number":9}`))
}))
defer upstream.Close()
srv := New(config.Config{HTTP: config.HTTP{InternalToken: "token"}, Upstream: config.Upstream{IoTServerURL: upstream.URL, Token: "token"}})
cmd := Command{Identity: "one", IdempotencyKey: "same", DeviceID: "1234567890123456", Action: "close_valve", ExpiresAt: time.Now().Add(time.Minute)}
status, packet, err := srv.dispatch(context.Background(), cmd)
if err != nil || status != "dispatched" || packet != 9 || calls != 1 {
t.Fatalf("status=%s packet=%d calls=%d err=%v", status, packet, calls, err)
}
}
func TestCommandsDoesNotDispatchDuplicateIdempotencyKey(t *testing.T) {
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"status":"dispatched","packet_number":9}`))
}))
defer upstream.Close()
srv := New(config.Config{HTTP: config.HTTP{InternalToken: "token"}, Upstream: config.Upstream{IoTServerURL: upstream.URL, Token: "token"}})
body := []byte(`{"identity":"one","idempotency_key":"same","device_id":"1234567890123456","action":"close_valve","expires_at":"2099-01-01T00:00:00Z"}`)
for range 2 {
request := httptest.NewRequest(http.MethodPost, "/v1/device-commands", bytes.NewReader(body))
request.Header.Set("X-Heqi-Iot-Token", "token")
response := httptest.NewRecorder()
srv.commands(response, request)
if response.Code != http.StatusAccepted && response.Code != http.StatusOK {
t.Fatalf("unexpected status %d: %s", response.Code, response.Body.String())
}
}
if calls != 1 {
t.Fatalf("duplicate command dispatched %d times", calls)
}
}