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,7 +2,7 @@ package cn.iocoder.yudao.module.im.controller.admin.rtc;
2 2
3 import cn.iocoder.yudao.framework.common.pojo.CommonResult; 3 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
4 import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; 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 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; 6 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
7 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO; 7 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallRespVO;
8 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO; 8 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcGroupCallRespVO;
@@ -37,34 +37,35 @@ public class ImRtcCallController { @@ -37,34 +37,35 @@ public class ImRtcCallController {
37 @Resource 37 @Resource
38 private ImProperties imProperties; 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 @PostMapping("/invite") 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 @PostMapping("/join") 55 @PostMapping("/join")
48 @Operation(summary = "加入已有群通话;用于胶囊条「加入」按钮") 56 @Operation(summary = "加入已有群通话;用于胶囊条「加入」按钮")
49 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") 57 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567")
50 public CommonResult<ImRtcCallRespVO> joinCall(@RequestParam("room") String room) { 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 @PostMapping("/accept") 63 @PostMapping("/accept")
63 @Operation(summary = "接听通话") 64 @Operation(summary = "接听通话")
64 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") 65 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567")
65 public CommonResult<ImRtcCallRespVO> accept(@RequestParam("room") String room) { 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 @PostMapping("/reject") 71 @PostMapping("/reject")
@@ -95,8 +96,9 @@ public class ImRtcCallController { @@ -95,8 +96,9 @@ public class ImRtcCallController {
95 @Operation(summary = "重新签发 Token;客户端重连或 Token 过期续期") 96 @Operation(summary = "重新签发 Token;客户端重连或 Token 过期续期")
96 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567") 97 @Parameter(name = "room", description = "业务通话编号", required = true, example = "f47ac10b58cc4372a567")
97 public CommonResult<ImRtcCallRespVO> refreshToken(@RequestParam("room") String room) { 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 @GetMapping("/get-active-call") 104 @GetMapping("/get-active-call")
@@ -104,7 +106,7 @@ public class ImRtcCallController { @@ -104,7 +106,7 @@ public class ImRtcCallController {
104 @Parameter(name = "groupId", description = "群编号", required = true, example = "2048") 106 @Parameter(name = "groupId", description = "群编号", required = true, example = "2048")
105 public CommonResult<ImRtcGroupCallRespVO> getActiveCall(@RequestParam("groupId") Long groupId) { 107 public CommonResult<ImRtcGroupCallRespVO> getActiveCall(@RequestParam("groupId") Long groupId) {
106 ImRtcCallDO call = rtcCallService.getActiveCall(getLoginUserId(), groupId); 108 ImRtcCallDO call = rtcCallService.getActiveCall(getLoginUserId(), groupId);
107 - return success(buildGroupActiveResp(call)); 109 + return success(buildGroupActiveRespVO(call));
108 } 110 }
109 111
110 // ========== VO 拼装 ========== 112 // ========== VO 拼装 ==========
@@ -116,7 +118,7 @@ public class ImRtcCallController { @@ -116,7 +118,7 @@ public class ImRtcCallController {
116 * @param userId 当前用户编号;token 按该用户签发 118 * @param userId 当前用户编号;token 按该用户签发
117 * @return 响应 VO;call 为空返回 null 119 * @return 响应 VO;call 为空返回 null
118 */ 120 */
119 - private ImRtcCallRespVO buildCallResp(ImRtcCallDO call, Long userId) { 121 + private ImRtcCallRespVO buildCallRespVO(ImRtcCallDO call, Long userId) {
120 if (call == null) { 122 if (call == null) {
121 return null; 123 return null;
122 } 124 }
@@ -125,7 +127,7 @@ public class ImRtcCallController { @@ -125,7 +127,7 @@ public class ImRtcCallController {
125 .setRoom(call.getRoom()) 127 .setRoom(call.getRoom())
126 .setLivekitUrl(imProperties.getRtc().getLivekitUrl()) 128 .setLivekitUrl(imProperties.getRtc().getLivekitUrl())
127 .setToken(rtcCallService.signCallToken(userId, call.getRoom())) 129 .setToken(rtcCallService.signCallToken(userId, call.getRoom()))
128 - .setScene(call.getConversationType()).setMediaType(call.getMediaType()) 130 + .setConversationType(call.getConversationType()).setMediaType(call.getMediaType())
129 .setStatus(call.getStatus()).setInviterId(call.getInviterUserId()) 131 .setStatus(call.getStatus()).setInviterId(call.getInviterUserId())
130 .setGroupId(call.getGroupId()) 132 .setGroupId(call.getGroupId())
131 .setInviteeIds(filterUserIds(participants, ImRtcParticipantStatusEnum.INVITING)) 133 .setInviteeIds(filterUserIds(participants, ImRtcParticipantStatusEnum.INVITING))
@@ -133,20 +135,18 @@ public class ImRtcCallController { @@ -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 * @param call 通话主表 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 if (call == null) { 144 if (call == null) {
143 return null; 145 return null;
144 } 146 }
145 List<ImRtcParticipantDO> participants = rtcCallService.getCallParticipantList(call.getRoom()); 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 .setJoinedUserIds(filterUserIds(participants, ImRtcParticipantStatusEnum.JOINED)) 150 .setJoinedUserIds(filterUserIds(participants, ImRtcParticipantStatusEnum.JOINED))
151 .setInviteeIds(filterUserIds(participants, ImRtcParticipantStatusEnum.INVITING)); 151 .setInviteeIds(filterUserIds(participants, ImRtcParticipantStatusEnum.INVITING));
152 } 152 }
@@ -161,7 +161,7 @@ public class ImRtcCallController { @@ -161,7 +161,7 @@ public class ImRtcCallController {
161 private static Set<Long> filterUserIds(List<ImRtcParticipantDO> participants, 161 private static Set<Long> filterUserIds(List<ImRtcParticipantDO> participants,
162 ImRtcParticipantStatusEnum status) { 162 ImRtcParticipantStatusEnum status) {
163 return CollectionUtils.convertLinkedSet(participants, ImRtcParticipantDO::getUserId, 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 package cn.iocoder.yudao.module.im.controller.admin.rtc.vo; 1 package cn.iocoder.yudao.module.im.controller.admin.rtc.vo;
2 2
3 import io.swagger.v3.oas.annotations.media.Schema; 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 import lombok.Data; 6 import lombok.Data;
6 7
7 import java.util.Set; 8 import java.util.Set;
8 9
9 -@Schema(description = "管理后台 - 发起通话 Request VO") 10 +@Schema(description = "管理后台 - 通话中追加邀请 Request VO;仅群通话可用")
10 @Data 11 @Data
11 public class ImRtcCallInviteReqVO { 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 private Set<Long> inviteeIds; 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,8 +18,8 @@ public class ImRtcCallRespVO {
18 @Schema(description = "LiveKit 接入 Token;需要时调 refreshToken 重新签发") 18 @Schema(description = "LiveKit 接入 Token;需要时调 refreshToken 重新签发")
19 private String token; 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 @Schema(description = "媒体类型") 24 @Schema(description = "媒体类型")
25 private Integer mediaType; // 参见 ImCallMediaTypeEnum 枚举类 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,6 +20,7 @@ public enum ImRtcCallEndReasonEnum implements ArrayValuable&lt;Integer&gt; {
20 HANGUP(1, "通话结束"), // 接通后任一方主动挂断 20 HANGUP(1, "通话结束"), // 接通后任一方主动挂断
21 REJECT(2, "已拒绝"), // 被叫接通前点拒接 21 REJECT(2, "已拒绝"), // 被叫接通前点拒接
22 CANCEL(3, "已取消"), // 主叫接通前主动取消 22 CANCEL(3, "已取消"), // 主叫接通前主动取消
  23 + // TODO @AI:思考时,会看到一条,【对方正忙】;目的是:私聊后续会看到这个通话,从而去回去联系。类似未接电话一样的味道。
23 BUSY(5, "对方正忙"), // 私聊呼叫时对方在另一通话 24 BUSY(5, "对方正忙"), // 私聊呼叫时对方在另一通话
24 ERROR(9, "通话异常"); // 网络中断、设备失败等 25 ERROR(9, "通话异常"); // 网络中断、设备失败等
25 26
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java
1 package cn.iocoder.yudao.module.im.service.rtc; 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 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; 4 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
5 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; 5 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
6 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; 6 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
@@ -9,21 +9,27 @@ import java.util.List; @@ -9,21 +9,27 @@ import java.util.List;
9 9
10 /** 10 /**
11 * IM 实时通话 Service 11 * IM 实时通话 Service
12 - * <p>  
13 - * 业务模型:一个好友对 / 一个群同时只能有一个进行中的通话;inviteCall 仅负责「创建新通话」,加入已有群通话走 {@link #joinCall}  
14 * 12 *
15 * @author 芋道源码 13 * @author 芋道源码
16 */ 14 */
17 public interface ImRtcCallService { 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 * @return 通话主表 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 * 加入已有群通话:用于群胶囊条「加入」按钮;旁观者作为 ACTIVE_JOIN 加入,邀请池内成员转 JOINED 35 * 加入已有群通话:用于群胶囊条「加入」按钮;旁观者作为 ACTIVE_JOIN 加入,邀请池内成员转 JOINED
@@ -35,14 +41,6 @@ public interface ImRtcCallService { @@ -35,14 +41,6 @@ public interface ImRtcCallService {
35 ImRtcCallDO joinCall(Long userId, String room); 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 * 接听通话:参与者 INVITING → JOINED;主表 CREATED → RUNNING(首次有非发起人接通时) 44 * 接听通话:参与者 INVITING → JOINED;主表 CREATED → RUNNING(首次有非发起人接通时)
47 * 45 *
48 * @param userId 接听者用户编号 46 * @param userId 接听者用户编号
@@ -117,8 +115,8 @@ public interface ImRtcCallService { @@ -117,8 +115,8 @@ public interface ImRtcCallService {
117 /** 115 /**
118 * 处理 LiveKit Webhook 事件;用于关 tab / 强杀 / 网络断开等异常退出场景的兜底清理 116 * 处理 LiveKit Webhook 事件;用于关 tab / 强杀 / 网络断开等异常退出场景的兜底清理
119 * <p> 117 * <p>
120 - * 关键事件:participant_left(成员离开) / room_finished(房间结束)。前端正常 leave 时  
121 - * 也会触发同样的 LiveKit 事件;此处需做幂等处理,session 已被业务接口移除时直接忽略。 118 + * 关键事件:participant_left(成员离开) / room_finished(房间结束)。
  119 + * 前端正常 leave 时,也会触发同样的 LiveKit 事件;此处需做幂等处理,session 已被业务接口移除时直接忽略。
122 * 120 *
123 * @param event LiveKit Webhook 事件 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,7 +5,7 @@ import cn.hutool.core.util.IdUtil;
5 import cn.hutool.core.util.ObjUtil; 5 import cn.hutool.core.util.ObjUtil;
6 import cn.hutool.core.util.StrUtil; 6 import cn.hutool.core.util.StrUtil;
7 import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; 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 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; 9 import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
10 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; 10 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
11 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; 11 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
@@ -98,66 +98,101 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -98,66 +98,101 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
98 98
99 @Override 99 @Override
100 @SneakyThrows 100 @SneakyThrows
101 - public ImRtcCallDO inviteCall(Long userId, ImRtcCallInviteReqVO reqVO) { 101 + public ImRtcCallDO createCall(Long userId, ImRtcCallCreateReqVO reqVO) {
102 validateEnabled(); 102 validateEnabled();
103 // 1. 校验入参与场景 103 // 1. 校验入参与场景
104 - validateInviteScene(userId, reqVO); 104 + validateCreateCall(userId, reqVO);
105 105
106 // 2. 加锁后跑业务主体;同好友对 / 同群串行,避免并发各开一通的竞态 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 * @param userId 发起人编号 117 * @param userId 发起人编号
117 - * @param reqVO 发起请求 118 + * @param reqVO 创建请求
118 * @return 通话主表 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 ImRtcParticipantDO selfActive = rtcParticipantMapper.selectLastOneByUserIdAndStatus( 128 ImRtcParticipantDO selfActive = rtcParticipantMapper.selectLastOneByUserIdAndStatus(
144 userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES); 129 userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES);
145 if (selfActive != null) { 130 if (selfActive != null) {
146 throw exception(RTC_SELF_BUSY); 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 // 2. 新建通话 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 * @param inviterId 发起人编号 191 * @param inviterId 发起人编号
157 - * @param reqVO 发起请求 192 + * @param reqVO 创建请求
158 * @return 通话主表 193 * @return 通话主表
159 */ 194 */
160 - private ImRtcCallDO createCall(Long inviterId, ImRtcCallInviteReqVO reqVO) { 195 + private ImRtcCallDO createCall0(Long inviterId, ImRtcCallCreateReqVO reqVO) {
161 // 1. 构造参数:room 用 UUID;解析被邀请池 196 // 1. 构造参数:room 用 UUID;解析被邀请池
162 String room = IdUtil.fastSimpleUUID(); 197 String room = IdUtil.fastSimpleUUID();
163 LocalDateTime now = LocalDateTime.now(); 198 LocalDateTime now = LocalDateTime.now();
@@ -165,7 +200,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -165,7 +200,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
165 200
166 // 2.1 INSERT 主表;群聊发起人即时 JOINED 但通话仍处 CREATED,等首个非发起人接通才切 RUNNING 201 // 2.1 INSERT 主表;群聊发起人即时 JOINED 但通话仍处 CREATED,等首个非发起人接通才切 RUNNING
167 ImRtcCallDO call = new ImRtcCallDO().setRoom(room) 202 ImRtcCallDO call = new ImRtcCallDO().setRoom(room)
168 - .setConversationType(reqVO.getScene()).setMediaType(reqVO.getMediaType()) 203 + .setConversationType(reqVO.getConversationType()).setMediaType(reqVO.getMediaType())
169 .setInviterUserId(inviterId).setGroupId(reqVO.getGroupId()) 204 .setInviterUserId(inviterId).setGroupId(reqVO.getGroupId())
170 .setStatus(ImRtcCallStatusEnum.CREATED.getStatus()).setStartTime(now); 205 .setStatus(ImRtcCallStatusEnum.CREATED.getStatus()).setStartTime(now);
171 rtcCallMapper.insert(call); 206 rtcCallMapper.insert(call);
@@ -191,7 +226,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -191,7 +226,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
191 pushCallInviteNotification(call, inviterUser, inviteeId, inviteeMap.get(inviteeId)); 226 pushCallInviteNotification(call, inviterUser, inviteeId, inviteeMap.get(inviteeId));
192 } 227 }
193 // 3.2 群通话再向全群广播 RTC_CALL_START 入聊天流 228 // 3.2 群通话再向全群广播 RTC_CALL_START 入聊天流
194 - if (ImConversationTypeEnum.isGroup(reqVO.getScene())) { 229 + if (ImConversationTypeEnum.isGroup(reqVO.getConversationType())) {
195 pushCallStartNotification(call, inviterUser); 230 pushCallStartNotification(call, inviterUser);
196 } 231 }
197 return call; 232 return call;
@@ -211,7 +246,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -211,7 +246,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
211 LocalDateTime now = LocalDateTime.now(); 246 LocalDateTime now = LocalDateTime.now();
212 ImRtcParticipantDO existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); 247 ImRtcParticipantDO existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId);
213 if (existing != null) { 248 if (existing != null) {
214 - // 非 JOINED 状态切回 JOINED + accept_time;INVITER / INVITEE 重连 / 旁观点胶囊条都走这条 249 + // 非 JOINED 状态切回 JOINED + accept_time;INVITER / INVITEE 重连、旁观者点击胶囊条加入,都走这条
215 if (!ImRtcParticipantStatusEnum.isJoined(existing.getStatus())) { 250 if (!ImRtcParticipantStatusEnum.isJoined(existing.getStatus())) {
216 rtcParticipantMapper.updateById(new ImRtcParticipantDO().setId(existing.getId()) 251 rtcParticipantMapper.updateById(new ImRtcParticipantDO().setId(existing.getId())
217 .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setAcceptTime(now)); 252 .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setAcceptTime(now));
@@ -228,26 +263,20 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -228,26 +263,20 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
228 return call; 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 Set<Long> existingUserIds = CollectionUtils.convertSet( 277 Set<Long> existingUserIds = CollectionUtils.convertSet(
249 rtcParticipantMapper.selectListByRoom(call.getRoom()), ImRtcParticipantDO::getUserId); 278 rtcParticipantMapper.selectListByRoom(call.getRoom()), ImRtcParticipantDO::getUserId);
250 - Set<Long> incomingUserIds = new LinkedHashSet<>(reqVO.getInviteeIds()); 279 + Set<Long> incomingUserIds = new LinkedHashSet<>(inviteeIds);
251 incomingUserIds.removeAll(existingUserIds); 280 incomingUserIds.removeAll(existingUserIds);
252 if (CollUtil.isEmpty(incomingUserIds)) { 281 if (CollUtil.isEmpty(incomingUserIds)) {
253 return; 282 return;
@@ -262,7 +291,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -262,7 +291,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
262 rtcParticipantMapper.insertBatch(participants); 291 rtcParticipantMapper.insertBatch(participants);
263 292
264 // 3. 推送通知:RTC_CALL(INVITE) 给每个新邀请人 293 // 3. 推送通知:RTC_CALL(INVITE) 给每个新邀请人
265 - AdminUserRespDTO inviter = adminUserApi.getUser(userId); 294 + AdminUserRespDTO inviter = adminUserApi.getUser(inviterId);
266 Map<Long, AdminUserRespDTO> inviteeMap = adminUserApi.getUserMap(incomingUserIds); 295 Map<Long, AdminUserRespDTO> inviteeMap = adminUserApi.getUserMap(incomingUserIds);
267 for (Long inviteeId : incomingUserIds) { 296 for (Long inviteeId : incomingUserIds) {
268 pushCallInviteNotification(call, inviter, inviteeId, inviteeMap.get(inviteeId)); 297 pushCallInviteNotification(call, inviter, inviteeId, inviteeMap.get(inviteeId));
@@ -316,10 +345,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -316,10 +345,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
316 return; 345 return;
317 } 346 }
318 347
319 - // 3.1 群通话拒绝:仅推 RTC_CALL(REJECT) 给主叫;不影响通话整体  
320 - // TODO @AI:群聊如果拒绝,需要做类似的 endSession 逻辑么? 348 + // 3.1 群通话拒绝:推 RTC_CALL(REJECT) 给主叫,再判定是否要关房(全员拒接 / 只剩主叫一人时收敛)
321 if (ImConversationTypeEnum.isGroup(call.getConversationType())) { 349 if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
322 pushCallRejectNotification(call, userId); 350 pushCallRejectNotification(call, userId);
  351 + endSessionIfTerminal(call, userId);
323 return; 352 return;
324 } 353 }
325 // 3.2 私聊拒绝:走 endSession(推 RTC_CALL_END(reason=REJECT)) 354 // 3.2 私聊拒绝:走 endSession(推 RTC_CALL_END(reason=REJECT))
@@ -359,23 +388,32 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -359,23 +388,32 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
359 388
360 // 3. 触发关房判定:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关 389 // 3. 触发关房判定:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关
361 // 群通话单人离开:LiveKit `ParticipantDisconnected` 自动通知房内成员;业务后端不另外推 390 // 群通话单人离开:LiveKit `ParticipantDisconnected` 自动通知房内成员;业务后端不另外推
362 - endSessionIfTerminal(call, userId, ImRtcCallEndReasonEnum.HANGUP); 391 + endSessionIfTerminal(call, userId);
363 } 392 }
364 393
365 /** 394 /**
366 - * 群通话是否应该关闭:房内没有 JOINED 就关 395 + * 群通话是否应该关闭
367 * <p> 396 * <p>
368 - * 群通话至少需要 1 个 JOINED 在房才有意义;只剩 INVITING(响铃中)也无价值 397 + * 两种终态:
  398 + * 1. JOINED 数 = 0(房内已没人)
  399 + * 2. JOINED 数 = 1 且 INVITING 数 = 0(只剩 1 人独守,无后续可加入者)
369 * <p> 400 * <p>
370 * 关房后 endSession 会把残留 INVITING 批量改 NO_ANSWER 并推 RTC_CALL_END,响铃端 UI 自动收敛 401 * 关房后 endSession 会把残留 INVITING 批量改 NO_ANSWER 并推 RTC_CALL_END,响铃端 UI 自动收敛
371 * 402 *
372 * @param room 业务通话编号 403 * @param room 业务通话编号
373 - * @return true 表示房内已无 JOINED,需要关房 404 + * @return true 表示无法继续通话,应关房
374 */ 405 */
375 private boolean shouldCloseGroupRoom(String room) { 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,11 +470,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
432 470
433 @Override 471 @Override
434 public String signCallToken(Long userId, String room) { 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 @Override 476 @Override
@@ -518,7 +552,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -518,7 +552,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
518 // 3. 推 1603 通知参与方 / 全群参与方 552 // 3. 推 1603 通知参与方 / 全群参与方
519 pushParticipantDisconnectedNotification(call, userId); 553 pushParticipantDisconnectedNotification(call, userId);
520 // 4. 触发关房判定:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关 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,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 * @param call 通话主表 650 * @param call 通话主表
615 * @param operatorId 操作者用户编号;webhook 兜底场景可空 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 * @param userId 发起人编号 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 throw exception(RTC_PRIVATE_INVITEE_REQUIRED); 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 throw exception(RTC_INVITE_SELF); 678 throw exception(RTC_INVITE_SELF);
639 } 679 }
640 - friendService.validateFriend(userId, reqVO.getPeerUserId()); 680 + friendService.validateFriend(userId, peerUserId);
641 return; 681 return;
642 } 682 }
643 - if (ImConversationTypeEnum.isGroup(scene)) { 683 + if (ImConversationTypeEnum.isGroup(conversationType)) {
644 if (reqVO.getGroupId() == null) { 684 if (reqVO.getGroupId() == null) {
645 throw exception(RTC_GROUP_REQUIRED); 685 throw exception(RTC_GROUP_REQUIRED);
646 } 686 }
@@ -651,20 +691,20 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -651,20 +691,20 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
651 groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); 691 groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId);
652 return; 692 return;
653 } 693 }
654 - throw new IllegalArgumentException("非法的 scene: " + scene); 694 + throw new IllegalArgumentException("非法的 conversationType: " + conversationType);
655 } 695 }
656 696
657 /** 697 /**
658 * 解析被邀请池:私聊为 peerUserId 单元素;群聊为前端选中子集(超量抛错) 698 * 解析被邀请池:私聊为 peerUserId 单元素;群聊为前端选中子集(超量抛错)
659 * 699 *
660 - * @param reqVO 发起请求 700 + * @param reqVO 创建请求
661 * @param inviterId 发起人编号;自己不进被邀请池 701 * @param inviterId 发起人编号;自己不进被邀请池
662 * @return 被邀请人 userId 集合 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 // 2. 群聊校验:被邀请人必须是该群活跃成员,防止恶意客户端塞任意 userId 710 // 2. 群聊校验:被邀请人必须是该群活跃成员,防止恶意客户端塞任意 userId
@@ -776,10 +816,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -776,10 +816,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
776 */ 816 */
777 private void pushCallInviteNotification(ImRtcCallDO call, AdminUserRespDTO inviter, 817 private void pushCallInviteNotification(ImRtcCallDO call, AdminUserRespDTO inviter,
778 Long inviteeId, AdminUserRespDTO invitee) { 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 ImRtcCallNotification payload = ImRtcCallNotification.ofInvite( 820 ImRtcCallNotification payload = ImRtcCallNotification.ofInvite(
784 call, inviter, imProperties.getRtc().getLivekitUrl(), token); 821 call, inviter, imProperties.getRtc().getLivekitUrl(), token);
785 webSocketService.sendPrivateMessageAsync(inviteeId, ImPrivateMessageDTO.ofRtcNotification( 822 webSocketService.sendPrivateMessageAsync(inviteeId, ImPrivateMessageDTO.ofRtcNotification(
@@ -901,4 +938,16 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -901,4 +938,16 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
901 return liveKitClient.signJoinToken(liveKitClient.buildIdentity(userId), displayName, room); 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 }