chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/database"
|
||||
"silk-server-go/internal/handler"
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
"silk-server-go/internal/ws"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. 加载配置
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
slog.Error("配置加载失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 2. 连接数据库(GORM 自动迁移)
|
||||
if err := database.Init(cfg); err != nil {
|
||||
slog.Error("数据库连接失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db := database.DB
|
||||
|
||||
// 3. 初始化 IoTDB(失败则降级 PostgreSQL)
|
||||
iotdb := service.NewIoTDBService(cfg.IoTDBURL)
|
||||
if err := iotdb.Init(); err != nil {
|
||||
slog.Warn("IoTDB 初始化失败,将降级使用 PostgreSQL", "err", err)
|
||||
}
|
||||
|
||||
// 4. 连接 Redis(失败不阻断启动)
|
||||
if opt, err := redis.ParseURL(cfg.Redis); err == nil {
|
||||
rdb := redis.NewClient(opt)
|
||||
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||
slog.Warn("Redis 连接失败", "err", err)
|
||||
} else {
|
||||
slog.Info("Redis 连接成功")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 创建 WebSocket Hub
|
||||
hub := ws.NewHub(cfg.JWTSecret)
|
||||
|
||||
// 6. 创建并启动 MQTT 服务
|
||||
mqttSvc := service.NewMQTTService(cfg.MQTT, db, iotdb, hub)
|
||||
if err := mqttSvc.Start(); err != nil {
|
||||
slog.Warn("MQTT 启动失败", "err", err)
|
||||
}
|
||||
defer mqttSvc.Stop()
|
||||
|
||||
// 启动设备离线检测(每 60 秒检查,超过 5 分钟未上报标记离线)
|
||||
mqttSvc.StartOfflineChecker(5 * time.Minute)
|
||||
|
||||
// 7. 注入 MQTT publisher(用于控制命令下发)
|
||||
handler.SetMQTTPublisher(mqttSvc)
|
||||
handler.SetDeviceCommander(mqttSvc)
|
||||
|
||||
// 8. 创建 S3/Media/Transcode 服务
|
||||
s3Svc := service.NewS3Service(cfg)
|
||||
mediaSvc := service.NewMediaService(cfg)
|
||||
transcodeSvc := service.NewTranscodeService(db, s3Svc)
|
||||
|
||||
// 9. 创建 Gin 引擎
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.Logger())
|
||||
r.Use(middleware.SecurityHeaders())
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
// 10. WebSocket 路由(不经过 auth 中间件)
|
||||
r.GET("/ws", hub.HandleWebSocket)
|
||||
|
||||
// 11. API 路由组(经过 JWT auth 中间件,白名单路径自动跳过)
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.Auth(cfg))
|
||||
|
||||
// 公开路由(auth 白名单中跳过鉴权)
|
||||
handler.RegisterHealthRoutes(api)
|
||||
handler.RegisterVideoStreamRoutes(api, transcodeSvc, db, mediaSvc, cfg)
|
||||
|
||||
// JWT 保护的业务路由
|
||||
handler.RegisterAuthRoutes(api, db, cfg)
|
||||
handler.RegisterRoomRoutes(api, db)
|
||||
handler.RegisterDeviceRoutes(api, db)
|
||||
handler.RegisterSensorRoutes(api, db)
|
||||
handler.RegisterThresholdRoutes(api, db)
|
||||
handler.RegisterAlarmRoutes(api, db)
|
||||
handler.RegisterAlarmClipRoutes(api, db)
|
||||
handler.RegisterControlRoutes(api, db)
|
||||
handler.RegisterNotificationRoutes(api, db)
|
||||
handler.RegisterTelemetryRoutes(api, db, iotdb)
|
||||
handler.RegisterVideoCameraRoutes(api, db, mediaSvc)
|
||||
handler.RegisterVideoClipRoutes(api, db, cfg)
|
||||
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
||||
handler.RegisterStorageRoutes(api, db)
|
||||
|
||||
// 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态)
|
||||
go startDeviceStatusSync(db, mediaSvc)
|
||||
|
||||
// 用户管理与审计日志路由(各路由内部按权限码校验)
|
||||
handler.RegisterAuditRoutes(api, db)
|
||||
handler.RegisterUserRoutes(api, db)
|
||||
handler.RegisterPermissionRoutes(api, db)
|
||||
|
||||
// 12. 启动 HTTP 服务
|
||||
addr := ":" + strconv.Itoa(cfg.Port)
|
||||
slog.Info("🚀 Silk Go server 启动", "addr", addr, "apiPrefix", "/api/v1")
|
||||
if err := r.Run(addr); err != nil {
|
||||
slog.Error("服务启动失败", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// startDeviceStatusSync 定期从 WVP 同步设备信息到数据库(在线状态 + 共有参数)
|
||||
func startDeviceStatusSync(db *gorm.DB, media *service.MediaService) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
devMap, err := media.SyncWvpDevices()
|
||||
if err != nil {
|
||||
slog.Debug("同步 WVP 设备信息失败", "error", err)
|
||||
continue
|
||||
}
|
||||
if len(devMap) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新所有有 GB 设备 ID 的摄像头
|
||||
var cameras []model.Camera
|
||||
db.Where("gb_device_id IS NOT NULL AND gb_device_id != ''").Find(&cameras)
|
||||
for _, cam := range cameras {
|
||||
if cam.GbDeviceID == nil {
|
||||
continue
|
||||
}
|
||||
info, exists := devMap[*cam.GbDeviceID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if cam.IsOnline != info.OnLine {
|
||||
updates["is_online"] = info.OnLine
|
||||
}
|
||||
if info.Name != "" && cam.Name != info.Name {
|
||||
updates["name"] = info.Name
|
||||
}
|
||||
if info.Manufacturer != "" {
|
||||
if cam.GbManufacturer == nil || *cam.GbManufacturer != info.Manufacturer {
|
||||
updates["gb_manufacturer"] = info.Manufacturer
|
||||
}
|
||||
}
|
||||
if info.Password != "" {
|
||||
if cam.GbAuthPassword == nil || *cam.GbAuthPassword != info.Password {
|
||||
updates["gb_auth_password"] = info.Password
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Camera{}).Where("id = ?", cam.ID).Updates(updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
module silk-server-go
|
||||
|
||||
go 1.23
|
||||
|
||||
exclude github.com/rogpeppe/go-internal v1.15.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
|
||||
github.com/caarlos0/env v3.5.0+incompatible
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.6.1
|
||||
golang.org/x/crypto v0.25.0
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
gorm.io/gorm v1.25.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 // indirect
|
||||
github.com/aws/smithy-go v1.20.3 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 h1:Z5r7SycxmSllHYmaAZPpmN8GviDrSGhMS6bldqtXZPw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15/go.mod h1:CetW7bDE00QoGEmPUoZuRog07SGVAUVW6LFpNP0YfIg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 h1:YPYe6ZmvUfDDDELqEKtAd6bo8zxhkm+XEFEzQisqUIE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17/go.mod h1:oBtcnYua/CgzCWYN7NZ5j7PotFDaFSUjCYVTtfyn7vw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 h1:246A4lSTXWJw/rmlQI+TT2OcqeDMKBdyjEQrafMaQdA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15/go.mod h1:haVfg3761/WF7YPuJOER2MP0k4UAXyHaLclKXB6usDg=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2 h1:sZXIzO38GZOU+O0C+INqbH7C2yALwfMWpd64tONS/NE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2/go.mod h1:Lcxzg5rojyVPU/0eFwLtcyTaek/6Mtic5B1gJo7e/zE=
|
||||
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
|
||||
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/caarlos0/env v3.5.0+incompatible h1:Yy0UN8o9Wtr/jGHZDpCBLpNrzcFLLM2yixi/rBrKyJs=
|
||||
github.com/caarlos0/env v3.5.0+incompatible/go.mod h1:tdCsowwCzMLdkqRYDlHpZCp2UooDD3MspDBjZ2AD02Y=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
|
||||
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
|
||||
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,40 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/caarlos0/env"
|
||||
)
|
||||
|
||||
// Config 全局配置,从环境变量加载
|
||||
type Config struct {
|
||||
PG string `env:"PG" envDefault:"postgresql://postgres:pan@localhost:5432/silk"`
|
||||
Redis string `env:"REDIS" envDefault:"redis://:pan@localhost:6379"`
|
||||
JWTSecret string `env:"JWT_SECRET" envDefault:"silk-secret-please-change-me"`
|
||||
JWTExpiresIn string `env:"JWT_EXPIRES_IN" envDefault:"2h"`
|
||||
MQTT string `env:"MQTT" envDefault:"mqtt://pan:pan@localhost:1883"`
|
||||
IoTDBURL string `env:"IOTDB_URL" envDefault:"http://127.0.0.1:18081"`
|
||||
S3Endpoint string `env:"S3_ENDPOINT" envDefault:"http://100.83.103.1:7480"`
|
||||
S3AccessKey string `env:"S3_ACCESS_KEY" envDefault:"silk-app"`
|
||||
S3SecretKey string `env:"S3_SECRET_KEY" envDefault:"Silk-App-Secret-2026!"`
|
||||
S3Bucket string `env:"S3_BUCKET" envDefault:"silk-video-events"`
|
||||
S3Region string `env:"S3_REGION" envDefault:"us-east-1"`
|
||||
WVPAPIBase string `env:"WVP_API_BASE" envDefault:"http://localhost:18978"`
|
||||
WVPUsername string `env:"WVP_USERNAME" envDefault:"admin"`
|
||||
WVPPassword string `env:"WVP_PASSWORD" envDefault:"admin"`
|
||||
ZLMAPIBase string `env:"ZLM_API_BASE" envDefault:"http://100.83.103.1:8081"`
|
||||
ZLMSecret string `env:"ZLM_SECRET" envDefault:"su6TiedN2rVAmBbIDX0aa0QTiBJLBdcf"`
|
||||
RecorderAPIBase string `env:"RECORDER_API_BASE" envDefault:"http://localhost:9090"`
|
||||
InternalAPIKey string `env:"INTERNAL_API_KEY" envDefault:"silk-internal-2026"`
|
||||
Port int `env:"PORT" envDefault:"3000"`
|
||||
DefaultAdminUsername string `env:"DEFAULT_ADMIN_USERNAME" envDefault:"admin"`
|
||||
DefaultAdminPassword string `env:"DEFAULT_ADMIN_PASSWORD" envDefault:"silk@123"`
|
||||
DefaultAdminEmail string `env:"DEFAULT_ADMIN_EMAIL" envDefault:"admin@silk.local"`
|
||||
}
|
||||
|
||||
// Load 从环境变量加载配置
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{}
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func Init(cfg *config.Config) error {
|
||||
db, err := gorm.Open(postgres.Open(cfg.PG), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
DB = db
|
||||
// 自动迁移(错误不阻止启动,仅记录警告)
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.Room{}, &model.Device{}, &model.Sensor{},
|
||||
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
||||
&model.AuditLog{}, &model.Telemetry{},
|
||||
&model.Permission{}, &model.RolePermission{},
|
||||
); err != nil {
|
||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||
}
|
||||
// 初始化权限种子数据
|
||||
seedPermissions(db)
|
||||
slog.Info("数据库连接成功,自动迁移完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedPermissions 初始化权限和角色-权限映射种子数据(幂等)
|
||||
func seedPermissions(db *gorm.DB) {
|
||||
// 1. 写入权限定义(已存在则跳过)
|
||||
codeToID := make(map[string]string)
|
||||
for _, p := range model.AllPermissions {
|
||||
var existing model.Permission
|
||||
if db.Where("code = ?", p.Code).First(&existing).Error == nil {
|
||||
codeToID[p.Code] = existing.ID
|
||||
continue
|
||||
}
|
||||
desc := p.Description
|
||||
perm := model.Permission{
|
||||
Code: p.Code,
|
||||
Name: p.Name,
|
||||
Description: &desc,
|
||||
}
|
||||
if err := db.Create(&perm).Error; err != nil {
|
||||
slog.Warn("写入权限种子失败", "code", p.Code, "err", err)
|
||||
continue
|
||||
}
|
||||
codeToID[p.Code] = perm.ID
|
||||
}
|
||||
|
||||
// 2. 写入角色-权限映射(已存在则跳过)
|
||||
for role, codes := range model.RolePermissionMap {
|
||||
for _, code := range codes {
|
||||
pid, ok := codeToID[code]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var cnt int64
|
||||
db.Model(&model.RolePermission{}).
|
||||
Where("role = ? AND permission_id = ?", role, pid).
|
||||
Count(&cnt)
|
||||
if cnt > 0 {
|
||||
continue
|
||||
}
|
||||
db.Create(&model.RolePermission{Role: role, PermissionID: pid})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterAlarmRoutes 注册告警路由
|
||||
func RegisterAlarmRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/alarms", middleware.RequirePermission(db, "alarm:read"), listAlarms(db))
|
||||
rg.GET("/alarms/:id", middleware.RequirePermission(db, "alarm:read"), getAlarm(db))
|
||||
rg.POST("/alarms/:id/ack", middleware.RequirePermission(db, "alarm:ack"), ackAlarm(db))
|
||||
rg.POST("/alarms/:id/resolve", middleware.RequirePermission(db, "alarm:ack"), resolveAlarm(db))
|
||||
}
|
||||
|
||||
// listAlarms 告警列表(query: openOnly,默认上限500,按 triggeredAt DESC)
|
||||
func listAlarms(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limit := 500
|
||||
q := db.Model(&model.Alarm{}).Order("triggered_at DESC").Limit(limit)
|
||||
if c.Query("openOnly") == "true" {
|
||||
q = q.Where("open = true")
|
||||
}
|
||||
var alarms []model.Alarm
|
||||
q.Find(&alarms)
|
||||
c.JSON(http.StatusOK, alarms)
|
||||
}
|
||||
}
|
||||
|
||||
// getAlarm 告警详情
|
||||
func getAlarm(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var alarm model.Alarm
|
||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
}
|
||||
}
|
||||
|
||||
// ackAlarm 确认告警(设置 acknowledged=true, acknowledgedAt=now)
|
||||
func ackAlarm(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var alarm model.Alarm
|
||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
db.Model(&model.Alarm{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"acknowledged": true,
|
||||
"acknowledged_at": now,
|
||||
})
|
||||
db.Where("id = ?", id).First(&alarm)
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAlarm 解除告警(设置 open=false, resolvedAt=now)
|
||||
func resolveAlarm(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var alarm model.Alarm
|
||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
db.Model(&model.Alarm{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"open": false,
|
||||
"resolved_at": now,
|
||||
})
|
||||
db.Where("id = ?", id).First(&alarm)
|
||||
c.JSON(http.StatusOK, alarm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterAlarmClipRoutes 注册告警视频片段路由
|
||||
func RegisterAlarmClipRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/alarms/:id/clip", middleware.RequirePermission(db, "alarm:read"), getAlarmClip(db))
|
||||
}
|
||||
|
||||
// getAlarmClip 获取告警关联视频片段(查 video_clips 表 where alarm_id=:id,按 start_at DESC)
|
||||
func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// 先检查告警是否存在
|
||||
var alarm model.Alarm
|
||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var clips []model.VideoClip
|
||||
db.Where("alarm_id = ?", id).Order("start_at DESC").Find(&clips)
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
for i := range clips {
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
||||
clips[i].PlaybackURL = &url
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clips)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterAuditRoutes 注册审计日志路由
|
||||
func RegisterAuditRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
perm := middleware.RequirePermission(db, "audit:read")
|
||||
rg.GET("/audit-logs", perm, listAuditLogs(db))
|
||||
rg.POST("/audit-logs", perm, createAuditLog(db))
|
||||
}
|
||||
|
||||
// listAuditLogs 审计日志列表(query: limit,默认100,上限500)
|
||||
func listAuditLogs(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limit := 100
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
var logs []model.AuditLog
|
||||
db.Order("created_at DESC").Limit(limit).Find(&logs)
|
||||
c.JSON(http.StatusOK, logs)
|
||||
}
|
||||
}
|
||||
|
||||
// createAuditLog 手动写入审计日志,自动补充 userId/username/IP/UA
|
||||
func createAuditLog(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
Resource *string `json:"resource"`
|
||||
TargetID *string `json:"targetId"`
|
||||
Description *string `json:"description"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Action == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "action 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
// 从 context 获取当前用户信息
|
||||
var userID, username *string
|
||||
if user, ok := c.Get("user"); ok {
|
||||
if userMap, ok := user.(map[string]interface{}); ok {
|
||||
if sub, ok := userMap["sub"].(string); ok {
|
||||
s := sub
|
||||
userID = &s
|
||||
}
|
||||
if uname, ok := userMap["username"].(string); ok {
|
||||
s := uname
|
||||
username = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
ua := c.GetHeader("User-Agent")
|
||||
|
||||
log := model.AuditLog{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Action: body.Action,
|
||||
Resource: body.Resource,
|
||||
TargetID: body.TargetID,
|
||||
Description: body.Description,
|
||||
IPAddress: &ip,
|
||||
UserAgent: &ua,
|
||||
Metadata: body.Metadata,
|
||||
}
|
||||
if err := db.Create(&log).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "写入审计日志失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, log)
|
||||
}
|
||||
}
|
||||
|
||||
// recordAudit 记录审计日志(供其他 handler 调用)
|
||||
func recordAudit(db *gorm.DB, log *model.AuditLog) {
|
||||
if log == nil || log.Action == "" {
|
||||
return
|
||||
}
|
||||
if err := db.Create(log).Error; err != nil {
|
||||
slog.Warn("记录审计日志失败", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 默认令牌有效期:访问令牌 2 小时,刷新令牌 7 天
|
||||
const (
|
||||
defaultAccessExpiry = 2 * time.Hour
|
||||
defaultRefreshExpiry = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// RegisterAuthRoutes 注册认证相关路由
|
||||
func RegisterAuthRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
// 启动时确保默认 admin 用户存在
|
||||
ensureDefaultAdmin(db, cfg)
|
||||
|
||||
rg.POST("/auth/register", registerHandler(db, cfg))
|
||||
rg.POST("/auth/login", loginHandler(db, cfg))
|
||||
rg.POST("/auth/refresh", refreshHandler(db, cfg))
|
||||
rg.POST("/auth/logout", logoutHandler(cfg))
|
||||
rg.POST("/auth/change-password", changePasswordHandler(db, cfg))
|
||||
rg.GET("/auth/me", meHandler(db))
|
||||
}
|
||||
|
||||
// registerHandler 注册用户(bcrypt 哈希密码),返回 accessToken + refreshToken + user
|
||||
func registerHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
FullName *string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(body.Username) < 3 || len(body.Password) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username 至少3位,password 至少6位"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户名或邮箱是否已存在
|
||||
var exists model.User
|
||||
if db.Where("username = ? OR email = ?", body.Username, body.Email).First(&exists).Error == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username or email already taken"})
|
||||
return
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), 10)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "密码哈希失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 强制角色为 viewer,防止垂直越权(注册接口不允许自选角色)
|
||||
role := model.RoleViewer
|
||||
user := model.User{
|
||||
Username: body.Username,
|
||||
Email: body.Email,
|
||||
PasswordHash: string(hash),
|
||||
FullName: body.FullName,
|
||||
Role: role,
|
||||
Active: true,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录审计日志
|
||||
uid := user.ID
|
||||
uname := user.Username
|
||||
res := "users"
|
||||
desc := "user registered"
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: &uname,
|
||||
Action: "create",
|
||||
Resource: &res,
|
||||
TargetID: &uid,
|
||||
Description: &desc,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, buildLoginPayload(db, user, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
// loginHandler 登录(用户名或邮箱 + 密码),返回 token
|
||||
func loginHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 登录限流:检查 IP+用户名是否被锁定
|
||||
if middleware.CheckLoginLock(c, body.Username) {
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if db.Where("username = ? OR email = ?", body.Username, body.Username).First(&user).Error != nil {
|
||||
middleware.RecordLoginFail(c, body.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
|
||||
middleware.RecordLoginFail(c, body.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Active {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "账号已禁用"})
|
||||
return
|
||||
}
|
||||
|
||||
// 登录成功,清空失败计数
|
||||
middleware.RecordLoginSuccess(c, body.Username)
|
||||
|
||||
// 记录审计日志
|
||||
uid := user.ID
|
||||
uname := user.Username
|
||||
res := "users"
|
||||
desc := "user login"
|
||||
ip := c.ClientIP()
|
||||
ua := c.GetHeader("User-Agent")
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: &uname,
|
||||
Action: "login",
|
||||
Resource: &res,
|
||||
TargetID: &uid,
|
||||
Description: &desc,
|
||||
IPAddress: &ip,
|
||||
UserAgent: &ua,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, buildLoginPayload(db, user, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
// refreshHandler 刷新访问令牌(body 传 refreshToken)
|
||||
func refreshHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.RefreshToken) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "refreshToken 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, token, err := middleware.ExtractClaims(body.RefreshToken, cfg.JWTSecret)
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"})
|
||||
return
|
||||
}
|
||||
if claims.TokenType != "refresh" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"})
|
||||
return
|
||||
}
|
||||
if middleware.IsRevoked(claims) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌已注销"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询用户,确保仍然有效
|
||||
var user model.User
|
||||
if db.Where("id = ?", claims.Subject).First(&user).Error != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if !user.Active {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "账号已禁用"})
|
||||
return
|
||||
}
|
||||
|
||||
// 吊销旧刷新令牌(一次性使用),签发新令牌对
|
||||
middleware.RevokeToken(claims, body.RefreshToken, claims.ExpiresAt.Time)
|
||||
c.JSON(http.StatusOK, buildLoginPayload(db, user, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
// logoutHandler 登出:将当前访问令牌加入黑名单
|
||||
func logoutHandler(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
if claims, token, err := middleware.ExtractClaims(parts[1], cfg.JWTSecret); err == nil && token.Valid {
|
||||
if claims.ExpiresAt != nil {
|
||||
middleware.RevokeToken(claims, parts[1], claims.ExpiresAt.Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// changePasswordHandler 修改当前用户密码
|
||||
func changePasswordHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userVal, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
userMap, _ := userVal.(map[string]interface{})
|
||||
uid, _ := userMap["sub"].(string)
|
||||
|
||||
var body struct {
|
||||
OldPassword string `json:"oldPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(body.NewPassword) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码至少 6 位"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if db.Where("id = ?", uid).First(&user).Error != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.OldPassword)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "原密码错误"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), 10)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码哈希失败"})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&model.User{}).Where("id = ?", uid).Update("password_hash", string(hash)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新密码失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录审计日志
|
||||
uname := user.Username
|
||||
res := "users"
|
||||
desc := "password changed"
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: &uname,
|
||||
Action: "update",
|
||||
Resource: &res,
|
||||
TargetID: &uid,
|
||||
Description: &desc,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// meHandler 返回当前用户信息(从 context 获取 user)+ 权限列表
|
||||
func meHandler(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userVal, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
userMap, _ := userVal.(map[string]interface{})
|
||||
role, _ := userMap["role"].(string)
|
||||
permissions := getUserPermissionCodes(db, role)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sub": userMap["sub"],
|
||||
"username": userMap["username"],
|
||||
"role": role,
|
||||
"permissions": permissions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// getUserPermissionCodes 查询指定角色的权限码列表
|
||||
func getUserPermissionCodes(db *gorm.DB, role string) []string {
|
||||
if role == model.RoleAdmin {
|
||||
// admin 拥有全部权限
|
||||
codes := make([]string, 0, len(model.AllPermissions))
|
||||
for _, p := range model.AllPermissions {
|
||||
codes = append(codes, p.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
var codes []string
|
||||
db.Table("role_permissions").
|
||||
Select("permissions.code").
|
||||
Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
|
||||
Where("role_permissions.role = ?", role).
|
||||
Scan(&codes)
|
||||
return codes
|
||||
}
|
||||
|
||||
// buildLoginPayload 构建登录返回数据(accessToken + refreshToken + user + permissions)
|
||||
func buildLoginPayload(db *gorm.DB, user model.User, cfg *config.Config) gin.H {
|
||||
return gin.H{
|
||||
"accessToken": signToken(user, cfg, parseDuration(cfg.JWTExpiresIn, defaultAccessExpiry), "access"),
|
||||
"refreshToken": signToken(user, cfg, defaultRefreshExpiry, "refresh"),
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"fullName": user.FullName,
|
||||
"role": user.Role,
|
||||
"permissions": getUserPermissionCodes(db, user.Role),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// signToken 签发 JWT(payload: sub/username/role/tokenType/jti,HS256 + JWTSecret)
|
||||
func signToken(user model.User, cfg *config.Config, expiry time.Duration, tokenType string) string {
|
||||
claims := middleware.JWTClaims{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
TokenType: tokenType,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: randomJTI(),
|
||||
Subject: user.ID,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := token.SignedString([]byte(cfg.JWTSecret))
|
||||
if err != nil {
|
||||
slog.Error("签发 JWT 失败", "error", err)
|
||||
return ""
|
||||
}
|
||||
return tokenStr
|
||||
}
|
||||
|
||||
// randomJTI 生成 16 字节随机 token ID
|
||||
func randomJTI() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// parseDuration 解析过期时间字符串(支持 "7d"、"1h"、"30m" 等),失败返回默认值
|
||||
func parseDuration(s string, fallback time.Duration) time.Duration {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
// 支持 "7d" 格式(Go 原生 time.ParseDuration 不支持天)
|
||||
if strings.HasSuffix(s, "d") {
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
|
||||
if err == nil {
|
||||
return time.Duration(days) * 24 * time.Hour
|
||||
}
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ensureDefaultAdmin 确保默认 admin 用户存在
|
||||
func ensureDefaultAdmin(db *gorm.DB, cfg *config.Config) {
|
||||
username := cfg.DefaultAdminUsername
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
var existing model.User
|
||||
if db.Where("username = ?", username).First(&existing).Error == nil {
|
||||
return // 已存在
|
||||
}
|
||||
|
||||
password := cfg.DefaultAdminPassword
|
||||
if password == "" {
|
||||
password = "silk@123"
|
||||
}
|
||||
email := cfg.DefaultAdminEmail
|
||||
if email == "" {
|
||||
email = "admin@silk.local"
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
|
||||
if err != nil {
|
||||
slog.Error("默认 admin 密码哈希失败", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
fullName := "系统管理员"
|
||||
admin := model.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
FullName: &fullName,
|
||||
Role: "admin",
|
||||
Active: true,
|
||||
}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
slog.Error("创建默认 admin 失败", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录审计日志
|
||||
uid := admin.ID
|
||||
uname := admin.Username
|
||||
res := "users"
|
||||
desc := "default admin created"
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: &uname,
|
||||
Action: "create",
|
||||
Resource: &res,
|
||||
TargetID: &uid,
|
||||
Description: &desc,
|
||||
})
|
||||
|
||||
slog.Info("默认 admin 用户已创建", "username", username)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MQTTPublisher MQTT 发布接口(后续 MQTT 模块实现后注入)
|
||||
type MQTTPublisher interface {
|
||||
Publish(topic string, payload interface{}) error
|
||||
}
|
||||
|
||||
// DeviceCommander 设备命令发送接口(向特定设备发送命令)
|
||||
type DeviceCommander interface {
|
||||
PublishToDevice(deviceKey string, payload interface{}) error
|
||||
GetIRResult(deviceKey string) interface{}
|
||||
GetIRLearnedCodes(deviceKey string) []int
|
||||
}
|
||||
|
||||
// mqttPublisher 全局 MQTT 发布者实例(后续 MQTT 模块初始化后赋值)
|
||||
var mqttPublisher MQTTPublisher
|
||||
|
||||
// deviceCommander 全局设备命令发送者(用于 GSTMB1 等设备特定命令)
|
||||
var deviceCommander DeviceCommander
|
||||
|
||||
// SetMQTTPublisher 设置全局 MQTT 发布者(供 main 或 MQTT 模块调用)
|
||||
func SetMQTTPublisher(p MQTTPublisher) {
|
||||
mqttPublisher = p
|
||||
}
|
||||
|
||||
// SetDeviceCommander 设置全局设备命令发送者
|
||||
func SetDeviceCommander(d DeviceCommander) {
|
||||
deviceCommander = d
|
||||
}
|
||||
|
||||
// ControlCommand 控制命令
|
||||
type ControlCommand struct {
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
Action string `json:"action"`
|
||||
Value interface{} `json:"value"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// RegisterControlRoutes 注册控制命令路由
|
||||
func RegisterControlRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
perm := middleware.RequirePermission(db, "device:control")
|
||||
rg.POST("/control/send", perm, sendControl())
|
||||
rg.POST("/control/batch", perm, batchControl())
|
||||
rg.POST("/devices/:id/gstmb1/command", perm, sendGSTMB1Command(db))
|
||||
rg.POST("/devices/:id/gstmb1/info", perm, sendGSTMB1Info(db))
|
||||
rg.POST("/devices/:id/gstmb1/restart", perm, sendGSTMB1Restart(db))
|
||||
rg.POST("/devices/:id/gstmb1/interval", perm, sendGSTMB1Interval(db))
|
||||
rg.POST("/devices/:id/plug/on", perm, sendPlugOn(db))
|
||||
rg.POST("/devices/:id/plug/off", perm, sendPlugOff(db))
|
||||
rg.POST("/devices/:id/plug/statistic", perm, sendPlugStatistic(db))
|
||||
rg.POST("/devices/:id/plug/info", perm, sendGSTMB1Info(db)) // 插座 info 命令与传感器相同
|
||||
// GSCU1B-4G 红外控制器
|
||||
rg.POST("/devices/:id/ir/learn", perm, sendIRLearn(db))
|
||||
rg.POST("/devices/:id/ir/emit", perm, sendIREmit(db))
|
||||
rg.POST("/devices/:id/ir/cancel", perm, sendIRCancel(db))
|
||||
rg.POST("/devices/:id/ir/erase", perm, sendIRErase(db))
|
||||
rg.GET("/devices/:id/ir/status", perm, getIRStatus(db))
|
||||
}
|
||||
|
||||
// sendControl 下发单条控制命令(通过 MQTT 发布到 devices/{deviceKey}/cmd 主题)
|
||||
func sendControl() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var cmd ControlCommand
|
||||
if err := c.ShouldBindJSON(&cmd); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := publishControl(cmd)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== GSPE1B 智能插座控制 =====
|
||||
|
||||
// sendPlugOn 插座通电 {"type":"event","key":1}
|
||||
func sendPlugOn(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "event",
|
||||
"key": 1,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendPlugOff 插座断电 {"type":"event","key":0}
|
||||
func sendPlugOff(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "event",
|
||||
"key": 0,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendPlugStatistic 查询插座电量信息 {"type":"statistic"}
|
||||
func sendPlugStatistic(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "statistic",
|
||||
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== GSCU1B-4G 红外控制器 =====
|
||||
|
||||
// sendIRLearn 学习红外码 {"type":"infrared","action":"learn","data":{"no":N}}
|
||||
func sendIRLearn(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
No int `json:"no"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.No < 1 || body.No > 248 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no 必须为 1-248 的整数"})
|
||||
return
|
||||
}
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "infrared",
|
||||
"action": "learn",
|
||||
"data": map[string]int{"no": body.No},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendIREmit 发射红外码 {"type":"infrared","action":"emit","data":{"no":N}}
|
||||
func sendIREmit(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
No int `json:"no"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.No < 1 || body.No > 248 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no 必须为 1-248 的整数"})
|
||||
return
|
||||
}
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "infrared",
|
||||
"action": "emit",
|
||||
"data": map[string]int{"no": body.No},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendIRCancel 取消学习 {"type":"infrared","action":"learnCancel"}
|
||||
func sendIRCancel(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "infrared",
|
||||
"action": "learnCancel",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendIRErase 擦除全部红外码 {"type":"infrared","action":"erase"}
|
||||
func sendIRErase(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "infrared",
|
||||
"action": "erase",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// getIRStatus 查询红外操作结果
|
||||
func getIRStatus(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey, err := getDeviceKey(db, c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if deviceCommander == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "设备命令服务未初始化"})
|
||||
return
|
||||
}
|
||||
result := deviceCommander.GetIRResult(deviceKey)
|
||||
learnedCodes := deviceCommander.GetIRLearnedCodes(deviceKey)
|
||||
if result == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"hasResult": false, "learnedCodes": learnedCodes})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"hasResult": true, "result": result, "learnedCodes": learnedCodes})
|
||||
}
|
||||
}
|
||||
|
||||
// batchControl 批量下发控制命令
|
||||
func batchControl() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Commands []ControlCommand `json:"commands"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]gin.H, 0, len(body.Commands))
|
||||
for _, cmd := range body.Commands {
|
||||
result, err := publishControl(cmd)
|
||||
if err != nil {
|
||||
results = append(results, gin.H{"error": err.Error(), "deviceKey": cmd.DeviceKey})
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
}
|
||||
|
||||
// publishControl 发布控制命令到 MQTT 主题 devices/{deviceKey}/cmd
|
||||
func publishControl(cmd ControlCommand) (gin.H, error) {
|
||||
if cmd.DeviceKey == "" || cmd.Action == "" {
|
||||
return nil, &controlError{"deviceKey/action required"}
|
||||
}
|
||||
|
||||
topic := "devices/" + cmd.DeviceKey + "/cmd"
|
||||
|
||||
// 构建消息体:action + value + payload 展开 + ts
|
||||
payload := map[string]interface{}{
|
||||
"action": cmd.Action,
|
||||
"value": cmd.Value,
|
||||
}
|
||||
for k, v := range cmd.Payload {
|
||||
payload[k] = v
|
||||
}
|
||||
payload["ts"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
// 通过 MQTT 发布(若未注入则仅记录日志)
|
||||
if mqttPublisher != nil {
|
||||
if err := mqttPublisher.Publish(topic, payload); err != nil {
|
||||
slog.Warn("MQTT 发布失败", "topic", topic, "error", err)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("MQTT 发布者未注入,跳过实际发布", "topic", topic)
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"topic": topic,
|
||||
"payload": payload,
|
||||
"ok": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// controlError 控制命令错误
|
||||
type controlError struct{ msg string }
|
||||
|
||||
func (e *controlError) Error() string { return e.msg }
|
||||
|
||||
// ===== GSTMB1 命令处理 =====
|
||||
|
||||
// getDeviceKey 从数据库查找设备的 deviceKey
|
||||
func getDeviceKey(db *gorm.DB, deviceID string) (string, error) {
|
||||
var device struct {
|
||||
DeviceKey string `gorm:"column:device_key"`
|
||||
}
|
||||
if err := db.Table("devices").Where("id = ?", deviceID).First(&device).Error; err != nil {
|
||||
return "", fmt.Errorf("设备不存在")
|
||||
}
|
||||
return device.DeviceKey, nil
|
||||
}
|
||||
|
||||
// publishGSTMB1Command 向 GSTMB1 设备发送命令
|
||||
func publishGSTMB1Command(db *gorm.DB, deviceID string, payload map[string]interface{}) (gin.H, error) {
|
||||
deviceKey, err := getDeviceKey(db, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deviceCommander == nil {
|
||||
return nil, fmt.Errorf("设备命令服务未初始化")
|
||||
}
|
||||
if err := deviceCommander.PublishToDevice(deviceKey, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("GSTMB1 命令已发送", "deviceKey", deviceKey, "payload", payload)
|
||||
return gin.H{"ok": true, "deviceKey": deviceKey, "payload": payload}, nil
|
||||
}
|
||||
|
||||
// sendGSTMB1Command 发送自定义 GSTMB1 命令(body 原样转发)
|
||||
func sendGSTMB1Command(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendGSTMB1Info 获取设备信息 {"type":"info"}
|
||||
func sendGSTMB1Info(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "info",
|
||||
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendGSTMB1Restart 重启设备 {"type":"setting","system":"restart"}
|
||||
func sendGSTMB1Restart(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "setting",
|
||||
"system": "restart",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// sendGSTMB1Interval 设置定时上报间隔 {"type":"setting","timerEnable":1,"timerInterval":N}
|
||||
func sendGSTMB1Interval(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
interval := body.Interval
|
||||
if interval < 5 {
|
||||
interval = 60 // 默认 60 秒
|
||||
}
|
||||
if interval > 86400 {
|
||||
interval = 86400
|
||||
}
|
||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||
"type": "setting",
|
||||
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||
"timerEnable": 1,
|
||||
"timerInterval": interval,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterDeviceRoutes 注册设备路由
|
||||
func RegisterDeviceRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/devices", middleware.RequirePermission(db, "device:read"), listDevices(db))
|
||||
rg.GET("/devices/:id", middleware.RequirePermission(db, "device:read"), getDevice(db))
|
||||
rg.POST("/devices", middleware.RequirePermission(db, "room:write"), createDevice(db))
|
||||
rg.PATCH("/devices/:id", middleware.RequirePermission(db, "room:write"), updateDevice(db))
|
||||
rg.DELETE("/devices/:id", middleware.RequirePermission(db, "room:write"), deleteDevice(db))
|
||||
}
|
||||
|
||||
// listDevices 设备列表
|
||||
func listDevices(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var devices []model.Device
|
||||
query := db.Order("created_at DESC")
|
||||
if kind := c.Query("kind"); kind != "" {
|
||||
query = query.Where("kind = ?", kind)
|
||||
}
|
||||
query.Find(&devices)
|
||||
c.JSON(http.StatusOK, devices)
|
||||
}
|
||||
}
|
||||
|
||||
// getDevice 设备详情
|
||||
func getDevice(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var device model.Device
|
||||
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, device)
|
||||
}
|
||||
}
|
||||
|
||||
// createDevice 新建设备(检查 deviceKey 唯一)
|
||||
func createDevice(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var device model.Device
|
||||
if err := c.ShouldBindJSON(&device); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if device.DeviceKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceKey 不能为空"})
|
||||
return
|
||||
}
|
||||
// 检查 deviceKey 是否已存在
|
||||
var existing model.Device
|
||||
if db.Where("device_key = ?", device.DeviceKey).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "deviceKey already exists"})
|
||||
return
|
||||
}
|
||||
device.ID = "" // 让数据库自动生成
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, device)
|
||||
}
|
||||
}
|
||||
|
||||
// updateDevice 更新设备
|
||||
func updateDevice(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var device model.Device
|
||||
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Device{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&device)
|
||||
c.JSON(http.StatusOK, device)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteDevice 删除设备
|
||||
func deleteDevice(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var device model.Device
|
||||
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||
return
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Device{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterHealthRoutes 注册健康检查路由
|
||||
func RegisterHealthRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// notificationItem 通知项
|
||||
type notificationItem struct {
|
||||
ID string `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
Target string `json:"target"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// 通知内存存储(后续可替换为 Redis)
|
||||
var (
|
||||
notificationStore []notificationItem
|
||||
notificationMu sync.Mutex
|
||||
)
|
||||
|
||||
// RegisterNotificationRoutes 注册通知路由
|
||||
func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
readPerm := middleware.RequirePermission(db, "alarm:read")
|
||||
rg.GET("/notifications", readPerm, listNotifications())
|
||||
rg.POST("/notifications", readPerm, createNotification())
|
||||
}
|
||||
|
||||
// listNotifications 通知列表(上限200)
|
||||
func listNotifications() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
notificationMu.Lock()
|
||||
defer notificationMu.Unlock()
|
||||
|
||||
limit := 200
|
||||
if len(notificationStore) < limit {
|
||||
limit = len(notificationStore)
|
||||
}
|
||||
// 返回最新的 limit 条(存储已按新到旧排序)
|
||||
result := make([]notificationItem, limit)
|
||||
copy(result, notificationStore[:limit])
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// createNotification 手动发通知
|
||||
func createNotification() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Channel string `json:"channel"`
|
||||
Target string `json:"target"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ntf := notificationItem{
|
||||
ID: fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(1000000)),
|
||||
Channel: body.Channel,
|
||||
Target: body.Target,
|
||||
Title: body.Title,
|
||||
Body: body.Body,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
notificationMu.Lock()
|
||||
// 插入到头部(最新在前)
|
||||
notificationStore = append([]notificationItem{ntf}, notificationStore...)
|
||||
// 保留最近 500 条
|
||||
if len(notificationStore) > 500 {
|
||||
notificationStore = notificationStore[:500]
|
||||
}
|
||||
notificationMu.Unlock()
|
||||
|
||||
c.JSON(http.StatusCreated, ntf)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterPermissionRoutes 注册权限和角色查询路由(需要 user:manage 权限)
|
||||
func RegisterPermissionRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
perm := middleware.RequirePermission(db, "user:manage")
|
||||
rg.GET("/permissions", perm, listPermissions(db))
|
||||
rg.GET("/roles", perm, listRoles(db))
|
||||
}
|
||||
|
||||
// listPermissions 权限列表
|
||||
func listPermissions(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var perms []model.Permission
|
||||
db.Order("code ASC").Find(&perms)
|
||||
c.JSON(http.StatusOK, perms)
|
||||
}
|
||||
}
|
||||
|
||||
// listRoles 角色列表(含权限码)
|
||||
func listRoles(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
type roleInfo struct {
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// 查询角色-权限映射
|
||||
var rows []struct {
|
||||
Role string `gorm:"column:role"`
|
||||
Code string `gorm:"column:code"`
|
||||
}
|
||||
db.Table("role_permissions").
|
||||
Select("role_permissions.role AS role, permissions.code AS code").
|
||||
Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
|
||||
Order("role_permissions.role, permissions.code").
|
||||
Scan(&rows)
|
||||
|
||||
rolePermMap := make(map[string][]string)
|
||||
for _, r := range rows {
|
||||
rolePermMap[r.Role] = append(rolePermMap[r.Role], r.Code)
|
||||
}
|
||||
|
||||
result := make([]roleInfo, 0, len(model.AllRoles))
|
||||
for _, role := range model.AllRoles {
|
||||
result = append(result, roleInfo{
|
||||
Role: role,
|
||||
Name: model.RoleNames[role],
|
||||
Permissions: rolePermMap[role],
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterRoomRoutes 注册蚕房路由
|
||||
func RegisterRoomRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/rooms", middleware.RequirePermission(db, "room:read"), listRooms(db))
|
||||
rg.GET("/rooms/:id", middleware.RequirePermission(db, "room:read"), getRoom(db))
|
||||
rg.POST("/rooms", middleware.RequirePermission(db, "room:write"), createRoom(db))
|
||||
rg.PATCH("/rooms/:id", middleware.RequirePermission(db, "room:write"), updateRoom(db))
|
||||
rg.DELETE("/rooms/:id", middleware.RequirePermission(db, "room:write"), deleteRoom(db))
|
||||
}
|
||||
|
||||
// listRooms 蚕房列表
|
||||
func listRooms(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var rooms []model.Room
|
||||
db.Order("created_at DESC").Find(&rooms)
|
||||
c.JSON(http.StatusOK, rooms)
|
||||
}
|
||||
}
|
||||
|
||||
// getRoom 蚕房详情
|
||||
func getRoom(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var room model.Room
|
||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, room)
|
||||
}
|
||||
}
|
||||
|
||||
// createRoom 新建蚕房
|
||||
func createRoom(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var room model.Room
|
||||
if err := c.ShouldBindJSON(&room); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
room.ID = "" // 让数据库自动生成
|
||||
if err := db.Create(&room).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, room)
|
||||
}
|
||||
}
|
||||
|
||||
// updateRoom 更新蚕房
|
||||
func updateRoom(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var room model.Room
|
||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Room{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&room)
|
||||
c.JSON(http.StatusOK, room)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteRoom 删除蚕房
|
||||
func deleteRoom(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var room model.Room
|
||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||
return
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Room{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
// camelToSnake 将 camelCase 转为 snake_case(供所有 CRUD handler 复用)
|
||||
func camelToSnake(s string) string {
|
||||
var result strings.Builder
|
||||
for i, r := range s {
|
||||
if i > 0 && unicode.IsUpper(r) {
|
||||
result.WriteRune('_')
|
||||
}
|
||||
result.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// bindUpdates 绑定 JSON body 并转为 snake_case 的 map(排除不可更新字段)
|
||||
func bindUpdates(c *gin.Context) (map[string]interface{}, error) {
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates := make(map[string]interface{})
|
||||
for k, v := range body {
|
||||
// 排除不可更新字段
|
||||
if k == "id" || k == "createdAt" || k == "updatedAt" {
|
||||
continue
|
||||
}
|
||||
updates[camelToSnake(k)] = v
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterSensorRoutes 注册传感器路由
|
||||
func RegisterSensorRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/sensors", middleware.RequirePermission(db, "device:read"), listSensors(db))
|
||||
rg.GET("/sensors/:id", middleware.RequirePermission(db, "device:read"), getSensor(db))
|
||||
rg.POST("/sensors", middleware.RequirePermission(db, "room:write"), createSensor(db))
|
||||
rg.PATCH("/sensors/:id", middleware.RequirePermission(db, "room:write"), updateSensor(db))
|
||||
rg.DELETE("/sensors/:id", middleware.RequirePermission(db, "room:write"), deleteSensor(db))
|
||||
}
|
||||
|
||||
// listSensors 传感器列表
|
||||
func listSensors(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var sensors []model.Sensor
|
||||
db.Order("created_at DESC").Find(&sensors)
|
||||
c.JSON(http.StatusOK, sensors)
|
||||
}
|
||||
}
|
||||
|
||||
// getSensor 传感器详情
|
||||
func getSensor(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var sensor model.Sensor
|
||||
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sensor)
|
||||
}
|
||||
}
|
||||
|
||||
// createSensor 新建传感器
|
||||
func createSensor(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var sensor model.Sensor
|
||||
if err := c.ShouldBindJSON(&sensor); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sensor.ID = "" // 让数据库自动生成
|
||||
if err := db.Create(&sensor).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, sensor)
|
||||
}
|
||||
}
|
||||
|
||||
// updateSensor 更新传感器
|
||||
func updateSensor(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var sensor model.Sensor
|
||||
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Sensor{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&sensor)
|
||||
c.JSON(http.StatusOK, sensor)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteSensor 删除传感器
|
||||
func deleteSensor(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var sensor model.Sensor
|
||||
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||
return
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Sensor{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterStorageRoutes 注册存储状态路由
|
||||
func RegisterStorageRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/storage/ceph", middleware.RequirePermission(db, "video:read"), getCephStorage())
|
||||
}
|
||||
|
||||
// getCephStorage 获取 Ceph 存储使用情况
|
||||
func getCephStorage() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 1. ceph df --format json
|
||||
dfCmd := exec.Command("ceph", "df", "--format", "json")
|
||||
dfOutput, err := dfCmd.Output()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ceph df 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var dfData map[string]interface{}
|
||||
if err := json.Unmarshal(dfOutput, &dfData); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析 ceph df 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. ceph osd tree --format json
|
||||
treeCmd := exec.Command("ceph", "osd", "tree", "--format", "json")
|
||||
treeOutput, err := treeCmd.Output()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ceph osd tree 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var treeData map[string]interface{}
|
||||
if err := json.Unmarshal(treeOutput, &treeData); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析 ceph osd tree 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. ceph health --format json
|
||||
healthCmd := exec.Command("ceph", "health", "--format", "json")
|
||||
healthOutput, err := healthCmd.Output()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ceph health 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var healthData map[string]interface{}
|
||||
if err := json.Unmarshal(healthOutput, &healthData); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析 ceph health 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"df": dfData,
|
||||
"osdTree": treeData,
|
||||
"health": healthData,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
)
|
||||
|
||||
// RegisterTelemetryRoutes 注册遥测数据查询路由
|
||||
func RegisterTelemetryRoutes(rg *gin.RouterGroup, db *gorm.DB, iotdb *service.IoTDBService) {
|
||||
readPerm := middleware.RequirePermission(db, "device:read")
|
||||
rg.GET("/telemetry", readPerm, listTelemetry(db, iotdb))
|
||||
rg.GET("/telemetry/:deviceKey/metrics", readPerm, listMetrics(iotdb, db))
|
||||
rg.GET("/telemetry/:deviceKey/latest", readPerm, latestTelemetry(iotdb, db))
|
||||
rg.GET("/telemetry/:deviceKey/:metric/history", readPerm, historyBucket(iotdb, db))
|
||||
}
|
||||
|
||||
// GET /telemetry?deviceKey=&metric=&from=&to=&limit=
|
||||
func listTelemetry(db *gorm.DB, iotdb *service.IoTDBService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey := c.Query("deviceKey")
|
||||
metric := c.Query("metric")
|
||||
from := c.Query("from")
|
||||
to := c.Query("to")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "2000"))
|
||||
if limit <= 0 {
|
||||
limit = 2000
|
||||
}
|
||||
|
||||
// 优先 IoTDB
|
||||
if iotdb.IsAvailable() && deviceKey != "" && metric != "" {
|
||||
fromTime, toTime := parseTimeRange(from, to)
|
||||
rows, err := iotdb.QueryHistory(deviceKey, metric, fromTime, toTime, limit)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, rows)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 降级 PostgreSQL
|
||||
q := db.Model(&model.Telemetry{}).Order("timestamp DESC").Limit(limit)
|
||||
if deviceKey != "" {
|
||||
q = q.Where("device_key = ?", deviceKey)
|
||||
}
|
||||
if metric != "" {
|
||||
q = q.Where("metric = ?", metric)
|
||||
}
|
||||
if from != "" || to != "" {
|
||||
fromVal, toVal := parseTimeRangeStr(from, to)
|
||||
q = q.Where("timestamp BETWEEN ? AND ?", fromVal, toVal)
|
||||
}
|
||||
var rows []model.Telemetry
|
||||
q.Find(&rows)
|
||||
c.JSON(http.StatusOK, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /telemetry/:deviceKey/metrics
|
||||
func listMetrics(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey := c.Param("deviceKey")
|
||||
|
||||
// 优先 IoTDB
|
||||
if iotdb.IsAvailable() {
|
||||
metrics, err := iotdb.ListMetrics(deviceKey)
|
||||
if err == nil && len(metrics) > 0 {
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 降级 PG
|
||||
var metrics []string
|
||||
db.Model(&model.Telemetry{}).Distinct("metric").Where("device_key = ?", deviceKey).Pluck("metric", &metrics)
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /telemetry/:deviceKey/latest
|
||||
func latestTelemetry(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey := c.Param("deviceKey")
|
||||
|
||||
// 查询 PG 中该设备每个指标的最新记录
|
||||
var records []model.Telemetry
|
||||
if err := db.Raw(`SELECT DISTINCT ON (metric) * FROM telemetry WHERE device_key = ? ORDER BY metric, timestamp DESC`, deviceKey).Scan(&records).Error; err != nil || len(records) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到遥测数据"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /telemetry/:deviceKey/:metric/history?from=&to=&bucketMin=
|
||||
func historyBucket(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceKey := c.Param("deviceKey")
|
||||
metric := c.Param("metric")
|
||||
from := c.Query("from")
|
||||
to := c.Query("to")
|
||||
if from == "" || to == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "from/to required"})
|
||||
return
|
||||
}
|
||||
bucketMin, _ := strconv.Atoi(c.DefaultQuery("bucketMin", "5"))
|
||||
if bucketMin <= 0 {
|
||||
bucketMin = 5
|
||||
}
|
||||
|
||||
fromTime, _ := time.Parse(time.RFC3339, from)
|
||||
toTime, _ := time.Parse(time.RFC3339, to)
|
||||
|
||||
// 优先 IoTDB
|
||||
if iotdb.IsAvailable() {
|
||||
rows, err := iotdb.AggregateByBucket(deviceKey, metric, fromTime, toTime, bucketMin)
|
||||
if err == nil && len(rows) > 0 {
|
||||
c.JSON(http.StatusOK, rows)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 降级 PG
|
||||
type BucketResult struct {
|
||||
Bucket time.Time `json:"time"`
|
||||
Avg float64 `json:"value"`
|
||||
}
|
||||
var results []BucketResult
|
||||
sql := `SELECT time_bucket(?, timestamp) AS bucket, AVG(value) AS avg
|
||||
FROM telemetry WHERE device_key = ? AND metric = ? AND timestamp BETWEEN ? AND ?
|
||||
GROUP BY 1 ORDER BY 1 ASC`
|
||||
if err := db.Raw(sql, strconv.Itoa(bucketMin)+" minutes", deviceKey, metric, fromTime, toTime).Scan(&results).Error; err != nil || len(results) == 0 {
|
||||
// time_bucket 不可用(无 TimescaleDB),降级为原始数据
|
||||
var raw []model.Telemetry
|
||||
db.Where("device_key = ? AND metric = ? AND timestamp BETWEEN ? AND ?", deviceKey, metric, fromTime, toTime).
|
||||
Order("timestamp ASC").Limit(500).Find(&raw)
|
||||
for _, r := range raw {
|
||||
results = append(results, BucketResult{Bucket: r.Timestamp, Avg: r.Value})
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 辅助函数 ---
|
||||
|
||||
func parseTimeRange(from, to string) (time.Time, time.Time) {
|
||||
var fromTime, toTime time.Time
|
||||
if from != "" {
|
||||
fromTime, _ = time.Parse(time.RFC3339, from)
|
||||
}
|
||||
if to != "" {
|
||||
toTime, _ = time.Parse(time.RFC3339, to)
|
||||
} else {
|
||||
toTime = time.Now()
|
||||
}
|
||||
return fromTime, toTime
|
||||
}
|
||||
|
||||
func parseTimeRangeStr(from, to string) (string, string) {
|
||||
if from == "" {
|
||||
from = "1970-01-01T00:00:00Z"
|
||||
}
|
||||
if to == "" {
|
||||
to = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterThresholdRoutes 注册阈值路由
|
||||
func RegisterThresholdRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/thresholds", middleware.RequirePermission(db, "threshold:read"), listThresholds(db))
|
||||
rg.GET("/thresholds/:id", middleware.RequirePermission(db, "threshold:read"), getThreshold(db))
|
||||
rg.POST("/thresholds", middleware.RequirePermission(db, "threshold:write"), createThreshold(db))
|
||||
rg.PATCH("/thresholds/:id", middleware.RequirePermission(db, "threshold:write"), updateThreshold(db))
|
||||
rg.DELETE("/thresholds/:id", middleware.RequirePermission(db, "threshold:write"), deleteThreshold(db))
|
||||
}
|
||||
|
||||
// listThresholds 阈值列表
|
||||
func listThresholds(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var thresholds []model.Threshold
|
||||
db.Find(&thresholds)
|
||||
c.JSON(http.StatusOK, thresholds)
|
||||
}
|
||||
}
|
||||
|
||||
// getThreshold 阈值详情
|
||||
func getThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var threshold model.Threshold
|
||||
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
// createThreshold 新建阈值
|
||||
func createThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var threshold model.Threshold
|
||||
if err := c.ShouldBindJSON(&threshold); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
threshold.ID = "" // 让数据库自动生成
|
||||
if err := db.Create(&threshold).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
// updateThreshold 更新阈值
|
||||
func updateThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var threshold model.Threshold
|
||||
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Threshold{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&threshold)
|
||||
c.JSON(http.StatusOK, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteThreshold 删除阈值
|
||||
func deleteThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var threshold model.Threshold
|
||||
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
||||
return
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Threshold{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterUserRoutes 注册用户管理路由(均需 user:manage 权限)
|
||||
func RegisterUserRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
perm := middleware.RequirePermission(db, "user:manage")
|
||||
rg.GET("/users", perm, listUsers(db))
|
||||
rg.GET("/users/:id", perm, getUser(db))
|
||||
rg.PATCH("/users/:id", perm, updateUser(db))
|
||||
}
|
||||
|
||||
// toUserPublic 将 User 转为公开信息(不含密码哈希)
|
||||
func toUserPublic(u model.User) gin.H {
|
||||
return gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"email": u.Email,
|
||||
"fullName": u.FullName,
|
||||
"role": u.Role,
|
||||
"active": u.Active,
|
||||
"createdAt": u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// listUsers 用户列表
|
||||
func listUsers(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var users []model.User
|
||||
db.Select("id", "username", "email", "full_name", "role", "active", "created_at").
|
||||
Order("created_at DESC").Find(&users)
|
||||
|
||||
result := make([]gin.H, 0, len(users))
|
||||
for _, u := range users {
|
||||
result = append(result, toUserPublic(u))
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// getUser 用户详情
|
||||
func getUser(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var user model.User
|
||||
if db.Select("id", "username", "email", "full_name", "role", "active", "created_at").
|
||||
Where("id = ?", id).First(&user).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toUserPublic(user))
|
||||
}
|
||||
}
|
||||
|
||||
// updateUser 更新用户(角色/active/fullName/email),记录审计日志
|
||||
func updateUser(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// 先检查用户是否存在
|
||||
var user model.User
|
||||
if db.Where("id = ?", id).First(&user).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许更新 fullName/email/role/active
|
||||
updates := make(map[string]interface{})
|
||||
if v, ok := body["fullName"]; ok {
|
||||
updates["full_name"] = v
|
||||
}
|
||||
if v, ok := body["email"]; ok {
|
||||
updates["email"] = v
|
||||
}
|
||||
if v, ok := body["role"]; ok {
|
||||
roleStr, _ := v.(string)
|
||||
valid := false
|
||||
for _, r := range model.AllRoles {
|
||||
if r == roleStr {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的角色,可选:" + strings.Join(model.AllRoles, ", ")})
|
||||
return
|
||||
}
|
||||
updates["role"] = v
|
||||
}
|
||||
if v, ok := body["active"]; ok {
|
||||
updates["active"] = v
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.User{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
|
||||
// 记录审计日志
|
||||
var actorID, actorName *string
|
||||
if u, ok := c.Get("user"); ok {
|
||||
if userMap, ok := u.(map[string]interface{}); ok {
|
||||
if sub, ok := userMap["sub"].(string); ok {
|
||||
s := sub
|
||||
actorID = &s
|
||||
}
|
||||
if uname, ok := userMap["username"].(string); ok {
|
||||
s := uname
|
||||
actorName = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
action := "update"
|
||||
description := "user updated"
|
||||
if _, ok := body["role"]; ok {
|
||||
action = "role_change"
|
||||
if r, ok := body["role"].(string); ok {
|
||||
description = "role changed to " + r
|
||||
}
|
||||
}
|
||||
res := "users"
|
||||
tid := id
|
||||
metaBytes, _ := json.Marshal(body)
|
||||
recordAudit(db, &model.AuditLog{
|
||||
UserID: actorID,
|
||||
Username: actorName,
|
||||
Action: action,
|
||||
Resource: &res,
|
||||
TargetID: &tid,
|
||||
Description: &description,
|
||||
Metadata: metaBytes,
|
||||
})
|
||||
|
||||
// 返回更新后的用户
|
||||
db.Select("id", "username", "email", "full_name", "role", "active", "created_at").
|
||||
Where("id = ?", id).First(&user)
|
||||
c.JSON(http.StatusOK, toUserPublic(user))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterVideoCameraRoutes 注册摄像头管理路由
|
||||
func RegisterVideoCameraRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service.MediaService) {
|
||||
readPerm := middleware.RequirePermission(db, "video:read")
|
||||
writePerm := middleware.RequirePermission(db, "video:record")
|
||||
rg.GET("/video/cameras", readPerm, listCameras(db, media))
|
||||
rg.GET("/video/cameras/:id", readPerm, getCamera(db))
|
||||
rg.POST("/video/cameras", writePerm, createCamera(db, media))
|
||||
rg.PATCH("/video/cameras/:id", writePerm, updateCamera(db, media))
|
||||
rg.DELETE("/video/cameras/:id", writePerm, deleteCamera(db, media))
|
||||
rg.POST("/video/cameras/:id/play", readPerm, playCamera(db, media))
|
||||
rg.POST("/video/cameras/:id/live", readPerm, playCamera(db, media))
|
||||
rg.GET("/video/cameras/:id/playback", readPerm, playbackCamera(db))
|
||||
rg.GET("/video/wvp-config", readPerm, getWvpConfig(media))
|
||||
rg.GET("/video/wvp/devices", readPerm, listWvpDevices(media))
|
||||
rg.GET("/video/wvp/devices/:deviceId/channels", readPerm, listWvpChannels(media))
|
||||
rg.POST("/video/wvp/devices/:deviceId/sync", writePerm, syncWvpDevice(media))
|
||||
}
|
||||
|
||||
// listCameras 摄像头列表(按 createdAt DESC,同步 WVP 设备信息:在线状态 + 共有参数)
|
||||
func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var cameras []model.Camera
|
||||
db.Order("created_at DESC").Find(&cameras)
|
||||
|
||||
// 先用 DB 中的 is_online 初始化 Online 字段(gorm:"-" 不会自动填充)
|
||||
for i := range cameras {
|
||||
cameras[i].Online = cameras[i].IsOnline
|
||||
}
|
||||
|
||||
// 同步 WVP 设备信息(WVP → silk,失败时保留 DB 状态)
|
||||
if devMap, err := media.SyncWvpDevices(); err == nil {
|
||||
for i := range cameras {
|
||||
if cameras[i].GbDeviceID == nil || *cameras[i].GbDeviceID == "" {
|
||||
continue
|
||||
}
|
||||
info, ok := devMap[*cameras[i].GbDeviceID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 同步在线状态
|
||||
cameras[i].Online = info.OnLine
|
||||
// 同步共有参数(WVP → silk),仅当 WVP 侧有值时才覆盖
|
||||
updates := map[string]interface{}{}
|
||||
if info.OnLine != cameras[i].IsOnline {
|
||||
updates["is_online"] = info.OnLine
|
||||
cameras[i].IsOnline = info.OnLine
|
||||
}
|
||||
if info.Name != "" && cameras[i].Name != info.Name {
|
||||
updates["name"] = info.Name
|
||||
cameras[i].Name = info.Name
|
||||
}
|
||||
if info.Manufacturer != "" {
|
||||
if cameras[i].GbManufacturer == nil || *cameras[i].GbManufacturer != info.Manufacturer {
|
||||
updates["gb_manufacturer"] = info.Manufacturer
|
||||
cameras[i].GbManufacturer = &info.Manufacturer
|
||||
}
|
||||
}
|
||||
if info.Password != "" {
|
||||
if cameras[i].GbAuthPassword == nil || *cameras[i].GbAuthPassword != info.Password {
|
||||
updates["gb_auth_password"] = info.Password
|
||||
cameras[i].GbAuthPassword = &info.Password
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Camera{}).Where("id = ?", cameras[i].ID).Updates(updates)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("同步 WVP 设备信息失败,保留 DB 状态", "error", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cameras)
|
||||
}
|
||||
}
|
||||
|
||||
// getCamera 摄像头详情
|
||||
func getCamera(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, camera)
|
||||
}
|
||||
}
|
||||
|
||||
// createCamera 新建摄像头
|
||||
func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var camera model.Camera
|
||||
if err := c.ShouldBindJSON(&camera); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
camera.ID = 0 // 让数据库自动生成
|
||||
if camera.RoomID == nil {
|
||||
defaultRoom := "1"
|
||||
camera.RoomID = &defaultRoom
|
||||
}
|
||||
if err := db.Create(&camera).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
// 预添加设备到 WVP(设置独立密码,摄像头注册前 WVP 已有设备记录)
|
||||
if camera.GbDeviceID != nil && *camera.GbDeviceID != "" {
|
||||
manufacturer := ""
|
||||
if camera.GbManufacturer != nil {
|
||||
manufacturer = *camera.GbManufacturer
|
||||
}
|
||||
password := ""
|
||||
if camera.GbAuthPassword != nil {
|
||||
password = *camera.GbAuthPassword
|
||||
}
|
||||
if err := media.AddWvpDevice(*camera.GbDeviceID, camera.Name, manufacturer, password); err != nil {
|
||||
// 设备可能已存在(之前添加过),尝试更新
|
||||
if err2 := media.UpdateWvpDevice(*camera.GbDeviceID, camera.Name, manufacturer, password); err2 != nil {
|
||||
slog.Warn("预添加/同步设备到 WVP 均失败", "deviceId", *camera.GbDeviceID, "addError", err, "updateError", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusCreated, camera)
|
||||
}
|
||||
}
|
||||
|
||||
// updateCamera 更新摄像头
|
||||
func updateCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
updates, err := bindUpdates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := db.Model(&model.Camera{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", id).First(&camera)
|
||||
// 同步共有参数到 WVP(silk → WVP,更新了 name/manufacturer/password 任一字段就触发)
|
||||
if camera.GbDeviceID != nil && *camera.GbDeviceID != "" {
|
||||
needSync := false
|
||||
for _, key := range []string{"name", "gb_manufacturer", "gb_auth_password"} {
|
||||
if _, ok := updates[key]; ok {
|
||||
needSync = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needSync {
|
||||
manufacturer := ""
|
||||
if camera.GbManufacturer != nil {
|
||||
manufacturer = *camera.GbManufacturer
|
||||
}
|
||||
password := ""
|
||||
if camera.GbAuthPassword != nil {
|
||||
password = *camera.GbAuthPassword
|
||||
}
|
||||
if err := media.UpdateWvpDevice(*camera.GbDeviceID, camera.Name, manufacturer, password); err != nil {
|
||||
slog.Warn("同步摄像头信息到 WVP 失败", "deviceId", *camera.GbDeviceID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, camera)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteCamera 删除摄像头
|
||||
func deleteCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
// 同步删除 WVP 中的设备
|
||||
if camera.GbDeviceID != nil && *camera.GbDeviceID != "" {
|
||||
if err := media.DeleteWvpDevice(*camera.GbDeviceID); err != nil {
|
||||
slog.Warn("从 WVP 删除设备失败", "deviceId", *camera.GbDeviceID, "error", err)
|
||||
}
|
||||
}
|
||||
db.Where("id = ?", id).Delete(&model.Camera{})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
// playCamera 播放摄像头实时流(body: {format},调用 media.StartPlay)
|
||||
func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
if !camera.IsOnline {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头离线,无法播放"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Format string `json:"format"`
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
format := body.Format
|
||||
if format == "" {
|
||||
format = "hls"
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
|
||||
|
||||
// GB28181 摄像头:通过 WVP 媒体服务器播放
|
||||
if camera.GbDeviceID != nil && camera.GbChannelID != nil &&
|
||||
*camera.GbDeviceID != "" && *camera.GbChannelID != "" {
|
||||
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
// StartPlay 失败不等于摄像头离线,可能是 WVP/ZLM 瞬时问题,不修改 is_online
|
||||
slog.Warn("StartPlay 失败", "deviceId", *camera.GbDeviceID, "channelId", *camera.GbChannelID, "error", err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "播放失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
url := result.HLS
|
||||
if format == "flv" && result.FLV != "" {
|
||||
url = result.FLV
|
||||
} else if format == "webrtc" && result.WebRtc != "" {
|
||||
url = result.WebRtc
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": camera.ID,
|
||||
"gbDeviceId": camera.GbDeviceID,
|
||||
"gbChannelId": camera.GbChannelID,
|
||||
"format": format,
|
||||
"url": url,
|
||||
"expiresAt": expiresAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback:使用摄像头自身的流地址
|
||||
var url string
|
||||
switch format {
|
||||
case "flv":
|
||||
if camera.FlvURL != nil {
|
||||
url = *camera.FlvURL
|
||||
}
|
||||
case "webrtc":
|
||||
if camera.WebrtcURL != nil {
|
||||
url = *camera.WebrtcURL
|
||||
}
|
||||
default:
|
||||
if camera.HlsURL != nil {
|
||||
url = *camera.HlsURL
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": camera.ID,
|
||||
"gbDeviceId": nil,
|
||||
"gbChannelId": nil,
|
||||
"format": format,
|
||||
"url": url,
|
||||
"expiresAt": expiresAt,
|
||||
"mock": url == "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// playbackCamera 查询摄像头的历史录像片段(query: from/to/limit)
|
||||
func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
||||
limit = l
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
}
|
||||
|
||||
q := db.Where("camera_id = ?", id).Order("start_at DESC").Limit(limit)
|
||||
if from := c.Query("from"); from != "" {
|
||||
q = q.Where("start_at >= ?", from)
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
q = q.Where("start_at <= ?", to)
|
||||
}
|
||||
|
||||
var clips []model.VideoClip
|
||||
q.Find(&clips)
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
for i := range clips {
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
||||
clips[i].PlaybackURL = &url
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clips)
|
||||
}
|
||||
}
|
||||
|
||||
// getWvpConfig 查询 WVP SIP 配置(供前端展示,便于配置摄像头硬件)
|
||||
func getWvpConfig(media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
config, err := media.GetServerConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "获取 WVP 配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
sip, ok := config["sip"].(map[string]interface{})
|
||||
if !ok {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WVP 配置中未找到 SIP 部分"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sipId": sip["id"],
|
||||
"sipDomain": sip["domain"],
|
||||
"sipPassword": sip["password"],
|
||||
"sipPort": sip["port"],
|
||||
"sipShowIp": sip["showIp"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// listWvpDevices 查询 WVP 已注册设备列表
|
||||
func listWvpDevices(media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
query := c.Query("query")
|
||||
devices, err := media.ListWvpDevices(query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "获取 WVP 设备列表失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, devices)
|
||||
}
|
||||
}
|
||||
|
||||
// listWvpChannels 查询 WVP 设备的通道列表
|
||||
func listWvpChannels(media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceId := c.Param("deviceId")
|
||||
if deviceId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId is required"})
|
||||
return
|
||||
}
|
||||
channels, err := media.ListWvpChannels(deviceId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "获取通道列表失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, channels)
|
||||
}
|
||||
}
|
||||
|
||||
// syncWvpDevice 触发 WVP 设备通道同步
|
||||
func syncWvpDevice(media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceId := c.Param("deviceId")
|
||||
if deviceId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId is required"})
|
||||
return
|
||||
}
|
||||
if err := media.SyncWvpDevice(deviceId); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "同步失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterVideoClipRoutes 注册录像片段管理路由
|
||||
func RegisterVideoClipRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
readPerm := middleware.RequirePermission(db, "video:read")
|
||||
rg.GET("/video/clips", readPerm, listClips(db))
|
||||
rg.GET("/video/clips/:clipId/play", readPerm, playClip(db))
|
||||
rg.POST("/video/clips/internal", createClipInternal(db, cfg)) // 白名单接口,无需权限
|
||||
}
|
||||
|
||||
// listClips 录像片段列表(query: cameraId/from/to/limit,按 startAt DESC)
|
||||
func listClips(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
||||
limit = l
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
}
|
||||
|
||||
q := db.Model(&model.VideoClip{}).Order("start_at DESC").Limit(limit)
|
||||
if cameraId := c.Query("cameraId"); cameraId != "" {
|
||||
q = q.Where("camera_id = ?", cameraId)
|
||||
}
|
||||
if from := c.Query("from"); from != "" {
|
||||
q = q.Where("start_at >= ?", from)
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
q = q.Where("start_at <= ?", to)
|
||||
}
|
||||
|
||||
clips := make([]model.VideoClip, 0)
|
||||
q.Find(&clips)
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
for i := range clips {
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
||||
clips[i].PlaybackURL = &url
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": clips, "total": len(clips)})
|
||||
}
|
||||
}
|
||||
|
||||
// playClip 获取片段播放地址
|
||||
func playClip(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clipId := c.Param("clipId")
|
||||
var clip model.VideoClip
|
||||
if db.Where("id = ?", clipId).First(&clip).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "video clip not found"})
|
||||
return
|
||||
}
|
||||
|
||||
format := clip.Format
|
||||
if format == "" {
|
||||
format = "mp4"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clipId": clip.ID,
|
||||
"url": fmt.Sprintf("/api/v1/video/clips/%d/stream", clip.ID),
|
||||
"format": format,
|
||||
"expiresAt": time.Now().Add(60 * time.Minute).UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createClipInternal 内部接口创建录像片段记录(校验 x-api-key header)
|
||||
func createClipInternal(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 校验 x-api-key
|
||||
apiKey := c.GetHeader("x-api-key")
|
||||
if apiKey != cfg.InternalAPIKey {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
CameraID string `json:"cameraId"`
|
||||
Trigger string `json:"trigger"`
|
||||
Format string `json:"format"`
|
||||
StartAt string `json:"startAt"`
|
||||
DurationSec float64 `json:"durationSec"`
|
||||
SizeBytes string `json:"sizeBytes"`
|
||||
S3Bucket string `json:"s3Bucket"`
|
||||
S3Key string `json:"s3Key"`
|
||||
Resolution string `json:"resolution"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 去重:同一 s3Key 不重复创建
|
||||
var existing model.VideoClip
|
||||
if db.Where("s3_key = ?", body.S3Key).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, existing)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析开始时间
|
||||
startAt, err := time.Parse(time.RFC3339, body.StartAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid startAt, expected RFC3339 format"})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
trigger := body.Trigger
|
||||
if trigger == "" {
|
||||
trigger = "schedule"
|
||||
}
|
||||
format := body.Format
|
||||
if format == "" {
|
||||
format = "mp4"
|
||||
}
|
||||
notes := body.Notes
|
||||
if notes == "" {
|
||||
notes = "自动录制归档"
|
||||
}
|
||||
|
||||
clip := model.VideoClip{
|
||||
CameraID: body.CameraID,
|
||||
Trigger: trigger,
|
||||
Format: format,
|
||||
StartAt: startAt,
|
||||
DurationSec: body.DurationSec,
|
||||
S3Bucket: &body.S3Bucket,
|
||||
S3Key: &body.S3Key,
|
||||
Notes: ¬es,
|
||||
}
|
||||
// 从 camera 查询 room_id
|
||||
var camera model.Camera
|
||||
if err := db.Where("id = ?", body.CameraID).First(&camera).Error; err == nil {
|
||||
clip.RoomID = camera.RoomID
|
||||
}
|
||||
if body.SizeBytes != "" {
|
||||
if n, err := strconv.ParseInt(body.SizeBytes, 10, 64); err == nil {
|
||||
clip.SizeBytes = &n
|
||||
}
|
||||
}
|
||||
if body.Resolution != "" {
|
||||
clip.Resolution = &body.Resolution
|
||||
}
|
||||
|
||||
if err := db.Create(&clip).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, clip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ActiveRecording 活跃录制记录(内存维护)
|
||||
type ActiveRecording struct {
|
||||
CameraID string `json:"cameraId"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
ChannelID string `json:"channelId"`
|
||||
Stream string `json:"stream"`
|
||||
App string `json:"app"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
}
|
||||
|
||||
// 活跃录制内存表(cameraId -> recording)
|
||||
var (
|
||||
activeRecordings = make(map[string]*ActiveRecording)
|
||||
activeMu sync.Mutex
|
||||
)
|
||||
|
||||
// RegisterVideoRecordRoutes 注册录制管理路由
|
||||
func RegisterVideoRecordRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service.MediaService, cfg *config.Config) {
|
||||
recordPerm := middleware.RequirePermission(db, "video:record")
|
||||
readPerm := middleware.RequirePermission(db, "video:read")
|
||||
rg.POST("/video/cameras/:id/record/start", recordPerm, startRecording(db, media, cfg))
|
||||
rg.POST("/video/cameras/:id/record/stop", recordPerm, stopRecording(db, media, cfg))
|
||||
rg.GET("/video/recordings/active", readPerm, listActiveRecordings(cfg))
|
||||
rg.POST("/video/recordings/internal/end", endRecordingInternal(media, cfg)) // 白名单接口,无需权限
|
||||
}
|
||||
|
||||
// startRecording 开始录制:查摄像头 → media.StartPlay → POST recorderApiBase/record/start
|
||||
func startRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
if camera.GbDeviceID == nil || camera.GbChannelID == nil ||
|
||||
*camera.GbDeviceID == "" || *camera.GbChannelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头未配置 GB28181 或不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已在录制
|
||||
activeMu.Lock()
|
||||
if _, ok := activeRecordings[cameraId]; ok {
|
||||
activeMu.Unlock()
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该摄像头正在录制中"})
|
||||
return
|
||||
}
|
||||
activeMu.Unlock()
|
||||
|
||||
// 1. 确保视频流在线(WVP play/start)
|
||||
_, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
slog.Warn("开始播放失败,跳过录制", "cameraId", cameraId, "error", err)
|
||||
// StartPlay 失败,标记摄像头离线
|
||||
db.Model(&model.Camera{}).Where("id = ?", cameraId).Update("is_online", false)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "摄像头可能离线: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 通知 Python 录制服务开始录制
|
||||
stream := *camera.GbDeviceID + "_" + *camera.GbChannelID
|
||||
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"stream": stream,
|
||||
"cameraId": cameraId,
|
||||
"deviceId": *camera.GbDeviceID,
|
||||
"channelId": *camera.GbChannelID,
|
||||
})
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Post(recorderBase+"/record/start", "application/json", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "录制服务不可用: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": fmt.Sprintf("录制服务返回 %d: %s", resp.StatusCode, string(respBody))})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 记录活跃录制
|
||||
rec := &ActiveRecording{
|
||||
CameraID: cameraId,
|
||||
DeviceID: *camera.GbDeviceID,
|
||||
ChannelID: *camera.GbChannelID,
|
||||
Stream: stream,
|
||||
App: "rtp",
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
activeMu.Lock()
|
||||
activeRecordings[cameraId] = rec
|
||||
activeMu.Unlock()
|
||||
|
||||
slog.Info("录制已开始", "cameraId", cameraId, "stream", stream)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": cameraId,
|
||||
"stream": stream,
|
||||
"startedAt": rec.StartedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stopRecording 停止录制:通知录制服务 + WVP play/stop 清理 session
|
||||
func stopRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
|
||||
activeMu.Lock()
|
||||
rec, ok := activeRecordings[cameraId]
|
||||
if ok {
|
||||
delete(activeRecordings, cameraId)
|
||||
}
|
||||
activeMu.Unlock()
|
||||
|
||||
// 即使没有活跃录制,也尝试用摄像头的 GB 信息停止 WVP play
|
||||
var deviceID, channelID string
|
||||
if ok {
|
||||
deviceID = rec.DeviceID
|
||||
channelID = rec.ChannelID
|
||||
} else {
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", cameraId).First(&camera).Error == nil &&
|
||||
camera.GbDeviceID != nil && camera.GbChannelID != nil {
|
||||
deviceID = *camera.GbDeviceID
|
||||
channelID = *camera.GbChannelID
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 通知录制服务停止
|
||||
if ok {
|
||||
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"stream": rec.Stream,
|
||||
})
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Post(recorderBase+"/record/stop", "application/json", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
slog.Warn("停止录制服务失败", "cameraId", cameraId, "error", err)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 停止 WVP play session(清理 ZLMediaKit 流)
|
||||
if deviceID != "" && channelID != "" {
|
||||
media.StopPlay(deviceID, channelID)
|
||||
}
|
||||
|
||||
slog.Info("录制已停止", "cameraId", cameraId, "hadActiveRecording", ok)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": cameraId,
|
||||
"stopped": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// endRecordingInternal 录制服务内部接口:通知录制已结束(流 EOF / 错误 / 停止)
|
||||
// 清理 activeRecordings 并停止 WVP play session
|
||||
func endRecordingInternal(media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
apiKey := c.GetHeader("x-api-key")
|
||||
if apiKey != cfg.InternalAPIKey {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Stream string `json:"stream"`
|
||||
CameraID string `json:"cameraId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
activeMu.Lock()
|
||||
rec, ok := activeRecordings[body.CameraID]
|
||||
if ok {
|
||||
delete(activeRecordings, body.CameraID)
|
||||
}
|
||||
activeMu.Unlock()
|
||||
|
||||
if ok {
|
||||
slog.Info("录制服务通知录制结束", "cameraId", body.CameraID, "stream", body.Stream)
|
||||
// 停止 WVP play session,清理 ZLMediaKit 流
|
||||
media.StopPlay(rec.DeviceID, rec.ChannelID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// listActiveRecordings 返回活跃录制列表
|
||||
// 优先以 recorder-go 服务的实际状态为准(Go 后端重启后内存 map 会丢失,但 recorder-go 仍在录制)
|
||||
func listActiveRecordings(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 1. 查询 recorder-go 实际录制状态
|
||||
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(recorderBase + "/record/status")
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var statusResp struct {
|
||||
Recordings []struct {
|
||||
Stream string `json:"stream"`
|
||||
CameraID string `json:"cameraId"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
SegmentCount int `json:"segmentCount"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&statusResp); err == nil && len(statusResp.Recordings) > 0 {
|
||||
list := make([]*ActiveRecording, 0, len(statusResp.Recordings))
|
||||
for _, r := range statusResp.Recordings {
|
||||
startedAt, _ := time.Parse(time.RFC3339, r.StartedAt)
|
||||
if startedAt.IsZero() {
|
||||
startedAt = time.Now()
|
||||
}
|
||||
// 拆分 stream 得到 deviceId/channelId
|
||||
deviceID, channelID := "", ""
|
||||
parts := strings.SplitN(r.Stream, "_", 2)
|
||||
if len(parts) == 2 {
|
||||
deviceID, channelID = parts[0], parts[1]
|
||||
}
|
||||
list = append(list, &ActiveRecording{
|
||||
CameraID: r.CameraID,
|
||||
DeviceID: deviceID,
|
||||
ChannelID: channelID,
|
||||
Stream: r.Stream,
|
||||
App: "rtp",
|
||||
StartedAt: startedAt,
|
||||
})
|
||||
// 同步更新内存 map(自愈:Go 后端重启后内存丢失,从 recorder-go 恢复)
|
||||
activeMu.Lock()
|
||||
if _, ok := activeRecordings[r.CameraID]; !ok {
|
||||
activeRecordings[r.CameraID] = &ActiveRecording{
|
||||
CameraID: r.CameraID,
|
||||
DeviceID: deviceID,
|
||||
ChannelID: channelID,
|
||||
Stream: r.Stream,
|
||||
App: "rtp",
|
||||
StartedAt: startedAt,
|
||||
}
|
||||
}
|
||||
activeMu.Unlock()
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. recorder-go 不可用时回退到内存 map
|
||||
activeMu.Lock()
|
||||
list := make([]*ActiveRecording, 0, len(activeRecordings))
|
||||
for _, rec := range activeRecordings {
|
||||
list = append(list, rec)
|
||||
}
|
||||
activeMu.Unlock()
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
"silk-server-go/internal/model"
|
||||
"silk-server-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterVideoStreamRoutes 注册视频流代理路由(公开接口,不需要 JWT)
|
||||
func RegisterVideoStreamRoutes(rg *gin.RouterGroup, transcode *service.TranscodeService, db *gorm.DB, media *service.MediaService, cfg *config.Config) {
|
||||
rg.GET("/video/clips/:clipId/stream", streamClip(transcode))
|
||||
rg.GET("/video/cameras/:id/live/stream", streamLive(db, media, cfg))
|
||||
rg.GET("/video/cameras/:id/live/proxy", proxyLive(db, media, cfg))
|
||||
}
|
||||
|
||||
// streamClip 视频流代理 + ffmpeg 转码
|
||||
func streamClip(transcode *service.TranscodeService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clipId := c.Param("clipId")
|
||||
c.Header("Content-Type", "video/mp4")
|
||||
|
||||
if err := transcode.StreamClip(clipId, c.Writer); err != nil {
|
||||
slog.Error("视频流转码失败", "clipId", clipId, "error", err)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamLive 实时直播流代理(直接转发,不转码)
|
||||
func streamLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
if camera.GbDeviceID == nil || camera.GbChannelID == nil ||
|
||||
*camera.GbDeviceID == "" || *camera.GbChannelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头未配置 GB28181"})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 WVP 启动流(非阻塞,超时也不影响后续代理)
|
||||
sourceURL := ""
|
||||
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
slog.Warn("直播流代理:StartPlay 失败,尝试直接代理", "cameraId", cameraId, "error", err)
|
||||
// StartPlay 失败时,直接构造 ZLM FLV 地址
|
||||
sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + "/rtp/" + *camera.GbDeviceID + "_" + *camera.GbChannelID + ".live.flv?originTypeStr=rtp_push&videoCodec=H264"
|
||||
} else {
|
||||
sourceURL = result.FLV
|
||||
}
|
||||
|
||||
if sourceURL == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "无法获取视频流地址"})
|
||||
return
|
||||
}
|
||||
|
||||
// 确保地址是完整的 ZLM 地址(WVP 返回的可能是相对路径 /rtp/...)
|
||||
if strings.HasPrefix(sourceURL, "/") {
|
||||
sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + sourceURL
|
||||
}
|
||||
|
||||
slog.Info("直播流代理", "cameraId", cameraId, "sourceURL", sourceURL)
|
||||
|
||||
resp, err := http.Get(sourceURL)
|
||||
if err != nil {
|
||||
slog.Error("直播流代理:获取源流失败", "cameraId", cameraId, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "获取视频流失败"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Header(key, value)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("直播流代理:转发流失败", "cameraId", cameraId, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proxyLive 实时直播流直接代理(不转码,性能更好)
|
||||
func proxyLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
|
||||
// 1. 查摄像头
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
if camera.GbDeviceID == nil || camera.GbChannelID == nil ||
|
||||
*camera.GbDeviceID == "" || *camera.GbChannelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头未配置 GB28181"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 确保 WVP 流在线
|
||||
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
slog.Warn("直播流代理:StartPlay 失败", "cameraId", cameraId, "error", err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "摄像头可能离线: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 获取 FLV 源地址
|
||||
sourceURL := result.FLV
|
||||
if sourceURL == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "无法获取视频流地址"})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 确保地址是完整的 ZLM 地址
|
||||
if strings.HasPrefix(sourceURL, "/") {
|
||||
sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + sourceURL
|
||||
}
|
||||
|
||||
slog.Info("直播流代理", "cameraId", cameraId, "sourceURL", sourceURL)
|
||||
|
||||
// 5. 直接代理转发
|
||||
resp, err := http.Get(sourceURL)
|
||||
if err != nil {
|
||||
slog.Error("直播流代理:获取源流失败", "cameraId", cameraId, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "获取视频流失败"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 复制响应头
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Header(key, value)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
// 复制响应体
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("直播流代理:转发流失败", "cameraId", cameraId, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Admin 管理员权限中间件,检查 context 中 user 的 role 是否为 admin
|
||||
func Admin() gin.HandlerFunc {
|
||||
return AdminMiddleware()
|
||||
}
|
||||
|
||||
// AdminMiddleware 管理员权限中间件,检查 context 中 user 的 role 是否为 admin
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
userMap, ok := user.(map[string]interface{})
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
|
||||
return
|
||||
}
|
||||
|
||||
role, ok := userMap["role"].(string)
|
||||
if !ok || role != "admin" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"silk-server-go/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// JWTClaims JWT 负载,与 NestJS 签发格式一致
|
||||
type JWTClaims struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
TokenType string `json:"tokenType,omitempty"` // access | refresh,空值视为 access(兼容旧令牌)
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// 白名单路径,无需鉴权
|
||||
var whitelist = map[string]bool{
|
||||
"/api/v1/health": true,
|
||||
"/health": true,
|
||||
"/api/health": true,
|
||||
"/api/v1/auth/login": true,
|
||||
"/auth/login": true,
|
||||
"/api/v1/auth/register": true,
|
||||
"/auth/register": true,
|
||||
"/api/v1/auth/refresh": true,
|
||||
"/auth/refresh": true,
|
||||
"/api/v1/video/clips/internal": true,
|
||||
"/api/v1/video/recordings/internal/end": true,
|
||||
}
|
||||
|
||||
// Auth JWT 鉴权中间件
|
||||
func Auth(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 白名单路径跳过鉴权
|
||||
if whitelist[path] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 视频片段流接口支持动态 ID,使用前缀+后缀匹配放行
|
||||
if strings.HasPrefix(path, "/api/v1/video/clips/") && strings.HasSuffix(path, "/stream") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 直播流转码代理接口
|
||||
if strings.HasPrefix(path, "/api/v1/video/cameras/") && (strings.HasSuffix(path, "/live/stream") || strings.HasSuffix(path, "/live/proxy")) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Authorization: Bearer <token>
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未提供认证信息"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证格式错误"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
|
||||
// 用 JWTSecret 验证 token,仅允许 HS256 算法
|
||||
claims := &JWTClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 校验令牌是否已被登出吊销
|
||||
if IsRevoked(claims) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌已注销,请重新登录"})
|
||||
return
|
||||
}
|
||||
// 拒绝使用 refresh 令牌访问业务接口
|
||||
if claims.TokenType == "refresh" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存入 context
|
||||
c.Set("user", map[string]interface{}{
|
||||
"sub": claims.Subject,
|
||||
"username": claims.Username,
|
||||
"role": claims.Role,
|
||||
})
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件,限制允许的来源、方法和头
|
||||
func CORS() gin.HandlerFunc {
|
||||
// 允许的来源:同源、本地开发、Tailscale 网段、局域网
|
||||
allowedOrigins := []string{
|
||||
"http://localhost:5174",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5174",
|
||||
"http://127.0.0.1:3000",
|
||||
}
|
||||
|
||||
isAllowed := func(origin string) bool {
|
||||
for _, o := range allowedOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 允许 Tailscale 100.x.x.x 和局域网 192.168.x.x 访问
|
||||
if strings.HasPrefix(origin, "http://100.") || strings.HasPrefix(origin, "http://192.168.") ||
|
||||
strings.HasPrefix(origin, "http://113.") || strings.HasPrefix(origin, "http://115.") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && isAllowed(origin) {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Logger 请求日志中间件,记录方法、路径、状态码、耗时
|
||||
func Logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
|
||||
slog.Info("请求",
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"status", status,
|
||||
"latency", latency.String(),
|
||||
"ip", c.ClientIP(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// rolePermCache 角色-权限码缓存,避免每次请求查库
|
||||
var (
|
||||
rolePermCache = make(map[string]map[string]bool)
|
||||
rolePermCacheMu sync.RWMutex
|
||||
rolePermCacheTime time.Time
|
||||
)
|
||||
|
||||
const rolePermCacheTTL = 5 * time.Minute
|
||||
|
||||
// loadRolePermissions 从数据库加载所有角色-权限映射到缓存
|
||||
func loadRolePermissions(db *gorm.DB) map[string]map[string]bool {
|
||||
result := make(map[string]map[string]bool)
|
||||
|
||||
var rows []struct {
|
||||
Role string `gorm:"column:role"`
|
||||
Code string `gorm:"column:code"`
|
||||
}
|
||||
db.Table("role_permissions").
|
||||
Select("role_permissions.role AS role, permissions.code AS code").
|
||||
Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
|
||||
Scan(&rows)
|
||||
|
||||
for _, r := range rows {
|
||||
if result[r.Role] == nil {
|
||||
result[r.Role] = make(map[string]bool)
|
||||
}
|
||||
result[r.Role][r.Code] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getRolePermissions 获取缓存的角色权限映射(TTL 5 分钟)
|
||||
func getRolePermissions(db *gorm.DB) map[string]map[string]bool {
|
||||
rolePermCacheMu.RLock()
|
||||
if time.Since(rolePermCacheTime) < rolePermCacheTTL && len(rolePermCache) > 0 {
|
||||
cached := rolePermCache
|
||||
rolePermCacheMu.RUnlock()
|
||||
return cached
|
||||
}
|
||||
rolePermCacheMu.RUnlock()
|
||||
|
||||
rolePermCacheMu.Lock()
|
||||
defer rolePermCacheMu.Unlock()
|
||||
// 双重检查
|
||||
if time.Since(rolePermCacheTime) < rolePermCacheTTL && len(rolePermCache) > 0 {
|
||||
return rolePermCache
|
||||
}
|
||||
rolePermCache = loadRolePermissions(db)
|
||||
rolePermCacheTime = time.Now()
|
||||
return rolePermCache
|
||||
}
|
||||
|
||||
// InvalidateRolePermCache 使角色权限缓存失效(角色权限变更时调用)
|
||||
func InvalidateRolePermCache() {
|
||||
rolePermCacheMu.Lock()
|
||||
defer rolePermCacheMu.Unlock()
|
||||
rolePermCache = make(map[string]map[string]bool)
|
||||
rolePermCacheTime = time.Time{}
|
||||
}
|
||||
|
||||
// hasPermission 检查角色是否拥有指定权限码
|
||||
func hasPermission(db *gorm.DB, role, code string) bool {
|
||||
perms := getRolePermissions(db)
|
||||
rolePerms, ok := perms[role]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return rolePerms[code]
|
||||
}
|
||||
|
||||
// RequirePermission 返回一个校验指定权限码的中间件
|
||||
func RequirePermission(db *gorm.DB, code string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userVal, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
userMap, ok := userVal.(map[string]interface{})
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
|
||||
return
|
||||
}
|
||||
role, _ := userMap["role"].(string)
|
||||
if role == model.RoleAdmin {
|
||||
// admin 拥有全部权限,直接放行
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !hasPermission(db, role, code) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足,需要:" + code})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// loginAttempt 登录失败计数(按 IP + 用户名维度)
|
||||
type loginAttempt struct {
|
||||
failures int
|
||||
lockUntil time.Time
|
||||
lastFail time.Time
|
||||
}
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]*loginAttempt
|
||||
}
|
||||
|
||||
const (
|
||||
maxFailures = 5 // 连续失败 5 次后锁定
|
||||
lockDuration = 15 * time.Minute
|
||||
failureWindow = 10 * time.Minute // 失败计数窗口
|
||||
cleanupInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
var defaultLoginLimiter = newLoginLimiter()
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
l := &loginLimiter{seen: make(map[string]*loginAttempt)}
|
||||
go l.cleanupLoop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *loginLimiter) cleanupLoop() {
|
||||
t := time.NewTicker(cleanupInterval)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, v := range l.seen {
|
||||
if now.After(v.lockUntil) && now.Sub(v.lastFail) > failureWindow {
|
||||
delete(l.seen, k)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// key = ip + "|" + username(小写)
|
||||
func limiterKey(c *gin.Context, username string) string {
|
||||
return c.ClientIP() + "|" + strings.ToLower(strings.TrimSpace(username))
|
||||
}
|
||||
|
||||
// checkLock 返回是否被锁定及剩余锁定时间
|
||||
func (l *loginLimiter) checkLock(key string) (bool, time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a, ok := l.seen[key]
|
||||
if !ok {
|
||||
return false, 0
|
||||
}
|
||||
if time.Now().Before(a.lockUntil) {
|
||||
return true, time.Until(a.lockUntil)
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// recordFailure 记录一次失败,达到阈值则锁定
|
||||
func (l *loginLimiter) recordFailure(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a, ok := l.seen[key]
|
||||
if !ok {
|
||||
a = &loginAttempt{}
|
||||
l.seen[key] = a
|
||||
}
|
||||
now := time.Now()
|
||||
// 窗口外重置
|
||||
if now.Sub(a.lastFail) > failureWindow {
|
||||
a.failures = 0
|
||||
}
|
||||
a.failures++
|
||||
a.lastFail = now
|
||||
if a.failures >= maxFailures {
|
||||
a.lockUntil = now.Add(lockDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// recordSuccess 登录成功后清空计数
|
||||
func (l *loginLimiter) recordSuccess(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.seen, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// CheckLoginLock 检查是否被锁定,被锁定则写 429 并返回 true(在 handler 解析 body 后调用)
|
||||
func CheckLoginLock(c *gin.Context, username string) bool {
|
||||
key := limiterKey(c, username)
|
||||
if locked, remain := defaultLoginLimiter.checkLock(key); locked {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "登录尝试过多,已锁定,请稍后再试",
|
||||
"retry": int(remain.Minutes()) + 1,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RecordLoginFail 记录登录失败
|
||||
func RecordLoginFail(c *gin.Context, username string) {
|
||||
defaultLoginLimiter.recordFailure(limiterKey(c, username))
|
||||
}
|
||||
|
||||
// RecordLoginSuccess 登录成功后清空计数
|
||||
func RecordLoginSuccess(c *gin.Context, username string) {
|
||||
defaultLoginLimiter.recordSuccess(limiterKey(c, username))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SecurityHeaders 统一补齐 HTTP 安全响应头,缓解点击劫持、MIME 嗅探等风险
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.Writer.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
// 限制内联脚本/样式以缓解 XSS;允许同源与必要的外部资源
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'")
|
||||
// 仅在内网 HTTPS 网关后运行时生效;HTTP 下浏览器会忽略
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// tokenBlacklist 登出令牌黑名单(内存版,进程重启后失效,令牌自然过期兜底)
|
||||
type tokenBlacklist struct {
|
||||
mu sync.RWMutex
|
||||
revoked map[string]time.Time // tokenID(jti) -> 过期时间
|
||||
}
|
||||
|
||||
var defaultBlacklist = &tokenBlacklist{revoked: make(map[string]time.Time)}
|
||||
|
||||
// RevokeToken 将令牌加入黑名单(按 jti,若无 jti 则按 subject+签发时间)
|
||||
func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) {
|
||||
id := tokenIdentifier(claims)
|
||||
defaultBlacklist.mu.Lock()
|
||||
defaultBlacklist.revoked[id] = exp
|
||||
defaultBlacklist.mu.Unlock()
|
||||
}
|
||||
|
||||
// IsRevoked 判断令牌是否已被吊销
|
||||
func IsRevoked(claims *JWTClaims) bool {
|
||||
id := tokenIdentifier(claims)
|
||||
defaultBlacklist.mu.RLock()
|
||||
exp, ok := defaultBlacklist.revoked[id]
|
||||
defaultBlacklist.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// 已过期的黑名单项自动清理
|
||||
if time.Now().After(exp) {
|
||||
defaultBlacklist.mu.Lock()
|
||||
delete(defaultBlacklist.revoked, id)
|
||||
defaultBlacklist.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ExtractClaims 从 token 字符串解析 claims(供 logout handler 使用)
|
||||
func ExtractClaims(tokenStr string, secret string) (*JWTClaims, *jwt.Token, error) {
|
||||
claims := &JWTClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
return claims, token, err
|
||||
}
|
||||
|
||||
// tokenIdentifier 返回令牌唯一标识:优先 jti,否则用 subject+签发时间
|
||||
func tokenIdentifier(claims *JWTClaims) string {
|
||||
if claims.ID != "" {
|
||||
return claims.ID
|
||||
}
|
||||
iat := ""
|
||||
if claims.IssuedAt != nil {
|
||||
iat = claims.IssuedAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
return claims.Subject + "|" + iat
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User 用户表
|
||||
type User struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Username string `gorm:"unique" json:"username"`
|
||||
Email string `gorm:"unique" json:"email"`
|
||||
PasswordHash string `gorm:"column:password_hash" json:"passwordHash"`
|
||||
FullName *string `gorm:"column:full_name" json:"fullName,omitempty"`
|
||||
Role string `gorm:"default:user" json:"role"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
|
||||
// Room 房间表
|
||||
type Room struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Capacity *int `gorm:"type:int" json:"capacity,omitempty"`
|
||||
Stage *string `gorm:"size:32" json:"stage,omitempty"`
|
||||
Status string `gorm:"default:active" json:"status"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Room) TableName() string { return "rooms" }
|
||||
|
||||
// Device 设备表
|
||||
type Device struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
DeviceKey string `gorm:"column:device_key;unique" json:"deviceKey"`
|
||||
Name string `json:"name"`
|
||||
Kind string `gorm:"default:sensor" json:"kind"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
Firmware *string `json:"firmware,omitempty"`
|
||||
OnlineStatus string `gorm:"column:online_status;default:online" json:"onlineStatus"`
|
||||
LastSeen *time.Time `gorm:"column:last_seen;type:timestamptz" json:"lastSeen,omitempty"`
|
||||
RoomID string `gorm:"column:room_id;type:uuid;index" json:"roomId"`
|
||||
Topic *string `gorm:"column:topic" json:"topic,omitempty"` // 设备上行主题(持久化,防止后端重启丢失)
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Device) TableName() string { return "devices" }
|
||||
|
||||
// Sensor 传感器表
|
||||
type Sensor struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Name string `json:"name"`
|
||||
Metric string `json:"metric"`
|
||||
Unit *string `json:"unit,omitempty"`
|
||||
DataType *string `gorm:"column:data_type" json:"dataType,omitempty"`
|
||||
DeviceID string `gorm:"column:device_id;type:uuid;index" json:"deviceId"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (Sensor) TableName() string { return "sensors" }
|
||||
|
||||
// Threshold 阈值表
|
||||
type Threshold struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
MinValue float64 `gorm:"column:min_value;type:float" json:"minValue"`
|
||||
MaxValue float64 `gorm:"column:max_value;type:float" json:"maxValue"`
|
||||
DebounceSeconds int `gorm:"column:debounce_seconds;type:int;default:5" json:"debounceSeconds"`
|
||||
Severity int `gorm:"type:int;default:3" json:"severity"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
SensorID string `gorm:"column:sensor_id;type:uuid;index" json:"sensorId"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Threshold) TableName() string { return "thresholds" }
|
||||
|
||||
// Alarm 告警表
|
||||
type Alarm struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
Severity *string `json:"severity,omitempty"`
|
||||
Open bool `gorm:"default:true;index:idx_alarms_device_open_triggered,priority:2" json:"open"`
|
||||
Acknowledged bool `gorm:"default:false" json:"acknowledged"`
|
||||
AcknowledgedAt *time.Time `gorm:"column:acknowledged_at;type:timestamptz" json:"acknowledgedAt,omitempty"`
|
||||
TriggeredAt time.Time `gorm:"column:triggered_at;type:timestamptz;index:idx_alarms_device_open_triggered,priority:3" json:"triggeredAt"`
|
||||
ResolvedAt *time.Time `gorm:"column:resolved_at;type:timestamptz" json:"resolvedAt,omitempty"`
|
||||
DeviceKey *string `gorm:"column:device_key;index:idx_alarms_device_open_triggered,priority:1" json:"deviceKey,omitempty"`
|
||||
Metric *string `json:"metric,omitempty"`
|
||||
Value *float64 `gorm:"type:float" json:"value,omitempty"`
|
||||
ThresholdMin *float64 `gorm:"column:threshold_min;type:float" json:"thresholdMin,omitempty"`
|
||||
ThresholdMax *float64 `gorm:"column:threshold_max;type:float" json:"thresholdMax,omitempty"`
|
||||
}
|
||||
|
||||
func (Alarm) TableName() string { return "alarms" }
|
||||
|
||||
// Camera 摄像头表
|
||||
type Camera struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
RoomID *string `gorm:"column:room_id;index" json:"roomId,omitempty"`
|
||||
Code string `gorm:"size:64" json:"code"`
|
||||
Name string `gorm:"size:128" json:"name"`
|
||||
RtspURL *string `gorm:"column:rtsp_url;size:512" json:"rtspUrl,omitempty"`
|
||||
HTTPURL *string `gorm:"column:http_url;size:512" json:"httpUrl,omitempty"`
|
||||
Username *string `gorm:"size:64" json:"username,omitempty"`
|
||||
PasswordEnc *string `gorm:"column:password_enc;size:255" json:"passwordEnc,omitempty"`
|
||||
Position *string `gorm:"size:255" json:"position,omitempty"`
|
||||
Resolution *string `gorm:"size:32" json:"resolution,omitempty"`
|
||||
FPS *int `gorm:"type:int" json:"fps,omitempty"`
|
||||
IsOnline bool `gorm:"column:is_online;default:true" json:"isOnline"`
|
||||
GbDeviceID *string `gorm:"column:gb_device_id;size:20" json:"gbDeviceId,omitempty"`
|
||||
GbChannelID *string `gorm:"column:gb_channel_id;size:20" json:"gbChannelId,omitempty"`
|
||||
GbAuthID *string `gorm:"column:gb_auth_id;size:20" json:"gbAuthId,omitempty"`
|
||||
GbAuthPassword *string `gorm:"column:gb_auth_password;size:255" json:"gbAuthPassword,omitempty"`
|
||||
GbStreamType *string `gorm:"column:gb_stream_type;size:10" json:"gbStreamType,omitempty"`
|
||||
GbTransport *string `gorm:"column:gb_transport;size:10" json:"gbTransport,omitempty"`
|
||||
GbAlarmChannelID *string `gorm:"column:gb_alarm_channel_id;size:20" json:"gbAlarmChannelId,omitempty"`
|
||||
GbVoiceChannelID *string `gorm:"column:gb_voice_channel_id;size:20" json:"gbVoiceChannelId,omitempty"`
|
||||
GbManufacturer *string `gorm:"column:gb_manufacturer;size:64" json:"gbManufacturer,omitempty"`
|
||||
ManufacturerID *string `gorm:"column:manufacturer_id;size:64" json:"manufacturerId,omitempty"`
|
||||
// 以下字段不在数据库中,运行时通过 WVP API 填充
|
||||
StreamURL *string `gorm:"-" json:"streamUrl,omitempty"`
|
||||
HlsURL *string `gorm:"-" json:"hlsUrl,omitempty"`
|
||||
FlvURL *string `gorm:"-" json:"flvUrl,omitempty"`
|
||||
WebrtcURL *string `gorm:"-" json:"webrtcUrl,omitempty"`
|
||||
SnapshotURL *string `gorm:"-" json:"snapshotUrl,omitempty"`
|
||||
Online bool `gorm:"-" json:"online"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Camera) TableName() string { return "cameras" }
|
||||
|
||||
// AfterFind 查询后同步 IsOnline → Online
|
||||
func (c *Camera) AfterFind(tx *gorm.DB) error {
|
||||
c.Online = c.IsOnline
|
||||
return nil
|
||||
}
|
||||
|
||||
// VideoClip 视频片段表
|
||||
type VideoClip struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Trigger string `gorm:"default:alarm" json:"trigger"`
|
||||
FilePath *string `gorm:"column:file_path" json:"filePath,omitempty"`
|
||||
DurationSec float64 `gorm:"column:duration_sec;type:real;default:0" json:"durationSec"`
|
||||
StartAt time.Time `gorm:"column:start_at;type:timestamptz" json:"startAt"`
|
||||
EndAt *time.Time `gorm:"column:end_at;type:timestamptz" json:"endAt,omitempty"`
|
||||
Resolution *string `json:"resolution,omitempty"`
|
||||
SizeBytes *int64 `gorm:"column:size_bytes;type:bigint" json:"sizeBytes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
CameraID string `gorm:"column:camera_id;index" json:"cameraId"`
|
||||
RoomID *string `gorm:"column:room_id;index" json:"roomId,omitempty"`
|
||||
AlarmID *string `gorm:"column:alarm_id;index" json:"alarmId,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
S3Bucket *string `gorm:"column:s3_bucket" json:"s3Bucket,omitempty"`
|
||||
S3Key *string `gorm:"column:s3_key" json:"s3Key,omitempty"`
|
||||
// 以下字段不在数据库中,运行时填充
|
||||
PlaybackURL *string `gorm:"-" json:"playbackUrl,omitempty"`
|
||||
Format string `gorm:"-" json:"format"`
|
||||
}
|
||||
|
||||
func (VideoClip) TableName() string { return "video_clips" }
|
||||
|
||||
// AuditLog 审计日志表
|
||||
type AuditLog struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
UserID *string `gorm:"column:user_id;type:uuid;index:idx_audit_logs_user_created,priority:1" json:"userId,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Action string `gorm:"index:idx_audit_logs_action_created,priority:1" json:"action"`
|
||||
Resource *string `gorm:"index:idx_audit_logs_resource_target,priority:1" json:"resource,omitempty"`
|
||||
TargetID *string `gorm:"column:target_id;index:idx_audit_logs_resource_target,priority:2" json:"targetId,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
IPAddress *string `gorm:"column:ip_address" json:"ipAddress,omitempty"`
|
||||
UserAgent *string `gorm:"column:user_agent" json:"userAgent,omitempty"`
|
||||
Metadata json.RawMessage `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz;index:idx_audit_logs_user_created,priority:2;index:idx_audit_logs_action_created,priority:2" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (AuditLog) TableName() string { return "audit_logs" }
|
||||
|
||||
// Telemetry 遥测数据表
|
||||
type Telemetry struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
DeviceKey string `gorm:"column:device_key;index:idx_telemetry_device_timestamp,priority:1" json:"deviceKey"`
|
||||
Metric string `json:"metric"`
|
||||
Value float64 `gorm:"type:float" json:"value"`
|
||||
Timestamp time.Time `gorm:"type:timestamptz;index:idx_telemetry_device_timestamp,priority:2" json:"timestamp"`
|
||||
}
|
||||
|
||||
func (Telemetry) TableName() string { return "telemetry" }
|
||||
@@ -0,0 +1,43 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 角色常量
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleOperator = "operator"
|
||||
RoleViewer = "viewer"
|
||||
RoleFarmer = "farmer"
|
||||
)
|
||||
|
||||
// AllRoles 系统支持的全部角色
|
||||
var AllRoles = []string{RoleAdmin, RoleOperator, RoleViewer, RoleFarmer}
|
||||
|
||||
// RoleNames 角色中文名映射
|
||||
var RoleNames = map[string]string{
|
||||
RoleAdmin: "管理员",
|
||||
RoleOperator: "操作员",
|
||||
RoleViewer: "查看者",
|
||||
RoleFarmer: "养殖员",
|
||||
}
|
||||
|
||||
// Permission 权限表
|
||||
type Permission struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Code string `gorm:"column:code;uniqueIndex" json:"code"` // 权限码,如 device:control
|
||||
Name string `gorm:"column:name" json:"name"` // 权限名称
|
||||
Description *string `gorm:"column:description" json:"description,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (Permission) TableName() string { return "permissions" }
|
||||
|
||||
// RolePermission 角色-权限关联表
|
||||
type RolePermission struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Role string `gorm:"column:role;index:idx_role_permissions_role_code,unique,priority:1" json:"role"`
|
||||
PermissionID string `gorm:"column:permission_id;type:uuid;index:idx_role_permissions_role_code,unique,priority:2" json:"permissionId"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (RolePermission) TableName() string { return "role_permissions" }
|
||||
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
// PermissionDef 权限定义(用于种子数据初始化)
|
||||
type PermissionDef struct {
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// AllPermissions 系统全部权限定义
|
||||
var AllPermissions = []PermissionDef{
|
||||
{"dashboard:view", "看板查看", "查看仪表盘"},
|
||||
{"room:read", "蚕房查看", "查看蚕房列表和详情"},
|
||||
{"room:write", "蚕房管理", "新增、编辑、删除蚕房"},
|
||||
{"device:read", "设备查看", "查看设备列表和状态"},
|
||||
{"device:control", "设备控制", "下发设备控制命令"},
|
||||
{"threshold:read", "阈值查看", "查看阈值配置"},
|
||||
{"threshold:write", "阈值管理", "新增、编辑、删除阈值"},
|
||||
{"alarm:read", "告警查看", "查看告警列表"},
|
||||
{"alarm:ack", "告警确认", "确认和解除告警"},
|
||||
{"video:read", "视频查看", "查看视频监控和回放"},
|
||||
{"video:record", "视频录制", "启动和停止视频录制"},
|
||||
{"energy:view", "能耗查看", "查看能耗数据"},
|
||||
{"log:read", "日志查看", "查看控制日志"},
|
||||
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
||||
{"audit:read", "审计查看", "查看审计日志"},
|
||||
}
|
||||
|
||||
// RolePermissionMap 角色-权限码映射(种子数据)
|
||||
var RolePermissionMap = map[string][]string{
|
||||
RoleAdmin: {
|
||||
"dashboard:view", "room:read", "room:write", "device:read", "device:control",
|
||||
"threshold:read", "threshold:write", "alarm:read", "alarm:ack",
|
||||
"video:read", "video:record", "energy:view", "log:read",
|
||||
"user:manage", "audit:read",
|
||||
},
|
||||
RoleOperator: {
|
||||
"dashboard:view", "room:read", "room:write", "device:read", "device:control",
|
||||
"threshold:read", "threshold:write", "alarm:read", "alarm:ack",
|
||||
"video:read", "video:record", "energy:view", "log:read",
|
||||
},
|
||||
RoleViewer: {
|
||||
"dashboard:view", "room:read", "device:read",
|
||||
"threshold:read", "alarm:read", "video:read", "energy:view",
|
||||
},
|
||||
RoleFarmer: {
|
||||
"dashboard:view", "room:read", "device:read", "device:control",
|
||||
"alarm:read", "alarm:ack", "video:read", "energy:view",
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"silk-server-go/internal/service"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
}
|
||||
|
||||
// client WebSocket 客户端
|
||||
type client struct {
|
||||
conn *websocket.Conn
|
||||
rooms map[string]bool // 订阅的房间(device:<deviceKey>)
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// Hub WebSocket 中心,管理客户端和房间
|
||||
type Hub struct {
|
||||
jwtSecret string
|
||||
clients map[*client]bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewHub 创建 Hub
|
||||
func NewHub(jwtSecret string) *Hub {
|
||||
return &Hub{
|
||||
jwtSecret: jwtSecret,
|
||||
clients: make(map[*client]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket 处理 WebSocket 连接(Gin handler)
|
||||
func (h *Hub) HandleWebSocket(c *gin.Context) {
|
||||
// 验证 JWT(从 query.auth.token / query.token / header.Authorization 获取)
|
||||
tokenStr := ""
|
||||
if t := c.Query("auth.token"); t != "" {
|
||||
tokenStr = t
|
||||
} else if t := c.Query("token"); t != "" {
|
||||
tokenStr = t
|
||||
} else if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
tokenStr = auth[7:]
|
||||
}
|
||||
|
||||
if tokenStr == "" {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err == nil {
|
||||
conn.WriteJSON(map[string]interface{}{"event": "auth.fail", "data": map[string]bool{"ok": false}})
|
||||
conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 token
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(h.jwtSecret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err == nil {
|
||||
conn.WriteJSON(map[string]interface{}{"event": "auth.fail", "data": map[string]bool{"ok": false}})
|
||||
conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 升级为 WebSocket
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
slog.Warn("WebSocket 升级失败", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
cl := &client{
|
||||
conn: conn,
|
||||
rooms: make(map[string]bool),
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[cl] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
// 发送 auth.ok
|
||||
h.sendJSON(cl, "auth.ok", map[string]interface{}{
|
||||
"ok": true,
|
||||
"user": claims,
|
||||
})
|
||||
|
||||
go h.readPump(cl)
|
||||
go h.writePump(cl)
|
||||
}
|
||||
|
||||
// readPump 读取客户端消息
|
||||
func (h *Hub) readPump(cl *client) {
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, cl)
|
||||
h.mu.Unlock()
|
||||
cl.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, msg, err := cl.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// 解析消息(兼容 {event: "...", deviceKey: "..."} 格式)
|
||||
var data struct {
|
||||
Event string `json:"event"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &data); err != nil {
|
||||
// 尝试 Socket.IO 格式 ["event", {deviceKey: "..."}]
|
||||
var arr []json.RawMessage
|
||||
if err2 := json.Unmarshal(msg, &arr); err2 == nil && len(arr) >= 2 {
|
||||
if len(arr[0]) > 0 {
|
||||
json.Unmarshal(arr[0], &data.Event)
|
||||
}
|
||||
if len(arr[1]) > 0 {
|
||||
var payload struct {
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
}
|
||||
json.Unmarshal(arr[1], &payload)
|
||||
data.DeviceKey = payload.DeviceKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch data.Event {
|
||||
case "subscribe.device":
|
||||
room := "device:" + data.DeviceKey
|
||||
h.mu.Lock()
|
||||
cl.rooms[room] = true
|
||||
h.mu.Unlock()
|
||||
h.sendJSON(cl, "subscribed", map[string]interface{}{"ok": true, "room": room})
|
||||
case "unsubscribe.device":
|
||||
room := "device:" + data.DeviceKey
|
||||
h.mu.Lock()
|
||||
delete(cl.rooms, room)
|
||||
h.mu.Unlock()
|
||||
h.sendJSON(cl, "unsubscribed", map[string]interface{}{"ok": true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump 向客户端发送消息
|
||||
func (h *Hub) writePump(cl *client) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
cl.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-cl.send:
|
||||
if !ok {
|
||||
cl.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := cl.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
if err := cl.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendJSON 向客户端发送 JSON 消息
|
||||
func (h *Hub) sendJSON(cl *client, event string, data interface{}) {
|
||||
msg := map[string]interface{}{"event": event, "data": data}
|
||||
body, _ := json.Marshal(msg)
|
||||
select {
|
||||
case cl.send <- body:
|
||||
default:
|
||||
slog.Warn("WebSocket 客户端发送缓冲区满,丢弃消息")
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastTelemetry 广播遥测数据(实现 service.EventHub 接口)
|
||||
func (h *Hub) BroadcastTelemetry(deviceKey string, data interface{}) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
room := "device:" + deviceKey
|
||||
for cl := range h.clients {
|
||||
// 推送到设备房间
|
||||
if cl.rooms[room] {
|
||||
h.sendJSON(cl, "telemetry", data)
|
||||
}
|
||||
// 全局推送
|
||||
h.sendJSON(cl, "telemetry.all", data)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastAlarm 广播告警(实现 service.EventHub 接口)
|
||||
func (h *Hub) BroadcastAlarm(event service.AlarmEvent) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
room := "device:" + event.DeviceKey
|
||||
isRecovery := event.Code == "recovery"
|
||||
|
||||
for cl := range h.clients {
|
||||
if isRecovery {
|
||||
// 恢复通知
|
||||
h.sendJSON(cl, "alarm.recovery", event)
|
||||
if event.DeviceKey != "" && cl.rooms[room] {
|
||||
h.sendJSON(cl, "alarm.device.recovery", event)
|
||||
}
|
||||
} else {
|
||||
// 告警触发
|
||||
h.sendJSON(cl, "alarm", event)
|
||||
if event.DeviceKey != "" && cl.rooms[room] {
|
||||
h.sendJSON(cl, "alarm.device", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceStatus 广播设备状态变更(实现 service.EventHub 接口)
|
||||
func (h *Hub) BroadcastDeviceStatus(deviceKey string, status string) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
room := "device:" + deviceKey
|
||||
data := map[string]interface{}{"deviceKey": deviceKey, "status": status}
|
||||
for cl := range h.clients {
|
||||
h.sendJSON(cl, "device.status", data)
|
||||
if cl.rooms[room] {
|
||||
h.sendJSON(cl, "device.status.device", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
test
|
||||
Reference in New Issue
Block a user