Browse Source

fix:直播优化

master
刘浩东 1 month ago
parent
commit
7e484ba4e9
  1. 69
      README.md
  2. 7
      cache/cache_key.go
  3. 2
      common/busi_error.go
  4. 8
      common/mysql.go
  5. 3
      config-prod.yaml
  6. 9
      handler/live_handler.go
  7. 1
      model/media.go
  8. 2
      mqtt/message.go
  9. 12
      mqtt/mqtt.go
  10. 14
      service/billing_service.go
  11. 83
      service/command_service.go
  12. 261
      service/live_service.go
  13. 46
      service/mqtt_subscriber.go
  14. 5
      sql/001_schema.sql
  15. 12
      vo/media_vo.go

69
README.md

@ -85,6 +85,75 @@ to a JSON-safe `providerPayload` data URI (`data:image/png;base64,...`); clients
should use that value directly as the image `src` and must not UTF-8 decode the
original response body.
## Live session device event uniqueness
`live_session.uk_live_session_device_event` was originally built directly on
`device_event_id`. MySQL unique indexes treat multiple `NULL` values as
distinct but multiple empty strings as duplicates, so only one live session
without a reported device event could exist at a time. Creating a second live
session then failed with:
```
Error 1062 (23000): Duplicate entry '' for key 'live_session.uk_live_session_device_event'
```
Run `sql/023_live_session_device_event_nullable.sql` on any database created
before this fix. It adds a `device_event_key` generated column
(`NULLIF(device_event_id, '')`) and moves the unique index onto it. The
migration is repeatable. `sql/001_schema.sql` already contains the fixed
schema for new databases.
## Live stop convergence
`stopping` is never moved back to an active phase (`starting`/`streaming`/
`reconnecting`). Any transition that did so would make the reconciler see an
active session again and publish another `video.stop_stream`, producing an
unbounded stop loop when the edge no longer knows the session.
If the edge restarts `dock-video-relay`, it returns to `idle` and rejects the
stop with `VIDEO_SESSION_MISMATCH` (protocol section 5.2.4; the mock uses
`SESSION_NOT_FOUND`). Both codes mark the session `stopped` immediately.
Otherwise the session stays `stopping` and is forced to `stopped` after 120
seconds (`stopForceAfter`), including when the stop command times out because
the edge is offline. That bound also keeps
`uk_live_session_active_dock` released so the dock can start a new session.
## Live session lifetime
A live session's absolute deadline comes from `live.max-session-seconds` (falling
back to `live.auth-expire-seconds` when unset). It is sent to the edge as
`video.start_stream.expiresAt` and also bounds the signed push/play URLs, so it
cannot be extended mid-session: re-sending `video.start_stream` with a different
`expiresAt` changes the params digest and the edge rejects it with
`COMMAND_ID_CONFLICT` (protocol section 5.1/6).
Idle shutdown is therefore handled by viewer leases, not by that deadline: with
no active lease the session is stopped after `live.stop-grace-seconds`, while a
viewer that keeps calling the heartbeat endpoint keeps the stream alive until
the absolute deadline. The production value is 2 hours (`max-session-seconds:
7200`); raise it if users need longer continuous viewing. A `stopping` session
is forced to `stopped` after 120 seconds even if the edge keeps reporting
`streaming=true`, so a stuck publisher cannot block the dock from starting a new
session.
## Live billing bitrate
Live billing now charges the average bitrate the edge reports in
`state/video.bitrateBps` (the protocol's `expectedBitrateBps`, 1 Mbps by
default) instead of the session peak `maxBitrateBps` (1.5 Mbps). Run
`sql/024_live_session_billing_bitrate.sql` before deploying; it adds
`live_session.billing_bitrate_bps`. The migration is repeatable and
`sql/001_schema.sql` already contains the column for new databases. Until the
edge reports a bitrate the charged value falls back to `max_bitrate_bps`.
## Live start params rehearsal
`video.start_stream` retries must resend the exact same `params` because the
edge compares a params digest for `commandId` idempotency. The push URL carries
a short-lived credential and is therefore stored in Redis under
`laic:live:startparams:{commandId}` (never in MySQL, per the live backend
design, section 11) and replayed verbatim on retry.
## Test traffic grant
For QA environments, set the following configuration explicitly (it defaults

7
cache/cache_key.go

@ -25,6 +25,7 @@ const (
TrafficKeyPrefix = "laic:traffic:" // 流量余额 key 前缀(快照刷新扫描用)
LiveBilledKey = "laic:live:billed:%s" // 直播会话上次计费时间戳(sessionId)
LiveStartParamsKey = "laic:live:startparams:%d" // video.start_stream 原始 params 缓存(commandId)
)
func UserTokenKeyOf(userID int64, shortID string) string {
@ -90,3 +91,9 @@ func TrafficKeyOf(userID int64) string {
func LiveBilledKeyOf(sessionID string) string {
return fmt.Sprintf(LiveBilledKey, sessionID)
}
// LiveStartParamsKeyOf 缓存 video.start_stream 的完整 params。推流地址含短期鉴权串,
// 按设计不落 MySQL,但超时重试必须原样重发同一份 params。
func LiveStartParamsKeyOf(commandID int64) string {
return fmt.Sprintf(LiveStartParamsKey, commandID)
}

2
common/busi_error.go

@ -62,6 +62,7 @@ const (
LiveProviderInvalid = 57005
LiveLeaseNotFound = 57006
LiveStopping = 57007
LiveNotReady = 57008
// 计费 58xxx
TrafficNotEnough = 58001
@ -124,6 +125,7 @@ var (
ErrLiveProviderInvalid = &BusiError{Code: LiveProviderInvalid, Msg: "直播服务配置无效"}
ErrLiveLeaseNotFound = &BusiError{Code: LiveLeaseNotFound, Msg: "直播观看租约不存在或已过期"}
ErrLiveStopping = &BusiError{Code: LiveStopping, Msg: "上一场直播正在停止,请稍后重试"}
ErrLiveNotReady = &BusiError{Code: LiveNotReady, Msg: "直播尚未就绪"}
ErrVideoNotFound = &BusiError{Code: VideoNotFound, Msg: "视频不存在"}
ErrVideoUploading = &BusiError{Code: VideoUploading, Msg: "视频上传中"}

8
common/mysql.go

@ -81,7 +81,13 @@ func extractConstraintName(errMsg string) string {
start := strings.LastIndex(errMsg, "for key '") + len("for key '")
end := strings.LastIndex(errMsg, "'")
if start < end {
return errMsg[start:end]
name := errMsg[start:end]
// MySQL 8 的错误信息会带表名前缀(table.key),只保留索引名,
// 否则调用方按 'uk_xxx' 比较时永远匹配不到。
if i := strings.LastIndex(name, "."); i >= 0 {
name = name[i+1:]
}
return name
}
return ""
}

3
config-prod.yaml

@ -39,6 +39,9 @@ live:
play-url-ttl-seconds: 300
protocol: srt
srt-port: 1105
# 单次直播会话的绝对时长上限,同时决定下发给设备的 expiresAt 与推流/播放
# 地址签名有效期。会话中途无法延长(协议不允许重发不同 params 的 start_stream),
# 而无人观看由观看租约 + stop-grace-seconds 控制。
max-session-seconds: 7200
viewer-lease-seconds: 30
stop-grace-seconds: 15

9
handler/live_handler.go

@ -119,7 +119,14 @@ func StopLive(c *gin.Context) {
common.FailWithBusiError(c, common.ErrParam)
return
}
if busiErr := service.DefaultLiveService.Stop(common.GetUserId(c), common.IsAdmin(c), dockID); busiErr != nil {
// streamSessionId 可在 body 或 query 中携带;为空时按兼容路径停止当前活动会话。
var req vo.LiveStopReq
_ = c.ShouldBindJSON(&req)
sessionID := req.StreamSessionID
if sessionID == "" {
sessionID = c.Query("streamSessionId")
}
if busiErr := service.DefaultLiveService.Stop(common.GetUserId(c), common.IsAdmin(c), dockID, sessionID); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}

1
model/media.go

@ -11,6 +11,7 @@ type LiveSession struct {
PushURLHash string `gorm:"column:push_url_hash;type:VARCHAR(128)" json:"pushUrlHash"`
ExpiresAt int64 `gorm:"column:expires_at;type:BIGINT;not null" json:"expiresAt"`
MaxBitrateBps int64 `gorm:"column:max_bitrate_bps;type:BIGINT;default:1500000" json:"maxBitrateBps"`
BillingBitrateBps int64 `gorm:"column:billing_bitrate_bps;type:BIGINT;not null;default:0" json:"billingBitrateBps"`
Phase string `gorm:"column:phase;type:VARCHAR(16);default:idle" json:"phase"`
StopReason string `gorm:"column:stop_reason;type:VARCHAR(32)" json:"stopReason"`
ErrorCode string `gorm:"column:error_code;type:VARCHAR(64)" json:"errorCode"`

2
mqtt/message.go

@ -16,8 +16,6 @@ import (
// }
type Envelope struct {
RequestID string `json:"requestId"`
EventID string `json:"eventId"`
Version int64 `json:"version"`
DockID string `json:"dockId"`
DroneSN *string `json:"droneSn"`
Timestamp int64 `json:"timestamp"`

12
mqtt/mqtt.go

@ -2,6 +2,7 @@ package mqtt
import (
"errors"
"regexp"
mqtt "github.com/eclipse/paho.mqtt.golang"
@ -73,6 +74,15 @@ func inboundHandler(c mqtt.Client, msg mqtt.Message) {
}
}
// authKeyPattern 匹配推流/播放地址中的短期鉴权串。
var authKeyPattern = regexp.MustCompile(`auth_key=[^"',&\s]+`)
// redactSecrets 在下行 payload 落日志前遮蔽鉴权串。
// 直播后台设计 §11 要求推流 URL 和播放鉴权参数不得进入日志。
func redactSecrets(payload []byte) string {
return authKeyPattern.ReplaceAllString(string(payload), "auth_key=***")
}
// Publish 发布消息到指定 topic
func Publish(topic string, qos byte, retained bool, payload []byte) error {
if client == nil || !client.IsConnected() {
@ -82,7 +92,7 @@ func Publish(topic string, qos byte, retained bool, payload []byte) error {
"topic="+topic,
"qos=", qos,
"retained=", retained,
"payload="+string(payload),
"payload="+redactSecrets(payload),
)
token := client.Publish(topic, qos, retained, payload)
token.Wait()

14
service/billing_service.go

@ -501,6 +501,18 @@ func mustID() int64 {
return id
}
// billingBitrateBps 返回计费用码率:优先使用设备上报的平均码率(协议 §7.8
// 的 expectedBitrateBps),仅在设备尚未上报时退化到会话峰值码率。
func billingBitrateBps(session *model.LiveSession) int64 {
if session.BillingBitrateBps > 0 {
return session.BillingBitrateBps
}
if session.MaxBitrateBps > 0 {
return session.MaxBitrateBps
}
return 1500000
}
// chargeLiveSessions 只结算设备与云端均确认在线的 streaming 会话。
func (b *BillingService) chargeLiveSessions() {
var sessions []model.LiveSession
@ -545,7 +557,7 @@ func (b *BillingService) chargeLiveSession(session *model.LiveSession, now time.
if !now.After(periodStart) {
return nil
}
amount := int64(now.Sub(periodStart).Seconds() * float64(current.MaxBitrateBps) / 8)
amount := int64(now.Sub(periodStart).Seconds() * float64(billingBitrateBps(&current)) / 8)
if amount <= 0 {
return nil
}

83
service/command_service.go

@ -166,6 +166,19 @@ func (s *CommandService) DispatchToDock(dockID, commandType string, params map[s
auditParams["pushUrlHash"] = hex.EncodeToString(sum[:])
delete(auditParams, "pushUrl")
}
if _, ok := params["pushUrl"].(string); ok {
logPushURL(dockID, params)
}
// 推流地址含短期鉴权串,按直播后台设计 §11 不能写入 MySQL;但协议 §5.1/§6
// 要求超时重试复用同一 commandId 且 params 摘要完全一致,因此把原始 params
// 缓存在 Redis 供重试原样重发,避免重签名导致 COMMAND_ID_CONFLICT。
if b, e := json.Marshal(params); e == nil {
if err := common.SetValueWithExpired(cache.LiveStartParamsKeyOf(commandID), string(b), liveStartParamsTTL()); err != nil {
logger.WARN("缓存推流启动参数失败", err)
}
} else {
logger.ERROR("序列化推流启动参数失败", e)
}
}
auditJSON, err := json.Marshal(auditParams)
if err != nil {
@ -200,6 +213,61 @@ func cloneParams(params map[string]any) map[string]any {
return copy
}
// liveStartParamsTTL 返回推流启动参数缓存的有效期:覆盖整个会话时长再留出余量。
func liveStartParamsTTL() uint {
seconds := int64(0)
if common.AppConf != nil {
seconds = common.AppConf.Live.MaxSessionSeconds
}
if seconds <= 0 {
seconds = 7200
}
return uint(seconds + 600)
}
// loadLiveStartParams 读取为指定 commandId 缓存的原始推流参数。
func loadLiveStartParams(commandID int64) (map[string]any, bool) {
raw, err := common.GetBytes(cache.LiveStartParamsKeyOf(commandID))
if err != nil || len(raw) == 0 {
return nil, false
}
var params map[string]any
if err := json.Unmarshal(raw, &params); err != nil {
return nil, false
}
return params, true
}
// logPushURL records the generated push URL so the edge allowlist rejection
// (VIDEO_PUSH_URL_NOT_ALLOWED) can be diagnosed. The signed auth_key is
// redacted so the credential never lands in logs; everything else (scheme,
// host, port, streamid) is kept intact for comparison with the edge allowlist.
func logPushURL(dockID string, params map[string]any) {
pushURL, _ := params["pushUrl"].(string)
streamSessionID, _ := params["streamSessionId"].(string)
logger.INFO("下发推流地址",
"dockId="+dockID,
"streamSessionId="+streamSessionID,
"len="+strconv.Itoa(len(pushURL)),
"url="+redactPushURL(pushURL))
}
// redactPushURL masks only the auth_key value, keeping the rest of the URL
// intact for comparison against the device-side allowlist.
func redactPushURL(pushURL string) string {
const marker = "auth_key="
start := strings.Index(pushURL, marker)
if start < 0 {
return pushURL
}
valueStart := start + len(marker)
end := strings.IndexAny(pushURL[valueStart:], ",&")
if end < 0 {
return pushURL[:valueStart] + "***"
}
return pushURL[:valueStart] + "***" + pushURL[valueStart+end:]
}
func (s *CommandService) StartRetryScanner() {
go func() {
ticker := time.NewTicker(10 * time.Second)
@ -253,7 +321,20 @@ func (s *CommandService) retryTimeout() {
_ = json.Unmarshal([]byte(cmd.Params), &params)
}
if cmd.CommandType == "video.start_stream" {
params = DefaultLiveService.RefreshStartParams(cmd.DockID, params)
// 审计参数里只有 pushUrlHash;重试必须使用缓存的原样 params,
// 否则参数摘要变化会被工控机按 COMMAND_ID_CONFLICT 拒绝。
sessionID, _ := params["streamSessionId"].(string)
cached, ok := loadLiveStartParams(cmd.ID)
if !ok {
logger.ERROR("重试缺少缓存的推流启动参数,跳过重发 commandId="+strconv.FormatInt(cmd.ID, 10), errors.New("live start params lost"))
if sessionID != "" {
_ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting')", sessionID, cmd.DockID).
Updates(map[string]any{"phase": "failed", "error_code": "START_PARAMS_LOST"}).Error
}
continue
}
params = cached
logPushURL(cmd.DockID, params)
}
b, _ := json.Marshal(s.commandEnvelope(&cmd, requestID, params))
topic := fmt.Sprintf("dock-edge/v1/dock/%s/command", cmd.DockID)

261
service/live_service.go

@ -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"
}
}

46
service/mqtt_subscriber.go

@ -93,9 +93,9 @@ type otaReported struct {
}
// videoState §7.8 视频推流状态
// 协议外层(§4)和 payload 都没有 eventId/version,只有 updatedAt(毫秒),
// 因为 state/video 是状态快照而不是事件流。
type videoState struct {
Version int64 `json:"version"`
EventID string `json:"eventId"`
Provider string `json:"provider"`
Phase string `json:"phase"`
InputOnline bool `json:"inputOnline"`
@ -106,7 +106,7 @@ type videoState struct {
Width *int `json:"width"`
Height *int `json:"height"`
FrameRate *float64 `json:"frameRate"`
BitrateBps uint64 `json:"bitrateBps"`
BitrateBps int64 `json:"bitrateBps"`
RetryCount int `json:"retryCount"`
StopReason *string `json:"stopReason"`
ErrorCode *string `json:"errorCode"`
@ -291,20 +291,40 @@ func handleVideo(dockID string, env *mqtt.Envelope) {
_ = json.Unmarshal(env.Payload, &raw)
var st videoState
if err := json.Unmarshal(env.Payload, &st); err != nil {
logger.ERROR("解析 state/video 失败", err)
logger.ERROR("解析 state/video 失败 payload="+string(env.Payload), err)
return
}
if st.EventID == "" {
st.EventID = env.EventID
}
if st.Version == 0 {
st.Version = env.Version
}
if st.StreamSessionID == "" || st.EventID == "" || st.Version <= 0 || st.UpdatedAt <= 0 {
logger.WARN("忽略缺少顺序元数据的视频状态", dockID, st.StreamSessionID)
// mqtt_interface_design.md §7.8:state/video 是保留状态快照,协议外层(§4)
// 和 payload 都没有 eventId/version(“状态快照不需要单独的事件 ID”),只有
// updatedAt(毫秒)。因此用 updatedAt 充当单调序号,并以
// <streamSessionId>-<updatedAt> 作为幂等去重键(内部仍存入 device_event_id)。
sequence := st.UpdatedAt
eventID := ""
if st.StreamSessionID != "" && st.UpdatedAt > 0 {
eventID = st.StreamSessionID + "-" + strconv.FormatInt(st.UpdatedAt, 10)
}
// 无条件记录每一条 state/video,用于判断设备是否真的在上报。
logger.INFO("收到视频状态",
"dockId="+dockID,
"requestId="+env.RequestID,
"streamSessionId="+st.StreamSessionID,
"streaming="+strconv.FormatBool(st.Streaming),
"phase="+st.Phase,
"updatedAt="+strconv.FormatInt(st.UpdatedAt, 10),
"eventId="+eventID,
"raw="+string(env.Payload))
if st.StreamSessionID == "" || eventID == "" || sequence <= 0 || st.UpdatedAt <= 0 {
// 设备自述“没有会话且不在推流”是权威的重启信号:dock-video-relay 把会话
// 保存在进程内存里,重启后会回到 idle + streamSessionId=null。这条消息
// 之前被直接丢弃,导致后端会话只能卡在 reconnecting 直到 expires_at。
if st.StreamSessionID == "" && !st.Streaming {
DefaultLiveService.OnDeviceIdle(dockID)
}
logger.WARN("忽略缺少顺序元数据的视频状态", dockID, st.StreamSessionID, string(env.Payload))
return
}
if !DefaultLiveService.OnVideoState(dockID, st.Streaming, st.StreamSessionID, st.EventID, st.Version, st.UpdatedAt) {
if !DefaultLiveService.OnVideoState(dockID, st.Streaming, st.StreamSessionID, eventID, sequence, st.UpdatedAt, st.BitrateBps) {
logger.WARN("视频状态被顺序或归属校验拒绝", dockID, st.StreamSessionID, eventID, sequence, st.UpdatedAt)
return
}
_ = common.HashSetValues(cache.VideoKeyOf(dockID), flatten(raw))

5
sql/001_schema.sql

@ -217,6 +217,7 @@ CREATE TABLE IF NOT EXISTS live_session (
push_url_hash VARCHAR(128) COMMENT '推流地址哈希',
expires_at BIGINT NOT NULL COMMENT '过期时间戳',
max_bitrate_bps BIGINT DEFAULT 1500000 COMMENT '最大码率(比特/秒)',
billing_bitrate_bps BIGINT NOT NULL DEFAULT 0 COMMENT '设备上报的平均码率(bit/s),计费口径',
phase VARCHAR(16) DEFAULT 'idle' COMMENT '直播会话阶段',
stop_reason VARCHAR(32) COMMENT '停止原因',
error_code VARCHAR(64) COMMENT '错误码',
@ -236,9 +237,11 @@ CREATE TABLE IF NOT EXISTS live_session (
active_dock_id VARCHAR(64) GENERATED ALWAYS AS (
CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END
) STORED COMMENT '活动机巢标识(生成列)',
device_event_key VARCHAR(128) GENERATED ALWAYS AS (NULLIF(device_event_id, '')) STORED
COMMENT '设备事件唯一键(空串转 NULL,允许多条未上报事件的会话)',
KEY idx_live_session_dock_phase (dock_id, phase, created_at),
KEY idx_live_session_phase_deadline (phase, stop_deadline),
UNIQUE KEY uk_live_session_device_event (device_event_id),
UNIQUE KEY uk_live_session_device_event (device_event_key),
UNIQUE KEY uk_live_session_active_dock (active_dock_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

12
vo/media_vo.go

@ -18,6 +18,12 @@ type LiveStartReq struct {
MaxBitrateBps int64 `json:"maxBitrateBps"`
}
// LiveStopReq 停止直播。streamSessionId 建议携带:省略时为兼容旧客户端的
// “停止当前活动会话”,携带时必须与当前活动会话一致,避免旧页面停掉新会话。
type LiveStopReq struct {
StreamSessionID string `json:"streamSessionId"`
}
// LiveSessionCreateReq 创建或加入指定机巢的活动直播会话。
type LiveSessionCreateReq struct {
MaxBitrateBps int64 `json:"maxBitrateBps"`
@ -29,10 +35,14 @@ type LiveSessionVO struct {
LeaseExpiresAt int64 `json:"leaseExpiresAt"`
}
// LivePlayURLVO 直播播放地址
// LivePlayURLVO 直播播放地址。playUrl 保留 HLS 以兼容旧前端;
// 实时监控建议用 playUrlFlv(mpegts.js),延迟远低于 HLS。
type LivePlayURLVO struct {
StreamName string `json:"streamName"`
PlayURL string `json:"playUrl"`
PlayURLHLS string `json:"playUrlHls"`
PlayURLFLV string `json:"playUrlFlv"`
PlayURLRTMP string `json:"playUrlRtmp"`
ExpiresAt int64 `json:"expiresAt"`
}

Loading…
Cancel
Save