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 }
|
||||
@@ -0,0 +1,723 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
)
|
||||
|
||||
// GbPlayResult GB28181 播放结果
|
||||
type GbPlayResult struct {
|
||||
DeviceID string
|
||||
ChannelID string
|
||||
HLS string
|
||||
FLV string
|
||||
WsFlv string
|
||||
Fmp4 string
|
||||
Rtsp string
|
||||
WebRtc string
|
||||
}
|
||||
|
||||
// MediaService WVP/ZLMediaKit 媒体服务代理
|
||||
type MediaService struct {
|
||||
cfg *config.Config
|
||||
httpClient *http.Client
|
||||
cachedToken string
|
||||
tokenExpiresAt time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewMediaService 根据配置创建媒体服务
|
||||
func NewMediaService(cfg *config.Config) *MediaService {
|
||||
return &MediaService{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// wvpAPIBase 返回去掉末尾斜杠的 WVP API 基地址
|
||||
func (m *MediaService) wvpAPIBase() string {
|
||||
return strings.TrimSuffix(m.cfg.WVPAPIBase, "/")
|
||||
}
|
||||
|
||||
// zlmAPIBase 返回去掉末尾斜杠的 ZLMediaKit API 基地址
|
||||
func (m *MediaService) zlmAPIBase() string {
|
||||
return strings.TrimSuffix(m.cfg.ZLMAPIBase, "/")
|
||||
}
|
||||
|
||||
// login 登录 WVP API 并缓存 token(密码需 MD5),缓存 55 分钟
|
||||
func (m *MediaService) login(ctx context.Context) (string, error) {
|
||||
md5Password := md5.Sum([]byte(m.cfg.WVPPassword))
|
||||
md5Hex := hex.EncodeToString(md5Password[:])
|
||||
|
||||
loginURL := fmt.Sprintf("%s/api/user/login?username=%s&password=%s",
|
||||
m.wvpAPIBase(),
|
||||
m.cfg.WVPUsername,
|
||||
md5Hex,
|
||||
)
|
||||
|
||||
slog.Info("登录 WVP API", "username", m.cfg.WVPUsername)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 WVP 登录请求失败: %w", err)
|
||||
}
|
||||
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("WVP 登录请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("WVP 登录失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return "", fmt.Errorf("解析 WVP 登录响应失败: %w", err)
|
||||
}
|
||||
if body.Code != 0 || body.Data.AccessToken == "" {
|
||||
return "", fmt.Errorf("WVP 登录错误: %s", body.Msg)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.cachedToken = body.Data.AccessToken
|
||||
m.tokenExpiresAt = time.Now().Add(55 * time.Minute)
|
||||
m.mu.Unlock()
|
||||
|
||||
slog.Info("WVP 登录成功,token 已缓存")
|
||||
return body.Data.AccessToken, nil
|
||||
}
|
||||
|
||||
// getAuthToken 获取有效的认证 token,必要时重新登录
|
||||
func (m *MediaService) getAuthToken(ctx context.Context) (string, error) {
|
||||
m.mu.Lock()
|
||||
if m.cachedToken != "" && time.Now().Before(m.tokenExpiresAt) {
|
||||
token := m.cachedToken
|
||||
m.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return m.login(ctx)
|
||||
}
|
||||
|
||||
// invalidateToken 使缓存的 token 失效
|
||||
func (m *MediaService) invalidateToken() {
|
||||
m.mu.Lock()
|
||||
m.cachedToken = ""
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// StartPlay 开始播放 GB28181 设备实时流,GET /api/play/start/{deviceId}/{channelId}
|
||||
func (m *MediaService) StartPlay(deviceId, channelId string) (*GbPlayResult, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/api/play/start/%s/%s", m.wvpAPIBase(), deviceId, channelId)
|
||||
slog.Info("调用 WVP play/start", "url", url)
|
||||
|
||||
body, err := m.doWVPRequest(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.extractPlayResult(body, deviceId, channelId), nil
|
||||
}
|
||||
|
||||
// StopPlay 停止播放 GB28181 设备实时流,GET /api/play/stop/{deviceId}/{channelId}
|
||||
func (m *MediaService) StopPlay(deviceId, channelId string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/api/play/stop/%s/%s", m.wvpAPIBase(), deviceId, channelId)
|
||||
token, err := m.getAuthToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("access-token", token)
|
||||
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("停止播放失败", "deviceId", deviceId, "channelId", channelId, "error", err)
|
||||
return nil
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartPlayback 开始回放 GB28181 设备历史流,GET /api/playback/start/{deviceId}/{channelId}?startTime=...&endTime=...
|
||||
func (m *MediaService) StartPlayback(deviceId, channelId, startTime, endTime string) (*GbPlayResult, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/api/playback/start/%s/%s?startTime=%s&endTime=%s",
|
||||
m.wvpAPIBase(), deviceId, channelId,
|
||||
startTime, endTime,
|
||||
)
|
||||
slog.Info("调用 WVP playback/start", "url", url)
|
||||
|
||||
body, err := m.doWVPRequest(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.extractPlayResult(body, deviceId, channelId), nil
|
||||
}
|
||||
|
||||
// doWVPRequest 执行 WVP API 请求(token 过期时自动重试一次)
|
||||
func (m *MediaService) doWVPRequest(ctx context.Context, reqURL string) (map[string]interface{}, error) {
|
||||
token, err := m.getAuthToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := m.sendWVPRequest(ctx, reqURL, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// token 过期,重试一次
|
||||
code, ok := body["code"].(float64)
|
||||
if ok && int(code) == 401 {
|
||||
m.invalidateToken()
|
||||
newToken, err := m.getAuthToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err = m.sendWVPRequest(ctx, reqURL, newToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
code, ok = body["code"].(float64)
|
||||
if !ok || int(code) != 0 {
|
||||
msg, _ := body["msg"].(string)
|
||||
if msg == "" {
|
||||
msg, _ = body["message"].(string)
|
||||
}
|
||||
return nil, fmt.Errorf("WVP API 错误: %s", msg)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// sendWVPRequest 发送带 access-token 的 GET 请求
|
||||
func (m *MediaService) sendWVPRequest(ctx context.Context, reqURL, token string) (map[string]interface{}, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("access-token", token)
|
||||
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("流媒体服务暂时不可用: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("WVP API 返回 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("解析 WVP 响应失败: %w", err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// extractPlayResult 从 WVP 响应中提取播放地址,并将 ZLM 的 :80/ 替换为 :8081/
|
||||
func (m *MediaService) extractPlayResult(body map[string]interface{}, deviceId, channelId string) *GbPlayResult {
|
||||
data, _ := body["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
return &GbPlayResult{
|
||||
DeviceID: deviceId,
|
||||
ChannelID: channelId,
|
||||
HLS: fixZlmPort(getString(data, "hls")),
|
||||
FLV: fixZlmPort(getString(data, "flv")),
|
||||
WsFlv: fixZlmPort(getString(data, "ws_flv")),
|
||||
Fmp4: fixZlmPort(getString(data, "fmp4")),
|
||||
Rtsp: getString(data, "rtsp"),
|
||||
WebRtc: fixZlmPort(getString(data, "webRtc")),
|
||||
}
|
||||
}
|
||||
|
||||
// fixZlmPort 将 ZLMediaKit 容器内地址替换为前端可通过 server.cjs 代理访问的相对路径。
|
||||
// ZLM 返回的 URL 形如 http://100.83.103.1:80/rtp/xxx.live.flv?...,
|
||||
// 改为相对路径 /rtp/xxx.live.flv?... 后,前端浏览器会以同源请求走 server.cjs 代理,
|
||||
// 避免 CORS / CSP 限制(server.cjs 已内置 /rtp/ 到 127.0.0.1:8081 的代理)。
|
||||
func fixZlmPort(u string) string {
|
||||
if u == "" {
|
||||
return u
|
||||
}
|
||||
// 提取 /rtp/ 起始的相对路径(含 query string)
|
||||
if idx := strings.Index(u, "/rtp/"); idx >= 0 {
|
||||
return u[idx:]
|
||||
}
|
||||
// 兜底:替换 Docker 内部主机名为外部可访问 IP
|
||||
u = strings.Replace(u, "polaris-media:", "100.83.103.1:", 1)
|
||||
u = strings.Replace(u, ":80/", ":8081/", 1)
|
||||
if strings.HasSuffix(u, ":80") {
|
||||
u = u[:len(u)-2] + ":8081"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// getString 从 map 中安全取字符串
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- ZLMediaKit 录制控制 ---
|
||||
|
||||
// StartRecord 开始录制(type=1 for MP4)
|
||||
func (m *MediaService) StartRecord(app, stream string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/index/api/startRecord?type=1&vhost=__defaultVhost__&app=%s&stream=%s&secret=%s",
|
||||
m.zlmAPIBase(), app, stream, m.cfg.ZLMSecret,
|
||||
)
|
||||
slog.Info("ZLMediaKit startRecord", "app", app, "stream", stream)
|
||||
|
||||
return m.zlmGet(ctx, url, "startRecord")
|
||||
}
|
||||
|
||||
// StopRecord 停止录制
|
||||
func (m *MediaService) StopRecord(app, stream string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/index/api/stopRecord?type=1&vhost=__defaultVhost__&app=%s&stream=%s&secret=%s",
|
||||
m.zlmAPIBase(), app, stream, m.cfg.ZLMSecret,
|
||||
)
|
||||
slog.Info("ZLMediaKit stopRecord", "app", app, "stream", stream)
|
||||
|
||||
return m.zlmGet(ctx, url, "stopRecord")
|
||||
}
|
||||
|
||||
// IsRecording 检查是否正在录制
|
||||
func (m *MediaService) IsRecording(app, stream string) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/index/api/isRecording?type=1&vhost=__defaultVhost__&app=%s&stream=%s&secret=%s",
|
||||
m.zlmAPIBase(), app, stream, m.cfg.ZLMSecret,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("ZLM isRecording 请求失败", "error", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Code int `json:"code"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return false
|
||||
}
|
||||
return body.Code == 0 && body.Data == true
|
||||
}
|
||||
|
||||
// zlmGet 执行 ZLMediaKit GET 请求并检查 code==0
|
||||
func (m *MediaService) zlmGet(ctx context.Context, url, action string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 %s 请求失败: %w", action, err)
|
||||
}
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("流媒体录制服务暂时不可用: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("ZLM %s 失败: HTTP %d", action, resp.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return fmt.Errorf("解析 ZLM %s 响应失败: %w", action, err)
|
||||
}
|
||||
if body.Code != 0 {
|
||||
return fmt.Errorf("ZLM %s 错误: %s", action, body.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WvpDeviceInfo WVP 设备同步信息(silk 与 WVP 共有参数)
|
||||
type WvpDeviceInfo struct {
|
||||
Name string
|
||||
Manufacturer string
|
||||
Transport string
|
||||
StreamMode string
|
||||
Password string
|
||||
OnLine bool
|
||||
}
|
||||
|
||||
// SyncWvpDevices 查询 WVP 设备列表,返回 deviceId -> 设备信息映射(用于 WVP → silk 同步)
|
||||
func (m *MediaService) SyncWvpDevices() (map[string]*WvpDeviceInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
u := fmt.Sprintf("%s/api/device/query/devices?page=1&count=100", m.wvpAPIBase())
|
||||
body, err := m.doWVPRequest(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]*WvpDeviceInfo)
|
||||
if data, ok := body["data"].(map[string]interface{}); ok {
|
||||
if devices, ok := data["list"].([]interface{}); ok {
|
||||
for _, d := range devices {
|
||||
if dev, ok := d.(map[string]interface{}); ok {
|
||||
deviceId, _ := dev["deviceId"].(string)
|
||||
if deviceId == "" {
|
||||
continue
|
||||
}
|
||||
info := &WvpDeviceInfo{
|
||||
Name: getString(dev, "name"),
|
||||
Manufacturer: getString(dev, "manufacturer"),
|
||||
Transport: getString(dev, "transport"),
|
||||
StreamMode: getString(dev, "streamMode"),
|
||||
Password: getString(dev, "password"),
|
||||
}
|
||||
if onLine, ok := dev["onLine"].(bool); ok {
|
||||
info.OnLine = onLine
|
||||
}
|
||||
result[deviceId] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetServerConfig 查询 WVP 服务器配置(含 SIP 参数),供前端展示
|
||||
func (m *MediaService) GetServerConfig() (map[string]interface{}, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/api/server/config", m.wvpAPIBase())
|
||||
body, err := m.doWVPRequest(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if data, ok := body["data"].(map[string]interface{}); ok {
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("WVP 配置响应格式异常")
|
||||
}
|
||||
|
||||
// WvpDevice WVP 设备信息
|
||||
type WvpDevice struct {
|
||||
ID int `json:"id"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Name string `json:"name"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
OnLine bool `json:"onLine"`
|
||||
Transport string `json:"transport"`
|
||||
StreamMode string `json:"streamMode"`
|
||||
HostAddress string `json:"hostAddress"`
|
||||
}
|
||||
|
||||
// WvpChannel WVP 通道信息
|
||||
type WvpChannel struct {
|
||||
ID int `json:"id"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
ChannelID string `json:"channelId"`
|
||||
Name string `json:"name"`
|
||||
OnLine bool `json:"onLine"`
|
||||
}
|
||||
|
||||
// ListWvpDevices 查询 WVP 已注册设备列表
|
||||
func (m *MediaService) ListWvpDevices(query string) ([]WvpDevice, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
u := fmt.Sprintf("%s/api/device/query/devices?page=1&count=100", m.wvpAPIBase())
|
||||
if query != "" {
|
||||
u += "&query=" + url.QueryEscape(query)
|
||||
}
|
||||
|
||||
body, err := m.doWVPRequest(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var devices []WvpDevice
|
||||
if data, ok := body["data"].(map[string]interface{}); ok {
|
||||
if list, ok := data["list"].([]interface{}); ok {
|
||||
for _, d := range list {
|
||||
if dev, ok := d.(map[string]interface{}); ok {
|
||||
device := WvpDevice{
|
||||
DeviceID: getString(dev, "deviceId"),
|
||||
Name: getString(dev, "name"),
|
||||
Manufacturer: getString(dev, "manufacturer"),
|
||||
Model: getString(dev, "model"),
|
||||
Transport: getString(dev, "transport"),
|
||||
StreamMode: getString(dev, "streamMode"),
|
||||
HostAddress: getString(dev, "hostAddress"),
|
||||
}
|
||||
if onLine, ok := dev["onLine"].(bool); ok {
|
||||
device.OnLine = onLine
|
||||
}
|
||||
if id, ok := dev["id"].(float64); ok {
|
||||
device.ID = int(id)
|
||||
}
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// ListWvpChannels 查询 WVP 设备的通道列表
|
||||
func (m *MediaService) ListWvpChannels(deviceId string) ([]WvpChannel, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
u := fmt.Sprintf("%s/api/device/query/devices/%s/channels?page=1&count=100", m.wvpAPIBase(), url.PathEscape(deviceId))
|
||||
body, err := m.doWVPRequest(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var channels []WvpChannel
|
||||
if data, ok := body["data"].(map[string]interface{}); ok {
|
||||
if list, ok := data["list"].([]interface{}); ok {
|
||||
for _, c := range list {
|
||||
if ch, ok := c.(map[string]interface{}); ok {
|
||||
channel := WvpChannel{
|
||||
DeviceID: getString(ch, "deviceId"),
|
||||
ChannelID: getString(ch, "channelId"),
|
||||
Name: getString(ch, "name"),
|
||||
}
|
||||
if onLine, ok := ch["onLine"].(bool); ok {
|
||||
channel.OnLine = onLine
|
||||
}
|
||||
if id, ok := ch["id"].(float64); ok {
|
||||
channel.ID = int(id)
|
||||
}
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
// SyncWvpDevice 触发 WVP 设备通道同步
|
||||
func (m *MediaService) SyncWvpDevice(deviceId string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
u := fmt.Sprintf("%s/api/device/query/devices/%s/sync", m.wvpAPIBase(), url.PathEscape(deviceId))
|
||||
_, err := m.doWVPRequest(ctx, u)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateWvpDevice 同步设备信息到 WVP(silk → WVP,共有可编辑参数:name、manufacturer、password)
|
||||
func (m *MediaService) UpdateWvpDevice(deviceId string, name, manufacturer, password string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 先查询设备获取 WVP 内部 ID
|
||||
queryURL := fmt.Sprintf("%s/api/device/query/devices/%s", m.wvpAPIBase(), url.PathEscape(deviceId))
|
||||
body, err := m.doWVPRequest(ctx, queryURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
devData, ok := body["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("设备尚未在 WVP 注册,请在摄像头硬件配置设备ID后等待注册完成")
|
||||
}
|
||||
devID, ok := devData["id"].(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("WVP 设备 ID 未找到")
|
||||
}
|
||||
|
||||
// POST 更新设备信息(WVP API 仅接受 name/manufacturer/model 等可编辑字段)
|
||||
updateURL := fmt.Sprintf("%s/api/device/query/device/update", m.wvpAPIBase())
|
||||
token, err := m.getAuthToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"id": int(devID),
|
||||
"deviceId": deviceId, // WVP API 要求 deviceId 字段必填
|
||||
}
|
||||
if name != "" {
|
||||
updates["name"] = name
|
||||
}
|
||||
if manufacturer != "" {
|
||||
updates["manufacturer"] = manufacturer
|
||||
}
|
||||
if password != "" {
|
||||
updates["password"] = password
|
||||
}
|
||||
|
||||
updateBody, _ := json.Marshal(updates)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, updateURL, strings.NewReader(string(updateBody)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("access-token", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("WVP 更新请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("WVP 更新失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 检查 WVP 响应体中的 code 字段
|
||||
var respBody struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
||||
return fmt.Errorf("解析 WVP 更新响应失败: %w", err)
|
||||
}
|
||||
if respBody.Code != 0 {
|
||||
return fmt.Errorf("WVP 更新失败: %s", respBody.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddWvpDevice 预先添加设备到 WVP(在摄像头注册前,设置独立密码等参数)
|
||||
func (m *MediaService) AddWvpDevice(deviceId, name, manufacturer, password string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token, err := m.getAuthToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addURL := fmt.Sprintf("%s/api/device/query/device/add", m.wvpAPIBase())
|
||||
payload := map[string]interface{}{
|
||||
"deviceId": deviceId,
|
||||
}
|
||||
if name != "" {
|
||||
payload["name"] = name
|
||||
}
|
||||
if manufacturer != "" {
|
||||
payload["manufacturer"] = manufacturer
|
||||
}
|
||||
if password != "" {
|
||||
payload["password"] = password
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, addURL, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("access-token", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("WVP 添加设备请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var respBody struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
||||
return fmt.Errorf("解析 WVP 添加设备响应失败: %w", err)
|
||||
}
|
||||
if respBody.Code != 0 {
|
||||
return fmt.Errorf("WVP 添加设备失败: %s", respBody.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWvpDevice 从 WVP 删除设备(silk 删除摄像头时同步删除 WVP 中预添加的设备)
|
||||
func (m *MediaService) DeleteWvpDevice(deviceId string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token, err := m.getAuthToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("%s/api/device/query/devices/%s/delete", m.wvpAPIBase(), url.PathEscape(deviceId))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("access-token", token)
|
||||
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("WVP 删除设备请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var respBody struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
||||
return fmt.Errorf("解析 WVP 删除设备响应失败: %w", err)
|
||||
}
|
||||
if respBody.Code != 0 {
|
||||
return fmt.Errorf("WVP 删除设备失败: %s", respBody.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
)
|
||||
|
||||
// AlarmEvent 告警事件
|
||||
type AlarmEvent struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
DeviceKey string `json:"deviceKey,omitempty"`
|
||||
Metric string `json:"metric,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
ThresholdMin float64 `json:"thresholdMin,omitempty"`
|
||||
ThresholdMax float64 `json:"thresholdMax,omitempty"`
|
||||
}
|
||||
|
||||
// TelemetryEvent 遥测事件
|
||||
type TelemetryEvent struct {
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
Metric string `json:"metric"`
|
||||
Value float64 `json:"value"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// EventHub 事件广播接口,由 ws 包实现
|
||||
type EventHub interface {
|
||||
BroadcastTelemetry(deviceKey string, data interface{})
|
||||
BroadcastAlarm(event AlarmEvent)
|
||||
BroadcastDeviceStatus(deviceKey string, status string)
|
||||
}
|
||||
|
||||
// IROperationResult 红外操作结果
|
||||
type IROperationResult struct {
|
||||
Action string `json:"action"` // learn, emit, learnCancel, erase
|
||||
Success bool `json:"success"` // 操作是否成功
|
||||
No int `json:"no"` // 红外码编号(learn/emit 时有值)
|
||||
Timestamp time.Time `json:"timestamp"` // 操作时间
|
||||
}
|
||||
|
||||
// MQTTService MQTT 消息处理服务
|
||||
type MQTTService struct {
|
||||
client mqtt.Client
|
||||
db *gorm.DB
|
||||
iotdb *IoTDBService
|
||||
hub EventHub
|
||||
topic string
|
||||
prefix string // 下行命令主题前缀
|
||||
suffix string // 下行命令主题后缀
|
||||
coolDown map[string]int64 // 告警冷却(内存防抖)
|
||||
deviceTopics map[string]string // deviceKey -> 上行主题(用于推导下行主题)
|
||||
irResults map[string]*IROperationResult // deviceKey -> 最新红外操作结果
|
||||
irLearnedCodes map[string]map[int]bool // deviceKey -> 已学习的红外码编号集合
|
||||
irHeartbeat map[string]time.Time // deviceKey -> 红外设备上次心跳发送时间
|
||||
pendingLearnNo map[string]int // deviceKey -> 待学习编号(设备成功响应时 no=0,需用此映射还原)
|
||||
}
|
||||
|
||||
// isIRController 判断设备是否为红外控制器(GSCU1B-4G,无定时上报,需主动心跳)
|
||||
func isIRController(d *model.Device) bool {
|
||||
if d.Model != nil && *d.Model == "GSCU1B-4G" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(d.Name, "红外")
|
||||
}
|
||||
|
||||
// NewMQTTService 创建 MQTT 服务
|
||||
func NewMQTTService(mqttURL string, db *gorm.DB, iotdb *IoTDBService, hub EventHub) *MQTTService {
|
||||
s := &MQTTService{
|
||||
db: db,
|
||||
iotdb: iotdb,
|
||||
hub: hub,
|
||||
topic: "silk/+/+/+/up/telemetry",
|
||||
prefix: "silk",
|
||||
suffix: "down/cmd",
|
||||
coolDown: make(map[string]int64),
|
||||
deviceTopics: make(map[string]string),
|
||||
irResults: make(map[string]*IROperationResult),
|
||||
irLearnedCodes: make(map[string]map[int]bool),
|
||||
irHeartbeat: make(map[string]time.Time),
|
||||
pendingLearnNo: make(map[string]int),
|
||||
}
|
||||
opts := mqtt.NewClientOptions()
|
||||
opts.AddBroker(mqttURL)
|
||||
opts.SetClientID("silk-server-go")
|
||||
opts.SetAutoReconnect(true)
|
||||
opts.OnConnect = func(c mqtt.Client) {
|
||||
slog.Info("MQTT 已连接", "url", mqttURL)
|
||||
// 订阅上行遥测主题(标准方向:设备 -> 后端)
|
||||
if token := c.Subscribe(s.topic, 0, s.handleMessage); token.Wait() && token.Error() != nil {
|
||||
slog.Warn("MQTT 订阅失败", "topic", s.topic, "err", token.Error())
|
||||
} else {
|
||||
slog.Info("MQTT 已订阅", "topic", s.topic)
|
||||
}
|
||||
// 也订阅 down/cmd 主题(部分设备 publish/subscribe 方向反配)
|
||||
topic2 := "silk/+/+/+/down/cmd"
|
||||
if token := c.Subscribe(topic2, 0, s.handleMessage); token.Wait() && token.Error() != nil {
|
||||
slog.Warn("MQTT 订阅失败", "topic", topic2, "err", token.Error())
|
||||
} else {
|
||||
slog.Info("MQTT 已订阅", "topic", topic2)
|
||||
}
|
||||
}
|
||||
opts.OnConnectionLost = func(c mqtt.Client, err error) {
|
||||
slog.Warn("MQTT 连接断开", "err", err)
|
||||
}
|
||||
s.client = mqtt.NewClient(opts)
|
||||
return s
|
||||
}
|
||||
|
||||
// Start 启动 MQTT 服务
|
||||
func (s *MQTTService) Start() error {
|
||||
if token := s.client.Connect(); token.Wait() && token.Error() != nil {
|
||||
return token.Error()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOfflineChecker 启动设备离线检测定时任务
|
||||
func (s *MQTTService) StartOfflineChecker(timeout time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
s.checkOffline(timeout)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// checkOffline 检查离线设备并更新状态
|
||||
// 红外控制器(GSCU1B-4G)无定时上报功能,采用主动心跳:
|
||||
// - 超时 5 分钟未收到数据 -> 下发 info 命令(不标记离线)
|
||||
// - 心跳后 5 分钟仍未收到响应 -> 标记离线
|
||||
func (s *MQTTService) checkOffline(timeout time.Duration) {
|
||||
cutoff := time.Now().Add(-timeout)
|
||||
var devices []model.Device
|
||||
s.db.Where("online_status = ? AND last_seen IS NOT NULL AND last_seen < ?", "online", cutoff).Find(&devices)
|
||||
for _, d := range devices {
|
||||
// 红外控制器:先下发心跳,给宽限期等待响应
|
||||
if isIRController(&d) {
|
||||
lastHB, ok := s.irHeartbeat[d.DeviceKey]
|
||||
// 未发过心跳 或 距上次心跳超过阈值 -> 下发 info
|
||||
if !ok || time.Since(lastHB) >= timeout {
|
||||
err := s.PublishToDevice(d.DeviceKey, map[string]interface{}{
|
||||
"type": "info",
|
||||
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
})
|
||||
s.irHeartbeat[d.DeviceKey] = time.Now()
|
||||
if err != nil {
|
||||
slog.Warn("红外设备心跳发送失败", "deviceKey", d.DeviceKey, "err", err)
|
||||
} else {
|
||||
slog.Info("红外设备心跳", "deviceKey", d.DeviceKey, "action", "发送 info 查询")
|
||||
}
|
||||
continue // 不标记离线,等待响应
|
||||
}
|
||||
// 已发过心跳且距上次心跳超过阈值仍未收到响应 -> 标记离线
|
||||
slog.Info("红外设备心跳超时,标记离线", "deviceKey", d.DeviceKey, "lastSeen", d.LastSeen, "lastHeartbeat", lastHB)
|
||||
delete(s.irHeartbeat, d.DeviceKey) // 清理心跳记录
|
||||
} else {
|
||||
slog.Info("设备离线", "deviceKey", d.DeviceKey, "lastSeen", d.LastSeen)
|
||||
}
|
||||
s.db.Model(&model.Device{}).Where("id = ?", d.ID).Update("online_status", "offline")
|
||||
if s.hub != nil {
|
||||
s.hub.BroadcastDeviceStatus(d.DeviceKey, "offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止 MQTT 服务
|
||||
func (s *MQTTService) Stop() {
|
||||
if s.client.IsConnected() {
|
||||
s.client.Unsubscribe(s.topic, "silk/+/+/+/down/cmd")
|
||||
s.client.Disconnect(500)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish 发布消息(用于控制命令下发)
|
||||
func (s *MQTTService) Publish(topic string, payload interface{}) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token := s.client.Publish(topic, 1, false, body)
|
||||
token.Wait()
|
||||
return token.Error()
|
||||
}
|
||||
|
||||
// TopicOf 生成设备下行命令主题
|
||||
func (s *MQTTService) TopicOf(deviceKey string) string {
|
||||
return fmt.Sprintf("%s/%s/%s", s.prefix, deviceKey, s.suffix)
|
||||
}
|
||||
|
||||
// GetIRResult 获取设备最新的红外操作结果
|
||||
func (s *MQTTService) GetIRResult(deviceKey string) interface{} {
|
||||
return s.irResults[deviceKey]
|
||||
}
|
||||
|
||||
// GetIRLearnedCodes 获取设备已学习的红外码编号列表
|
||||
func (s *MQTTService) GetIRLearnedCodes(deviceKey string) []int {
|
||||
codes := s.irLearnedCodes[deviceKey]
|
||||
if codes == nil {
|
||||
return []int{}
|
||||
}
|
||||
result := make([]int, 0, len(codes))
|
||||
for no := range codes {
|
||||
result = append(result, no)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// PublishToDevice 向设备发送命令(自动推导下行主题)
|
||||
// deviceKey 用于查找设备的上行主题,根据主题方向推导命令主题
|
||||
func (s *MQTTService) PublishToDevice(deviceKey string, payload interface{}) error {
|
||||
// 检测 learn 命令时记录待学习编号(设备成功响应时 no=0,需用此映射还原)
|
||||
if data, ok := payload.(map[string]interface{}); ok {
|
||||
if t, _ := data["type"].(string); t == "infrared" {
|
||||
if action, _ := data["action"].(string); action == "learn" {
|
||||
if d, ok := data["data"].(map[string]int); ok {
|
||||
if no, ok := d["no"]; ok && no > 0 {
|
||||
s.pendingLearnNo[deviceKey] = no
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
topic, ok := s.deviceTopics[deviceKey]
|
||||
if !ok {
|
||||
// 内存未命中,从数据库查持久化的主题
|
||||
var device model.Device
|
||||
if err := s.db.Select("topic").Where("device_key = ?", deviceKey).First(&device).Error; err == nil {
|
||||
if device.Topic != nil && *device.Topic != "" {
|
||||
topic = *device.Topic
|
||||
s.deviceTopics[deviceKey] = topic // 缓存到内存
|
||||
}
|
||||
}
|
||||
}
|
||||
if topic == "" {
|
||||
return fmt.Errorf("设备 %s 的主题未知,等待设备上报后再发送命令", deviceKey)
|
||||
}
|
||||
// 根据设备上报主题推导命令主题
|
||||
// 设备上报 /up/telemetry -> 命令发送到 /down/cmd
|
||||
// 设备上报 /down/cmd -> 命令发送到 /up/telemetry(反配设备)
|
||||
var downlinkTopic string
|
||||
if strings.HasSuffix(topic, "/up/telemetry") {
|
||||
downlinkTopic = strings.Replace(topic, "/up/telemetry", "/down/cmd", 1)
|
||||
} else if strings.HasSuffix(topic, "/down/cmd") {
|
||||
downlinkTopic = strings.Replace(topic, "/down/cmd", "/up/telemetry", 1)
|
||||
} else {
|
||||
downlinkTopic = topic // 无法推导,直接用原主题
|
||||
}
|
||||
return s.Publish(downlinkTopic, payload)
|
||||
}
|
||||
|
||||
// handleMessage 处理 MQTT 消息
|
||||
func (s *MQTTService) handleMessage(client mqtt.Client, msg mqtt.Message) {
|
||||
topic := msg.Topic()
|
||||
payload := msg.Payload()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &body); err != nil {
|
||||
slog.Warn("MQTT 消息解析失败", "topic", topic, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(topic, "/")
|
||||
deviceKey, _ := body["deviceKey"].(string)
|
||||
if deviceKey == "" {
|
||||
// GeekOpen 设备用 mac 或 imei 字段标识
|
||||
if mac, ok := body["mac"].(string); ok && mac != "" {
|
||||
deviceKey = mac
|
||||
}
|
||||
}
|
||||
if deviceKey == "" {
|
||||
// GSCW1M-4G 断路器用 imei 字段标识
|
||||
if imei, ok := body["imei"].(string); ok && imei != "" {
|
||||
// 规范化 IMEI:部分设备固件 bug 会将 "6" 误发为 "G"
|
||||
deviceKey = strings.ReplaceAll(imei, "G", "6")
|
||||
}
|
||||
}
|
||||
if deviceKey == "" && len(parts) > 3 {
|
||||
deviceKey = parts[3]
|
||||
}
|
||||
if deviceKey == "" {
|
||||
deviceKey = "unknown"
|
||||
}
|
||||
|
||||
// 记录设备的上行主题(用于推导下行命令主题)
|
||||
s.deviceTopics[deviceKey] = topic
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 更新设备在线状态(同时持久化上行主题,防止后端重启后丢失)
|
||||
s.db.Model(&model.Device{}).Where("device_key = ?", deviceKey).
|
||||
Updates(map[string]interface{}{"online_status": "online", "last_seen": now, "topic": topic})
|
||||
|
||||
// GeekOpen 设备命令响应处理:source=="command" 表示设备回复的命令结果
|
||||
if source, _ := body["source"].(string); source == "command" {
|
||||
commandName, _ := body["commandName"].(string)
|
||||
success, _ := body["success"].(bool)
|
||||
if !success {
|
||||
message, _ := body["message"].(string)
|
||||
slog.Warn("设备命令错误", "deviceKey", deviceKey, "commandName", commandName, "message", message)
|
||||
return
|
||||
}
|
||||
slog.Info("设备命令响应", "deviceKey", deviceKey, "commandName", commandName)
|
||||
// 包含遥测数据的命令响应
|
||||
if commandName == "info-all" || commandName == "device-timer-interval" ||
|
||||
commandName == "info-statistic" || commandName == "controller-event" {
|
||||
s.extractGSTMB1Metrics(body, deviceKey, now)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 红外控制器响应处理:type=="infrared" 表示红外操作结果
|
||||
if msgType, _ := body["type"].(string); msgType == "infrared" {
|
||||
action, _ := body["action"].(string)
|
||||
success, _ := body["success"].(bool)
|
||||
no := 0
|
||||
if data, ok := body["data"].(map[string]interface{}); ok {
|
||||
if n, ok := data["no"].(float64); ok {
|
||||
no = int(n)
|
||||
}
|
||||
}
|
||||
// 设备学习成功时返回 no=0,需用 pendingLearnNo 还原发送时的编号
|
||||
effectiveNo := no
|
||||
if action == "learn" && success && no == 0 {
|
||||
if pendingNo, ok := s.pendingLearnNo[deviceKey]; ok && pendingNo > 0 {
|
||||
effectiveNo = pendingNo
|
||||
delete(s.pendingLearnNo, deviceKey)
|
||||
}
|
||||
}
|
||||
result := &IROperationResult{
|
||||
Action: action,
|
||||
Success: success,
|
||||
No: effectiveNo,
|
||||
Timestamp: now,
|
||||
}
|
||||
s.irResults[deviceKey] = result
|
||||
// 学习成功时记录红外码编号
|
||||
if action == "learn" && success && effectiveNo > 0 {
|
||||
if s.irLearnedCodes[deviceKey] == nil {
|
||||
s.irLearnedCodes[deviceKey] = make(map[int]bool)
|
||||
}
|
||||
s.irLearnedCodes[deviceKey][effectiveNo] = true
|
||||
}
|
||||
// 擦除全部时清空记录
|
||||
if action == "erase" && success {
|
||||
s.irLearnedCodes[deviceKey] = make(map[int]bool)
|
||||
}
|
||||
slog.Info("红外操作结果", "deviceKey", deviceKey, "action", action, "success", success, "no", effectiveNo)
|
||||
return
|
||||
}
|
||||
|
||||
// 提取指标
|
||||
var events []TelemetryEvent
|
||||
if metrics, ok := body["metrics"].(map[string]interface{}); ok {
|
||||
for metric, val := range metrics {
|
||||
v := toFloat(val)
|
||||
if !isNaN(v) {
|
||||
events = append(events, TelemetryEvent{DeviceKey: deviceKey, Metric: metric, Value: v, Timestamp: now})
|
||||
}
|
||||
}
|
||||
} else if data, ok := body["data"].([]interface{}); ok {
|
||||
for _, d := range data {
|
||||
if item, ok := d.(map[string]interface{}); ok {
|
||||
metric, _ := item["metric"].(string)
|
||||
v := toFloat(item["value"])
|
||||
if metric != "" && !isNaN(v) {
|
||||
events = append(events, TelemetryEvent{DeviceKey: deviceKey, Metric: metric, Value: v, Timestamp: now})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if val, ok := body["value"]; ok {
|
||||
v := toFloat(val)
|
||||
if !isNaN(v) {
|
||||
metric, _ := body["metric"].(string)
|
||||
if metric == "" {
|
||||
metric = "value"
|
||||
}
|
||||
events = append(events, TelemetryEvent{DeviceKey: deviceKey, Metric: metric, Value: v, Timestamp: now})
|
||||
}
|
||||
} else {
|
||||
// GSTMB1 定时上报:温度/湿度等字段直接在 body 顶层
|
||||
events = s.extractGSTMB1Events(body, deviceKey, now)
|
||||
}
|
||||
|
||||
// 持久化 + 事件推送
|
||||
for _, e := range events {
|
||||
s.persistTelemetry(e.DeviceKey, e.Metric, e.Value, e.Timestamp)
|
||||
// 推送到 WebSocket
|
||||
if s.hub != nil {
|
||||
s.hub.BroadcastTelemetry(e.DeviceKey, e)
|
||||
}
|
||||
// 阈值检测
|
||||
s.evaluateThresholds(e)
|
||||
}
|
||||
}
|
||||
|
||||
// extractGSTMB1Metrics 从 GSTMB1 响应中提取遥测指标
|
||||
func (s *MQTTService) extractGSTMB1Metrics(body map[string]interface{}, deviceKey string, now time.Time) {
|
||||
events := s.extractGSTMB1Events(body, deviceKey, now)
|
||||
for _, e := range events {
|
||||
s.persistTelemetry(e.DeviceKey, e.Metric, e.Value, e.Timestamp)
|
||||
if s.hub != nil {
|
||||
s.hub.BroadcastTelemetry(e.DeviceKey, e)
|
||||
}
|
||||
s.evaluateThresholds(e)
|
||||
}
|
||||
}
|
||||
|
||||
// extractGSTMB1Events 从 body 顶层提取 GSTMB1 的温湿度等指标
|
||||
func (s *MQTTService) extractGSTMB1Events(body map[string]interface{}, deviceKey string, now time.Time) []TelemetryEvent {
|
||||
// GeekOpen 设备上报的数值型指标字段
|
||||
metricFields := []string{
|
||||
// GSTMB1 传感器
|
||||
"temperature", "humidity", "lux", "co2",
|
||||
"t_compensate", "h_compensate",
|
||||
"timerInterval", "timerEnable",
|
||||
// GSPE1B 智能插座 / GSCW1M-4G 断路器
|
||||
"voltage", "current", "power", "energy",
|
||||
"key", "onState", "signal",
|
||||
"keyLock", "resetLock",
|
||||
}
|
||||
var events []TelemetryEvent
|
||||
for _, field := range metricFields {
|
||||
if val, ok := body[field]; ok {
|
||||
v := toFloat(val)
|
||||
if !isNaN(v) {
|
||||
events = append(events, TelemetryEvent{
|
||||
DeviceKey: deviceKey, Metric: field, Value: v, Timestamp: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// persistTelemetry 持久化遥测数据:优先 IoTDB,降级 PG
|
||||
func (s *MQTTService) persistTelemetry(deviceKey, metric string, value float64, ts time.Time) {
|
||||
if s.iotdb != nil && s.iotdb.IsAvailable() {
|
||||
if s.iotdb.InsertTelemetry(deviceKey, metric, value, ts) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 降级 PG
|
||||
t := model.Telemetry{DeviceKey: deviceKey, Metric: metric, Value: value, Timestamp: ts}
|
||||
if err := s.db.Create(&t).Error; err != nil {
|
||||
slog.Warn("遥测数据写入 PG 失败", "deviceKey", deviceKey, "metric", metric, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateThresholds 阈值检测
|
||||
func (s *MQTTService) evaluateThresholds(e TelemetryEvent) {
|
||||
// 查设备关联的传感器
|
||||
var device model.Device
|
||||
if err := s.db.Where("device_key = ?", e.DeviceKey).First(&device).Error; err != nil {
|
||||
return
|
||||
}
|
||||
var sensor model.Sensor
|
||||
if err := s.db.Where("device_id = ? AND metric = ?", device.ID, e.Metric).First(&sensor).Error; err != nil {
|
||||
return
|
||||
}
|
||||
// 查启用的阈值
|
||||
var thresholds []model.Threshold
|
||||
s.db.Where("sensor_id = ? AND enabled = true", sensor.ID).Find(&thresholds)
|
||||
|
||||
for _, t := range thresholds {
|
||||
isLo := e.Value < t.MinValue
|
||||
isHi := e.Value > t.MaxValue
|
||||
if !isLo && !isHi {
|
||||
// 恢复正常
|
||||
s.db.Model(&model.Alarm{}).
|
||||
Where("device_key = ? AND metric = ? AND open = true", e.DeviceKey, e.Metric).
|
||||
Updates(map[string]interface{}{"open": false, "resolved_at": time.Now()})
|
||||
if s.hub != nil {
|
||||
s.hub.BroadcastAlarm(AlarmEvent{
|
||||
Code: "recovery", Title: e.Metric + " 恢复正常",
|
||||
DeviceKey: e.DeviceKey, Metric: e.Metric,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 防抖检查
|
||||
cooldownKey := t.ID
|
||||
now := time.Now().UnixMilli()
|
||||
if last, ok := s.coolDown[cooldownKey]; ok && now-last < int64(t.DebounceSeconds)*1000 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已有 open 告警
|
||||
var count int64
|
||||
s.db.Model(&model.Alarm{}).Where("code = ? AND device_key = ? AND open = true",
|
||||
fmt.Sprintf("%s.%s.%s", e.DeviceKey, e.Metric, loHi(isLo)), e.DeviceKey).Count(&count)
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
s.coolDown[cooldownKey] = now
|
||||
|
||||
// 创建告警
|
||||
code := fmt.Sprintf("%s.%s.%s", e.DeviceKey, e.Metric, loHi(isLo))
|
||||
title := fmt.Sprintf("%s %s阈值", e.Metric, loHiCN(isLo))
|
||||
msg := fmt.Sprintf("设备 %s 的 %s = %g(阈值 [%g, %g])", e.DeviceKey, e.Metric, e.Value, t.MinValue, t.MaxValue)
|
||||
severity := fmt.Sprintf("%d", t.Severity)
|
||||
alarm := model.Alarm{
|
||||
Code: &code,
|
||||
Title: &title,
|
||||
Message: &msg,
|
||||
Severity: &severity,
|
||||
Open: true,
|
||||
Acknowledged: false,
|
||||
TriggeredAt: time.Now(),
|
||||
DeviceKey: &e.DeviceKey,
|
||||
Metric: &e.Metric,
|
||||
Value: &e.Value,
|
||||
ThresholdMin: &t.MinValue,
|
||||
ThresholdMax: &t.MaxValue,
|
||||
}
|
||||
if err := s.db.Create(&alarm).Error; err != nil {
|
||||
slog.Warn("创建告警失败", "err", err)
|
||||
continue
|
||||
}
|
||||
slog.Warn("🔔 ALARM: " + title + " (" + e.DeviceKey + ")")
|
||||
|
||||
// 推送到 WebSocket
|
||||
if s.hub != nil {
|
||||
s.hub.BroadcastAlarm(AlarmEvent{
|
||||
Code: code, Title: title, Message: msg,
|
||||
Severity: severity, DeviceKey: e.DeviceKey, Metric: e.Metric,
|
||||
Value: e.Value, ThresholdMin: t.MinValue, ThresholdMax: t.MaxValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loHi(isLo bool) string {
|
||||
if isLo {
|
||||
return "LOW"
|
||||
}
|
||||
return "HIGH"
|
||||
}
|
||||
|
||||
func loHiCN(isLo bool) string {
|
||||
if isLo {
|
||||
return "低于"
|
||||
}
|
||||
return "高于"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
// S3Service Ceph S3 服务,提供 presigned URL、对象流、上传等能力
|
||||
type S3Service struct {
|
||||
client *s3.Client
|
||||
endpoint string
|
||||
region string
|
||||
accessKey string
|
||||
secretKey string
|
||||
}
|
||||
|
||||
// NewS3Service 根据配置创建 S3 服务(forcePathStyle: true,兼容 Ceph)
|
||||
func NewS3Service(cfg *config.Config) *S3Service {
|
||||
endpoint := strings.TrimSuffix(cfg.S3Endpoint, "/")
|
||||
client := s3.New(s3.Options{
|
||||
BaseEndpoint: aws.String(endpoint),
|
||||
Region: cfg.S3Region,
|
||||
Credentials: credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, ""),
|
||||
UsePathStyle: true,
|
||||
})
|
||||
return &S3Service{
|
||||
client: client,
|
||||
endpoint: endpoint,
|
||||
region: cfg.S3Region,
|
||||
accessKey: cfg.S3AccessKey,
|
||||
secretKey: cfg.S3SecretKey,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPresignedURL 手动实现 V4 签名 presigned GET URL(1小时有效),与 NestJS s3.service.ts 一致
|
||||
func (s *S3Service) GetPresignedURL(bucket, key string) (string, error) {
|
||||
expiresIn := 3600
|
||||
now := time.Now().UTC()
|
||||
dateStamp := now.Format("20060102")
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
|
||||
// 从 endpoint 提取 host(去掉 http:// 或 https:// 前缀)
|
||||
host := strings.TrimPrefix(strings.TrimPrefix(s.endpoint, "https://"), "http://")
|
||||
path := "/" + bucket + "/" + key
|
||||
|
||||
// 构造规范化查询字符串(按字母序排列)
|
||||
params := url.Values{}
|
||||
params.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
||||
params.Set("X-Amz-Credential", s.accessKey+"/"+dateStamp+"/"+s.region+"/s3/aws4_request")
|
||||
params.Set("X-Amz-Date", amzDate)
|
||||
params.Set("X-Amz-Expires", fmt.Sprintf("%d", expiresIn))
|
||||
params.Set("X-Amz-SignedHeaders", "host")
|
||||
canonQuery := params.Encode()
|
||||
|
||||
// 规范化请求
|
||||
canonHeaders := "host:" + host + "\n"
|
||||
canonRequest := "GET\n" + path + "\n" + canonQuery + "\n" + canonHeaders + "\nhost\nUNSIGNED-PAYLOAD"
|
||||
|
||||
// 待签名字符串
|
||||
scope := dateStamp + "/" + s.region + "/s3/aws4_request"
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + sha256Hex(canonRequest)
|
||||
|
||||
// 签名密钥派生链:AWS4{secret} -> dateStamp -> region -> s3 -> aws4_request
|
||||
kDate := hmacSHA256([]byte("AWS4"+s.secretKey), dateStamp)
|
||||
kRegion := hmacSHA256(kDate, s.region)
|
||||
kService := hmacSHA256(kRegion, "s3")
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
|
||||
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
|
||||
return s.endpoint + path + "?" + canonQuery + "&X-Amz-Signature=" + signature, nil
|
||||
}
|
||||
|
||||
// GetObjectStream 获取对象流(支持 Range 请求),用于视频流代理
|
||||
func (s *S3Service) GetObjectStream(bucket, key string, rangeHeader string) (*s3.GetObjectOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
input := &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
}
|
||||
if rangeHeader != "" {
|
||||
input.Range = aws.String(rangeHeader)
|
||||
}
|
||||
return s.client.GetObject(ctx, input)
|
||||
}
|
||||
|
||||
// ListObjects 列举桶内对象(可按前缀过滤)
|
||||
func (s *S3Service) ListObjects(bucket, prefix string) ([]types.Object, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
input := &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
MaxKeys: aws.Int32(100),
|
||||
}
|
||||
if prefix != "" {
|
||||
input.Prefix = aws.String(prefix)
|
||||
}
|
||||
result, err := s.client.ListObjectsV2(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Contents, nil
|
||||
}
|
||||
|
||||
// ObjectExists 检查对象是否存在
|
||||
func (s *S3Service) ObjectExists(bucket, key string) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// UploadFile 上传本地文件到 S3
|
||||
func (s *S3Service) UploadFile(bucket, key, filePath string) error {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: file,
|
||||
ContentType: aws.String("video/mp4"),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// sha256Hex 计算 SHA256 十六进制摘要
|
||||
func sha256Hex(data string) string {
|
||||
h := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// hmacSHA256 计算 HMAC-SHA256
|
||||
func hmacSHA256(key []byte, data string) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write([]byte(data))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TranscodeService ffmpeg 实时转码服务
|
||||
type TranscodeService struct {
|
||||
db *gorm.DB
|
||||
s3 *S3Service
|
||||
sem chan struct{} // 信号量,限制并发转码数为 4
|
||||
}
|
||||
|
||||
// NewTranscodeService 创建转码服务,信号量默认容量 4
|
||||
func NewTranscodeService(db *gorm.DB, s3 *S3Service) *TranscodeService {
|
||||
return &TranscodeService{
|
||||
db: db,
|
||||
s3: s3,
|
||||
sem: make(chan struct{}, 4),
|
||||
}
|
||||
}
|
||||
|
||||
// StreamLive 通过 ffmpeg 实时转码直播流(H264 re-encode for browser compatibility)
|
||||
func (s *TranscodeService) StreamLive(sourceURL string, writer io.Writer) error {
|
||||
s.sem <- struct{}{}
|
||||
defer func() { <-s.sem }()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||
"-i", sourceURL,
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-tune", "zerolatency",
|
||||
"-b:v", "1M",
|
||||
"-maxrate", "1.5M",
|
||||
"-bufsize", "1M",
|
||||
"-g", "30",
|
||||
"-an",
|
||||
"-f", "flv",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 stdout 管道失败: %w", err)
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 stderr 管道失败: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("启动 ffmpeg 失败: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stderrPipe)
|
||||
for scanner.Scan() {
|
||||
slog.Info("ffmpeg-live", "msg", scanner.Text())
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(writer, stdoutPipe); err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("流式传输失败: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("ffmpeg 异常退出: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamClip 通过 ffmpeg 实时转码视频片段并流式写入 writer
|
||||
// 流程:查库获取 S3 信息 → 生成 presigned URL → ffmpeg -c copy 转封装为 fragmented MP4 → 管道输出
|
||||
func (s *TranscodeService) StreamClip(clipId string, writer io.Writer) error {
|
||||
// 1. 查库获取 clip 的 s3Bucket + s3Key
|
||||
var clip model.VideoClip
|
||||
if err := s.db.Where("id = ?", clipId).First(&clip).Error; err != nil {
|
||||
return fmt.Errorf("视频片段不存在: %w", err)
|
||||
}
|
||||
if clip.S3Bucket == nil || clip.S3Key == nil || *clip.S3Bucket == "" || *clip.S3Key == "" {
|
||||
return fmt.Errorf("该片段没有 S3 对象")
|
||||
}
|
||||
|
||||
// 2. 生成 S3 presigned URL
|
||||
presignedURL, err := s.s3.GetPresignedURL(*clip.S3Bucket, *clip.S3Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成 presigned URL 失败: %w", err)
|
||||
}
|
||||
|
||||
// 3. 获取信号量(并发限制 4)
|
||||
s.sem <- struct{}{}
|
||||
defer func() { <-s.sem }()
|
||||
|
||||
// 4. 启动 ffmpeg: ffmpeg -i <url> -c copy -movflags frag_keyframe+empty_moov -f mp4 pipe:1
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||
"-i", presignedURL,
|
||||
"-c", "copy",
|
||||
"-movflags", "frag_keyframe+empty_moov",
|
||||
"-f", "mp4",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 stdout 管道失败: %w", err)
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 stderr 管道失败: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("启动 ffmpeg 失败: %w", err)
|
||||
}
|
||||
|
||||
// 确保 ffmpeg 进程被清理(如果还在运行则 kill)
|
||||
defer func() {
|
||||
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}()
|
||||
|
||||
// 5. 记录 ffmpeg stderr 日志
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stderrPipe)
|
||||
for scanner.Scan() {
|
||||
slog.Info("ffmpeg", "clipId", clipId, "msg", scanner.Text())
|
||||
}
|
||||
}()
|
||||
|
||||
// 6. 流式传输:io.Copy(writer, ffmpeg.Stdout)
|
||||
if _, err := io.Copy(writer, stdoutPipe); err != nil {
|
||||
cancel() // 取消 context,终止 ffmpeg
|
||||
return fmt.Errorf("流式传输失败: %w", err)
|
||||
}
|
||||
|
||||
// 7. 等待 ffmpeg 结束,检查退出码
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("ffmpeg 异常退出: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user