feat: 收口视频访问与摄像头密钥输出

This commit is contained in:
weijuesen
2026-08-14 00:07:52 +08:00
parent 440cf803e4
commit 1a29def480
14 changed files with 494 additions and 78 deletions
+7 -3
View File
@@ -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 { Text, Card, Button, useTheme, ActivityIndicator, Surface, IconButton, SegmentedButtons } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native'; 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 { resolveUrl } from '../api/client';
import { formatDateTime, formatDuration, formatFileSize } from '../utils/format'; import { formatDateTime, formatDuration, formatFileSize } from '../utils/format';
import type { Camera, VideoClip } from '../types'; import type { Camera, VideoClip } from '../types';
@@ -41,12 +41,16 @@ export default function VideoScreen() {
}, [loadData]); }, [loadData]);
const handlePlay = async (camera: Camera) => { const handlePlay = async (camera: Camera) => {
const streamUrl = resolveUrl(`/api/v1/video/cameras/${camera.id}/live/stream`); try {
const info = await playCamera(camera.id, 'flv');
navigation.navigate('VideoPlayer', { navigation.navigate('VideoPlayer', {
cameraId: camera.id, cameraId: camera.id,
cameraName: camera.name, cameraName: camera.name,
streamUrl, streamUrl: resolveUrl(info.url),
}); });
} catch (err: any) {
Alert.alert('提示', err?.message || '获取播放地址失败');
}
}; };
const handlePlayClip = async (clip: VideoClip) => { const handlePlayClip = async (clip: VideoClip) => {
+10 -7
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { View, Text, Video } from '@tarojs/components'; import { View, Text, Video } from '@tarojs/components';
import { useRouter } from '@tarojs/taro'; import { useRouter } from '@tarojs/taro';
import styles from './index.module.scss'; import styles from './index.module.scss';
import { getCameras } from '@/api/video'; import { getCameras, playCamera } from '@/api/video';
import { resolveUrl } from '@/api/config'; import { resolveUrl } from '@/api/config';
import type { Camera } from '@/types'; import type { Camera } from '@/types';
@@ -30,10 +30,6 @@ const VideoPlayerPage: React.FC = () => {
const found = cameras.find((c) => c.id === cameraId); const found = cameras.find((c) => c.id === cameraId);
if (found) { if (found) {
setCamera(found); setCamera(found);
const url = found.hlsUrl || found.streamUrl || found.flvUrl;
if (url) {
setVideoUrl(resolveUrl(url));
}
} }
} catch (err) { } catch (err) {
console.error('[VideoPlayer] 获取摄像头信息失败:', err); console.error('[VideoPlayer] 获取摄像头信息失败:', err);
@@ -44,14 +40,21 @@ const VideoPlayerPage: React.FC = () => {
}, [cameraId, directUrl]); }, [cameraId, directUrl]);
const handlePlay = async () => { const handlePlay = async () => {
if (!cameraId && !camera) return; const id = cameraId || camera?.id;
if (!id) return;
setLoading(true); setLoading(true);
setError(''); setError('');
const streamUrl = resolveUrl(`/api/v1/video/cameras/${cameraId || camera!.id}/live/stream`); try {
const info = await playCamera(id, { format: 'flv' });
const streamUrl = resolveUrl(info.url);
console.log('[VideoPlayer] 获取播放地址成功:', streamUrl); console.log('[VideoPlayer] 获取播放地址成功:', streamUrl);
setVideoUrl(streamUrl); setVideoUrl(streamUrl);
} catch (err) {
setError(err instanceof Error ? err.message : '获取播放地址失败');
} finally {
setLoading(false); setLoading(false);
}
}; };
const formats: { key: 'hls' | 'flv' | 'webrtc'; label: string }[] = [ const formats: { key: 'hls' | 'flv' | 'webrtc'; label: string }[] = [
+12 -1
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"silk-server-go/internal/middleware" "silk-server-go/internal/middleware"
"silk-server-go/internal/model" "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 // getAlarmClip 获取告警关联视频片段(查 video_clips 表 where alarm_id=:id,按 start_at DESC
func getAlarmClip(db *gorm.DB) gin.HandlerFunc { func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
userID := currentUserID(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
return
}
id := c.Param("id") id := c.Param("id")
// 先检查告警是否存在 // 先检查告警是否存在
@@ -33,7 +40,11 @@ func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
// 为每个片段设置 playbackUrl // 为每个片段设置 playbackUrl
for i := range clips { 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 clips[i].PlaybackURL = &url
} }
+102
View File
@@ -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)
}
}
+44 -50
View File
@@ -86,7 +86,11 @@ func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
slog.Warn("同步 WVP 设备信息失败,保留 DB 状态", "error", err) 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"}) c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
return return
} }
c.JSON(http.StatusOK, camera) c.JSON(http.StatusOK, toCameraPublic(camera))
} }
} }
// createCamera 新建摄像头 // createCamera 新建摄像头
func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc { func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var camera model.Camera var input CameraInput
if err := c.ShouldBindJSON(&camera); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
camera := input.toModel()
camera.ID = 0 // 让数据库自动生成 camera.ID = 0 // 让数据库自动生成
if camera.RoomID == nil { if camera.RoomID == nil {
defaultRoom := "1" 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 // playCamera 播放摄像头实时流(body: {format},调用 media.StartPlay
func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc { func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
userID := currentUserID(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
return
}
id := c.Param("id") id := c.Param("id")
var camera model.Camera var camera model.Camera
if db.Where("id = ?", id).First(&camera).Error != nil { if db.Where("id = ?", id).First(&camera).Error != nil {
@@ -223,71 +234,45 @@ func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
return return
} }
var body struct { token, err := IssueVideoToken(*userID, "camera", strconv.FormatUint(uint64(camera.ID), 10), videoTokenTTL)
Format string `json:"format"` if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成播放令牌失败"})
return
} }
c.ShouldBindJSON(&body) expiresAt := time.Now().Add(videoTokenTTL).UTC().Format(time.RFC3339)
format := body.Format streamURL := fmt.Sprintf("/api/v1/video/cameras/%d/live/proxy?videoToken=%s", camera.ID, token)
if format == "" {
format = "hls"
}
expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
// GB28181 摄像头:通过 WVP 媒体服务器播放 // GB28181 摄像头:通过 WVP 媒体服务器播放
if camera.GbDeviceID != nil && camera.GbChannelID != nil && if camera.GbDeviceID != nil && camera.GbChannelID != nil &&
*camera.GbDeviceID != "" && *camera.GbChannelID != "" { *camera.GbDeviceID != "" && *camera.GbChannelID != "" {
result, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID) _, playErr := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
if err != nil { if playErr != nil {
// StartPlay 失败不等于摄像头离线,可能是 WVP/ZLM 瞬时问题,不修改 is_online // StartPlay 失败不等于摄像头离线,可能是 WVP/ZLM 瞬时问题,不修改 is_online
slog.Warn("StartPlay 失败", "deviceId", *camera.GbDeviceID, "channelId", *camera.GbChannelID, "error", err) slog.Warn("StartPlay 失败", "deviceId", *camera.GbDeviceID, "channelId", *camera.GbChannelID, "error", playErr)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "播放失败: " + err.Error()}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "播放失败: " + playErr.Error()})
return 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{ c.JSON(http.StatusOK, gin.H{
"cameraId": camera.ID, "cameraId": camera.ID,
"gbDeviceId": camera.GbDeviceID, "gbDeviceId": camera.GbDeviceID,
"gbChannelId": camera.GbChannelID, "gbChannelId": camera.GbChannelID,
"format": format, "format": "flv",
"url": url, "url": streamURL,
"expiresAt": expiresAt, "expiresAt": expiresAt,
}) })
return return
} }
// Fallback:使用摄像头自身的流地址 // 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{ c.JSON(http.StatusOK, gin.H{
"cameraId": camera.ID, "cameraId": camera.ID,
"gbDeviceId": nil, "gbDeviceId": nil,
"gbChannelId": nil, "gbChannelId": nil,
"format": format, "format": "flv",
"url": url, "url": streamURL,
"expiresAt": expiresAt, "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 // playbackCamera 查询摄像头的历史录像片段(query: from/to/limit
func playbackCamera(db *gorm.DB) gin.HandlerFunc { func playbackCamera(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
userID := currentUserID(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
return
}
id := c.Param("id") id := c.Param("id")
limit := 50 limit := 50
@@ -318,7 +309,11 @@ func playbackCamera(db *gorm.DB) gin.HandlerFunc {
// 为每个片段设置 playbackUrl // 为每个片段设置 playbackUrl
for i := range clips { 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 clips[i].PlaybackURL = &url
} }
@@ -342,7 +337,6 @@ func getWvpConfig(media *service.MediaService) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"sipId": sip["id"], "sipId": sip["id"],
"sipDomain": sip["domain"], "sipDomain": sip["domain"],
"sipPassword": sip["password"],
"sipPort": sip["port"], "sipPort": sip["port"],
"sipShowIp": sip["showIp"], "sipShowIp": sip["showIp"],
}) })
+25 -3
View File
@@ -25,6 +25,12 @@ func RegisterVideoClipRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Confi
// listClips 录像片段列表(query: cameraId/from/to/limit,按 startAt DESC // listClips 录像片段列表(query: cameraId/from/to/limit,按 startAt DESC
func listClips(db *gorm.DB) gin.HandlerFunc { func listClips(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
userID := currentUserID(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
return
}
limit := 50 limit := 50
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 { if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
limit = l limit = l
@@ -49,7 +55,11 @@ func listClips(db *gorm.DB) gin.HandlerFunc {
// 为每个片段设置 playbackUrl // 为每个片段设置 playbackUrl
for i := range clips { 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 clips[i].PlaybackURL = &url
} }
@@ -60,6 +70,12 @@ func listClips(db *gorm.DB) gin.HandlerFunc {
// playClip 获取片段播放地址 // playClip 获取片段播放地址
func playClip(db *gorm.DB) gin.HandlerFunc { func playClip(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
userID := currentUserID(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供用户信息"})
return
}
clipId := c.Param("clipId") clipId := c.Param("clipId")
var clip model.VideoClip var clip model.VideoClip
if db.Where("id = ?", clipId).First(&clip).Error != nil { if db.Where("id = ?", clipId).First(&clip).Error != nil {
@@ -72,11 +88,17 @@ func playClip(db *gorm.DB) gin.HandlerFunc {
format = "mp4" 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{ c.JSON(http.StatusOK, gin.H{
"clipId": clip.ID, "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, "format": format,
"expiresAt": time.Now().Add(60 * time.Minute).UTC().Format(time.RFC3339), "expiresAt": time.Now().Add(videoTokenTTL).UTC().Format(time.RFC3339),
}) })
} }
} }
+128
View File
@@ -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,
}
}
+11 -1
View File
@@ -14,8 +14,9 @@ import (
"gorm.io/gorm" "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) { 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/clips/:clipId/stream", streamClip(transcode))
rg.GET("/video/cameras/:id/live/stream", streamLive(db, media, cfg)) rg.GET("/video/cameras/:id/live/stream", streamLive(db, media, cfg))
rg.GET("/video/cameras/:id/live/proxy", proxyLive(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 { func streamClip(transcode *service.TranscodeService) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
clipId := c.Param("clipId") clipId := c.Param("clipId")
if !requireVideoToken(c, "clip", clipId) {
return
}
c.Header("Content-Type", "video/mp4") c.Header("Content-Type", "video/mp4")
if err := transcode.StreamClip(clipId, c.Writer); err != nil { 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 { func streamLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
cameraId := c.Param("id") cameraId := c.Param("id")
if !requireVideoToken(c, "camera", cameraId) {
return
}
var camera model.Camera var camera model.Camera
if db.Where("id = ?", cameraId).First(&camera).Error != nil { 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 { func proxyLive(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
cameraId := c.Param("id") cameraId := c.Param("id")
if !requireVideoToken(c, "camera", cameraId) {
return
}
// 1. 查摄像头 // 1. 查摄像头
var camera model.Camera var camera model.Camera
+2 -2
View File
@@ -44,13 +44,13 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
return return
} }
// 视频片段流接口支持动态 ID,使用前缀+后缀匹配放行 // 视频片段流接口仅放行播放器请求;handler 必须校验 videoToken
if strings.HasPrefix(path, "/api/v1/video/clips/") && strings.HasSuffix(path, "/stream") { if strings.HasPrefix(path, "/api/v1/video/clips/") && strings.HasSuffix(path, "/stream") {
c.Next() c.Next()
return return
} }
// 直播流转码代理接口 // 直播流转码代理接口仅放行播放器请求;handler 必须校验 videoToken
if strings.HasPrefix(path, "/api/v1/video/cameras/") && (strings.HasSuffix(path, "/live/stream") || strings.HasSuffix(path, "/live/proxy")) { if strings.HasPrefix(path, "/api/v1/video/cameras/") && (strings.HasSuffix(path, "/live/stream") || strings.HasSuffix(path, "/live/proxy")) {
c.Next() c.Next()
return return
+2 -2
View File
@@ -116,7 +116,7 @@ type Camera struct {
RtspURL *string `gorm:"column:rtsp_url;size:512" json:"rtspUrl,omitempty"` RtspURL *string `gorm:"column:rtsp_url;size:512" json:"rtspUrl,omitempty"`
HTTPURL *string `gorm:"column:http_url;size:512" json:"httpUrl,omitempty"` HTTPURL *string `gorm:"column:http_url;size:512" json:"httpUrl,omitempty"`
Username *string `gorm:"size:64" json:"username,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"` Position *string `gorm:"size:255" json:"position,omitempty"`
Resolution *string `gorm:"size:32" json:"resolution,omitempty"` Resolution *string `gorm:"size:32" json:"resolution,omitempty"`
FPS *int `gorm:"type:int" json:"fps,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"` GbDeviceID *string `gorm:"column:gb_device_id;size:20" json:"gbDeviceId,omitempty"`
GbChannelID *string `gorm:"column:gb_channel_id;size:20" json:"gbChannelId,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"` 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"` GbStreamType *string `gorm:"column:gb_stream_type;size:10" json:"gbStreamType,omitempty"`
GbTransport *string `gorm:"column:gb_transport;size:10" json:"gbTransport,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"` GbAlarmChannelID *string `gorm:"column:gb_alarm_channel_id;size:20" json:"gbAlarmChannelId,omitempty"`
+2 -1
View File
@@ -15,6 +15,7 @@ export interface Camera {
webrtcUrl?: string; webrtcUrl?: string;
snapshotUrl?: string; snapshotUrl?: string;
username?: string; username?: string;
/** 写回字段,接口不会返回 */
passwordEnc?: string; passwordEnc?: string;
position?: string; position?: string;
resolution?: string; resolution?: string;
@@ -22,6 +23,7 @@ export interface Camera {
gbDeviceId?: string; gbDeviceId?: string;
gbChannelId?: string; gbChannelId?: string;
gbAuthId?: string; gbAuthId?: string;
/** 写回字段,接口不会返回 */
gbAuthPassword?: string; gbAuthPassword?: string;
gbStreamType?: string; gbStreamType?: string;
gbTransport?: string; gbTransport?: string;
@@ -34,7 +36,6 @@ export interface Camera {
export interface WvpSipConfig { export interface WvpSipConfig {
sipId: string; sipId: string;
sipDomain: string; sipDomain: string;
sipPassword: string;
sipPort: number; sipPort: number;
sipShowIp: string; sipShowIp: string;
} }
+1 -1
View File
@@ -12,7 +12,7 @@
| Wave 0 | 决策与基线 | Task 1 恢复可复现的多端质量基线 | 部分可用 | CI 待 Task 0 平台落地 | | Wave 0 | 决策与基线 | Task 1 恢复可复现的多端质量基线 | 部分可用 | CI 待 Task 0 平台落地 |
| Wave 1 | P0 安全与正确性 | Task 2 版本化数据库迁移与 Schema 启动门禁 | 部分可用 | 开发库升级已验证,真实 down 回滚演练待后续 | | Wave 1 | P0 安全与正确性 | Task 2 版本化数据库迁移与 Schema 启动门禁 | 部分可用 | 开发库升级已验证,真实 down 回滚演练待后续 |
| Wave 1 | P0 安全与正确性 | Task 3 移除默认密钥与默认管理员密码 | 延后到最后(跳过) | 用户 2026-08-13 明确要求跳过并留到最后 | | 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 5 修复 WebSocket 越权与 AI 流 SSRF | 未开始 | 无 |
| Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 未开始 | 无 | | Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 未开始 | 无 |
| Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 未开始 | 需领域专家确认 | | Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 未开始 | 需领域专家确认 |
+30
View File
@@ -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 0Go test/vet/build、Web test/lint/build、小程序 typecheck/build、APP tsc/lint、AI pytest 均通过;
- 新增测试覆盖:摄像头响应不含密码字段、token 正常/过期/资源错配/用户错配、流代理缺失或错误 token 返回 401/403
- 未部署开发服务器,未做真实摄像头播放联调。
### 回滚点
- 本任务前分支提交为 `440cf80`;回滚可还原 Task 4 提交;
- 若已部署流代理版本,回滚需恢复旧二进制并重启,无需数据库变更;播放地址需由客户端重新请求。