腾讯 IM × TRTC 联动实战:后端如何撑起一次视频问诊(混流录制篇)

腾讯 IM 前端对接小白教程:医患一对一聊天(含自定义报告卡片)

腾讯 IM 后端对接小白教程:Java 服务端如何撑起一个医患聊天系统

这是这个系列的第三篇。前两篇我们讲了前端怎么用 Chat SDK 收发消息、后端怎么签 UserSig、建群、代发卡片。有同学可能问:"图文聊明白了,那视频问诊呢?医生点'开始视频'之后,后端干了什么?"

这一篇就来补上这块拼图------IM 和 TRTC 音视频是怎么联动的,重点讲清 startMCUMixTranscode(开启云端混流录制)这个"视频问诊后端最复杂的方法"。

老规矩:先建立认知 → 再看代码 → 最后避坑。不需要你懂任何音视频知识,会用手机打视频电话就够。


目录

  1. [开胃菜:IM 和 TRTC 到底是什么关系](#开胃菜:IM 和 TRTC 到底是什么关系)
  2. 全景图:一次视频问诊的生命周期
  3. [房间号的诞生:IM 群 ID 和 TRTC roomId 不是一回事](#房间号的诞生:IM 群 ID 和 TRTC roomId 不是一回事)
  4. 两张门禁卡:进房要带的两种签名
  5. [信令走 IM:一条"开视频了"的消息](#信令走 IM:一条"开视频了"的消息)
  6. [正菜:startMCUMixTranscode 混流录制](#正菜:startMCUMixTranscode 混流录制)
  7. [收尾:stopMCUMixTranscode 与录像文件的去向](#收尾:stopMCUMixTranscode 与录像文件的去向)
  8. [查录像:findVideoUrl 的兜底设计](#查录像:findVideoUrl 的兜底设计)
  9. 数据库与配置清单
  10. [踩坑实录:6 条真实经验](#踩坑实录:6 条真实经验)
  11. 调试技巧
  12. 总结

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)就是解决这个问题的: 让腾讯云的服务器把房间里的多路流实时合成一路。合成时你还可以指定"布局"------比如医生全屏、患者右下角小窗。合成后的这一路流:

  1. 录制 下来就是一个标准 mp4 文件,回看体验和普通视频没区别;
  2. 也可以直接推到 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 要分清:

  • StreamId0000_{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、再腾讯控制台

"没有录像"的问题按顺序查:

  1. titan_im_video_record:有没有这条记录?startTime 有没有?------ 没有记录说明 start 接口压根没被调到,或调用报错了(先查自己服务日志搜 startMCUMixTranscode ==req);
  2. 有记录但 videoUrl 空:搜日志 video url------------>,看 stop 时的 VOD 搜索有没有搜到;搜不到就用坑 4 说的格式,手工拼 streamId 去腾讯 VOD 控制台搜文件;
  3. 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 官方文档:云端混流转码

相关推荐
吴声子夜歌17 分钟前
Java——类、对象及方法(二)
java·开发语言
2401_8906034019 分钟前
Python入门语法(一)
java·开发语言·python
小蒜学长20 分钟前
springboot党建云课堂学习与管理系统(代码+数据库+LW)
java·数据库·spring boot·后端·学习
mldong32 分钟前
六语言引擎实现对比:同构背后的妥协与差异
java·架构
腾讯云大数据9 小时前
DataBuddy数据语义驱动的企业Agent Runtime实践
大数据·人工智能·腾讯云·agent
EXI-小洲9 小时前
Java 操作 Word:字符串替换、图片插入、动态生成表格与API接口下载
java·开发语言·spring boot·word
2601_9619017010 小时前
SpringBoot使用Nacos进行application.yml配置管理
java·spring boot·spring
何以解忧,唯有..11 小时前
LangChain 工具调用(Tool Calling)实战指南
java·前端·langchain
jike_202611 小时前
iPhone采访录音同时拍现场照片的APP:图片音频同步记录实测
笔记·智能手机·音视频·语音识别