// 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) }