chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user