392 lines
10 KiB
Go
392 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// HistoryRow 历史数据行
|
|
type HistoryRow struct {
|
|
TS time.Time `json:"ts"`
|
|
Value float64 `json:"value"`
|
|
}
|
|
|
|
// BucketRow 聚合桶数据
|
|
type BucketRow struct {
|
|
Bucket time.Time `json:"bucket"`
|
|
Avg float64 `json:"avg"`
|
|
Min float64 `json:"min"`
|
|
Max float64 `json:"max"`
|
|
}
|
|
|
|
// IoTDBService IoTDB 时序数据库服务,通过 HTTP REST API 查询
|
|
type IoTDBService struct {
|
|
baseURL string
|
|
user string
|
|
password string
|
|
database string
|
|
enabled bool
|
|
available bool
|
|
mu sync.RWMutex
|
|
createdTS sync.Map // 已创建的时间序列缓存
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewIoTDBService 创建 IoTDB 服务
|
|
func NewIoTDBService(baseURL string) *IoTDBService {
|
|
return &IoTDBService{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
user: "root",
|
|
password: "root",
|
|
database: "root.silk",
|
|
enabled: true,
|
|
httpClient: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
}
|
|
|
|
// IsAvailable 返回 IoTDB 是否可用
|
|
func (s *IoTDBService) IsAvailable() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.enabled && s.available
|
|
}
|
|
|
|
// Init 初始化 IoTDB(创建数据库)
|
|
func (s *IoTDBService) Init() error {
|
|
_, err := s.nonQuery(fmt.Sprintf("CREATE DATABASE %s", s.database))
|
|
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already exist") {
|
|
s.mu.Lock()
|
|
s.available = false
|
|
s.mu.Unlock()
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
s.available = true
|
|
s.mu.Unlock()
|
|
slog.Info("IoTDB 连接成功", "url", s.baseURL, "database", s.database)
|
|
return nil
|
|
}
|
|
|
|
// InsertTelemetry 插入遥测数据
|
|
func (s *IoTDBService) InsertTelemetry(deviceKey, metric string, value float64, ts time.Time) bool {
|
|
if !s.IsAvailable() || deviceKey == "" || metric == "" {
|
|
return false
|
|
}
|
|
if err := s.ensureTimeseries(deviceKey, metric); err != nil {
|
|
slog.Warn("IoTDB ensureTimeseries 失败", "deviceKey", deviceKey, "metric", metric, "err", err)
|
|
s.markUnavailable()
|
|
return false
|
|
}
|
|
sql := fmt.Sprintf("INSERT INTO %s.%s(timestamp, %s) VALUES(%d, %g)",
|
|
s.database, s.devicePath(deviceKey), s.pathSegment(metric), ts.UnixMilli(), value)
|
|
if _, err := s.nonQuery(sql); err != nil {
|
|
slog.Warn("IoTDB insert 失败", "deviceKey", deviceKey, "metric", metric, "err", err)
|
|
s.markUnavailable()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// QueryHistory 查询历史数据
|
|
func (s *IoTDBService) QueryHistory(deviceKey, metric string, from, to time.Time, limit int) ([]HistoryRow, error) {
|
|
if !s.IsAvailable() {
|
|
return nil, fmt.Errorf("IoTDB unavailable")
|
|
}
|
|
if limit <= 0 || limit > 2000 {
|
|
limit = 2000
|
|
}
|
|
sql := fmt.Sprintf("SELECT %s FROM %s WHERE time >= %d AND time <= %d ORDER BY TIME DESC LIMIT %d",
|
|
s.pathSegment(metric), s.devicePath(deviceKey), from.UnixMilli(), to.UnixMilli(), limit)
|
|
resp, err := s.query(sql, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.parseHistoryRows(resp), nil
|
|
}
|
|
|
|
// ListMetrics 列出设备的所有指标
|
|
func (s *IoTDBService) ListMetrics(deviceKey string) ([]string, error) {
|
|
if !s.IsAvailable() {
|
|
return nil, fmt.Errorf("IoTDB unavailable")
|
|
}
|
|
sql := fmt.Sprintf("SHOW TIMESERIES %s.*", s.devicePath(deviceKey))
|
|
resp, err := s.query(sql, 2000)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.extractMetrics(resp), nil
|
|
}
|
|
|
|
// QueryLatestAny 查询设备最新一条遥测
|
|
func (s *IoTDBService) QueryLatestAny(deviceKey string) (*HistoryRow, error) {
|
|
if !s.IsAvailable() {
|
|
return nil, fmt.Errorf("IoTDB unavailable")
|
|
}
|
|
metrics, err := s.ListMetrics(deviceKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var picked *HistoryRow
|
|
for _, m := range metrics {
|
|
rows, err := s.QueryHistory(deviceKey, m, time.Time{}, time.Now(), 1)
|
|
if err != nil || len(rows) == 0 {
|
|
continue
|
|
}
|
|
if picked == nil || rows[0].TS.After(picked.TS) {
|
|
picked = &HistoryRow{TS: rows[0].TS, Value: rows[0].Value}
|
|
}
|
|
}
|
|
return picked, nil
|
|
}
|
|
|
|
// AggregateByBucket 按桶聚合
|
|
func (s *IoTDBService) AggregateByBucket(deviceKey, metric string, from, to time.Time, minutesBucket int) ([]BucketRow, error) {
|
|
if !s.IsAvailable() {
|
|
return nil, fmt.Errorf("IoTDB unavailable")
|
|
}
|
|
if minutesBucket <= 0 {
|
|
minutesBucket = 5
|
|
}
|
|
ms := s.pathSegment(metric)
|
|
sql := fmt.Sprintf("SELECT AVG(%s), MIN(%s), MAX(%s) FROM %s WHERE time >= %d AND time < %d GROUP BY ([%d, %d), %dm)",
|
|
ms, ms, ms, s.devicePath(deviceKey), from.UnixMilli(), to.UnixMilli(), from.UnixMilli(), to.UnixMilli(), minutesBucket)
|
|
resp, err := s.query(sql, 2000)
|
|
if err != nil {
|
|
slog.Warn("IoTDB aggregate 失败", "err", err)
|
|
return []BucketRow{}, nil
|
|
}
|
|
return s.parseAggregateRows(resp), nil
|
|
}
|
|
|
|
// --- 内部方法 ---
|
|
|
|
func (s *IoTDBService) markUnavailable() {
|
|
s.mu.Lock()
|
|
s.available = false
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *IoTDBService) ensureTimeseries(deviceKey, metric string) error {
|
|
path := s.timeseriesPath(deviceKey, metric)
|
|
if _, ok := s.createdTS.Load(path); ok {
|
|
return nil
|
|
}
|
|
sql := fmt.Sprintf("CREATE TIMESERIES %s WITH DATATYPE=DOUBLE, ENCODING=GORILLA, COMPRESSOR=LZ4", path)
|
|
_, err := s.nonQuery(sql)
|
|
if err != nil && strings.Contains(strings.ToLower(err.Error()), "already exist") {
|
|
s.createdTS.Store(path, true)
|
|
return nil
|
|
}
|
|
if err == nil {
|
|
s.createdTS.Store(path, true)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *IoTDBService) nonQuery(sql string) (map[string]interface{}, error) {
|
|
return s.request("POST", "/rest/v2/nonQuery", map[string]interface{}{"sql": sql})
|
|
}
|
|
|
|
func (s *IoTDBService) query(sql string, rowLimit int) (map[string]interface{}, error) {
|
|
if rowLimit <= 0 || rowLimit > 2000 {
|
|
rowLimit = 2000
|
|
}
|
|
return s.request("POST", "/rest/v2/query", map[string]interface{}{"sql": sql, "row_limit": rowLimit})
|
|
}
|
|
|
|
func (s *IoTDBService) request(method, endpoint string, body interface{}) (map[string]interface{}, error) {
|
|
url := s.baseURL + endpoint
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bodyReader = bytes.NewReader(data)
|
|
}
|
|
req, err := http.NewRequest(method, url, bodyReader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.SetBasicAuth(s.user, s.password)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("IoTDB HTTP %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
trimmed := strings.TrimSpace(string(data))
|
|
if trimmed == "" {
|
|
return map[string]interface{}{"code": float64(resp.StatusCode)}, nil
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, fmt.Errorf("IoTDB 响应解析失败: %w, body=%s", err, trimmed)
|
|
}
|
|
if code, ok := result["code"].(float64); ok && code != 0 && code != 200 {
|
|
msg, _ := result["message"].(string)
|
|
if msg == "" {
|
|
msg, _ = result["desc"].(string)
|
|
}
|
|
return nil, fmt.Errorf("IoTDB error code=%v: %s", code, msg)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *IoTDBService) parseHistoryRows(resp map[string]interface{}) []HistoryRow {
|
|
var rows []HistoryRow
|
|
// 格式1: {timestamps: [...], values: [[...]]}
|
|
if timestamps, ok := resp["timestamps"].([]interface{}); ok {
|
|
if values, ok := resp["values"].([]interface{}); ok && len(values) > 0 {
|
|
if firstValues, ok := values[0].([]interface{}); ok {
|
|
for i, ts := range timestamps {
|
|
if i < len(firstValues) {
|
|
v := toFloat(firstValues[i])
|
|
if !isNaN(v) {
|
|
rows = append(rows, HistoryRow{TS: toTime(ts), Value: v})
|
|
}
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
}
|
|
}
|
|
// 格式2: {data: [[...]]}
|
|
if data, ok := resp["data"].([]interface{}); ok {
|
|
for _, row := range data {
|
|
if r, ok := row.([]interface{}); ok && len(r) >= 2 {
|
|
v := toFloat(r[1])
|
|
if !isNaN(v) {
|
|
rows = append(rows, HistoryRow{TS: toTime(r[0]), Value: v})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (s *IoTDBService) parseAggregateRows(resp map[string]interface{}) []BucketRow {
|
|
var rows []BucketRow
|
|
if timestamps, ok := resp["timestamps"].([]interface{}); ok {
|
|
if values, ok := resp["values"].([]interface{}); ok && len(values) >= 3 {
|
|
avgs, _ := values[0].([]interface{})
|
|
mins, _ := values[1].([]interface{})
|
|
maxs, _ := values[2].([]interface{})
|
|
for i, ts := range timestamps {
|
|
row := BucketRow{Bucket: toTime(ts)}
|
|
if i < len(avgs) {
|
|
row.Avg = toFloat(avgs[i])
|
|
}
|
|
if i < len(mins) {
|
|
row.Min = toFloat(mins[i])
|
|
}
|
|
if i < len(maxs) {
|
|
row.Max = toFloat(maxs[i])
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
}
|
|
if data, ok := resp["data"].([]interface{}); ok {
|
|
for _, row := range data {
|
|
if r, ok := row.([]interface{}); ok && len(r) >= 4 {
|
|
rows = append(rows, BucketRow{
|
|
Bucket: toTime(r[0]), Avg: toFloat(r[1]), Min: toFloat(r[2]), Max: toFloat(r[3]),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (s *IoTDBService) extractMetrics(resp map[string]interface{}) []string {
|
|
prefix := s.database + ".telemetry."
|
|
seen := make(map[string]bool)
|
|
var metrics []string
|
|
extract := func(cell interface{}) {
|
|
str, ok := cell.(string)
|
|
if !ok || !strings.HasPrefix(str, prefix) {
|
|
return
|
|
}
|
|
// 提取最后一部分(反引号内的内容)作为 metric
|
|
parts := strings.Split(str, ".")
|
|
if len(parts) > 0 {
|
|
m := strings.Trim(parts[len(parts)-1], "`")
|
|
if m != "" && !seen[m] {
|
|
seen[m] = true
|
|
metrics = append(metrics, m)
|
|
}
|
|
}
|
|
}
|
|
if data, ok := resp["data"].([]interface{}); ok {
|
|
for _, row := range data {
|
|
if r, ok := row.([]interface{}); ok {
|
|
for _, cell := range r {
|
|
extract(cell)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return metrics
|
|
}
|
|
|
|
func (s *IoTDBService) devicePath(deviceKey string) string {
|
|
return fmt.Sprintf("%s.telemetry.%s", s.database, s.pathSegment(deviceKey))
|
|
}
|
|
|
|
func (s *IoTDBService) timeseriesPath(deviceKey, metric string) string {
|
|
return fmt.Sprintf("%s.%s", s.devicePath(deviceKey), s.pathSegment(metric))
|
|
}
|
|
|
|
func (s *IoTDBService) pathSegment(str string) string {
|
|
return "`" + strings.ReplaceAll(str, "`", "``") + "`"
|
|
}
|
|
|
|
// --- 辅助函数 ---
|
|
|
|
func toTime(v interface{}) time.Time {
|
|
switch t := v.(type) {
|
|
case float64:
|
|
return time.UnixMilli(int64(t))
|
|
case string:
|
|
if n, err := strconv.ParseInt(t, 10, 64); err == nil {
|
|
return time.UnixMilli(n)
|
|
}
|
|
if parsed, err := time.Parse(time.RFC3339, t); err == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
func toFloat(v interface{}) float64 {
|
|
switch f := v.(type) {
|
|
case float64:
|
|
return f
|
|
case string:
|
|
n, _ := strconv.ParseFloat(f, 64)
|
|
return n
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isNaN(v float64) bool { return v != v }
|