☰
群聊里的视频邀请为什么会“诈尸”?Spring Boot 通话卡片状态机与重启恢复实战
2026/10/8 2:16:39 网站建设 项目流程

群聊、WebSocket 和 WebRTC 都已经接通,Demo 里点击“发起视频”也能正常进入房间,但一遇到并发点击、弱网重试或服务重启,问题就会集中出现:

  • 同一成员收到两次响铃;
  • 发起人已经取消,其他人仍能点击接听;
  • 两个人几乎同时接听,系统却创建了两次通话;
  • Spring Boot 重启后,旧邀请重新弹出;
  • 群消息里的通话卡片显示“进行中”,实际房间早已结束。

这类故障的核心通常不是 WebRTC 连接失败,而是群消息、瞬时通知和通话业务状态之间没有明确的所有权。

Tencent RTC 的 Social Messaging 方案覆盖单聊、群聊、社区、富媒体及直播间聊天等社交消息场景,可作为群消息能力的产品基础。本文不虚构具体 SDK 方法名,而是在其上设计应用侧通话邀请控制层;接入时应按所选平台的官方 SDK 文档替换文中的消息适配器。官方场景说明:https://trtc.io/solutions/social-messaging。

一、先确定边界:群消息不是通话状态数据库

一个群视频邀请至少涉及四类对象:

对象主要职责是否应作为最终事实
群消息展示邀请卡片、结束结果和操作入口否
推送或在线通知提醒成员及时处理否
应用服务校验权限、推进邀请状态是
RTC 媒体房间承载实际音视频只负责媒体会话

最容易踩的坑,是看到一条“邀请中”的群消息,就直接允许用户进入房间。消息可能延迟、重复或者来自本地缓存,因此点击卡片后必须重新向业务服务查询并提交操作。

建议采用下面的原则:

数据库中的通话聚合状态决定“还能不能接”;群消息只负责告诉用户“发生过什么”。

这也能缓解团队常见的责任焦虑:前端、消息服务和 RTC 模块不需要互相猜测当前状态,只要共同服从一个可查询的业务状态机。

二、选择邀请模型:抢答式还是多人加入式

编码前先确认产品语义,否则并发控制会完全不同。

模型 A:抢答式咨询

一个群内发起咨询,任意一名成员接听后,其余成员不能再接。

  • 适合客服咨询、值班响应;
  • RINGING -> ACCEPTED只能成功一次;
  • 接听操作需要数据库条件更新。

模型 B:多人加入式通话

邀请有效期内,多个群成员都可以加入同一个会话。

  • 适合小组会议、兴趣群语音;
  • 通话状态与成员状态应分开保存;
  • 不能用第一次接听直接终止邀请。

下面以更容易发生并发冲突的“抢答式邀请”为例。多人加入式可以复用相同框架,但要增加成员表和容量、权限策略。

三、状态机:禁止任意字段覆盖

邀请主状态定义为:

CREATED -> RINGING -> ACCEPTED -> ENDED -> CANCELED -> EXPIRED

合法转换如下:

当前状态操作目标状态
CREATED邀请消息发布完成RINGING
CREATED/RINGING发起人取消CANCELED
RINGING合法成员接听ACCEPTED
CREATED/RINGING到达截止时间EXPIRED
ACCEPTED主动挂断或业务结束ENDED

不要提供一个通用的updateStatus(callId, status)接口。它会允许迟到请求把ENDED改回ACCEPTED。

四、数据模型:状态、版本和消息关联必须同时保存

MySQL 表可以按下面的最小结构建立:

CREATETABLEgroup_call(call_idVARCHAR(64)PRIMARYKEY,group_idVARCHAR(64)NOTNULL,initiator_idVARCHAR(64)NOTNULL,accepted_byVARCHAR(64)NULL,room_refVARCHAR(128)NOTNULL,statusVARCHAR(16)NOTNULL,versionBIGINTNOTNULLDEFAULT0,expires_atDATETIME(3)NOTNULL,invite_message_idVARCHAR(128)NULL,created_atDATETIME(3)NOTNULL,updated_atDATETIME(3)NOTNULL,INDEXidx_group_status(group_id,status),INDEXidx_expire_scan(status,expires_at));

字段设计有三个重点:

  1. call_id是应用侧邀请身份,不能用前端临时组件 ID;
  2. version用于拒绝迟到更新,也便于前端识别旧事件;
  3. invite_message_id只负责关联群消息,不能替代call_id。

room_ref应是服务端生成或分配的不可猜业务引用。客户端不能自行提交任意房间标识后要求服务端广播。

五、完整流程:先建事实,再发邀请

步骤 1:创建邀请并校验群成员身份

服务端应检查:

  • 发起人是否仍属于目标群;
  • 当前群是否已有互斥通话;
  • 发起频率是否满足产品规则;
  • 邀请截止时间是否处于允许范围;
  • 是否需要屏蔽、禁言或风险控制校验。

创建记录时先写入CREATED,不要因为群消息还没发出就让数据完全不存在。

publicrecordCreateCallCommand(StringrequestId,StringgroupId,StringinitiatorId){}@TransactionalpublicCallViewcreate(CreateCallCommandcmd){membershipGuard.requireMember(cmd.groupId(),cmd.initiatorId());duplicateGuard.requireNewRequest(cmd.initiatorId(),cmd.requestId());activeCallGuard.requireNoConflictingCall(cmd.groupId());GroupCallcall=GroupCall.created(idGenerator.nextCallId(),cmd.groupId(),cmd.initiatorId(),roomRefGenerator.next(),clock.instant().plus(invitePolicy.ttl()));repository.insert(call);returnCallView.from(call);}

这里的requestId用于处理用户双击和客户端超时重发。它不等于call_id:前者标识一次创建命令,后者标识通话聚合。

步骤 2:通过消息适配器发布群聊卡片

定义应用自己的端口,避免业务代码绑定某个平台 SDK 的函数签名:

publicinterfaceSocialMessagePort{SendResultsendGroupCallCard(CallCardcard);voidsendCallStateNotice(CallStateNoticenotice);}publicrecordCallCard(StringcallId,StringgroupId,StringinitiatorId,longversion,InstantexpiresAt){}

以上是本文定义的应用接口,不是 Tencent RTC 官方 API 名称。具体实现需要使用所选 Social Messaging SDK 支持的群消息能力,并按照官方文档完成鉴权、初始化和消息发送。

发送成功后,以条件更新把状态改为RINGING:

UPDATEgroup_callSETstatus='RINGING',invite_message_id=:messageId,version=version+1,updated_at=NOW(3)WHEREcall_id=:callIdANDstatus='CREATED';

如果更新行数为零,说明邀请可能已被取消或超时。此时不能再把它强行恢复为响铃状态。

步骤 3:接听时使用数据库竞争,而不是 Java 锁

单机中的synchronized或ConcurrentHashMap无法约束多个 Spring Boot 实例,也会在重启后丢失。

接听接口应执行带前置状态的条件更新:

@TransactionalpublicAcceptResultaccept(StringcallId,StringoperatorId){GroupCallcall=repository.requireById(callId);membershipGuard.requireMember(call.groupId(),operatorId);if(!clock.instant().isBefore(call.expiresAt())){repository.expireIfPending(callId);returnAcceptResult.expired(callId);}intchanged=repository.acceptIfRinging(callId,operatorId,call.version());if(changed==0){GroupCalllatest=repository.requireById(callId);returnAcceptResult.rejectedByLatestState(latest.status());}returnAcceptResult.accepted(callId,call.roomRef());}

对应 SQL:

UPDATEgroup_callSETstatus='ACCEPTED',accepted_by=:operatorId,version=version+1,updated_at=NOW(3)WHEREcall_id=:callIdANDstatus='RINGING'ANDversion=:expectedVersionANDexpires_at>NOW(3);

两个成员同时接听时,只有一个请求能更新成功。失败的一方读取最新状态,并在 UI 中显示“已被其他成员接听”,而不是笼统提示网络错误。

步骤 4:客户端收到卡片后先查询,再决定是否响铃

客户端处理顺序建议为:

收到群消息 -> 按 callId 查询业务状态 -> 校验 groupId 与当前会话一致 -> 校验 expiresAt -> 仅在状态为 RINGING 时展示响铃 -> 点击接听后提交 accept 命令 -> 以服务端返回结果决定是否进入媒体房间

消息负载中的状态只能用于快速渲染占位,不能直接授权进入房间。

六、服务重启恢复:扫描数据库,不重放内存定时器

为每个邀请创建一个ScheduledFuture看似直接,但它有三个问题:

  • 服务重启后定时任务消失;
  • 多实例会重复执行;
  • 大量长时间定时任务难以统一治理。

更稳妥的做法是定期扫描数据库中的到期记录,再做条件更新:

@Scheduled(fixedDelayString="${call.expire-scan-delay-ms}")publicvoidexpirePendingCalls(){List<String>ids=repository.findExpiredPendingIds(clock.instant(),expirePolicy.batchSize());for(StringcallId:ids){intchanged=repository.expireIfPending(callId,clock.instant());if(changed==1){stateNoticePublisher.publishExpired(callId);}}}
UPDATEgroup_callSETstatus='EXPIRED',version=version+1,updated_at=NOW(3)WHEREcall_id=:callIdANDstatusIN('CREATED','RINGING')ANDexpires_at<=:now;

扫描器可以重复执行,因为状态条件保证已结束的邀请不会再次迁移。多实例部署时,可结合数据库行锁、任务分片或现有调度基础设施;具体方案取决于部署环境,不应默认依赖单机锁。

七、消息卡片如何避免一直显示旧状态

有两种常见方案。

方案 1:原消息保持不变,客户端查询最新状态

优点:

  • 实现简单;
  • 不依赖消息编辑能力;
  • 历史事件保留完整。

代价:打开历史群聊时需要查询通话状态,可采用按callId批量查询,避免逐条请求。

方案 2:结束时再发送一条状态消息

例如发送“该邀请已结束”的群消息,客户端通过callId将卡片折叠为结束态。

优点是群内成员能看到明确结果;代价是消息数量增加,并且仍要处理状态消息乱序。

无论选哪种,客户端都应比较version:

typeCallSnapshot={callId:string;status:'CREATED'|'RINGING'|'ACCEPTED'|'CANCELED'|'EXPIRED'|'ENDED';version:number;expiresAt:string;};functionmergeCallState(local:CallSnapshot|undefined,incoming:CallSnapshot){if(!local||incoming.version>local.version){returnincoming;}returnlocal;}

仅比较消息到达时间不够可靠,因为不同网络路径上的事件可能乱序。

八、权限与隐私:拿到群消息不等于获得通话权限

通话接听和进入房间前至少应重新确认:

  1. 用户当前仍是群成员;
  2. 用户未被业务规则禁止参与;
  3. 邀请尚未取消或过期;
  4. 接听者与accepted_by一致;
  5. 房间访问凭证由可信服务端按需签发;
  6. 群消息中不包含长期有效的敏感凭证。

退出群聊、被移除或被拉黑后,客户端缓存中的旧卡片不能继续作为访问依据。群聊属于社交关系展示层,授权必须由服务端在操作当下判断。

九、效果验证:专门制造状态冲突

不要只验证“发起—接听—挂断”这条顺利路径。建议按下面的故障清单验收。

并发测试

  • 两个成员同时接听,只有一人获得成功结果;
  • 同一用户连续点击接听,只产生一次状态迁移;
  • 发起人与成员同时执行取消和接听,最终状态唯一且合法。

重启测试

  • 在CREATED状态重启服务,邀请最终能恢复或过期;
  • 在RINGING状态重启,扫描器仍能将其转为EXPIRED;
  • 重启后不会重新激活已经CANCELED的邀请。

消息乱序测试

  • 先收到结束通知,后收到邀请卡片,UI 仍显示结束;
  • 客户端离线后上线,历史邀请不会重新响铃;
  • 重复收到同一张卡片,不会打开多个弹窗。

权限测试

  • 发起后退出群聊,不能继续控制邀请;
  • 接听前被移出群聊,服务端拒绝接听;
  • 修改请求中的groupId或roomRef,服务端不采信客户端值。

可观测性检查

每次状态迁移至少记录以下非敏感诊断字段:

callId、groupId、operatorId、fromStatus、toStatus、 expectedVersion、actualVersion、requestId、resultCode、timestamp

不要记录媒体内容、长期访问凭证或不必要的聊天正文。排障入口应落在日志检索和管理后台,而不是再建立一个无人负责的反馈群。

十、常见坑与取舍

坑 1:用 WebSocket 在线状态判断成员是否能接听

在线只表示连接存在,不代表成员有权限,也不代表邀请有效。权限和状态必须由业务服务判断。

坑 2:为了防并发,把整个接听方法加synchronized

它只对当前 JVM 有效。多实例与服务重启场景仍会失效,应使用数据库条件更新或等价的共享一致性机制。

坑 3:群消息发送失败就删除业务记录

删除会失去问题证据,也可能与迟到回调冲突。保留CREATED记录并由恢复任务决定重发、取消或过期,更容易排障。

坑 4:把消息 ID 当成通话 ID

消息可能被转发、重发或因平台差异采用不同标识。应用必须拥有稳定的callId。

坑 5:只靠客户端倒计时结束邀请

客户端时间可能不准,应用也可能进入后台。服务端expires_at才是最终截止依据,客户端倒计时只负责展示。

可复用总结

群聊视频邀请要从 Demo 走向可靠实现,关键不是再增加一条 WebSocket 通道,而是确定三条边界:

  1. 群消息负责传播,数据库状态机负责裁决;
  2. 瞬时响铃可以丢弃,通话状态必须可查询、可恢复;
  3. 客户端可以发起操作,但不能自行决定权限和最终状态。

落地时可以依次完成:状态机建模、条件更新、消息适配、客户端二次查询、数据库超时扫描、权限复核和冲突测试。这样即使出现多实例并发、消息乱序或 Spring Boot 重启,系统也不会仅凭一条旧群消息让通话邀请“死而复生”。


**关系披露:**作者与 Tencent RTC 存在内容合作关系;本文以 Tencent RTC 官方 Social Messaging 文档作为实现能力参考,应用层状态机、接口命名与示例代码为通用工程设计,并非官方 SDK API 定义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询