
群聊、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邀请消息发布完成RINGINGCREATED/RINGING发起人取消CANCELEDRINGING合法成员接听ACCEPTEDCREATED/RINGING到达截止时间EXPIREDACCEPTED主动挂断或业务结束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));字段设计有三个重点call_id是应用侧邀请身份不能用前端临时组件 IDversion用于拒绝迟到更新也便于前端识别旧事件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());GroupCallcallGroupCall.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 支持的群消息能力并按照官方文档完成鉴权、初始化和消息发送。发送成功后以条件更新把状态改为RINGINGUPDATEgroup_callSETstatusRINGING,invite_message_id:messageId,versionversion1,updated_atNOW(3)WHEREcall_id:callIdANDstatusCREATED;如果更新行数为零说明邀请可能已被取消或超时。此时不能再把它强行恢复为响铃状态。步骤 3接听时使用数据库竞争而不是 Java 锁单机中的synchronized或ConcurrentHashMap无法约束多个 Spring Boot 实例也会在重启后丢失。接听接口应执行带前置状态的条件更新TransactionalpublicAcceptResultaccept(StringcallId,StringoperatorId){GroupCallcallrepository.requireById(callId);membershipGuard.requireMember(call.groupId(),operatorId);if(!clock.instant().isBefore(call.expiresAt())){repository.expireIfPending(callId);returnAcceptResult.expired(callId);}intchangedrepository.acceptIfRinging(callId,operatorId,call.version());if(changed0){GroupCalllatestrepository.requireById(callId);returnAcceptResult.rejectedByLatestState(latest.status());}returnAcceptResult.accepted(callId,call.roomRef());}对应 SQLUPDATEgroup_callSETstatusACCEPTED,accepted_by:operatorId,versionversion1,updated_atNOW(3)WHEREcall_id:callIdANDstatusRINGINGANDversion:expectedVersionANDexpires_atNOW(3);两个成员同时接听时只有一个请求能更新成功。失败的一方读取最新状态并在 UI 中显示“已被其他成员接听”而不是笼统提示网络错误。步骤 4客户端收到卡片后先查询再决定是否响铃客户端处理顺序建议为收到群消息 - 按 callId 查询业务状态 - 校验 groupId 与当前会话一致 - 校验 expiresAt - 仅在状态为 RINGING 时展示响铃 - 点击接听后提交 accept 命令 - 以服务端返回结果决定是否进入媒体房间消息负载中的状态只能用于快速渲染占位不能直接授权进入房间。六、服务重启恢复扫描数据库不重放内存定时器为每个邀请创建一个ScheduledFuture看似直接但它有三个问题服务重启后定时任务消失多实例会重复执行大量长时间定时任务难以统一治理。更稳妥的做法是定期扫描数据库中的到期记录再做条件更新Scheduled(fixedDelayString${call.expire-scan-delay-ms})publicvoidexpirePendingCalls(){ListStringidsrepository.findExpiredPendingIds(clock.instant(),expirePolicy.batchSize());for(StringcallId:ids){intchangedrepository.expireIfPending(callId,clock.instant());if(changed1){stateNoticePublisher.publishExpired(callId);}}}UPDATEgroup_callSETstatusEXPIRED,versionversion1,updated_atNOW(3)WHEREcall_id:callIdANDstatusIN(CREATED,RINGING)ANDexpires_at:now;扫描器可以重复执行因为状态条件保证已结束的邀请不会再次迁移。多实例部署时可结合数据库行锁、任务分片或现有调度基础设施具体方案取决于部署环境不应默认依赖单机锁。七、消息卡片如何避免一直显示旧状态有两种常见方案。方案 1原消息保持不变客户端查询最新状态优点实现简单不依赖消息编辑能力历史事件保留完整。代价打开历史群聊时需要查询通话状态可采用按callId批量查询避免逐条请求。方案 2结束时再发送一条状态消息例如发送“该邀请已结束”的群消息客户端通过callId将卡片折叠为结束态。优点是群内成员能看到明确结果代价是消息数量增加并且仍要处理状态消息乱序。无论选哪种客户端都应比较versiontypeCallSnapshot{callId:string;status:CREATED|RINGING|ACCEPTED|CANCELED|EXPIRED|ENDED;version:number;expiresAt:string;};functionmergeCallState(local:CallSnapshot|undefined,incoming:CallSnapshot){if(!local||incoming.versionlocal.version){returnincoming;}returnlocal;}仅比较消息到达时间不够可靠因为不同网络路径上的事件可能乱序。八、权限与隐私拿到群消息不等于获得通话权限通话接听和进入房间前至少应重新确认用户当前仍是群成员用户未被业务规则禁止参与邀请尚未取消或过期接听者与accepted_by一致房间访问凭证由可信服务端按需签发群消息中不包含长期有效的敏感凭证。退出群聊、被移除或被拉黑后客户端缓存中的旧卡片不能继续作为访问依据。群聊属于社交关系展示层授权必须由服务端在操作当下判断。九、效果验证专门制造状态冲突不要只验证“发起—接听—挂断”这条顺利路径。建议按下面的故障清单验收。并发测试两个成员同时接听只有一人获得成功结果同一用户连续点击接听只产生一次状态迁移发起人与成员同时执行取消和接听最终状态唯一且合法。重启测试在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 通道而是确定三条边界群消息负责传播数据库状态机负责裁决瞬时响铃可以丢弃通话状态必须可查询、可恢复客户端可以发起操作但不能自行决定权限和最终状态。落地时可以依次完成状态机建模、条件更新、消息适配、客户端二次查询、数据库超时扫描、权限复核和冲突测试。这样即使出现多实例并发、消息乱序或 Spring Boot 重启系统也不会仅凭一条旧群消息让通话邀请“死而复生”。**关系披露**作者与 Tencent RTC 存在内容合作关系本文以 Tencent RTC 官方 Social Messaging 文档作为实现能力参考应用层状态机、接口命名与示例代码为通用工程设计并非官方 SDK API 定义。