腾讯 IM 前端对接小白教程:医患一对一聊天(含自定义报告卡片)
腾讯 IM 后端对接小白教程:Java 服务端如何撑起一个医患聊天系统
这是这个系列的第三篇。前两篇我们讲了前端怎么用 Chat SDK 收发消息、后端怎么签 UserSig、建群、代发卡片。有同学可能问:"图文聊明白了,那视频问诊呢?医生点'开始视频'之后,后端干了什么?"
这一篇就来补上这块拼图------IM 和 TRTC 音视频是怎么联动的,重点讲清
startMCUMixTranscode(开启云端混流录制)这个"视频问诊后端最复杂的方法"。老规矩:先建立认知 → 再看代码 → 最后避坑。不需要你懂任何音视频知识,会用手机打视频电话就够。
目录
- [开胃菜:IM 和 TRTC 到底是什么关系](#开胃菜:IM 和 TRTC 到底是什么关系)
- 全景图:一次视频问诊的生命周期
- [房间号的诞生:IM 群 ID 和 TRTC roomId 不是一回事](#房间号的诞生:IM 群 ID 和 TRTC roomId 不是一回事)
- 两张门禁卡:进房要带的两种签名
- [信令走 IM:一条"开视频了"的消息](#信令走 IM:一条"开视频了"的消息)
- [正菜:startMCUMixTranscode 混流录制](#正菜:startMCUMixTranscode 混流录制)
- [收尾:stopMCUMixTranscode 与录像文件的去向](#收尾:stopMCUMixTranscode 与录像文件的去向)
- [查录像:findVideoUrl 的兜底设计](#查录像:findVideoUrl 的兜底设计)
- 数据库与配置清单
- [踩坑实录:6 条真实经验](#踩坑实录:6 条真实经验)
- 调试技巧
- 总结
0. 开胃菜:IM 和 TRTC 到底是什么关系
先回答一个最基础的问题:视频问诊需要 IM 吗?
需要,而且配合得非常紧。把整套系统想象成一家医院:
| 组件 | 类比 | 职责 |
|---|---|---|
| IM | 护士站的呼叫广播 | 传"话":文字消息、卡片、通知 |
| TRTC | 诊室本身 | 传"画面和声音":实时音视频流 |
| 你的后端 | 导诊台 | 发门禁卡、分配诊室(房间号)、预约录像 |
TRTC(腾讯实时音视频) 就是那个"诊室"。医生和患者各自打开摄像头,音视频数据实时传给对方------这套传输由 TRTC 的云端服务器和前端 SDK 搞定,你的后端碰不到视频数据本身(跟 IM 消息不经过你后端是一个道理)。
那两者怎么"联动"?关键在一个词:信令。
医生点"开始视频",系统总得想办法让患者的手机响起来吧?这个"响铃通知"就是信令------它本质上只是一条消息:"张医生邀请你视频问诊,房间号 12345"。而传消息,正是 IM 的老本行。
所以在腾讯的技术版图里,IM 和 TRTC 是天生的搭档,官方甚至提供了融合 SDK(TUICallKit)把这套配合封装好了。但很多老项目(包括本系列讲的这个)是自己手动做联动的,理解手动版,你才能真正理解这套配合的本质:
typescript
┌─────────── 你的后端 ───────────┐
│ 1. 生成 roomId(分配诊室) │
│ 2. 签发进房凭证(发门禁卡) │
│ 3. 发 IM 消息通知对方(广播) │──┐
└────────────────────────────────┘ │ 信令走 IM
▼
┌──── 医生端 ────┐ ┌──── 患者端 ────┐
│ 收到 IM 消息 │ │ 收到 IM 消息 │
│ TRTC SDK 进房 │ │ TRTC SDK 进房 │
└───────┬────────┘ └────────┬───────┘
│ 视频流走 TRTC 云端 │
└────────────►◄──────────────┘
┌─────── 你的后端(通话期间)────────┐
│ 4. 调 TRTC 云端接口开启混流录制 │
│ 5. 结束时停止录制、取回录像 URL │
└────────────────────────────────┘
一句话记住分工:IM 管"叫人来",TRTC 管"面对面",你的后端管"开门、锁门、存录像"。
1. 全景图:一次视频问诊的生命周期
先鸟瞰全流程,后面每一章对应其中一环。按时间顺序:
typescript
阶段一:图文问诊中,医生点了"开始视频"
│
▼
后端 createRoom(第 2、3、4 章)
├─ 给这个群分配一个数字 roomId
├─ 签发 liveUserSig + privateMapEncrypt 两张"门禁卡"
└─ 往群里发一条 LIVE_STATUS_NOTIFY 消息(患者端收到弹窗)
│
▼
患者点了"接听" → 双方 TRTC SDK 进房,视频接通
│
▼
后端 startMCUMixTranscode(第 5 章,核心)
├─ 告诉腾讯云端:把房间里两路流混成一路(医生大画面+患者小窗)
├─ 同时开启录制,文件存进腾讯云点播(VOD)
└─ 本地数据库记一条 VideoRecord
│
▼
......通话中......(后端没有任何参与,流都在腾讯那边)
│
▼
任一方挂断 → 后端 stopMCUMixTranscode(第 6 章)
├─ 停止混流录制
├─ 去 VOD 搜录制出来的文件
├─ 拿到 URL,换成自家 CDN 域名,存进数据库
└─ 顺手给文件归个类
│
▼
日后医生/监管方回看 → findVideoUrl(第 7 章)
└─ 查库返回录像地址
有个认知要先建立:从"接通"到"挂断"这中间,你的后端是完全闲着的。视频流从医生端直接上腾讯云,再从腾讯云下发到患者端,不经过你的服务器。后端要做的事都发生在通话的"头"(进房前)和"尾"(挂断后)。
这个设计很好理解------视频流是大流量数据,如果全走你的服务器,带宽费用和延迟都会爆炸。让专业的云做专业的事,你只做"调度"和"存档"。
2. 房间号的诞生:IM 群 ID 和 TRTC roomId 不是一回事
第一个容易懵的点来了:视频问诊的"房间"是 TRTC 房间,不是 IM 群。
- IM 群 ID:字符串,本项目中就是订单号 (比如
5000G1194147232539037698) - TRTC roomId:必须是数字 (TRTC 服务端接口的
RoomId参数是 Long 类型)
这两个 ID 长得不一样,但业务上要一一对应 ------一次问诊一个群、一个房间。后端的处理在 ImDefaultService#createRoom:
java
private ResultVO<?> createRoom(GroupDTO tlsGroup, String fromAccount) {
if (tlsGroup.isCreateRoom()) {
HashMap<Object, Object> objectHashMap = Maps.newHashMap();
// roomId 只能是 Number 类型
Integer roomId = tlsGroup.getRoomId();
if (tlsGroup.getRoomId() == null || tlsGroup.getRoomId() == 0) {
roomId = createRoomId(); // 没有就生成一个
tlsGroup.setRoomId(roomId);
imSessionService.update(tlsGroup, 1); // 存回 kefu_im_session 表
}
// ...后面签发凭证、发通知(第 3、4 章讲)
}
return buildSuccess(getRoomID(tlsGroup));
}
room 号怎么生成?看 createRoomId:
java
private Integer createRoomId() {
String id = String.valueOf(getSnowflake().nextId()).substring(10, 19);
return Integer.parseInt(id);
}
用雪花算法(Snowflake)生成一个全局唯一 ID,然后截取第 10 到 19 位(共 9 位数字)。为什么截中间?因为雪花 ID 是 19 位的长整数,TRTC 房间号要求数字且不能太大,截取中间 9 位既保住了数字长度(最大 999999999,刚好卡在 int 上限内),又基本不会重复------雪花 ID 的中间段包含了机器 ID 和序列号,随机性够用。
注意一个设计:roomId 不是每次都新生成的。 代码先判断 roomId == null || roomId == 0 才生成,说明同一个问诊群多次发起视频共用同一个房间号 ------医生第一次视频没聊完,挂断后再次呼叫,还是进同一个房间。号码生成后就持久化在 kefu_im_session 表里,和 groupId 绑定终身。
这个"群 ID ↔ 房间号"的映射关系是整个视频问诊的骨架,后面所有环节(发通知带 roomId、开混流要 roomId、查录像先查群再查房间)都靠它串起来。
3. 两张门禁卡:进房要带的两种签名
患者端凭什么能进 TRTC 房间?总不能谁拿着房间号都能闯进来。这里要再请出系列的的老朋友------UserSig。只不过这次它有两种形态:
java
// 卡片一:liveUserSig ------ 普通版,30 天有效
String liveUserSig = AssemlyIMParamterUtil.getLiveUserSig(
tlsGroup.getUserID(), 60 * 60 * 24 * 30L);
objectHashMap.put("liveUserSig", liveUserSig);
// 卡片二:privateMapEncrypt ------ 带权限位版本,300 秒有效
String privateMapEncrypt = AssemlyIMParamterUtil.getPrivateMapEncrypt(
tlsGroup.getUserID(), tlsGroup.getGroupId());
objectHashMap.put("privateMapEncrypt", privateMapEncrypt);
这两种签名底层都是上一篇讲过的那个 UserSigUtil 四步流水线(JSON → HMAC-SHA256 → 压缩 → Base64),区别在于带不带 userbuf:
| liveUserSig | privateMapEncrypt | |
|---|---|---|
| 调用方法 | genNewSig |
genSigWithUserBuf |
| 有效期 | 30 天 | 300 秒 |
| 携带数据 | 无(只证明"我是谁") | userbuf = groupId 字节(还证明"我能进哪个房间") |
| 类比 | 长期工牌 | 一张限定房间的限时邀请函 |
getPrivateMapEncrypt 的实现就一行核心:
java
public static String getPrivateMapEncrypt(String userId, String groupId) {
return UserSigUtil.genSigWithUserBuf(userId, 300,
groupId.getBytes(), // 把群 ID 塞进 userbuf
AccountConfig.getIMSdkAppId(), AccountConfig.getIMPrivateKey());
}
userbuf 是签名里额外携带的一小段数据。TRTC 侧验证时不仅验"签名是谁的",还验"userbuf 里写的房间和你要进的房间是否一致"。这就是腾讯文档里说的进房权限保护:就算签名泄露,也只能进 userbuf 里指定的那个房间,而且 5 分钟就作废。
为什么两张卡有效期差这么多? liveUserSig 要支撑长时间的会话(患者可能 App 挂后台几天后再进来),所以要长;privateMapEncrypt 只在"进房那一瞬间"用一次,用完就没用了,短命即安全------和上篇讲的 admin 签名 720 秒是同一个安全哲学:权限越小,有效期越短。
还有个容易忽略的前提:IM 和 TRTC 共用同一套 SDKAppID 和账号体系 。所以医生在 IM 里的 userID(doctor_xxx),进 TRTC 房间时也是这个 ID------不需要两套账号。这正是"IM×TRTC 联动"能这么顺滑的根本原因。
4. 信令走 IM:一条"开视频了"的消息
凭证备好了,还差最后一步:通知患者"医生喊你视频呢"。这就是上一章全景图里说的"信令"。
看 createRoom 的后半段:
java
CustomGroupRemindMessageDTO msg = new CustomGroupRemindMessageDTO();
msg.setGroupId(tlsGroup.getGroupId()); // 发到哪个群
msg.setFromAccount(tlsGroup.getUserID()); // 以谁的名义发
msg.setMsgType(MsgType.LIVE_STATUS_NOTIFY); // 消息类型:视频状态通知
JSONObject info = JSON.parseObject(JSON.toJSONString(user)); // 发起人信息
info.put("roomId", roomId); // 关键:带上房间号!
msg.setType("create_live"); // 子类型:创建视频通话
if (tlsGroup.getDataMap() != null && !tlsGroup.getDataMap().isEmpty()) {
info.putAll(tlsGroup.getDataMap()); // 业务方额外塞的字段
}
msg.setInfo(info);
templateMessageService.sendRemindMsg(msg); // 发出去(内部走 send_group_msg)
这条消息最终发到群里时,是上一篇讲过的 TIMCustomElem 自定义消息,内容大致长这样:
json
{
"MsgType": "TIMCustomElem",
"MsgContent": {
"Data": "{\"type\":\"LIVE_STATUS_NOTIFY\",\"show\":0,\"content\":{\"status\":-1,\"info\":{\"roomId\":182637465,\"nick\":\"张医生\",...},\"type\":\"create_live\",\"id\":\"\"}}",
"Desc": "...",
"Ext": "..."
}
}
患者端 SDK 收到这条消息 → 解析 type 发现是 LIVE_STATUS_NOTIFY、子类型 create_live → 弹出"张医生邀请你视频问诊"的接听界面 → 患者点接听 → 用消息里的 roomId 加上后端下发的签名进房。
为什么用 IM 发信令而不是另起一个 WebSocket? 这是本系列反复出现的架构思想:IM 是现成的、可靠的消息通道,双方本来就都在线(正在图文聊天),拿它传"控制指令"零成本。你自己搭信令服务器,连接管理、断线重连、消息可靠性全是坑。通道复用,是这套联动设计的第一个精髓。
顺带一提,反过来也有:视频挂断后,前端还会往群里发一条结束通知(type=finish_live 之类),对端据此更新 UI。通话状态机的前后两个状态变迁,都由 IM 消息驱动。
5. 正菜:startMCUMixTranscode 混流录制
前面的都是铺垫,现在进入本篇的核心。先解释一个最大的疑问:
5.1 什么是混流?为什么必须混?
TRTC 房间里,每个人是一路独立的流。医生一路(带摄像头画面+麦克风声音),患者一路。这时如果你想把这次问诊录下来,会遇到一个麻烦:
录两路,得到两个视频文件。 医生一个 doctor.mp4,患者一个 patient.mp4。以后回看的时候,你得同时开两个播放器,还得自己想"这两个画面怎么摆"。对医疗质控来说这简直是灾难------监管方要抽查问诊录像,你给他两个文件让他自己对时间轴?
混流(Mix Transcode)就是解决这个问题的: 让腾讯云的服务器把房间里的多路流实时合成一路。合成时你还可以指定"布局"------比如医生全屏、患者右下角小窗。合成后的这一路流:
- 录制 下来就是一个标准 mp4 文件,回看体验和普通视频没区别;
- 也可以直接推到 CDN 做直播,让第三方(比如患者的家属、监管人员)用普通播放器观看。
干"把多路合成一路"这件事的云端服务器,行业黑话叫 MCU (Multipoint Control Unit,多点控制单元)。所以这个接口名叫 StartMCUMixTranscode------"开启 MCU 混流转码"。
5.2 三大参数块
看代码骨架,发起混流录制要组装三大参数块:
java
@Override
public ResultVO<String> startMCUMixTranscode(StartMCUMixTranscodeDTO dto) {
GroupDTO groupDTO = new GroupDTO();
groupDTO.setGroupId(dto.getGroupId());
GroupDTO group = imSessionService.findById(groupDTO); // 查群,拿医生/患者ID
TrtcClient client = getTrtcClient(); // 创建腾讯SDK客户端
Long imSdkAppId = AccountConfig.getIMSdkAppId();
// 输出标识:streamId = "0000_{sdkAppId}_{roomId}_mix"
String streamId = String.format("%s_%d_%d_mix",
DEFAULT_ID, imSdkAppId, dto.getRoomId());
String fileId = streamId + "_file";
StartMCUMixTranscodeRequest req = getStartMCUMixTranscodeRequest(dto, imSdkAppId, streamId, fileId);
LayoutParams layoutParams = getLayoutParams(dto);
// ...布局细节(下面细讲)
req.setLayoutParams(layoutParams);
StartMCUMixTranscodeResponse resp = client.StartMCUMixTranscode(req); // 真正调腾讯
VideoRecord videoRecord = buildVideoRecord(dto, streamId, fileId, memId, docId, resp);
saveVideo(videoRecord); // 记录到数据库
return ResultVO.buildSuccess(resp.getRequestId());
}
入参 StartMCUMixTranscodeDTO 只有三个字段:roomId(房间号)、mainUserId(谁是大画面)、groupId(群号,用来反查医生患者)。接口签名越简单,说明后端替调用方干的活越多------医生端前端只需要说"我要录,房间是 X,主角是医生",剩下的全由后端补齐。
下面逐块拆。
参数块一:OutputParams------"产物存到哪"
java
private OutputParams getOutputParams(String streamId, String fileId) {
OutputParams outputParams = new OutputParams();
outputParams.setStreamId(streamId); // 混流输出到哪条流(推CDN直播用)
outputParams.setPureAudioStream(0L); // 0=纯音频流关闭(要视频)
outputParams.setRecordId(fileId); // 录制文件的ID(存进VOD点播)
outputParams.setRecordAudioOnly(0L); // 0=不只录音频(要画面)
return outputParams;
}
两个关键 ID 要分清:
- StreamId (
0000_{sdkAppId}_{roomId}_mix):混流输出的"流"标识。如果要做 CDN 直播观看,观众拉的就是这条流;后续去点播库里搜录制文件,也是拿它当搜索关键词。 - RecordId (StreamId +
_file):录制的文件 ID,腾讯用它命名存进 VOD(云点播)的文件。
命名规则里塞进了租户 ID、SDKAppID、房间号,好处是看名字就知道这文件是谁的,坏处是这串 ID 全靠拼字符串约定------两端(写入方和查询方)必须用同一套格式拼,拼错一个字符就"查无此录像"。
参数块二:EncodeParams------"画质参数"
java
private EncodeParams buildEncodeParams() {
EncodeParams encodeParams = new EncodeParams();
encodeParams.setVideoWidth(720L); // 宽
encodeParams.setVideoHeight(1280L); // 高(720x1280 竖屏)
encodeParams.setVideoBitrate(200L); // 视频码率 200kbps ⚠️(后面坑点讲)
encodeParams.setVideoFramerate(15L); // 帧率 15fps
encodeParams.setVideoGop(2L); // GOP
encodeParams.setBackgroundColor(0L); // 背景色:黑
encodeParams.setAudioSampleRate(48000L); // 音频采样率
encodeParams.setAudioBitrate(64L); // 音频码率 64kbps
encodeParams.setAudioChannels(1L); // 单声道
return encodeParams;
}
逐个翻译成人话:720×1280 竖屏 ------手机视频问诊,竖着拿手机是主流姿势,所以合成的画布也是竖的。15 帧 ------视频通话不需要电影级的流畅度,15fps 省带宽也够看。单声道------人声通话用不着立体声。这套参数的共同点是"够用就好",每一项都在为省流量让路。
参数块三:LayoutParams------"谁站 C 位"(最有业务味的一块)
java
private LayoutParams getLayoutParams(StartMCUMixTranscodeDTO dto) {
LayoutParams layoutParams = new LayoutParams();
// 混流布局模板:0悬浮 1九宫格 2屏幕分享 3画中画
layoutParams.setTemplate(3L); // 画中画
layoutParams.setMainVideoUserId(dto.getMainUserId()); // 大画面给谁
layoutParams.setMainVideoStreamType(0L); // 主画面摄像头流
return layoutParams;
}
腾讯提供了四个现成布局模板:0 悬浮 (默认小窗叠加)、1 九宫格 (多人会议)、2 屏幕分享 (共享屏幕为主)、3 画中画 (一大一小)。医患 1v1 问诊选画中画:一方全屏,另一方右下角小窗。
那"谁大谁小"怎么定?接着看:
java
SmallVideoLayoutParams smallVideoLayoutParams = getSmallVideoLayoutParams(); // 小窗 180x320
String memId = group.getMembers().get(0).getTlsId(); // 患者 IM 账号
String docId = group.getDoctors().get(0).getTlsId(); // 医生 IM 账号
if (StringUtils.equals(docId, layoutParams.getMainVideoUserId())) {
smallVideoLayoutParams.setUserId(memId); // 医生是大画面 → 小窗放患者
} else {
smallVideoLayoutParams.setUserId(docId); // 否则 → 小窗放医生
}
layoutParams.setSmallVideoLayoutParams(smallVideoLayoutParams);
一个小小的 if-else,藏着一点产品心思:调用方传入 mainUserId 指定大画面,另一个人自动进小窗 。医患场景里通常是患者想看医生("我要看清医生的表情和指示"),所以医生是主角;但如果某次反过来,传患者的 ID 当主角,小窗自动切给医生------不需要调用方关心"那另一个人是谁",后端查群就知道。
这也是前面说的"入参只有三个字段"的底气:医生、患者是谁,后端拿 groupId 查 kefu_im_session 表就有,前端不用传。
最后:调腾讯,存记录
java
StartMCUMixTranscodeResponse resp = client.StartMCUMixTranscode(req);
VideoRecord videoRecord = buildVideoRecord(dto, streamId, fileId, memId, docId, resp);
// buildVideoRecord 里装:docId、patientId、groupId、roomId、streamId、
// fileId、requestId、channel="tencent"、startTime=now
saveVideo(videoRecord); // insert 进 titan_im_video_record 表
注意调腾讯用的是官方 SDK 客户端 TrtcClient,不是上一篇 IM 那样的裸 REST 调用:
java
private TrtcClient getTrtcClient() {
String secretId = AESUtil.decryptAES(AccountConfig.get(SECRET_ID, DEFAULT_ID));
String secretKey = AESUtil.decryptAES(AccountConfig.get(SECRET_KEY, DEFAULT_ID));
Credential cred = new Credential(secretId, secretKey); // 云API密钥对
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("trtc.tencentcloudapi.com");
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
return new TrtcClient(cred, "ap-beijing", clientProfile); // 地区:北京
}
这里用的是**腾讯云 API 密钥(SecretId/SecretKey)**体系,和 IM 的 UserSig 完全是两套鉴权:IM 认"IM 管理员签名",腾讯云 API 认"账号密钥对"。上篇说过 IM 不需要 SDK,而 TRTC 这边项目选择了用官方 SDK(tencentcloud-sdk-java)------同一个项目里两朵"腾讯云",两种对接姿势并存,这本身就是个容易搞混的点,新手要特别留意密钥别用混了(IM 的 privateKey 和云 API 的 SecretKey 是不同的东西,控制台里位于不同的页面)。密钥在配置中心里是 AES 加密存的,比 IM 私钥的明文存储要讲究些。
6. 收尾:stopMCUMixTranscode 与录像文件的去向
通话结束,后端要干的收尾工作,难度其实超过了"开始"------因为要等。
6.1 停止混流
java
@Override
public ResultVO<String> stopMCUMixTranscode(Long roomId) {
try {
TrtcClient client = getTrtcClient();
StopMCUMixTranscodeRequest req = new StopMCUMixTranscodeRequest();
req.setSdkAppId(AccountConfig.getIMSdkAppId());
req.setRoomId(roomId);
StopMCUMixTranscodeResponse resp = client.StopMCUMixTranscode(req);
updateVideo(roomId); // 核心:取回录像URL并落库
return ResultVO.buildSuccess(resp.getRequestId());
} catch (TencentCloudSDKException e) {
if (e.getErrorCode().startsWith("FailedOperation.RoomNotExist")) {
updateVideo(roomId); // 房间不存在?照样去取录像!
}
return ResultVO.buildError(-1, e.getMessage());
}
}
注意那个 catch 里的分支:报错 FailedOperation.RoomNotExist(房间不存在)时,依然执行 updateVideo。想明白为什么没?
混流是挂在房间上的,房间都销毁了还停什么?------但录制文件早就生成好了 ,躺在 VOD 的库里。这个分支处理的是"通话已经自然结束、房间已释放,但业务侧的停止接口被延迟调用了"的场景。此时停止操作本身没意义会报错,但取录像的工作一刻也不能耽误。把"停止失败"和"录像取不到"解耦,这是对异常边界的精细处理。
6.2 取录像:一场与 VOD 转码的赛跑
updateVideo 的核心是 getVideoUrl(roomId):
java
private String getVideoUrl(String roomId) {
// ...创建 VodClient(同款密钥,endpoint 换成 vod.tencentcloudapi.com)
SearchMediaRequest req = new SearchMediaRequest();
req.setSubAppId(subAppId);
req.setStreamId(streamId); // 拿 streamId 搜点播库
int total = 0;
while (total == 0) { // 没搜到就一直搜!
SearchMediaResponse resp = client.SearchMedia(req);
if (resp.getTotalCount() == 0) {
ThreadUtil.sleep(50); // 等 50ms 再搜
} else {
templateUrl = resp.getMediaInfoSet()[0].getBasicInfo().getMediaUrl();
// ...后续处理
total = 1;
}
}
return templateUrl;
}
为什么要轮询 ?因为停止混流的那一刻,录制文件还处在"从 TRTC 的录制系统搬运到 VOD 点播系统"的途中,VOD 还要对它做转码、生成封面图等处理。此刻立刻去搜,大概率搜不到。 所以代码里做了个 50 毫秒间隔的循环搜索,直到文件"上架"为止。
这里其实暴露了一个值得商榷的实现(第九章坑 2 详说):同步阻塞轮询 。如果 VOD 抖动,这个请求的线程就卡死在这。生产上更稳的做法是 VOD 的事件通知 (文件处理完成会推 NewFileUpload 事件,项目里直播模块 LiveVodPull 就是这么做的,用 PullEvents 拉事件再确认)------同项目里两种方案并存,问诊录制选了简单的轮询,直播录像选了事件驱动。
6.3 拿到 URL 之后的三步加工
搜到文件后,代码做了三件收尾的事:
java
// 1) 换域名:把腾讯原始域名换成自家 CDN
// 原始:http://1500002198.vod2.myqcloud.com/6c988efcvodcq1500002198/xxxx/f0.mp4
templateUrl = templateUrl.replace(getHost(templateUrl), cdn);
// 2) 归类:在 VOD 里把这个文件挂到 "租户ID/im" 分类下
modifyVideoFileProperties(mediaInfo.getFileId(), getClassId(DEFAULT_ID));
// 3) 落库:更新 titan_im_video_record 的 endTime 和 videoUrl
updateWrapper.eq(VideoRecord::getRoomId, roomId)
.set(VideoRecord::getEndTime, new Date())
.set(VideoRecord::getVideoUrl, videoUrl);
第 1 步换 CDN 域名很有讲究:直接用腾讯 VOD 原始域名给用户播,流量费按 VOD 计费且没有加速;换成自家 CDN 域名后,走自己的内容分发网络------更快、更便宜、还能加防盗链。实现就是字符串替换 host 部分(用正则从 URL 里抠出域名替换掉)。
第 2 步归类 的逻辑在 getClassId,它维护了一个 VOD 里两层的分类树:第一层是租户(0000),第二层是 im(IM 视频问诊录像)。查不到就现场创建:
java
String className = "im";
// 遍历 VOD 所有分类,找出租户分类和 im 子分类
// 租户分类不存在 → createClass(父ID=-1, "0000") 建顶层
// im 子分类不存在 → createClass(父ID=租户分类, "im") 建子分类
想象 VOD 是个大仓库,不归类的话所有录像堆在"未分类"区,几万个文件后谁也没法管理。挂到 租户/im 目录下,将来按租户清理、对账、迁移都有抓手。
至此,一段录像的完整旅程:房间里的两路流 → 云端混成一路 → 录制成文件 → 落进 VOD 仓库 → 换上 CDN 域名 → 挂到分类目录 → URL 存进数据库。
7. 查录像:findVideoUrl 的兜底设计
最后一步最简单,但有个小设计值得一提:
java
@Override
public ResultVO<FindVideoUrlDTO> findVideoUrl(String groupId, Long roomId) {
VideoRecord videoRecord = getVideoRecord(groupId, roomId); // 查库
if (videoRecord == null) {
return ResultVO.buildError(4004, "没有找到该记录");
}
FindVideoUrlDTO res = new FindVideoUrlDTO();
if (StringUtils.isEmpty(videoRecord.getVideoUrl())) { // URL 是空的?
updateVideo(roomId); // 现场去取!
videoRecord = getVideoRecord(groupId, roomId); // 再查一次
}
res.setVideoUrl(videoRecord.getVideoUrl());
return ResultVO.buildSuccess(res);
}
videoUrl 为什么会是空的?回看第 6 章:如果 stop 接口被调用时 VOD 还没处理好文件(或 stop 压根没被调到),库里就只有 startTime 没有 videoUrl。这个接口做了懒加载兜底:查到记录但没 URL,就现场重跑一遍"搜索→换域名→落库"的流程。
这是一个很务实的补偿设计:录像这条链路涉及腾讯两个云产品(TRTC→VOD)的异步衔接,任何一环抖动都会造成"记录在、URL 空",与其写定时任务全表扫描修补,不如在读的时候顺手补------查询频次天然低于通话频次,且每次都只补自己要的那一条。
8. 数据库与配置清单
8.1 titan_im_video_record 表
一次成功录制对应一条记录:
| 字段 | 说明 | 什么时候写入 |
|---|---|---|
| id | 主键(雪花字符串) | startMCUMixTranscode 时 |
| groupId / roomId | 群号 / 房间号(骨架双 ID) | startMCUMixTranscode 时 |
| streamId / fileId | 混流流标识 / 录制文件标识 | startMCUMixTranscode 时 |
| requestId | 腾讯返回的请求 ID(排查凭据) | startMCUMixTranscode 时 |
| docId / patientId | 医生、患者的 IM 账号 | startMCUMixTranscode 时 |
| startTime / endTime | 通话起止时间 | start 写开始,stop 补结束 |
| videoUrl | 最终 CDN 播放地址 | stopMCUMixTranscode 时(或查询时懒加载) |
| channel | 渠道,固定 "tencent" | startMCUMixTranscode 时 |
| tenantId | 租户 ID | startMCUMixTranscode 时 |
channel 这个字段值得多看一眼------它为将来接入其他厂商的音视频服务预留了区分位(比如某天要对接联通自家的视频能力,channel 换个值就行,表结构不用动)。
8.2 涉及的配置项(配置中心管理)
| 配置 Key | 用途 |
|---|---|
titan-im.im.sdk.app.id |
SDKAppID(IM 和 TRTC 共用) |
titan-im.im.sdk.app.private_key |
IM 私钥(签 UserSig 用) |
titan-open.image-tencent-secret-id |
腾讯云 API 密钥对之 SecretId(AES 加密存储) |
titan-open.image-tencent-secret-key |
腾讯云 API 密钥对之 SecretKey(AES 加密存储) |
titan-im-video-record-cdn |
录像的自家 CDN 域名 |
titan-im-video-subappid |
VOD 点播的子应用 ID |
再强调一次第 5 章的结论:IM 私钥和云 API 密钥是两套东西。前者签 UserSig(HMAC-SHA256 那套),后者走腾讯云 API 鉴权(调 TrtcClient、VodClient 都用它),别配混。
8.3 接口清单
| 接口 | 路径 | 用途 |
|---|---|---|
| 开启混流录制 | POST /video/start |
入参 roomId + mainUserId + groupId |
| 停止混流录制 | GET /video/stop?roomId=xxx |
停止 + 取录像 URL 落库 |
| 查录像地址 | GET /getVideoUrl/{groupId}/{roomId} |
带"URL 为空则现取"的兜底 |
| 查文件详情 | GET /video/detail?roomId=xxx |
另一个查 URL 的入口 |
9. 踩坑实录:6 条真实经验
坑 1:VideoBitrate 200kbps,画质糊了别怪网络
第 5 章那个编码参数里,VideoBitrate(200L) 这个值值得单独拎出来讲。720×1280 的视频,腾讯官方推荐的码率是 500~1200kbps(摄像头场景),200kbps 属于明显偏低------后果是画面一运动就糊成马赛克(码率决定"每秒允许用多少数据描述画面",数据不够,只能糊)。
这个值怎么来的?大概率是从某个 demo 或低分辨率示例抄来的,没人验过画质。教训:编码参数上线前,务必真机录一段"有动作"的视频看效果,静态对着白墙测试是测不出码率问题的。改起来也简单,调到 800 左右对比一下。
坑 2:同步轮询卡线程
getVideoUrl 里那个 while (total == 0) sleep(50) 没有超时上限。VOD 一旦抽风(转码队列堆积、文件丢失),这个 HTTP 请求的线程就永远卡在循环里。通话量一上来,线程池被这种请求占满,整个服务的其他接口跟着遭殃。
更稳的姿势:给循环加最大次数(比如 100 次 × 200ms = 20 秒)+ 失败落表等补偿任务重试;或者干脆改用 VOD 的事件通知机制(项目里直播模块 LiveVodPull 已经在用 PullEvents 拉事件了,问诊录制完全可以复用同一套)。另外吐个槽:ThreadUtil.sleep(50) 那个工具类内部日志打的是 "sleep {} second",实际单位是毫秒------单位混乱本身就是 bug 之源。
坑 3:RoomNotExist 不是失败,别把它当错误处理
第 6 章讲了:stop 时报"房间不存在",但录像可能已经好好生成。如果你的代码在 catch 里直接返回错误、不去取 URL,用户就会遇到"明明视频聊完了,历史记录里却没有录像"------查日志还发现"一切正常,就是停止失败"。判断一个异常是不是真失败,要看业务后果,不是看返回码。
坑 4:streamId 拼接格式是隐形契约
0000_{sdkAppId}_{roomId}_mix 这串格式,写入时拼一次(start),查询时拼一次(stop / findFileDetail),两处代码要完全一致 。这个字符串没有任何编译期保护,手滑多打个下划线,编译照过、测试环境录像正常(那台服务拼对了),上了生产某个改过的环境就"查无此录像"。建议把拼接收口到一个工具方法里(目前是两个相似的私有方法各拼各的,已经出现细微重复)。改造多租户时更要注意:这里的 DEFAULT_ID 是写死的 "0000",多租户场景 roomId 前缀要不要带真实租户 ID,得想清楚。
坑 5:getVideoUrl 搜不到就返回 null,调用方接不住
异常分支里 return null,而 updateVideo 拿着这个 null 直接 set(videoUrl, null) 更新数据库------本来库里可能有旧 URL(比如二次停止时新文件还没出来),一下被覆盖成 null。查询接口返回给前端的也就是空。null 值在链路上"裸奔",每个环节都得自己判空,这是 Java 老项目的经典隐患。
坑 6:TRTC roomId 是 Long,别拿字符串 ID 直接用
IM 的 groupId 是字符串(订单号),TRTC 的 roomId 是数字。两套 ID 体系转换时(字符串→Long),如果哪天有人直接 Long.parseLong(groupId),遇到带字母的订单号当场炸。本项目用独立的雪花截取生成 roomId 并持久化绑定,思路是对的;如果你在新项目里做这事,切记不要试图"转换",要"映射"------各自生成、存表绑定。
10. 调试技巧
(1)录像链路排查顺序:先库、后 VOD、再腾讯控制台
"没有录像"的问题按顺序查:
- 查
titan_im_video_record:有没有这条记录?startTime有没有?------ 没有记录说明 start 接口压根没被调到,或调用报错了(先查自己服务日志搜startMCUMixTranscode ==req); - 有记录但
videoUrl空:搜日志video url------------>,看 stop 时的 VOD 搜索有没有搜到;搜不到就用坑 4 说的格式,手工拼 streamId 去腾讯 VOD 控制台搜文件; - VOD 控制台有文件但库里没 URL:大概率是 stop 那次调用失败在半路(比如进程重启),触发一次
findVideoUrl走懒加载兜底。
(2)TRTC 侧看房间状态
腾讯云控制台 → 实时音视频 TRTC → 通话管理,可以按 roomId 查房间:有没有人进房、进房时间、每路流的画质(丢包率、码率、帧率全都有)。"对方听不见/看不见"这类前端扯皮问题,先来这看流到底有没有推上来。
(3)混流任务有没有在跑
控制台 → 实时音视频 → 用量统计里能看混流用量;更直接的是调一次 stop 看返回------如果报 RoomNotExist,说明混流任务早跟着房间一起没了;如果 stop 成功返回了 requestId,说明任务确实挂过。
(4)日志关键字速查
| 搜这个 | 定位什么环节 |
|---|---|
startMCUMixTranscode ==req |
录制开始(含入参 mainUserId/roomId) |
startMCUMixTranscode ==resp |
腾讯混流接口的返回 |
videoRecord========= |
落库的录制记录内容 |
stopMCUMixTranscode req |
录制停止 |
video url------------> |
VOD 搜到了文件(原始 URL + CDN URL 各打一条) |
ModifyMediaInfoResponse |
VOD 文件归类结果 |
send====group============video |
createRoom 视频房创建(含 roomId 生成) |
(5)验证签名问题
进不了房(前端报进房失败),先用上篇的思路排查:SDKAppID 一致吗?liveUserSig 过期了吗(30 天内)?如果开了进房权限保护,检查 privateMapEncrypt 是否 5 分钟有效期已过------它是即用即取的,前端不该缓存它,缓存超过 300 秒再用,进房必挂。
11. 总结
把整条链路收拢成一张图:
typescript
图文问诊(IM 群 = 订单)
│ 医生点"开始视频"
▼
┌─ 后端 createRoom ──────────────────────────┐
│ 雪花截取生成数字 roomId(与群ID绑定存库) │
│ 签发 liveUserSig(30天)+ privateMapEncrypt │
│ (300秒,userbuf=groupId,进房权限位) │
│ 发 LIVE_STATUS_NOTIFY 消息 → 患者端弹接听 │
└────────────┬───────────────────────────────┘
│ 双方拿 roomId + 签名,TRTC SDK 进房
▼
◄── 视频流直连腾讯云端,后端全程旁观 ──►
│ 通话中,后端调一次:
▼
┌─ 后端 startMCUMixTranscode ─────────────────┐
│ StreamId = 租户_sdkAppId_roomId_mix │
│ 混流布局:画中画(mainUserId 大 + 另一方小窗) │
│ 编码:720x1280 竖屏 / 15fps / 单声道 │
│ OutputParams 同时指定推流ID + 录制ID │
│ → 腾讯 MCU 云端合成一路 + 录制进 VOD │
│ titan_im_video_record 落一条记录 │
└─────────────────────────────────────────────┘
│ 挂断
▼
┌─ 后端 stopMCUMixTranscode ──────────────────┐
│ 停混流(RoomNotExist 也继续走取录像) │
│ VOD SearchMedia 轮询等文件上架 │
│ URL 换自家 CDN 域名 + 归类到 租户/im │
│ 回填 endTime、videoUrl │
└─────────────────────────────────────────────┘
│ 日后回看
▼
findVideoUrl(URL 空则懒加载重取)
如果只带走三句话,我希望是这三句:
信令走 IM,媒体走 TRTC,后端只出现在通话的头和尾。
混流的本质是"让云端替你把多人画面合成一屏",录出来的是一个能直接播放的文件。
录制的产物在 VOD 不在 TRTC,取文件要接受"异步",要么轮询要么事件通知。
这个系列到这就完整了:IM 前端、IM 后端、IM×TRTC 联动------一套医患问诊系统里跟"聊天"和"视频"有关的后端知识,基本都在这三篇里了。剩下的(比如 TRTC 服务端录制的新版 API、VOD 事件通知改造)就留给各位在真实项目里打怪升级了。
腾讯云 TRTC 官方文档:云端混流转码。