项目:飞鸽即时通讯,一套包含单聊、群聊、音视频通话、AI 助手、管理后台、桌面客户端的完整 IM 系统。 后端 Spring Boot 3 多模块,前端 Vue 3 + qiankun 微前端,媒体面自建 LiveKit SFU,客户端除 Web 外还有 Tauri 桌面端。这篇文章不复述功能清单,写的是:为什么这么选、哪里差点走错、上线这一段又教会了我什么。
线上地址: www.bwyxy.cn/chat/
0. 项目全貌
一句话概括:用一套系统把"微前端 + IM + 实时音视频 + AI + 桌面端"这几件事串起来,而不是做五个 Demo。
| 维度 | 规模 |
|---|---|
| 后端模块 | 8 个 Maven 模块(common / framework / system / admin / ai / chat / app / blog) |
| 前端 | 3 个子应用(主应用基座 + 聊天 + 管理后台)+ 2 个公共包,pnpm workspace |
| 客户端 | Web(qiankun 微前端)+ Tauri 桌面端 |
| 服务端口 | 9070 AI、9080 聊天、9090 管理后台、9100 Todo;前端 5000 / 9001 / 9002 |
| 数据库 | MySQL 8 + Redis(缓存、分布式锁、限流) |
后端技术栈:Spring Boot 3.2.4 / JDK 17 / Spring Security 6 + JWT / MyBatis-Plus / MySQL 8 / Redis / Bucket4j / Spring AI。 前端技术栈:Vue 3 + TypeScript / Vite / qiankun / Pinia / Element Plus / easyjssdk(IM)/ livekit-client(音视频)。
bash
xy-service (parent)
├── xy-common # 公共实体、枚举、Feign Client、异常
├── xy-framework # 安全、JWT、全局异常、限流、Web 配置
├── xy-system # 用户、权限、菜单、七牛云、悟空 IM Client
├── xy-admin # 管理后台 API :9090
├── common-api/xy-ai # AI 能力层:对话/RAG/Agent :9070
├── chat-api/fg-chat # 聊天主 API(含音视频通话) :9080
└── customer-api # xy-app :9100 / xy-blog
1. 四个关键决策
项目里真正花时间想清楚的,不是怎么写代码,而是下面四个"选 A 还是选 B"。
1.1 IM 底座:自研还是用现成的?
最后选了 悟空 IM(WuKongIM),只做几件事:频道(channel)模型、WebSocket 长连接、离线消息、多端同步。
判断依据很简单:IM 的"最后一公里"(多端同步、离线补偿、消息序号、已读位点)是脏活累活,但它不是这个项目的差异化价值。 把时间花在这上面,就没有时间做音视频、AI 和微前端了。
不过这只是阶段性的务实取舍,不是终点 。用现成 IM 的代价很明确:协议怎么走、序号怎么长、离线消息怎么补、多端位点怎么维护,这些都在别人的代码里------想改只能等上游,想扩展只能绕过去。 所以长连接的自主实现是我接下来最想投入的方向(见第 5 节)。
1.2 发消息为什么绕一层自己的后端?
前端拿到 IM 的 SDK 后,最省事的做法是直接 sdk.send()。我没有这么做,而是统一走 POST /message/send,由后端转发给悟空 IM。
多一跳的代价换来的是:消息出口在后端。敏感词、图片审核、频率控制、消息审计、灰度策略这些能力,将来只需要在后端加,前端一行不改。如果让前端直连 IM 发消息,这个位置就永远失去了。
1.3 音视频:从 P2P 到 SFU 的一次真实演进
第一版是1v1 P2P 直连 (RTCPeerConnection + 自建信令),当时觉得很优雅:不花服务器带宽。坑都记录在《给即时通讯做 1v1 音视频通话,我踩的 7 个坑》里。
但需求走到"群聊音视频"时,P2P 直接出局:
| 方案 | N 人上行路数 | 我的结论 |
|---|---|---|
| Mesh(两两互联) | N-1 | 3 人还行,5 人浏览器就开始发热,9 人不可能 |
| MCU(服务端混流) | 1 | 服务端要做解码/混流,CPU 成本高,不适合单机 |
| SFU(只转发不混流) | 1 | 选它:服务端只转发,弱网可分层(simulcast),人数上限可控 |
最终选定自建 LiveKit SFU ,并把 1v1 也一起迁到同一套媒体层------统一一套媒体面,比同时维护两套要省心得多。房间人数上限设为 9。
1.4 呼叫信令:开一条新长连接,还是复用 IM?
复用。呼叫控制(邀请/接听/拒绝/挂断/取消/结束)用悟空 IM 个人频道的 200--205 消息类型码传输,不新增长连接、不新增一个"信令服务器"。
代价是:复用了消息通道,就必须回到消息分发器确认语义假设 。这一点我在 1v1 那版上栽过------前端有一行 type >= 100 一律当系统通知,而通话信令刚好落在 200--208,结果一次通话弹几十条系统通知。教训写在那篇博客里,新加类型码时都会先回去看一圈分发顺序。
最终音视频被拆成四个平面:
| 平面 | 职责 | 实现 |
|---|---|---|
| 控制面 | 发起/接听/拒绝/挂断、超时、通话记录 | REST /rtc/*,权威落库、可幂等 |
| 信令面 | 呼叫事件传递 | 复用悟空 IM 200--205 |
| 媒体面 | 音视频流 | 自建 LiveKit SFU |
| 状态面 | 房间与参与者审计 | t_rtc_room / t_rtc_participant + 定时兜底 |
为什么控制面坚持走 REST?因为控制指令需要权威和幂等("这通电话到底接没接"不能有两个答案),而信令只是管道里的数据。
2. 做得最扎实的三个细节
2.1 「仅本机生效」的删除聊天记录
微信里删聊天记录,对方是不知道的。所以"删除"这个动作只能作用于当前用户,不能去改悟空服务端的消息。
如果直接删服务端消息,会同时影响对端可见性------这是数据模型层面的错误。我采用的是私有会话标记:
lua
-- t_conversation_setting
cleared_seq -- 清空聊天记录时的最新消息序号
deleted_seq -- 删除会话时的最新消息序号
- 拉历史消息时,按 seq 过滤掉 cleared_seq 之前的消息;
- 会话列表查询时,过滤掉 deleted_seq 的会话;
- 收到新消息(seq 更大)后,会话自动重新出现。
"删除"变成了"记一个我看不到的边界",对端数据完好无损,而且新消息还能把会话救回来 ------行为和微信一致,实现上只是两个 bigint。
2.2 群聊通话:状态机 + 三层兜底
一次群聊通话会经历:发起 → 振铃 → 多人陆续加入 → 有人退出 → 结束。中间任何一环都可能异常(前端崩溃、直接关标签页、断网、媒体建连失败)。
三层兜底:
- 幂等结束:结束房间时用"受影响行数"判断,UPDATE ... WHERE status = 通话中,只有真正改到的那一次才发通知、写通话记录,重复调用返回 0 行直接忽略;
- 超长通话:单次通话上限 180 分钟,定时任务兜底结束;
- 异常残留(本次补的):每 10 秒扫一次"通话中"房间,只要 LiveKit 里已经没人在场、且开始时间已过 60 秒宽限,就结束房间并落记录。
第 3 条是踩出来的:前端异常退出时只清了本地状态,服务端房间还停在"通话中",导致该用户此后发起通话一直被"你已在通话中"挡住。修法是两头一起做------前端异常分支补发一次挂断上报,后端再用"媒体面还有没有人"做权威判定。LiveKit 不可达时直接跳过扫描,避免网络抖动误杀进行中的通话。
2.3 消息去重:id 靠不住
去重最初只看消息 id,结果还是重复。原因在于同一条消息有三个来源:
- IM 推送的消息(真实 id)
- 历史拉取的消息(服务端 id)
- 本地乐观插入的消息(临时 id,clientMsgNo)
同一句话在三个来源里的 id 可能都不一样,用 id 去重必然漏。改成语义身份判定:
- 优先按 clientMsgNo 匹配(发送方生成的,三个来源都一致);
- 其次按"频道 + 消息序号"匹配;
- 兼容服务端把序号序列化成字符串的情况。
去重键应该来自消息的业务身份,而不是存储层的主键。
3. 上线这一段,才叫项目
本地跑通只证明代码能跑。部署这一天,我遇到的每一个问题都不是代码问题。
3.1 镜像拉不下来
vbscript
Error response from daemon: Get "https://registry-1.docker.io/v2/": net/http: request canceled
国内服务器直连 Docker Hub 超时。配了腾讯云内网镜像源 mirror.ccs.tencentyun.com 后正常。
3.2 443 端口被占用:不要抢,要复用
我给 LiveKit 配了 Caddy 自动签证书,结果容器反复重启:
perl
listening on :443: listen tcp :443: bind: address already in use
排查发现 443 早被服务器上的宝塔 Nginx 占着,而且上面还跑着别的站点,不可能让出来。
最后方案是删掉 Caddy,复用已有 Nginx 按域名反向代理到 LiveKit 的 7880。两个容易踩的细节:
perl
location ^~ /livekit/ {
proxy_pass http://127.0.0.1:7880/; # 结尾斜杠 = 剥掉前缀
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # WebSocket 必须:缺了会握手失败
proxy_set_header Connection "upgrade";
}
教训:服务器上"已有入口"往往比"新起一个入口"更划算。抢端口之前,先看看那个端口上跑的是什么。
3.3 LiveKit 广播了内网 IP:最隐蔽的一个坑
信令通了、浏览器连上了,但怎么都出不了画面,控制台只给一句:
makefile
ConnectionError: could not establish pc connection
看 LiveKit 日志才找到根因:
sql
INFO using external IPs {"ips": ["10.x.x.x/10.x.x.x", "172.17.0.1/172.17.0.1", ...],
"advertiseInternalIP": false}
WARN could not validate external IP {"ip": "<公网IP>", "error": "context canceled"}
LiveKit 用 STUN 正确探测到了公网 IP,但要再做一次反向校验 (从本机往自己的公网 IP 发探测包确认可达)。云厂商的弹性 IP 通常不支持回环(hairpin NAT) ,校验失败 → LiveKit 把公网 IP 从候选列表里剔掉 → 对外只广播 10.x.x.x 这类内网地址 → 外部客户端的 ICE 永远连不上。
修法是显式指定对外公布的 IP,跳过探测与校验:
yaml
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: false
node_ip: <你的公网 IP>
教训:NAT 环境下别让服务"自己猜"自己的公网地址。猜错了它不会报错,只是安静地广播一个别人永远到不了的地址。
3.4 TCP 通了不等于媒体通了
还有个更基础的:放行要放两层 ------云安全组 + 系统防火墙(firewalld)。我一开始只放行了 TCP:
bash
firewall-cmd --list-ports
20/tcp 21/tcp 22/tcp 80/tcp 443/tcp ... # 一条 UDP 都没有
而 WebRTC 媒体优先走 UDP ,UDP 被静默丢包时 ICE 会长时间卡住,最后同样报 could not establish pc connection。
另外一个实践建议:媒体端口别开整段(50000-50100/udp),用单端口更省事 ------udp_port: 7882,只需要管 7881/tcp + 7882/udp 两条规则,出问题时排查面小得多。
3.5 浏览器能跑 ≠ 桌面端能跑
Web 端一切正常,同一份前端打包成 Tauri 桌面端后所有接口都访问不了。查出三个独立问题:
① 打包塞进去的是旧产物。 构建输出目录是 chat/,而 Tauri 的 frontendDist 指向 ../dist------新构建写在 A 目录,打包读的是 B 目录,桌面端里跑的是好几天前的代码。修法就是让两者对齐:
css
// vite.config.ts
build: {
outDir: isTauri ? 'dist' : 'chat',
}
② 桌面端天然是跨域。 桌面端页面自身的源是 tauri://localhost(Windows 上是 http://tauri.localhost),访问后端接口必然跨域。因此后端必须显式放行,用户才会遇到下面这个经典的意外------预检请求 OPTIONS 被认证拦截了:
bash
OPTIONS /chat-api/chat/login Origin: tauri://localhost → 403 Invalid CORS request
预检请求不携带 token ,如果它排在 Spring Security 后面,会被判"未登录"直接 401/403,跨域还没开始就结束了。所以跨域处理必须早于安全过滤器链:
ini
FilterRegistrationBean<CorsFilter> registration = new FilterRegistrationBean<>(new CorsFilter(source));
registration.setOrder(Ordered.HIGHEST_PRECEDENCE); // 关键:早于 Spring Security 的 -100
③ 相对路径会打到"自己身上"。 如果打包时 API 地址为空,baseURL 为空字符串,/chat/login 就被解析成 tauri://localhost/chat/login------请求打给了应用自己,连后端都没碰到。
教训:Web 端的"同源"假设,在桌面端、小程序、Electron 里全部失效。只要客户端不是浏览器同源加载的,CORS、Cookie、混合内容都要重新过一遍。
4. 复盘:能迁移走的几条
- 先想清楚"哪些不做",再决定做什么。 IM 最后一公里交给悟空 IM,是因为它不构成差异化;省下的时间做了音视频、AI 和微前端。
- 状态存在哪里,决定了能不能做对。 删除聊天记录做成"私有边界"而非"删数据",是因为数据是共享的、而边界是私有的------选错位置,功能永远做不对。
- 异步 + 多来源的场景,去重键必须是业务身份。 存储主键在跨来源时不可靠。
- 控制面要幂等,兜底要"看事实"而不是"看上报"。 前端说"我挂了"不可信,媒体面还有没有人才可信。
- 本地跑通不是项目,上线才是。 云主机上的问题是叠加的:NAT、三层防火墙、共享 443、镜像源、证书......每一层单独都不难,难的是不知道问题在哪一层------先把"信令面通不通"和"媒体面通不通"分开验证,能省掉大量盲猜。
- 同一个前端,不同宿主就是不同环境。 桌面端教我重新过了一遍跨域、产物目录和资源路径。
5. 后续规划
最想投入的方向是把聊天长连接握在自己手里 :现在的 IM 底座够稳够快,但协议、存储、扩展点都不是自己的------能力长在自己身上,才谈得上往里加东西。
计划分三步,不跳级:
| 阶段 | 目标 | 要啃的点 |
|---|---|---|
| 1. 看懂 | 能用抓包和日志把"一条消息的一生"讲清楚 | 连接鉴权、心跳与保活、收发 ACK、上行/下行序号、离线补偿边界 |
| 2. 自研连接层 | 独立的 WebSocket 网关,多端在线 + 断线重连 | 长连接框架、连接注册表与会话路由、心跳超时踢线、优雅下线与灰度重启 |
| 3. 补齐可靠语义 | 做到"不丢、不重、可补、可读" | 会话级递增序号、离线消息与拉取补洞、多端已读位点、幂等落库 |
围绕这条主线,音视频与 AI 侧也在列:
- 录制与回放:LiveKit 的 Egress 服务落对象存储,群聊会议支持录制;
- 服务端混流:多人通话时的合流输出,便于直播旁听场景;
- AI 助手进入会话:现在 AI 是独立助手,下一步希望它能在群里"被 @ 就上车"。