package api import ( "aquacontrolai/internal/model" "aquacontrolai/internal/pkg/response" postgres "aquacontrolai/internal/repository/postgres" "aquacontrolai/internal/service/platform" "context" "encoding/csv" "encoding/json" "fmt" "github.com/gin-gonic/gin" "github.com/google/uuid" "log/slog" "net/http" "strconv" "strings" "time" ) type Handler struct { Platform *platform.Service History *platform.History } func NewRouter(h *Handler) *gin.Engine { r := gin.New() r.Use(gin.Recovery(), requestLog()) v := r.Group("/api/v1") v.GET("/health", h.health) v.GET("/devices/protocols", h.protocols) v.GET("/devices", h.devices) v.POST("/devices/export", h.exportConfig("device")) v.POST("/devices/import", h.importConfig("device")) v.POST("/devices", h.createDevice) v.GET("/devices/:id", h.deviceDetail) v.PUT("/devices/:id", h.updateDevice) v.DELETE("/devices/:id", h.deleteDevice) v.GET("/collection-points", h.points("collection")) v.GET("/collection-points/groups", h.groups) v.POST("/collection-points/groups", h.createGroup) v.PUT("/collection-points/groups", h.updateGroup) v.DELETE("/collection-points/groups/:name", h.deleteGroup) v.POST("/collection-points/export", h.exportConfig("collection")) v.POST("/collection-points/import", h.importConfig("collection")) v.GET("/collection-points/:id", h.pointDetail("collection")) v.POST("/collection-points", h.savePoint("collection", false)) v.PUT("/collection-points/:id", h.savePoint("collection", true)) v.DELETE("/collection-points/:id", h.deletePoint("collection")) v.GET("/write-points", h.points("write")) v.POST("/write-points/export", h.exportConfig("write")) v.POST("/write-points/import", h.importConfig("write")) v.GET("/write-points/:id", h.pointDetail("write")) v.POST("/write-points", h.savePoint("write", false)) v.PUT("/write-points/:id", h.savePoint("write", true)) v.DELETE("/write-points/:id", h.deletePoint("write")) v.POST("/write-points/:id/write", h.writePoint) v.GET("/write-logs", h.logs) v.GET("/history/tree", h.tree) v.POST("/history/archive/cleanup", h.cleanupHistoryArchive) v.POST("/history/query", h.historyQuery) v.POST("/history/query-table", h.historyTable) v.POST("/history/export", h.exportHistory) v.GET("/system/history-retention", h.getRetention) v.PUT("/system/history-retention", h.setRetention) return r } func requestLog() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() slog.Info("HTTP 请求", "method", c.Request.Method, "path", c.Request.URL.Path, "status", c.Writer.Status(), "duration_ms", time.Since(start).Milliseconds()) } } func (h *Handler) health(c *gin.Context) { response.OK(c, map[string]string{"status": "ok"}) } func (h *Handler) protocols(c *gin.Context) { response.OK(c, map[string]any{"items": h.Platform.Registry.Metadata()}) } func (h *Handler) devices(c *gin.Context) { items, e := h.Platform.ListDevices(c, c.Query("keyword")) if e != nil { serverError(c, e) return } filtered := make([]model.Device, 0, len(items)) for _, d := range items { if v := c.Query("protocol_type"); v != "" && d.ProtocolType != v { continue } if v := c.Query("enabled"); v != "" && strconv.FormatBool(d.Enabled) != v { continue } filtered = append(filtered, d) } pageNo, pageSize := pagination(c) start, end := bounds(len(filtered), pageNo, pageSize) response.OK(c, map[string]any{"total": len(filtered), "page": pageNo, "page_size": pageSize, "items": filtered[start:end]}) } func (h *Handler) deviceDetail(c *gin.Context) { id, e := uuid.Parse(c.Param("id")) if e != nil { response.Error(c, 400, 40001, "无效ID", nil) return } d, e := h.Platform.GetDevice(c, id) if e != nil { response.Error(c, 404, 40004, "设备不存在", nil) return } response.OK(c, d) } func bindDevice(c *gin.Context) (model.Device, error) { var req struct { Name string `json:"name"` ProtocolType string `json:"protocol_type"` Enabled *bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` ConnectTimeout int `json:"connect_timeout"` ReconnectInterval int `json:"reconnect_interval"` ProtocolConfig json.RawMessage `json:"protocol_config"` } if e := c.ShouldBindJSON(&req); e != nil { return model.Device{}, e } enabled := true if req.Enabled != nil { enabled = *req.Enabled } return model.Device{Name: req.Name, ProtocolType: req.ProtocolType, Enabled: enabled, Host: req.Host, Port: req.Port, ConnectTimeout: req.ConnectTimeout, ReconnectInterval: req.ReconnectInterval, ProtocolConfig: req.ProtocolConfig}, nil } func (h *Handler) createDevice(c *gin.Context) { d, e := bindDevice(c) if e == nil { e = h.Platform.SaveDevice(c, nil, &d) } if e != nil { response.Error(c, http.StatusBadRequest, 40001, e.Error(), nil) return } response.Created(c, d) } func (h *Handler) updateDevice(c *gin.Context) { id, e := uuid.Parse(c.Param("id")) d, e2 := bindDevice(c) if e != nil || e2 != nil { response.Error(c, 400, 40001, "无效请求", nil) return } if e = h.Platform.SaveDevice(c, &id, &d); e != nil { response.Error(c, 400, 40001, e.Error(), nil) return } response.OK(c, d) } func (h *Handler) deleteDevice(c *gin.Context) { id, e := uuid.Parse(c.Param("id")) if e != nil { response.Error(c, 400, 40001, "无效ID", nil) return } if e = h.Platform.DeleteDevice(c, id); e != nil { response.Error(c, 404, 40004, "设备不存在", nil) return } response.OK(c, nil) } func (h *Handler) points(kind string) gin.HandlerFunc { return func(c *gin.Context) { items, e := h.Platform.ListPoints(c, kind, c.Query("keyword")) if e != nil { serverError(c, e) return } filtered := make([]postgres.PointRow, 0, len(items)) for _, p := range items { if v := c.Query("device_id"); v != "" && p.DeviceID.String() != v { continue } if v := c.Query("group_name"); v != "" && p.GroupName != v { continue } if v := c.Query("data_type"); v != "" && p.DataType != v { continue } if kind == "collection" { if v := c.Query("enabled"); v != "" && strconv.FormatBool(p.Enabled) != v { continue } } if kind == "write" { if v := c.Query("write_enabled"); v != "" && strconv.FormatBool(p.WriteEnabled) != v { continue } } filtered = append(filtered, p) } pageNo, pageSize := pagination(c) start, end := bounds(len(filtered), pageNo, pageSize) response.OK(c, map[string]any{"total": len(filtered), "page": pageNo, "page_size": pageSize, "items": filtered[start:end]}) } } func pagination(c *gin.Context) (int, int) { pageNo := parseInt(c.DefaultQuery("page", "1"), 1) pageSize := parseInt(c.DefaultQuery("page_size", "20"), 20) if pageNo < 1 { pageNo = 1 } if pageSize < 1 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } return pageNo, pageSize } func bounds(total, pageNo, pageSize int) (int, int) { start := (pageNo - 1) * pageSize if start > total { start = total } end := start + pageSize if end > total { end = total } return start, end } func (h *Handler) pointDetail(kind string) gin.HandlerFunc { return func(c *gin.Context) { id, e := uuid.Parse(c.Param("id")) if e != nil { response.Error(c, 400, 41001, "无效ID", nil) return } p, e := h.Platform.GetPoint(c, kind, id) if e != nil { response.Error(c, 404, 41004, "点位不存在", nil) return } response.OK(c, p) } } func (h *Handler) groups(c *gin.Context) { groups, e := h.Platform.Groups(c) if e != nil { serverError(c, e) return } response.OK(c, map[string]any{"groups": groups}) } func (h *Handler) createGroup(c *gin.Context) { var req struct { Name string `json:"name"` } if e := c.ShouldBindJSON(&req); e != nil { response.Error(c, 400, 41001, "参数格式错误", nil) return } if e := h.Platform.CreateGroup(c, req.Name); e != nil { response.Error(c, 400, 41001, e.Error(), nil) return } response.Created(c, map[string]string{"name": req.Name}) } func (h *Handler) updateGroup(c *gin.Context) { var req struct { OldName string `json:"old_name"` Name string `json:"name"` } if e := c.ShouldBindJSON(&req); e != nil { response.Error(c, 400, 41001, "参数格式错误", nil) return } if e := h.Platform.UpdateGroup(c, req.OldName, req.Name); e != nil { response.Error(c, 400, 41001, e.Error(), nil) return } response.OK(c, map[string]string{"name": req.Name}) } func (h *Handler) deleteGroup(c *gin.Context) { name := c.Param("name") if e := h.Platform.DeleteGroup(c, name); e != nil { response.Error(c, 409, 41003, e.Error(), nil) return } response.OK(c, nil) } func (h *Handler) getRetention(c *gin.Context) { days, e := h.Platform.GetRetention(c) if e != nil { serverError(c, e) return } response.OK(c, map[string]int{"history_retention_days": days}) } func (h *Handler) setRetention(c *gin.Context) { var req struct { Days int `json:"history_retention_days"` } if e := c.ShouldBindJSON(&req); e != nil { response.Error(c, 400, 43001, "参数格式错误", nil) return } if e := h.Platform.SetRetention(c, req.Days); e != nil { response.Error(c, 502, 43005, e.Error(), nil) return } response.OK(c, map[string]int{"history_retention_days": req.Days}) } func (h *Handler) exportConfig(kind string) gin.HandlerFunc { return func(c *gin.Context) { c.Header("Content-Type", "text/csv; charset=utf-8-sig") c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.csv"`, map[string]string{"device": "devices", "collection": "collection_points", "write": "write_points"}[kind])) c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) w := csv.NewWriter(c.Writer) defer w.Flush() if kind == "device" { items, e := h.Platform.ListDevices(c, "") if e != nil { return } w.Write([]string{"name", "protocol_type", "host", "port", "connect_timeout", "reconnect_interval", "protocol_config", "enabled"}) for _, d := range items { w.Write([]string{d.Name, d.ProtocolType, d.Host, strconv.Itoa(d.Port), strconv.Itoa(d.ConnectTimeout), strconv.Itoa(d.ReconnectInterval), string(d.ProtocolConfig), strings.ToUpper(strconv.FormatBool(d.Enabled))}) } return } items, e := h.Platform.ListPoints(c, kind, "") if e != nil { return } if kind == "collection" { w.Write([]string{"name", "group_name", "device_name", "address", "data_type", "unit", "collect_interval", "store_history", "history_interval", "enabled"}) for _, p := range items { w.Write([]string{p.Name, p.GroupName, p.DeviceName, p.Address, p.DataType, stringValue(p.Unit), strconv.Itoa(p.CollectInterval), strings.ToUpper(strconv.FormatBool(p.StoreHistory)), strconv.Itoa(p.HistoryInterval), strings.ToUpper(strconv.FormatBool(p.Enabled))}) } } else { w.Write([]string{"name", "group_name", "device_name", "address", "data_type", "unit", "write_enabled"}) for _, p := range items { w.Write([]string{p.Name, p.GroupName, p.DeviceName, p.Address, p.DataType, stringValue(p.Unit), strings.ToUpper(strconv.FormatBool(p.WriteEnabled))}) } } } } func stringValue(v *string) string { if v == nil { return "" } return *v } func (h *Handler) importConfig(kind string) gin.HandlerFunc { return func(c *gin.Context) { file, e := c.FormFile("file") if e != nil { response.Error(c, 400, 10001, "缺少file字段", nil) return } f, e := file.Open() if e != nil { serverError(c, e) return } defer f.Close() rows, e := csv.NewReader(f).ReadAll() if e != nil || len(rows) < 1 { response.Error(c, 400, 10001, "CSV格式错误", nil) return } headers := map[string]int{} for i, v := range rows[0] { headers[strings.TrimPrefix(v, "\ufeff")] = i } result := map[string]any{"total": len(rows) - 1, "created": 0, "updated": 0, "failed": 0, "errors": []map[string]any{}} errs := []map[string]any{} for i, row := range rows[1:] { created, err := h.importRow(c, kind, headers, row) if err != nil { result["failed"] = result["failed"].(int) + 1 errs = append(errs, map[string]any{"row": i + 2, "field": "row", "message": err.Error()}) } else if created { result["created"] = result["created"].(int) + 1 } else { result["updated"] = result["updated"].(int) + 1 } } result["errors"] = errs response.OK(c, result) } } func (h *Handler) importRow(ctx context.Context, kind string, head map[string]int, row []string) (bool, error) { get := func(k string) string { i, ok := head[k] if !ok || i >= len(row) { return "" } return strings.TrimSpace(row[i]) } if kind == "device" { cfg := json.RawMessage(get("protocol_config")) enabled, e := parseCSVBool(get("enabled"), true) if e != nil { return false, e } d := model.Device{Name: get("name"), ProtocolType: get("protocol_type"), Host: get("host"), Port: parseInt(get("port"), 0), ConnectTimeout: parseInt(get("connect_timeout"), 5), ReconnectInterval: parseInt(get("reconnect_interval"), 10), ProtocolConfig: cfg, Enabled: enabled} existing, e := h.Platform.FindDeviceByName(ctx, d.Name) created := platform.IsNotFound(e) var id *uuid.UUID if !created { id = &existing.ID } return created, h.Platform.SaveDevice(ctx, id, &d) } device, e := h.Platform.FindDeviceByName(ctx, get("device_name")) if e != nil { return false, fmt.Errorf("device_name不存在") } existing, e := h.Platform.FindPointByName(ctx, kind, get("name")) created := platform.IsNotFound(e) enabled := true if kind == "collection" { var be error enabled, be = parseCSVBool(get("enabled"), true) if be != nil { return false, be } } unit := get("unit") p := postgres.PointRow{ID: uuid.Nil, Name: get("name"), GroupName: get("group_name"), DeviceID: device.ID, Enabled: enabled, Address: get("address"), DataType: get("data_type"), Unit: &unit} if !created { p.ID = existing.ID } if kind == "collection" { p.CollectInterval = parseInt(get("collect_interval"), 1) p.StoreHistory, _ = parseCSVBool(get("store_history"), true) p.HistoryInterval = parseInt(get("history_interval"), 1) } else { p.WriteEnabled, _ = parseCSVBool(get("write_enabled"), false) } return created, h.Platform.SavePoint(ctx, kind, &p) } func parseInt(v string, d int) int { if v == "" { return d } n, e := strconv.Atoi(v) if e != nil { return d } return n } func parseCSVBool(v string, d bool) (bool, error) { if v == "" { return d, nil } return strconv.ParseBool(strings.ToLower(v)) } func (h *Handler) savePoint(kind string, update bool) gin.HandlerFunc { return func(c *gin.Context) { var p struct { ID uuid.UUID `json:"-"` Name string `json:"name"` GroupName string `json:"group_name"` DeviceID uuid.UUID `json:"device_id"` Enabled *bool `json:"enabled"` WriteEnabled bool `json:"write_enabled"` Address string `json:"address"` DataType string `json:"data_type"` Unit *string `json:"unit"` CollectInterval int `json:"collect_interval"` StoreHistory *bool `json:"store_history"` HistoryInterval int `json:"history_interval"` } if e := c.ShouldBindJSON(&p); e != nil { response.Error(c, 400, 41001, "参数格式错误", nil) return } id := uuid.Nil if update { var e error id, e = uuid.Parse(c.Param("id")) if e != nil { response.Error(c, 400, 41001, "无效ID", nil) return } } enabled := true if p.Enabled != nil { enabled = *p.Enabled } storeHistory := true if p.StoreHistory != nil { storeHistory = *p.StoreHistory } if p.CollectInterval == 0 { p.CollectInterval = 1 } if p.HistoryInterval == 0 { p.HistoryInterval = 1 } if kind == "write" { enabled = false } row := postgres.PointRow{ID: id, Name: p.Name, GroupName: p.GroupName, DeviceID: p.DeviceID, Enabled: enabled, WriteEnabled: p.WriteEnabled, Address: p.Address, DataType: p.DataType, Unit: p.Unit, CollectInterval: p.CollectInterval, StoreHistory: storeHistory, HistoryInterval: p.HistoryInterval, PointKind: kind} if e := h.Platform.SavePoint(c, kind, &row); e != nil { code := 41001 if kind == "write" { code = 42001 } response.Error(c, 400, code, e.Error(), nil) return } if update { response.OK(c, row) } else { response.Created(c, row) } } } func (h *Handler) deletePoint(kind string) gin.HandlerFunc { return func(c *gin.Context) { id, e := uuid.Parse(c.Param("id")) if e != nil { response.Error(c, 400, 41001, "无效ID", nil) return } if e = h.Platform.DeletePoint(c, kind, id); e != nil { response.Error(c, 404, 41004, "点位不存在", nil) return } response.OK(c, nil) } } func (h *Handler) writePoint(c *gin.Context) { id, e := uuid.Parse(c.Param("id")) if e != nil { response.Error(c, 400, 42001, "无效ID", nil) return } var req struct { Value any `json:"value"` Reason *string `json:"reason"` Source any `json:"source"` Operator any `json:"operator"` } if e = c.ShouldBindJSON(&req); e != nil || req.Source != nil || req.Operator != nil { response.Error(c, 400, 42001, "参数格式错误,source/operator不允许由请求指定", nil) return } data, code, e := h.Platform.ExecuteWrite(c, id, req.Value, req.Reason) if e != nil { status := http.StatusBadRequest if code >= 51000 { status = http.StatusBadGateway } response.Error(c, status, code, e.Error(), data) return } response.OK(c, data) } func (h *Handler) logs(c *gin.Context) { items, e := h.Platform.ListLogs(c) if e != nil { serverError(c, e) return } filtered := make([]map[string]any, 0, len(items)) for _, item := range items { if v := c.Query("point_id"); v != "" && fmt.Sprint(item["point_id"]) != v { continue } if v := c.Query("device_id"); v != "" && fmt.Sprint(item["device_id"]) != v { continue } if v := c.Query("result"); v != "" && fmt.Sprint(item["result"]) != v { continue } if v := strings.ToLower(c.Query("keyword")); v != "" && !strings.Contains(strings.ToLower(fmt.Sprint(item["point_name"])), v) { continue } filtered = append(filtered, item) } pageNo, pageSize := pagination(c) start, end := bounds(len(filtered), pageNo, pageSize) response.OK(c, map[string]any{"total": len(filtered), "page": pageNo, "page_size": pageSize, "items": filtered[start:end]}) } func page(items any) map[string]any { return map[string]any{"total": length(items), "page": 1, "page_size": 100, "items": items} } func length(v any) int { switch x := v.(type) { case []model.Device: return len(x) case []map[string]any: return len(x) default: b, _ := json.Marshal(v) var a []any json.Unmarshal(b, &a) return len(a) } } func (h *Handler) tree(c *gin.Context) { tree, e := h.History.Tree(c) if e != nil { serverError(c, e) return } response.OK(c, map[string]any{"tree": tree}) } func (h *Handler) cleanupHistoryArchive(c *gin.Context) { count, e := h.History.CleanupDeletedArchives(c) if e != nil { serverError(c, e) return } response.OK(c, map[string]int{"deleted_points": count}) } type historyRequest struct { PointIDs []uuid.UUID `json:"point_ids"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` MaxSamples int `json:"max_samples"` IntervalMinutes int `json:"interval_minutes"` } func bindHistory(c *gin.Context) (historyRequest, bool) { var r historyRequest if e := c.ShouldBindJSON(&r); e != nil { response.Error(c, 400, 43001, "参数格式错误", nil) return r, false } if len(r.PointIDs) < 1 || len(r.PointIDs) > 20 { response.Error(c, 400, 43002, "点位数量必须为1~20", nil) return r, false } if !r.EndTime.After(r.StartTime) || r.EndTime.Sub(r.StartTime) > 31*24*time.Hour { response.Error(c, 400, 43003, "时间范围无效或超过31天", nil) return r, false } return r, true } func (h *Handler) historyQuery(c *gin.Context) { r, ok := bindHistory(c) if !ok { return } if r.MaxSamples == 0 { r.MaxSamples = 2000 } if r.MaxSamples < 100 || r.MaxSamples > 10000 { response.Error(c, 400, 43001, "max_samples必须为100~10000", nil) return } data, e := h.History.Query(c, r.PointIDs, r.StartTime, r.EndTime, r.MaxSamples) if e != nil { historyError(c, e) return } response.OK(c, map[string]any{"series": data}) } func (h *Handler) historyTable(c *gin.Context) { r, ok := bindHistory(c) if !ok { return } if r.IntervalMinutes < 1 || r.IntervalMinutes > 1440 { response.Error(c, 400, 43001, "interval_minutes必须为1~1440", nil) return } data, e := h.History.QueryTable(c, r.PointIDs, r.StartTime, r.EndTime, r.IntervalMinutes) if e != nil { historyError(c, e) return } response.OK(c, data) } func (h *Handler) exportHistory(c *gin.Context) { r, ok := bindHistory(c) if !ok { return } if r.IntervalMinutes < 1 || r.IntervalMinutes > 1440 { response.Error(c, 400, 43001, "interval_minutes必须为1~1440", nil) return } rows := int(r.EndTime.Sub(r.StartTime)/(time.Duration(r.IntervalMinutes)*time.Minute)) + 1 if rows > 50000 { response.Error(c, 413, 43006, "导出行数超过50000", nil) return } data, e := h.History.QueryTable(c, r.PointIDs, r.StartTime, r.EndTime, r.IntervalMinutes) if e != nil { historyError(c, e) return } name := fmt.Sprintf("history_%s_%s_%dm.csv", r.StartTime.Format("20060102T150405"), r.EndTime.Format("20060102T150405"), r.IntervalMinutes) c.Header("Content-Type", "text/csv; charset=utf-8-sig") c.Header("Content-Disposition", `attachment; filename="`+name+`"`) c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) w := csv.NewWriter(c.Writer) head := []string{"时间"} used := map[string]bool{} for _, col := range data.Columns { n := col.PointName if col.Unit != nil { n += "[" + *col.Unit + "]" } if used[n] { n += "_" + col.PointID.String()[:8] } used[n] = true head = append(head, n) } w.Write(head) for i, t := range data.TimeColumn { row := []string{t.Format("2006-01-02 15:04:05")} for _, col := range data.Columns { x := col.Data[i] if x.Value != nil { row = append(row, strconv.FormatFloat(*x.Value, 'f', -1, 64)) } else { row = append(row, "") } } w.Write(row) } w.Flush() } func serverError(c *gin.Context, e error) { slog.Error("服务执行失败", "error", e) response.Error(c, 500, 10001, "服务内部错误", nil) } func historyError(c *gin.Context, e error) { slog.Error("历史查询失败", "error", e) code, status := 43005, 502 if strings.Contains(e.Error(), "元数据不存在") { code, status = 43004, 404 } response.Error(c, status, code, map[int]string{43004: "点位元数据不存在", 43005: "TDengine查询失败"}[code], nil) } var _ context.Context