适用读者:有 WebRTC / IM 基础,正在做私聊音视频或从 Mesh 迁移到 SFU 的前端 / 后端同学。
技术栈示例:Spring Boot + Redis + IM 信令;uni-app(H5 / App)+ LiveKit Client SDK。
一、写在前面
一对一视频通话看似"两端 PeerConnection 连一下就完了",真正做成可上线的 IM 能力时,至少要拆成两件事:
- 信令(Signaling):谁在呼叫、谁振铃、谁接听/拒绝/取消、何时挂断
- 媒体(Media):音视频怎么进房、怎么发布与订阅、怎么开关麦和摄像头
本文基于一套真实业务实现,介绍如何用 LiveKit SFU 承载媒体,用 现有 IM 私聊消息 + REST 承载信令,完成完整的一对一视频通话。
二、总体架构:信令与媒体分离

设计原则:
| 层次 | 职责 | 技术 |
|---|---|---|
| 业务信令 | 呼叫、振铃、接听、拒接、取消、失败、挂断、开关音视频状态同步 | IM 消息类型 + /webrtc/private/* |
| 媒体传输 | 进房、发布本地轨、订阅远端轨 | LiveKit Room SDK |
| 会话状态 | 房间名、主被叫终端、忙线 | Redis |
LiveKit 不负责振铃 UI,只负责"拿到 token 之后怎么传音视频"。
三、核心概念:房间、Token、忙线
3.1 房间命名
每次呼叫创建新房间,避免复用脏房间:
p_{min(userA,userB)}{max(userA,userB)}{timestamp}
例如用户 u1、u2:
p_u1_u2_1723000000000
ID 字典序排序,保证同一对用户房间名稳定可推导;时间戳保证每次呼叫唯一。
3.2 Token 签发(后端)
后端用 LiveKit API Key / Secret 签发短时 JWT,客户端永不持有密钥。
关键 grant:

3.3 Redis 会话与忙线
| Key | 含义 | TTL |
|---|---|---|
im:webrtc:private:session:{a}:{b} |
私聊 RTC 会话 | 24h(心跳续期) |
im:user:state:{userId} |
忙线标记 | 60s(心跳续期) |
会话查找兼容双向:先查 A:B,没有再查 B:A。
四、信令消息类型(私聊)
一对一私聊 没有 群聊那种 RTC_GROUP_SETUP,发起信令是 RTC_CALL_*:
| code | 常量 | 含义 | 典型方向 |
|---|---|---|---|
| 100 | RTC_CALL_VOICE | 语音呼叫 | 主叫 → 被叫全终端 |
| 101 | RTC_CALL_VIDEO | 视频呼叫 | 主叫 → 被叫全终端 |
| 102 | RTC_ACCEPT_VIDEO | 视频接听 | 被叫 → 主叫指定终端 |
| 111 | RTC_ACCEPT_VOICE | 语音接听 | 同上 |
| 103 | RTC_REJECT | 拒接 | 被叫 → 主叫 |
| 104 | RTC_CANCEL | 主叫取消 | 主叫 → 被叫 |
| 105 | RTC_FAILED | 呼叫失败(超时等) | 主叫 → 被叫 |
| 106 | RTC_HANDUP | 挂断 | 任一方 → 对方 |
| 110 | RTC_CHANGE_VIDEO_AUDIO | 开关麦/摄像头状态 | 任一方 → 对方 |
| 40/41 | ACT_RT_VOICE/VIDEO | 通话结束后的聊天记录 | 落库展示 |
多端互斥靠两件事:
recvTerminals:只打到"正在参与通话"的那个终端sendToSelf:接听/拒接时通知自己其他终端关闭振铃页
五、REST API 一览
Controller 前缀:/api/v1/webrtc/private
| 接口 | 作用 |
|---|---|
POST /call |
创建会话、忙线、推呼叫信令,返回主叫凭证 |
POST /accept |
写被叫终端、推接听信令,返回被叫凭证 |
POST /token |
按当前用户重新签发进房 JWT(媒体页真正用这个) |
POST /reject |
拒接 |
POST /cancel |
主叫取消 |
POST /failed |
超时失败等 |
POST /handup |
挂断(历史拼写,前后端统一) |
POST /heartbeat |
续会话 + 忙线(主叫定时调用) |
POST /changeVideoAudio |
同步对方 UI 的音视频开关状态 |
实现细节:
/call虽会返回 LiveKit 凭证,但当前前端媒体页统一走/token再进房;拨号页只负责呼叫信令,不立刻进房。
六、完整时序:从拨号到挂断

七、前端页面流转
7.1 发起侧
- 聊天页申请摄像头/麦克风权限
- 进入拨号页:
/sendvideocall?mode=video¤tCallId={friendId} - 拨号页立即调用
POST /call,播放等待音 - 不进 LiveKit,等待
RTC_ACCEPT_* - 全局
App.vue收到接听信令后redirectTo媒体页,并带上isCaller=true
7.2 被叫侧
App.vue收到RTC_CALL_VIDEO/VOICE(或离线推送点击)- 打开振铃页:
/videocallrev?mode=...¤tCallId={主叫ID} - 接听:
POST /accept→redirectTo媒体页isCaller=false - 拒绝:
POST /reject→ 回聊天
7.3 媒体页参数
| 参数 | 含义 |
|---|---|
mode |
video / voice |
currentCallId |
对端用户 ID |
isCaller |
是否主叫(主叫开心跳) |
平台分流:
- H5 →
videocallh5.vue - App →
videocall.vue(web-view 壳)+hybrid/html/videocall.html(真实 LiveKit)
八、H5 媒体进房实现要点
8.1 封装 connectLiveKitRoom
核心步骤:

说明:
autoSubscribe: true:远端发布后自动订阅- 进房后再扫一遍
remoteParticipants,避免"对方已在房内但错过事件" - 摄像头失败可降级为仅语音,避免直接进房失败
8.2 页面侧 
渲染建议:
- 用原生 DOM /
track.attach()挂载视频,比 uni-app<video>+srcObject更稳 - 画面元素静音,远端音频单独挂
<audio>,避免浏览器拦截自动播放导致黑屏
8.3 开关麦 / 摄像头
媒体层改轨 + 信令同步 UI:

对方收到 RTC_CHANGE_VIDEO_AUDIO 后只更新头像区静音/关摄像头状态,不必重进房。
8.4 挂断

// 回聊天页
后端清理会话、解忙线,并给对方推 RTC_HANDUP,同时落一条"通话时长 mm:ss"的会话消息。
九、App 端:web-view + Hybrid HTML
App 不便直接跑完整 LiveKit 时,常见做法是:
- Vue 壳页负责路由参数、心跳、把 IM 事件
evalJS进 web-view - Hybrid HTML 加载
livekit-clientUMD,真正connect/publish/subscribe - 挂断后
uni.postMessage({ type: 'back' })通知壳页返回
壳页拼 URL 示例:

HTML 内:

信令桥接:

十、后端关键业务逻辑(摘要)
10.1 call
requireEnabled():LiveKit 未配置直接失败,不做无效 Mesh 降级- 生成
WebrtcPrivateSession+ 房间名 - 校验对端在线 / 非忙线
- Redis 存会话,双方
setBusy - 推送
RTC_CALL_VIDEO/VOICE - 返回主叫凭证
10.2 accept
- 找到会话,记录被叫终端
- 推送
RTC_ACCEPT_*到主叫终端,并sendToSelf关自己其他端振铃 - 返回被叫凭证
10.3 handup
- 计算通话时长
- 删会话、双方解忙
- 推
RTC_HANDUP - 落库
ACT_RT_VIDEO/VOICE
10.4 heartbeat
主叫约每 50s 调用一次:
- 会话 TTL 续 24h
- 忙线 TTL 续 60s
否则长时间通话可能被误判空闲,或忙线提前过期导致可被再次呼叫。
十一、异常分支怎么收口
| 场景 | API / 信令 | 结果 |
|---|---|---|
| 被叫拒接 | /reject + 103 |
主叫结束等待 |
| 主叫取消 | /cancel + 104 |
被叫关振铃 |
| 30s 无应答 | 主叫 /failed + 105 |
双方结束,记录"未接通" |
| 对方正忙 | /call 直接失败 |
提示忙线 |
| 对方离线 | /call 失败 |
提示不在线 |
| 自己其他端已接 | ACCEPT 的 self 分支 | 本端关振铃,不进房 |
| 媒体中对方挂断 | 106 | 本端 disconnect 并退出 |
产品上建议:拨号页等 ACCEPT 再进房,避免主叫先进空房间空转。
十二、工程上值得注意的点
-
终端类型要传准
App / H5 / 小程序终端码不同,后端靠它把信令打到"正在通话的那一端"。
-
自动播放策略
远端
<video>若混入音轨且未静音,移动浏览器常拦截播放 → 黑屏。画面静音 + 音频独立是稳妥做法。 -
挂断接口拼写
历史接口是
/handup,前后端保持一致即可,不必强行改成 hangup 制造分裂。 -
SDK 版本对齐
H5 npm 与 App CDN 建议锁定同一主版本(如
livekit-client@2.21.x),减少双端行为差异。 -
小程序
若业务暂不支持,IM 分发里对 MP 直接 return,避免半吊子体验。
-
安全
API Secret 只放服务端;Token 短时有效;房间名不可被客户端随意伪造进房(无有效 JWT 进不去)。
十三、和"纯 P2P / Mesh"对比,为什么这样划界
| 维度 | 纯 P2P/Mesh | 本文方案(IM 信令 + LiveKit SFU) |
|---|---|---|
| 两人通话 | 简单直接 | 多一跳,但模型统一 |
| 后续扩展到群通话 | 连接数爆炸 | 同一套进房模型可复用 |
| 振铃/多端 | 要自己造 | 复用现有 IM |
| 密钥与权限 | 难统一 | JWT Grant 清晰 |
| 排障 | 端到端黑盒 | 信令问题 / 媒体问题可分离 |
一句话:
IM 解决"通话业务状态",LiveKit 解决"音视频传输"。
十四、总结
基于 LiveKit 做一对一视频通话,推荐落地路径:
- 后端创建会话 + 忙线 + 签发房间 Token
- IM 推送呼叫 / 接听 / 挂断等信令驱动页面跳转
- 双方媒体页再
POST /token→Room.connect→ publish/subscribe - 主叫心跳续期,挂断清理会话并写聊天记录
这条路径的好处是:业务状态机仍在你自己手里,媒体层可独立升级、监控和扩容;同时为后续群通话、录制等能力留下同一套 SFU 底座。
如果你正在从 Mesh 迁移,建议先把私聊链路按本文拆成"信令页 + 媒体页 + Token 服务"三块,再复用到群通话,迁移成本会小很多。