一个即时通讯项目是怎么长出来的:(Spring Boot 3 + Vue 3 微前端 + 悟空 IM + LiveKit)

项目:飞鸽即时通讯,一套包含单聊、群聊、音视频通话、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 群聊通话:状态机 + 三层兜底

一次群聊通话会经历:发起 → 振铃 → 多人陆续加入 → 有人退出 → 结束。中间任何一环都可能异常(前端崩溃、直接关标签页、断网、媒体建连失败)。

三层兜底:

  1. 幂等结束:结束房间时用"受影响行数"判断,UPDATE ... WHERE status = 通话中,只有真正改到的那一次才发通知、写通话记录,重复调用返回 0 行直接忽略;
  2. 超长通话:单次通话上限 180 分钟,定时任务兜底结束;
  3. 异常残留(本次补的):每 10 秒扫一次"通话中"房间,只要 LiveKit 里已经没人在场、且开始时间已过 60 秒宽限,就结束房间并落记录。

第 3 条是踩出来的:前端异常退出时只清了本地状态,服务端房间还停在"通话中",导致该用户此后发起通话一直被"你已在通话中"挡住。修法是两头一起做------前端异常分支补发一次挂断上报,后端再用"媒体面还有没有人"做权威判定。LiveKit 不可达时直接跳过扫描,避免网络抖动误杀进行中的通话。

2.3 消息去重:id 靠不住

去重最初只看消息 id,结果还是重复。原因在于同一条消息有三个来源:

  • IM 推送的消息(真实 id)
  • 历史拉取的消息(服务端 id)
  • 本地乐观插入的消息(临时 id,clientMsgNo)

同一句话在三个来源里的 id 可能都不一样,用 id 去重必然漏。改成语义身份判定:

  1. 优先按 clientMsgNo 匹配(发送方生成的,三个来源都一致);
  2. 其次按"频道 + 消息序号"匹配;
  3. 兼容服务端把序号序列化成字符串的情况。

去重键应该来自消息的业务身份,而不是存储层的主键。

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. 复盘:能迁移走的几条

  1. 先想清楚"哪些不做",再决定做什么。 IM 最后一公里交给悟空 IM,是因为它不构成差异化;省下的时间做了音视频、AI 和微前端。
  2. 状态存在哪里,决定了能不能做对。 删除聊天记录做成"私有边界"而非"删数据",是因为数据是共享的、而边界是私有的------选错位置,功能永远做不对。
  3. 异步 + 多来源的场景,去重键必须是业务身份。 存储主键在跨来源时不可靠。
  4. 控制面要幂等,兜底要"看事实"而不是"看上报"。 前端说"我挂了"不可信,媒体面还有没有人才可信。
  5. 本地跑通不是项目,上线才是。 云主机上的问题是叠加的:NAT、三层防火墙、共享 443、镜像源、证书......每一层单独都不难,难的是不知道问题在哪一层------先把"信令面通不通"和"媒体面通不通"分开验证,能省掉大量盲猜。
  6. 同一个前端,不同宿主就是不同环境。 桌面端教我重新过了一遍跨域、产物目录和资源路径。

5. 后续规划

最想投入的方向是把聊天长连接握在自己手里 :现在的 IM 底座够稳够快,但协议、存储、扩展点都不是自己的------能力长在自己身上,才谈得上往里加东西。

计划分三步,不跳级:

阶段 目标 要啃的点
1. 看懂 能用抓包和日志把"一条消息的一生"讲清楚 连接鉴权、心跳与保活、收发 ACK、上行/下行序号、离线补偿边界
2. 自研连接层 独立的 WebSocket 网关,多端在线 + 断线重连 长连接框架、连接注册表与会话路由、心跳超时踢线、优雅下线与灰度重启
3. 补齐可靠语义 做到"不丢、不重、可补、可读" 会话级递增序号、离线消息与拉取补洞、多端已读位点、幂等落库

围绕这条主线,音视频与 AI 侧也在列:

  • 录制与回放:LiveKit 的 Egress 服务落对象存储,群聊会议支持录制;
  • 服务端混流:多人通话时的合流输出,便于直播旁听场景;
  • AI 助手进入会话:现在 AI 是独立助手,下一步希望它能在群里"被 @ 就上车"。
相关推荐
n87651 小时前
Playwright Java WEB UI 自动化实现框架与逻辑
java·前端·ui
Java_AI工程师1 小时前
Spring4 / SpringBoot1.x 老项目想接入 AI?不用升级框架!3 套无痛落地方案
java·人工智能·spring boot
BBmmo1 小时前
Java Stream 的 filter 为什么看起来没运行?
java
VimorphTech1 小时前
Java 文件缓存实战:如何避免读到未下载完成的视频?
java·spring boot
白露与泡影1 小时前
后端 Java 面试核心八股汇总|高频面试题 + 解析,覆盖基础到进阶
java·开发语言·面试
SL_staff2 小时前
JVS-APS 实践:用双向反馈闭环打破制造计划孤岛
java·spring cloud·全栈
\光辉岁月/2 小时前
3.包装类
java·开发语言·包装类
BeiGuTing_code2 小时前
Java动态数组(一)
java·数据结构·windows
我命由我123452 小时前
软件工程概念 - 可插拔性
java·后端·学习·java-ee·系统架构·软件工程·学习方法