Files
Cyrene_v2/internal/services/gateway/admin_write_iot_automations.go
T
AskaEth d37d4a899b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): 余下 3 处行尾注释中文化(存量注释债结项)
- 事实订正:任务书说「gateway 全包中文注释 ≈ 0」是 2026-10-03 的旧状态;该债早已分批还清(074ffc4 / 355f81d / fe1ff33)。本次清点:70 个非测试文件 / 3493 行 // 注释,仅 6 行无中文。

- 翻 3 行(都是行尾枚举提示,代码前缀逐字节不变):admin_write_iot_automations.go:107 (cron|event)、history.go:20 (main|sub)、history.go:47 (user|assistant);另 3 行是中文多行句子的纯标识符/帧名续行,按规则不翻。

- 机械校验:除这 3 组行尾注释外零输出(即整包 diff 无任何非注释行、非空行改动);gofmt -l 干净(改动前也干净);go build/vet 全树绿;gateway 包测试绿。

- 主会话独立复核:机械校验输出恰为 3 对(代码部分逐字相同)、gofmt 干净、go test ./internal/services/gateway/... 绿。

- 文档订正:开发习惯与约定.md 的「gateway ≈ 0 中文注释」已改写成结项事实(测试文件里的存量注释仍未清点,另记)。
2026-10-04 18:57:58 +08:00

271 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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[:])
}