196 lines
5.2 KiB
Go
196 lines
5.2 KiB
Go
package handler
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"silk-server-go/internal/config"
|
||
"silk-server-go/internal/middleware"
|
||
"silk-server-go/internal/model"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// RegisterVideoClipRoutes 注册录像片段管理路由
|
||
func RegisterVideoClipRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||
readPerm := middleware.RequirePermission(db, "video:read")
|
||
rg.GET("/video/clips", readPerm, listClips(db))
|
||
rg.GET("/video/clips/:clipId/play", readPerm, playClip(db))
|
||
rg.POST("/video/clips/internal", createClipInternal(db, cfg)) // 白名单接口,无需权限
|
||
}
|
||
|
||
// 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
|
||
if limit > 200 {
|
||
limit = 200
|
||
}
|
||
}
|
||
|
||
q := applyRoomScope(db.Model(&model.VideoClip{}), c, "room_id").Order("start_at DESC").Limit(limit)
|
||
if cameraId := c.Query("cameraId"); cameraId != "" {
|
||
q = q.Where("camera_id = ?", cameraId)
|
||
}
|
||
if from := c.Query("from"); from != "" {
|
||
q = q.Where("start_at >= ?", from)
|
||
}
|
||
if to := c.Query("to"); to != "" {
|
||
q = q.Where("start_at <= ?", to)
|
||
}
|
||
|
||
clips := make([]model.VideoClip, 0)
|
||
q.Find(&clips)
|
||
|
||
// 为每个片段设置 playbackUrl
|
||
for i := range clips {
|
||
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
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"items": clips, "total": len(clips)})
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
if !requireObjectAccess(c, db, "video_clip", clipId) {
|
||
return
|
||
}
|
||
var clip model.VideoClip
|
||
if db.Where("id = ?", clipId).First(&clip).Error != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "video clip not found"})
|
||
return
|
||
}
|
||
|
||
format := clip.Format
|
||
if format == "" {
|
||
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?videoToken=%s", clip.ID, token),
|
||
"format": format,
|
||
"expiresAt": time.Now().Add(videoTokenTTL).UTC().Format(time.RFC3339),
|
||
})
|
||
}
|
||
}
|
||
|
||
// createClipInternal 内部接口创建录像片段记录(校验 x-api-key header)
|
||
func createClipInternal(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
// 校验 x-api-key
|
||
apiKey := c.GetHeader("x-api-key")
|
||
if apiKey != cfg.InternalAPIKey {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
|
||
return
|
||
}
|
||
|
||
var body struct {
|
||
CameraID string `json:"cameraId"`
|
||
Trigger string `json:"trigger"`
|
||
Format string `json:"format"`
|
||
StartAt string `json:"startAt"`
|
||
DurationSec float64 `json:"durationSec"`
|
||
SizeBytes string `json:"sizeBytes"`
|
||
S3Bucket string `json:"s3Bucket"`
|
||
S3Key string `json:"s3Key"`
|
||
Resolution string `json:"resolution"`
|
||
Notes string `json:"notes"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 去重:同一 s3Key 不重复创建
|
||
var existing model.VideoClip
|
||
if db.Where("s3_key = ?", body.S3Key).First(&existing).Error == nil {
|
||
c.JSON(http.StatusOK, existing)
|
||
return
|
||
}
|
||
|
||
// 解析开始时间
|
||
startAt, err := time.Parse(time.RFC3339, body.StartAt)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid startAt, expected RFC3339 format"})
|
||
return
|
||
}
|
||
|
||
// 设置默认值
|
||
trigger := body.Trigger
|
||
if trigger == "" {
|
||
trigger = "schedule"
|
||
}
|
||
format := body.Format
|
||
if format == "" {
|
||
format = "mp4"
|
||
}
|
||
notes := body.Notes
|
||
if notes == "" {
|
||
notes = "自动录制归档"
|
||
}
|
||
|
||
clip := model.VideoClip{
|
||
CameraID: body.CameraID,
|
||
Trigger: trigger,
|
||
Format: format,
|
||
StartAt: startAt,
|
||
DurationSec: body.DurationSec,
|
||
S3Bucket: &body.S3Bucket,
|
||
S3Key: &body.S3Key,
|
||
Notes: ¬es,
|
||
}
|
||
// 从 camera 查询 room_id
|
||
var camera model.Camera
|
||
if err := db.Where("id = ?", body.CameraID).First(&camera).Error; err == nil {
|
||
clip.RoomID = camera.RoomID
|
||
}
|
||
if body.SizeBytes != "" {
|
||
if n, err := strconv.ParseInt(body.SizeBytes, 10, 64); err == nil {
|
||
clip.SizeBytes = &n
|
||
}
|
||
}
|
||
if body.Resolution != "" {
|
||
clip.Resolution = &body.Resolution
|
||
}
|
||
|
||
if err := db.Create(&clip).Error; err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusCreated, clip)
|
||
}
|
||
}
|