feat: 收口视频访问与摄像头密钥输出
This commit is contained in:
@@ -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`);
|
||||
try {
|
||||
const info = await playCamera(camera.id, 'flv');
|
||||
navigation.navigate('VideoPlayer', {
|
||||
cameraId: camera.id,
|
||||
cameraName: camera.name,
|
||||
streamUrl,
|
||||
streamUrl: resolveUrl(info.url),
|
||||
});
|
||||
} catch (err: any) {
|
||||
Alert.alert('提示', err?.message || '获取播放地址失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayClip = async (clip: VideoClip) => {
|
||||
|
||||
@@ -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`);
|
||||
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 }[] = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 判读与检测质控 | 未开始 | 需领域专家确认 |
|
||||
|
||||
@@ -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 提交;
|
||||
- 若已部署流代理版本,回滚需恢复旧二进制并重启,无需数据库变更;播放地址需由客户端重新请求。
|
||||
|
||||
Reference in New Issue
Block a user