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 { 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) => {
|
||||||
|
|||||||
@@ -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 }[] = [
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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,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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 判读与检测质控 | 未开始 | 需领域专家确认 |
|
||||||
|
|||||||
@@ -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