feat: 收口视频访问与摄像头密钥输出
This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"silk-server-go/internal/middleware"
|
||||
"silk-server-go/internal/model"
|
||||
@@ -19,6 +20,12 @@ func RegisterAlarmClipRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
// getAlarmClip 获取告警关联视频片段(查 video_clips 表 where alarm_id=:id,按 start_at DESC)
|
||||
func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
// 先检查告警是否存在
|
||||
@@ -33,7 +40,11 @@ func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
for i := range clips {
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
||||
token, err := IssueVideoToken(*userID, "clip", strconv.FormatUint(uint64(clips[i].ID), 10), videoTokenTTL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream?videoToken=%s", clips[i].ID, token)
|
||||
clips[i].PlaybackURL = &url
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const videoTokenTTL = 5 * time.Minute
|
||||
|
||||
var videoTokenSecret []byte
|
||||
|
||||
// SetVideoTokenSecret 设置视频短时令牌签名密钥。
|
||||
func SetVideoTokenSecret(secret string) {
|
||||
videoTokenSecret = []byte(secret)
|
||||
}
|
||||
|
||||
// IssueVideoToken 签发绑定用户、资源类型和资源 ID 的短时视频令牌。
|
||||
func IssueVideoToken(userID, resourceType, resourceID string, ttl time.Duration) (string, error) {
|
||||
if len(videoTokenSecret) == 0 {
|
||||
return "", errors.New("video token secret not configured")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"purpose": "video",
|
||||
"rtype": resourceType,
|
||||
"rid": resourceID,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(videoTokenSecret)
|
||||
}
|
||||
|
||||
// ValidateVideoToken 校验视频令牌的资源类型、资源 ID、用途和有效期。
|
||||
func ValidateVideoToken(token, resourceType, resourceID string) error {
|
||||
claims, err := parseVideoToken(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateVideoClaims(claims, resourceType, resourceID, "")
|
||||
}
|
||||
|
||||
// ValidateVideoTokenForUser 额外校验令牌所属用户,用于可以拿到当前 JWT 的调用方。
|
||||
func ValidateVideoTokenForUser(token, resourceType, resourceID, userID string) error {
|
||||
claims, err := parseVideoToken(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateVideoClaims(claims, resourceType, resourceID, userID)
|
||||
}
|
||||
|
||||
// requireVideoToken 校验流代理 URL 上的 videoToken,缺失返回 401,错误/过期/资源错配返回 403。
|
||||
func requireVideoToken(c *gin.Context, resourceType, resourceID string) bool {
|
||||
token := c.Query("videoToken")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing video token"})
|
||||
return false
|
||||
}
|
||||
if err := ValidateVideoToken(token, resourceType, resourceID); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "invalid video token"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseVideoToken(token string) (jwt.MapClaims, error) {
|
||||
if len(videoTokenSecret) == 0 {
|
||||
return nil, errors.New("video token secret not configured")
|
||||
}
|
||||
claims := jwt.MapClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return videoTokenSecret, nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.Valid {
|
||||
return nil, errors.New("invalid video token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func validateVideoClaims(claims jwt.MapClaims, resourceType, resourceID, userID string) error {
|
||||
if claims["purpose"] != "video" {
|
||||
return errors.New("invalid video token purpose")
|
||||
}
|
||||
if claims["rtype"] != resourceType {
|
||||
return errors.New("video token resource type mismatch")
|
||||
}
|
||||
if claims["rid"] != resourceID {
|
||||
return errors.New("video token resource mismatch")
|
||||
}
|
||||
if userID != "" && claims["sub"] != userID {
|
||||
return errors.New("video token user mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestCameraResponseNeverContainsSecrets(t *testing.T) {
|
||||
password := "secret-password"
|
||||
camera := model.Camera{
|
||||
PasswordEnc: &password,
|
||||
GbAuthPassword: &password,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(camera)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal camera: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
if strings.Contains(body, "passwordEnc") || strings.Contains(body, password) {
|
||||
t.Fatalf("camera JSON must not contain password fields: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "gbAuthPassword") {
|
||||
t.Fatalf("camera JSON must not contain gbAuthPassword: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAndValidateVideoToken(t *testing.T) {
|
||||
SetVideoTokenSecret("test-secret")
|
||||
token, err := IssueVideoToken("user-1", "camera", "12", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue video token: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected non-empty video token")
|
||||
}
|
||||
|
||||
if err := ValidateVideoToken(token, "camera", "12"); err != nil {
|
||||
t.Fatalf("valid video token rejected: %v", err)
|
||||
}
|
||||
if err := ValidateVideoToken(token, "camera", "13"); err == nil {
|
||||
t.Fatal("expected resource mismatch error")
|
||||
}
|
||||
if err := ValidateVideoToken(token, "clip", "12"); err == nil {
|
||||
t.Fatal("expected resource type mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVideoTokenForUserRejectsMismatch(t *testing.T) {
|
||||
SetVideoTokenSecret("test-secret")
|
||||
token, err := IssueVideoToken("user-1", "camera", "12", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue video token: %v", err)
|
||||
}
|
||||
|
||||
if err := ValidateVideoTokenForUser(token, "camera", "12", "user-1"); err != nil {
|
||||
t.Fatalf("same-user token rejected: %v", err)
|
||||
}
|
||||
if err := ValidateVideoTokenForUser(token, "camera", "12", "user-2"); err == nil {
|
||||
t.Fatal("expected cross-user token rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoTokenExpires(t *testing.T) {
|
||||
SetVideoTokenSecret("test-secret")
|
||||
token, err := IssueVideoToken("user-1", "camera", "12", -time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("issue expired token: %v", err)
|
||||
}
|
||||
if err := ValidateVideoToken(token, "camera", "12"); err == nil {
|
||||
t.Fatal("expected expired token rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoStreamRequiresValidToken(t *testing.T) {
|
||||
SetVideoTokenSecret("test-secret")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = &http.Request{URL: &url.URL{RawQuery: ""}}
|
||||
if requireVideoToken(c, "camera", "12") {
|
||||
t.Fatal("missing token should be rejected")
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing token status = %d, want 401", rec.Code)
|
||||
}
|
||||
|
||||
token, err := IssueVideoToken("user-1", "camera", "13", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(rec2)
|
||||
c2.Request = &http.Request{URL: &url.URL{RawQuery: "videoToken=" + token}}
|
||||
if requireVideoToken(c2, "camera", "12") {
|
||||
t.Fatal("wrong resource token should be rejected")
|
||||
}
|
||||
if rec2.Code != http.StatusForbidden {
|
||||
t.Fatalf("wrong resource token status = %d, want 403", rec2.Code)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,11 @@ func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
slog.Warn("同步 WVP 设备信息失败,保留 DB 状态", "error", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cameras)
|
||||
publicCameras := make([]CameraPublic, 0, len(cameras))
|
||||
for i := range cameras {
|
||||
publicCameras = append(publicCameras, toCameraPublic(cameras[i]))
|
||||
}
|
||||
c.JSON(http.StatusOK, publicCameras)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,18 +103,19 @@ func getCamera(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, camera)
|
||||
c.JSON(http.StatusOK, toCameraPublic(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 {
|
||||
var input CameraInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
camera := input.toModel()
|
||||
camera.ID = 0 // 让数据库自动生成
|
||||
if camera.RoomID == nil {
|
||||
defaultRoom := "1"
|
||||
@@ -137,7 +142,7 @@ func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusCreated, camera)
|
||||
c.JSON(http.StatusCreated, toCameraPublic(camera))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +190,7 @@ func updateCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, camera)
|
||||
c.JSON(http.StatusOK, toCameraPublic(camera))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +217,12 @@ func deleteCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
// playCamera 播放摄像头实时流(body: {format},调用 media.StartPlay)
|
||||
func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||
@@ -223,71 +234,45 @@ func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Format string `json:"format"`
|
||||
token, err := IssueVideoToken(*userID, "camera", strconv.FormatUint(uint64(camera.ID), 10), videoTokenTTL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成播放令牌失败"})
|
||||
return
|
||||
}
|
||||
c.ShouldBindJSON(&body)
|
||||
format := body.Format
|
||||
if format == "" {
|
||||
format = "hls"
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
|
||||
expiresAt := time.Now().Add(videoTokenTTL).UTC().Format(time.RFC3339)
|
||||
streamURL := fmt.Sprintf("/api/v1/video/cameras/%d/live/proxy?videoToken=%s", camera.ID, token)
|
||||
|
||||
// GB28181 摄像头:通过 WVP 媒体服务器播放
|
||||
if camera.GbDeviceID != nil && camera.GbChannelID != nil &&
|
||||
*camera.GbDeviceID != "" && *camera.GbChannelID != "" {
|
||||
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if err != nil {
|
||||
_, playErr := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
|
||||
if playErr != 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()})
|
||||
slog.Warn("StartPlay 失败", "deviceId", *camera.GbDeviceID, "channelId", *camera.GbChannelID, "error", playErr)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "播放失败: " + playErr.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,
|
||||
"format": "flv",
|
||||
"url": streamURL,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 不再返回直连流地址,避免绕过令牌代理。
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cameraId": camera.ID,
|
||||
"gbDeviceId": nil,
|
||||
"gbChannelId": nil,
|
||||
"format": format,
|
||||
"url": url,
|
||||
"format": "flv",
|
||||
"url": streamURL,
|
||||
"expiresAt": expiresAt,
|
||||
"mock": url == "",
|
||||
"mock": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,6 +280,12 @@ func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||
// playbackCamera 查询摄像头的历史录像片段(query: from/to/limit)
|
||||
func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
limit := 50
|
||||
@@ -318,7 +309,11 @@ func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
for i := range clips {
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
||||
token, err := IssueVideoToken(*userID, "clip", strconv.FormatUint(uint64(clips[i].ID), 10), videoTokenTTL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream?videoToken=%s", clips[i].ID, token)
|
||||
clips[i].PlaybackURL = &url
|
||||
}
|
||||
|
||||
@@ -342,7 +337,6 @@ func getWvpConfig(media *service.MediaService) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sipId": sip["id"],
|
||||
"sipDomain": sip["domain"],
|
||||
"sipPassword": sip["password"],
|
||||
"sipPort": sip["port"],
|
||||
"sipShowIp": sip["showIp"],
|
||||
})
|
||||
|
||||
@@ -25,6 +25,12 @@ func RegisterVideoClipRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Confi
|
||||
// listClips 录像片段列表(query: cameraId/from/to/limit,按 startAt DESC)
|
||||
func listClips(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
||||
limit = l
|
||||
@@ -49,7 +55,11 @@ func listClips(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// 为每个片段设置 playbackUrl
|
||||
for i := range clips {
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream", clips[i].ID)
|
||||
token, err := IssueVideoToken(*userID, "clip", strconv.FormatUint(uint64(clips[i].ID), 10), videoTokenTTL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
url := fmt.Sprintf("/api/v1/video/clips/%d/stream?videoToken=%s", clips[i].ID, token)
|
||||
clips[i].PlaybackURL = &url
|
||||
}
|
||||
|
||||
@@ -60,6 +70,12 @@ func listClips(db *gorm.DB) gin.HandlerFunc {
|
||||
// playClip 获取片段播放地址
|
||||
func playClip(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := currentUserID(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
|
||||
return
|
||||
}
|
||||
|
||||
clipId := c.Param("clipId")
|
||||
var clip model.VideoClip
|
||||
if db.Where("id = ?", clipId).First(&clip).Error != nil {
|
||||
@@ -72,11 +88,17 @@ func playClip(db *gorm.DB) gin.HandlerFunc {
|
||||
format = "mp4"
|
||||
}
|
||||
|
||||
token, err := IssueVideoToken(*userID, "clip", strconv.FormatUint(uint64(clip.ID), 10), videoTokenTTL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成播放令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clipId": clip.ID,
|
||||
"url": fmt.Sprintf("/api/v1/video/clips/%d/stream", clip.ID),
|
||||
"url": fmt.Sprintf("/api/v1/video/clips/%d/stream?videoToken=%s", clip.ID, token),
|
||||
"format": format,
|
||||
"expiresAt": time.Now().Add(60 * time.Minute).UTC().Format(time.RFC3339),
|
||||
"expiresAt": time.Now().Add(videoTokenTTL).UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
)
|
||||
|
||||
// CameraPublic 摄像头对外返回的非敏感字段。
|
||||
type CameraPublic struct {
|
||||
ID uint `json:"id"`
|
||||
RoomID *string `json:"roomId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
RtspURL *string `json:"rtspUrl,omitempty"`
|
||||
HTTPURL *string `json:"httpUrl,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Position *string `json:"position,omitempty"`
|
||||
Resolution *string `json:"resolution,omitempty"`
|
||||
FPS *int `json:"fps,omitempty"`
|
||||
IsOnline bool `json:"isOnline"`
|
||||
GbDeviceID *string `json:"gbDeviceId,omitempty"`
|
||||
GbChannelID *string `json:"gbChannelId,omitempty"`
|
||||
GbAuthID *string `json:"gbAuthId,omitempty"`
|
||||
GbStreamType *string `json:"gbStreamType,omitempty"`
|
||||
GbTransport *string `json:"gbTransport,omitempty"`
|
||||
GbAlarmChannelID *string `json:"gbAlarmChannelId,omitempty"`
|
||||
GbVoiceChannelID *string `json:"gbVoiceChannelId,omitempty"`
|
||||
GbManufacturer *string `json:"gbManufacturer,omitempty"`
|
||||
ManufacturerID *string `json:"manufacturerId,omitempty"`
|
||||
StreamURL *string `json:"streamUrl,omitempty"`
|
||||
HlsURL *string `json:"hlsUrl,omitempty"`
|
||||
FlvURL *string `json:"flvUrl,omitempty"`
|
||||
WebrtcURL *string `json:"webrtcUrl,omitempty"`
|
||||
SnapshotURL *string `json:"snapshotUrl,omitempty"`
|
||||
Online bool `json:"online"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// CameraInput 摄像头创建输入,允许接收密码字段,但不会作为公开响应。
|
||||
type CameraInput struct {
|
||||
ID uint `json:"id,omitempty"`
|
||||
RoomID *string `json:"roomId,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Name string `json:"name"`
|
||||
RtspURL *string `json:"rtspUrl,omitempty"`
|
||||
HTTPURL *string `json:"httpUrl,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
PasswordEnc *string `json:"passwordEnc,omitempty"`
|
||||
Position *string `json:"position,omitempty"`
|
||||
Resolution *string `json:"resolution,omitempty"`
|
||||
FPS *int `json:"fps,omitempty"`
|
||||
IsOnline bool `json:"isOnline"`
|
||||
GbDeviceID *string `json:"gbDeviceId,omitempty"`
|
||||
GbChannelID *string `json:"gbChannelId,omitempty"`
|
||||
GbAuthID *string `json:"gbAuthId,omitempty"`
|
||||
GbAuthPassword *string `json:"gbAuthPassword,omitempty"`
|
||||
GbStreamType *string `json:"gbStreamType,omitempty"`
|
||||
GbTransport *string `json:"gbTransport,omitempty"`
|
||||
GbAlarmChannelID *string `json:"gbAlarmChannelId,omitempty"`
|
||||
GbVoiceChannelID *string `json:"gbVoiceChannelId,omitempty"`
|
||||
GbManufacturer *string `json:"gbManufacturer,omitempty"`
|
||||
ManufacturerID *string `json:"manufacturerId,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (in CameraInput) toModel() model.Camera {
|
||||
return model.Camera{
|
||||
RoomID: in.RoomID,
|
||||
Code: in.Code,
|
||||
Name: in.Name,
|
||||
RtspURL: in.RtspURL,
|
||||
HTTPURL: in.HTTPURL,
|
||||
Username: in.Username,
|
||||
PasswordEnc: in.PasswordEnc,
|
||||
Position: in.Position,
|
||||
Resolution: in.Resolution,
|
||||
FPS: in.FPS,
|
||||
IsOnline: in.IsOnline,
|
||||
GbDeviceID: in.GbDeviceID,
|
||||
GbChannelID: in.GbChannelID,
|
||||
GbAuthID: in.GbAuthID,
|
||||
GbAuthPassword: in.GbAuthPassword,
|
||||
GbStreamType: in.GbStreamType,
|
||||
GbTransport: in.GbTransport,
|
||||
GbAlarmChannelID: in.GbAlarmChannelID,
|
||||
GbVoiceChannelID: in.GbVoiceChannelID,
|
||||
GbManufacturer: in.GbManufacturer,
|
||||
ManufacturerID: in.ManufacturerID,
|
||||
Enabled: in.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func toCameraPublic(camera model.Camera) CameraPublic {
|
||||
return CameraPublic{
|
||||
ID: camera.ID,
|
||||
RoomID: camera.RoomID,
|
||||
Code: camera.Code,
|
||||
Name: camera.Name,
|
||||
RtspURL: camera.RtspURL,
|
||||
HTTPURL: camera.HTTPURL,
|
||||
Username: camera.Username,
|
||||
Position: camera.Position,
|
||||
Resolution: camera.Resolution,
|
||||
FPS: camera.FPS,
|
||||
IsOnline: camera.IsOnline,
|
||||
GbDeviceID: camera.GbDeviceID,
|
||||
GbChannelID: camera.GbChannelID,
|
||||
GbAuthID: camera.GbAuthID,
|
||||
GbStreamType: camera.GbStreamType,
|
||||
GbTransport: camera.GbTransport,
|
||||
GbAlarmChannelID: camera.GbAlarmChannelID,
|
||||
GbVoiceChannelID: camera.GbVoiceChannelID,
|
||||
GbManufacturer: camera.GbManufacturer,
|
||||
ManufacturerID: camera.ManufacturerID,
|
||||
StreamURL: camera.StreamURL,
|
||||
HlsURL: camera.HlsURL,
|
||||
FlvURL: camera.FlvURL,
|
||||
WebrtcURL: camera.WebrtcURL,
|
||||
SnapshotURL: camera.SnapshotURL,
|
||||
Online: camera.Online,
|
||||
Enabled: camera.Enabled,
|
||||
CreatedAt: camera.CreatedAt,
|
||||
UpdatedAt: camera.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegisterVideoStreamRoutes 注册视频流代理路由(公开接口,不需要 JWT)
|
||||
// RegisterVideoStreamRoutes 注册视频流代理路由;路由可免 Authorization,但必须校验 videoToken。
|
||||
func RegisterVideoStreamRoutes(rg *gin.RouterGroup, transcode *service.TranscodeService, db *gorm.DB, media *service.MediaService, cfg *config.Config) {
|
||||
SetVideoTokenSecret(cfg.JWTSecret)
|
||||
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))
|
||||
@@ -25,6 +26,9 @@ func RegisterVideoStreamRoutes(rg *gin.RouterGroup, transcode *service.Transcode
|
||||
func streamClip(transcode *service.TranscodeService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clipId := c.Param("clipId")
|
||||
if !requireVideoToken(c, "clip", clipId) {
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "video/mp4")
|
||||
|
||||
if err := transcode.StreamClip(clipId, c.Writer); err != nil {
|
||||
@@ -40,6 +44,9 @@ func streamClip(transcode *service.TranscodeService) gin.HandlerFunc {
|
||||
func streamLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
if !requireVideoToken(c, "camera", cameraId) {
|
||||
return
|
||||
}
|
||||
|
||||
var camera model.Camera
|
||||
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
||||
@@ -101,6 +108,9 @@ func streamLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gi
|
||||
func proxyLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cameraId := c.Param("id")
|
||||
if !requireVideoToken(c, "camera", cameraId) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 查摄像头
|
||||
var camera model.Camera
|
||||
|
||||
Reference in New Issue
Block a user