package service import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "strconv" "time" "gorm.io/gorm" "laic-backend/cache" "laic-backend/common" liveprovider "laic-backend/live" "laic-backend/logger" "laic-backend/model" "laic-backend/tool" "laic-backend/vo" ) type LiveService struct { provider liveprovider.Adapter } var DefaultLiveService = &LiveService{} func pushURLHash(pushURL string) string { sum := sha256.Sum256([]byte(pushURL)) return hex.EncodeToString(sum[:]) } func InitLiveProvider(conf common.Live) error { provider, err := liveprovider.NewAdapter(conf) if err != nil { return err } DefaultLiveService.provider = provider return nil } func (s *LiveService) adapter() (liveprovider.Adapter, *common.BusiError) { if s.provider == nil || s.provider.Provider() == "disabled" { return nil, common.NewBusiError(common.LiveProviderDisabled, "直播服务未配置") } return s.provider, nil } func (s *LiveService) Join(userID int64, isAdmin bool, dockID string, req *vo.LiveSessionCreateReq) (*vo.LiveSessionVO, *common.BusiError) { provider, busiErr := s.adapter() if busiErr != nil { return nil, busiErr } var dock model.Dock if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ?", dockID).First(&dock).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrDockNotFound } return nil, common.ErrInternal } online, _ := common.SetMemberExists(cache.OnlineDockSetKey, dockID) if !online { return nil, common.ErrDockOffline } var session model.LiveSession var lease model.LiveViewerLease var startParams map[string]any err := common.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("dock_id = ?", dockID).First(&model.Dock{}).Error; err != nil { return err } if err := tx.Where("dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", dockID).Order("created_at DESC, id DESC").First(&session).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } if session.ID == "" { maxBitrate := req.MaxBitrateBps if maxBitrate <= 0 { maxBitrate = 1500000 } streamID, err := tool.NextID() if err != nil { return err } expireSec := common.AppConf.Live.AuthExpireSeconds if expireSec <= 0 { expireSec = 7200 } expiresAt := time.Now().Add(time.Duration(expireSec) * time.Second) streamName := strconv.FormatInt(streamID, 10) credentials, err := provider.CreateStream(liveprovider.StreamRequest{StreamName: streamName, ExpiresAt: expiresAt, MaxBitrateBps: maxBitrate}) if err != nil { return err } now := time.Now() session = model.LiveSession{ID: streamName, DockID: dockID, Provider: credentials.Provider, StreamName: streamName, PushURLHash: pushURLHash(credentials.PushURL), ExpiresAt: expiresAt.Unix(), MaxBitrateBps: maxBitrate, Phase: "starting", RequestedBy: userID, CreatedAt: now, UpdatedAt: now} if err := tx.Create(&session).Error; err != nil { return err } startParams = map[string]any{"provider": credentials.Provider, "streamSessionId": session.ID, "pushUrl": credentials.PushURL, "expiresAt": credentials.ExpiresAt.UnixMilli(), "maxBitrateBps": maxBitrate} } now := time.Now() leaseID, err := tool.NextID() if err != nil { return err } lease = model.LiveViewerLease{ID: leaseID, StreamSessionID: session.ID, ViewerID: userID, ExpiresAt: now.Add(time.Duration(leaseSeconds()) * time.Second), CreatedAt: now, UpdatedAt: now} return tx.Where("stream_session_id = ? AND viewer_id = ?", session.ID, userID).Assign(map[string]any{"expires_at": lease.ExpiresAt, "released_at": nil, "updated_at": now}).FirstOrCreate(&lease).Error }) if err != nil { logger.ERROR("创建直播观看租约失败", err) return nil, common.ErrInternal } if startParams != nil { if _, err := DefaultCommandService.DispatchToDock(dockID, "video.start_stream", startParams); err != nil { logger.ERROR("下发推流指令失败", err) _ = common.DB.Model(&model.LiveSession{}).Where("id = ?", session.ID).Updates(map[string]any{"phase": "failed", "error_code": "MQTT_PUBLISH_FAILED"}).Error return nil, common.ErrInternal } } return &vo.LiveSessionVO{Session: &session, LeaseExpiresAt: lease.ExpiresAt.Unix()}, nil } func leaseSeconds() int { seconds := common.AppConf.Live.ViewerLeaseSeconds if seconds <= 0 { return 30 } return seconds } func (s *LiveService) GetSession(userID int64, isAdmin bool, dockID, streamSessionID string) (*model.LiveSession, *common.BusiError) { var session model.LiveSession if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ?", streamSessionID, dockID).First(&session).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrLiveNotFound } return nil, common.ErrInternal } return &session, nil } func (s *LiveService) Heartbeat(userID int64, isAdmin bool, dockID, streamSessionID string) (*vo.LiveSessionVO, *common.BusiError) { var session model.LiveSession if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ?", streamSessionID, dockID).First(&session).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrLiveNotFound } return nil, common.ErrInternal } expiresAt := time.Now().Add(time.Duration(leaseSeconds()) * time.Second) result := common.DB.Model(&model.LiveViewerLease{}).Where("stream_session_id = ? AND viewer_id = ? AND released_at IS NULL", streamSessionID, userID).Updates(map[string]any{"expires_at": expiresAt, "updated_at": time.Now()}) if result.Error != nil || result.RowsAffected == 0 { return nil, common.ErrLiveNotFound } return &vo.LiveSessionVO{Session: &session, LeaseExpiresAt: expiresAt.Unix()}, nil } func (s *LiveService) Leave(userID int64, isAdmin bool, dockID, streamSessionID string) *common.BusiError { var session model.LiveSession if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ?", streamSessionID, dockID).First(&session).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return common.ErrLiveNotFound } return common.ErrInternal } now := time.Now() if err := common.DB.Model(&model.LiveViewerLease{}).Where("stream_session_id = ? AND viewer_id = ? AND released_at IS NULL", streamSessionID, userID).Updates(map[string]any{"released_at": now, "updated_at": now}).Error; err != nil { return common.ErrInternal } return nil } func stopGraceSeconds() time.Duration { seconds := common.AppConf.Live.StopGraceSeconds if seconds <= 0 { seconds = 15 } return time.Duration(seconds) * time.Second } // ReconcileLeases expires stale viewer leases and stops unviewed sessions after the grace period. func (s *LiveService) ReconcileLeases() { now := time.Now() if err := common.DB.Model(&model.LiveViewerLease{}).Where("released_at IS NULL AND expires_at <= ?", now).Updates(map[string]any{"released_at": now, "updated_at": now}).Error; err != nil { logger.ERROR("清理过期直播租约失败", err) return } var active []model.LiveSession if err := common.DB.Where("phase IN ('starting','streaming','reconnecting')").Find(&active).Error; err != nil { logger.ERROR("扫描活动直播会话失败", err) return } for _, session := range active { if session.ExpiresAt > 0 && now.Unix() >= session.ExpiresAt { if _, busiErr := s.stopForBilling(session.DockID, session.ID); busiErr != nil { logger.ERROR("直播会话到期停止失败: "+session.ID, busiErr) } continue } var viewers int64 if err := common.DB.Model(&model.LiveViewerLease{}).Where("stream_session_id = ? AND released_at IS NULL AND expires_at > ?", session.ID, now).Count(&viewers).Error; err != nil || viewers > 0 { if session.StopDeadline != nil { _ = common.DB.Model(&model.LiveSession{}).Where("id = ?", session.ID).Update("stop_deadline", nil).Error } continue } if session.StopDeadline == nil { _ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND stop_deadline IS NULL", session.ID).Update("stop_deadline", now.Add(stopGraceSeconds())).Error continue } if session.StopDeadline.After(now) { continue } if _, busiErr := s.stopForBilling(session.DockID, session.ID); busiErr != nil { logger.ERROR("无观看者停止直播失败: "+session.ID, busiErr) } } } func (s *LiveService) ReconcileCloudSessions() { provider, busiErr := s.adapter() if busiErr != nil { return } var sessions []model.LiveSession if err := common.DB.Where("phase IN ('starting','streaming','reconnecting','stopping')").Find(&sessions).Error; err != nil { logger.ERROR("扫描直播云端对账会话失败", err) return } now := time.Now() for i := range sessions { s.reconcileCloudSession(provider, &sessions[i], now) } } func (s *LiveService) reconcileCloudSession(provider liveprovider.Adapter, session *model.LiveSession, now time.Time) { online, err := provider.QueryOnline(session.StreamName) updates := map[string]any{"cloud_checked_at": now} if err != nil { updates["error_code"] = "PROVIDER_ONLINE_QUERY_FAILED" _ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", session.ID, session.DockID).Updates(updates).Error return } updates["cloud_online"] = online.Online if session.DeviceStreaming && online.Online { updates["cloud_confirmed_at"] = now } switch session.Phase { case "starting", "reconnecting": if session.DeviceStreaming && online.Online { updates["phase"] = "streaming" updates["started_at"] = now } case "streaming": if !session.DeviceStreaming || !online.Online { updates["phase"] = "reconnecting" } case "stopping": if !session.DeviceStreaming && !online.Online { updates["phase"] = "stopped" updates["stopped_at"] = now } } _ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase = ?", session.ID, session.DockID, session.Phase).Updates(updates).Error } func (s *LiveService) RefreshStartParams(dockID string, params map[string]any) map[string]any { if params == nil { return params } streamSessionID, _ := params["streamSessionId"].(string) if streamSessionID == "" { return params } var session model.LiveSession if common.DB.Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", streamSessionID, dockID).First(&session).Error != nil { return params } provider, busiErr := s.adapter() if busiErr != nil { return params } credentials, err := provider.CreateStream(liveprovider.StreamRequest{StreamName: session.StreamName, ExpiresAt: time.Unix(session.ExpiresAt, 0), MaxBitrateBps: session.MaxBitrateBps}) if err != nil { return params } params["pushUrl"] = credentials.PushURL params["expiresAt"] = credentials.ExpiresAt.UnixMilli() return params } func (s *LiveService) OnCommandTimeout(cmd model.DeviceCommandLog) { var params struct { StreamSessionID string `json:"streamSessionId"` } if json.Unmarshal([]byte(cmd.Params), ¶ms) != nil || params.StreamSessionID == "" { return } updates := map[string]any{"error_code": "COMMAND_ACK_TIMEOUT"} if cmd.CommandType == "video.start_stream" { updates["phase"] = "failed" } else { updates["phase"] = "reconnecting" } _ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", params.StreamSessionID, cmd.DockID).Updates(updates).Error } // Start 兼容旧启动接口,统一复用创建或加入会话的租约流程。 func (s *LiveService) Start(userID int64, isAdmin bool, dockID string, req *vo.LiveStartReq) (*model.LiveSession, *common.BusiError) { result, busiErr := s.Join(userID, isAdmin, dockID, &vo.LiveSessionCreateReq{MaxBitrateBps: req.MaxBitrateBps}) if busiErr != nil { return nil, busiErr } return result.Session, nil } // Stop 停止指定机巢最新活动会话;设备必须根据 streamSessionId 防止误停新会话。 func (s *LiveService) Stop(userID int64, isAdmin bool, dockID string) *common.BusiError { var dock model.Dock if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ?", dockID).First(&dock).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return common.ErrDockNotFound } return common.ErrInternal } var active model.LiveSession if err := common.DB.Where("dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", dockID).Order("created_at DESC, id DESC").First(&active).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return common.ErrLiveNotFound } return common.ErrInternal } params := map[string]any{ "streamSessionId": active.ID, "reason": "user_request", } if _, err := DefaultCommandService.DispatchToDock(dockID, "video.stop_stream", params); err != nil { logger.ERROR("下发停止推流指令失败", err) return common.ErrInternal } if err := common.DB.Model(&model.LiveSession{}).Where("id = ? AND phase IN ('starting','streaming','reconnecting')", active.ID).Updates(map[string]any{"phase": "stopping", "stop_reason": "manual"}).Error; err != nil { return common.ErrInternal } return nil } func (s *LiveService) stopForBilling(dockID, streamSessionID string) (*model.DeviceCommandLog, *common.BusiError) { params := map[string]any{ "streamSessionId": streamSessionID, "reason": "no_balance", } cmd, err := DefaultCommandService.DispatchToDock(dockID, "video.stop_stream", params) if err != nil { return nil, common.ErrInternal } if err := common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting')", streamSessionID, dockID).Updates(map[string]any{"phase": "stopping", "stop_reason": "no_balance"}).Error; err != nil { return nil, common.ErrInternal } return cmd, nil } func (s *LiveService) GetPage(userID int64, isAdmin bool, req *vo.LivePageReq) (*common.PageResponse[model.LiveSession], *common.BusiError) { db := common.DB.Model(&model.LiveSession{}).Scopes(withDockFilter(userID, isAdmin)) if req.Phase != "" { db = db.Where("phase = ?", req.Phase) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, common.ErrInternal } var list []model.LiveSession if err := db.Scopes(req.Paginate).Order("created_at DESC, id DESC").Find(&list).Error; err != nil { return nil, common.ErrInternal } return common.Page(req.Pagination, total, list), nil } // GetPlayURL only returns a URL after both edge and provider confirmation are represented by streaming. func (s *LiveService) GetPlayURL(userID int64, isAdmin bool, dockID, sessionID string) (*vo.LivePlayURLVO, *common.BusiError) { provider, busiErr := s.adapter() if busiErr != nil { return nil, busiErr } var session model.LiveSession if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ? AND phase = 'streaming'", sessionID, dockID).First(&session).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrLiveNotFound } return nil, common.ErrInternal } var lease model.LiveViewerLease if err := common.DB.Where("stream_session_id = ? AND viewer_id = ? AND released_at IS NULL AND expires_at > ?", session.ID, userID, time.Now()).First(&lease).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrLiveLeaseNotFound } return nil, common.ErrInternal } ttl := common.AppConf.Live.PlayURLTTLSeconds if ttl <= 0 || ttl > 300 { ttl = 300 } urls, err := provider.CreatePlayURLs(liveprovider.PlayRequest{StreamName: session.StreamName, ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Second)}) if err != nil { return nil, common.NewBusiError(common.LiveProviderInvalid, "生成播放地址失败") } return &vo.LivePlayURLVO{StreamName: session.StreamName, PlayURL: urls.HLS, ExpiresAt: urls.ExpiresAt.Unix()}, nil } func (s *LiveService) OnCommandAck(cmd *model.DeviceCommandLog, accepted bool, resultCode string) { if cmd.CommandType != "video.start_stream" && cmd.CommandType != "video.stop_stream" { return } var params struct { StreamSessionID string `json:"streamSessionId"` } if err := json.Unmarshal([]byte(cmd.Params), ¶ms); err != nil || params.StreamSessionID == "" { logger.ERROR("解析直播指令参数失败", err) return } if accepted { return } updates := map[string]any{"error_code": resultCode} if cmd.CommandType == "video.start_stream" { updates["phase"] = "failed" } else { updates["phase"] = "streaming" } if err := common.DB.Model(&model.LiveSession{}). Where("id = ? AND dock_id = ?", params.StreamSessionID, cmd.DockID). Updates(updates).Error; err != nil { logger.ERROR("更新直播指令失败状态失败", err) } } // OnVideoState accepts only ordered state from the matching non-terminal stream session. func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionID, eventID string, version, updatedAt int64) bool { if streamSessionID == "" || eventID == "" || version <= 0 || updatedAt <= 0 { return false } now := time.Now() if abs(now.UnixMilli()-updatedAt) > int64((5*time.Minute)/time.Millisecond) { logger.WARN("忽略过期视频状态", dockID, streamSessionID) return false } var session model.LiveSession if err := common.DB.Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", streamSessionID, dockID).First(&session).Error; err != nil { return false } if version <= session.DeviceStateVersion || (session.DeviceEventID != "" && session.DeviceEventID == eventID) || updatedAt < session.DeviceUpdatedAt { return false } updates := map[string]any{ "device_state_version": version, "device_event_id": eventID, "device_updated_at": updatedAt, "device_streaming": streaming, "updated_at": now, } if streaming { provider, busiErr := s.adapter() if busiErr != nil { return false } online, queryErr := provider.QueryOnline(session.StreamName) if queryErr != nil || !online.Online { updates["error_code"] = "PROVIDER_ONLINE_UNVERIFIED" return common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND device_state_version < ?", streamSessionID, dockID, version).Updates(updates).Error == nil } updates["phase"] = "streaming" updates["started_at"] = now updates["cloud_confirmed_at"] = now } else if session.Phase == "stopping" { updates["error_code"] = "DEVICE_STREAM_OFFLINE" } else { updates["phase"] = "reconnecting" updates["error_code"] = "DEVICE_STREAM_OFFLINE" } result := common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping') AND device_state_version < ?", streamSessionID, dockID, version).Updates(updates) return result.Error == nil && result.RowsAffected == 1 } func abs(value int64) int64 { if value < 0 { return -value } return value }