chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)

This commit is contained in:
weijuesen
2026-08-10 22:30:53 +08:00
commit 84abf4454c
358 changed files with 75993 additions and 0 deletions
+175
View File
@@ -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.
+68
View File
@@ -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
)
+157
View File
@@ -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=
+40
View File
@@ -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
}
+79
View File
@@ -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})
}
}
}
+85
View File
@@ -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)
}
}
+42
View File
@@ -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)
}
}
+106
View File
@@ -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)
}
}
+447
View File
@@ -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 签发 JWTpayload: sub/username/role/tokenType/jtiHS256 + 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)
}
+401
View File
@@ -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)
}
}
+109
View File
@@ -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})
}
}
+14
View File
@@ -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)
}
}
+64
View File
@@ -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)
}
}
+126
View File
@@ -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
}
+95
View File
@@ -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})
}
}
+70
View File
@@ -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,
})
}
}
+174
View File
@@ -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
}
+95
View File
@@ -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})
}
}
+154
View File
@@ -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))
}
}
+396
View File
@@ -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)
// 同步共有参数到 WVPsilk → 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})
}
}
+170
View File
@@ -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: &notes,
}
// 从 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)
}
}
+288
View File
@@ -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)
}
}
+162
View File
@@ -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)
}
}
}
+37
View File
@@ -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()
}
}
+105
View File
@@ -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()
}
}
+50
View File
@@ -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()
}
}
+29
View File
@@ -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(),
)
}
}
+109
View File
@@ -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()
}
}
+122
View File
@@ -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))
}
+20
View File
@@ -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
}
+204
View File
@@ -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" }
+43
View File
@@ -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",
},
}
+391
View File
@@ -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 }
+723
View File
@@ -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
}
+558
View File
@@ -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 "高于"
}
+165
View File
@@ -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 URL1小时有效),与 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)
}
+172
View File
@@ -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
}
+254
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
test