219 lines
6.8 KiB
Go
219 lines
6.8 KiB
Go
// 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)
|
|
}
|