Commit c69e20d681e097c65ef9d161d125090f476720fb
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 修复
Showing
8 changed files
with
226 additions
and
183 deletions
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<Integer> { | @@ -20,6 +20,7 @@ public enum ImRtcCallEndReasonEnum implements ArrayValuable<Integer> { | ||
| 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 | } |