视频会议这类系统,真正的难点从来不是"能不能看到画面",而是三件事:控制面和媒体面怎么分、多人多方订阅怎么控、移动端怎么接进同一套房间与权限体系。这套融合通信平台(Unified Communications Platform)就是围绕这三件事搭的:Go 负责控制面与业务面,Vue 3 负责 Web 端,ZLMediaKit 承担媒体转发;移动端另有一套独立的纯 C ABI 原生 SDK(C++ 内核 + Flutter FFI),和 Web 端共享同一套会议、票证与订阅计划。
项目地址放在文末。下面按"架构 → 会中体验 → AI 协同与第三方接入 → 移动端 SDK → 部署运维 → 工程质量"的顺序,把设计和取舍讲清楚,也把当前真正验证到哪一步如实交代。
一、整体架构:控制面不碰媒体
一句话概括分工:Go 服务只做控制面与业务面,不转发 RTP/SRTP;媒体走受控的 WHIP/WHEP 直连 ZLMediaKit。目标拓扑如下:
Vue 3 + TypeScript |-- REST / WebSocket ------> Go ucs server |-- WHIP/WHEP -------------> 票证 / 代理 ----> ZLMediaKit | 普通会议 simulcast、设备媒体、单流录像 | ^ Go ucs server ----------------------------+ | media API / WebHook / scoped ticket +-- 鉴权 API --------------> 设备/视频资源能力平台 --> GB28181 设备 +-- provider 适配器 -------> ASR / AI 服务 +-- repository ------------> MySQL几个关键设计:
- 发布方式按轨道拆开:麦克风独立 audio-only 发布;摄像头一次采集、以 VP8 simulcast 发布 low / medium / high 三档;屏幕共享独立发布。这样订阅端可以按需选档,而不是让服务端替所有人硬转码。
- 订阅由服务端下发计划:Go 根据 Room 权威状态、当前布局、活跃发言人和网络状况生成
SubscriptionPlan,并把 play ticket 绑定到"计划 + 源 + 清晰度"上;Room WebSocket 只承载聊天、举手和主持命令。 - 契约先行:仓库里
contracts/放了 OpenAPI 与一组 JSON Schema(房间事件、房间命令、媒体票证、房间票证、订阅计划、ZLM Hook、用户通知事件),前后端与移动 SDK 引用同一份契约,另有validate.mjs做校验。跨端通信最容易烂的地方就是"各自理解一份协议",这里把它变成可校验的产物。
二、会中体验:会议、布局、设备
会中主画面支持随发言人 / 共享内容切换,右侧是可切换的协作侧栏(成员、聊天、转写、AI 总结),底部是静音、开关视频、扬声器、共享屏幕、成员、聊天、设备、应用、举手、邀请和结束会议。
布局不是写死的宫格:提供自动布局、焦点布局、单人、两人,以及四宫格 / 八宫格 / 九宫格 / 十六宫格,另有"共享内容优先"开关——有人共享屏幕时自动切主画面,把共享内容顶到最大,这是会议里最常用的一个动作。
入会前有独立的设备预检页:麦克风、摄像头、扬声器逐项选择并带实时输入音量条,设备选完再入会,避免"进了会才发现麦克风选错"这种最耽误事的体验。
三、AI 协同与第三方接入
会中的实时转写会喂给 AI 纪要:自动总结默认每 120 秒检查一次新转写内容,生成后按模块归纳(截图里这段 24 秒生成、约 7.4K 字的纪要,是把一段非正式的多人对话按"学术背景与成长路径 / 对环境的看法 / 个人兴趣"等条目重新组织的)。纪要版本化并保留生成时间与耗时,可以标记为正式纪要,也可以手动重新生成。
第三方系统要接进来,不走"给个账号密码"这种野路子,而是走接入应用:管理员创建应用、选择应用类型、配置会议 scopes(查看会议 / 发起会议 / 加入会议 / 会议控制 / 查看 AI 纪要)、单场最大参会人数、同时并行的会议数、单场最大时长,以及是否启用语音识别和 AI 纪要(可以"继承系统配置",也可以只覆盖非敏感参数)。
分享 Key 的安全处理值得单说:明文只在创建和轮换时返回一次,数据库只保存 hash。应用侧调用
POST /api/v1/applications/{appId}/meetings X-Smart-Meeting-Share-Key: <应用分享 Key>服务端会创建并启动会议,返回会议级短期访客会话,把会话交给会议页即进入原生 WebRTC 会议——不需要客户端保存长期凭据。
面向集成方,仓库里给了三种可跑通的接入姿势:examples/meeting-dashboard(iframe 插件大屏,默认 20130 端口)、examples/meeting-sdk-demo(非 iframe的 SDK 直连 Demo,20134 端口),以及给 Vue 2.7 老系统准备的packages/meeting-sdk-vue2——不要求宿主项目为了接会议去升级框架。SDK 直连模式下,调用方既可使用平台签发的短期会议会话,也可以直接复用已有的统一用户中心 token,由平台服务端完成验真和本地权限映射,客户端不需要保存第二个会议 Token。
四、移动端:一套纯 C ABI 的 Native SDK
移动端没有走 WebView 套壳,也没有依赖flutter_webrtc,而是自己实现了一套原生 SDK:
- 公开面是
include/ucp/下稳定的纯 C ABI(版本化 ABI、client / session / track 句柄、状态机、错误码),内部媒体实现用 C++ 和 libdatachannel; - 网络操作走单线程 FIFO runtime:
ucp_join、publish、subscribe、leave 只提交任务,结果通过回调和ucp_event_poll获取,调用方不必自己管线程; - 所有权写进契约:事件队列是有上限的 FIFO(默认 256,满时丢弃最旧事件),快照字符串由 SDK 分配、调用方必须用
ucp_event_snapshot_free释放,track 用ucp_track_retain / release引用计数管理; - 订阅能力做过压测口径的设计:
ucp_subscribe_many最多 64 路独立订阅、ucp_unsubscribe_source按源取消、ucp_reconnect之后恢复媒体意图(重连不用业务层重新算一遍要订谁)。
核心调用长这样(真实签名,C / C++ 直接可用):
ucp_init(&options, &client); ucp_create_session(client, &session); ucp_join(session, &join_options); /* 提交任务,结果走事件 */ ucp_publish_audio(session, &audio_options); ucp_publish_camera(session, &camera_options); ucp_request_subscription_plan(session, &plan_options); ucp_subscribe_many(session, subscriptions, count); /* 最多 64 路 */ ucp_track_request_keyframe(track); /* 远端首帧等待时主动要关键帧 */ /* ... 事件循环 ... */ ucp_event_poll(client, &event); ucp_event_snapshot_free(&event); ucp_track_release(track);Android 侧接的是平台原生采集与编解码:Java HTTP bridge 承接控制面,Camera2 + MediaCodec 采集编码 H.264,AudioRecord + MediaCodec 编码 Opus,远端视频解码后渲染到 Flutter 的SurfaceTexture,音频支持 Opus / PCMA 播放。一个容易漏的细节:手机旋转时会重建 camera track,让编码尺寸和远端 SDP 保持一致(竖持 9:16、横持 16:9),而不是让远端收到一个方向拧着的画面。
Flutter 侧是 FFI 包 + 薄插件,事件驱动;没收到真实 track 就不会显示"媒体成功"——这条"不假装成功"的约定,比任何进度条都省事。宿主接入是可以直接用 Dart 写完的:
const auth = PlatformAuthClient(); final session = await auth.login(baseUrl: platformUrl, username: username, password: password); nativeSession.join( meetingId: meetingId, displayName: session.displayName, accessToken: session.accessToken, /* 短期 bearer token,SDK 不保存长期密码 */ );移动端会中界面与 Web 端共享同一套房间语义:正在讲话提示、成员状态、聊天、共享屏幕内容、主讲 / 宫格切换,远端画面标签用参会人显示名而不是 RTC ID。
三端 Demo 实拍:下面这些图不是设计稿,是仓库里三个 Demo(Android / iOS 用 Flutter Demo,鸿蒙用 ArkTS Demo)在真实平台上跑通后抓的屏;图注里写了各自的采集环境和「这张图证明了什么」。Demo 源码、证据文档和三端的接入说明都在文末的移动端仓库里。
Android · 后台与锁屏来电(AVD sm-native-api35,Android 15 / API 35)
图 1|App 退到后台、手机停在桌面时,顶部弹出高优先级来电横幅「会议邀请 · 移动端后台来电演示」,带 Join / Later 两个动作按钮——后台仍然收得到来电,不靠前台保活。
图 2|屏幕锁定时按来电形态全屏弹出,不用先解锁就能看到是谁在呼叫、会议叫什么。
图 3|点「加入会议」直接落到会议页:会议已连接、本地摄像头预览、上行码率在跑——接听即入会,不用再回列表把会议选一遍。
HarmonyOS · 桌面横幅与「接听 / 忽略」(HarmonyOS 5.0.3(15),x86_64 模拟器)
图 4|停在桌面(应用在后台)时的来电横幅。这条链路有个容易踩的坑:鸿蒙第三方应用的通知默认不开「横幅通知」,需要用户在系统设置里自己勾选;Demo 从第三轮起会自检这一项,并提供「开启桌面横幅」一键入口。
图 5|横幅上直接给出「接听」「忽略」两个动作按钮,而不只是一个只能点进通知中心的通知;点接听走 WantAgent → onNewWant → ucp_join 这条链路。
图 6|锁屏状态下,来电通知直接出现在锁屏底部。
图 7|接听入会后的远端画面:每路远端视频各自一个 XComponent(独立 surface 与独立解码器),标题栏显示当前路数。
iOS · 登录 → 被呼叫 → 收流上屏(iOS 18.6 模拟器,iPhone 16 Pro;远端发布端是同一场会议里的 Android Demo)
图 8|宿主侧登录页:平台地址、账号、显示名,登录后创建 SDK 会话——移动端不保存长期密码,只拿短期 bearer token。
图 9|未读 type=meeting 通知轮询触发被呼叫卡片,接听按钮直接进会。
图 10|接听后 ucp_join → 请求订阅计划 → H.264 解码上屏;图上的「远端视频源 1 / 订阅 2 / 已附着 1」和接收字节数是这一轮的实测计数。注意 iOS 侧只收不发,发布方向按能力边界拒绝。
另一端的能力边界也是明说的:iOS 与 HarmonyOS 已实现接收路径(VideoToolbox / AVAudioEngine 与 AVCodec / OHAudio 原生桥,含 Swift pod 与 hvigor HAR 插件),发布方向明确拒绝——采集类方法直接返回platform_media_unavailable。上面那组图就是两端接收路径的实测(截图与日志原文都在仓库 docs/evidence/ 下),但两端都只到模拟器,真机验收还没有,所以这里一律写"模拟器实测"而不是"支持"。另外,SDK 不保存、不暴露 ZLMediaKit secret、内部 app/stream 或任何长期凭据,会议鉴权、Room、media ticket 和媒体编排全部由平台后端负责。
五、部署与在线更新
服务端是一套 Go 模块化单体 + MySQL,发布侧有两条约定:
- 构建不打断服务:一键脚本
scripts/build-and-start.ps1先把候选产物写进bin/.staging/,只有显式"激活"时才原子替换bin/,然后执行 Schema Sync、启动和登录 smoke;菜单里构建、激活、重启、状态、停止是分开的动作。 - 发布包不带秘密:
scripts/package-releases.ps1生成 Windows / Linux amd64 的公开目录包,真实的config.yaml、secrets、TLS 私钥和日志都不进包。
平台自身支持在线更新:上传发布包(必须包含服务端二进制、前端dist/index.html与release.json)→ 校验 → 执行更新 → 完成更新,历史更新记录可以查看详情、切换版本,媒体服务(ZLMediaKit)不随平台包一起替换,配置、证书与 unit 也不被覆盖。
上线前还有一道只读 preflight 门禁:分 predeploy / postdeploy 两阶段,逐项报告传输、sudo、产物、数据库管理权限、migration、服务与冒烟探测结果;它只探测、不执行build / upload / migration / stop / restart,在人工批准之前始终保持NO-GO。把"默认不许上线"写进工具,比写进流程文档可靠得多。
六、工程质量:把设计结论沉淀成契约
这个仓库在工程约束上是比较"较真"的,几个做法值得抄:
- 依赖方向自动化:后端采用受门禁约束的顶层横向分层(api / service / model / repository / router / adapter / infrastructure / app),依赖方向由
architecture_test.go在测试里检查,文件规模由脚本check-file-lines.mjs卡上限——架构约束不靠代码评审时的口头约定。 - 前端按 Hi-Fi 画板做像素级对齐:把设计实测结论(字体族、字号字重字阶、导航选中底片坐标与圆角、对话框标题栏高度与分隔线颜色)沉淀成 CSS 令牌和契约单测,再用真实 Chromium 在 1920×1080 下量几何(行距、底片起止 x 坐标、1px 描边峰值)验证,防止改一处样式导致另一处回潮。
- 状态描述克制:README 里明确写着"2 人纯语音短测已取得音频 RTP、
audio/mp4、Range、多轨回放和资源归零证据;2 人视频重跑在屏幕共享收敛阶段失败;8/16 人长稳、Go/ZLM 重启恢复和视频录像重跑仍未完成",并且写明页面能打开不等于设备、AI 或媒体能力已可用。
第三点尤其想强调:自研会议系统最常见的事故,就是拿"两个人能通话"当"可以上生产"。把未验证项写进文档、明确"当前不承诺生产级多人会议",是一种更省事也更负责的工程习惯。
七、本地跑起来
开发环境的端口是成套分配的(前后端与媒体服务各占一段,仅监听本机):
| 入口 | 地址 |
|---|---|
| Vite 开发(HTTP / HTTPS) | http://127.0.0.1:20121/https://127.0.0.1:20122 |
| Go + 前端构建产物 | http://127.0.0.1:20123/https://127.0.0.1:20124 |
| ZLMediaKit(HTTP / WHIP / WHEP) | 127.0.0.1:20125(HTTPS20126) |
| ZLM WebRTC 单端口 | 127.0.0.1:20127/tcp+udp |
| MySQL | 127.0.0.1:3306 |
# 构建、激活、重启、状态、停止(菜单式) scripts\build-and-start.ps1 # 生成 Windows / Linux amd64 发布目录包(不含配置与密钥) scripts\package-releases.ps1技术选型汇总:
| 层次 | 选型 |
|---|---|
| 控制面 / 业务面 | Go 模块化单体 + MySQL(GORM Schema Sync) |
| 媒体面 | ZLMediaKit(WHIP/WHEP 直连、simulcast、单流录像) |
| Web 前端 | Vue 3 + TypeScript + Element Plus |
| 接入方式 | 接入应用 OpenAPI / iframe 大屏 / SDK 直连(Vue 3 与 Vue 2.7) |
| 移动端 | 纯 C ABI(C++ / libdatachannel)+ Flutter FFI 插件 |
| 跨端契约 | OpenAPI + JSON Schema(房间 / 媒体 / 订阅计划 / 通知) |
八、仓库地址
- Gitee:https://gitee.com/newpc/unified-communications-platform
- 移动端 SDK 仓库 Gitee:https://gitee.com/newpc/unified-communications-mobile-sdk
两个仓库里分别放了平台(后端、Web 前端、跨端契约、运维脚本)和移动端(纯 C ABI 的 Native SDK、Flutter 包,以及 Android / iOS / HarmonyOS 三个 Demo),README 里写清了当前阶段、未验证项和本地入口,欢迎按需取用、提 Issue。
最后留个问题:如果你要自建会议/融合通信系统,你会把"订阅决策"放在服务端(下发计划)还是客户端(自己算要订谁)?我们在移动端选了"服务端计划 + 客户端恢复意图",很想听听不同规模的场景下大家的做法。