@ -99,12 +99,10 @@ func (s *LiveService) Join(userID int64, isAdmin bool, dockID string, req *vo.Li
if err != nil {
return err
}
expireSec := common . AppConf . Live . AuthExpireSeconds
if expireSec <= 0 {
expireSec = 7200
}
expiresAt := time . Now ( ) . Add ( time . Duration ( expireSec ) * time . Second )
expiresAt := sessionExpiry ( time . Now ( ) )
streamName := strconv . FormatInt ( streamID , 10 )
// pushUrl 的签名有效期与设备硬停止时间保持一致,保证整个会话期间
// 重试下发的同一个 pushUrl 仍然有效(协议 §7.2)。
credentials , err := provider . CreateStream ( liveprovider . StreamRequest { StreamName : streamName , ExpiresAt : expiresAt , MaxBitrateBps : maxBitrate } )
if err != nil {
return err
@ -211,6 +209,34 @@ func stopGraceSeconds() time.Duration {
return time . Duration ( seconds ) * time . Second
}
// stopForceAfter 是 stopping 的强制收敛期限。设备可能因为推流子进程卡死而一直
// 上报 streaming=true,必须在有限时间内把会话判定为 stopped,否则
// uk_live_session_active_dock 会让该机巢再也无法开启新直播。
const stopForceAfter = 120 * time . Second
func stoppingDeadline ( ) time . Time {
return time . Now ( ) . Add ( stopForceAfter )
}
// sessionExpiry 返回会话的绝对过期时间。它有两个用途:
// 1. 作为 video.start_stream 的 expiresAt 下发给设备(设备侧硬停止时间);
// 2. 作为推流/播放地址的签名有效期上限。
//
// 协议不允许在会话中途延长 expiresAt(重发 start_stream 会因参数摘要变化被
// 按 COMMAND_ID_CONFLICT 拒绝),所以这里必须在创建时一次性给足。
// “无人观看就停”由观看租约 + 宽限期控制,会话在有人持续心跳时会一直保持到
// 这个上限。取值以 max-session-seconds 为准,未配置时退回 auth-expire-seconds。
func sessionExpiry ( now time . Time ) time . Time {
seconds := common . AppConf . Live . MaxSessionSeconds
if seconds <= 0 {
seconds = common . AppConf . Live . AuthExpireSeconds
}
if seconds <= 0 {
seconds = 7200
}
return now . Add ( 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 ( )
@ -288,14 +314,20 @@ func (s *LiveService) reconcileCloudSession(provider liveprovider.Adapter, sessi
case "starting" , "reconnecting" :
if session . DeviceStreaming && online . Online {
updates [ "phase" ] = "streaming"
// 只在首次进入 streaming 时记开始时间,避免重连后把计费周期起点重置
if session . StartedAt == nil {
updates [ "started_at" ] = now
}
}
case "streaming" :
if ! session . DeviceStreaming || ! online . Online {
updates [ "phase" ] = "reconnecting"
}
case "stopping" :
if ! session . DeviceStreaming && ! online . Online {
// 设备与云端都离线,或超过强制收敛期限 → 判定已停止。
// 后者避免设备(推流子进程)卡死时会话永久停在 stopping。
if ( ! session . DeviceStreaming && ! online . Online ) ||
( session . StopDeadline != nil && now . After ( * session . StopDeadline ) ) {
updates [ "phase" ] = "stopped"
updates [ "stopped_at" ] = now
}
@ -303,29 +335,32 @@ func (s *LiveService) reconcileCloudSession(provider liveprovider.Adapter, sessi
_ = 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
// OnDeviceIdle 处理设备自述“没有会话且不在推流”(state/video 里 streamSessionId 为空
// 且 streaming=false)。dock-video-relay 只把会话存在进程内存,重启后会回到 idle,
// 此时继续等阿里云超时只会让会话卡在 reconnecting。
//
// 只对“已确认过”的会话生效(streaming/reconnecting):starting 阶段的设备还没收到
// start_stream,上报的 idle 属于正常状态,不能误杀。
func ( s * LiveService ) OnDeviceIdle ( dockID string ) {
now := time . Now ( )
result := common . DB . Model ( & model . LiveSession { } ) .
Where ( "dock_id = ? AND phase IN ('streaming','reconnecting')" , dockID ) .
Updates ( map [ string ] any {
"phase" : "stopped" ,
"stop_reason" : "device_restarted" ,
"error_code" : "DEVICE_SESSION_LOST" ,
"device_streaming" : false ,
"cloud_online" : false ,
"stopped_at" : now ,
"updated_at" : now ,
} )
if result . Error != nil {
logger . ERROR ( "处理设备空闲状态失败" , result . Error )
return
}
credentials , err := provider . CreateStream ( liveprovider . StreamRequest { StreamName : session . StreamName , ExpiresAt : time . Unix ( session . ExpiresAt , 0 ) , MaxBitrateBps : session . MaxBitrateBps } )
if err != nil {
return params
if result . RowsAffected > 0 {
logger . WARN ( "设备已无直播会话,会话判定结束" , dockID , result . RowsAffected )
}
params [ "pushUrl" ] = credentials . PushURL
params [ "expiresAt" ] = credentials . ExpiresAt . UnixMilli ( )
return params
}
func ( s * LiveService ) OnCommandTimeout ( cmd model . DeviceCommandLog ) {
@ -335,13 +370,22 @@ func (s *LiveService) OnCommandTimeout(cmd model.DeviceCommandLog) {
if json . Unmarshal ( [ ] byte ( cmd . Params ) , & params ) != 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')" , params . StreamSessionID , cmd . DockID ) .
Updates ( map [ string ] any { "phase" : "failed" , "error_code" : "COMMAND_ACK_TIMEOUT" } ) . Error
return
}
_ = common . DB . Model ( & model . LiveSession { } ) . Where ( "id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')" , params . StreamSessionID , cmd . DockID ) . Updates ( updates ) . Error
// 停止指令超时:绝不能再把会话改回活跃态(reconnecting),否则对账会重新
// 扫描到活跃会话并再次下发停止指令,形成“停→复活→再停”的死循环。
// 这里保持 stopping,由 stop_deadline 在有限时间内强制收敛为 stopped;
// COALESCE 保证已设置的期限不被重复超时向后推。
_ = common . DB . Model ( & model . LiveSession { } ) .
Where ( "id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')" , params . StreamSessionID , cmd . DockID ) .
Updates ( map [ string ] any {
"error_code" : "STOP_COMMAND_ACK_TIMEOUT" ,
"stop_deadline" : gorm . Expr ( "COALESCE(stop_deadline, ?)" , stoppingDeadline ( ) ) ,
} ) . Error
}
// Start 兼容旧启动接口,统一复用创建或加入会话的租约流程。
@ -353,8 +397,9 @@ func (s *LiveService) Start(userID int64, isAdmin bool, dockID string, req *vo.L
return result . Session , nil
}
// Stop 停止指定机巢最新活动会话;设备必须根据 streamSessionId 防止误停新会话。
func ( s * LiveService ) Stop ( userID int64 , isAdmin bool , dockID string ) * common . BusiError {
// Stop 停止指定机巢的活动会话。sessionID 非空时必须与当前活动会话一致,
// 避免旧页面拿着已过期的会话号把新会话停掉;设备仍须根据 streamSessionId 防误停。
func ( s * LiveService ) Stop ( userID int64 , isAdmin bool , dockID , sessionID 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 ) {
@ -369,15 +414,33 @@ func (s *LiveService) Stop(userID int64, isAdmin bool, dockID string) *common.Bu
}
return common . ErrInternal
}
// 调用方带了会话号就必须匹配当前活动会话,防止旧页面/旧任务停掉新会话。
if sessionID != "" && sessionID != active . ID {
logger . WARN ( "拒绝停止非当前直播会话" , dockID , sessionID , active . ID )
return common . NewBusiError ( common . LiveNotFound , "该直播会话已结束或已更换" )
}
if sessionID == "" {
logger . WARN ( "停止直播未携带 streamSessionId(旧客户端兼容路径)" , dockID )
}
// 先做条件置位抢占:只有把会话从活跃态推到 stopping 的调用者才下发停流指令。
// 重复调用(前端重试、多人同时点停止、对账任务并发)只会幂等返回,不会重复下发。
result := common . DB . Model ( & model . LiveSession { } ) .
Where ( "id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting')" , active . ID , dockID ) .
Updates ( map [ string ] any { "phase" : "stopping" , "stop_reason" : "manual" , "stop_deadline" : stoppingDeadline ( ) } )
if result . Error != nil {
return common . ErrInternal
}
if result . RowsAffected == 0 {
return nil
}
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 {
_ = common . DB . Model ( & model . LiveSession { } ) . Where ( "id = ? AND phase = 'stopping'" , active . ID ) .
Updates ( map [ string ] any { "error_code" : "STOP_COMMAND_PUBLISH_FAILED" } ) . Error
return common . ErrInternal
}
return nil
@ -390,7 +453,7 @@ func (s *LiveService) stopForBilling(dockID, streamSessionID string, reason stri
// Transition first with a conditional update. This is the idempotency gate
// for concurrent billing/reconciliation workers: only the worker that moves
// the session into stopping is allowed to publish video.stop_stream.
updates := map [ string ] any { "phase" : "stopping" , "stop_reason" : reason }
updates := map [ string ] any { "phase" : "stopping" , "stop_reason" : reason , "stop_deadline" : stoppingDeadline ( ) }
if reason == "no_balance" {
updates [ "error_code" ] = "TRAFFIC_NOT_ENOUGH"
}
@ -405,7 +468,7 @@ func (s *LiveService) stopForBilling(dockID, streamSessionID string, reason stri
}
params := map [ string ] any {
"streamSessionId" : streamSessionID ,
"reason" : reason ,
"reason" : protocolStopReason ( reason ) ,
}
cmd , err := DefaultCommandService . DispatchToDock ( dockID , "video.stop_stream" , params )
if err != nil {
@ -437,7 +500,9 @@ func (s *LiveService) GetPlayURL(userID int64, isAdmin bool, dockID, sessionID s
return nil , busiErr
}
var session model . LiveSession
query := common . DB . Scopes ( withDockFilter ( userID , isAdmin ) ) . Where ( "dock_id = ? AND phase = 'streaming' AND (expires_at = 0 OR expires_at > ?)" , dockID , time . Now ( ) . Unix ( ) )
query := common . DB . Model ( & model . LiveSession { } ) .
Scopes ( withDockFilter ( userID , isAdmin ) ) .
Where ( "dock_id = ? AND phase = 'streaming' AND (expires_at = 0 OR expires_at > ?)" , dockID , time . Now ( ) . Unix ( ) )
if sessionID != "" {
query = query . Where ( "id = ?" , sessionID )
} else {
@ -449,12 +514,31 @@ func (s *LiveService) GetPlayURL(userID int64, isAdmin bool, dockID, sessionID s
WHERE viewer_id = ? AND released_at IS NULL AND expires_at > ?
) ` , userID , time . Now ( ) ) . Order ( "created_at DESC, id DESC" )
}
if err := query . First ( & session ) . Error ; err != nil {
if errors . Is ( err , gorm . ErrRecordNotFound ) {
return nil , common . ErrLiveNotFound
// 用 Find 而不是 First:会话尚未进入 streaming 属于正常等待态,
// 不应该被 GORM 记成 record not found 的 ERROR 日志。
result := query . Limit ( 1 ) . Find ( & session )
if result . Error != nil {
logger . ERROR ( "查询直播会话失败" , result . Error )
return nil , common . ErrInternal
}
if result . RowsAffected == 0 {
// 区分“还没就绪”和“已经结束”,让前端知道该继续等还是该收尾。
var active int64
existQuery := common . DB . Model ( & model . LiveSession { } ) .
Scopes ( withDockFilter ( userID , isAdmin ) ) .
Where ( "dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')" , dockID )
if sessionID != "" {
existQuery = existQuery . Where ( "id = ?" , sessionID )
}
if err := existQuery . Count ( & active ) . Error ; err != nil {
logger . ERROR ( "查询直播会话状态失败" , err )
return nil , common . ErrInternal
}
if active > 0 {
return nil , common . ErrLiveNotReady
}
return nil , common . ErrLiveNotFound
}
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 ) {
@ -470,7 +554,16 @@ func (s *LiveService) GetPlayURL(userID int64, isAdmin bool, dockID, sessionID s
if err != nil {
return nil , common . NewBusiError ( common . LiveProviderInvalid , "生成播放地址失败" )
}
return & vo . LivePlayURLVO { StreamName : session . StreamName , PlayURL : urls . HLS , ExpiresAt : urls . ExpiresAt . Unix ( ) } , nil
// 同时返回 HLS / FLV / RTMP:HLS 兼容性最好但延迟高,实时监控用 FLV
// (HTTP-FLV)延迟可压到 1~3 秒。playUrl 仍保持 HLS,避免破坏旧前端。
return & vo . LivePlayURLVO {
StreamName : session . StreamName ,
PlayURL : urls . HLS ,
PlayURLHLS : urls . HLS ,
PlayURLFLV : urls . FLV ,
PlayURLRTMP : urls . RTMP ,
ExpiresAt : urls . ExpiresAt . Unix ( ) ,
} , nil
}
func ( s * LiveService ) OnCommandAck ( cmd * model . DeviceCommandLog , accepted bool , resultCode string ) {
@ -493,18 +586,19 @@ func (s *LiveService) OnCommandAck(cmd *model.DeviceCommandLog, accepted bool, r
if cmd . CommandType == "video.start_stream" {
updates [ "phase" ] = "failed"
} else {
// A stop rejection means the stream is still active for ordinary
// command failures. SESSION_NOT_FOUND is different: the edge has
// already discarded this session (typically after a mock restart), so
// restoring streaming would make expiry reconciliation send stop forever.
if resultCode == "SESSION_NOT_FOUND" || params . Reason == "session_expired" {
// 设备已经不认这个会话(工控机/视频服务重启后回到 idle,或会话已被替换):
// 继续重试停止毫无意义,直接判定结束,释放活动会话唯一约束。
// SESSION_NOT_FOUND 是 mock 的码,VIDEO_SESSION_MISMATCH 是协议 §5.2.4 的码。
if resultCode == "SESSION_NOT_FOUND" || resultCode == "VIDEO_SESSION_MISMATCH" {
now := time . Now ( )
updates [ "phase" ] = "stopped"
updates [ "device_streaming" ] = false
updates [ "cloud_online" ] = false
updates [ "stopped_at" ] = now
} else {
updates [ "phase" ] = "streaming"
// 一般停止失败(设备仍在推流等)保持 stopping,由 stop_deadline 强制收敛。
// 绝不能改回 streaming,否则会与停流对账形成死循环。
updates [ "phase" ] = "stopping"
}
}
if err := common . DB . Model ( & model . LiveSession { } ) .
@ -514,42 +608,68 @@ func (s *LiveService) OnCommandAck(cmd *model.DeviceCommandLog, accepted bool, r
}
}
// 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 {
// OnVideoState accepts ordered state from the matching non-terminal stream session.
// sequence 是幂等去重用的单调序号(协议无 version,由 state/video 快照的
// updatedAt 毫秒充当),eventId 是派生的 <streamSessionId>-<updatedAt>,
// 两者仍旧落在 device_state_version / device_event_id 两个既有列上以兼容历史数据。
func ( s * LiveService ) OnVideoState ( dockID string , streaming bool , streamSessionID , eventID string , sequence , updatedAt , bitrateBps int64 ) bool {
if streamSessionID == "" || eventID == "" || sequence <= 0 || updatedAt <= 0 {
logger . WARN ( "视频状态缺少顺序元数据(OnVideoState)" , dockID , streamSessionID , eventID , sequence , updatedAt )
return false
}
now := time . Now ( )
if abs ( now . UnixMilli ( ) - updatedAt ) > int64 ( ( 5 * time . Minute ) / time . Millisecond ) {
logger . WARN ( "忽略过期视频状态" , dockID , streamSessionID )
// updatedAt 单位不一致(秒 vs 毫秒)或设备时钟偏差会走到这里。
logger . WARN ( "忽略过期视频状态" , dockID , streamSessionID , "nowMs" , now . UnixMilli ( ) , "updatedAt" , updatedAt )
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 {
logger . WARN ( "视频状态对应的直播会话不存在或已结束" , dockID , streamSessionID , err )
return false
}
if version <= session . DeviceStateVersion || ( session . DeviceEventID != "" && session . DeviceEventID == eventID ) || updatedAt < session . DeviceUpdatedAt {
// 重复投递由序号是否前进判定;eventId 只用于“跨会话归属”校验,
// 因为协议 §7.8 的 state/video 是快照,requestId 可能在同一会话内被复用。
if sequence <= session . DeviceStateVersion || updatedAt < session . DeviceUpdatedAt {
logger . WARN ( "忽略乱序或重复的视频状态" , dockID , streamSessionID ,
"sequence" , sequence , "curSequence" , session . DeviceStateVersion ,
"eventId" , eventID , "curEventId" , session . DeviceEventID ,
"updatedAt" , updatedAt , "curUpdatedAt" , session . DeviceUpdatedAt )
return false
}
// eventId is expected to be globally unique. Reject a stale/reused device
// event that was already recorded for another live session before touching
// the unique device-event column.
// eventId 由 <sessionId>-<updatedAt> 派生,天然唯一;这里只防止同一事件被
// 陈旧会话重复占用(device_event_id 有唯一索引 uk_live_session_device_event)。
var eventOwner model . LiveSession
if result := common . DB . Select ( "id" ) . Where ( "device_event_id = ? AND id <> ?" , eventID , streamSessionID ) . Limit ( 1 ) . Find ( & eventOwner ) ; result . Error != nil {
logger . ERROR ( "校验视频状态事件唯一性失败" , result . Error )
return false
} else if result . RowsAffected > 0 {
logger . WARN ( "忽略已被其他直播会话使用的视频状态事件" , eventID , streamSessionID , eventOwner . ID )
return false
}
updates := map [ string ] any {
"device_state_version" : version ,
"device_state_version" : sequence ,
"device_event_id" : eventID ,
"device_updated_at" : updatedAt ,
"device_streaming" : streaming ,
"updated_at" : now ,
}
// 协议 §7.8:bitrateBps 是设备侧 expectedBitrateBps(平均码率),作为计费口径。
// 只在推流且有有效值时写入,避免停止时把计费码率清零。
if streaming && bitrateBps > 0 {
updates [ "billing_bitrate_bps" ] = bitrateBps
}
if session . Phase == "stopping" {
// 已下发扬停指令:设备可能仍在上报 streaming=true(或保留了停止后的会话号),
// 绝不能把 phase 改回 streaming,否则会和停流对账来回拉扯成死循环。
// 这里只记录设备侧事实,phase 交给对账任务收敛到 stopped。
if streaming {
updates [ "error_code" ] = "STOP_IN_PROGRESS"
} else {
updates [ "error_code" ] = "DEVICE_STREAM_OFFLINE"
}
} else if streaming {
provider , busiErr := s . adapter ( )
if busiErr != nil {
return false
@ -557,25 +677,23 @@ func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionI
online , queryErr := provider . QueryOnline ( session . StreamName )
if queryErr != nil || ! online . Online {
updates [ "error_code" ] = "PROVIDER_ONLINE_UNVERIFIED"
result := common . DB . Model ( & model . LiveSession { } ) . Where ( "id = ? AND dock_id = ? AND device_state_version < ?" , streamSessionID , dockID , version ) . Updates ( updates )
result := common . DB . Model ( & model . LiveSession { } ) . Where ( "id = ? AND dock_id = ? AND device_state_version < ?" , streamSessionID , dockID , sequence ) . Updates ( updates )
if result . Error != nil {
if code , constraint := common . ParseError ( result . Error ) ; code == 1062 && constraint == "uk_live_session_device_event" {
return false
}
return false
}
return true
}
updates [ "phase" ] = "streaming"
// 只在首次进入 streaming 时记开始时间,避免重连后把计费周期起点重置
if session . StartedAt == nil {
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 )
result := common . DB . Model ( & model . LiveSession { } ) . Where ( "id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping') AND device_state_version < ?" , streamSessionID , dockID , sequence ) . Updates ( updates )
if result . Error != nil {
if code , constraint := common . ParseError ( result . Error ) ; code == 1062 && constraint == "uk_live_session_device_event" {
return false
@ -591,3 +709,14 @@ func abs(value int64) int64 {
}
return value
}
// protocolStopReason 把内部停止原因映射为协议 §5.2.4 允许的取值,
// 避免工控机按 INVALID_PARAM 拒绝停止命令;真实原因仍记录在 live_session.stop_reason。
func protocolStopReason ( reason string ) string {
switch reason {
case "no_viewers" , "user_request" , "admin_stop" , "session_replaced" :
return reason
default :
return "user_request"
}
}