diff --git a/app/src/screens/VideoScreen.tsx b/app/src/screens/VideoScreen.tsx index 223e4d8..17be4f5 100644 --- a/app/src/screens/VideoScreen.tsx +++ b/app/src/screens/VideoScreen.tsx @@ -3,7 +3,7 @@ import { StyleSheet, View, FlatList, RefreshControl, Alert } from 'react-native' import { Text, Card, Button, useTheme, ActivityIndicator, Surface, IconButton, SegmentedButtons } from 'react-native-paper'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; -import { getCameras, getClips } from '../api/video'; +import { getCameras, getClips, playCamera } from '../api/video'; import { resolveUrl } from '../api/client'; import { formatDateTime, formatDuration, formatFileSize } from '../utils/format'; import type { Camera, VideoClip } from '../types'; @@ -41,12 +41,16 @@ export default function VideoScreen() { }, [loadData]); const handlePlay = async (camera: Camera) => { - const streamUrl = resolveUrl(`/api/v1/video/cameras/${camera.id}/live/stream`); - navigation.navigate('VideoPlayer', { - cameraId: camera.id, - cameraName: camera.name, - streamUrl, - }); + try { + const info = await playCamera(camera.id, 'flv'); + navigation.navigate('VideoPlayer', { + cameraId: camera.id, + cameraName: camera.name, + streamUrl: resolveUrl(info.url), + }); + } catch (err: any) { + Alert.alert('提示', err?.message || '获取播放地址失败'); + } }; const handlePlayClip = async (clip: VideoClip) => { diff --git a/miniapp/src/pages/video/player/index.tsx b/miniapp/src/pages/video/player/index.tsx index 2c39fdf..1a746f1 100644 --- a/miniapp/src/pages/video/player/index.tsx +++ b/miniapp/src/pages/video/player/index.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { View, Text, Video } from '@tarojs/components'; import { useRouter } from '@tarojs/taro'; import styles from './index.module.scss'; -import { getCameras } from '@/api/video'; +import { getCameras, playCamera } from '@/api/video'; import { resolveUrl } from '@/api/config'; import type { Camera } from '@/types'; @@ -30,10 +30,6 @@ const VideoPlayerPage: React.FC = () => { const found = cameras.find((c) => c.id === cameraId); if (found) { setCamera(found); - const url = found.hlsUrl || found.streamUrl || found.flvUrl; - if (url) { - setVideoUrl(resolveUrl(url)); - } } } catch (err) { console.error('[VideoPlayer] 获取摄像头信息失败:', err); @@ -44,14 +40,21 @@ const VideoPlayerPage: React.FC = () => { }, [cameraId, directUrl]); const handlePlay = async () => { - if (!cameraId && !camera) return; + const id = cameraId || camera?.id; + if (!id) return; setLoading(true); setError(''); - const streamUrl = resolveUrl(`/api/v1/video/cameras/${cameraId || camera!.id}/live/stream`); - console.log('[VideoPlayer] 获取播放地址成功:', streamUrl); - setVideoUrl(streamUrl); - setLoading(false); + try { + const info = await playCamera(id, { format: 'flv' }); + const streamUrl = resolveUrl(info.url); + console.log('[VideoPlayer] 获取播放地址成功:', streamUrl); + setVideoUrl(streamUrl); + } catch (err) { + setError(err instanceof Error ? err.message : '获取播放地址失败'); + } finally { + setLoading(false); + } }; const formats: { key: 'hls' | 'flv' | 'webrtc'; label: string }[] = [ diff --git a/server-go/internal/handler/alarm_clip.go b/server-go/internal/handler/alarm_clip.go index 336ae1b..536e102 100644 --- a/server-go/internal/handler/alarm_clip.go +++ b/server-go/internal/handler/alarm_clip.go @@ -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 } diff --git a/server-go/internal/handler/video_auth.go b/server-go/internal/handler/video_auth.go new file mode 100644 index 0000000..a4541b6 --- /dev/null +++ b/server-go/internal/handler/video_auth.go @@ -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 +} diff --git a/server-go/internal/handler/video_auth_test.go b/server-go/internal/handler/video_auth_test.go new file mode 100644 index 0000000..4027f8e --- /dev/null +++ b/server-go/internal/handler/video_auth_test.go @@ -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) + } +} diff --git a/server-go/internal/handler/video_camera.go b/server-go/internal/handler/video_camera.go index b844eb0..3a18906 100644 --- a/server-go/internal/handler/video_camera.go +++ b/server-go/internal/handler/video_camera.go @@ -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"], }) diff --git a/server-go/internal/handler/video_clip.go b/server-go/internal/handler/video_clip.go index 9202452..cc97d46 100644 --- a/server-go/internal/handler/video_clip.go +++ b/server-go/internal/handler/video_clip.go @@ -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), }) } } diff --git a/server-go/internal/handler/video_dto.go b/server-go/internal/handler/video_dto.go new file mode 100644 index 0000000..a241329 --- /dev/null +++ b/server-go/internal/handler/video_dto.go @@ -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, + } +} diff --git a/server-go/internal/handler/video_stream.go b/server-go/internal/handler/video_stream.go index 639307a..d39ff90 100644 --- a/server-go/internal/handler/video_stream.go +++ b/server-go/internal/handler/video_stream.go @@ -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 diff --git a/server-go/internal/middleware/auth.go b/server-go/internal/middleware/auth.go index 4b60be6..2828cec 100644 --- a/server-go/internal/middleware/auth.go +++ b/server-go/internal/middleware/auth.go @@ -44,13 +44,13 @@ func Auth(cfg *config.Config) gin.HandlerFunc { return } - // 视频片段流接口支持动态 ID,使用前缀+后缀匹配放行 + // 视频片段流接口仅放行播放器请求;handler 必须校验 videoToken if strings.HasPrefix(path, "/api/v1/video/clips/") && strings.HasSuffix(path, "/stream") { c.Next() return } - // 直播流转码代理接口 + // 直播流转码代理接口仅放行播放器请求;handler 必须校验 videoToken if strings.HasPrefix(path, "/api/v1/video/cameras/") && (strings.HasSuffix(path, "/live/stream") || strings.HasSuffix(path, "/live/proxy")) { c.Next() return diff --git a/server-go/internal/model/models.go b/server-go/internal/model/models.go index 27f4787..8931982 100644 --- a/server-go/internal/model/models.go +++ b/server-go/internal/model/models.go @@ -116,7 +116,7 @@ type Camera struct { 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"` + PasswordEnc *string `gorm:"column:password_enc;size:255" json:"-"` Position *string `gorm:"size:255" json:"position,omitempty"` Resolution *string `gorm:"size:32" json:"resolution,omitempty"` FPS *int `gorm:"type:int" json:"fps,omitempty"` @@ -124,7 +124,7 @@ type Camera struct { 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"` + GbAuthPassword *string `gorm:"column:gb_auth_password;size:255" json:"-"` 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"` diff --git a/web/src/dal/video.ts b/web/src/dal/video.ts index 4f3550a..3ad0e20 100644 --- a/web/src/dal/video.ts +++ b/web/src/dal/video.ts @@ -15,6 +15,7 @@ export interface Camera { webrtcUrl?: string; snapshotUrl?: string; username?: string; + /** 写回字段,接口不会返回 */ passwordEnc?: string; position?: string; resolution?: string; @@ -22,6 +23,7 @@ export interface Camera { gbDeviceId?: string; gbChannelId?: string; gbAuthId?: string; + /** 写回字段,接口不会返回 */ gbAuthPassword?: string; gbStreamType?: string; gbTransport?: string; @@ -34,7 +36,6 @@ export interface Camera { export interface WvpSipConfig { sipId: string; sipDomain: string; - sipPassword: string; sipPort: number; sipShowIp: string; } diff --git a/后续工作计划.md b/后续工作计划.md index 045fdc4..5a5b82b 100644 --- a/后续工作计划.md +++ b/后续工作计划.md @@ -12,7 +12,7 @@ | Wave 0 | 决策与基线 | Task 1 恢复可复现的多端质量基线 | 部分可用 | CI 待 Task 0 平台落地 | | Wave 1 | P0 安全与正确性 | Task 2 版本化数据库迁移与 Schema 启动门禁 | 部分可用 | 开发库升级已验证,真实 down 回滚演练待后续 | | Wave 1 | P0 安全与正确性 | Task 3 移除默认密钥与默认管理员密码 | 延后到最后(跳过) | 用户 2026-08-13 明确要求跳过并留到最后 | -| Wave 1 | P0 安全与正确性 | Task 4 收口视频访问与摄像头密钥输出 | 未开始 | 无 | +| Wave 1 | P0 安全与正确性 | Task 4 收口视频访问与摄像头密钥输出 | 部分可用 | 待开发服务器部署联调 | | Wave 1 | P0 安全与正确性 | Task 5 修复 WebSocket 越权与 AI 流 SSRF | 未开始 | 无 | | Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 未开始 | 无 | | Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 未开始 | 需领域专家确认 | diff --git a/开发交接记录.md b/开发交接记录.md index 20d92aa..30462b7 100644 --- a/开发交接记录.md +++ b/开发交接记录.md @@ -860,3 +860,33 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准 ### 回滚点 - 本记录仅修改文档,无代码、数据库、服务器或生成产物变更;如需回滚,删除本记录并恢复相关计划/决策表即可。 + +--- + +## 2026-08-14 整改 Task 4:收口视频访问与摄像头密钥输出 + +### 做了什么 + +- 新增视频短时令牌服务:`IssueVideoToken` / `ValidateVideoToken` / `ValidateVideoTokenForUser`,令牌绑定用户、资源类型、资源 ID、用途和 5 分钟有效期; +- `Camera.PasswordEnc`、`Camera.GbAuthPassword` 改为 `json:"-"`,新增 `CameraPublic` / `CameraInput` DTO,创建和更新仍可写入密码,但列表/详情/创建/更新响应不再返回密码; +- 实时播放和录像播放接口统一返回带 `videoToken` 的代理 URL,不再返回 WVP/ZLM 直连地址; +- `/video/clips/:clipId/stream`、`/video/cameras/:id/live/stream`、`/video/cameras/:id/live/proxy` 必须校验 token,缺失返回 401,错误/过期/资源错配返回 403; +- 告警片段、Web 录像列表、APP 和小程序播放链路同步改为使用带 token 的地址; +- WVP SIP 配置响应移除 `sipPassword`;前端 DAL 同步调整。 + +### 设计思路与决策依据 + +- 播放器标签无法稳定携带 Authorization,因此采用短时签名 token 作为流代理的 bearer capability,同时保留播放地址生成接口的 JWT + 权限校验; +- 摄像头密钥字段允许写回但禁止输出,创建/更新走独立 `CameraInput`,对外统一 `CameraPublic`; +- 当前项目没有用户级资源 ACL,Task 4 先收口“未授权直连”和“密钥返回”两类 P0;按用户/房间的对象级授权留待后续任务。 + +### 验证结果 + +- `scripts/verify.ps1` 最终 exit 0:Go test/vet/build、Web test/lint/build、小程序 typecheck/build、APP tsc/lint、AI pytest 均通过; +- 新增测试覆盖:摄像头响应不含密码字段、token 正常/过期/资源错配/用户错配、流代理缺失或错误 token 返回 401/403; +- 未部署开发服务器,未做真实摄像头播放联调。 + +### 回滚点 + +- 本任务前分支提交为 `440cf80`;回滚可还原 Task 4 提交; +- 若已部署流代理版本,回滚需恢复旧二进制并重启,无需数据库变更;播放地址需由客户端重新请求。