package handler import ( "bytes" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "sync" "time" "silk-server-go/internal/config" "silk-server-go/internal/middleware" "silk-server-go/internal/model" "silk-server-go/internal/service" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // ActiveRecording 活跃录制记录(内存维护) type ActiveRecording struct { CameraID string `json:"cameraId"` DeviceID string `json:"deviceId"` ChannelID string `json:"channelId"` Stream string `json:"stream"` App string `json:"app"` StartedAt time.Time `json:"startedAt"` } // 活跃录制内存表(cameraId -> recording) var ( activeRecordings = make(map[string]*ActiveRecording) activeMu sync.Mutex ) // RegisterVideoRecordRoutes 注册录制管理路由 func RegisterVideoRecordRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service.MediaService, cfg *config.Config) { recordPerm := middleware.RequirePermission(db, "video:record") readPerm := middleware.RequirePermission(db, "video:read") rg.POST("/video/cameras/:id/record/start", recordPerm, startRecording(db, media, cfg)) rg.POST("/video/cameras/:id/record/stop", recordPerm, stopRecording(db, media, cfg)) rg.GET("/video/recordings/active", readPerm, listActiveRecordings(cfg)) rg.POST("/video/recordings/internal/end", endRecordingInternal(media, cfg)) // 白名单接口,无需权限 } // startRecording 开始录制:查摄像头 → media.StartPlay → POST recorderApiBase/record/start func startRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { cameraId := c.Param("id") var camera model.Camera if db.Where("id = ?", cameraId).First(&camera).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"}) return } if camera.GbDeviceID == nil || camera.GbChannelID == nil || *camera.GbDeviceID == "" || *camera.GbChannelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头未配置 GB28181 或不存在"}) return } // 检查是否已在录制 activeMu.Lock() if _, ok := activeRecordings[cameraId]; ok { activeMu.Unlock() c.JSON(http.StatusConflict, gin.H{"error": "该摄像头正在录制中"}) return } activeMu.Unlock() // 1. 确保视频流在线(WVP play/start) _, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID) if err != nil { slog.Warn("开始播放失败,跳过录制", "cameraId", cameraId, "error", err) // StartPlay 失败,标记摄像头离线 db.Model(&model.Camera{}).Where("id = ?", cameraId).Update("is_online", false) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "摄像头可能离线: " + err.Error()}) return } // 2. 通知 Python 录制服务开始录制 stream := *camera.GbDeviceID + "_" + *camera.GbChannelID recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/") payload, _ := json.Marshal(map[string]string{ "stream": stream, "cameraId": cameraId, "deviceId": *camera.GbDeviceID, "channelId": *camera.GbChannelID, }) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Post(recorderBase+"/record/start", "application/json", bytes.NewReader(payload)) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "录制服务不可用: " + err.Error()}) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) c.JSON(http.StatusServiceUnavailable, gin.H{"error": fmt.Sprintf("录制服务返回 %d: %s", resp.StatusCode, string(respBody))}) return } // 3. 记录活跃录制 rec := &ActiveRecording{ CameraID: cameraId, DeviceID: *camera.GbDeviceID, ChannelID: *camera.GbChannelID, Stream: stream, App: "rtp", StartedAt: time.Now(), } activeMu.Lock() activeRecordings[cameraId] = rec activeMu.Unlock() slog.Info("录制已开始", "cameraId", cameraId, "stream", stream) c.JSON(http.StatusOK, gin.H{ "cameraId": cameraId, "stream": stream, "startedAt": rec.StartedAt, }) } } // stopRecording 停止录制:通知录制服务 + WVP play/stop 清理 session func stopRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { cameraId := c.Param("id") activeMu.Lock() rec, ok := activeRecordings[cameraId] if ok { delete(activeRecordings, cameraId) } activeMu.Unlock() // 即使没有活跃录制,也尝试用摄像头的 GB 信息停止 WVP play var deviceID, channelID string if ok { deviceID = rec.DeviceID channelID = rec.ChannelID } else { var camera model.Camera if db.Where("id = ?", cameraId).First(&camera).Error == nil && camera.GbDeviceID != nil && camera.GbChannelID != nil { deviceID = *camera.GbDeviceID channelID = *camera.GbChannelID } } // 1. 通知录制服务停止 if ok { recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/") payload, _ := json.Marshal(map[string]string{ "stream": rec.Stream, }) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Post(recorderBase+"/record/stop", "application/json", bytes.NewReader(payload)) if err != nil { slog.Warn("停止录制服务失败", "cameraId", cameraId, "error", err) } else { resp.Body.Close() } } // 2. 停止 WVP play session(清理 ZLMediaKit 流) if deviceID != "" && channelID != "" { media.StopPlay(deviceID, channelID) } slog.Info("录制已停止", "cameraId", cameraId, "hadActiveRecording", ok) c.JSON(http.StatusOK, gin.H{ "cameraId": cameraId, "stopped": true, }) } } // endRecordingInternal 录制服务内部接口:通知录制已结束(流 EOF / 错误 / 停止) // 清理 activeRecordings 并停止 WVP play session func endRecordingInternal(media *service.MediaService, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { apiKey := c.GetHeader("x-api-key") if apiKey != cfg.InternalAPIKey { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"}) return } var body struct { Stream string `json:"stream"` CameraID string `json:"cameraId"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } activeMu.Lock() rec, ok := activeRecordings[body.CameraID] if ok { delete(activeRecordings, body.CameraID) } activeMu.Unlock() if ok { slog.Info("录制服务通知录制结束", "cameraId", body.CameraID, "stream", body.Stream) // 停止 WVP play session,清理 ZLMediaKit 流 media.StopPlay(rec.DeviceID, rec.ChannelID) } c.JSON(http.StatusOK, gin.H{"ok": true}) } } // listActiveRecordings 返回活跃录制列表 // 优先以 recorder-go 服务的实际状态为准(Go 后端重启后内存 map 会丢失,但 recorder-go 仍在录制) func listActiveRecordings(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { // 1. 查询 recorder-go 实际录制状态 recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/") client := &http.Client{Timeout: 3 * time.Second} resp, err := client.Get(recorderBase + "/record/status") if err == nil { defer resp.Body.Close() if resp.StatusCode == http.StatusOK { var statusResp struct { Recordings []struct { Stream string `json:"stream"` CameraID string `json:"cameraId"` StartedAt string `json:"startedAt"` TotalBytes int64 `json:"totalBytes"` SegmentCount int `json:"segmentCount"` } `json:"recordings"` } if err := json.NewDecoder(resp.Body).Decode(&statusResp); err == nil && len(statusResp.Recordings) > 0 { list := make([]*ActiveRecording, 0, len(statusResp.Recordings)) for _, r := range statusResp.Recordings { startedAt, _ := time.Parse(time.RFC3339, r.StartedAt) if startedAt.IsZero() { startedAt = time.Now() } // 拆分 stream 得到 deviceId/channelId deviceID, channelID := "", "" parts := strings.SplitN(r.Stream, "_", 2) if len(parts) == 2 { deviceID, channelID = parts[0], parts[1] } list = append(list, &ActiveRecording{ CameraID: r.CameraID, DeviceID: deviceID, ChannelID: channelID, Stream: r.Stream, App: "rtp", StartedAt: startedAt, }) // 同步更新内存 map(自愈:Go 后端重启后内存丢失,从 recorder-go 恢复) activeMu.Lock() if _, ok := activeRecordings[r.CameraID]; !ok { activeRecordings[r.CameraID] = &ActiveRecording{ CameraID: r.CameraID, DeviceID: deviceID, ChannelID: channelID, Stream: r.Stream, App: "rtp", StartedAt: startedAt, } } activeMu.Unlock() } c.JSON(http.StatusOK, list) return } } } // 2. recorder-go 不可用时回退到内存 map activeMu.Lock() list := make([]*ActiveRecording, 0, len(activeRecordings)) for _, rec := range activeRecordings { list = append(list, rec) } activeMu.Unlock() c.JSON(http.StatusOK, list) } }