package gateway import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "net/http" "strconv" "strings" "time" 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 设备控制与自动化规则管理写操作(仅 owner,带审计)。 // 设备控制会在所属 provider 上执行真实控制;其余在没有 // 接线场景/规则存储时诚实作答。 // iotControlRequest 是 POST /api/v1/admin/iot/{id}/control 的请求体。 type iotControlRequest struct { Action string `json:"action"` Params map[string]any `json:"params"` } // adminIoTControl 经 IoT 管理器向设备发送控制指令,管理器 // 在所属 provider 上施加它并发布 device_update // 事件。只有部署了设备 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 诚实作答:IoT 子系统只管设备 // 列表/控制/状态(Provider 接口没有场景模型或 // 存储),因此没有可持久化场景的地方。它报告缺失的 // 存储,而不是编造一个。 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 场景未实现:IoT 子系统仅有设备列表/控制/状态(Provider 接口无场景模型与存储);场景 CRUD 需先新增模型 + 执行器", HTTPCode: http.StatusNotImplemented, }) } // adminIoTRule 诚实作答:不存在 IoT 自动化规则模型或存储 // (通用 automations 服务运行 cron/event 规则,而不是带设备动作的 // 设备状态条件),因此规则无法被持久化或执行。 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 自动化规则未实现:IoT 子系统无规则模型与存储(通用 automations 服务只跑 cron/事件规则,不含设备状态条件 + 设备动作执行器)", HTTPCode: http.StatusNotImplemented, }) } // mapIotError 把 iot 哨兵错误映射为 HTTP 错误。 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 是 POST /api/v1/admin/automations 的请求体。 type automationCreateRequest struct { Name string `json:"name"` Kind string `json:"kind"` // 取值:cron | event Expression string `json:"expression"` } // adminCreateAutomation 注册一条真实的 cron/event 规则,其动作会记录 // 触发。无效的 cron 表达式或畸形的规则会被诚实作答。 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 手动触发一次已注册规则的动作。 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 按 id 移除一条已注册规则(cron 或 event)。 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}) } // reminderCreateRequest 是 POST /api/v1/admin/automations/reminders 的请求体。 // At 是 RFC3339(带小数秒亦可);ID 省略时由后端生成,使面板无需 // 自己保证唯一。 type reminderCreateRequest struct { ID string `json:"id"` At string `json:"at"` Message string `json:"message"` } // adminCreateReminder 创建一条真实的定时提醒(design/10 §3 #12「提醒」)。 // 提醒进 ReminderStore 后由既有的轮询循环到点发布 reminder.trigger // 到进程内 bus(P0-22 之前该 Store 只有测试调用,生产没有创建入口, // 因此提醒功能实际不可达)。at 若已是过去时刻,按 Store 语义会在 // 下一次轮询立即触发 —— 这是真实行为,不是假成功。 func (s *Server) adminCreateReminder(w http.ResponseWriter, r *http.Request) { var req reminderCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } message := strings.TrimSpace(req.Message) rawAt := strings.TrimSpace(req.At) if message == "" || rawAt == "" { s.auditClaims(r, "automation.reminder.create", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "at 与 message 均为必填")) return } at, err := time.Parse(time.RFC3339, rawAt) if err != nil { s.auditClaims(r, "automation.reminder.create", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_at", "at 必须是 RFC3339 时间戳(如 2026-10-05T09:30:00+08:00)")) return } auto, ok := s.automations.(*automations.Service) if !ok { s.auditClaims(r, "automation.reminder.create", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置")) return } id := strings.TrimSpace(req.ID) if id == "" { id = newReminderID() } rem := automations.Reminder{ID: id, At: at, Message: message} if err := auto.AddReminder(r.Context(), rem); err != nil { s.auditClaims(r, "automation.reminder.create", "error") if errors.Is(err, automations.ErrDuplicate) { writeError(w, apperr.Wrap(apperr.ErrConflict, "already_exists", "提醒 %q 已存在", id)) return } if errors.Is(err, automations.ErrInvalidRule) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_rule", "提醒 id 或时间非法")) return } writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "提醒创建失败")) return } s.auditClaims(r, "automation.reminder.create", "ok") writeJSON(w, http.StatusCreated, map[string]any{ "id": rem.ID, "at": rem.At.Format(time.RFC3339), "message": rem.Message, "kind": "reminder", }) } // newReminderID 生成面板省略 id 时使用的提醒 id。它用 crypto/rand 的 // 128 位十六进制随机值(与 skills.Store 的 newID 同范式),而不是 // UnixNano:Windows 的 time.Now 粒度可粗到毫秒,同一刻度内连创两条 // 会撞 id,被 ReminderStore 判为 ErrDuplicate(假失败)。 func newReminderID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { // crypto/rand 实际不会失败;兜底也在前缀上与随机形式同形。 return "rem-" + strconv.FormatInt(time.Now().UnixNano(), 10) } return "rem-" + hex.EncodeToString(b[:]) }