Commit c69e20d681e097c65ef9d161d125090f476720fb

Authored by YunaiV
1 parent 690a5499

✨ feat(im): RTC 接口拆 create / invite + 字段对齐 + 群关房语义升级

- refactor: inviteCall 拆成 createCall(创建新通话)/ inviteCall(追加邀请);VO `ImRtcCallInviteReqVO`(统一版)→ git mv 为 `ImRtcCallCreateReqVO`;恢复 + git mv `ImRtcCallInviteMoreReqVO` → `ImRtcCallInviteReqVO`(room + inviteeIds);Controller 端点 `/invite` → `/create` + 新 `/invite`;Service 内部拆 createGroupCall / createPrivateCall / createCall0
- refactor: VO 字段 `scene` → `conversationType`(CreateReqVO / RespVO 同步),与 ImConversationTypeEnum 对齐;`peerUserId` 融合进 `inviteeIds`,私聊必传 1 个对端从集合派生
- feat: 群通话关房语义升级;shouldCloseGroupRoom 改 `(JOINED==0) || (JOINED==1 && INVITING==0)`,1 人独守 + 无后续即关;endSessionIfTerminal 按 call.status 自动推 reason(CREATED→CANCEL 没真接通过 / RUNNING→HANGUP);rejectCall 群分支末尾触发关房判定,全员拒接自动收敛
- chore: 错误码新增 RTC_GROUP_INVITEE_REQUIRED;validateInviteScene → validateCreateCall;Controller `getLoginUserId` 抽局部变量;resolveDisplayName 抽私有 helper 复用 displayName 派生
- fix: pushCallInviteNotification 加 NPE 防御(getUserMap 缺键时降级为 userId 字符串);pushCallEndNotification 私聊 receiverId 去掉错误的 senderId 兜底
- chore: handleLiveKitEvent 改回传统 switch 兼容 JDK8;其它若干 Javadoc 补齐 / 注释精简 / typo 修复
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/ImRtcCallController.java
... ... @@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.im.controller.admin.rtc;
2 2  
3 3 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
4 4 import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
5   -import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO;
  5 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO;
6 6 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
7 7 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO;
8 8 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO;
... ... @@ -37,34 +37,35 @@ public class ImRtcCallController {
37 37 @Resource
38 38 private ImProperties imProperties;
39 39  
  40 + @PostMapping("/create")
  41 + @Operation(summary = "创建新通话;按 conversationType 区分私聊 / 群聊")
  42 + public CommonResult<ImRtcCallRespVO> createCall(@Valid @RequestBody ImRtcCallCreateReqVO reqVO) {
  43 + Long userId = getLoginUserId();
  44 + ImRtcCallDO call = rtcCallService.createCall(userId, reqVO);
  45 + return success(buildCallRespVO(call, userId));
  46 + }
  47 +
40 48 @PostMapping("/invite")
41   - @Operation(summary = "发起通话;私聊或群聊根据 scene 区分")
42   - public CommonResult<ImRtcCallRespVO> invite(@Valid @RequestBody ImRtcCallInviteReqVO reqVO) {
43   - ImRtcCallDO call = rtcCallService.inviteCall(getLoginUserId(), reqVO);
44   - return success(buildCallResp(call, getLoginUserId()));
  49 + @Operation(summary = "通话中追加邀请;仅群通话可用")
  50 + public CommonResult<Boolean> inviteCall(@Valid @RequestBody ImRtcCallInviteReqVO reqVO) {
  51 + rtcCallService.inviteCall(getLoginUserId(), reqVO);
  52 + return success(true);
45 53 }
46 54  
47 55 @PostMapping("/join")
48 56 @Operation(summary = "加入已有群通话;用于胶囊条「加入」按钮")
49 57 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567")
50 58 public CommonResult<ImRtcCallRespVO> joinCall(@RequestParam("room") String room) {
51   - ImRtcCallDO call = rtcCallService.joinCall(getLoginUserId(), room);
52   - return success(buildCallResp(call, getLoginUserId()));
53   - }
54   -
55   - @PostMapping("/invite-more")
56   - @Operation(summary = "通话中添加成员;仅群通话可用")
57   - public CommonResult<Boolean> inviteMore(@Valid @RequestBody ImRtcCallInviteMoreReqVO reqVO) {
58   - rtcCallService.inviteMoreCall(getLoginUserId(), reqVO);
59   - return success(true);
  59 + Long userId = getLoginUserId();
  60 + return success(buildCallRespVO(rtcCallService.joinCall(userId, room), userId));
60 61 }
61 62  
62 63 @PostMapping("/accept")
63 64 @Operation(summary = "接听通话")
64 65 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567")
65 66 public CommonResult<ImRtcCallRespVO> accept(@RequestParam("room") String room) {
66   - ImRtcCallDO call = rtcCallService.acceptCall(getLoginUserId(), room);
67   - return success(buildCallResp(call, getLoginUserId()));
  67 + Long userId = getLoginUserId();
  68 + return success(buildCallRespVO(rtcCallService.acceptCall(userId, room), userId));
68 69 }
69 70  
70 71 @PostMapping("/reject")
... ... @@ -95,8 +96,9 @@ public class ImRtcCallController {
95 96 @Operation(summary = "重新签发 Token;客户端重连或 Token 过期续期")
96 97 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567")
97 98 public CommonResult<ImRtcCallRespVO> refreshToken(@RequestParam("room") String room) {
98   - ImRtcCallDO call = rtcCallService.validateCallParticipant(getLoginUserId(), room);
99   - return success(buildCallResp(call, getLoginUserId()));
  99 + Long userId = getLoginUserId();
  100 + ImRtcCallDO call = rtcCallService.validateCallParticipant(userId, room);
  101 + return success(buildCallRespVO(call, userId));
100 102 }
101 103  
102 104 @GetMapping("/get-active-call")
... ... @@ -104,7 +106,7 @@ public class ImRtcCallController {
104 106 @Parameter(name = "groupId", description = "群编号", required = true, example = "2048")
105 107 public CommonResult<ImRtcGroupCallRespVO> getActiveCall(@RequestParam("groupId") Long groupId) {
106 108 ImRtcCallDO call = rtcCallService.getActiveCall(getLoginUserId(), groupId);
107   - return success(buildGroupActiveResp(call));
  109 + return success(buildGroupActiveRespVO(call));
108 110 }
109 111  
110 112 // ========== VO 拼装 ==========
... ... @@ -116,7 +118,7 @@ public class ImRtcCallController {
116 118 * @param userId 当前用户编号;token 按该用户签发
117 119 * @return 响应 VO;call 为空返回 null
118 120 */
119   - private ImRtcCallRespVO buildCallResp(ImRtcCallDO call, Long userId) {
  121 + private ImRtcCallRespVO buildCallRespVO(ImRtcCallDO call, Long userId) {
120 122 if (call == null) {
121 123 return null;
122 124 }
... ... @@ -125,7 +127,7 @@ public class ImRtcCallController {
125 127 .setRoom(call.getRoom())
126 128 .setLivekitUrl(imProperties.getRtc().getLivekitUrl())
127 129 .setToken(rtcCallService.signCallToken(userId, call.getRoom()))
128   - .setScene(call.getConversationType()).setMediaType(call.getMediaType())
  130 + .setConversationType(call.getConversationType()).setMediaType(call.getMediaType())
129 131 .setStatus(call.getStatus()).setInviterId(call.getInviterUserId())
130 132 .setGroupId(call.getGroupId())
131 133 .setInviteeIds(filterUserIds(participants, ImRtcParticipantStatusEnum.INVITING))
... ... @@ -133,20 +135,18 @@ public class ImRtcCallController {
133 135 }
134 136  
135 137 /**
136   - * 拼装 get-active-call 的响应 VO;只用于群聊胶囊条,不含 token
  138 + * 拼装 get-active-call 的响应 VO
137 139 *
138 140 * @param call 通话主表
139   - * @return 响应 VO;call 为空返回 null
  141 + * @return 响应 VO:只用于群聊胶囊条,不含 token
140 142 */
141   - private ImRtcGroupCallRespVO buildGroupActiveResp(ImRtcCallDO call) {
  143 + private ImRtcGroupCallRespVO buildGroupActiveRespVO(ImRtcCallDO call) {
142 144 if (call == null) {
143 145 return null;
144 146 }
145 147 List<ImRtcParticipantDO> participants = rtcCallService.getCallParticipantList(call.getRoom());
146   - return new ImRtcGroupCallRespVO()
147   - .setRoom(call.getRoom())
148   - .setGroupId(call.getGroupId()).setMediaType(call.getMediaType())
149   - .setInviterId(call.getInviterUserId())
  148 + return new ImRtcGroupCallRespVO().setRoom(call.getRoom()).setMediaType(call.getMediaType())
  149 + .setGroupId(call.getGroupId()).setInviterId(call.getInviterUserId())
150 150 .setJoinedUserIds(filterUserIds(participants, ImRtcParticipantStatusEnum.JOINED))
151 151 .setInviteeIds(filterUserIds(participants, ImRtcParticipantStatusEnum.INVITING));
152 152 }
... ... @@ -161,7 +161,7 @@ public class ImRtcCallController {
161 161 private static Set<Long> filterUserIds(List<ImRtcParticipantDO> participants,
162 162 ImRtcParticipantStatusEnum status) {
163 163 return CollectionUtils.convertLinkedSet(participants, ImRtcParticipantDO::getUserId,
164   - p -> Objects.equals(p.getStatus(), status.getStatus()));
  164 + participant -> Objects.equals(participant.getStatus(), status.getStatus()));
165 165 }
166 166  
167 167 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallCreateReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.rtc.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotNull;
  5 +import lombok.Data;
  6 +
  7 +import java.util.Set;
  8 +
  9 +@Schema(description = "管理后台 - 创建通话 Request VO")
  10 +@Data
  11 +public class ImRtcCallCreateReqVO {
  12 +
  13 + @Schema(description = "会话类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  14 + @NotNull(message = "会话类型不能为空")
  15 + private Integer conversationType; // 参见 ImConversationTypeEnum 枚举类
  16 +
  17 + @Schema(description = "媒体类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
  18 + @NotNull(message = "媒体类型不能为空")
  19 + private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类
  20 +
  21 + @Schema(description = "群编号;群聊场景必填", example = "2048")
  22 + private Long groupId;
  23 +
  24 + @Schema(description = "被邀请的用户编号集合;私聊必传 1 个对端,群聊必传至少 1 人")
  25 + private Set<Long> inviteeIds;
  26 +
  27 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteMoreReqVO.java deleted
1   -package cn.iocoder.yudao.module.im.controller.admin.rtc.vo;
2   -
3   -import io.swagger.v3.oas.annotations.media.Schema;
4   -import jakarta.validation.constraints.NotBlank;
5   -import jakarta.validation.constraints.NotEmpty;
6   -import lombok.Data;
7   -
8   -import java.util.Set;
9   -
10   -@Schema(description = "管理后台 - 通话中添加成员 Request VO")
11   -@Data
12   -public class ImRtcCallInviteMoreReqVO {
13   -
14   - @Schema(description = "业务通话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "f47ac10b58cc4372a567")
15   - @NotBlank(message = "通话编号不能为空")
16   - private String room;
17   -
18   - @Schema(description = "新邀请的用户编号集合", requiredMode = Schema.RequiredMode.REQUIRED)
19   - @NotEmpty(message = "请至少选择一位成员")
20   - private Set<Long> inviteeIds;
21   -
22   -}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallInviteReqVO.java
1 1 package cn.iocoder.yudao.module.im.controller.admin.rtc.vo;
2 2  
3 3 import io.swagger.v3.oas.annotations.media.Schema;
4   -import jakarta.validation.constraints.NotNull;
  4 +import jakarta.validation.constraints.NotBlank;
  5 +import jakarta.validation.constraints.NotEmpty;
5 6 import lombok.Data;
6 7  
7 8 import java.util.Set;
8 9  
9   -@Schema(description = "管理后台 - 发起通话 Request VO")
  10 +@Schema(description = "管理后台 - 通话中追加邀请 Request VO;仅群通话可用")
10 11 @Data
11 12 public class ImRtcCallInviteReqVO {
12 13  
13   - // TODO @AI:换成 conversationType
14   - @Schema(description = "会话场景", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
15   - @NotNull(message = "会话场景不能为空")
16   - private Integer scene; // 参见 ImConversationTypeEnum 枚举类
  14 + @Schema(description = "业务通话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "f47ac10b58cc4372a567")
  15 + @NotBlank(message = "通话编号不能为空")
  16 + private String room;
17 17  
18   - @Schema(description = "媒体类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
19   - @NotNull(message = "媒体类型不能为空")
20   - private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类
21   -
22   - // TODO @AI:是不是融合到 inviteeIds 里?
23   - @Schema(description = "对方用户编号;私聊场景必填", example = "1024")
24   - private Long peerUserId;
25   -
26   - @Schema(description = "群编号;群聊场景必填", example = "2048")
27   - private Long groupId;
28   -
29   - @Schema(description = "被邀请的用户编号集合;群聊场景由前端选成员后传入;为空时回退到拉群活跃成员")
  18 + @Schema(description = "新邀请的用户编号集合", requiredMode = Schema.RequiredMode.REQUIRED)
  19 + @NotEmpty(message = "请至少选择一位成员")
30 20 private Set<Long> inviteeIds;
31 21  
32 22 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/rtc/vo/ImRtcCallRespVO.java
... ... @@ -18,8 +18,8 @@ public class ImRtcCallRespVO {
18 18 @Schema(description = "LiveKit 接入 Token;需要时调 refreshToken 重新签发")
19 19 private String token;
20 20  
21   - @Schema(description = "会话场景")
22   - private Integer scene; // 参见 ImConversationTypeEnum 枚举类
  21 + @Schema(description = "会话类型")
  22 + private Integer conversationType; // 参见 ImConversationTypeEnum 枚举类
23 23  
24 24 @Schema(description = "媒体类型")
25 25 private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcCallEndReasonEnum.java
... ... @@ -20,6 +20,7 @@ public enum ImRtcCallEndReasonEnum implements ArrayValuable&lt;Integer&gt; {
20 20 HANGUP(1, "通话结束"), // 接通后任一方主动挂断
21 21 REJECT(2, "已拒绝"), // 被叫接通前点拒接
22 22 CANCEL(3, "已取消"), // 主叫接通前主动取消
  23 + // TODO @AI:思考时,会看到一条,【对方正忙】;目的是:私聊后续会看到这个通话,从而去回去联系。类似未接电话一样的味道。
23 24 BUSY(5, "对方正忙"), // 私聊呼叫时对方在另一通话
24 25 ERROR(9, "通话异常"); // 网络中断、设备失败等
25 26  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java
1 1 package cn.iocoder.yudao.module.im.service.rtc;
2 2  
3   -import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO;
  3 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO;
4 4 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
5 5 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
6 6 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
... ... @@ -9,21 +9,27 @@ import java.util.List;
9 9  
10 10 /**
11 11 * IM 实时通话 Service
12   - * <p>
13   - * 业务模型:一个好友对 / 一个群同时只能有一个进行中的通话;inviteCall 仅负责「创建新通话」,加入已有群通话走 {@link #joinCall}
14 12 *
15 13 * @author 芋道源码
16 14 */
17 15 public interface ImRtcCallService {
18 16  
19 17 /**
20   - * 发起新通话;同好友对 / 同群已有进行中通话直接抛错(群通话场景应改走 {@link #joinCall})
  18 + * 创建新通话;同好友对 / 同群已有进行中通话直接抛错(群场景应改走 {@link #inviteCall} 追加邀请,或 {@link #joinCall} 加入旁观)
21 19 *
22   - * @param userId 操作人编号;通常是当前登录用户
23   - * @param reqVO 请求参数(scene / mediaType / peerUserId 或 groupId)
  20 + * @param userId 发起人编号;通常是当前登录用户
  21 + * @param reqVO 请求参数(scene / mediaType / peerUserId 或 groupId + inviteeIds)
24 22 * @return 通话主表
25 23 */
26   - ImRtcCallDO inviteCall(Long userId, ImRtcCallInviteReqVO reqVO);
  24 + ImRtcCallDO createCall(Long userId, ImRtcCallCreateReqVO reqVO);
  25 +
  26 + /**
  27 + * 通话中追加邀请:仅群通话场景可用;本人必须是房内 JOINED 参与者;给新邀请人推 RTC_CALL(INVITE)
  28 + *
  29 + * @param userId 操作人编号;必须是当前会话参与者
  30 + * @param reqVO room + 新邀请的用户编号集合
  31 + */
  32 + void inviteCall(Long userId, ImRtcCallInviteReqVO reqVO);
27 33  
28 34 /**
29 35 * 加入已有群通话:用于群胶囊条「加入」按钮;旁观者作为 ACTIVE_JOIN 加入,邀请池内成员转 JOINED
... ... @@ -35,14 +41,6 @@ public interface ImRtcCallService {
35 41 ImRtcCallDO joinCall(Long userId, String room);
36 42  
37 43 /**
38   - * 通话中追加成员:仅群通话场景可用;给新邀请人推 RTC_CALL(INVITE)
39   - *
40   - * @param userId 操作人编号;必须是当前会话参与者
41   - * @param reqVO room + 新邀请的用户编号集合
42   - */
43   - void inviteMoreCall(Long userId, ImRtcCallInviteMoreReqVO reqVO);
44   -
45   - /**
46 44 * 接听通话:参与者 INVITING → JOINED;主表 CREATED → RUNNING(首次有非发起人接通时)
47 45 *
48 46 * @param userId 接听者用户编号
... ... @@ -117,8 +115,8 @@ public interface ImRtcCallService {
117 115 /**
118 116 * 处理 LiveKit Webhook 事件;用于关 tab / 强杀 / 网络断开等异常退出场景的兜底清理
119 117 * <p>
120   - * 关键事件:participant_left(成员离开) / room_finished(房间结束)。前端正常 leave 时
121   - * 也会触发同样的 LiveKit 事件;此处需做幂等处理,session 已被业务接口移除时直接忽略。
  118 + * 关键事件:participant_left(成员离开) / room_finished(房间结束)。
  119 + * 前端正常 leave 时,也会触发同样的 LiveKit 事件;此处需做幂等处理,session 已被业务接口移除时直接忽略。
122 120 *
123 121 * @param event LiveKit Webhook 事件
124 122 */
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
... ... @@ -5,7 +5,7 @@ import cn.hutool.core.util.IdUtil;
5 5 import cn.hutool.core.util.ObjUtil;
6 6 import cn.hutool.core.util.StrUtil;
7 7 import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
8   -import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteMoreReqVO;
  8 +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO;
9 9 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
10 10 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
11 11 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
... ... @@ -98,66 +98,101 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
98 98  
99 99 @Override
100 100 @SneakyThrows
101   - public ImRtcCallDO inviteCall(Long userId, ImRtcCallInviteReqVO reqVO) {
  101 + public ImRtcCallDO createCall(Long userId, ImRtcCallCreateReqVO reqVO) {
102 102 validateEnabled();
103 103 // 1. 校验入参与场景
104   - validateInviteScene(userId, reqVO);
  104 + validateCreateCall(userId, reqVO);
105 105  
106 106 // 2. 加锁后跑业务主体;同好友对 / 同群串行,避免并发各开一通的竞态
107   - if (ImConversationTypeEnum.isGroup(reqVO.getScene())) {
108   - return rtcCallLockRedisDAO.lockGroup(reqVO.getGroupId(), () -> inviteCall0(userId, reqVO));
  107 + if (ImConversationTypeEnum.isGroup(reqVO.getConversationType())) {
  108 + return rtcCallLockRedisDAO.lockGroup(reqVO.getGroupId(), () -> createGroupCall(userId, reqVO));
109 109 }
110   - return rtcCallLockRedisDAO.lockPrivate(userId, reqVO.getPeerUserId(), () -> inviteCall0(userId, reqVO));
  110 + Long peerUserId = CollUtil.getFirst(reqVO.getInviteeIds());
  111 + return rtcCallLockRedisDAO.lockPrivate(userId, peerUserId, () -> createPrivateCall(userId, reqVO, peerUserId));
111 112 }
112 113  
113 114 /**
114   - * invite 锁内主体:按场景先做活跃通话 / 对端忙线校验,再做发起人自身忙线校验,最后建通话
  115 + * 群通话创建锁内主体:群有活跃通话引导走 inviteCall / joinCall;无则兜底自身忙线后新建
115 116 *
116 117 * @param userId 发起人编号
117   - * @param reqVO 发起请求
  118 + * @param reqVO 创建请求
118 119 * @return 通话主表
119 120 */
120   - private ImRtcCallDO inviteCall0(Long userId, ImRtcCallInviteReqVO reqVO) {
121   - // 1.1 按场景检测活跃通话
122   - if (ImConversationTypeEnum.isGroup(reqVO.getScene())) {
123   - // 群通话已存在则引导走 joinCall(旁观者加入按钮),不再开新通话
124   - ImRtcCallDO active = rtcCallMapper.selectLastOneByGroupIdAndStatusIn(
125   - reqVO.getGroupId(), ImRtcCallStatusEnum.ACTIVE_STATUSES);
126   - if (active != null) {
127   - throw exception(RTC_GROUP_CALL_ACTIVE);
128   - }
129   - } else {
130   - // 私聊已在通话中说明发起人自己已是参与者,当作自身忙线
131   - ImRtcCallDO active = getActivePrivateCallByPair(userId, reqVO.getPeerUserId());
132   - if (active != null) {
133   - throw exception(RTC_SELF_BUSY);
134   - }
135   - // 私聊额外检查对端忙线
136   - ImRtcParticipantDO peerActive = rtcParticipantMapper.selectLastOneByUserIdAndStatus(
137   - reqVO.getPeerUserId(), ImRtcParticipantStatusEnum.ACTIVE_STATUSES);
138   - if (peerActive != null) {
139   - throw exception(RTC_PEER_BUSY);
140   - }
  121 + private ImRtcCallDO createGroupCall(Long userId, ImRtcCallCreateReqVO reqVO) {
  122 + ImRtcCallDO active = rtcCallMapper.selectLastOneByGroupIdAndStatusIn(
  123 + reqVO.getGroupId(), ImRtcCallStatusEnum.ACTIVE_STATUSES);
  124 + if (active != null) {
  125 + throw exception(RTC_GROUP_CALL_ACTIVE);
141 126 }
142   - // 1.2 兜底校验:发起人在另一通通话中(跨好友 / 跨群)
  127 + // 兜底校验发起人不在另一通通话中(跨群 / 跨好友)
143 128 ImRtcParticipantDO selfActive = rtcParticipantMapper.selectLastOneByUserIdAndStatus(
144 129 userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES);
145 130 if (selfActive != null) {
146 131 throw exception(RTC_SELF_BUSY);
147 132 }
  133 + return createCall0(userId, reqVO);
  134 + }
148 135  
  136 + /**
  137 + * 私聊创建锁内主体:检查双方忙线后新建
  138 + *
  139 + * @param userId 发起人编号
  140 + * @param reqVO 创建请求
  141 + * @param peerUserId 对端编号;来自 reqVO.inviteeIds 的唯一元素
  142 + * @return 通话主表
  143 + */
  144 + private ImRtcCallDO createPrivateCall(Long userId, ImRtcCallCreateReqVO reqVO, Long peerUserId) {
  145 + // 1.1 发起人 & 对端已在同一通话 → 视为自身忙线
  146 + ImRtcCallDO active = getActivePrivateCallByPair(userId, peerUserId);
  147 + if (active != null) {
  148 + throw exception(RTC_SELF_BUSY);
  149 + }
  150 + // 1.2 对端忙线
  151 + ImRtcParticipantDO peerActive = rtcParticipantMapper.selectLastOneByUserIdAndStatus(
  152 + peerUserId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES);
  153 + if (peerActive != null) {
  154 + throw exception(RTC_PEER_BUSY);
  155 + }
  156 + // 1.3 发起人在另一通通话(跨好友 / 跨群兜底)
  157 + ImRtcParticipantDO selfActive = rtcParticipantMapper.selectLastOneByUserIdAndStatus(
  158 + userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES);
  159 + if (selfActive != null) {
  160 + throw exception(RTC_SELF_BUSY);
  161 + }
149 162 // 2. 新建通话
150   - return createCall(userId, reqVO);
  163 + return createCall0(userId, reqVO);
  164 + }
  165 +
  166 + @Override
  167 + @SneakyThrows
  168 + public void inviteCall(Long userId, ImRtcCallInviteReqVO reqVO) {
  169 + validateEnabled();
  170 + // 1.1 校验通话存在且活跃
  171 + ImRtcCallDO call = validateCallActive(reqVO.getRoom());
  172 + // 1.2 仅群通话支持追加邀请
  173 + if (!ImConversationTypeEnum.isGroup(call.getConversationType())) {
  174 + throw exception(RTC_GROUP_REQUIRED);
  175 + }
  176 + // 1.3 操作者必须是房内 JOINED 参与者
  177 + ImRtcParticipantDO operator = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId);
  178 + if (operator == null || !ImRtcParticipantStatusEnum.isJoined(operator.getStatus())) {
  179 + throw exception(RTC_NOT_PARTICIPANT);
  180 + }
  181 + // 2. 加群锁后执行追加邀请;避免与新建 / 重复追加的竞态
  182 + rtcCallLockRedisDAO.lockGroup(call.getGroupId(), () -> {
  183 + addInvitees(call, userId, reqVO.getInviteeIds());
  184 + return null;
  185 + });
151 186 }
152 187  
153 188 /**
154   - * 新建通话
  189 + * 新建通话实体;INSERT 主表 + 参与表 + 推送 INVITE / START
155 190 *
156 191 * @param inviterId 发起人编号
157   - * @param reqVO 发起请求
  192 + * @param reqVO 创建请求
158 193 * @return 通话主表
159 194 */
160   - private ImRtcCallDO createCall(Long inviterId, ImRtcCallInviteReqVO reqVO) {
  195 + private ImRtcCallDO createCall0(Long inviterId, ImRtcCallCreateReqVO reqVO) {
161 196 // 1. 构造参数:room 用 UUID;解析被邀请池
162 197 String room = IdUtil.fastSimpleUUID();
163 198 LocalDateTime now = LocalDateTime.now();
... ... @@ -165,7 +200,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
165 200  
166 201 // 2.1 INSERT 主表;群聊发起人即时 JOINED 但通话仍处 CREATED,等首个非发起人接通才切 RUNNING
167 202 ImRtcCallDO call = new ImRtcCallDO().setRoom(room)
168   - .setConversationType(reqVO.getScene()).setMediaType(reqVO.getMediaType())
  203 + .setConversationType(reqVO.getConversationType()).setMediaType(reqVO.getMediaType())
169 204 .setInviterUserId(inviterId).setGroupId(reqVO.getGroupId())
170 205 .setStatus(ImRtcCallStatusEnum.CREATED.getStatus()).setStartTime(now);
171 206 rtcCallMapper.insert(call);
... ... @@ -191,7 +226,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
191 226 pushCallInviteNotification(call, inviterUser, inviteeId, inviteeMap.get(inviteeId));
192 227 }
193 228 // 3.2 群通话再向全群广播 RTC_CALL_START 入聊天流
194   - if (ImConversationTypeEnum.isGroup(reqVO.getScene())) {
  229 + if (ImConversationTypeEnum.isGroup(reqVO.getConversationType())) {
195 230 pushCallStartNotification(call, inviterUser);
196 231 }
197 232 return call;
... ... @@ -211,7 +246,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
211 246 LocalDateTime now = LocalDateTime.now();
212 247 ImRtcParticipantDO existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId);
213 248 if (existing != null) {
214   - // 非 JOINED 状态切回 JOINED + accept_time;INVITER / INVITEE 重连 / 旁观点胶囊条都走这条
  249 + // 非 JOINED 状态切回 JOINED + accept_time;INVITER / INVITEE 重连、旁观者点击胶囊条加入,都走这条
215 250 if (!ImRtcParticipantStatusEnum.isJoined(existing.getStatus())) {
216 251 rtcParticipantMapper.updateById(new ImRtcParticipantDO().setId(existing.getId())
217 252 .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setAcceptTime(now));
... ... @@ -228,26 +263,20 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
228 263 return call;
229 264 }
230 265  
231   - @Override
232   - public void inviteMoreCall(Long userId, ImRtcCallInviteMoreReqVO reqVO) {
233   - validateEnabled();
234   - // 1.1 校验通话存在且活跃
235   - ImRtcCallDO call = validateCallActive(reqVO.getRoom());
236   - // 1.2 仅群通话支持追加邀请
237   - if (!ImConversationTypeEnum.isGroup(call.getConversationType())) {
238   - throw exception(RTC_GROUP_REQUIRED);
239   - }
240   - // 1.3 操作者必须是已在房间的参与者
241   - ImRtcParticipantDO operator = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId);
242   - if (operator == null || !ImRtcParticipantStatusEnum.isJoined(operator.getStatus())) {
243   - throw exception(RTC_NOT_PARTICIPANT);
244   - }
245   - // 1.4 校验被邀请人必须是该群活跃成员;非本群成员直接抛
246   - groupMemberService.validateMembersInGroup(call.getGroupId(), reqVO.getInviteeIds());
247   - // 1.5 排除已在通话池的;剩余即本次新邀请
  266 + /**
  267 + * 给已存在的活跃群通话追加邀请;批量校验群成员 + 去重已在通话池 + 推送 INVITE
  268 + *
  269 + * @param call 活跃通话主表
  270 + * @param inviterId 本次追加邀请的发起人;已是房内 JOINED 参与者
  271 + * @param inviteeIds 本次追加的被邀请人编号
  272 + */
  273 + private void addInvitees(ImRtcCallDO call, Long inviterId, Collection<Long> inviteeIds) {
  274 + // 1.1 校验被邀请人都是群活跃成员
  275 + groupMemberService.validateMembersInGroup(call.getGroupId(), inviteeIds);
  276 + // 1.2 排除已在通话池的;剩余即本次新邀请
248 277 Set<Long> existingUserIds = CollectionUtils.convertSet(
249 278 rtcParticipantMapper.selectListByRoom(call.getRoom()), ImRtcParticipantDO::getUserId);
250   - Set<Long> incomingUserIds = new LinkedHashSet<>(reqVO.getInviteeIds());
  279 + Set<Long> incomingUserIds = new LinkedHashSet<>(inviteeIds);
251 280 incomingUserIds.removeAll(existingUserIds);
252 281 if (CollUtil.isEmpty(incomingUserIds)) {
253 282 return;
... ... @@ -262,7 +291,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
262 291 rtcParticipantMapper.insertBatch(participants);
263 292  
264 293 // 3. 推送通知:RTC_CALL(INVITE) 给每个新邀请人
265   - AdminUserRespDTO inviter = adminUserApi.getUser(userId);
  294 + AdminUserRespDTO inviter = adminUserApi.getUser(inviterId);
266 295 Map<Long, AdminUserRespDTO> inviteeMap = adminUserApi.getUserMap(incomingUserIds);
267 296 for (Long inviteeId : incomingUserIds) {
268 297 pushCallInviteNotification(call, inviter, inviteeId, inviteeMap.get(inviteeId));
... ... @@ -316,10 +345,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
316 345 return;
317 346 }
318 347  
319   - // 3.1 群通话拒绝:仅推 RTC_CALL(REJECT) 给主叫;不影响通话整体
320   - // TODO @AI:群聊如果拒绝,需要做类似的 endSession 逻辑么?
  348 + // 3.1 群通话拒绝:推 RTC_CALL(REJECT) 给主叫,再判定是否要关房(全员拒接 / 只剩主叫一人时收敛)
321 349 if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
322 350 pushCallRejectNotification(call, userId);
  351 + endSessionIfTerminal(call, userId);
323 352 return;
324 353 }
325 354 // 3.2 私聊拒绝:走 endSession(推 RTC_CALL_END(reason=REJECT))
... ... @@ -359,23 +388,32 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
359 388  
360 389 // 3. 触发关房判定:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关
361 390 // 群通话单人离开:LiveKit `ParticipantDisconnected` 自动通知房内成员;业务后端不另外推
362   - endSessionIfTerminal(call, userId, ImRtcCallEndReasonEnum.HANGUP);
  391 + endSessionIfTerminal(call, userId);
363 392 }
364 393  
365 394 /**
366   - * 群通话是否应该关闭:房内没有 JOINED 就关
  395 + * 群通话是否应该关闭
367 396 * <p>
368   - * 群通话至少需要 1 个 JOINED 在房才有意义;只剩 INVITING(响铃中)也无价值
  397 + * 两种终态:
  398 + * 1. JOINED 数 = 0(房内已没人)
  399 + * 2. JOINED 数 = 1 且 INVITING 数 = 0(只剩 1 人独守,无后续可加入者)
369 400 * <p>
370 401 * 关房后 endSession 会把残留 INVITING 批量改 NO_ANSWER 并推 RTC_CALL_END,响铃端 UI 自动收敛
371 402 *
372 403 * @param room 业务通话编号
373   - * @return true 表示房内已无 JOINED,需要关房
  404 + * @return true 表示无法继续通话,应关房
374 405 */
375 406 private boolean shouldCloseGroupRoom(String room) {
376   - List<ImRtcParticipantDO> joined = rtcParticipantMapper.selectListByRoomAndStatus(room,
377   - ImRtcParticipantStatusEnum.JOINED.getStatus());
378   - return CollUtil.isEmpty(joined);
  407 + int joined = 0;
  408 + int inviting = 0;
  409 + for (ImRtcParticipantDO p : rtcParticipantMapper.selectListByRoom(room)) {
  410 + if (ImRtcParticipantStatusEnum.isJoined(p.getStatus())) {
  411 + joined++;
  412 + } else if (ImRtcParticipantStatusEnum.isInviting(p.getStatus())) {
  413 + inviting++;
  414 + }
  415 + }
  416 + return joined == 0 || (joined == 1 && inviting == 0);
379 417 }
380 418  
381 419 /**
... ... @@ -432,11 +470,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
432 470  
433 471 @Override
434 472 public String signCallToken(Long userId, String room) {
435   - AdminUserRespDTO user = adminUserApi.getUser(userId);
436   - // displayName 不能为空;nickname 缺失时降级为 userId 字符串
437   - String displayName = StrUtil.blankToDefault(
438   - user == null ? null : user.getNickname(), String.valueOf(userId));
439   - return signToken(userId, displayName, room);
  473 + return signToken(userId, resolveDisplayName(adminUserApi.getUser(userId), userId), room);
440 474 }
441 475  
442 476 @Override
... ... @@ -518,7 +552,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
518 552 // 3. 推 1603 通知参与方 / 全群参与方
519 553 pushParticipantDisconnectedNotification(call, userId);
520 554 // 4. 触发关房判定:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关
521   - endSessionIfTerminal(call, userId, ImRtcCallEndReasonEnum.HANGUP);
  555 + endSessionIfTerminal(call, userId);
522 556 }
523 557  
524 558 /**
... ... @@ -610,37 +644,43 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
610 644  
611 645 /**
612 646 * 关房判定收口:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关
  647 + * <p>
  648 + * end reason 按 call.status 自动推:CREATED(没人接通过)= CANCEL;RUNNING(有人接通过)= HANGUP
613 649 *
614 650 * @param call 通话主表
615 651 * @param operatorId 操作者用户编号;webhook 兜底场景可空
616   - * @param reason 结束原因
617 652 */
618   - private void endSessionIfTerminal(ImRtcCallDO call, Long operatorId, ImRtcCallEndReasonEnum reason) {
619   - if (ImConversationTypeEnum.isPrivate(call.getConversationType())
620   - || shouldCloseGroupRoom(call.getRoom())) {
621   - endSession(call, operatorId, reason);
  653 + private void endSessionIfTerminal(ImRtcCallDO call, Long operatorId) {
  654 + if (!ImConversationTypeEnum.isPrivate(call.getConversationType())
  655 + && !shouldCloseGroupRoom(call.getRoom())) {
  656 + return;
622 657 }
  658 + ImRtcCallEndReasonEnum reason = ImRtcCallStatusEnum.isCreated(call.getStatus())
  659 + ? ImRtcCallEndReasonEnum.CANCEL : ImRtcCallEndReasonEnum.HANGUP;
  660 + endSession(call, operatorId, reason);
623 661 }
624 662  
625 663 /**
626   - * 校验 invite 入参;按场景区分必填字段,私聊补好友校验,群聊补群成员校验
  664 + * 校验创建通话入参;按场景区分必填字段,私聊补好友校验,群聊补群成员校验
627 665 *
628 666 * @param userId 发起人编号
629   - * @param reqVO 发起请求
  667 + * @param reqVO 创建请求
630 668 */
631   - private void validateInviteScene(Long userId, ImRtcCallInviteReqVO reqVO) {
632   - Integer scene = reqVO.getScene();
633   - if (ImConversationTypeEnum.isPrivate(scene)) {
634   - if (reqVO.getPeerUserId() == null) {
  669 + private void validateCreateCall(Long userId, ImRtcCallCreateReqVO reqVO) {
  670 + Integer conversationType = reqVO.getConversationType();
  671 + if (ImConversationTypeEnum.isPrivate(conversationType)) {
  672 + // 私聊必须 1 个对端
  673 + if (CollUtil.size(reqVO.getInviteeIds()) != 1) {
635 674 throw exception(RTC_PRIVATE_INVITEE_REQUIRED);
636 675 }
637   - if (ObjUtil.equal(userId, reqVO.getPeerUserId())) {
  676 + Long peerUserId = CollUtil.getFirst(reqVO.getInviteeIds());
  677 + if (ObjUtil.equal(userId, peerUserId)) {
638 678 throw exception(RTC_INVITE_SELF);
639 679 }
640   - friendService.validateFriend(userId, reqVO.getPeerUserId());
  680 + friendService.validateFriend(userId, peerUserId);
641 681 return;
642 682 }
643   - if (ImConversationTypeEnum.isGroup(scene)) {
  683 + if (ImConversationTypeEnum.isGroup(conversationType)) {
644 684 if (reqVO.getGroupId() == null) {
645 685 throw exception(RTC_GROUP_REQUIRED);
646 686 }
... ... @@ -651,20 +691,20 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
651 691 groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId);
652 692 return;
653 693 }
654   - throw new IllegalArgumentException("非法的 scene: " + scene);
  694 + throw new IllegalArgumentException("非法的 conversationType: " + conversationType);
655 695 }
656 696  
657 697 /**
658 698 * 解析被邀请池:私聊为 peerUserId 单元素;群聊为前端选中子集(超量抛错)
659 699 *
660   - * @param reqVO 发起请求
  700 + * @param reqVO 创建请求
661 701 * @param inviterId 发起人编号;自己不进被邀请池
662 702 * @return 被邀请人 userId 集合
663 703 */
664   - private Set<Long> resolveInvitees(ImRtcCallInviteReqVO reqVO, Long inviterId) {
665   - // 1. 私聊校验
666   - if (ImConversationTypeEnum.isPrivate(reqVO.getScene())) {
667   - return CollUtil.newLinkedHashSet(reqVO.getPeerUserId());
  704 + private Set<Long> resolveInvitees(ImRtcCallCreateReqVO reqVO, Long inviterId) {
  705 + // 1. 私聊:inviteeIds 已在 validateCreateCall 校验仅 1 个对端,直接复用
  706 + if (ImConversationTypeEnum.isPrivate(reqVO.getConversationType())) {
  707 + return new LinkedHashSet<>(reqVO.getInviteeIds());
668 708 }
669 709  
670 710 // 2. 群聊校验:被邀请人必须是该群活跃成员,防止恶意客户端塞任意 userId
... ... @@ -776,10 +816,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
776 816 */
777 817 private void pushCallInviteNotification(ImRtcCallDO call, AdminUserRespDTO inviter,
778 818 Long inviteeId, AdminUserRespDTO invitee) {
779   - String displayName = invitee != null
780   - ? StrUtil.blankToDefault(invitee.getNickname(), String.valueOf(inviteeId))
781   - : String.valueOf(inviteeId);
782   - String token = signToken(inviteeId, displayName, call.getRoom());
  819 + String token = signToken(inviteeId, resolveDisplayName(invitee, inviteeId), call.getRoom());
783 820 ImRtcCallNotification payload = ImRtcCallNotification.ofInvite(
784 821 call, inviter, imProperties.getRtc().getLivekitUrl(), token);
785 822 webSocketService.sendPrivateMessageAsync(inviteeId, ImPrivateMessageDTO.ofRtcNotification(
... ... @@ -901,4 +938,16 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
901 938 return liveKitClient.signJoinToken(liveKitClient.buildIdentity(userId), displayName, room);
902 939 }
903 940  
  941 + /**
  942 + * 解析房内显示名:优先取 user.nickname,缺失时降级为 userId 字符串(LiveKit displayName 不可空)
  943 + *
  944 + * @param user 用户信息;可空
  945 + * @param userId 用户编号;displayName 兜底
  946 + * @return 房内显示名
  947 + */
  948 + private static String resolveDisplayName(AdminUserRespDTO user, Long userId) {
  949 + return StrUtil.blankToDefault(
  950 + user == null ? null : user.getNickname(), String.valueOf(userId));
  951 + }
  952 +
904 953 }
... ...