ghp / docs /architecture /data-flow.md
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
|
Raw
History Blame Contribute Delete
27.9 kB

架构与数据流

整体架构

graph TB
  subgraph client [Client - React SPA]
    Pages[Pages]
    Hooks[Custom Hooks]
    Stores[Zustand Stores]
    SocketClient[Socket.IO Client]
  end

  subgraph server [Server - Node.js]
    Express[Express REST API]
    SocketServer[Socket.IO Server]
    Controllers[Controllers]
    Services[Services]
    Repos[In-Memory Repositories]
  end

  subgraph external [External]
    Meting["@meting/core 音乐源"]
  end

  Pages --> Hooks
  Hooks --> Stores
  Hooks --> SocketClient

  SocketClient <-->|"WebSocket 双向通信"| SocketServer
  Pages -->|"HTTP GET /api/music/*"| Express

  SocketServer --> Controllers
  Controllers --> Services
  Services --> Repos
  Express --> Meting
  Services --> Meting

Socket 事件清单

分类 客户端 → 服务端 服务端 → 客户端
Room room:create, room:join, room:leave, room:list, room:settings, room:set_role room:created, room:state, room:user_joined, room:user_left, room:settings, room:error, room:list_update, room:role_changed
Player player:play, player:pause, player:seek, player:next, player:prev, player:sync, player:sync_request, player:set_mode player:play, player:pause, player:resume, player:seek, player:sync_response
Queue queue:add, queue:add_batch, queue:remove, queue:reorder, queue:clear queue:updated
Chat chat:message chat:message, chat:history
Vote vote:start, vote:cast vote:started, vote:result
Auth auth:request_qr, auth:check_qr, auth:set_cookie, auth:logout, auth:get_status auth:qr_generated, auth:qr_status, auth:set_cookie_result, auth:status_update, auth:my_status
Playlist playlist:get_my playlist:my_list
NTP ntp:ping ntp:pong
Server server:audio_proxy_policy

关键数据模型

// 音乐曲目
interface Track {
  id: string
  title: string
  artist: string[]
  album: string
  duration: number
  cover: string
  source: 'netease' | 'tencent' | 'kugou'
  sourceId: string
  urlId: string
  lyricId?: string
  picId?: string
  streamUrl?: string
  requiresServerProxy?: boolean // 上游字节需要服务器解密等处理
  requestedBy?: string // 点歌人昵称
  vip?: boolean // 是否为 VIP / 付费歌曲(可能无法播放或仅试听)
}

// 播放模式
type PlayMode = 'sequential' | 'loop-all' | 'loop-one' | 'shuffle'

// 音频质量档位 (kbps)
type AudioQuality = 128 | 192 | 320 | 999 | 'highest' | ProviderSpecificQuality

interface AudioProxyPolicy {
  kugouForceProxy: boolean
}

// 客户端可见的房间状态
interface RoomState {
  id: string
  name: string
  creatorId: string
  hostId: string
  hasPassword: boolean
  password?: string | null // 仅 owner 专用状态携带;公开状态不含该字段
  audioQuality: AudioQuality
  users: User[]
  queue: Track[]
  currentTrack: Track | null
  playState: PlayState
  playMode: PlayMode
}

// 播放状态(含服务端时间戳用于同步校准)
interface PlayState {
  isPlaying: boolean
  currentTime: number
  serverTimestamp: number
}

// 预定执行播放状态(play/pause/seek/resume 广播时使用)
interface ScheduledPlayState extends PlayState {
  serverTimeToExecute: number // 客户端应在此服务器时间点执行动作
}

// 歌单元数据
interface Playlist {
  id: string
  name: string
  cover: string
  trackCount: number
  source: MusicSource
  creator?: string
  description?: string
}

// 用户(RBAC: owner > admin > member)
// hostId 是自动选举的播放主持(conductor),不是可见角色
interface User {
  id: string
  nickname: string
  role: UserRole
}
type UserRole = 'owner' | 'admin' | 'member'

// 聊天消息
interface ChatMessage {
  id: string
  userId: string
  nickname: string
  content: string
  timestamp: number
  type: 'user' | 'system'
}

播放同步机制

采用事件驱动同步 + 周期性比例漂移校正架构:NTP 时钟同步 + Scheduled Execution + 比例控制漂移校正(EMA 平滑 + proportional rate + hard seek)。

三层防护

  1. NTP 时钟同步:保证各客户端时钟与服务器对齐(时间衰减加权中位数)
  2. Scheduled Execution:离散事件(play/pause/seek/resume)通过预定执行消除网络延迟差异(P90 RTT 自适应调度)
  3. 周期性比例漂移校正:客户端每 2 秒发起 PLAYER_SYNC_REQUEST,服务端返回当前预期位置;漂移经 EMA 低通滤波后进入比例控制器,rate 调整幅度与漂移成正比(自然收敛无振荡),>200ms 用 hard seek 跳转

Layer 1:NTP 时钟同步 + RTT 回报

客户端与服务器通过 ntp:ping / ntp:pong 事件交换时间戳,计算 clockOffset(客户端与服务端时钟差值),使 getServerTime() 返回与服务端对齐的时间。

  • 初始阶段:快速采样(每 50ms)收集 20 个样本,使用 switchedRef 保证仅在首次校准完成时切换到稳定阶段
  • 稳定阶段:每 5 秒一次 NTP 心跳
  • NTP 仅在用户进入房间后启动(ClockSyncRunner 渲染在 RoomPage 中),大厅用户不运行时钟同步
  • 使用时间衰减加权中位数计算 offset:每个样本按 exp(-age / halfLife) 衰减(halfLife=30s),兼具中位数的异常值鲁棒性和对网络环境突变的快速收敛能力
  • performance.now() 锚点getServerTime() 基于 performance.now()(单调递增)计算时间流逝,不受系统时钟突变影响(NTP 调整、手动改时间、休眠唤醒)。每次 processPong 刷新锚点:anchorServerTime = Date.now() + medianOffsetanchorPerfNow = performance.now()
  • RTT 回报:每次 ntp:ping 附带 lastRttMs(客户端中位 RTT),服务端在 NTP_PING handler 中调用 roomRepo.setSocketRTT() 存储,用于自适应调度延迟计算
  • 核心模块:clockSync.ts(采样引擎 + getServerTime() + getMedianRTT() + computeWeightedMedian())、useClockSync.ts(React Hook,在 SocketProvider 中运行)

Layer 2:Scheduled Execution(预定执行)

所有多客户端同步动作(play、pause、seek、resume)由服务端广播 ScheduledPlayState,包含 serverTimeToExecute 字段。客户端收到后通过 setTimeout(execute, serverTimeToExecute - getServerTime()) 在同一时刻执行,消除网络延迟差异。

  • 服务端根据房间 P90 RTT 动态计算调度延迟:max(P90RTT * 1.5 + 100, 300ms),上限 3000ms。P90 避免单个慢连接拖累整个房间,房间人数 ≤3 时退化为取 max
  • RTT 由客户端 NTP 测量后通过 ntp:ping 事件回报,服务端以指数移动平均(alpha=0.2)平滑存储在 roomRepository 的 per-socket RTT map
  • 全部客户端(含操作发起者)统一收到广播并在预定时刻执行
  • serverTimestamp 对齐:播放中的动作(play/resume/seek)将 room.playState.serverTimestamp 设为 serverTimeToExecute 而非 Date.now(),确保 estimateCurrentTime() 在下一次 conductor 上报前也能准确估算位置
  • Scheduled action(seek/pause/resume)执行时自动重置 rate(1),避免残留非正常速率;执行后同步更新 roomStore.playState(仅 PlayState 三字段,不含 serverTimeToExecute),确保 recovery effect 读到最新状态
  • NTP 未校准保护scheduleDelay()usePlayer 的 PLAYER_PLAY 调度在 NTP 未校准完成前退化为 0(立即执行),避免本地时钟偏差导致离谱的调度延迟
  • Action ID 竞态保护:每个 scheduled action 分配单调递增 ID,setTimeout(fn, 0) 回调执行前检查 ID 是否匹配,防止快速连续事件导致 stale 回调执行

Layer 3:周期性比例漂移校正(EMA + Proportional Rate + Hard Seek)

非 conductor 客户端SYNC_REQUEST_INTERVAL_MS(2s)向服务端发送 PLAYER_SYNC_REQUEST(conductor 跳过,因为 conductor 是权威播放源,不应被 server 估算值反向校正),服务端通过 estimateCurrentTime() 计算当前预期位置后回复 PLAYER_SYNC_RESPONSE。客户端利用 NTP 校准时钟补偿网络延迟,计算原始漂移量后经 EMA 低通滤波(alpha=0.3)得到 smoothedDrift,再进入比例控制器:

  • 新曲 Grace Period:新曲加载后 DRIFT_GRACE_PERIOD_MS(3s)内仅跳过 rate 微调(EMA 产生的比例速率校正),但保留 hard seek(大偏差 >200ms 仍会跳转修正)。此窗口内 estimateCurrentTime() 基于 scheduleTime 锚点,尚未被 conductor 上报修正,rate 微调可能基于不准确的估算。等待至少一次 conductor 上报后再启用全面校正
  • EMA 平滑smoothed = alpha * rawDrift + (1 - alpha) * prevSmoothed,消除测量噪声导致的正负跳动
  • EMA 冷启动种子:pause/resume/新曲/hard seek 后 EMA 重置,首次 sync response 直接用 rawDrift 种子初始化(而非从 0 开始混合),避免恢复播放后 6-8 秒的 EMA 收敛滞后
  • |smoothedDrift| > DRIFT_SEEK_THRESHOLD_MS(200ms)→ hard seek 到预期位置 + rate(1) + 重置 smoothedDrift
  • |smoothedDrift| 5~200ms(死区之上) → 比例控制rate = 1 - clamp(smoothedDrift * Kp, ±MAX_RATE_ADJUSTMENT)(Kp=0.5,最大 ±2%)。漂移越大修正越强,接近目标时自然减速——数学上保证不振荡
  • |smoothedDrift| < DRIFT_DEAD_ZONE_MS(5ms)→ 恢复正常速率 rate(1)(消除稳态微小抖动)
  • UI 展示 smoothedDrift 而非 rawDrift,界面数值更稳定
  • 插件干扰自动降级:设置 rate 后通过 setTimeout(50ms) 验证是否生效(timer 存于 ref,每次新 sync response 前清理上一个,组件卸载时也清理),若连续 3 次检测到被浏览器倍速插件覆盖才标记 rateDisabled;禁用后 hard seek 阈值降至 DRIFT_PLUGIN_SEEK_THRESHOLD_MS(30ms);新曲加载时重置标记和计数器

典型场景:手机息屏暂停后解锁、浏览器后台标签页节流、网络波动导致的累积偏移。

Conductor 上报与服务端状态维护

Conductor(当前 hostId 对应用户)自适应频率上报当前播放位置到服务端:新曲开始后前 10 秒高频上报(每 2 秒,CONDUCTOR_REPORT_FAST_INTERVAL_MS),之后回到正常频率(每 5 秒,CONDUCTOR_REPORT_INTERVAL_MS),使用动态 setTimeout 链实现。仅用于维护 room.playState 的准确性(供 mid-song join、reconnect recovery 和漂移校正使用),不会转发给其他客户端。Conductor 标签页从后台恢复时(visibilitychange → visible),立即补偿上报一次当前位置,避免 setTimeout 被浏览器节流后 playState 过时。

  • NTP 校准时间戳:conductor 上报时附带 hostServerTime(历史字段名,通过 getServerTime() 获取的 NTP 校准后服务器时间),服务端优先使用此值作为 playState.serverTimestamp,替代 Date.now()。这消除了 conductor→Server 单向网络延迟(≈RTT/2)导致的 estimateCurrentTime() 系统性落后偏差。服务端对 hostServerTime 做 10 秒容差校验(Math.abs(hostServerTime - Date.now()) < 10_000),超出范围回退到 Date.now()
  • 服务端通过 playerService.validateConductorReport() 校验 conductor 上报位置与 estimateCurrentTime() 预估值的偏差,超过 CONDUCTOR_REJECT_DRIFT_THRESHOLD_S(3 秒)的报告视为过时数据(如手机息屏后恢复)被拒绝;但连续拒绝 CONDUCTOR_REJECT_FORCE_ACCEPT_COUNT(2)次后强制接受以打破僵局。conductorRejectCountlastNextTimestampplayMutexes 统一在 playerService.cleanupRoom() 中清理,避免内存泄漏。Conductor 切换时自动刷新 playState.serverTimestampcurrentTime,确保新 conductor 的首个报告不会被误拒
  • syncService.estimateCurrentTime() 基于 conductor 上报的位置 + 经过时间估算当前位置,对 elapsedMath.max(0, ...) 防护(serverTimestamp 可能是未来的 scheduleTime),且 clamp 到曲目时长上界(room.currentTrack.duration),防止 conductor 断线后估算值无限增长
  • 新用户加入时,通过 ROOM_STATE 获取 playState 并计算应跳转到的位置
  • 断线重连时,usePlayer 的 recovery 机制自动检测 desync 并重新加载音轨。Recovery 通过检查 loadingRef 避免与 onPlayerPlay 双重 loadTrack,且在加载前清理 playTimerRef 防止定时器重复触发
  • 加载补偿上限useHowl 加载音频后会根据 loadStartTime 计算 elapsed 补偿 seek,但 elapsed 被 MAX_LOAD_COMPENSATION_S(2s)上限 clamp,防止网络慢时跳过歌曲开头过多
  • 漂移校正时,PLAYER_SYNC_RESPONSE 基于此数据返回准确位置

播放模式

房间支持 4 种播放模式(PlayMode),由 room.playMode 字段控制,默认 loop-all

模式 说明
sequential 顺序播放,末尾停止
loop-all 列表循环,末尾回到第一首
loop-one 单曲循环,重播当前曲目
shuffle 随机播放,从队列随机选一首(排除当前)
  • Owner/Admin 直接 emit player:set_mode,服务端更新 room.playMode 并广播 ROOM_STATE
  • Member 通过 vote:start { action: 'set-mode', payload: { mode } } 投票切换
  • 指定播放:播放列表工具栏提供 Play 按钮,Owner/Admin 直接 emit player:play;Member 通过 vote:start { action: 'play-track', payload: { trackId, trackTitle } } 投票播放
  • 投票移除:播放列表工具栏的删除按钮对所有用户可见,Owner/Admin 直接 emit queue:remove;Member 通过 vote:start { action: 'remove-track', payload: { trackId, trackTitle } } 投票移除
  • 服务端 queueService.getNextTrack(roomId, playMode) 根据模式返回下一首;getPreviousTrackloop-all 模式下支持尾→首回绕
  • 客户端 PlayerControls 提供循环切换按钮,带 AnimatePresence 图标过渡动画

音频质量

房间音质由 room.audioQuality 字段控制,默认 320(HQ)。除固定和平台特色档位外,highest 表示“尽量高”:

档位 bitrate 说明
标准 128 kbps 流量节省
较高 192 kbps 平衡音质与流量
HQ 320 kbps 高品质(默认)
无损 SQ 999 kbps 无损音质,通常需要 VIP 账号
尽量高 highest 根据歌曲平台及房间内最高会员等级选择
  • 房主和房间管理员可在房间设置中切换音质;其他房间设置仍仅限房主
  • 登录平台会员账号不会改变房间音质;新房间仍保持默认 320,需要房主手动切换其他档位
  • 音质切换仅对下一首歌生效,当前播放不中断
  • 服务端将各平台会员统一为 0/1/2(普通/VIP/SVIP),并从房间凭据池选择该歌曲平台等级最高的账号
  • audioQualityPolicy.getEffectiveQuality() 一次确定不超过账号权益和房间设置的目标档位,不通过连续请求猜测会员等级
  • 网易云:普通账号最高 320、VIP 最高高清臻音、SVIP 最高超清母带;QQ 与酷狗使用各自对应的 VIP/SVIP 档位
  • B站:普通账号最高 192K,普通/年度大会员最高 Hi-Res;服务端用带 Cookie 的单次 WBI playurl 请求合并普通和 FLAC DASH 音轨,并在同一响应内选择不超过目标的最高可用档位。B站杜比为 E-AC-3,桌面浏览器支持不完整,当前明确不参与选流
  • B站沿用现有五档,不增加平台专属 UI 选项:
房间音质 无会员 普通大会员 年度大会员
标准 128kbps B站 132K B站 132K B站 132K
较高 192kbps B站 192K B站 192K B站 192K
高品质 320kbps B站 192K B站 192K B站 192K
无损 SQ B站 192K Hi-Res Hi-Res
尽量高 B站 192K Hi-Res Hi-Res

B站没有与房间 128K、320K 完全对应的普通 DASH 音轨,因此分别映射为最接近的 132K、192K。Hi-Res 还要求视频本身提供 FLAC 音轨,否则直接从同一次响应选 192K,不再次请求其他音质。

  • 上游若返回歌曲实际可用的较低规格,服务端记录 actualQuality 和实际平均码率,不再逐档重新请求
  • musicProvider.streamUrlCache 的 key 包含 bitrate,不同音质自动隔离缓存

音频代理策略

  • B站音频始终通过服务器代理,播放所需 Cookie 只保留在服务端,不提供关闭入口。
  • 全局 AudioProxyPolicy 只包含酷狗策略并持久化在 SQLite server_settings 表,旧数据库或无效配置默认酷狗强制代理。
  • 只有服务器管理员可以通过 GET/PATCH /api/admin/audio-proxy-policy 读取或修改酷狗策略;PATCH 接受 kugouForceProxy 并返回完整策略。
  • 新 WebSocket 连接会收到 server:audio_proxy_policy,管理员修改后服务端向 lobby 中的全部连接广播完整策略。
  • kugouForceProxy=false 时,Web 端的酷狗标准版明文资源由兼容代理入口返回 307 到 CDN,音频字节不经过服务器;酷狗概念版和需要服务端处理的资源仍使用代理。Android 继续按自身能力先请求 CDN,失败时仅回退一次现有服务器代理。
  • 重新启用酷狗强制代理时,Android 将正在直连的对应曲目保留位置切回代理;关闭强制代理不打断当前播放,从下一次加载开始生效。
  • 酷狗策略同时覆盖 kugoukugou_concept。服务端登记概念版 URL,并为已注册 QMC2 解密器的流设置 Track.requiresServerProxy=true.mflac/.mgg 地址也按加密资源保守处理。Android 即使在关闭强制代理时也直接使用服务器代理解密,仅对明文资源尝试 CDN 直连。

队列清空

  • Owner/Admin 可通过播放列表抽屉的「清空」按钮(ListX 图标)一次性清空队列
  • 采用二次确认防误操作:首次点击变为 destructive 提示,3 秒内再次点击才执行
  • 服务端 queue:clear handler 复用 remove on Queue 权限,清空后停止播放并广播 QUEUE_UPDATED + PLAYER_PAUSE + ROOM_STATE

其他同步机制

  1. 暂停快照:服务端 pauseTrack() 在暂停前调用 estimateCurrentTime() 快照准确位置
  2. 恢复播放:暂停后点击播放,服务端检测同一首歌时发 player:resume(所有客户端预定时刻恢复)
  3. 自动续播:房主独自重新加入时,若有歌曲暂停/排队中,自动恢复播放
  4. 加入房间补偿:中途加入的客户端使用 getServerTime() 计算当前应处的播放位置,采用 fade-in 淡入策略(400ms 等待 + 200ms fade)减少加入延迟
  5. 房间宽限期:普通房间空置 60 秒 (ROOM_GRACE_PERIOD_MS) 后自动清理(重复调用 scheduleDeletion 不会创建重复 timer);永久房间不会因空置被清理,最近 200 条聊天消息会持久化到 SQLite 并在服务重启后恢复
  6. 角色与 Conductor 机制:房间记录 creatorId(创建者 ID,永久不变)、adminUserIds: Set<string>(持久化 admin 集合)和 temporaryAdminUserId(临时管理员,仅在线态)。非空房间通过 reconcileRoomRoles() 保证至少有一个具备管理能力的在线用户:创建者在线时为 owner;持久 admin 在线时保持 admin;若 owner / 持久 admin 都不在线,则选择一个在线成员作为临时 admin,且不写入 adminUserIds。owner / 持久 admin 返回时会清除临时管理员并恢复其普通成员身份。room.hostId 是自动选举的播放主持(conductor),在用户加入/离开时基于已协调后的角色重选(优先级:owner > admin > member),无需宽限期。setUserRole 只能设置持久 admin / member(不能改 owner),同步维护 adminUserIds。返回的创建者/持久化 admin 免密码验证
  7. 持久化用户身份:客户端通过 storage.getUserId() 生成并持久化 nanoid,每次 ROOM_CREATE / ROOM_JOIN 携带 userId,使服务端可跨 socket 重连识别同一用户。服务端通过 roomRepo.getSocketMapping(socket.id) 获取 { roomId, userId } 映射——socket.id 仅用于 Socket 映射查找,所有涉及用户身份的操作(host 判断、auth cookie 归属、权限检查等)统一使用 mapping.userId
  8. currentUser 自动推导roomStorecurrentUser 始终从 room.users 自动推导(deriveCurrentUser),setRoom / addUser / removeUser / updateRoom 等 action 内部自动同步,不暴露 setCurrentUser 以避免脱节风险
  9. 断线时钟重置resetAllRoomState() 除重置 Zustand stores 外,还调用 resetClockSync() 清空 NTP 采样,确保重连后使用全新的时钟校准数据
  10. Socket 断开竞态防护:页面刷新时新旧 socket 的 join/disconnect 到达顺序不确定,leaveRoom 通过 roomRepo.hasOtherSocketForUser() 检测同一用户是否有更新的 socket 连接,避免旧 socket disconnect 误删活跃用户
  11. 投票安全网voteController 接收 VOTE_START 时,若检测到用户已有直接操作权限(owner/admin),不再返回错误,而是直接执行该操作(executeAction),防止客户端-服务端角色不同步时操作失效。部分 VoteAction 通过 PERM_MAP 映射到不同的 CASL action+subject(如 'play-track'('play', 'Player')'remove-track'('remove', 'Queue')
  12. 切歌防抖:500ms (PLAYER_NEXT_DEBOUNCE_MS) 内不重复触发下一首。playNextTrackInRoom / playPrevTrackInRoom 将 debounce 检查和队列导航封装在 per-room mutex 内部,确保同 tick 的多个 NEXT/PREV 事件不会都通过 debounce。支持 { skipDebounce: true } 选项,投票执行、删除当前曲目等场景绕过 debounce 以确保操作不被静默吞掉
  13. 停止播放统一处理playerService.stopPlayback() 统一处理"队列为空/清空"场景——清除 currentTrack、emit PLAYER_PAUSE、广播 ROOM_STATE、刷新大厅列表,避免 controller 中重复逻辑。stopPlaybackSafe() 提供 mutex 保护版本,QUEUE_CLEAR 使用此版本防止与并发 autoPlayIfEmpty 竞态
  14. 大厅重连刷新useLobby 监听 socket connect 事件,断线重连后自动重新拉取房间列表
  15. 投票执行VOTE_CAST / VOTE_STARTexecuteAction 使用 await 确保动作完成后才广播 VOTE_RESULT。投票的 next/prev 通过 playerService.playNextTrackInRoom / playPrevTrackInRoomskipDebounce: true)执行,与直接操作路径完全一致(含 stopPlayback 兜底和播放失败重试),且不受 debounce 影响
  16. 密码安全隔离toPublicRoomState() 默认不含密码明文;toPublicRoomStateForOwner() 仅在发送给 owner 的 socket 时使用(创建房间、加入房间、设置变更、conductor/角色变更)。非 owner 成员仅能看到 hasPassword 布尔标记,无法获取密码明文。设置广播通过 socket.emit(owner) + socket.to(roomId).emit(其他成员)分别发送。owner 在线且 conductor/角色变更时,通过 roomRepo.getSocketIdForUser() 反查 owner 的 socketId 定向发送含密码版本;没有 owner 在线(仅临时管理员)时广播不含密码版本

REST API

路径 方法 用途
/api/music/search GET 搜索曲目(source + keyword + page
/api/music/url GET 解析流媒体 URL(source + id
/api/music/lyric GET 获取歌词
/api/music/cover GET 获取封面图
/api/music/playlist GET 获取歌单曲目列表(source + id + limit + offset),分页返回 { tracks, total, offset, hasMore }
/api/rooms/:roomId/check GET 房间预检(存在性 + 是否需要密码),用于分享链接直接访问时的前置校验
/api/admin/audio-proxy-policy GET 服务器管理员读取酷狗全局强制代理策略
/api/admin/audio-proxy-policy PATCH 服务器管理员部分更新代理策略并广播完整结果
/api/health GET 健康检查