package gateway import ( "encoding/json" "errors" "net/http" "strings" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" ) // IoT device-control and automation-rule admin writes (owner-only, audited). // Device control performs a real control on the owning provider; the others // answer honestly when no scenario/rule store is wired. // iotControlRequest is the POST /api/v1/admin/iot/{id}/control body. type iotControlRequest struct { Action string `json:"action"` Params map[string]any `json:"params"` } // adminIoTControl sends a control instruction to a device through the IoT // manager, which applies it on the owning provider and publishes a device_update // event. A real action only when the deployment has a device provider. func (s *Server) adminIoTControl(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req iotControlRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.Action) == "" { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "action is required")) return } mgr, ok := s.iot.(*iot.Manager) if !ok { s.auditClaims(r, "iot.control", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "iot_unavailable", "IoT 服务未配置")) return } dev, err := mgr.Control(r.Context(), id, req.Action, req.Params) if err != nil { s.auditClaims(r, "iot.control", "error") writeError(w, mapIotError(err)) return } if dev == nil { s.auditClaims(r, "iot.control", "error") writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "device %q not found", id)) return } s.auditClaims(r, "iot.control", "ok") writeJSON(w, http.StatusOK, iotDeviceSummary{ID: dev.ID, Name: dev.Name, Type: dev.Type, State: dev.State, Online: dev.Online}) } // adminIoTScenario answers honestly: the IoT subsystem has no scenario store yet // (it owns device list/control/status only), so scenario write lands in M10. func (s *Server) adminIoTScenario(w http.ResponseWriter, r *http.Request) { s.auditClaims(r, "iot.scenario", "error") writeError(w, &apperr.AppError{Code: "not_implemented", Message: "IoT 场景存储为 M10 提供(当前 IoT 仅有设备列表/控制/状态)", HTTPCode: http.StatusNotImplemented}) } // adminIoTRule answers honestly: no IoT automation-rule store exists yet. func (s *Server) adminIoTRule(w http.ResponseWriter, r *http.Request) { s.auditClaims(r, "iot.rule", "error") writeError(w, &apperr.AppError{Code: "not_implemented", Message: "IoT 自动化规则存储为 M10 提供(当前 IoT 仅有设备列表/控制/状态)", HTTPCode: http.StatusNotImplemented}) } // mapIotError maps iot sentinel errors to HTTP errors. func mapIotError(err error) *apperr.AppError { switch { case errors.Is(err, iot.ErrDeviceNotFound): return apperr.Wrap(apperr.ErrNotFound, "not_found", "device not found") case errors.Is(err, iot.ErrUnsupported): return apperr.Wrap(apperr.ErrBadRequest, "unsupported_action", "action not supported") case errors.Is(err, iot.ErrOffline): return apperr.Wrap(apperr.ErrConflict, "device_offline", "device offline") case errors.Is(err, iot.ErrInvalidParam): return apperr.Wrap(apperr.ErrBadRequest, "invalid_param", "invalid parameter") default: return apperr.Wrap(apperr.ErrInternal, "iot_control_failed", "device control failed") } } // automationCreateRequest is the POST /api/v1/admin/automations body. type automationCreateRequest struct { Name string `json:"name"` Kind string `json:"kind"` // cron | event Expression string `json:"expression"` } // adminCreateAutomation registers a real cron/event rule whose action records its // fire. Invalid cron expressions or malformed rules are answered honestly. func (s *Server) adminCreateAutomation(w http.ResponseWriter, r *http.Request) { var req automationCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Kind) == "" || strings.TrimSpace(req.Expression) == "" { s.auditClaims(r, "automation.create", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name, kind and expression are required")) return } auto, ok := s.automations.(*automations.Service) if !ok { s.auditClaims(r, "automation.create", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置")) return } id, err := auto.AddRuleByName(r.Context(), req.Name, req.Kind, req.Expression) if err != nil { s.auditClaims(r, "automation.create", "error") if errors.Is(err, automations.ErrInvalidCron) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_cron", "invalid cron expression")) return } if errors.Is(err, automations.ErrInvalidRule) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_rule", "invalid rule")) return } writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to add automation")) return } s.auditClaims(r, "automation.create", "ok") writeJSON(w, http.StatusCreated, map[string]any{"id": id, "name": req.Name, "kind": req.Kind}) } // adminRunAutomation manually triggers a registered rule's action once. func (s *Server) adminRunAutomation(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") auto, ok := s.automations.(*automations.Service) if !ok { s.auditClaims(r, "automation.run", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置")) return } out, err := auto.RunRule(r.Context(), id) if err != nil { s.auditClaims(r, "automation.run", "error") if errors.Is(err, automations.ErrNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "automation %q not found", id)) return } writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to run automation")) return } s.auditClaims(r, "automation.run", "ok") writeJSON(w, http.StatusOK, map[string]any{"id": id, "output": out}) } // adminDeleteAutomation removes a registered rule (cron or event) by id. func (s *Server) adminDeleteAutomation(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") auto, ok := s.automations.(*automations.Service) if !ok { s.auditClaims(r, "automation.delete", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置")) return } if err := auto.RemoveRule(r.Context(), id); err != nil { s.auditClaims(r, "automation.delete", "error") if errors.Is(err, automations.ErrNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "automation %q not found", id)) return } writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to delete automation")) return } s.auditClaims(r, "automation.delete", "ok") writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true}) }