This commit is contained in:
qsc
2026-07-12 19:18:26 +08:00
parent dd588b5daa
commit 34ec65c8b5
52 changed files with 10859 additions and 0 deletions
+748
View File
@@ -0,0 +1,748 @@
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/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 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", "enabled", "write_enabled", "readback_tolerance"})
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.Enabled)), strings.ToUpper(strconv.FormatBool(p.WriteEnabled)), strconv.FormatFloat(p.ReadbackTolerance, 'g', -1, 64)})
}
}
}
}
func stringValue(v *string) string {
if v == nil {
return ""
}
return *v
}
func floatPtr(v *float64) string {
if v == nil {
return ""
}
return strconv.FormatFloat(*v, 'g', -1, 64)
}
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, 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)
p.ReadbackTolerance = parseFloat(get("readback_tolerance"), .0001)
}
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 parseFloat(v string, d float64) float64 {
if v == "" {
return d
}
n, e := strconv.ParseFloat(v, 64)
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"`
ReadbackTolerance float64 `json:"readback_tolerance"`
}
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 p.ReadbackTolerance == 0 {
p.ReadbackTolerance = .0001
}
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, ReadbackTolerance: p.ReadbackTolerance}
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})
}
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, 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.Quality == "good" && x.Value != nil {
row = append(row, strconv.FormatFloat(*x.Value, 'f', -1, 64), "good")
} else if x.Quality == "bad" {
row = append(row, "—", "bad")
} 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
+150
View File
@@ -0,0 +1,150 @@
package collector
import (
"aquacontrolai/internal/protocol"
pg "aquacontrolai/internal/repository/postgres"
td "aquacontrolai/internal/repository/tdengine"
"context"
"github.com/google/uuid"
"log/slog"
"sync"
"time"
)
type LatestValue struct {
Value *float64 `json:"value"`
Quality string `json:"quality"`
QualityReason *string `json:"quality_reason"`
TS time.Time `json:"ts"`
}
type Engine struct {
manager *Manager
pg *pg.Store
td *td.Store
workers int
jobs chan pg.PointRow
mu sync.RWMutex
latest map[uuid.UUID]LatestValue
lastHistory map[uuid.UUID]time.Time
cancel context.CancelFunc
wg sync.WaitGroup
}
func NewEngine(manager *Manager, pgStore *pg.Store, tdStore *td.Store, workers int) *Engine {
if workers < 1 {
workers = 1
}
return &Engine{manager: manager, pg: pgStore, td: tdStore, workers: workers, jobs: make(chan pg.PointRow, workers*4), latest: map[uuid.UUID]LatestValue{}, lastHistory: map[uuid.UUID]time.Time{}}
}
func (e *Engine) Start(parent context.Context) {
ctx, cancel := context.WithCancel(parent)
e.cancel = cancel
for i := 0; i < e.workers; i++ {
e.wg.Add(1)
go e.worker(ctx)
}
e.wg.Add(1)
go e.schedule(ctx)
slog.Info("采集引擎已启动", "workers", e.workers)
}
func (e *Engine) Stop() {
if e.cancel != nil {
e.cancel()
}
e.wg.Wait()
slog.Info("采集引擎已停止")
}
func (e *Engine) Latest(id uuid.UUID) *LatestValue {
e.mu.RLock()
defer e.mu.RUnlock()
v, ok := e.latest[id]
if !ok {
return nil
}
return &v
}
func (e *Engine) schedule(ctx context.Context) {
defer e.wg.Done()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
last := map[uuid.UUID]time.Time{}
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
points, err := e.pg.ListPoints(ctx, "collection", "", false)
if err != nil {
slog.Error("加载采集点失败", "error", err)
continue
}
for _, p := range points {
if !p.Enabled {
continue
}
if now.Sub(last[p.ID]) < time.Duration(p.CollectInterval)*time.Second {
continue
}
select {
case e.jobs <- p:
last[p.ID] = now
default:
slog.Warn("采集任务队列已满", "point_id", p.ID)
}
}
}
}
}
func (e *Engine) worker(ctx context.Context) {
defer e.wg.Done()
for {
select {
case <-ctx.Done():
return
case p := <-e.jobs:
e.collect(ctx, p)
}
}
}
func (e *Engine) collect(ctx context.Context, p pg.PointRow) {
now := time.Now()
var value *float64
quality := 1
reasonText := "disconnected"
reason := &reasonText
c, err := e.manager.Connection(ctx, p.DeviceID)
if err == nil {
v, readErr := c.Read(ctx, p.Address, protocol.DataType(p.DataType))
if readErr == nil {
value = &v
quality = 0
reason = nil
} else {
reasonText = "read_error"
reason = &reasonText
e.manager.MarkDisconnected(p.DeviceID)
}
}
q := "good"
if quality == 1 {
q = "bad"
}
e.mu.Lock()
e.latest[p.ID] = LatestValue{value, q, reason, now}
last := e.lastHistory[p.ID]
shouldStore := p.StoreHistory && now.Sub(last) >= time.Duration(p.HistoryInterval)*time.Minute
if shouldStore {
e.lastHistory[p.ID] = now
}
e.mu.Unlock()
if shouldStore {
d, deviceErr := e.pg.GetDevice(ctx, p.DeviceID)
if deviceErr == nil {
if markErr := e.pg.MarkHistoryStarted(ctx, p.ID); markErr == nil {
if insertErr := e.td.Insert(ctx, p, d, value, quality, reason, now); insertErr != nil {
slog.Error("写入TDengine失败", "point_id", p.ID, "error", insertErr)
}
}
}
}
}
+128
View File
@@ -0,0 +1,128 @@
package collector
import (
"aquacontrolai/internal/protocol"
pg "aquacontrolai/internal/repository/postgres"
"context"
"encoding/json"
"github.com/google/uuid"
"sync"
"time"
)
type Manager struct {
mu sync.RWMutex
connections map[uuid.UUID]protocol.Connection
statuses map[uuid.UUID]string
lastOnline map[uuid.UUID]time.Time
lastOffline map[uuid.UUID]time.Time
store *pg.Store
registry *protocol.Registry
}
func NewManager(store *pg.Store, registry *protocol.Registry) *Manager {
return &Manager{connections: map[uuid.UUID]protocol.Connection{}, statuses: map[uuid.UUID]string{}, lastOnline: map[uuid.UUID]time.Time{}, lastOffline: map[uuid.UUID]time.Time{}, store: store, registry: registry}
}
func (m *Manager) Connection(ctx context.Context, id uuid.UUID) (protocol.Connection, error) {
m.mu.RLock()
c := m.connections[id]
m.mu.RUnlock()
if c != nil {
return c, nil
}
d, e := m.store.GetDevice(ctx, id)
if e != nil {
return nil, e
}
if !d.Enabled {
m.setStatus(id, "disabled")
return nil, context.Canceled
}
f, e := m.registry.Get(d.ProtocolType)
if e != nil {
return nil, e
}
var cfg map[string]any
if e = json.Unmarshal(d.ProtocolConfig, &cfg); e != nil {
return nil, e
}
connectCtx, cancel := context.WithTimeout(ctx, time.Duration(d.ConnectTimeout)*time.Second)
defer cancel()
c, e = f.NewConnection(connectCtx, protocol.DeviceConnectionConfig{DeviceID: id, Host: d.Host, Port: d.Port, ConnectTimeoutSeconds: d.ConnectTimeout, ProtocolConfig: cfg})
if e != nil {
m.setStatus(id, "disconnected")
m.markOffline(id)
return nil, e
}
m.mu.Lock()
if existing := m.connections[id]; existing != nil {
m.mu.Unlock()
_ = c.Close()
return existing, nil
}
m.connections[id] = c
m.statuses[id] = "connected"
m.lastOnline[id] = time.Now()
m.mu.Unlock()
return c, nil
}
func (m *Manager) Invalidate(id uuid.UUID) {
m.mu.Lock()
c := m.connections[id]
delete(m.connections, id)
m.statuses[id] = "disconnected"
m.lastOffline[id] = time.Now()
m.mu.Unlock()
if c != nil {
_ = c.Close()
}
}
func (m *Manager) Status(id uuid.UUID) string {
m.mu.RLock()
defer m.mu.RUnlock()
if s := m.statuses[id]; s != "" {
return s
}
return "disconnected"
}
func (m *Manager) Times(id uuid.UUID) (online, offline *time.Time) {
m.mu.RLock()
defer m.mu.RUnlock()
if t, ok := m.lastOnline[id]; ok {
v := t
online = &v
}
if t, ok := m.lastOffline[id]; ok {
v := t
offline = &v
}
return
}
func (m *Manager) MarkDisconnected(id uuid.UUID) {
m.mu.Lock()
m.statuses[id] = "disconnected"
m.lastOffline[id] = time.Now()
m.mu.Unlock()
}
func (m *Manager) setStatus(id uuid.UUID, s string) {
m.mu.Lock()
m.statuses[id] = s
if s == "disconnected" {
m.lastOffline[id] = time.Now()
}
m.mu.Unlock()
}
func (m *Manager) markOffline(id uuid.UUID) {
m.mu.Lock()
m.lastOffline[id] = time.Now()
m.mu.Unlock()
}
func (m *Manager) Close() {
m.mu.Lock()
all := m.connections
m.connections = map[uuid.UUID]protocol.Connection{}
m.mu.Unlock()
for _, c := range all {
_ = c.Close()
}
}
+50
View File
@@ -0,0 +1,50 @@
package writer
import (
"aquacontrolai/internal/engine/collector"
"aquacontrolai/internal/protocol"
pg "aquacontrolai/internal/repository/postgres"
"context"
"errors"
"math"
"time"
)
type Engine struct {
Manager *collector.Manager
Store *pg.Store
}
type Result struct {
Value, Readback float64
TS time.Time
}
func (e *Engine) Execute(ctx context.Context, p pg.PointRow, value float64) (Result, error) {
if !p.Enabled || !p.WriteEnabled {
return Result{}, errors.New("写入点未启用或写入开关关闭")
}
c, err := e.Manager.Connection(ctx, p.DeviceID)
if err != nil {
return Result{}, err
}
dt := protocol.DataType(p.DataType)
for attempt := 0; attempt < 2; attempt++ {
if err = c.Write(ctx, p.Address, dt, value); err != nil {
continue
}
actual, readErr := c.Read(ctx, p.Address, dt)
if readErr != nil {
err = readErr
continue
}
ok := actual == value
if dt == protocol.Real {
ok = math.Abs(actual-value) <= p.ReadbackTolerance
}
if ok {
return Result{value, actual, time.Now()}, nil
}
err = errors.New("回读值与目标值不一致")
}
return Result{}, err
}
+40
View File
@@ -0,0 +1,40 @@
package model
import (
"encoding/json"
"github.com/google/uuid"
"time"
)
type Device struct {
ID uuid.UUID `json:"id"`
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"`
ConnectionStatus string `json:"connection_status"`
LastOnlineAt *time.Time `json:"last_online_at,omitempty"`
LastOfflineAt *time.Time `json:"last_offline_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Point struct {
ID uuid.UUID `json:"id"`
Name, GroupName string
DeviceID uuid.UUID
DeviceName, ProtocolType, Address, DataType string
Unit *string
Enabled bool
LatestValue any
CollectInterval int
StoreHistory bool
HistoryInterval int
HistoryStartedAt *time.Time
WriteEnabled bool
ReadbackTolerance float64
CreatedAt, UpdatedAt time.Time
}
+31
View File
@@ -0,0 +1,31 @@
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
Port, PostgresDSN, TDengineDSN, TDengineDatabase string
HistoryRetentionDays, CollectorWorkers int
}
func env(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func intEnv(k string, d int) int {
v, e := strconv.Atoi(env(k, strconv.Itoa(d)))
if e != nil {
return d
}
return v
}
func Load() Config {
ph, pp, pu, pw, pd := env("POSTGRES_HOST", "localhost"), env("POSTGRES_PORT", "5432"), env("POSTGRES_USER", "postgres"), env("POSTGRES_PASSWORD", ""), env("POSTGRES_DATABASE", "aquacontrolai")
th, tp, tu, tw, td := env("TDENGINE_HOST", "localhost"), env("TDENGINE_PORT", "6041"), env("TDENGINE_USER", "root"), env("TDENGINE_PASSWORD", ""), env("TDENGINE_DATABASE", "aquacontrolai")
return Config{Port: env("APP_PORT", "8080"), PostgresDSN: fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", pu, pw, ph, pp, pd, env("POSTGRES_SSLMODE", "disable")), TDengineDSN: fmt.Sprintf("%s:%s@http(%s:%s)/%s", tu, tw, th, tp, td), TDengineDatabase: td, HistoryRetentionDays: intEnv("HISTORY_RETENTION_DAYS", 365), CollectorWorkers: intEnv("COLLECTOR_WORKERS", 8)}
}
+18
View File
@@ -0,0 +1,18 @@
package response
import (
"github.com/gin-gonic/gin"
"net/http"
)
type Envelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func OK(c *gin.Context, data any) { c.JSON(http.StatusOK, Envelope{0, "success", data}) }
func Created(c *gin.Context, data any) { c.JSON(http.StatusCreated, Envelope{0, "success", data}) }
func Error(c *gin.Context, status, code int, message string, data any) {
c.JSON(status, Envelope{code, message, data})
}
+103
View File
@@ -0,0 +1,103 @@
package protocol
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"regexp"
"sync"
)
type DataType string
const (
Bool DataType = "BOOL"
Int DataType = "INT"
Real DataType = "REAL"
)
type DeviceConnectionConfig struct {
DeviceID uuid.UUID
Host string
Port int
ConnectTimeoutSeconds int
ProtocolConfig map[string]any
}
type Connection interface {
Read(context.Context, string, DataType) (float64, error)
Write(context.Context, string, DataType, float64) error
Close() error
}
type Factory interface {
ProtocolType() string
ValidateConfig(map[string]any) error
ValidateAddress(string, DataType, bool) error
ConfigSchema() map[string]any
NewConnection(context.Context, DeviceConnectionConfig) (Connection, error)
}
type Registry struct {
mu sync.RWMutex
items map[string]Factory
}
func NewRegistry() *Registry { return &Registry{items: map[string]Factory{}} }
func (r *Registry) Register(f Factory) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.items[f.ProtocolType()]; ok {
return fmt.Errorf("协议已注册: %s", f.ProtocolType())
}
r.items[f.ProtocolType()] = f
return nil
}
func (r *Registry) Get(t string) (Factory, error) {
r.mu.RLock()
defer r.mu.RUnlock()
f, ok := r.items[t]
if !ok {
return nil, errors.New("不支持的协议")
}
return f, nil
}
func (r *Registry) Metadata() []map[string]any {
r.mu.RLock()
defer r.mu.RUnlock()
out := []map[string]any{}
for _, f := range r.items {
port := 502
if f.ProtocolType() == "S7" {
port = 102
}
out = append(out, map[string]any{"protocol_type": f.ProtocolType(), "default_port": port, "config_schema": f.ConfigSchema()})
}
return out
}
var s7DB = regexp.MustCompile(`^DB\d+\.\d+(?:\.[0-7])?$`)
var s7Standard = regexp.MustCompile(`^[MIQ]\d+(?:\.[0-7])?$`)
var s7Word = regexp.MustCompile(`^[MQ][WD]\d+$`)
func ValidateS7Address(a string, t DataType, w bool) error {
if !s7DB.MatchString(a) && !s7Standard.MatchString(a) && !s7Word.MatchString(a) {
return errors.New("S7 地址格式无效")
}
hasBit := regexp.MustCompile(`\.[0-7]$`).MatchString(a)
numericDBZeroSuffix := t != Bool && regexp.MustCompile(`^DB\d+\.\d+\.0$`).MatchString(a)
if t == Bool && !hasBit {
return errors.New("BOOL 地址必须包含 bit")
}
if t != Bool && hasBit && !numericDBZeroSuffix {
return errors.New("INT/REAL 地址不能包含 bit(仅兼容 DB 数值地址末尾 .0")
}
if t == Int && regexp.MustCompile(`^[MQ]D`).MatchString(a) {
return errors.New("INT 应使用 MW/QW 地址")
}
if t == Real && regexp.MustCompile(`^[MQ]W`).MatchString(a) {
return errors.New("REAL 应使用 MD/QD 地址")
}
if w && len(a) > 0 && a[0] == 'I' {
return errors.New("I 区只读")
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package protocol
import "testing"
func TestValidateS7Address(t *testing.T) {
tests := []struct {
address string
dataType DataType
writable, wantError bool
}{{"DB2.1186.0", Real, false, false}, {"DB2.1186.1", Real, false, true}, {"MD540", Real, true, false}, {"MW540", Int, true, false}, {"M4.0", Bool, true, false}, {"I1.0", Bool, true, true}}
for _, tt := range tests {
t.Run(tt.address+string(tt.dataType), func(t *testing.T) {
err := ValidateS7Address(tt.address, tt.dataType, tt.writable)
if (err != nil) != tt.wantError {
t.Fatalf("ValidateS7Address() error=%v wantError=%v", err, tt.wantError)
}
})
}
}
+46
View File
@@ -0,0 +1,46 @@
package modbus
import (
"aquacontrolai/internal/protocol"
"context"
"errors"
"strconv"
)
type Factory struct{}
func (Factory) ProtocolType() string { return "MODBUS_TCP" }
func (Factory) ValidateConfig(c map[string]any) error {
u, ok := c["unit_id"].(float64)
if !ok || u < 1 || u > 247 {
return errors.New("unit_id 必须在1~247")
}
o, ok := c["float32_order"].(string)
if !ok || !map[string]bool{"ABCD": true, "BADC": true, "CDAB": true, "DCBA": true}[o] {
return errors.New("float32_order 无效")
}
return nil
}
func (Factory) ValidateAddress(a string, t protocol.DataType, w bool) error {
n, e := strconv.Atoi(a)
if e != nil || n < 1 || n > 49999 {
return errors.New("Modbus 地址无效")
}
prefix := n / 10000
if prefix <= 1 && t != protocol.Bool {
return errors.New("线圈/离散输入仅支持BOOL")
}
if prefix >= 3 && t == protocol.Bool {
return errors.New("寄存器仅支持INT/REAL")
}
if w && (prefix == 1 || prefix == 3) {
return errors.New("该区域只读")
}
return nil
}
func (Factory) ConfigSchema() map[string]any {
return map[string]any{"type": "object", "required": []string{"unit_id", "float32_order"}}
}
func (Factory) NewConnection(context.Context, protocol.DeviceConnectionConfig) (protocol.Connection, error) {
return nil, errors.New("Modbus 连接由运行时适配器创建")
}
+204
View File
@@ -0,0 +1,204 @@
package s7
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"sync"
"github.com/robinson/gos7"
"aquacontrolai/internal/protocol"
)
type Factory struct{}
func (Factory) ProtocolType() string { return "S7" }
func (Factory) ValidateConfig(c map[string]any) error {
if _, ok := c["rack"]; !ok {
return errors.New("缺少 rack")
}
if _, ok := c["slot"]; !ok {
return errors.New("缺少 slot")
}
return nil
}
func (Factory) ValidateAddress(a string, t protocol.DataType, w bool) error {
return protocol.ValidateS7Address(a, t, w)
}
func (Factory) ConfigSchema() map[string]any {
return map[string]any{"type": "object", "required": []string{"rack", "slot"}, "properties": map[string]any{"rack": map[string]any{"type": "integer", "default": 0}, "slot": map[string]any{"type": "integer", "default": 1}}}
}
func (Factory) NewConnection(ctx context.Context, cfg protocol.DeviceConnectionConfig) (protocol.Connection, error) {
handler := gos7.NewTCPClientHandler(fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), intConfig(cfg.ProtocolConfig, "rack"), intConfig(cfg.ProtocolConfig, "slot"))
result := make(chan error, 1)
go func() { result <- handler.Connect() }()
select {
case <-ctx.Done():
_ = handler.Close()
return nil, ctx.Err()
case err := <-result:
if err != nil {
return nil, err
}
}
return &connection{handler: handler, client: gos7.NewClient(handler)}, nil
}
type address struct {
area string
db, offset, bit int
}
type connection struct {
mu sync.Mutex
handler *gos7.TCPClientHandler
client gos7.Client
}
func intConfig(c map[string]any, k string) int {
switch v := c[k].(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
func parseAddress(raw string) (address, error) {
parts := strings.Split(raw, ".")
if len(parts) == 0 || parts[0] == "" {
return address{}, errors.New("无效S7地址")
}
a := address{bit: -1}
first := parts[0]
if strings.HasPrefix(first, "DB") {
a.area = "DB"
var e error
a.db, e = strconv.Atoi(strings.TrimPrefix(first, "DB"))
if e != nil || len(parts) < 2 {
return a, errors.New("无效DB地址")
}
a.offset, e = strconv.Atoi(parts[1])
if e != nil {
return a, e
}
if len(parts) == 3 {
a.bit, e = strconv.Atoi(parts[2])
if e != nil {
return a, e
}
}
} else {
a.area = first[:1]
offsetText := first[1:]
if len(offsetText) > 1 && (offsetText[0] == 'W' || offsetText[0] == 'D') {
offsetText = offsetText[1:]
}
var e error
a.offset, e = strconv.Atoi(offsetText)
if e != nil {
return a, e
}
if len(parts) == 2 {
a.bit, e = strconv.Atoi(parts[1])
if e != nil {
return a, e
}
}
}
return a, nil
}
func (c *connection) Read(ctx context.Context, raw string, t protocol.DataType) (float64, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := ctx.Err(); err != nil {
return 0, err
}
a, e := parseAddress(raw)
if e != nil {
return 0, e
}
size := 2
if t == protocol.Bool {
size = 1
} else if t == protocol.Real {
size = 4
}
buf := make([]byte, size)
if e = c.read(a, size, buf); e != nil {
return 0, e
}
switch t {
case protocol.Bool:
if buf[0]&(1<<a.bit) != 0 {
return 1, nil
}
return 0, nil
case protocol.Int:
return float64(int16(binary.BigEndian.Uint16(buf))), nil
default:
value := float64(math.Float32frombits(binary.BigEndian.Uint32(buf)))
return math.Round(value*1000) / 1000, nil
}
}
func (c *connection) Write(ctx context.Context, raw string, t protocol.DataType, v float64) error {
c.mu.Lock()
defer c.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
a, e := parseAddress(raw)
if e != nil {
return e
}
if t == protocol.Bool {
buf := []byte{0}
if e = c.read(a, 1, buf); e != nil {
return e
}
if v != 0 {
buf[0] |= 1 << a.bit
} else {
buf[0] &= ^(1 << a.bit)
}
return c.write(a, 1, buf)
}
size := 2
buf := make([]byte, 4)
if t == protocol.Int {
binary.BigEndian.PutUint16(buf, uint16(int16(v)))
} else {
size = 4
binary.BigEndian.PutUint32(buf, math.Float32bits(float32(v)))
}
return c.write(a, size, buf[:size])
}
func (c *connection) read(a address, size int, b []byte) error {
switch a.area {
case "DB":
return c.client.AGReadDB(a.db, a.offset, size, b)
case "M":
return c.client.AGReadMB(a.offset, size, b)
case "I":
return c.client.AGReadEB(a.offset, size, b)
case "Q":
return c.client.AGReadAB(a.offset, size, b)
}
return errors.New("不支持的S7区域")
}
func (c *connection) write(a address, size int, b []byte) error {
switch a.area {
case "DB":
return c.client.AGWriteDB(a.db, a.offset, size, b)
case "M":
return c.client.AGWriteMB(a.offset, size, b)
case "Q":
return c.client.AGWriteAB(a.offset, size, b)
}
return errors.New("不支持的S7写入区域")
}
func (c *connection) Close() error { c.mu.Lock(); defer c.mu.Unlock(); return c.handler.Close() }
+333
View File
@@ -0,0 +1,333 @@
package postgres
import (
"aquacontrolai/internal/model"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"time"
)
type Store struct{ DB *pgxpool.Pool }
func Open(ctx context.Context, dsn string) (*Store, error) {
db, e := pgxpool.New(ctx, dsn)
if e != nil {
return nil, e
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if e = db.Ping(ctx); e != nil {
db.Close()
return nil, fmt.Errorf("PostgreSQL 健康检查失败: %w", e)
}
return &Store{db}, nil
}
func (s *Store) ListDevices(ctx context.Context, keyword string) ([]model.Device, error) {
rows, e := s.DB.Query(ctx, `SELECT id,name,protocol_type,enabled,host,port,connect_timeout,reconnect_interval,protocol_config,created_at,updated_at FROM devices WHERE deleted=FALSE AND ($1='' OR name ILIKE '%'||$1||'%') ORDER BY name LIMIT 10000`, keyword)
if e != nil {
return nil, e
}
defer rows.Close()
var out []model.Device
for rows.Next() {
var d model.Device
if e = rows.Scan(&d.ID, &d.Name, &d.ProtocolType, &d.Enabled, &d.Host, &d.Port, &d.ConnectTimeout, &d.ReconnectInterval, &d.ProtocolConfig, &d.CreatedAt, &d.UpdatedAt); e != nil {
return nil, e
}
if d.Enabled {
d.ConnectionStatus = "disconnected"
} else {
d.ConnectionStatus = "disabled"
}
out = append(out, d)
}
return out, rows.Err()
}
func (s *Store) CreateDevice(ctx context.Context, d *model.Device) error {
return s.DB.QueryRow(ctx, `INSERT INTO devices(name,protocol_type,enabled,host,port,connect_timeout,reconnect_interval,protocol_config) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id,created_at,updated_at`, d.Name, d.ProtocolType, d.Enabled, d.Host, d.Port, d.ConnectTimeout, d.ReconnectInterval, d.ProtocolConfig).Scan(&d.ID, &d.CreatedAt, &d.UpdatedAt)
}
func (s *Store) UpdateDevice(ctx context.Context, d *model.Device) error {
return s.DB.QueryRow(ctx, `UPDATE devices SET name=$2,protocol_type=$3,enabled=$4,host=$5,port=$6,connect_timeout=$7,reconnect_interval=$8,protocol_config=$9,updated_at=NOW() WHERE id=$1 AND deleted=FALSE RETURNING created_at,updated_at`, d.ID, d.Name, d.ProtocolType, d.Enabled, d.Host, d.Port, d.ConnectTimeout, d.ReconnectInterval, d.ProtocolConfig).Scan(&d.CreatedAt, &d.UpdatedAt)
}
func (s *Store) DeleteDevice(ctx context.Context, id uuid.UUID) error {
tx, e := s.DB.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
tag, e := tx.Exec(ctx, `UPDATE devices SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, id)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
for _, q := range []string{`UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`, `UPDATE write_points SET deleted=TRUE,enabled=FALSE,write_enabled=FALSE,updated_at=NOW() WHERE device_id=$1 AND deleted=FALSE`} {
if _, e = tx.Exec(ctx, q, id); e != nil {
return e
}
}
return tx.Commit(ctx)
}
type PointRow struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
GroupName string `json:"group_name"`
DeviceID uuid.UUID `json:"device_id"`
DeviceName string `json:"device_name"`
ProtocolType string `json:"protocol_type"`
Address string `json:"address"`
DataType string `json:"data_type"`
Unit *string `json:"unit"`
Enabled bool `json:"enabled"`
CollectInterval int `json:"collect_interval,omitempty"`
StoreHistory bool `json:"store_history,omitempty"`
HistoryInterval int `json:"history_interval,omitempty"`
HistoryStartedAt *time.Time `json:"history_started_at,omitempty"`
WriteEnabled bool `json:"write_enabled,omitempty"`
ReadbackTolerance float64 `json:"readback_tolerance,omitempty"`
LatestValue any `json:"latest_value"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *Store) GetPoint(ctx context.Context, kind string, id uuid.UUID) (PointRow, error) {
items, e := s.ListPoints(ctx, kind, "", false)
if e != nil {
return PointRow{}, e
}
for _, p := range items {
if p.ID == id {
return p, nil
}
}
return PointRow{}, pgx.ErrNoRows
}
func (s *Store) SavePoint(ctx context.Context, kind string, p *PointRow) error {
if kind == "collection" {
if p.ID == uuid.Nil {
return s.DB.QueryRow(ctx, `INSERT INTO collection_points(name,group_name,device_id,enabled,address,data_type,unit,collect_interval,store_history,history_interval) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id,created_at,updated_at`, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.Address, p.DataType, p.Unit, p.CollectInterval, p.StoreHistory, p.HistoryInterval).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
tag, e := s.DB.Exec(ctx, `UPDATE collection_points SET name=$2,group_name=$3,device_id=$4,enabled=$5,address=$6,data_type=$7,unit=$8,collect_interval=$9,store_history=$10,history_interval=$11,updated_at=NOW() WHERE id=$1 AND deleted=FALSE AND (history_started_at IS NULL OR (device_id=$4 AND data_type=$7))`, p.ID, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.Address, p.DataType, p.Unit, p.CollectInterval, p.StoreHistory, p.HistoryInterval)
if e == nil && tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return e
}
if p.ID == uuid.Nil {
return s.DB.QueryRow(ctx, `INSERT INTO write_points(name,group_name,device_id,enabled,write_enabled,address,data_type,unit,readback_tolerance) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id,created_at,updated_at`, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.WriteEnabled, p.Address, p.DataType, p.Unit, p.ReadbackTolerance).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
tag, e := s.DB.Exec(ctx, `UPDATE write_points SET name=$2,group_name=$3,device_id=$4,enabled=$5,write_enabled=$6,address=$7,data_type=$8,unit=$9,readback_tolerance=$10,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, p.ID, p.Name, p.GroupName, p.DeviceID, p.Enabled, p.WriteEnabled, p.Address, p.DataType, p.Unit, p.ReadbackTolerance)
if e == nil && tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return e
}
func (s *Store) DeletePoint(ctx context.Context, kind string, id uuid.UUID) error {
table := "collection_points"
extra := ""
if kind == "write" {
table = "write_points"
extra = ",write_enabled=FALSE"
}
q := fmt.Sprintf(`UPDATE %s SET deleted=TRUE,enabled=FALSE%s,updated_at=NOW() WHERE id=$1 AND deleted=FALSE`, table, extra)
tag, e := s.DB.Exec(ctx, q, id)
if e == nil && tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return e
}
func (s *Store) GetDevice(ctx context.Context, id uuid.UUID) (model.Device, error) {
var d model.Device
e := s.DB.QueryRow(ctx, `SELECT id,name,protocol_type,enabled,host,port,connect_timeout,reconnect_interval,protocol_config,created_at,updated_at FROM devices WHERE id=$1 AND deleted=FALSE`, id).Scan(&d.ID, &d.Name, &d.ProtocolType, &d.Enabled, &d.Host, &d.Port, &d.ConnectTimeout, &d.ReconnectInterval, &d.ProtocolConfig, &d.CreatedAt, &d.UpdatedAt)
return d, e
}
func (s *Store) FindDeviceByName(ctx context.Context, name string) (model.Device, error) {
var d model.Device
e := s.DB.QueryRow(ctx, `SELECT id,name,protocol_type,enabled,host,port,connect_timeout,reconnect_interval,protocol_config,created_at,updated_at FROM devices WHERE name=$1 AND deleted=FALSE`, name).Scan(&d.ID, &d.Name, &d.ProtocolType, &d.Enabled, &d.Host, &d.Port, &d.ConnectTimeout, &d.ReconnectInterval, &d.ProtocolConfig, &d.CreatedAt, &d.UpdatedAt)
return d, e
}
func (s *Store) FindPointByName(ctx context.Context, kind, name string) (PointRow, error) {
items, e := s.ListPoints(ctx, kind, name, false)
if e != nil {
return PointRow{}, e
}
for _, p := range items {
if p.Name == name {
return p, nil
}
}
return PointRow{}, pgx.ErrNoRows
}
func (s *Store) Groups(ctx context.Context) ([]map[string]any, error) {
rows, e := s.DB.Query(ctx, `SELECT g.name,COUNT(p.id) FROM collection_groups g LEFT JOIN collection_points p ON p.group_name=g.name AND p.deleted=FALSE GROUP BY g.name ORDER BY g.name`)
if e != nil {
return nil, e
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var name string
var count int
if e = rows.Scan(&name, &count); e != nil {
return nil, e
}
out = append(out, map[string]any{"name": name, "count": count})
}
return out, rows.Err()
}
func (s *Store) CreateGroup(ctx context.Context, name string) error {
_, e := s.DB.Exec(ctx, `INSERT INTO collection_groups(name) VALUES($1)`, name)
return e
}
func (s *Store) UpdateGroup(ctx context.Context, oldName, newName string) error {
tx, e := s.DB.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
if _, e = tx.Exec(ctx, `INSERT INTO collection_groups(name) VALUES($1)`, newName); e != nil {
return e
}
if _, e = tx.Exec(ctx, `UPDATE collection_points SET group_name=$2,updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, oldName, newName); e != nil {
return e
}
if _, e = tx.Exec(ctx, `DELETE FROM collection_groups WHERE name=$1`, oldName); e != nil {
return e
}
return tx.Commit(ctx)
}
func (s *Store) DeleteGroup(ctx context.Context, name string) error {
if name == "default" {
return fmt.Errorf("default分组不可删除")
}
tx, e := s.DB.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
if _, e = tx.Exec(ctx, `UPDATE collection_points SET deleted=TRUE,enabled=FALSE,updated_at=NOW() WHERE group_name=$1 AND deleted=FALSE`, name); e != nil {
return e
}
tag, e := tx.Exec(ctx, `DELETE FROM collection_groups WHERE name=$1`, name)
if e == nil && tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
if e != nil {
return e
}
return tx.Commit(ctx)
}
func (s *Store) GetRetention(ctx context.Context) (int, error) {
var days int
e := s.DB.QueryRow(ctx, `SELECT (value #>> '{}')::integer FROM system_settings WHERE key='history_retention_days'`).Scan(&days)
return days, e
}
func (s *Store) SetRetention(ctx context.Context, days int) error {
_, e := s.DB.Exec(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('history_retention_days',to_jsonb($1::integer),NOW()) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,updated_at=NOW()`, days)
return e
}
func (s *Store) ListPoints(ctx context.Context, kind, keyword string, archived bool) ([]PointRow, error) {
table := "collection_points"
cols := `p.collect_interval,p.store_history,p.history_interval,p.history_started_at,FALSE,0`
if kind == "write" {
table = "write_points"
cols = `NULL::double precision,NULL::double precision,0,FALSE,0,NULL::timestamptz,p.write_enabled,p.readback_tolerance`
}
deleted := "p.deleted=FALSE AND d.deleted=FALSE"
if archived {
deleted = "TRUE"
}
q := fmt.Sprintf(`SELECT p.id,p.name,p.group_name,p.device_id,d.name,d.protocol_type,p.address,p.data_type,p.unit,p.enabled,%s,p.created_at,p.updated_at FROM %s p JOIN devices d ON d.id=p.device_id WHERE %s AND ($1='' OR p.name ILIKE '%%'||$1||'%%' OR p.group_name ILIKE '%%'||$1||'%%') ORDER BY p.group_name,p.name LIMIT 1000`, cols, table, deleted)
rows, e := s.DB.Query(ctx, q, keyword)
if e != nil {
return nil, e
}
defer rows.Close()
var out []PointRow
for rows.Next() {
var p PointRow
if e = rows.Scan(&p.ID, &p.Name, &p.GroupName, &p.DeviceID, &p.DeviceName, &p.ProtocolType, &p.Address, &p.DataType, &p.Unit, &p.Enabled, &p.CollectInterval, &p.StoreHistory, &p.HistoryInterval, &p.HistoryStartedAt, &p.WriteEnabled, &p.ReadbackTolerance, &p.CreatedAt, &p.UpdatedAt); e != nil {
return nil, e
}
out = append(out, p)
}
return out, rows.Err()
}
type WriteLog struct {
ID uuid.UUID `json:"id"`
PointID uuid.UUID `json:"point_id"`
PointName string `json:"point_name"`
DeviceID uuid.UUID `json:"device_id"`
DeviceName, Address, DataType string
Unit *string
TargetValue any `json:"target_value"`
ReadbackValue any `json:"readback_value"`
Result string `json:"result"`
ErrorMessage *string `json:"error_message"`
Reason *string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
func (s *Store) ListLogs(ctx context.Context) ([]map[string]any, error) {
rows, e := s.DB.Query(ctx, `SELECT id,point_id,point_name,device_id,device_name,address,data_type,unit,target_value,readback_value,result,error_message,reason,created_at FROM write_logs ORDER BY created_at DESC LIMIT 100`)
if e != nil {
return nil, e
}
defer rows.Close()
var out []map[string]any
for rows.Next() {
var id, pid, did uuid.UUID
var pn, dn, a, dt, tv, res string
var unit, rv, em, reason *string
var ts time.Time
if e = rows.Scan(&id, &pid, &pn, &did, &dn, &a, &dt, &unit, &tv, &rv, &res, &em, &reason, &ts); e != nil {
return nil, e
}
out = append(out, map[string]any{"id": id, "point_id": pid, "point_name": pn, "device_id": did, "device_name": dn, "address": a, "data_type": dt, "unit": unit, "target_value": parseValue(dt, tv), "readback_value": parseOptional(dt, rv), "result": res, "error_message": em, "reason": reason, "source": "manual", "operator": nil, "created_at": ts})
}
return out, rows.Err()
}
func parseValue(dt, v string) any {
var out any
if dt == "BOOL" {
json.Unmarshal([]byte(v), &out)
return out
}
json.Unmarshal([]byte(v), &out)
return out
}
func parseOptional(dt string, v *string) any {
if v == nil {
return nil
}
return parseValue(dt, *v)
}
func (s *Store) InsertWriteLog(ctx context.Context, p PointRow, target string, readback *string, result string, errorMessage *string, reason *string) (uuid.UUID, error) {
var id uuid.UUID
e := s.DB.QueryRow(ctx, `INSERT INTO write_logs(point_id,point_name,device_id,device_name,address,data_type,unit,source,target_value,readback_value,result,error_message,operator,reason) VALUES($1,$2,$3,$4,$5,$6,$7,'manual',$8,$9,$10,$11,NULL,$12) RETURNING id`, p.ID, p.Name, p.DeviceID, p.DeviceName, p.Address, p.DataType, p.Unit, target, readback, result, errorMessage, reason).Scan(&id)
return id, e
}
func IsConflict(err error) bool {
return err != nil && (errors.Is(err, pgx.ErrNoRows) || contains(err.Error(), "duplicate key"))
}
func (s *Store) MarkHistoryStarted(ctx context.Context, id uuid.UUID) error {
_, e := s.DB.Exec(ctx, `UPDATE collection_points SET history_started_at=COALESCE(history_started_at,NOW()) WHERE id=$1`, id)
return e
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+102
View File
@@ -0,0 +1,102 @@
package tdengine
import (
"aquacontrolai/internal/model"
pg "aquacontrolai/internal/repository/postgres"
"context"
"database/sql"
"fmt"
"github.com/google/uuid"
_ "github.com/taosdata/driver-go/v3/taosRestful"
"regexp"
"strconv"
"strings"
"time"
)
type Store struct {
DB *sql.DB
Database string
}
func (s *Store) Insert(ctx context.Context, p pg.PointRow, d model.Device, value *float64, quality int, reason *string, ts time.Time) error {
valueLiteral := "NULL"
if value != nil {
valueLiteral = strconv.FormatFloat(*value, 'g', -1, 64)
}
reasonLiteral := "NULL"
if reason != nil {
reasonLiteral = sqlString(*reason)
}
q := fmt.Sprintf("INSERT INTO `%s`.`%s` USING `%s`.`collection_data` TAGS(%s,%s,%s) VALUES(%s,%s,%d,%s,%s,%s)", s.Database, TableName(p.ID), s.Database, sqlString(d.ID.String()), sqlString(d.Name), sqlString(p.DataType), sqlString(ts.Format("2006-01-02 15:04:05.000")), valueLiteral, quality, reasonLiteral, sqlString(p.ID.String()), sqlString(p.Name))
_, e := s.DB.ExecContext(ctx, q)
return e
}
func sqlString(v string) string { return "'" + strings.ReplaceAll(v, "'", "''") + "'" }
func (s *Store) SetRetention(ctx context.Context, days int) error {
if days < 1 || days > 730 {
return fmt.Errorf("保留天数超出范围")
}
_, e := s.DB.ExecContext(ctx, fmt.Sprintf("ALTER DATABASE `%s` KEEP %d", s.Database, days))
return e
}
type Sample struct {
TS time.Time `json:"ts"`
Value *float64 `json:"value"`
Quality string `json:"quality"`
QualityReason *string `json:"quality_reason"`
}
func Open(ctx context.Context, dsn, database string) (*Store, error) {
db, e := sql.Open("taosRestful", dsn)
if e != nil {
return nil, e
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if e = db.PingContext(ctx); e != nil {
db.Close()
return nil, fmt.Errorf("TDengine 健康检查失败: %w", e)
}
return &Store{db, database}, nil
}
var tableRE = regexp.MustCompile(`^p_[0-9a-f]{32}$`)
func TableName(id uuid.UUID) string {
n := "p_" + strings.ReplaceAll(strings.ToLower(id.String()), "-", "")
if !tableRE.MatchString(n) {
panic("invalid derived table")
}
return n
}
func (s *Store) HasData(ctx context.Context, id uuid.UUID) bool {
q := fmt.Sprintf("SELECT COUNT(*) FROM `%s`.`%s`", s.Database, TableName(id))
var n int
return s.DB.QueryRowContext(ctx, q).Scan(&n) == nil && n > 0
}
func (s *Store) Query(ctx context.Context, id uuid.UUID, start, end time.Time) ([]Sample, error) {
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
q := fmt.Sprintf("SELECT ts,`value`,quality,quality_reason FROM `%s`.`%s` WHERE ts >= %s AND ts <= %s ORDER BY ts", s.Database, TableName(id), sqlString(start.In(shanghai).Format("2006-01-02 15:04:05.000")), sqlString(end.In(shanghai).Format("2006-01-02 15:04:05.000")))
rows, e := s.DB.QueryContext(ctx, q)
if e != nil {
return nil, e
}
defer rows.Close()
out := make([]Sample, 0)
for rows.Next() {
var x Sample
var quality int
if e = rows.Scan(&x.TS, &x.Value, &quality, &x.QualityReason); e != nil {
return nil, e
}
if quality == 0 {
x.Quality = "good"
} else {
x.Quality = "bad"
}
out = append(out, x)
}
return out, rows.Err()
}
@@ -0,0 +1,18 @@
package tdengine
import (
"github.com/google/uuid"
"testing"
)
func TestTableName(t *testing.T) {
id := uuid.MustParse("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d")
if got := TableName(id); got != "p_a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d" {
t.Fatalf("unexpected table: %s", got)
}
}
func TestSQLStringEscapesQuote(t *testing.T) {
if got := sqlString("a'b"); got != "'a''b'" {
t.Fatalf("unexpected literal: %s", got)
}
}
+197
View File
@@ -0,0 +1,197 @@
package platform
import (
collector "aquacontrolai/internal/engine/collector"
pg "aquacontrolai/internal/repository/postgres"
td "aquacontrolai/internal/repository/tdengine"
"context"
"crypto/sha256"
"fmt"
"github.com/google/uuid"
"math"
"sort"
"time"
)
type History struct {
PG *pg.Store
TD *td.Store
Collector interface {
Latest(uuid.UUID) *collector.LatestValue
}
}
type Series struct {
PointID uuid.UUID `json:"point_id"`
PointName string `json:"point_name"`
DataType string `json:"data_type"`
Unit *string `json:"unit"`
Sampled bool `json:"sampled"`
RawCount int `json:"raw_count"`
SampleCount int `json:"sample_count"`
Data []td.Sample `json:"data"`
}
func (h *History) Tree(ctx context.Context) ([]map[string]any, error) {
points, e := h.PG.ListPoints(ctx, "collection", "", true)
if e != nil {
return nil, e
}
groups := map[string][]map[string]any{}
for _, p := range points {
active := p.Enabled && p.StoreHistory
has := h.TD.HasData(ctx, p.ID)
if !active && !has {
continue
}
life := "active"
if !active {
life = "archived"
}
var latest any
if h.Collector != nil && life == "active" {
latest = h.Collector.Latest(p.ID)
}
groups[p.GroupName] = append(groups[p.GroupName], map[string]any{"id": p.ID, "name": p.Name, "type": "collection", "data_type": p.DataType, "unit": p.Unit, "history_interval": p.HistoryInterval, "device_id": p.DeviceID, "device_name": p.DeviceName, "group_name": p.GroupName, "lifecycle_status": life, "has_history_data": has, "latest_value": latest})
}
names := make([]string, 0, len(groups))
for n := range groups {
names = append(names, n)
}
sort.Strings(names)
tree := []map[string]any{}
for _, n := range names {
hash := sha256.Sum256([]byte(n))
tree = append(tree, map[string]any{"id": fmt.Sprintf("group_%x", hash[:8]), "name": n, "type": "group", "children": groups[n]})
}
tree = append(tree, map[string]any{"id": "internal-data", "name": "内部数据", "type": "reserved", "children": []map[string]any{{"id": "placeholder", "name": "暂无数据", "type": "placeholder", "disabled": true}}})
return tree, nil
}
func (h *History) Query(ctx context.Context, ids []uuid.UUID, start, end time.Time, max int) ([]Series, error) {
meta, e := h.PG.ListPoints(ctx, "collection", "", true)
if e != nil {
return nil, e
}
byID := map[uuid.UUID]pg.PointRow{}
for _, p := range meta {
byID[p.ID] = p
}
out := make([]Series, 0, len(ids))
for _, id := range ids {
p, ok := byID[id]
if !ok {
return nil, fmt.Errorf("点位元数据不存在: %s", id)
}
data, e := h.TD.Query(ctx, id, start, end)
if e != nil {
return nil, e
}
raw := len(data)
if raw > max {
data = minMax(data, max)
}
out = append(out, Series{id, p.Name, p.DataType, p.Unit, raw > len(data), raw, len(data), data})
}
return out, nil
}
func minMax(data []td.Sample, max int) []td.Sample {
if len(data) <= max {
return data
}
keep := map[int]bool{0: true, len(data) - 1: true}
bucket := float64(len(data)) / float64(max/2)
for b := 0; b < max/2; b++ {
lo, hi := int(float64(b)*bucket), int(float64(b+1)*bucket)
if hi > len(data) {
hi = len(data)
}
minI, maxI := -1, -1
for i := lo; i < hi; i++ {
if data[i].Value == nil {
keep[i] = true
continue
}
if minI < 0 || *data[i].Value < *data[minI].Value {
minI = i
}
if maxI < 0 || *data[i].Value > *data[maxI].Value {
maxI = i
}
if i > 0 && data[i].Quality != data[i-1].Quality {
keep[i-1] = true
keep[i] = true
}
}
if minI >= 0 {
keep[minI] = true
keep[maxI] = true
}
}
idx := make([]int, 0, len(keep))
for i := range keep {
idx = append(idx, i)
}
sort.Ints(idx)
if len(idx) > max {
idx = idx[:max]
}
out := make([]td.Sample, 0, len(idx))
for _, i := range idx {
out = append(out, data[i])
}
return out
}
type TableValue struct {
Value *float64 `json:"value"`
Quality string `json:"quality"`
QualityReason *string `json:"quality_reason"`
MatchedTS *time.Time `json:"matched_ts"`
}
type TableColumn struct {
PointID uuid.UUID `json:"point_id"`
PointName string `json:"point_name"`
Unit *string `json:"unit"`
Data []TableValue `json:"data"`
}
type TableResult struct {
TimeColumn []time.Time `json:"time_column"`
Columns []TableColumn `json:"columns"`
}
func (h *History) QueryTable(ctx context.Context, ids []uuid.UUID, start, end time.Time, minutes int) (TableResult, error) {
step := time.Duration(minutes) * time.Minute
times := []time.Time{}
for t := start; !t.After(end); t = t.Add(step) {
times = append(times, t)
}
series, e := h.Query(ctx, ids, start.Add(-step/2), end.Add(step/2), 10000)
if e != nil {
return TableResult{}, e
}
res := TableResult{TimeColumn: times, Columns: []TableColumn{}}
for _, s := range series {
col := TableColumn{s.PointID, s.PointName, s.Unit, make([]TableValue, 0, len(times))}
for _, target := range times {
best := -1
bestDist := time.Duration(math.MaxInt64)
for i, x := range s.Data {
d := x.TS.Sub(target)
if d < 0 {
d = -d
}
if d <= step/2 && (d < bestDist || (d == bestDist && best >= 0 && x.TS.Before(s.Data[best].TS))) {
best, bestDist = i, d
}
}
if best < 0 {
col.Data = append(col.Data, TableValue{nil, "none", nil, nil})
} else {
x := s.Data[best]
ts := x.TS
col.Data = append(col.Data, TableValue{x.Value, x.Quality, x.QualityReason, &ts})
}
}
res.Columns = append(res.Columns, col)
}
return res, nil
}
+265
View File
@@ -0,0 +1,265 @@
package platform
import (
collectorengine "aquacontrolai/internal/engine/collector"
writerengine "aquacontrolai/internal/engine/writer"
"aquacontrolai/internal/model"
"aquacontrolai/internal/protocol"
pg "aquacontrolai/internal/repository/postgres"
td "aquacontrolai/internal/repository/tdengine"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"math"
"net"
"strings"
"time"
)
type Service struct {
Store *pg.Store
Registry *protocol.Registry
Connections *collectorengine.Manager
Writer *writerengine.Engine
Collector *collectorengine.Engine
TD *td.Store
}
func (s *Service) ListDevices(ctx context.Context, keyword string) ([]model.Device, error) {
items, e := s.Store.ListDevices(ctx, keyword)
if e == nil && s.Connections != nil {
for i := range items {
if items[i].Enabled {
items[i].ConnectionStatus = s.Connections.Status(items[i].ID)
}
items[i].LastOnlineAt, items[i].LastOfflineAt = s.Connections.Times(items[i].ID)
}
}
return items, e
}
func (s *Service) SaveDevice(ctx context.Context, id *uuid.UUID, d *model.Device) error {
d.Name = strings.TrimSpace(d.Name)
if d.Name == "" || len([]rune(d.Name)) > 128 {
return errors.New("设备名称长度必须为1~128")
}
if net.ParseIP(d.Host) == nil && !validHost(d.Host) {
return errors.New("无效的IP地址或域名")
}
if d.Port < 1 || d.Port > 65535 || d.ConnectTimeout < 1 || d.ConnectTimeout > 60 || d.ReconnectInterval < 1 || d.ReconnectInterval > 3600 {
return errors.New("连接参数超出范围")
}
factory, e := s.Registry.Get(d.ProtocolType)
if e != nil {
return e
}
var cfg map[string]any
if e = json.Unmarshal(d.ProtocolConfig, &cfg); e != nil {
return errors.New("protocol_config必须为对象")
}
if e = factory.ValidateConfig(cfg); e != nil {
return e
}
if id == nil {
return s.Store.CreateDevice(ctx, d)
}
d.ID = *id
e = s.Store.UpdateDevice(ctx, d)
if e == nil && s.Connections != nil {
s.Connections.Invalidate(d.ID)
}
return e
}
func validHost(h string) bool {
if len(h) < 1 || len(h) > 253 {
return false
}
for _, p := range strings.Split(h, ".") {
if len(p) < 1 || len(p) > 63 {
return false
}
for _, r := range p {
if !(r == '-' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
return false
}
}
}
return true
}
func (s *Service) DeleteDevice(ctx context.Context, id uuid.UUID) error {
e := s.Store.DeleteDevice(ctx, id)
if e == nil && s.Connections != nil {
s.Connections.Invalidate(id)
}
return e
}
func (s *Service) ListPoints(ctx context.Context, kind, keyword string) ([]pg.PointRow, error) {
items, e := s.Store.ListPoints(ctx, kind, keyword, false)
if e == nil && kind == "collection" && s.Collector != nil {
for i := range items {
items[i].LatestValue = s.Collector.Latest(items[i].ID)
}
}
return items, e
}
func (s *Service) ListLogs(ctx context.Context) ([]map[string]any, error) {
return s.Store.ListLogs(ctx)
}
func (s *Service) GetDevice(ctx context.Context, id uuid.UUID) (model.Device, error) {
d, e := s.Store.GetDevice(ctx, id)
if e == nil {
if d.Enabled && s.Connections != nil {
d.ConnectionStatus = s.Connections.Status(id)
d.LastOnlineAt, d.LastOfflineAt = s.Connections.Times(id)
} else {
d.ConnectionStatus = "disabled"
}
}
return d, e
}
func (s *Service) GetPoint(ctx context.Context, kind string, id uuid.UUID) (pg.PointRow, error) {
p, e := s.Store.GetPoint(ctx, kind, id)
if e == nil && kind == "collection" && s.Collector != nil {
p.LatestValue = s.Collector.Latest(id)
}
return p, e
}
func (s *Service) Groups(ctx context.Context) ([]map[string]any, error) { return s.Store.Groups(ctx) }
func (s *Service) CreateGroup(ctx context.Context, name string) error {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 64 {
return errors.New("分组名称长度必须为1~64")
}
return s.Store.CreateGroup(ctx, name)
}
func (s *Service) UpdateGroup(ctx context.Context, oldName, newName string) error {
newName = strings.TrimSpace(newName)
if newName == "" || len([]rune(newName)) > 64 {
return errors.New("分组名称长度必须为1~64")
}
return s.Store.UpdateGroup(ctx, oldName, newName)
}
func (s *Service) DeleteGroup(ctx context.Context, name string) error {
return s.Store.DeleteGroup(ctx, name)
}
func (s *Service) FindDeviceByName(ctx context.Context, name string) (model.Device, error) {
return s.Store.FindDeviceByName(ctx, name)
}
func (s *Service) FindPointByName(ctx context.Context, kind, name string) (pg.PointRow, error) {
return s.Store.FindPointByName(ctx, kind, name)
}
func (s *Service) GetRetention(ctx context.Context) (int, error) { return s.Store.GetRetention(ctx) }
func (s *Service) SetRetention(ctx context.Context, days int) error {
if days < 1 || days > 730 {
return errors.New("history_retention_days必须为1~730")
}
if e := s.TD.SetRetention(ctx, days); e != nil {
return fmt.Errorf("更新TDengine保留策略失败: %w", e)
}
return s.Store.SetRetention(ctx, days)
}
func (s *Service) SavePoint(ctx context.Context, kind string, p *pg.PointRow) error {
p.Name = strings.TrimSpace(p.Name)
p.GroupName = strings.TrimSpace(p.GroupName)
if p.Name == "" || len([]rune(p.Name)) > 128 {
return errors.New("点位名称长度必须为1~128")
}
if p.GroupName == "" {
p.GroupName = "default"
}
d, e := s.Store.GetDevice(ctx, p.DeviceID)
if e != nil {
return errors.New("所属设备不存在")
}
f, e := s.Registry.Get(d.ProtocolType)
if e != nil {
return e
}
if e = f.ValidateAddress(p.Address, protocol.DataType(p.DataType), kind == "write"); e != nil {
return e
}
if p.DataType != "BOOL" && p.DataType != "INT" && p.DataType != "REAL" {
return errors.New("data_type无效")
}
if kind == "collection" {
if p.CollectInterval < 1 {
return errors.New("collect_interval必须至少1秒")
}
if p.HistoryInterval < 1 || p.HistoryInterval > 1440 {
return errors.New("history_interval必须为1~1440")
}
} else if p.ReadbackTolerance < 0 || p.ReadbackTolerance > 1000000 {
return errors.New("readback_tolerance超出范围")
}
return s.Store.SavePoint(ctx, kind, p)
}
func (s *Service) DeletePoint(ctx context.Context, kind string, id uuid.UUID) error {
return s.Store.DeletePoint(ctx, kind, id)
}
func (s *Service) ExecuteWrite(ctx context.Context, id uuid.UUID, value any, reason *string) (map[string]any, int, error) {
p, e := s.Store.GetPoint(ctx, "write", id)
if e != nil {
return nil, 42004, e
}
numeric, e := typedNumber(p.DataType, value)
if e != nil {
return nil, 42001, e
}
if reason != nil && len([]rune(*reason)) > 500 {
return nil, 42001, errors.New("reason最多500字符")
}
result, e := s.Writer.Execute(ctx, p, numeric)
target := fmt.Sprintf("%v", value)
status := "success"
var readback, errorMessage *string
if e != nil {
status = "failed"
m := "设备写入或回读失败"
errorMessage = &m
} else {
r := fmt.Sprintf("%v", result.Readback)
readback = &r
}
logID, logErr := s.Store.InsertWriteLog(ctx, p, target, readback, status, errorMessage, reason)
if logErr != nil {
return nil, 51001, logErr
}
data := map[string]any{"write_log_id": logID, "point_name": p.Name, "data_type": p.DataType, "value": value, "readback_value": nil, "result": status, "ts": time.Now()}
if readback != nil {
data["readback_value"] = result.Readback
}
if e != nil {
data["error_message"] = *errorMessage
return data, 51001, e
}
return data, 0, nil
}
func typedNumber(dt string, v any) (float64, error) {
switch dt {
case "BOOL":
b, ok := v.(bool)
if !ok {
return 0, errors.New("BOOL只接受JSON boolean")
}
if b {
return 1, nil
}
return 0, nil
case "INT":
n, ok := v.(float64)
if !ok || math.Trunc(n) != n || n < -32768 || n > 32767 {
return 0, errors.New("INT只接受16位整数")
}
return n, nil
case "REAL":
n, ok := v.(float64)
if !ok {
return 0, errors.New("REAL只接受JSON number")
}
return n, nil
}
return 0, errors.New("数据类型无效")
}
func IsNotFound(e error) bool { return errors.Is(e, pgx.ErrNoRows) }