【通话】语音通话、视频通话、共享屏幕
通话提供实时音视频能力:私聊一对一、群聊多人,支持语音、视频与共享屏幕。通话模块由 yudao-module-im 后端模块的 rtc 包实现,用户侧前端在 @/views/im/home 的通话组件里。
本篇是「手册 + 原理」合篇:通话的「怎么用」很薄(发起 / 接听 / 挂断 + 通话记录),「怎么实现」(状态机、LiveKit 接入、信令与兜底)才是主体,所以一起讲。
信令走 IM,媒体走 LiveKit
芋道 IM 不自己传音视频流,而是外接开源 SFU LiveKit (opens new window):呼叫、接听、挂断等信令通过 IM 的 WebSocket 走,真正的音视频媒体流由客户端直连 LiveKit 房间。所以 RTC 是一个相对独立的子系统,不与文本消息的推送管线交织。
为什么选 LiveKit:自研 SFU(音视频选择性转发、级联、拥塞控制)成本极高,而 LiveKit 是开源、可自托管的 WebRTC SFU,提供成熟的多端 SDK(Web / 移动 / 桌面)、服务端 token 鉴权与房间事件 webhook,接入快、生态活跃;OpenAI 的实时语音(Realtime API / ChatGPT 语音)等方案也基于它,可靠性经过验证。芋道 IM 只负责「信令 + 业务状态」,把媒体面整体交给 LiveKit。
本文涉及通话记录表 im_rtc_call、通话参与者表 im_rtc_participant:一通通话一条主记录(im_rtc_call),通话里每个参与者一条明细(im_rtc_participant),通过 room(业务通话编号)关联。
- 通话记录:一通通话的主信息,含会话类型、媒体类型、发起人、状态、结束原因、各阶段时间。
- 通话参与者:通话中每个人的明细,含角色、参与状态、被邀请 / 接听 / 离开时间。
# 1. 表结构
省略 creator/create_time/updater/update_time/deleted/tenant_id 等通用字段
# 1.1 通话记录
通话记录,用户侧由 ImRtcCallController 提供接口(/im/rtc)。
CREATE TABLE `im_rtc_call` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
`room` varchar(64) NOT NULL COMMENT '业务通话编号',
`conversation_type` tinyint NOT NULL COMMENT '会话类型(1=私聊,2=群聊)',
`media_type` tinyint NOT NULL COMMENT '媒体类型(1=语音,2=视频)',
`inviter_user_id` bigint NOT NULL COMMENT '发起人用户编号',
`group_id` bigint DEFAULT NULL COMMENT '群编号(群通话时)',
`status` tinyint NOT NULL COMMENT '通话状态',
`end_reason` tinyint DEFAULT NULL COMMENT '结束原因',
`start_time` datetime NOT NULL COMMENT '发起时间',
`accept_time` datetime DEFAULT NULL COMMENT '接通时间',
`end_time` datetime DEFAULT NULL COMMENT '结束时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_room` (`room`)
) ENGINE=InnoDB COMMENT='IM 通话记录表';
① room 业务通话编号,全局唯一,是一通通话的稳定标识,也是 LiveKit 的房间名。
② conversation_type 私聊 / 群聊;media_type 语音 / 视频(枚举 ImRtcCallMediaTypeEnum);共享屏幕是视频通话里的一路屏幕轨道,不单独占媒体类型。group_id 仅群通话有值。
③ status 通话状态、end_reason 结束原因,见 §2 状态机;start_time / accept_time / end_time 分别是发起 / 接通 / 结束时间,通话时长 = end_time - accept_time。
# 1.2 通话参与者
CREATE TABLE `im_rtc_participant` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
`call_id` bigint NOT NULL COMMENT '通话编号',
`room` varchar(64) NOT NULL COMMENT '业务通话编号',
`user_id` bigint NOT NULL COMMENT '参与者用户编号',
`role` tinyint NOT NULL COMMENT '参与角色',
`status` tinyint NOT NULL COMMENT '参与状态',
`invite_time` datetime NOT NULL COMMENT '被邀请时间',
`accept_time` datetime DEFAULT NULL COMMENT '接听时间',
`leave_time` datetime DEFAULT NULL COMMENT '离开时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_room_user` (`room`, `user_id`)
) ENGINE=InnoDB COMMENT='IM 通话参与者表';
① call_id / room 关联通话,唯一键 (room, user_id) 保证一通通话里一个人一条明细。
② role 参与角色,枚举 ImRtcParticipantRoleEnum(1=发起人,2=被邀请者,3=主动加入者;主动加入仅群通话——旁观者点「N 人正在通话」胶囊条加入)。
③ status 参与状态,见 §2.2。
# 2. 通话状态机
通话有两套互相配合的状态机:主表 im_rtc_call.status(整通通话)与明细 im_rtc_participant.status(每个人)。
# 2.1 通话状态
枚举 ImRtcCallStatusEnum:
| 状态值 | 枚举 | 说明 | 可执行操作 |
|---|---|---|---|
| 10 | CREATED | 已创建(私聊等被叫接听;群聊发起人已进房) | 接听、拒绝、取消、加入 |
| 20 | RUNNING | 进行中(首个非发起人接通后) | 离开、追加邀请 |
| 30 | ENDED | 已结束 | — |
通话状态流转
createCall ──→ 创建(10) ──首个被叫接听──→ 进行中(20) ──任一方挂断/最后一人离开──→ 已结束(30)
│
└──无人接听 / 主叫取消 / 全部拒绝──→ 已结束(30)
- 发起(
createCall):落im_rtc_call(CREATED)+ 参与者明细(发起人 JOINED、被叫 INVITING),并推来电信令。 - 接听(
acceptCall):被叫接通,首个接通把通话推进到 RUNNING。 - 拒绝 / 取消(
rejectCall/cancelCall):接通前,被叫拒接或主叫取消。 - 离开(
leaveCall):接通后挂断;最后一人离开则通话 ENDED。 - 追加邀请 / 加入(
inviteCall/joinCall):仅群通话,通话中拉人或旁观者主动加入。
# 2.2 参与者状态
枚举 ImRtcParticipantStatusEnum:
| 状态值 | 枚举 | 说明 |
|---|---|---|
| 10 | INVITING | 邀请中(已发出 invite,等响应) |
| 20 | JOINED | 已加入(已进 LiveKit 房间) |
| 30 | REJECTED | 已拒绝(接通前点拒接) |
| 40 | NO_ANSWER | 未应答(通话结束仍未接) |
| 50 | LEFT | 已离开(接通后挂断 / 兜底) |
通话结束时,所有参与者明细必收敛到 LEFT / REJECTED / NO_ANSWER 三个终态之一。
# 2.3 结束原因
通话结束时按场景写 end_reason(枚举 ImRtcCallEndReasonEnum),并据此生成通话历史消息文案:
| 值 | 原因 | 场景 |
|---|---|---|
| 1 | 通话结束 | 接通后任一方主动挂断 |
| 2 | 已拒绝 | 被叫接通前点拒接 |
| 3 | 已取消 | 主叫接通前主动取消 |
| 4 | 无人接听 | 振铃超时未接通(参与者超时 Job 触发) |
| 5 | 对方正忙 | 私聊呼叫时对方在另一通话 |
| 9 | 通话异常 | 网络中断、设备失败等 |
# 3. 通话流程与 LiveKit
以私聊视频呼叫为例,端到端时序如下:

几个关键点:
- Token 签发:
buildCallRespVO在 invite / join / accept 时按当前用户签 LiveKit token(signCallToken)并下发livekitUrl;通话已结束(ENDED)则不签发,前端据此判定「通话已结束」。 - Webhook 兜底:LiveKit 的
participant_joined/participant_left回调(由 ImRtcLiveKitController 接收)会兜底纠正参与者状态,防止客户端异常退出导致状态卡住。 - 振铃超时:被叫长时间不接,由后端超时 Job 把参与者置
NO_ANSWER、通话置 ENDED(end_reason=无人接听);前端 RUNNING 端也会调noAnswerCallCheck兜底触发。 - 通话信令不入库:来电 / 参与者加入离开等信令(ImContentTypeEnum 的 1601 / 1602 / 1603)仅 WebSocket 推送、不入库;只有「通话开始」「通话结束」(1610 / 1611)入消息流,用于在聊天里渲染通话记录气泡 / 提示。信令推送机制详见 《WebSocket 实时推送与离线消息》。
# 4. 用户侧
# 4.1 私聊通话(一对一)
在好友资料卡或私聊会话里点「语音通话 / 视频通话」发起,对端收到来电后可接听 / 拒绝;接通后进入音视频窗口,可静音、开关摄像头、共享屏幕(视频通话中开启屏幕共享轨道)、挂断。

# 4.2 群聊通话(多人)
在群里发起多人通话、勾选成员邀请,通话中还可继续追加邀请(inviteCall)。群里正在进行的通话会在群顶部显示「N 人正在通话」胶囊条(getActiveCall),旁观成员点击即可加入(joinCall)。

# 4.3 通话记录
无论私聊还是群聊,通话结束后都会在聊天里留下一条记录(如「通话时长 02:51」「已取消」「无人接听」),由 RTC_CALL_START / RTC_CALL_END 两条消息在聊天流里渲染。
# 5. 运营侧
运营侧在 [IM 即时通讯 -> 通话记录] 下,对应 @/views/im/manager/rtc 目录,由 ImRtcCallManagerController 提供只读分页查询:可按会话类型、媒体类型、通话状态、结束原因、时间等筛选,查看通话记录与参与人、通话时长。运营侧不发起 / 不干预通话。

# 6. 移动端通话(试验)
移动端(uni-app)通话复用同一套后端接口与 LiveKit 房间,可与 PC、H5 在同一通话中互通。各端支持情况如下:
| 端 | 默认 | 实现方式 | 说明 |
|---|---|---|---|
| H5 | 开启 | LiveKit Web SDK | 与 PC 一致,需 HTTPS(或 localhost)才能采集麦克风 / 摄像头 |
| App(Android / iOS) | 关闭 | 通话页 renderjs 视图层运行 LiveKit Web SDK | 试验能力,不支持屏幕共享 |
| 微信小程序 | 关闭 | 原生 live-pusher / live-player + 独立 RTMP 网关 | 试验能力,秒级延迟,不支持屏幕共享 |
仅用于试验
App、微信小程序通话默认关闭,未经真机验收,请勿直接用于生产。未开启时,在该端发起或加入通话会提示「请在 H5 或 PC 端使用通话」,收到的来电会被自动拒绝。
# 6.1 开启 App 通话
在 uni-app 项目的 env 文件中配置后重新编译:
VITE_IM_RTC_APP_ENABLED=true
- 首次发起 / 接听时,Android 会申请麦克风(视频通话还有相机)权限,权限已在
manifest.config.ts声明;iOS 由系统 WebView 首次采集时弹出授权。 - 已知限制:依赖系统 WebView 的 WebRTC 能力,低版本 Android WebView 可能不可用;通话页进入后台后的行为未验证;不支持屏幕共享,也不提供 CallKit 等系统来电界面。
# 6.2 开启微信小程序通话
小程序没有 WebRTC 能力,无法直接运行 LiveKit SDK,需要部署一个独立的 RTMP 网关服务:小程序通过 live-pusher 推 RTMP 流,经 LiveKit Ingress 进入同一房间;每个远端成员经 LiveKit Egress 转成 RTMP 流,再由 live-player 拉流播放。
小程序 live-pusher ──RTMP──▶ LiveKit Ingress ──▶ LiveKit 房间 ◀── PC / H5 / App
小程序 live-player ◀──RTMP── SRS ◀── LiveKit Egress(每个远端一路)
① 部署网关:网关位于ruoyi-vue-pro (opens new window) 仓库的 script/livekit-poc/mp-gateway 目录(独立的 Node.js 22 服务,只使用 Node 内置模块、无需安装 npm 依赖,与单体 / 微服务后端无关,可直接拷贝使用),网关运行时依赖 LiveKit Server、Ingress、Egress 与 SRS,docker-compose.yml 一并启动 Redis、LiveKit、Ingress、Egress、SRS 与网关,详细配置、接口协议见其 README.md。网关附带的 SRS 需独占使用,网关只使用专用 app yudao-mp-gw(可加后缀),并据此回收异常退出后残留的转流资源,不要与其它直播业务共用同一 SRS 实例或 vhost。网关同样默认关闭,需在其 .env 中设置 MP_GATEWAY_ENABLED=true,并配置与后端 yudao.im.rtc.api-key / api-secret 一致的凭据。
② 前端需同时配置两个变量后重新编译,缺一不开启:
VITE_IM_RTC_MP_ENABLED=true
VITE_IM_RTC_MP_GATEWAY_URL=https://你的网关域名
网关地址只允许 https://;开发构建(pnpm dev:mp)额外允许 http://127.0.0.1 / http://localhost,便于在微信开发者工具中联调。前端调用网关时只携带后端签发的房间 Token,不携带登录凭证。
③ 小程序后台要求:
- 小程序为企业主体,类目在 live-pusher (opens new window) / live-player (opens new window) 的支持范围内,并在「开发 → 开发管理 → 接口设置」中开通「实时播放音视频流」「实时录制音视频流」。
- 在「用户隐私保护指引」中声明麦克风、摄像头用途;首次通话会申请录音(视频通话还有摄像头)授权。
- 网关域名加入「request 合法域名」;Ingress 推流地址(默认 1935 端口)与 SRS 拉流地址(默认 1936 端口)需要手机可达。网关附带的
srs.conf默认只监听127.0.0.1:1936,仅适合本机联调,真机部署需改为listen 1936;并在防火墙中只放通需要的来源,同时在网关.env中把MP_INGRESS_RTMP_PUBLIC_HOST、MP_SRS_PLAY_PUBLIC_HOST设为手机可达的地址,详见网关README.md的「真机要求」。
④ 已知限制:
- 延迟:RTMP 推流 + 两次转码 + RTMP 拉流,端到端延迟通常在 1~3 秒,弱网更高,明显差于其他端。
- 回声:推流与拉流是两个独立原生组件,回声消除效果未验证,建议佩戴耳机。
- 资源:每个小程序成员 1 路 Ingress 转码,每个远端 1 路 Egress 转码(LiveKit 官方估算各约 2 核 CPU),网关默认最多 4 个会话、8 路 Egress,超出时如实提示,不伪造画面。
- 状态:小程序关闭麦克风 / 摄像头时,其他端看到的是静音 / 黑屏画面,而不是 LiveKit 的静音标记;小程序切到后台或 RTMP 网络断开(推流重连耗尽)时推流中断,其他端会看到该成员离开,私聊通话会随之结束,需要重新发起,不支持无缝恢复。
- 同账号:同一账号在同一通话中已有小程序会话(含刚挂断、资源仍在释放)时,新的小程序接入会提示先结束另一端,只退出本机通话页,不会顶替正在进行的会话,也不会让另一端离开通话。正常情况下资源确认释放后即可重新接入;若网关日志出现「创建结果未知」,该账号在该通话中会持续被占用,需运维排查后重启网关恢复,详见网关
README.md的「生命周期与资源」。 - 网关为单实例服务;小程序端不能发起屏幕共享,其他端的屏幕共享转给小程序尚未验证通过(本地测试中出现过转出一直未推流),失败时会如实显示为无法转出。
# 6.3 验证方法
- 包体:小程序网关代码通过条件编译只进入微信小程序产物,H5、App 产物不包含;小程序产物不包含 LiveKit Web SDK。
- 网关:参考网关
README.md中「用 FFmpeg 模拟小程序」,推测试流与 H5 / PC 账号在同一房间双向互通,并验证无凭证拉流被拒绝、停止轮询或挂断后 Ingress / Egress 被释放。 - 微信开发者工具:可验证开关、授权弹窗、网关会话创建 / 轮询 / 释放和通话页状态流转;但开发者工具不支持 live-pusher 推流,live-player 拉流也以真机为准,模拟器无法验证真实采集、播放、回声与延迟。真机验证需要满足上面的企业主体、类目与接口权限要求。