SpringBoot3+UniApp+LiveKit 企业音视频通话:资料卡拨打、来电浮层、通话中控制怎么落地
SpringBoot3+UniApp+LiveKit 企业音视频通话:资料卡拨打、来电浮层、通话中控制怎么落地
🌐 文档地址:https://ruoyioffice.com
📦 源码1·GitHub:https://github.com/yuqing2026/ruoyi-office
📦 源码2·GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office
📦 源码3·Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬 微信:17156169080(备注「RuoYi Office」)
催审批还在切微信打电话,忙线、拒接、超时在会话里都看不见。RuoYi Office 把音视频嵌进全功能 IM:资料卡点语音 / 视频,来电在任意子页浮层弹出,接通后控制条只管静音、摄像头和挂断。媒体进 LiveKit 房间,状态机、Token、来电信令留在自己的服务。

▲ 左手机来电 → SpringBoot3 推 1601 信令 → 中 PC 双人画面 / 来电浮层 → LiveKit Token 进同一房间 → 右 UniApp 静音挂断。Vue3 与 UniApp 共用房间,不共用页面
引言:通话若只是套一层 WebRTC,产品会先散
企业里「打个电话」看起来像聊天功能,落地时却会拆成四件互不相让的事:谁能拨、谁该响、房间怎么进、历史怎么写。四件塞进同一个前端组件,忙线会漏、群通话会重复开房、小程序还会假装自己也能推流。
| 错法 | 看起来省事 | 上线一周后 |
|---|---|---|
| 直接嵌第三方会议链接 | 不用自研 | 组织外账号、无忙线、会话里没有结束原因 |
| 自研 SDP 协商 | 显得完整 | 弱网、防火墙、移动端编解码全自己扛 |
| 通话按钮绑死在会话页 | 少写一个浮层 | 人在通讯录时来电看不见 |
| 按住说话和实时通话共用入口 | 少一个图标 | 用户按住想发语音条,却弹出摄像头授权 |
正确的形状是:LiveKit 只管房间里的媒体;自有后端只管会话状态、Token、来电信令和历史 tip。 按住说话是消息气泡,实时通话是另一条状态机。本文只写后一条。
连接性也要画清:SpringBoot3 发 1601、发 Token、写灰气泡;LiveKit 只认房间和轨道;Vue3 的来电浮层和 UniApp 的通话页共用同一个 room,不共用同一套页面。少任何一环,都会变成「信令到了但没声音」或「声音通了但会话里没有结束原因」。
不要把这篇读成 IM 大全。单聊发图、文件、未读红点,8 月那篇轻聊已经写过。这里只写 /im/home 上能拨出去、能接起来的通话。
一、先把产品边界画清
1.1 两种 IM,不要混成一个按钮
| 形态 | 路由 | 通话按钮 |
|---|---|---|
| OA 轻聊 | /oa/im | 通常没有。审批详情找人、传一句油费 |
| 全功能 IM | /im/home | 资料卡「语音聊天 / 视频聊天」,群会话另有选人发起 |
客户只要审批旁边能问一句,不要上 LiveKit。上了完整 IM,才在资料卡露出拨打。两种形态共用组织选人,不共用通话状态机。
1.2 私聊一次性信令,群通话两段灰气泡
私聊:主叫邀请,被叫接、拒或超时,结束原因写进会话。群通话还要让没被邀请的人看见「有人在打」。所以群聊在消息流里多写两段:
| 类型 | 编号 | 谁看见 | 什么时候写 |
|---|---|---|---|
| RTC_CALL | 1601 | 仅参与方 | 邀请、加入、拒绝、未接、离开 |
| RTC_CALL_START | 1610 | 群全员 | 发起时入消息流 |
| RTC_CALL_END | 1611 | 群全员 | 取消或离开导致关房 |
1601 是浮层的燃料。1610 / 1611 是会话列表预览和历史灰气泡。不要用 1601 广播全群,否则没被邀请的人屏幕上会弹接听。
1.3 状态机只要三档主表
主表 im_rtc_call:CREATED → RUNNING → ENDED。发起人自己先 JOINED,但通话仍停在 CREATED,等第一个非发起人接通才切 RUNNING。明细 im_rtc_participant 每人一行:INVITING / JOINED / LEFT / REJECTED / NO_ANSWER。关房时明细必须落到后三个终态之一,浮层才收得干净。
结束原因写在主表:挂断、已拒绝、已取消、无人接听、对方正忙、异常。灰气泡文案认这个枚举,不要前端自己编一句「通话结束」。
二、系统设计:谁干什么
| 子模块 | 功能 | 面向谁 |
|---|---|---|
| 通话服务 | 创建、邀请、接听、拒绝、离开、忙线锁 | 全功能 IM 用户 |
| LiveKit 客户端封装 | 签 Token、验 Webhook | 媒体房间 |
| WebSocket 通知 | 1601 推参与方 | 正在响铃或在房的人 |
| 消息服务 | 1610 / 1611 入聊天 | 群成员、私聊双方 |
| 超时 Job | 振铃超时标 NO_ANSWER | 无人接听 |
| 清理 Job | 房间残留兜底 | 运维 |
核心决策:
| 决策点 | 方案 | 理由 |
|---|---|---|
| 媒体不自研 | LiveKit 推拉流 | SDP、弱网、设备差异不进业务仓库 |
| 同好友 / 同群一把锁 | Redis 锁 + 锁内再查活跃通话 | 防并发各开一通 |
| Token 跟响应走 | create / accept / join 里签发 | ENDED 不再签,前端自然收起 |
| 浮层不绑会话页 | 全局通话容器 | 人在通讯录也能接 |
| 小程序不装通话 | supportsRtc() 为 false | 不要假装全端都通 |
Webhook 只做离开 / 关房兜底。签名失败返回成功但忽略,避免给伪造请求反馈。房内 Track 变化由客户端事件处理,后端不重复推「谁开了摄像头」。
三、PC:资料卡拨打和三态浮层
入口在全功能 IM 的用户资料卡,不是会话输入框旁边的麦克风。输入框麦克风是按住说话。资料卡是「语音聊天 / 视频聊天」。

▲ 全功能 IM 会话顶栏电话图标,点开是语音 / 视频。输入框麦克风是按住说话,不要点错
点下去之后,主叫进邀请中,被叫在任意 IM 子页弹出来电。浮层有三态:邀请中、来电、通话中。容器挂在 /im/home 根上,不跟当前会话组件一起卸载。历史里留下时长和结束原因,刷新后还在。

▲ 会话里的语音通话灰气泡:时长、已取消、已拒绝。1610 / 1611 写在这里,不是只推 1601

▲ 群或私聊预览写成「语音通话」。没被邀请的人只看见灰气泡,不弹接听
接通后控制条只留媒体动作:静音、关摄像头、切扬声器、挂断。忙线文案是「当前已有通话」,不要复用会议室「时段冲突」。

▲ 顶栏电话入口随时可再拨;进行中的通话在会话预览里写成「语音通话 00:44」
群通话顶部可以有一条「N 人正在通话」胶囊。点加入走 join,校验必须是群成员,防止只拿 room 字符串就越权进房。群里还能追加邀请,被邀请人再收 1601。胶囊条只给还在进行的房间,灰气泡留给历史。
四、移动端:拨打、来电、通话中
UniApp 通话页在 pages-im/home/conversation/rtc/call/。H5 和 App 走 LiveKit;小程序编译期关掉,supportsRtc() 返回 false,按钮直接不渲染。
麦克风、摄像头授权和打卡定位授权不是同一套。打卡要的是位置;通话要的是媒体设备。首次拨打再弹,不要在登录时一口气要完。

▲ 好友资料卡底部「音视频通话」,再拆语音 / 视频。这里不是按住说话

▲ 竖屏通话页:头像、计时、静音、扬声器、红色挂断。390×844

▲ 控制条只留媒体动作。挂断后回会话,灰气泡写结束原因
PC 与手机可以同时在线,但一通通话只允许一端接。另一端收到 JOINED / END 后收起浮层。不要做成「两边各进一个房间」。
演示站若 LiveKit 代理不通,页面仍应看得到拨打和来电 UI。媒体没接通不等于信令没落地。正文认分层,不假装已经听到声音。
五、后端核心实现
5.1 创建通话:锁、落库、推邀请
同好友对、同群必须串行。锁内再查活跃通话,避免两个窗口同时点视频。
public ImRtcCallDO createCall(Long userId, ImRtcCallCreateReqVO reqVO) {
validateEnabled();
validateCreateCall(userId, reqVO);
if (ImConversationTypeEnum.isGroup(reqVO.getConversationType())) {
return rtcCallLockRedisDAO.lockGroup(reqVO.getGroupId(),
() -> createGroupCall(userId, reqVO));
}
Long peerUserId = CollUtil.getFirst(reqVO.getInviteeIds());
return rtcCallLockRedisDAO.lockPrivate(userId, peerUserId,
() -> createPrivateCall(userId, reqVO, peerUserId));
}
锁内主体写主表 + 参与表,再推 1601,群聊再写 1610。room 用 UUID,同时当 LiveKit 房间名。
ImRtcCallDO call = new ImRtcCallDO().setRoom(room)
.setConversationType(reqVO.getConversationType())
.setMediaType(reqVO.getMediaType())
.setInviterUserId(inviterId)
.setStatus(ImRtcCallStatusEnum.CREATED.getStatus())
.setStartTime(now);
rtcCallMapper.insert(call);
// 发起人即时 JOINED;被邀请人 INVITING
pushCallInviteNotification(call, inviterUser, inviteeId, invitee, invitees);
pushCallStartNotification(call, inviterUser, invitees);
私聊对方已在另一通话,当场 end(BUSY),主叫看到「对方正忙」,不要空等振铃超时。
5.2 Token:只给还活着的房间
响应 VO 在 create / accept / join 里带 livekitUrl 和 token。ENDED 把 token 置空,前端据此提示「通话已结束」。
.setLivekitUrl(imProperties.getRtc().getLivekitUrl())
.setToken(ended ? null : rtcCallService.signCallToken(userId, call.getRoom()))
签发时 video claim 锁死房间:roomJoin、canPublish、canSubscribe。TTL 读配置小时数。同 identity 重连会踢前一个连接,正好满足「一端接了另一端掉线」。
5.3 移动端:平台开关比提示文案重要
function supportsRtc() {
// #ifdef H5 || APP-PLUS
return true
// #endif
// #ifndef H5 || APP-PLUS
return false
// #endif
}
小程序编译进 stub,按钮不出现。不要在运行时 toast「暂不支持」却仍画出绿色通话键。发起、接听、加入都做 single-flight,弱网连点不会开两个房间。
六、几个容易做错的点
浮层必须是全局的。 绑在会话页,人切到通讯录就接不到。PC 容器和移动端独立通话页都认同一套 store。
历史要落库。 重进会话应能看见「通话 3 分 20 秒」或「已拒绝」。只推 1601 不写 1611,刷新后气泡消失。
超时有两道。 前端 RUNNING 端定时打 no-answer-call-check;后端还有参与者超时 Job。只靠前端,被叫杀进程后主叫会一直响。
Webhook 是兜底,不是主路径。 正常挂断走 leave / cancel。房间被媒体侧关掉,再靠签名校验后的事件把主表推到 ENDED。
局域网地址。 配置里的 LiveKit URL 若写成 127.0.0.1,手机打不开。换成当前 hostname,和考勤围栏的「本机 IP」不是同一件事。
七、数据结构
im_rtc_call:id、room、会话类型、媒体类型、发起人、群 id(私聊空)、状态、结束原因、发起 / 接通 / 结束时间。
im_rtc_participant:id、callId、room、userId、角色(发起 / 被邀 / 中途加入)、状态、邀请 / 接听 / 离开时间。
不要把 SDP、Track、音量存进这两张表。媒体状态在房间里,业务表只回答「这通还在不在、谁该响、为什么结束」。
Redis 锁键按私聊双方或群 id。锁粒度不要落到「全站一把通话锁」,否则两个不相干的部门会互相排队。
八、技术亮点
| 设计要点 | 实现方式 | 价值 |
|---|---|---|
| 媒体与信令拆开 | LiveKit + 自有状态机 | 业务仓库不扛编解码 |
| 1601 只推参与方 | WebSocket 通知 | 群里其他人不受来电打扰 |
| 群 START / END | 1610 / 1611 入消息流 | 会话预览和历史灰气泡 |
| 忙线锁 | Redis 同好友 / 同群 | 不会并发出两通 |
| Token 跟房间走 | JWT video claim | 越权进房被房间名拦住 |
| 浮层全局 | /im/home 根容器 | 通讯录也能接 |
| 一端接听 | 同 identity 互踢 + 信令收敛 | PC 与手机不抢麦 |
| 小程序 stub | 编译期关闭 | 不假装全端都通 |
| 超时双通道 | 前端探活 + Job | 杀进程也能 NO_ANSWER |
| Webhook 兜底 | 验签后关房 | 媒体侧异常可收敛 |
| 轻聊无按钮 | /oa/im 不进 RTC | 审批找人不会弹摄像头 |
| 语音条分开 | 输入框 ≠ 资料卡 | 按住说话不是实时通话 |
九、快速体验
在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
- 登录后打开全功能 IM:
/im/home。 - 点开一个好友资料卡,确认有「语音聊天 / 视频聊天」。
- 另开一个浏览器(或手机 H5)用被叫账号登录,停在通讯录或会话页。
- 主叫点语音。被叫应看到来电浮层,而不是必须回到那条会话。
- 接听后看控制条:静音、摄像头、挂断。
- 挂断后回会话,灰气泡应有结束原因。
- 再进
/oa/im轻聊,确认没有这对通话按钮。 - 群会话若有人在打,看胶囊条和 START 灰气泡。
手机 H5 本机一般是 http://localhost:9000/,通话页路由 /pages-im/home/conversation/rtc/call/index。演示站 LiveKit 若不通,仍按上面核对浮层和按钮,不要用「没声音」否定信令。
源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
十、结语
企业音视频的难度不在画面能不能动,而在「谁该响、谁能进房、结束后会话里留下什么」。RuoYi Office 把媒体丢给 LiveKit,自己只做状态机、Token、1601 来电和 1610 / 1611 历史。资料卡拨打、全局浮层、通话中控制条,是用户能点到的三层;轻聊不带通话,是给只想问一句油费的客户留的门。
读者能带走的三句:按钮认资料卡,不认输入框麦克风;信令认参与方,不广播全群;小程序可以没有通话,不要假装有。
你们现在催审批是切微信,还是系统里能直接打?评论区对照一下轻聊和全功能 IM,比空泛「上了即时通讯」有用。
相关阅读:同目录轻聊讲单聊群聊发图发文件;IM 总览只点到通话。那些文章不替代本页的房间和浮层。
💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
📦 源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)