// Package service 提供管理系统/App 与 IoT 设备链路之间的稳定上下行 HTTP 边界。 package service import ( "bytes" "context" "crypto/subtle" "encoding/json" "fmt" "io" "net/http" "time" "git.apinb.com/heqiapp/platforms/backend/iot-gateway/internal/config" ) type Service struct { cfg config.Config client *http.Client 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"` } 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}} } 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("/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 } status, packet, err := s.dispatch(r.Context(), cmd) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"identity": cmd.Identity, "status": "dispatch_failed", "error_code": "IOT_DISPATCH_FAILED"}) return } writeJSON(w, http.StatusAccepted, map[string]any{"identity": cmd.Identity, "status": status, "packet_number": packet}) } 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) }