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 信令接到 LiveKit 房间,再落到 PC 与 UniApp

▲ 左手机来电 → 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_CALL1601仅参与方邀请、加入、拒绝、未接、离开
RTC_CALL_START1610群全员发起时入消息流
RTC_CALL_END1611群全员取消或离开导致关房

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 / END1610 / 1611 入消息流会话预览和历史灰气泡
忙线锁Redis 同好友 / 同群不会并发出两通
Token 跟房间走JWT video claim越权进房被房间名拦住
浮层全局/im/home 根容器通讯录也能接
一端接听同 identity 互踢 + 信令收敛PC 与手机不抢麦
小程序 stub编译期关闭不假装全端都通
超时双通道前端探活 + Job杀进程也能 NO_ANSWER
Webhook 兜底验签后关房媒体侧异常可收敛
轻聊无按钮/oa/im 不进 RTC审批找人不会弹摄像头
语音条分开输入框 ≠ 资料卡按住说话不是实时通话

九、快速体验

在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)

  1. 登录后打开全功能 IM:/im/home。
  2. 点开一个好友资料卡,确认有「语音聊天 / 视频聊天」。
  3. 另开一个浏览器(或手机 H5)用被叫账号登录,停在通讯录或会话页。
  4. 主叫点语音。被叫应看到来电浮层,而不是必须回到那条会话。
  5. 接听后看控制条:静音、摄像头、挂断。
  6. 挂断后回会话,灰气泡应有结束原因。
  7. 再进 /oa/im 轻聊,确认没有这对通话按钮。
  8. 群会话若有人在打,看胶囊条和 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 支持一下!

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐