diff --git a/.gitignore b/.gitignore index 2b4b40c..6e07688 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ /im-uniapp/unpackage/ /im-uniapp/hybrid/ /im-uniapp/package-lock.json +/im-ui/src/components/rtc/LocalVideo.vue +/im-ui/src/components/rtc/RemoteVideo.vue +/im-ui/src/components/rtc/RtcGroupAcceptor.vue diff --git a/README.md b/README.md index d524d93..032bf40 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,5 @@ wsApi.onClose((e) => { 1. 本系统允许用于商业用途,且不收费(自愿投币)。**但切记不要用于任何非法用途** ,本软件作者不会为此承担任何责任 1. 基于本系统二次开发后再次开源的项目,请注明引用出处,以避免引发不必要的误会 -1. 如果您也想体验开源(bei bai piao)的快感,成为本项目的贡献者,欢迎提交PR。开发前最好提前联系作者,避免功能重复开发 1. 作者目前不打算接项目,如果能接受1k/天以上的报价,也可以聊聊 diff --git a/im-commom/src/main/java/com/bx/imcommon/model/IMUserInfo.java b/im-commom/src/main/java/com/bx/imcommon/model/IMUserInfo.java index a25cd0f..680cda6 100644 --- a/im-commom/src/main/java/com/bx/imcommon/model/IMUserInfo.java +++ b/im-commom/src/main/java/com/bx/imcommon/model/IMUserInfo.java @@ -5,7 +5,7 @@ import lombok.Data; import lombok.NoArgsConstructor; /** - * @author: 谢绍许 + * @author: Blue * @date: 2023-09-24 09:23:11 * @version: 1.0 */ diff --git a/im-platform/pom.xml b/im-platform/pom.xml index 34b5834..c56461d 100644 --- a/im-platform/pom.xml +++ b/im-platform/pom.xml @@ -110,7 +110,11 @@ mybatis-plus-generator 3.3.2 - + + org.redisson + redisson + 3.17.3 + diff --git a/im-platform/src/main/java/com/bx/implatform/annotation/OnlineCheck.java b/im-platform/src/main/java/com/bx/implatform/annotation/OnlineCheck.java new file mode 100644 index 0000000..f616002 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/annotation/OnlineCheck.java @@ -0,0 +1,16 @@ +package com.bx.implatform.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +/** + * 在线校验,标注此注解的接口用户必须保持长连接,否则将抛异常 + */ +@Retention(RetentionPolicy.RUNTIME)//运行时生效 +@Target(ElementType.METHOD)//作用在方法上 +public @interface OnlineCheck { + +} diff --git a/im-platform/src/main/java/com/bx/implatform/annotation/RedisLock.java b/im-platform/src/main/java/com/bx/implatform/annotation/RedisLock.java new file mode 100644 index 0000000..0421931 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/annotation/RedisLock.java @@ -0,0 +1,37 @@ +package com.bx.implatform.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +/** + * 分布式锁注解 + */ +@Retention(RetentionPolicy.RUNTIME)//运行时生效 +@Target(ElementType.METHOD)//作用在方法上 +public @interface RedisLock { + + /** + * key的前缀,prefixKey+key就是redis的key + */ + String prefixKey() ; + + /** + * spel 表达式 + */ + String key(); + + /** + * 等待锁的时间,默认-1,不等待直接失败,redisson默认也是-1 + */ + int waitTime() default -1; + + /** + * 等待锁的时间单位,默认毫秒 + * + */ + TimeUnit unit() default TimeUnit.MILLISECONDS; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/aspect/OnlineCheckAspect.java b/im-platform/src/main/java/com/bx/implatform/aspect/OnlineCheckAspect.java new file mode 100644 index 0000000..b81124c --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/aspect/OnlineCheckAspect.java @@ -0,0 +1,43 @@ +package com.bx.implatform.aspect; + +import cn.hutool.core.util.StrUtil; +import com.bx.imclient.IMClient; +import com.bx.implatform.annotation.RedisLock; +import com.bx.implatform.exception.GlobalException; +import com.bx.implatform.session.SessionContext; +import com.bx.implatform.session.UserSession; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RLock; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +/** + * @author: blue + * @date: 2024-06-16 + * @version: 1.0 + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class OnlineCheckAspect { + + private final IMClient imClient; + + @Around("@annotation(com.bx.implatform.annotation.OnlineCheck)") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + UserSession session = SessionContext.getSession(); + if(!imClient.isOnline(session.getUserId())){ + throw new GlobalException("您当前的网络连接已断开,请稍后重试"); + } + return joinPoint.proceed(); + } + +} diff --git a/im-platform/src/main/java/com/bx/implatform/aspect/RedisLockAspect.java b/im-platform/src/main/java/com/bx/implatform/aspect/RedisLockAspect.java new file mode 100644 index 0000000..0cd5586 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/aspect/RedisLockAspect.java @@ -0,0 +1,82 @@ +package com.bx.implatform.aspect; + +import cn.hutool.core.util.StrUtil; +import com.bx.implatform.annotation.RedisLock; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.Redisson; +import org.redisson.RedissonLock; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.annotation.Order; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.Objects; + +/** + * @author: blue + * @date: 2024-06-09 + * @version: 1.0 + */ + +@Slf4j +@Aspect +@Order(0) +@Component +@RequiredArgsConstructor +public class RedisLockAspect { + + private ExpressionParser parser = new SpelExpressionParser(); + private DefaultParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + private final RedissonClient redissonClient; + + @Around("@annotation(com.bx.implatform.annotation.RedisLock)") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); + RedisLock annotation = method.getAnnotation(RedisLock.class); + // 解析表达式中的key + String key = parseKey(joinPoint); + String lockKey = StrUtil.join(":",annotation.prefixKey(),key); + // 上锁 + RLock lock = redissonClient.getLock(lockKey); + lock.lock(annotation.waitTime(),annotation.unit()); + try { + // 执行方法 + return joinPoint.proceed(); + }finally { + lock.unlock(); + } + } + + private String parseKey(ProceedingJoinPoint joinPoint){ + Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); + RedisLock annotation = method.getAnnotation(RedisLock.class); + // el解析需要的上下文对象 + EvaluationContext context = new StandardEvaluationContext(); + // 参数名 + String[] params = parameterNameDiscoverer.getParameterNames(method); + if(Objects.isNull(params)){ + return annotation.key(); + } + Object[] args = joinPoint.getArgs(); + for (int i = 0; i < params.length; i++) { + context.setVariable(params[i], args[i]);//所有参数都作为原材料扔进去 + } + Expression expression = parser.parseExpression(annotation.key()); + return expression.getValue(context, String.class); + } + + +} diff --git a/im-platform/src/main/java/com/bx/implatform/config/RedissonConfig.java b/im-platform/src/main/java/com/bx/implatform/config/RedissonConfig.java new file mode 100644 index 0000000..7dc1c92 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/config/RedissonConfig.java @@ -0,0 +1,42 @@ +package com.bx.implatform.config; + +import cn.hutool.core.util.StrUtil; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.StringCodec; +import org.redisson.config.Config; +import org.redisson.config.SingleServerConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author: Blue + * @date: 2024-06-09 + * @version: 1.0 + */ + +@Configuration +@ConditionalOnClass(Config.class) +@EnableConfigurationProperties(RedisProperties.class) +public class RedissonConfig { + + @Bean + RedissonClient redissonClient(RedisProperties redisProperties) { + Config config = new Config(); + config.setCodec(new StringCodec()); + String address = "redis://" + redisProperties.getHost()+":"+redisProperties.getPort(); + SingleServerConfig serverConfig = config.useSingleServer() + .setAddress(address) + .setDatabase(redisProperties.getDatabase()); + if(StrUtil.isNotEmpty(redisProperties.getPassword())) { + serverConfig.setPassword(redisProperties.getPassword()); + } + + return Redisson.create(config); + } + + +} diff --git a/im-platform/src/main/java/com/bx/implatform/config/ICEServerConfig.java b/im-platform/src/main/java/com/bx/implatform/config/WebrtcConfig.java similarity index 84% rename from im-platform/src/main/java/com/bx/implatform/config/ICEServerConfig.java rename to im-platform/src/main/java/com/bx/implatform/config/WebrtcConfig.java index 967a298..633d640 100644 --- a/im-platform/src/main/java/com/bx/implatform/config/ICEServerConfig.java +++ b/im-platform/src/main/java/com/bx/implatform/config/WebrtcConfig.java @@ -10,7 +10,9 @@ import java.util.List; @Data @Component @ConfigurationProperties(prefix = "webrtc") -public class ICEServerConfig { +public class WebrtcConfig { + + private Integer maxChannel = 9; private List iceServers = new ArrayList<>(); diff --git a/im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java b/im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java index 2585bb4..026e3c9 100644 --- a/im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java +++ b/im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java @@ -2,17 +2,22 @@ package com.bx.implatform.contant; public final class RedisKey { - private RedisKey() { - } - + /** + * 用户状态 无值:空闲 1:正在忙 + */ + public static final String IM_USER_STATE = "im:user:state"; /** * 已读群聊消息位置(已读最大id) */ public static final String IM_GROUP_READED_POSITION = "im:readed:group:position"; /** - * webrtc 会话信息 + * webrtc 单人通话 */ - public static final String IM_WEBRTC_SESSION = "im:webrtc:session"; + public static final String IM_WEBRTC_PRIVATE_SESSION = "im:webrtc:private:session"; + /** + * webrtc 群通话 + */ + public static final String IM_WEBRTC_GROUP_SESSION = "im:webrtc:group:session"; /** * 缓存前缀 */ @@ -30,4 +35,14 @@ public final class RedisKey { */ public static final String IM_CACHE_GROUP_MEMBER_ID = IM_CACHE + "group_member_ids"; + /** + * 分布式锁前缀 + */ + public static final String IM_LOCK = "im:lock:"; + + /** + * 分布式锁前缀 + */ + public static final String IM_LOCK_RTC_GROUP = IM_LOCK + "rtc:group"; + } diff --git a/im-platform/src/main/java/com/bx/implatform/controller/SystemController.java b/im-platform/src/main/java/com/bx/implatform/controller/SystemController.java new file mode 100644 index 0000000..d3cd30b --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/controller/SystemController.java @@ -0,0 +1,35 @@ +package com.bx.implatform.controller; + +import com.bx.implatform.config.WebrtcConfig; +import com.bx.implatform.dto.PrivateMessageDTO; +import com.bx.implatform.result.Result; +import com.bx.implatform.result.ResultUtils; +import com.bx.implatform.vo.SystemConfigVO; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + + + +/** + * @author: blue + * @date: 2024-06-10 + * @version: 1.0 + */ +@Api(tags = "系统相关") +@RestController +@RequestMapping("/system") +@RequiredArgsConstructor +public class SystemController { + + private final WebrtcConfig webrtcConfig; + + @GetMapping("/config") + @ApiOperation(value = "加载系统配置", notes = "加载系统配置") + public Result loadConfig() { + return ResultUtils.success(new SystemConfigVO(webrtcConfig)); + } + + +} diff --git a/im-platform/src/main/java/com/bx/implatform/controller/WebrtcGroupController.java b/im-platform/src/main/java/com/bx/implatform/controller/WebrtcGroupController.java new file mode 100644 index 0000000..c9fb6b7 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/controller/WebrtcGroupController.java @@ -0,0 +1,126 @@ +package com.bx.implatform.controller; + +import com.bx.implatform.config.WebrtcConfig; +import com.bx.implatform.dto.*; +import com.bx.implatform.result.Result; +import com.bx.implatform.result.ResultUtils; +import com.bx.implatform.service.IWebrtcGroupService; +import com.bx.implatform.vo.WebrtcGroupInfoVO; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Api(tags = "webrtc视频多人通话") +@RestController +@RequestMapping("/webrtc/group") +@RequiredArgsConstructor +public class WebrtcGroupController { + + private final IWebrtcGroupService webrtcGroupService; + + @ApiOperation(httpMethod = "POST", value = "发起群视频通话") + @PostMapping("/setup") + public Result setup(@Valid @RequestBody WebrtcGroupSetupDTO dto) { + webrtcGroupService.setup(dto); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "接受通话") + @PostMapping("/accept") + public Result accept(@RequestParam Long groupId) { + webrtcGroupService.accept(groupId); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "拒绝通话") + @PostMapping("/reject") + public Result reject(@RequestParam Long groupId) { + webrtcGroupService.reject(groupId); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "通话失败") + @PostMapping("/failed") + public Result failed(@Valid @RequestBody WebrtcGroupFailedDTO dto) { + webrtcGroupService.failed(dto); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "进入视频通话") + @PostMapping("/join") + public Result join(@RequestParam Long groupId) { + webrtcGroupService.join(groupId); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "取消通话") + @PostMapping("/cancel") + public Result cancel(@RequestParam Long groupId) { + webrtcGroupService.cancel(groupId); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "离开视频通话") + @PostMapping("/quit") + public Result quit(@RequestParam Long groupId) { + webrtcGroupService.quit(groupId); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "推送offer信息") + @PostMapping("/offer") + public Result offer(@Valid @RequestBody WebrtcGroupOfferDTO dto) { + webrtcGroupService.offer(dto); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "推送answer信息") + @PostMapping("/answer") + public Result answer(@Valid @RequestBody WebrtcGroupAnswerDTO dto) { + webrtcGroupService.answer(dto); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "邀请用户进入视频通话") + @PostMapping("/invite") + public Result invite(@Valid @RequestBody WebrtcGroupInviteDTO dto) { + webrtcGroupService.invite(dto); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "同步candidate") + @PostMapping("/candidate") + public Result candidate(@Valid @RequestBody WebrtcGroupCandidateDTO dto) { + webrtcGroupService.candidate(dto); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "POST", value = "设备操作") + @PostMapping("/device") + public Result device(@Valid @RequestBody WebrtcGroupDeviceDTO dto) { + webrtcGroupService.device(dto); + return ResultUtils.success(); + } + + @ApiOperation(httpMethod = "GET", value = "获取通话信息") + @GetMapping("/info") + public Result info(@RequestParam Long groupId) { + return ResultUtils.success(webrtcGroupService.info(groupId)); + } + + @ApiOperation(httpMethod = "POST", value = "获取通话信息") + @PostMapping("/heartbeat") + public Result heartbeat(@RequestParam Long groupId) { + webrtcGroupService.heartbeat(groupId); + return ResultUtils.success(); + } + + } diff --git a/im-platform/src/main/java/com/bx/implatform/controller/WebrtcController.java b/im-platform/src/main/java/com/bx/implatform/controller/WebrtcPrivateController.java similarity index 71% rename from im-platform/src/main/java/com/bx/implatform/controller/WebrtcController.java rename to im-platform/src/main/java/com/bx/implatform/controller/WebrtcPrivateController.java index d75ec6d..7d583c6 100644 --- a/im-platform/src/main/java/com/bx/implatform/controller/WebrtcController.java +++ b/im-platform/src/main/java/com/bx/implatform/controller/WebrtcPrivateController.java @@ -1,9 +1,10 @@ package com.bx.implatform.controller; +import com.bx.implatform.annotation.OnlineCheck; import com.bx.implatform.config.ICEServer; import com.bx.implatform.result.Result; import com.bx.implatform.result.ResultUtils; -import com.bx.implatform.service.IWebrtcService; +import com.bx.implatform.service.IWebrtcPrivateService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.RequiredArgsConstructor; @@ -15,21 +16,22 @@ import java.util.List; @RestController @RequestMapping("/webrtc/private") @RequiredArgsConstructor -public class WebrtcController { +public class WebrtcPrivateController { - private final IWebrtcService webrtcService; + private final IWebrtcPrivateService webrtcPrivateService; + @OnlineCheck @ApiOperation(httpMethod = "POST", value = "呼叫视频通话") @PostMapping("/call") public Result call(@RequestParam Long uid, @RequestParam(defaultValue = "video") String mode, @RequestBody String offer) { - webrtcService.call(uid, mode, offer); + webrtcPrivateService.call(uid, mode, offer); return ResultUtils.success(); } @ApiOperation(httpMethod = "POST", value = "接受视频通话") @PostMapping("/accept") public Result accept(@RequestParam Long uid, @RequestBody String answer) { - webrtcService.accept(uid, answer); + webrtcPrivateService.accept(uid, answer); return ResultUtils.success(); } @@ -37,28 +39,28 @@ public class WebrtcController { @ApiOperation(httpMethod = "POST", value = "拒绝视频通话") @PostMapping("/reject") public Result reject(@RequestParam Long uid) { - webrtcService.reject(uid); + webrtcPrivateService.reject(uid); return ResultUtils.success(); } @ApiOperation(httpMethod = "POST", value = "取消呼叫") @PostMapping("/cancel") public Result cancel(@RequestParam Long uid) { - webrtcService.cancel(uid); + webrtcPrivateService.cancel(uid); return ResultUtils.success(); } @ApiOperation(httpMethod = "POST", value = "呼叫失败") @PostMapping("/failed") public Result failed(@RequestParam Long uid, @RequestParam String reason) { - webrtcService.failed(uid, reason); + webrtcPrivateService.failed(uid, reason); return ResultUtils.success(); } @ApiOperation(httpMethod = "POST", value = "挂断") @PostMapping("/handup") public Result handup(@RequestParam Long uid) { - webrtcService.handup(uid); + webrtcPrivateService.handup(uid); return ResultUtils.success(); } @@ -66,14 +68,14 @@ public class WebrtcController { @PostMapping("/candidate") @ApiOperation(httpMethod = "POST", value = "同步candidate") public Result candidate(@RequestParam Long uid, @RequestBody String candidate) { - webrtcService.candidate(uid, candidate); + webrtcPrivateService.candidate(uid, candidate); return ResultUtils.success(); } - - @GetMapping("/iceservers") - @ApiOperation(httpMethod = "GET", value = "获取iceservers") - public Result> iceservers() { - return ResultUtils.success(webrtcService.getIceServers()); + @ApiOperation(httpMethod = "POST", value = "获取通话信息") + @PostMapping("/heartbeat") + public Result heartbeat(@RequestParam Long uid) { + webrtcPrivateService.heartbeat(uid); + return ResultUtils.success(); } } diff --git a/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupAnswerDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupAnswerDTO.java new file mode 100644 index 0000000..b66e68e --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupAnswerDTO.java @@ -0,0 +1,31 @@ +package com.bx.implatform.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Data +@ApiModel("回复用户连接请求DTO") +public class WebrtcGroupAnswerDTO { + + @NotNull(message = "群聊id不可为空") + @ApiModelProperty(value = "群聊id") + private Long groupId; + + @NotNull(message = "用户id不可为空") + @ApiModelProperty(value = "用户id,代表回复谁的连接请求") + private Long userId; + + @NotEmpty(message = "anwer不可为空") + @ApiModelProperty(value = "用户本地anwer信息") + private String answer; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupCandidateDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupCandidateDTO.java new file mode 100644 index 0000000..fd04e94 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupCandidateDTO.java @@ -0,0 +1,32 @@ +package com.bx.implatform.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Data +@ApiModel("发起群视频通话DTO") +public class WebrtcGroupCandidateDTO { + + @NotNull(message = "群聊id不可为空") + @ApiModelProperty(value = "群聊id") + private Long groupId; + + @NotNull(message = "用户id不可为空") + @ApiModelProperty(value = "用户id") + private Long userId; + + @NotEmpty(message = "candidate信息不可为空") + @ApiModelProperty(value = "candidate信息") + private String candidate; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupDeviceDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupDeviceDTO.java new file mode 100644 index 0000000..5d87b4d --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupDeviceDTO.java @@ -0,0 +1,29 @@ +package com.bx.implatform.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Data +@ApiModel("用户设备操作DTO") +public class WebrtcGroupDeviceDTO { + + @NotNull(message = "群聊id不可为空") + @ApiModelProperty(value = "群聊id") + private Long groupId; + + @ApiModelProperty(value = "是否开启摄像头") + private Boolean isCamera; + + @ApiModelProperty(value = "是否开启麦克风") + private Boolean isMicroPhone; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupFailedDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupFailedDTO.java new file mode 100644 index 0000000..3df2cb9 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupFailedDTO.java @@ -0,0 +1,25 @@ +package com.bx.implatform.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotNull; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Data +@ApiModel("用户通话失败DTO") +public class WebrtcGroupFailedDTO { + + @NotNull(message = "群聊id不可为空") + @ApiModelProperty(value = "群聊id") + private Long groupId; + + @ApiModelProperty(value = "失败原因") + private String reason; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupInviteDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupInviteDTO.java new file mode 100644 index 0000000..7719a39 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupInviteDTO.java @@ -0,0 +1,29 @@ +package com.bx.implatform.dto; + +import com.bx.implatform.session.WebrtcUserInfo; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Data +@ApiModel("邀请用户进入群视频通话DTO") +public class WebrtcGroupInviteDTO { + + @NotNull(message = "群聊id不可为空") + @ApiModelProperty(value = "群聊id") + private Long groupId; + + @NotEmpty(message = "参与用户信息不可为空") + @ApiModelProperty(value = "参与用户信息") + private List userInfos; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupJoinDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupJoinDTO.java new file mode 100644 index 0000000..adbc066 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupJoinDTO.java @@ -0,0 +1,23 @@ +package com.bx.implatform.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Data +@ApiModel("进入群视频通话DTO") +public class WebrtcGroupJoinDTO { + + @NotNull(message = "群聊id不可为空") + @ApiModelProperty(value = "群聊id") + private Long groupId; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupOfferDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupOfferDTO.java new file mode 100644 index 0000000..057fe20 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupOfferDTO.java @@ -0,0 +1,31 @@ +package com.bx.implatform.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Data +@ApiModel("回复用户连接请求DTO") +public class WebrtcGroupOfferDTO { + + @NotNull(message = "群聊id不可为空") + @ApiModelProperty(value = "群聊id") + private Long groupId; + + @NotNull(message = "用户id不可为空") + @ApiModelProperty(value = "用户id,代表回复谁的连接请求") + private Long userId; + + @NotEmpty(message = "offer不可为空") + @ApiModelProperty(value = "用户offer信息") + private String offer; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupSetupDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupSetupDTO.java new file mode 100644 index 0000000..f59989f --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/dto/WebrtcGroupSetupDTO.java @@ -0,0 +1,29 @@ +package com.bx.implatform.dto; + +import com.bx.implatform.session.WebrtcUserInfo; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Data +@ApiModel("发起群视频通话DTO") +public class WebrtcGroupSetupDTO { + + @NotNull(message = "群聊id不可为空") + @ApiModelProperty(value = "群聊id") + private Long groupId; + + @NotEmpty(message = "参与用户信息不可为空") + @ApiModelProperty(value = "参与用户信息") + private List userInfos; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/enums/MessageType.java b/im-platform/src/main/java/com/bx/implatform/enums/MessageType.java index 2200387..21537d4 100644 --- a/im-platform/src/main/java/com/bx/implatform/enums/MessageType.java +++ b/im-platform/src/main/java/com/bx/implatform/enums/MessageType.java @@ -1,9 +1,18 @@ package com.bx.implatform.enums; import lombok.AllArgsConstructor; -import lombok.Getter; +/** + * 0-9: 真正的消息,需要存储到数据库 + * 10-19: 状态类消息: 撤回、已读、回执 + * 20-29: 提示类消息: 在会话中间显示的提示 + * 30-39: UI交互类消息: 显示加载状态等 + * 40-49: 操作交互类消息: 语音通话、视频通话消息等 + * 100-199: 单人语音通话rtc信令 + * 200-299: 多人语音通话rtc信令 + * + */ @AllArgsConstructor public enum MessageType { @@ -27,6 +36,7 @@ public enum MessageType { * 视频 */ VIDEO(4, "视频"), + /** * 撤回 */ @@ -48,44 +58,41 @@ public enum MessageType { * 文字提示 */ TIP_TEXT(21,"文字提示"), - /** * 消息加载标记 */ - LOADDING(30,"加载中"), + LOADING(30,"加载中"), /** - * 语音呼叫 + * 语音通话提示 */ - RTC_CALL_VOICE(100, "语音呼叫"), + ACT_RT_VOICE(40,"语音通话"), /** - * 视频呼叫 + * 视频通话提示 */ + ACT_RT_VIDEO(41,"视频通话"), + + RTC_CALL_VOICE(100, "语音呼叫"), RTC_CALL_VIDEO(101, "视频呼叫"), - /** - * 接受 - */ RTC_ACCEPT(102, "接受"), - /** - * 拒绝 - */ RTC_REJECT(103, "拒绝"), - /** - * 取消呼叫 - */ RTC_CANCEL(104, "取消呼叫"), - /** - * 呼叫失败 - */ RTC_FAILED(105, "呼叫失败"), - /** - * 挂断 - */ RTC_HANDUP(106, "挂断"), - /** - * 同步candidate - */ - RTC_CANDIDATE(107, "同步candidate"); + RTC_CANDIDATE(107, "同步candidate"), + RTC_GROUP_SETUP(200,"发起群视频通话"), + RTC_GROUP_ACCEPT(201,"接受通话呼叫"), + RTC_GROUP_REJECT(202,"拒绝通话呼叫"), + RTC_GROUP_FAILED(203,"拒绝通话呼叫"), + RTC_GROUP_CANCEL(204,"取消通话呼叫"), + RTC_GROUP_QUIT(205,"退出通话"), + RTC_GROUP_INVITE(206,"邀请进入通话"), + RTC_GROUP_JOIN(207,"主动进入通话"), + RTC_GROUP_OFFER(208,"推送offer信息"), + RTC_GROUP_ANSWER(209,"推送answer信息"), + RTC_GROUP_CANDIDATE(210,"同步candidate"), + RTC_GROUP_DEVICE(211,"设备操作"), + ; private final Integer code; diff --git a/im-platform/src/main/java/com/bx/implatform/enums/WebrtcMode.java b/im-platform/src/main/java/com/bx/implatform/enums/WebrtcMode.java new file mode 100644 index 0000000..ebaf099 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/enums/WebrtcMode.java @@ -0,0 +1,27 @@ +package com.bx.implatform.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Getter +@AllArgsConstructor +public enum WebrtcMode { + + /** + * 视频通话 + */ + VIDEO( "video"), + + /** + * 语音通话 + */ + VOICE( "voice"); + + private final String value; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/service/IGroupMemberService.java b/im-platform/src/main/java/com/bx/implatform/service/IGroupMemberService.java index 7836729..bfa2440 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/IGroupMemberService.java +++ b/im-platform/src/main/java/com/bx/implatform/service/IGroupMemberService.java @@ -73,4 +73,12 @@ public interface IGroupMemberService extends IService { * @param userId 用户id */ void removeByGroupAndUserId(Long groupId, Long userId); + + /** + * 用户用户是否在群中 + * + * @param groupId 群聊id + * @param userIds 用户id + */ + Boolean isInGroup(Long groupId,List userIds); } diff --git a/im-platform/src/main/java/com/bx/implatform/service/IWebrtcGroupService.java b/im-platform/src/main/java/com/bx/implatform/service/IWebrtcGroupService.java new file mode 100644 index 0000000..edef9e6 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/service/IWebrtcGroupService.java @@ -0,0 +1,79 @@ +package com.bx.implatform.service; + +import com.bx.implatform.config.WebrtcConfig; +import com.bx.implatform.dto.*; +import com.bx.implatform.vo.WebrtcGroupInfoVO; + +public interface IWebrtcGroupService { + + /** + * 发起通话 + */ + void setup(WebrtcGroupSetupDTO dto); + + /** + * 接受通话 + */ + void accept(Long groupId); + + /** + * 拒绝通话 + */ + void reject(Long groupId); + + /** + * 通话失败,如设备不支持、用户忙等(此接口为系统自动调用,无需用户操作,所以不抛异常) + */ + void failed(WebrtcGroupFailedDTO dto); + + /** + * 主动加入通话 + */ + void join(Long groupId); + + /** + * 通话过程中继续邀请用户加入通话 + */ + void invite(WebrtcGroupInviteDTO dto); + + /** + * 取消通话,仅通话发起人可以取消通话 + */ + void cancel(Long groupId); + + /** + * 退出通话,如果当前没有人在通话中,将取消整个通话 + */ + void quit(Long groupId); + + /** + * 推送offer信息给对方 + */ + void offer(WebrtcGroupOfferDTO dto); + + /** + * 推送answer信息给对方 + */ + void answer(WebrtcGroupAnswerDTO dto); + + /** + * 推送candidate信息给对方 + */ + void candidate(WebrtcGroupCandidateDTO dto); + + /** + * 用户进行了设备操作,如果关闭摄像头 + */ + void device(WebrtcGroupDeviceDTO dto); + + /** + * 查询通话信息 + */ + WebrtcGroupInfoVO info(Long groupId); + + /** + * 心跳保持, 用户每15s上传一次心跳 + */ + void heartbeat(Long groupId); + +} diff --git a/im-platform/src/main/java/com/bx/implatform/service/IWebrtcService.java b/im-platform/src/main/java/com/bx/implatform/service/IWebrtcPrivateService.java similarity index 76% rename from im-platform/src/main/java/com/bx/implatform/service/IWebrtcService.java rename to im-platform/src/main/java/com/bx/implatform/service/IWebrtcPrivateService.java index b93858f..04dd4ad 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/IWebrtcService.java +++ b/im-platform/src/main/java/com/bx/implatform/service/IWebrtcPrivateService.java @@ -1,7 +1,6 @@ package com.bx.implatform.service; import com.bx.implatform.config.ICEServer; -import org.springframework.web.bind.annotation.RequestBody; import java.util.List; @@ -10,7 +9,7 @@ import java.util.List; * * @author */ -public interface IWebrtcService { +public interface IWebrtcPrivateService { void call(Long uid, String mode,String offer); @@ -26,7 +25,6 @@ public interface IWebrtcService { void candidate(Long uid, String candidate); - List getIceServers(); - + void heartbeat(Long uid); } diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/GroupMemberServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/GroupMemberServiceImpl.java index 3727add..592f761 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/GroupMemberServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/GroupMemberServiceImpl.java @@ -3,6 +3,7 @@ package com.bx.implatform.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bx.implatform.contant.RedisKey; @@ -34,30 +35,26 @@ public class GroupMemberServiceImpl extends ServiceImpl wrapper = new QueryWrapper<>(); - wrapper.lambda().eq(GroupMember::getGroupId, groupId) - .eq(GroupMember::getUserId, userId); + wrapper.lambda().eq(GroupMember::getGroupId, groupId).eq(GroupMember::getUserId, userId); return this.getOne(wrapper); } @Override public List findByUserId(Long userId) { LambdaQueryWrapper memberWrapper = Wrappers.lambdaQuery(); - memberWrapper.eq(GroupMember::getUserId, userId) - .eq(GroupMember::getQuit, false); + memberWrapper.eq(GroupMember::getUserId, userId).eq(GroupMember::getQuit, false); return this.list(memberWrapper); } @Override public List findQuitInMonth(Long userId) { - Date monthTime = DateTimeUtils.addMonths(new Date(),-1); + Date monthTime = DateTimeUtils.addMonths(new Date(), -1); LambdaQueryWrapper memberWrapper = Wrappers.lambdaQuery(); - memberWrapper.eq(GroupMember::getUserId, userId) - .eq(GroupMember::getQuit, true) - .ge(GroupMember::getQuitTime,monthTime); + memberWrapper.eq(GroupMember::getUserId, userId).eq(GroupMember::getQuit, true) + .ge(GroupMember::getQuitTime, monthTime); return this.list(memberWrapper); } @@ -72,9 +69,8 @@ public class GroupMemberServiceImpl extends ServiceImpl findUserIdsByGroupId(Long groupId) { LambdaQueryWrapper memberWrapper = Wrappers.lambdaQuery(); - memberWrapper.eq(GroupMember::getGroupId, groupId) - .eq(GroupMember::getQuit, false) - .select(GroupMember::getUserId); + memberWrapper.eq(GroupMember::getGroupId, groupId).eq(GroupMember::getQuit, false) + .select(GroupMember::getUserId); List members = this.list(memberWrapper); return members.stream().map(GroupMember::getUserId).collect(Collectors.toList()); } @@ -83,9 +79,8 @@ public class GroupMemberServiceImpl extends ServiceImpl wrapper = Wrappers.lambdaUpdate(); - wrapper.eq(GroupMember::getGroupId, groupId) - .set(GroupMember::getQuit, true) - .set(GroupMember::getQuitTime,new Date()); + wrapper.eq(GroupMember::getGroupId, groupId).set(GroupMember::getQuit, true) + .set(GroupMember::getQuitTime, new Date()); this.update(wrapper); } @@ -93,10 +88,19 @@ public class GroupMemberServiceImpl extends ServiceImpl wrapper = Wrappers.lambdaUpdate(); - wrapper.eq(GroupMember::getGroupId, groupId) - .eq(GroupMember::getUserId, userId) - .set(GroupMember::getQuit, true) - .set(GroupMember::getQuitTime,new Date()); + wrapper.eq(GroupMember::getGroupId, groupId).eq(GroupMember::getUserId, userId).set(GroupMember::getQuit, true) + .set(GroupMember::getQuitTime, new Date()); this.update(wrapper); } + + @Override + public Boolean isInGroup(Long groupId, List userIds) { + if (CollectionUtils.isEmpty(userIds)) { + return true; + } + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.eq(GroupMember::getGroupId, groupId).eq(GroupMember::getQuit, false) + .in(GroupMember::getUserId, userIds); + return userIds.size() == this.count(wrapper); + } } diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java index 1fb1795..5a8c45c 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java @@ -345,7 +345,6 @@ public class GroupMessageServiceImpl extends ServiceImpl wrapper = new QueryWrapper<>(); wrapper.lambda().eq(GroupMessage::getGroupId, groupId).gt(GroupMessage::getSendTime, member.getCreatedTime()) .ne(GroupMessage::getStatus, MessageStatus.RECALL.code()).orderByDesc(GroupMessage::getId).last("limit " + stIdx + "," + size); - List messages = this.list(wrapper); List messageInfos = messages.stream().map(m -> BeanUtils.copyProperties(m, GroupMessageVO.class)).collect(Collectors.toList()); @@ -369,7 +368,7 @@ public class GroupMessageServiceImpl extends ServiceImpl(); sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java index f3b4b59..feb13c8 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java @@ -50,6 +50,7 @@ public class GroupServiceImpl extends ServiceImpl implements private final IFriendService friendsService; private final IMClient imClient; private final RedisTemplate redisTemplate; + @Override public GroupVO createGroup(GroupVO vo) { UserSession session = SessionContext.getSession(); diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java index e97e0de..0e10922 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java @@ -9,7 +9,6 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bx.imclient.IMClient; import com.bx.imcommon.contant.IMConstant; import com.bx.imcommon.enums.IMTerminalType; -import com.bx.imcommon.model.IMGroupMessage; import com.bx.imcommon.model.IMPrivateMessage; import com.bx.imcommon.model.IMUserInfo; import com.bx.implatform.dto.PrivateMessageDTO; @@ -26,7 +25,6 @@ import com.bx.implatform.session.SessionContext; import com.bx.implatform.session.UserSession; import com.bx.implatform.util.BeanUtils; import com.bx.implatform.util.SensitiveFilterUtil; -import com.bx.implatform.vo.GroupMessageVO; import com.bx.implatform.vo.PrivateMessageVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -245,7 +243,7 @@ public class PrivateMessageServiceImpl extends ServiceImpl(); sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcGroupServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcGroupServiceImpl.java new file mode 100644 index 0000000..8851b32 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcGroupServiceImpl.java @@ -0,0 +1,585 @@ +package com.bx.implatform.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.bx.imclient.IMClient; +import com.bx.imcommon.model.IMGroupMessage; +import com.bx.imcommon.model.IMUserInfo; +import com.bx.implatform.annotation.OnlineCheck; +import com.bx.implatform.annotation.RedisLock; +import com.bx.implatform.config.WebrtcConfig; +import com.bx.implatform.contant.RedisKey; +import com.bx.implatform.dto.*; +import com.bx.implatform.entity.GroupMember; +import com.bx.implatform.entity.GroupMessage; +import com.bx.implatform.enums.MessageStatus; +import com.bx.implatform.enums.MessageType; +import com.bx.implatform.exception.GlobalException; +import com.bx.implatform.service.IGroupMemberService; +import com.bx.implatform.service.IGroupMessageService; +import com.bx.implatform.service.IWebrtcGroupService; +import com.bx.implatform.session.SessionContext; +import com.bx.implatform.session.UserSession; +import com.bx.implatform.session.WebrtcGroupSession; +import com.bx.implatform.session.WebrtcUserInfo; +import com.bx.implatform.util.BeanUtils; +import com.bx.implatform.util.UserStateUtils; +import com.bx.implatform.vo.GroupMessageVO; +import com.bx.implatform.vo.WebrtcGroupFailedVO; +import com.bx.implatform.vo.WebrtcGroupInfoVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 群语音通话服务类,所有涉及修改webtcSession的方法都要挂分布式锁 + * + * @author: blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WebrtcGroupServiceImpl implements IWebrtcGroupService { + + private final IGroupMemberService groupMemberService; + private final IGroupMessageService groupMessageService; + private final RedisTemplate redisTemplate; + private final IMClient imClient; + private final UserStateUtils userStateUtils; + private final WebrtcConfig webrtcConfig; + + + @OnlineCheck + @RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#dto.groupId") + @Override + public void setup(WebrtcGroupSetupDTO dto) { + UserSession userSession = SessionContext.getSession(); + if(!imClient.isOnline(userSession.getUserId())){ + throw new GlobalException("您已断开连接,请重新登陆"); + } + if (dto.getUserInfos().size() > webrtcConfig.getMaxChannel()) { + throw new GlobalException("最多支持" + webrtcConfig.getMaxChannel() + "人进行通话"); + } + List userIds = getRecvIds(dto.getUserInfos()); + if (!groupMemberService.isInGroup(dto.getGroupId(), userIds)) { + throw new GlobalException("部分用户不在群聊中"); + } + String key = buildWebrtcSessionKey(dto.getGroupId()); + if (redisTemplate.hasKey(key)) { + throw new GlobalException("该群聊已存在一个通话"); + } + // 有效用户 + List userInfos = new LinkedList<>(); + // 离线用户 + List offlineUserIds = new LinkedList<>(); + // 忙线用户 + List busyUserIds = new LinkedList<>(); + for (WebrtcUserInfo userInfo : dto.getUserInfos()) { + if (!imClient.isOnline(userInfo.getId())) { + //userInfos.add(userInfo); + offlineUserIds.add(userInfo.getId()); + } else if (userStateUtils.isBusy(userInfo.getId())) { + busyUserIds.add(userInfo.getId()); + } else { + userInfos.add(userInfo); + // 设置用户忙线状态 + userStateUtils.setBusy(userInfo.getId()); + } + } + // 创建通话session + WebrtcGroupSession webrtcSession = new WebrtcGroupSession(); + IMUserInfo userInfo = new IMUserInfo(userSession.getUserId(), userSession.getTerminal()); + webrtcSession.setHost(userInfo); + webrtcSession.setUserInfos(userInfos); + webrtcSession.getInChatUsers().add(userInfo); + saveWebrtcSession(dto.getGroupId(), webrtcSession); + // 向发起邀请者推送邀请失败消息 + if (!offlineUserIds.isEmpty()) { + WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO(); + vo.setUserIds(offlineUserIds); + vo.setReason("用户当前不在线"); + sendRtcMessage2(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), userInfo, JSON.toJSONString(vo)); + } + if (!busyUserIds.isEmpty()) { + WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO(); + vo.setUserIds(busyUserIds); + vo.setReason("用户正忙"); + IMUserInfo reciver = new IMUserInfo(userSession.getUserId(), userSession.getTerminal()); + sendRtcMessage2(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), reciver, JSON.toJSONString(vo)); + } + // 向被邀请的用户广播消息,发起呼叫 + List recvIds = getRecvIds(userInfos); + sendRtcMessage1(MessageType.RTC_GROUP_SETUP, dto.getGroupId(), recvIds, JSON.toJSONString(userInfos),false); + // 发送文字提示信息 + WebrtcUserInfo mineInfo = findUserInfo(webrtcSession,userSession.getUserId()); + String content = mineInfo.getNickName() + " 发起了语音通话"; + sendTipMessage(dto.getGroupId(),content); + log.info("发起群通话,userId:{},groupId:{}", userSession.getUserId(), dto.getGroupId()); + } + + @RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId") + @Override + public void accept(Long groupId) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(groupId); + // 校验 + if (!isExist(webrtcSession, userSession.getUserId())) { + throw new GlobalException("您未被邀请通话"); + } + // 防止重复进入 + if (isInchat(webrtcSession, userSession.getUserId())) { + throw new GlobalException("您已在通话中"); + } + // 将当前用户加入通话用户列表中 + webrtcSession.getInChatUsers().add(new IMUserInfo(userSession.getUserId(), userSession.getTerminal())); + saveWebrtcSession(groupId, webrtcSession); + // 广播信令 + List recvIds = getRecvIds(webrtcSession.getUserInfos()); + sendRtcMessage1(MessageType.RTC_GROUP_ACCEPT, groupId, recvIds, "",true); + log.info("加入群通话,userId:{},groupId:{}", userSession.getUserId(), groupId); + } + + @RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId") + @Override + public void reject(Long groupId) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(groupId); + // 校验 + if (!isExist(webrtcSession, userSession.getUserId())) { + throw new GlobalException("您未被邀请通话"); + } + // 防止重复进入 + if (isInchat(webrtcSession, userSession.getUserId())) { + throw new GlobalException("您已在通话中"); + } + // 将用户从列表中移除 + List userInfos = + webrtcSession.getUserInfos().stream().filter(user -> !user.getId().equals(userSession.getUserId())) + .collect(Collectors.toList()); + webrtcSession.setUserInfos(userInfos); + saveWebrtcSession(groupId, webrtcSession); + // 进入空闲状态 + userStateUtils.setFree(userSession.getUserId()); + // 广播消息给的所有用户 + List recvIds = getRecvIds(userInfos); + sendRtcMessage1(MessageType.RTC_GROUP_REJECT, groupId, recvIds, "",true); + log.info("拒绝群通话,userId:{},groupId:{}", userSession.getUserId(), groupId); + } + + @RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#dto.groupId") + @Override + public void failed(WebrtcGroupFailedDTO dto) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId()); + // 校验 + if (!isExist(webrtcSession, userSession.getUserId())) { + return; + } + if (isInchat(webrtcSession, userSession.getUserId())) { + return; + } + // 将用户从列表中移除 + List userInfos = + webrtcSession.getUserInfos().stream().filter(user -> !user.getId().equals(userSession.getUserId())) + .collect(Collectors.toList()); + webrtcSession.setUserInfos(userInfos); + saveWebrtcSession(dto.getGroupId(), webrtcSession); + // 进入空闲状态 + userStateUtils.setFree(userSession.getUserId()); + // 广播信令 + WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO(); + vo.setUserIds(Arrays.asList(userSession.getUserId())); + vo.setReason(dto.getReason()); + List recvIds = getRecvIds(userInfos); + sendRtcMessage1(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), recvIds, JSON.toJSONString(vo),false); + log.info("群通话失败,userId:{},groupId:{},原因:{}", userSession.getUserId(), dto.getReason()); + } + + @OnlineCheck + @RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId") + @Override + public void join(Long groupId) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(groupId); + if (webrtcSession.getUserInfos().size() >= webrtcConfig.getMaxChannel()) { + throw new GlobalException("人员已满,无法进入通话"); + } + GroupMember member = groupMemberService.findByGroupAndUserId(groupId, userSession.getUserId()); + if (Objects.isNull(member) || member.getQuit()) { + throw new GlobalException("您不在群里中"); + } + IMUserInfo mine = findInChatUser(webrtcSession, userSession.getUserId()); + if(!Objects.isNull(mine) && mine.getTerminal() != userSession.getTerminal()){ + throw new GlobalException("已在其他设备加入通话"); + } + WebrtcUserInfo userInfo = new WebrtcUserInfo(); + userInfo.setId(userSession.getUserId()); + userInfo.setNickName(member.getAliasName()); + userInfo.setHeadImage(member.getHeadImage()); + // 默认是开启麦克风,关闭摄像头 + userInfo.setIsCamera(false); + userInfo.setIsMicroPhone(true); + // 将当前用户加入通话用户列表中 + if (!isExist(webrtcSession, userSession.getUserId())) { + webrtcSession.getUserInfos().add(userInfo); + } + if (!isInchat(webrtcSession, userSession.getUserId())) { + webrtcSession.getInChatUsers().add(new IMUserInfo(userSession.getUserId(), userSession.getTerminal())); + } + saveWebrtcSession(groupId, webrtcSession); + // 进入忙线状态 + userStateUtils.setBusy(userSession.getUserId()); + // 广播信令 + List recvIds = getRecvIds(webrtcSession.getUserInfos()); + sendRtcMessage1(MessageType.RTC_GROUP_JOIN, groupId, recvIds, JSON.toJSONString(userInfo),false); + log.info("加入群通话,userId:{},groupId:{}", userSession.getUserId(), groupId); + } + + @OnlineCheck + @RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#dto.groupId") + @Override + public void invite(WebrtcGroupInviteDTO dto) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId()); + if (webrtcSession.getUserInfos().size() + dto.getUserInfos().size() > webrtcConfig.getMaxChannel()) { + throw new GlobalException("最多支持" + webrtcConfig.getMaxChannel() + "人进行通话"); + } + if (!groupMemberService.isInGroup(dto.getGroupId(), getRecvIds(dto.getUserInfos()))) { + throw new GlobalException("部分用户不在群聊中"); + } + // 过滤掉已经在通话中的用户 + List userInfos = webrtcSession.getUserInfos(); + // 原用户id + List userIds = getRecvIds(userInfos); + // 离线用户id + List offlineUserIds = new LinkedList<>(); + // 忙线用户 + List busyUserIds = new LinkedList<>(); + // 新加入的用户 + List newUserInfos = new LinkedList<>(); + for (WebrtcUserInfo userInfo : dto.getUserInfos()) { + if (isExist(webrtcSession, userInfo.getId())) { + // 防止重复进入 + continue; + } + if (!imClient.isOnline(userInfo.getId())) { + offlineUserIds.add(userInfo.getId()); +// userStateUtils.setBusy(userInfo.getId()); +// newUserInfos.add(userInfo); + } else if (userStateUtils.isBusy(userInfo.getId())) { + busyUserIds.add(userInfo.getId()); + } else { + // 进入忙线状态 + userStateUtils.setBusy(userInfo.getId()); + newUserInfos.add(userInfo); + } + } + // 更新会话信息 + userInfos.addAll(newUserInfos); + saveWebrtcSession(dto.getGroupId(), webrtcSession); + // 向发起邀请者推送邀请失败消息 + if (!offlineUserIds.isEmpty()) { + WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO(); + vo.setUserIds(offlineUserIds); + vo.setReason("用户当前不在线"); + IMUserInfo reciver = new IMUserInfo(userSession.getUserId(), userSession.getTerminal()); + sendRtcMessage2(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), reciver, JSON.toJSONString(vo)); + } + if (!busyUserIds.isEmpty()) { + WebrtcGroupFailedVO vo = new WebrtcGroupFailedVO(); + vo.setUserIds(busyUserIds); + vo.setReason("用户正在忙"); + IMUserInfo reciver = new IMUserInfo(userSession.getUserId(), userSession.getTerminal()); + sendRtcMessage2(MessageType.RTC_GROUP_FAILED, dto.getGroupId(), reciver, JSON.toJSONString(vo)); + } + // 向被邀请的发起呼叫 + List newUserIds = getRecvIds(newUserInfos); + sendRtcMessage1(MessageType.RTC_GROUP_SETUP, dto.getGroupId(), newUserIds, JSON.toJSONString(userInfos),false); + // 向已在通话中的用户同步新邀请的用户信息 + sendRtcMessage1(MessageType.RTC_GROUP_INVITE, dto.getGroupId(), userIds, JSON.toJSONString(newUserInfos),false); + log.info("邀请加入群通话,userId:{},groupId:{},邀请用户:{}", userSession.getUserId(), dto.getGroupId(), + newUserIds); + } + + @RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId") + @Override + public void cancel(Long groupId) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(groupId); + if (!userSession.getUserId().equals(webrtcSession.getHost().getId())) { + throw new GlobalException("只有发起人可以取消通话"); + } + // 移除rtc session + String key = buildWebrtcSessionKey(groupId); + redisTemplate.delete(key); + // 进入空闲状态 + webrtcSession.getUserInfos().forEach(user -> userStateUtils.setFree(user.getId())); + // 广播消息给的所有用户 + List recvIds = getRecvIds(webrtcSession.getUserInfos()); + sendRtcMessage1(MessageType.RTC_GROUP_CANCEL, groupId, recvIds, "",false); + // 发送文字提示信息 + sendTipMessage(groupId,"通话结束"); + log.info("发起人取消群通话,userId:{},groupId:{}", userSession.getUserId(), groupId); + } + + @RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#groupId") + @Override + public void quit(Long groupId) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(groupId); + // 将用户从列表中移除 + List inChatUsers = + webrtcSession.getInChatUsers().stream().filter(user -> !user.getId().equals(userSession.getUserId())) + .collect(Collectors.toList()); + List userInfos = + webrtcSession.getUserInfos().stream().filter(user -> !user.getId().equals(userSession.getUserId())) + .collect(Collectors.toList()); + + // 如果群聊中没有人已经接受了通话,则直接取消整个通话 + if (inChatUsers.isEmpty() || userInfos.isEmpty()) { + // 移除rtc session + String key = buildWebrtcSessionKey(groupId); + redisTemplate.delete(key); + // 进入空闲状态 + webrtcSession.getUserInfos().forEach(user -> userStateUtils.setFree(user.getId())); + // 广播给还在呼叫中的用户,取消通话 + List recvIds = getRecvIds(webrtcSession.getUserInfos()); + sendRtcMessage1(MessageType.RTC_GROUP_CANCEL, groupId, recvIds, "",false); + // 发送文字提示信息 + sendTipMessage(groupId,"通话结束"); + log.info("群通话结束,groupId:{}", groupId); + } else { + // 更新会话信息 + webrtcSession.setInChatUsers(inChatUsers); + webrtcSession.setUserInfos(userInfos); + saveWebrtcSession(groupId, webrtcSession); + // 进入空闲状态 + userStateUtils.setFree(userSession.getUserId()); + // 广播信令 + List recvIds = getRecvIds(userInfos); + sendRtcMessage1(MessageType.RTC_GROUP_QUIT, groupId, recvIds, "",false); + log.info("用户退出群通话,userId:{},groupId:{}", userSession.getUserId(), groupId); + } + } + + @Override + public void offer(WebrtcGroupOfferDTO dto) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId()); + IMUserInfo userInfo = findInChatUser(webrtcSession, dto.getUserId()); + if (Objects.isNull(userInfo)) { + log.info("对方未加入群通话,userId:{},对方id:{},groupId:{}", userSession.getUserId(), dto.getUserId(), + dto.getGroupId()); + return; + } + // 推送offer给对方 + sendRtcMessage2(MessageType.RTC_GROUP_OFFER, dto.getGroupId(), userInfo, dto.getOffer()); + log.info("推送offer信息,userId:{},对方id:{},groupId:{}", userSession.getUserId(), dto.getUserId(), + dto.getGroupId()); + } + + @Override + public void answer(WebrtcGroupAnswerDTO dto) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId()); + IMUserInfo userInfo = findInChatUser(webrtcSession, dto.getUserId()); + if (Objects.isNull(userInfo)) { + // 对方未加入群通话 + log.info("对方未加入群通话,userId:{},对方id:{},groupId:{}", userSession.getUserId(), dto.getUserId(), + dto.getGroupId()); + return; + } + // 推送answer信息给对方 + sendRtcMessage2(MessageType.RTC_GROUP_ANSWER, dto.getGroupId(), userInfo, dto.getAnswer()); + log.info("回复answer信息,userId:{},对方id:{},groupId:{}", userSession.getUserId(), dto.getUserId(), + dto.getGroupId()); + } + + @Override + public void candidate(WebrtcGroupCandidateDTO dto) { + UserSession userSession = SessionContext.getSession(); + WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId()); + IMUserInfo userInfo = findInChatUser(webrtcSession, dto.getUserId()); + if (Objects.isNull(userInfo)) { + // 对方未加入群通话 + log.info("对方未加入群通话,无法同步candidate,userId:{},remoteUserId:{},groupId:{}", userSession.getUserId(), + dto.getUserId(), dto.getGroupId()); + return; + } + // 推送candidate信息给对方 + sendRtcMessage2(MessageType.RTC_GROUP_CANDIDATE, dto.getGroupId(), userInfo, dto.getCandidate()); + log.info("同步candidate信息,userId:{},groupId:{}", userSession.getUserId(), dto.getGroupId()); + } + + @RedisLock(prefixKey = RedisKey.IM_LOCK_RTC_GROUP, key = "#dto.groupId") + @Override + public void device(WebrtcGroupDeviceDTO dto) { + UserSession userSession = SessionContext.getSession(); + // 查询会话信息 + WebrtcGroupSession webrtcSession = getWebrtcSession(dto.getGroupId()); + WebrtcUserInfo userInfo = findUserInfo(webrtcSession, userSession.getUserId()); + if (Objects.isNull(userInfo)) { + throw new GlobalException("您已不在通话中"); + } + // 更新设备状态 + userInfo.setIsCamera(dto.getIsCamera()); + userInfo.setIsMicroPhone(dto.getIsMicroPhone()); + saveWebrtcSession(dto.getGroupId(), webrtcSession); + // 广播信令 + List recvIds = getRecvIds(webrtcSession.getUserInfos()); + sendRtcMessage1(MessageType.RTC_GROUP_DEVICE, dto.getGroupId(), recvIds, JSON.toJSONString(dto),false); + log.info("设备操作,userId:{},groupId:{},摄像头:{}", userSession.getUserId(), dto.getGroupId(), + dto.getIsCamera()); + } + + @Override + public WebrtcGroupInfoVO info(Long groupId) { + WebrtcGroupInfoVO vo = new WebrtcGroupInfoVO(); + String key = buildWebrtcSessionKey(groupId); + WebrtcGroupSession webrtcSession = (WebrtcGroupSession)redisTemplate.opsForValue().get(key); + if (Objects.isNull(webrtcSession)) { + // 群聊当前没有通话 + vo.setIsChating(false); + } else { + // 群聊正在通话中 + vo.setIsChating(true); + vo.setUserInfos(webrtcSession.getUserInfos()); + Long hostId = webrtcSession.getHost().getId(); + WebrtcUserInfo host = findUserInfo(webrtcSession, hostId); + if (Objects.isNull(host)) { + // 如果发起人已经退出了通话,则从数据库查询发起人数据 + GroupMember member = groupMemberService.findByGroupAndUserId(groupId, hostId); + host = new WebrtcUserInfo(); + host.setId(hostId); + host.setNickName(member.getAliasName()); + host.setHeadImage(member.getHeadImage()); + } + vo.setHost(host); + } + return vo; + } + + @Override + public void heartbeat(Long groupId) { + UserSession userSession = SessionContext.getSession(); + // 给通话session续命 + String key = buildWebrtcSessionKey(groupId); + redisTemplate.expire(key, 30, TimeUnit.SECONDS); + // 用户忙线状态续命 + userStateUtils.expire(userSession.getUserId()); + } + + private WebrtcGroupSession getWebrtcSession(Long groupId) { + String key = buildWebrtcSessionKey(groupId); + WebrtcGroupSession webrtcSession = (WebrtcGroupSession)redisTemplate.opsForValue().get(key); + if (Objects.isNull(webrtcSession)) { + throw new GlobalException("通话已结束"); + } + return webrtcSession; + } + + private void saveWebrtcSession(Long groupId, WebrtcGroupSession webrtcSession) { + String key = buildWebrtcSessionKey(groupId); + redisTemplate.opsForValue().set(key, webrtcSession, 30, TimeUnit.SECONDS); + } + + private String buildWebrtcSessionKey(Long groupId) { + return StrUtil.join(":", RedisKey.IM_WEBRTC_GROUP_SESSION, groupId); + } + + private IMUserInfo findInChatUser(WebrtcGroupSession webrtcSession, Long userId) { + for (IMUserInfo userInfo : webrtcSession.getInChatUsers()) { + if (userInfo.getId().equals(userId)) { + return userInfo; + } + } + return null; + } + + private WebrtcUserInfo findUserInfo(WebrtcGroupSession webrtcSession, Long userId) { + for (WebrtcUserInfo userInfo : webrtcSession.getUserInfos()) { + if (userInfo.getId().equals(userId)) { + return userInfo; + } + } + return null; + } + + private List getRecvIds(List userInfos) { + UserSession userSession = SessionContext.getSession(); + return userInfos.stream().map(WebrtcUserInfo::getId).filter(id -> !id.equals(userSession.getUserId())) + .collect(Collectors.toList()); + } + + private Boolean isInchat(WebrtcGroupSession webrtcSession, Long userId) { + return webrtcSession.getInChatUsers().stream().anyMatch(user -> user.getId().equals(userId)); + } + + private Boolean isExist(WebrtcGroupSession webrtcSession, Long userId) { + return webrtcSession.getUserInfos().stream().anyMatch(user -> user.getId().equals(userId)); + } + + private void sendRtcMessage1(MessageType messageType, Long groupId, List recvIds, String content,Boolean sendSelf) { + UserSession userSession = SessionContext.getSession(); + GroupMessageVO messageInfo = new GroupMessageVO(); + messageInfo.setType(messageType.code()); + messageInfo.setGroupId(groupId); + messageInfo.setSendId(userSession.getUserId()); + messageInfo.setContent(content); + IMGroupMessage sendMessage = new IMGroupMessage<>(); + sendMessage.setSender(new IMUserInfo(userSession.getUserId(), userSession.getTerminal())); + sendMessage.setRecvIds(recvIds); + sendMessage.setSendToSelf(sendSelf); + sendMessage.setSendResult(false); + sendMessage.setData(messageInfo); + imClient.sendGroupMessage(sendMessage); + } + + private void sendRtcMessage2(MessageType messageType, Long groupId, IMUserInfo receiver, String content) { + UserSession userSession = SessionContext.getSession(); + GroupMessageVO messageInfo = new GroupMessageVO(); + messageInfo.setType(messageType.code()); + messageInfo.setGroupId(groupId); + messageInfo.setSendId(userSession.getUserId()); + messageInfo.setContent(content); + IMGroupMessage sendMessage = new IMGroupMessage<>(); + sendMessage.setSender(new IMUserInfo(userSession.getUserId(), userSession.getTerminal())); + sendMessage.setRecvIds(Arrays.asList(receiver.getId())); + sendMessage.setRecvTerminals(Arrays.asList(receiver.getTerminal())); + sendMessage.setSendToSelf(false); + sendMessage.setSendResult(false); + sendMessage.setData(messageInfo); + imClient.sendGroupMessage(sendMessage); + } + + private void sendTipMessage(Long groupId,String content){ + UserSession userSession = SessionContext.getSession(); + // 群聊成员列表 + List userIds = groupMemberService.findUserIdsByGroupId(groupId); + // 保存消息 + GroupMessage msg = new GroupMessage(); + msg.setGroupId(groupId); + msg.setContent(content); + msg.setSendId(userSession.getUserId()); + msg.setSendTime(new Date()); + msg.setStatus(MessageStatus.UNSEND.code()); + msg.setSendNickName(userSession.getNickName()); + msg.setType(MessageType.TIP_TEXT.code()); + groupMessageService.save(msg); + // 群发罅隙 + GroupMessageVO msgInfo = BeanUtils.copyProperties(msg, GroupMessageVO.class); + IMGroupMessage sendMessage = new IMGroupMessage<>(); + sendMessage.setSender(new IMUserInfo(userSession.getUserId(), userSession.getTerminal())); + sendMessage.setRecvIds(userIds); + sendMessage.setSendResult(false); + sendMessage.setData(msgInfo); + imClient.sendGroupMessage(sendMessage); + }; +} diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcPrivateServiceImpl.java similarity index 60% rename from im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcServiceImpl.java rename to im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcPrivateServiceImpl.java index 5b2ccad..ac9ca1a 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcPrivateServiceImpl.java @@ -3,15 +3,19 @@ package com.bx.implatform.service.impl; import com.bx.imclient.IMClient; import com.bx.imcommon.model.IMPrivateMessage; import com.bx.imcommon.model.IMUserInfo; -import com.bx.implatform.config.ICEServer; -import com.bx.implatform.config.ICEServerConfig; import com.bx.implatform.contant.RedisKey; +import com.bx.implatform.entity.PrivateMessage; +import com.bx.implatform.enums.MessageStatus; import com.bx.implatform.enums.MessageType; +import com.bx.implatform.enums.WebrtcMode; import com.bx.implatform.exception.GlobalException; -import com.bx.implatform.service.IWebrtcService; +import com.bx.implatform.service.IPrivateMessageService; +import com.bx.implatform.service.IWebrtcPrivateService; import com.bx.implatform.session.SessionContext; import com.bx.implatform.session.UserSession; -import com.bx.implatform.session.WebrtcSession; +import com.bx.implatform.session.WebrtcPrivateSession; +import com.bx.implatform.util.BeanUtils; +import com.bx.implatform.util.UserStateUtils; import com.bx.implatform.vo.PrivateMessageVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,33 +24,47 @@ import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.RequestBody; import java.util.Collections; -import java.util.List; +import java.util.Date; import java.util.concurrent.TimeUnit; @Slf4j @Service @RequiredArgsConstructor -public class WebrtcServiceImpl implements IWebrtcService { +public class WebrtcPrivateServiceImpl implements IWebrtcPrivateService { private final IMClient imClient; private final RedisTemplate redisTemplate; - private final ICEServerConfig iceServerConfig; + private final IPrivateMessageService privateMessageService; + private final UserStateUtils userStateUtils; @Override public void call(Long uid, String mode, String offer) { UserSession session = SessionContext.getSession(); - if (!imClient.isOnline(uid)) { - throw new GlobalException("对方目前不在线"); - } // 创建webrtc会话 - WebrtcSession webrtcSession = new WebrtcSession(); + WebrtcPrivateSession webrtcSession = new WebrtcPrivateSession(); webrtcSession.setCallerId(session.getUserId()); webrtcSession.setCallerTerminal(session.getTerminal()); - String key = getSessionKey(session.getUserId(), uid); - redisTemplate.opsForValue().set(key, webrtcSession, 12, TimeUnit.HOURS); + webrtcSession.setAcceptorId(uid); + webrtcSession.setMode(mode); + // 校验 + if (!imClient.isOnline(uid)) { + this.sendActMessage(webrtcSession,MessageStatus.UNSEND,"未接通"); + throw new GlobalException("对方目前不在线"); + } + if (userStateUtils.isBusy(uid)) { + this.sendActMessage(webrtcSession,MessageStatus.UNSEND,"未接通"); + throw new GlobalException("对方正忙"); + } + // 保存rtc session + String key = getWebRtcSessionKey(session.getUserId(), uid); + redisTemplate.opsForValue().set(key, webrtcSession, 60, TimeUnit.SECONDS); + // 设置用户忙线状态 + userStateUtils.setBusy(uid); + userStateUtils.setBusy(session.getUserId()); // 向对方所有终端发起呼叫 PrivateMessageVO messageInfo = new PrivateMessageVO(); - MessageType messageType = mode.equals("video") ? MessageType.RTC_CALL_VIDEO : MessageType.RTC_CALL_VOICE; + MessageType messageType = + mode.equals(WebrtcMode.VIDEO.getValue()) ? MessageType.RTC_CALL_VIDEO : MessageType.RTC_CALL_VOICE; messageInfo.setType(messageType.code()); messageInfo.setRecvId(uid); messageInfo.setSendId(session.getUserId()); @@ -66,12 +84,13 @@ public class WebrtcServiceImpl implements IWebrtcService { public void accept(Long uid, @RequestBody String answer) { UserSession session = SessionContext.getSession(); // 查询webrtc会话 - WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); + WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid); // 更新接受者信息 webrtcSession.setAcceptorId(session.getUserId()); webrtcSession.setAcceptorTerminal(session.getTerminal()); - String key = getSessionKey(session.getUserId(), uid); - redisTemplate.opsForValue().set(key, webrtcSession, 12, TimeUnit.HOURS); + webrtcSession.setChatTimeStamp(System.currentTimeMillis()); + String key = getWebRtcSessionKey(session.getUserId(), uid); + redisTemplate.opsForValue().set(key, webrtcSession, 60, TimeUnit.SECONDS); // 向发起人推送接受通话信令 PrivateMessageVO messageInfo = new PrivateMessageVO(); messageInfo.setType(MessageType.RTC_ACCEPT.code()); @@ -94,9 +113,12 @@ public class WebrtcServiceImpl implements IWebrtcService { public void reject(Long uid) { UserSession session = SessionContext.getSession(); // 查询webrtc会话 - WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); + WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid); // 删除会话信息 removeWebrtcSession(uid, session.getUserId()); + // 设置用户空闲状态 + userStateUtils.setFree(uid); + userStateUtils.setFree(session.getUserId()); // 向发起人推送拒绝通话信令 PrivateMessageVO messageInfo = new PrivateMessageVO(); messageInfo.setType(MessageType.RTC_REJECT.code()); @@ -112,13 +134,20 @@ public class WebrtcServiceImpl implements IWebrtcService { sendMessage.setRecvTerminals(Collections.singletonList(webrtcSession.getCallerTerminal())); sendMessage.setData(messageInfo); imClient.sendPrivateMessage(sendMessage); + // 生成通话消息 + sendActMessage(webrtcSession, MessageStatus.READED,"已拒绝"); } @Override public void cancel(Long uid) { UserSession session = SessionContext.getSession(); + // 查询webrtc会话 + WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid); // 删除会话信息 removeWebrtcSession(session.getUserId(), uid); + // 设置用户空闲状态 + userStateUtils.setFree(uid); + userStateUtils.setFree(session.getUserId()); // 向对方所有终端推送取消通话信令 PrivateMessageVO messageInfo = new PrivateMessageVO(); messageInfo.setType(MessageType.RTC_CANCEL.code()); @@ -133,15 +162,20 @@ public class WebrtcServiceImpl implements IWebrtcService { sendMessage.setData(messageInfo); // 通知对方取消会话 imClient.sendPrivateMessage(sendMessage); + // 生成通话消息 + sendActMessage(webrtcSession, MessageStatus.UNSEND,"已取消"); } @Override public void failed(Long uid, String reason) { UserSession session = SessionContext.getSession(); // 查询webrtc会话 - WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); + WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid); // 删除会话信息 removeWebrtcSession(uid, session.getUserId()); + // 设置用户空闲状态 + userStateUtils.setFree(uid); + userStateUtils.setFree(session.getUserId()); // 向发起方推送通话失败信令 PrivateMessageVO messageInfo = new PrivateMessageVO(); messageInfo.setType(MessageType.RTC_FAILED.code()); @@ -158,16 +192,20 @@ public class WebrtcServiceImpl implements IWebrtcService { sendMessage.setData(messageInfo); // 通知对方取消会话 imClient.sendPrivateMessage(sendMessage); - + // 生成消息 + sendActMessage(webrtcSession, MessageStatus.READED,"未接通"); } @Override public void handup(Long uid) { UserSession session = SessionContext.getSession(); // 查询webrtc会话 - WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); + WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid); // 删除会话信息 removeWebrtcSession(uid, session.getUserId()); + // 设置用户空闲状态 + userStateUtils.setFree(uid); + userStateUtils.setFree(session.getUserId()); // 向对方推送挂断通话信令 PrivateMessageVO messageInfo = new PrivateMessageVO(); messageInfo.setType(MessageType.RTC_HANDUP.code()); @@ -184,13 +222,15 @@ public class WebrtcServiceImpl implements IWebrtcService { sendMessage.setData(messageInfo); // 通知对方取消会话 imClient.sendPrivateMessage(sendMessage); + // 生成通话消息 + sendActMessage(webrtcSession, MessageStatus.READED,"通话时长 " + chatTimeText(webrtcSession)); } @Override public void candidate(Long uid, String candidate) { UserSession session = SessionContext.getSession(); // 查询webrtc会话 - WebrtcSession webrtcSession = getWebrtcSession(session.getUserId(), uid); + WebrtcPrivateSession webrtcSession = getWebrtcSession(session.getUserId(), uid); // 向发起方推送同步candidate信令 PrivateMessageVO messageInfo = new PrivateMessageVO(); messageInfo.setType(MessageType.RTC_CANDIDATE.code()); @@ -210,13 +250,18 @@ public class WebrtcServiceImpl implements IWebrtcService { } @Override - public List getIceServers() { - return iceServerConfig.getIceServers(); + public void heartbeat(Long uid) { + UserSession session = SessionContext.getSession(); + // 会话续命 + String key = getWebRtcSessionKey(session.getUserId(), uid); + redisTemplate.expire(key, 60, TimeUnit.SECONDS); + // 用户状态续命 + userStateUtils.expire(session.getUserId()); } - private WebrtcSession getWebrtcSession(Long userId, Long uid) { - String key = getSessionKey(userId, uid); - WebrtcSession webrtcSession = (WebrtcSession)redisTemplate.opsForValue().get(key); + private WebrtcPrivateSession getWebrtcSession(Long userId, Long uid) { + String key = getWebRtcSessionKey(userId, uid); + WebrtcPrivateSession webrtcSession = (WebrtcPrivateSession)redisTemplate.opsForValue().get(key); if (webrtcSession == null) { throw new GlobalException("通话已结束"); } @@ -224,21 +269,59 @@ public class WebrtcServiceImpl implements IWebrtcService { } private void removeWebrtcSession(Long userId, Long uid) { - String key = getSessionKey(userId, uid); + String key = getWebRtcSessionKey(userId, uid); redisTemplate.delete(key); } - private String getSessionKey(Long id1, Long id2) { + private String getWebRtcSessionKey(Long id1, Long id2) { Long minId = id1 > id2 ? id2 : id1; Long maxId = id1 > id2 ? id1 : id2; - return String.join(":", RedisKey.IM_WEBRTC_SESSION, minId.toString(), maxId.toString()); + return String.join(":", RedisKey.IM_WEBRTC_PRIVATE_SESSION, minId.toString(), maxId.toString()); } - private Integer getTerminalType(Long uid, WebrtcSession webrtcSession) { + private Integer getTerminalType(Long uid, WebrtcPrivateSession webrtcSession) { if (uid.equals(webrtcSession.getCallerId())) { return webrtcSession.getCallerTerminal(); } return webrtcSession.getAcceptorTerminal(); } + private void sendActMessage(WebrtcPrivateSession rtcSession, MessageStatus status,String content) { + // 保存消息 + PrivateMessage msg = new PrivateMessage(); + msg.setSendId(rtcSession.getCallerId()); + msg.setRecvId(rtcSession.getAcceptorId()); + msg.setContent(content); + msg.setSendTime(new Date()); + msg.setStatus(status.code()); + MessageType type = rtcSession.getMode().equals(WebrtcMode.VIDEO.getValue()) ? MessageType.ACT_RT_VIDEO + : MessageType.ACT_RT_VOICE; + msg.setType(type.code()); + privateMessageService.save(msg); + // 推给发起人 + PrivateMessageVO messageInfo = BeanUtils.copyProperties(msg, PrivateMessageVO.class); + IMPrivateMessage sendMessage = new IMPrivateMessage<>(); + sendMessage.setSender(new IMUserInfo(rtcSession.getCallerId(), rtcSession.getCallerTerminal())); + sendMessage.setRecvId(rtcSession.getCallerId()); + sendMessage.setSendToSelf(false); + sendMessage.setSendResult(false); + sendMessage.setData(messageInfo); + imClient.sendPrivateMessage(sendMessage); + // 推给接听方 + sendMessage.setRecvId(rtcSession.getAcceptorId()); + imClient.sendPrivateMessage(sendMessage); + } + + private String chatTimeText(WebrtcPrivateSession rtcSession) { + long chatTime = (System.currentTimeMillis() - rtcSession.getChatTimeStamp())/1000; + int min = Math.abs((int)chatTime / 60); + int sec = Math.abs((int)chatTime % 60); + String strTime = min < 10 ? "0" : ""; + strTime += min; + strTime += ":"; + strTime += sec < 10 ? "0" : ""; + strTime += sec; + return strTime; + } + } diff --git a/im-platform/src/main/java/com/bx/implatform/session/WebrtcGroupSession.java b/im-platform/src/main/java/com/bx/implatform/session/WebrtcGroupSession.java new file mode 100644 index 0000000..098c724 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/session/WebrtcGroupSession.java @@ -0,0 +1,33 @@ +package com.bx.implatform.session; + +import com.bx.imcommon.model.IMUserInfo; +import lombok.Data; + +import java.util.LinkedList; +import java.util.List; + +/** + * @author: Blue + * @date: 2024-06-01 + * @version: 1.0 + */ +@Data +public class WebrtcGroupSession { + + /** + * 通话发起者 + */ + private IMUserInfo host; + + /** + * 所有被邀请的用户列表 + */ + private List userInfos; + + /** + * 已经进入通话的用户列表 + */ + private List inChatUsers = new LinkedList<>(); + + +} diff --git a/im-platform/src/main/java/com/bx/implatform/session/WebrtcSession.java b/im-platform/src/main/java/com/bx/implatform/session/WebrtcPrivateSession.java similarity index 70% rename from im-platform/src/main/java/com/bx/implatform/session/WebrtcSession.java rename to im-platform/src/main/java/com/bx/implatform/session/WebrtcPrivateSession.java index a26f298..3d74cd1 100644 --- a/im-platform/src/main/java/com/bx/implatform/session/WebrtcSession.java +++ b/im-platform/src/main/java/com/bx/implatform/session/WebrtcPrivateSession.java @@ -8,7 +8,7 @@ import lombok.Data; * @Date 2022/10/21 */ @Data -public class WebrtcSession { +public class WebrtcPrivateSession { /** * 发起者id */ @@ -27,4 +27,13 @@ public class WebrtcSession { * 接受者终端类型 */ private Integer acceptorTerminal; + + /** + * 通话模式 + */ + private String mode; + /** + * 开始聊天时间戳 + */ + private Long chatTimeStamp; } diff --git a/im-platform/src/main/java/com/bx/implatform/session/WebrtcUserInfo.java b/im-platform/src/main/java/com/bx/implatform/session/WebrtcUserInfo.java new file mode 100644 index 0000000..5bae898 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/session/WebrtcUserInfo.java @@ -0,0 +1,29 @@ +package com.bx.implatform.session; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** + * @author: Blue + * @date: 2024-06-02 + * @version: 1.0 + */ +@Data +@ApiModel("用户信息") +public class WebrtcUserInfo { + @ApiModelProperty(value = "用户id") + private Long id; + + @ApiModelProperty(value = "用户昵称") + private String nickName; + + @ApiModelProperty(value = "用户头像") + private String headImage; + + @ApiModelProperty(value = "是否开启摄像头") + private Boolean isCamera; + + @ApiModelProperty(value = "是否开启麦克风") + private Boolean isMicroPhone; +} diff --git a/im-platform/src/main/java/com/bx/implatform/util/UserStateUtils.java b/im-platform/src/main/java/com/bx/implatform/util/UserStateUtils.java new file mode 100644 index 0000000..567737e --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/util/UserStateUtils.java @@ -0,0 +1,44 @@ +package com.bx.implatform.util; + +import cn.hutool.core.util.StrUtil; +import com.bx.implatform.contant.RedisKey; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * @author: Blue + * @date: 2024-06-10 + * @version: 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserStateUtils { + + private final RedisTemplate redisTemplate; + + public void setBusy(Long userId){ + String key = StrUtil.join(":", RedisKey.IM_USER_STATE,userId); + redisTemplate.opsForValue().set(key,1,30, TimeUnit.SECONDS); + } + + public void expire(Long userId){ + String key = StrUtil.join(":", RedisKey.IM_USER_STATE,userId); + redisTemplate.expire(key,30, TimeUnit.SECONDS); + } + + public void setFree(Long userId){ + String key = StrUtil.join(":", RedisKey.IM_USER_STATE,userId); + redisTemplate.delete(key); + } + + public Boolean isBusy(Long userId){ + String key = StrUtil.join(":", RedisKey.IM_USER_STATE,userId); + return redisTemplate.hasKey(key); + } + +} diff --git a/im-platform/src/main/java/com/bx/implatform/vo/OnlineTerminalVO.java b/im-platform/src/main/java/com/bx/implatform/vo/OnlineTerminalVO.java index 4aed6c1..67e5413 100644 --- a/im-platform/src/main/java/com/bx/implatform/vo/OnlineTerminalVO.java +++ b/im-platform/src/main/java/com/bx/implatform/vo/OnlineTerminalVO.java @@ -7,7 +7,7 @@ import lombok.Data; import java.util.List; /** - * @author: 谢绍许 + * @author: Blue * @date: 2023-10-28 21:17:59 * @version: 1.0 */ diff --git a/im-platform/src/main/java/com/bx/implatform/vo/SystemConfigVO.java b/im-platform/src/main/java/com/bx/implatform/vo/SystemConfigVO.java new file mode 100644 index 0000000..f8e936e --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/vo/SystemConfigVO.java @@ -0,0 +1,22 @@ +package com.bx.implatform.vo; + +import com.bx.implatform.config.WebrtcConfig; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * @author: blue + * @date: 2024-06-10 + * @version: 1.0 + */ +@Data +@ApiModel("系统配置VO") +@AllArgsConstructor +public class SystemConfigVO { + + @ApiModelProperty(value = "webrtc配置") + private WebrtcConfig webrtc; + +} diff --git a/im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupFailedVO.java b/im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupFailedVO.java new file mode 100644 index 0000000..5c7b988 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupFailedVO.java @@ -0,0 +1,23 @@ +package com.bx.implatform.vo; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.util.List; + +/** + * @author: Blue + * @date: 2024-06-09 + * @version: 1.0 + */ +@Data +@ApiModel("用户加入群通话失败VO") +public class WebrtcGroupFailedVO { + + @ApiModelProperty(value = "失败用户列表") + private List userIds; + + @ApiModelProperty(value = "失败原因") + private String reason; +} diff --git a/im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupInfoVO.java b/im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupInfoVO.java new file mode 100644 index 0000000..bf27527 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/vo/WebrtcGroupInfoVO.java @@ -0,0 +1,28 @@ +package com.bx.implatform.vo; + +import com.bx.implatform.session.WebrtcUserInfo; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.util.List; + +/** + * @author: Blue + * @date: 2024-06-09 + * @version: 1.0 + */ +@Data +@ApiModel("群通话信息VO") +public class WebrtcGroupInfoVO { + + + @ApiModelProperty(value = "是否在通话中") + private Boolean isChating; + + @ApiModelProperty(value = "通话发起人") + WebrtcUserInfo host; + + @ApiModelProperty(value = "通话用户列表") + private List userInfos; +} diff --git a/im-platform/src/main/resources/application.yml b/im-platform/src/main/resources/application.yml index b195ce1..8e0cbe8 100644 --- a/im-platform/src/main/resources/application.yml +++ b/im-platform/src/main/resources/application.yml @@ -39,6 +39,7 @@ minio: videoPath: video webrtc: + max-channel: 9 # 多人通话最大通道数量,最大不能超过16,建议值:4,9,16 iceServers: - urls: stun:stun.l.google.com:19302 diff --git a/im-ui/src/App.vue b/im-ui/src/App.vue index b60f612..60aa2b0 100644 --- a/im-ui/src/App.vue +++ b/im-ui/src/App.vue @@ -85,4 +85,31 @@ .el-button { padding: 8px 15px !important; } + +.el-checkbox { + display: flex; + align-items: center; + + //修改选中框的大小 + .el-checkbox__inner { + width: 20px; + height: 20px; + + //修改选中框中的对勾的大小和位置 + &::after { + height: 12px; + left: 7px; + } + } + + //修改点击文字颜色不变 + .el-checkbox__input.is-checked+.el-checkbox__label { + color: #333333; + } + + .el-checkbox__label { + line-height: 20px; + padding-left: 8px; + } +} \ No newline at end of file diff --git a/im-ui/src/api/camera.js b/im-ui/src/api/camera.js new file mode 100644 index 0000000..0cb5f0e --- /dev/null +++ b/im-ui/src/api/camera.js @@ -0,0 +1,76 @@ + +class ImCamera { + constructor() { + this.stream = null; + } +} + +ImCamera.prototype.isEnable = function() { + return !!navigator && !!navigator.mediaDevices && !!navigator.mediaDevices.getUserMedia; +} + +ImCamera.prototype.openVideo = function() { + return new Promise((resolve, reject) => { + if(this.stream){ + this.close() + } + let constraints = { + video: { + with: window.screen.width, + height: window.screen.height + }, + audio: { + echoCancellation: true, //音频开启回音消除 + noiseSuppression: true // 开启降噪 + } + } + console.log("getUserMedia") + navigator.mediaDevices.getUserMedia(constraints).then((stream) => { + console.log("摄像头打开") + this.stream = stream; + resolve(stream); + }).catch((e) => { + console.log(e) + console.log("摄像头未能正常打开") + reject({ + code: 0, + message: "摄像头未能正常打开" + }) + }) + }) +} + + +ImCamera.prototype.openAudio = function() { + return new Promise((resolve, reject) => { + let constraints = { + video: false, + audio: { + echoCancellation: true, //音频开启回音消除 + noiseSuppression: true // 开启降噪 + } + } + navigator.mediaDevices.getUserMedia(constraints).then((stream) => { + this.stream = stream; + resolve(stream); + }).catch(() => { + console.log("麦克风未能正常打开") + reject({ + code: 0, + message: "麦克风未能正常打开" + }) + }) + + }) +} + +ImCamera.prototype.close = function() { + // 停止流 + if (this.stream) { + this.stream.getTracks().forEach((track) => { + track.stop(); + }); + } +} + +export default ImCamera; \ No newline at end of file diff --git a/im-ui/src/api/enums.js b/im-ui/src/api/enums.js index 0e6a1b1..0fcb7eb 100644 --- a/im-ui/src/api/enums.js +++ b/im-ui/src/api/enums.js @@ -1,18 +1,17 @@ - const MESSAGE_TYPE = { TEXT: 0, - IMAGE:1, - FILE:2, - AUDIO:3, - VIDEO:4, - RT_VOICE:5, - RT_VIDEO:6, - RECALL:10, - READED:11, - RECEIPT:12, - TIP_TIME:20, - TIP_TEXT:21, - LOADDING:30, + IMAGE: 1, + FILE: 2, + AUDIO: 3, + VIDEO: 4, + RECALL: 10, + READED: 11, + RECEIPT: 12, + TIP_TIME: 20, + TIP_TEXT: 21, + LOADING: 30, + ACT_RT_VOICE: 40, + ACT_RT_VIDEO: 41, RTC_CALL_VOICE: 100, RTC_CALL_VIDEO: 101, RTC_ACCEPT: 102, @@ -20,7 +19,19 @@ const MESSAGE_TYPE = { RTC_CANCEL: 104, RTC_FAILED: 105, RTC_HANDUP: 106, - RTC_CANDIDATE: 107 + RTC_CANDIDATE: 107, + RTC_GROUP_SETUP: 200, + RTC_GROUP_ACCEPT: 201, + RTC_GROUP_REJECT: 202, + RTC_GROUP_FAILED: 203, + RTC_GROUP_CANCEL: 204, + RTC_GROUP_QUIT: 205, + RTC_GROUP_INVITE: 206, + RTC_GROUP_JOIN: 207, + RTC_GROUP_OFFER: 208, + RTC_GROUP_ANSWER: 209, + RTC_GROUP_CANDIDATE: 210, + RTC_GROUP_DEVICE: 211 } const RTC_STATE = { @@ -28,7 +39,7 @@ const RTC_STATE = { WAIT_CALL: 1, // 呼叫后等待 WAIT_ACCEPT: 2, // 被呼叫后等待 ACCEPTED: 3, // 已接受聊天,等待建立连接 - CHATING:4 // 聊天中 + CHATING: 4 // 聊天中 } const TERMINAL_TYPE = { @@ -39,8 +50,8 @@ const TERMINAL_TYPE = { const MESSAGE_STATUS = { UNSEND: 0, SENDED: 1, - RECALL:2, - READED:3 + RECALL: 2, + READED: 3 } @@ -49,4 +60,4 @@ export { RTC_STATE, TERMINAL_TYPE, MESSAGE_STATUS -} +} \ No newline at end of file diff --git a/im-ui/src/api/eventBus.js b/im-ui/src/api/eventBus.js new file mode 100644 index 0000000..a72b416 --- /dev/null +++ b/im-ui/src/api/eventBus.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); \ No newline at end of file diff --git a/im-ui/src/api/messageType.js b/im-ui/src/api/messageType.js new file mode 100644 index 0000000..4ab602c --- /dev/null +++ b/im-ui/src/api/messageType.js @@ -0,0 +1,40 @@ + +// 是否普通消息 +let isNormal = function(type){ + return type>=0 && type < 10; +} + +// 是否状态消息 +let isStatus = function(type){ + return type>=10 && type < 20; +} + +// 是否提示消息 +let isTip = function(type){ + return type>=20 && type < 30; +} + +// 操作交互类消息 +let isAction = function(type){ + return type>=40 && type < 50; +} + +// 单人通话信令 +let isRtcPrivate = function(type){ + return type>=100 && type < 300; +} + +// 多人通话信令 +let isRtcGroup = function(type){ + return type>=200 && type < 400; +} + + +export { + isNormal, + isStatus, + isTip, + isAction, + isRtcPrivate, + isRtcGroup +} \ No newline at end of file diff --git a/im-ui/src/api/rtcGroupApi.js b/im-ui/src/api/rtcGroupApi.js new file mode 100644 index 0000000..42fc4f0 --- /dev/null +++ b/im-ui/src/api/rtcGroupApi.js @@ -0,0 +1,151 @@ +import http from './httpRequest.js' + +class RtcGroupApi {} + +RtcGroupApi.prototype.setup = function(groupId, userInfos) { + let formData = { + groupId, + userInfos + } + return http({ + url: '/webrtc/group/setup', + method: 'post', + data: formData + }) +} + +RtcGroupApi.prototype.accept = function(groupId) { + return http({ + url: '/webrtc/group/accept?groupId='+groupId, + method: 'post' + }) +} + +RtcGroupApi.prototype.reject = function(groupId) { + return http({ + url: '/webrtc/group/reject?groupId='+groupId, + method: 'post' + }) +} + +RtcGroupApi.prototype.failed = function(groupId,reason) { + let formData = { + groupId, + reason + } + return http({ + url: '/webrtc/group/failed', + method: 'post', + data: formData + }) +} + + +RtcGroupApi.prototype.join = function(groupId) { + return http({ + url: '/webrtc/group/join?groupId='+groupId, + method: 'post' + }) +} + +RtcGroupApi.prototype.invite = function(groupId, userInfos) { + let formData = { + groupId, + userInfos + } + return http({ + url: '/webrtc/group/invite', + method: 'post', + data: formData + }) +} + + +RtcGroupApi.prototype.offer = function(groupId, userId, offer) { + let formData = { + groupId, + userId, + offer + } + return http({ + url: '/webrtc/group/offer', + method: 'post', + data: formData + }) +} + +RtcGroupApi.prototype.answer = function(groupId, userId, answer) { + let formData = { + groupId, + userId, + answer + } + return http({ + url: '/webrtc/group/answer', + method: 'post', + data: formData + }) +} + +RtcGroupApi.prototype.quit = function(groupId) { + return http({ + url: '/webrtc/group/quit?groupId=' + groupId, + method: 'post' + }) +} + +RtcGroupApi.prototype.cancel = function(groupId) { + return http({ + url: '/webrtc/group/cancel?groupId=' + groupId, + method: 'post' + }) +} + +RtcGroupApi.prototype.candidate = function(groupId, userId, candidate) { + let formData = { + groupId, + userId, + candidate + } + return http({ + url: '/webrtc/group/candidate', + method: 'post', + data: formData + }) +} + +RtcGroupApi.prototype.device = function(groupId, isCamera, isMicroPhone) { + let formData = { + groupId, + isCamera, + isMicroPhone + } + return http({ + url: '/webrtc/group/device', + method: 'post', + data: formData + }) +} + + +RtcGroupApi.prototype.candidate = function(groupId, userId, candidate) { + let formData = { + groupId, + userId, + candidate + } + return http({ + url: '/webrtc/group/candidate', + method: 'post', + data: formData + }) +} + +RtcGroupApi.prototype.heartbeat = function(groupId) { + return http({ + url: '/webrtc/group/heartbeat?groupId=' + groupId, + method: 'post' + }) +} + +export default RtcGroupApi; \ No newline at end of file diff --git a/im-ui/src/api/rtcPrivateApi.js b/im-ui/src/api/rtcPrivateApi.js new file mode 100644 index 0000000..687ee65 --- /dev/null +++ b/im-ui/src/api/rtcPrivateApi.js @@ -0,0 +1,66 @@ +import http from './httpRequest.js' + +class RtcPrivateApi { +} + +RtcPrivateApi.prototype.call = function(uid, mode, offer) { + return http({ + url: `/webrtc/private/call?uid=${uid}&mode=${mode}`, + method: 'post', + data: JSON.stringify(offer) + }) +} + +RtcPrivateApi.prototype.accept = function(uid, answer) { + return http({ + url: `/webrtc/private/accept?uid=${uid}`, + method: 'post', + data: JSON.stringify(answer) + }) +} + + +RtcPrivateApi.prototype.handup = function(uid) { + return http({ + url: `/webrtc/private/handup?uid=${uid}`, + method: 'post' + }) +} + +RtcPrivateApi.prototype.cancel = function(uid) { + return http({ + url: `/webrtc/private/cancel?uid=${uid}`, + method: 'post' + }) +} + +RtcPrivateApi.prototype.reject = function(uid) { + return http({ + url: `/webrtc/private/reject?uid=${uid}`, + method: 'post' + }) +} + +RtcPrivateApi.prototype.failed = function(uid, reason) { + return http({ + url: `/webrtc/private/failed?uid=${uid}&reason=${reason}`, + method: 'post' + }) +} + +RtcPrivateApi.prototype.sendCandidate = function(uid, candidate) { + return http({ + url: `/webrtc/private/candidate?uid=${uid}`, + method: 'post', + data: JSON.stringify(candidate) + }); +} + +RtcPrivateApi.prototype.heartbeat = function(uid) { + return http({ + url: `/webrtc/private/heartbeat?uid=${uid}`, + method: 'post' + }) +} + +export default RtcPrivateApi; \ No newline at end of file diff --git a/im-ui/src/api/webrtc.js b/im-ui/src/api/webrtc.js new file mode 100644 index 0000000..8382cd1 --- /dev/null +++ b/im-ui/src/api/webrtc.js @@ -0,0 +1,120 @@ + +class ImWebRtc { + constructor() { + this.configuration = {} + this.stream = null; + } +} + +ImWebRtc.prototype.isEnable = function() { + window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window + .mozRTCPeerConnection; + window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window + .mozRTCSessionDescription; + window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window + .mozRTCIceCandidate; + return !!window.RTCPeerConnection; +} + +ImWebRtc.prototype.init = function(configuration) { + this.configuration = configuration; +} + +ImWebRtc.prototype.setupPeerConnection = function(callback) { + this.peerConnection = new RTCPeerConnection(this.configuration); + this.peerConnection.ontrack = (e) => { + // 对方的视频流 + callback(e.streams[0]); + }; +} + + +ImWebRtc.prototype.setStream = function(stream) { + if(this.stream){ + this.peerConnection.removeStream(this.stream) + } + if(stream){ + stream.getTracks().forEach((track) => { + this.peerConnection.addTrack(track, stream); + }); + } + this.stream = stream; +} + + +ImWebRtc.prototype.onIcecandidate = function(callback) { + this.peerConnection.onicecandidate = (event) => { + // 追踪到候选信息 + if (event.candidate) { + callback(event.candidate) + } + } +} + +ImWebRtc.prototype.onStateChange = function(callback) { + // 监听连接状态 + this.peerConnection.oniceconnectionstatechange = (event) => { + let state = event.target.iceConnectionState; + console.log("ICE连接状态变化: : " + state) + callback(state) + }; +} + +ImWebRtc.prototype.createOffer = function() { + return new Promise((resolve, reject) => { + const offerParam = {}; + offerParam.offerToRecieveAudio = 1; + offerParam.offerToRecieveVideo = 1; + // 创建本地sdp信息 + this.peerConnection.createOffer(offerParam).then((offer) => { + // 设置本地sdp信息 + this.peerConnection.setLocalDescription(offer); + // 发起呼叫请求 + resolve(offer) + }).catch((e) => { + reject(e) + }) + }); +} + + +ImWebRtc.prototype.createAnswer = function(offer) { + return new Promise((resolve, reject) => { + // 设置远端的sdp + this.setRemoteDescription(offer); + // 创建本地dsp + const offerParam = {}; + offerParam.offerToRecieveAudio = 1; + offerParam.offerToRecieveVideo = 1; + this.peerConnection.createAnswer(offerParam).then((answer) => { + // 设置本地sdp信息 + this.peerConnection.setLocalDescription(answer); + // 接受呼叫请求 + resolve(answer) + }).catch((e) => { + reject(e) + }) + }); +} + +ImWebRtc.prototype.setRemoteDescription = function(offer) { + // 设置对方的sdp信息 + this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); +} + +ImWebRtc.prototype.addIceCandidate = function(candidate) { + // 添加对方的候选人信息 + this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); +} + +ImWebRtc.prototype.close = function(uid) { + // 关闭RTC连接 + if (this.peerConnection) { + this.peerConnection.close(); + this.peerConnection.onicecandidate = null; + this.peerConnection.onaddstream = null; + this.peerConnection = null; + } +} + +export default ImWebRtc; \ No newline at end of file diff --git a/im-ui/src/assets/iconfont/iconfont.css b/im-ui/src/assets/iconfont/iconfont.css index ff4113e..8c5b819 100644 --- a/im-ui/src/assets/iconfont/iconfont.css +++ b/im-ui/src/assets/iconfont/iconfont.css @@ -1,6 +1,6 @@ @font-face { font-family: "iconfont"; /* Project id 3791506 */ - src: url('iconfont.ttf?t=1714220334746') format('truetype'); + src: url('iconfont.ttf?t=1718373714629') format('truetype'); } .iconfont { @@ -11,6 +11,38 @@ -moz-osx-font-smoothing: grayscale; } +.icon-invite-rtc:before { + content: "\e65f"; +} + +.icon-quit:before { + content: "\e606"; +} + +.icon-camera-off:before { + content: "\e6b5"; +} + +.icon-speaker-off:before { + content: "\ea3c"; +} + +.icon-microphone-on:before { + content: "\e63b"; +} + +.icon-speaker-on:before { + content: "\e6a4"; +} + +.icon-camera-on:before { + content: "\e627"; +} + +.icon-microphone-off:before { + content: "\efe5"; +} + .icon-chat:before { content: "\e600"; } diff --git a/im-ui/src/assets/iconfont/iconfont.ttf b/im-ui/src/assets/iconfont/iconfont.ttf index 63ec967..1697727 100644 Binary files a/im-ui/src/assets/iconfont/iconfont.ttf and b/im-ui/src/assets/iconfont/iconfont.ttf differ diff --git a/im-ui/src/components/chat/ChatBox.vue b/im-ui/src/components/chat/ChatBox.vue index 6e4a766..9919f97 100644 --- a/im-ui/src/components/chat/ChatBox.vue +++ b/im-ui/src/components/chat/ChatBox.vue @@ -13,11 +13,11 @@
  • - +
@@ -36,30 +36,34 @@
- +
-
+
+ @click="showPrivateVideo('voice')"> +
+
+ @click="showPrivateVideo('video')">
x + @compositionstart="onEditorCompositionStart" + @compositionend="onEditorCompositionEnd" @input="onEditorInput" + :placeholder="placeholder" @blur="onEditBoxBlur()" @keydown.down="onKeyDown" + @keydown.up="onKeyUp" @keydown.enter.prevent="onKeyEnter">x
@@ -84,854 +88,903 @@ - - + + + +
\ No newline at end of file + \ No newline at end of file diff --git a/im-ui/src/components/chat/ChatItem.vue b/im-ui/src/components/chat/ChatItem.vue index 8fe65fe..b1c3fc6 100644 --- a/im-ui/src/components/chat/ChatItem.vue +++ b/im-ui/src/components/chat/ChatItem.vue @@ -12,7 +12,7 @@
{{atText}}
-
{{chat.sendNickName+': '}}
+
{{chat.sendNickName+': '}}
@@ -76,6 +76,18 @@ } }, computed: { + isShowSendName() { + if (!this.chat.sendNickName) { + return false; + } + let size = this.chat.messages.length; + if (size == 0) { + return false; + } + // 只有群聊的普通消息需要显示名称 + let lastMsg = this.chat.messages[size - 1]; + return this.$msgType.isNormal(lastMsg.type) + }, showTime() { return this.$date.toTimeText(this.chat.lastSendTime, true) }, @@ -108,11 +120,11 @@ &:hover { background-color: #F8FAFF; } - + &.active { background-color: #F4F9FF; } - + .chat-left { position: relative; display: flex; @@ -147,6 +159,7 @@ display: flex; line-height: 25px; height: 25px; + .chat-name-text { flex: 1; font-size: 15px; @@ -154,9 +167,9 @@ white-space: nowrap; overflow: hidden; } - - - .chat-time-text{ + + + .chat-time-text { font-size: 13px; text-align: right; color: #888888; @@ -169,23 +182,24 @@ .chat-content { display: flex; line-height: 22px; - + .chat-at-text { color: #c70b0b; font-size: 12px; } - - .chat-send-name{ + + .chat-send-name { font-size: 13px; } - + .chat-content-text { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-size: 13px; + font-size: 13px; + img { width: 20px !important; height: 20px !important; diff --git a/im-ui/src/components/chat/ChatMessageItem.vue b/im-ui/src/components/chat/ChatMessageItem.vue index 9a466a7..7852365 100644 --- a/im-ui/src/components/chat/ChatMessageItem.vue +++ b/im-ui/src/components/chat/ChatMessageItem.vue @@ -1,14 +1,13 @@ - - - - \ No newline at end of file diff --git a/im-ui/src/components/chat/ChatVoice.vue b/im-ui/src/components/chat/ChatRecord.vue similarity index 92% rename from im-ui/src/components/chat/ChatVoice.vue rename to im-ui/src/components/chat/ChatRecord.vue index de9f116..89fa3bb 100644 --- a/im-ui/src/components/chat/ChatVoice.vue +++ b/im-ui/src/components/chat/ChatRecord.vue @@ -1,12 +1,12 @@ @@ -50,8 +50,9 @@ import Setting from '../components/setting/Setting.vue'; import UserInfo from '../components/common/UserInfo.vue'; import FullImage from '../components/common/FullImage.vue'; - import ChatPrivateVideo from '../components/chat/ChatPrivateVideo.vue'; - import ChatVideoAcceptor from '../components/chat/ChatVideoAcceptor.vue'; + import RtcPrivateVideo from '../components/rtc/RtcPrivateVideo.vue'; + import RtcPrivateAcceptor from '../components/rtc/RtcPrivateAcceptor.vue'; + import RtcGroupVideo from '../components/rtc/RtcGroupVideo.vue'; export default { components: { @@ -59,8 +60,9 @@ Setting, UserInfo, FullImage, - ChatPrivateVideo, - ChatVideoAcceptor + RtcPrivateVideo, + RtcPrivateAcceptor, + RtcGroupVideo }, data() { return { @@ -70,6 +72,15 @@ }, methods: { init() { + this.$eventBus.$on('openPrivateVideo', (rctInfo) => { + // 进入单人视频通话 + this.$refs.rtcPrivateVideo.open(rctInfo); + }); + this.$eventBus.$on('openGroupVideo', (rctInfo) => { + // 进入多人视频通话 + this.$refs.rtcGroupVideo.open(rctInfo); + }); + this.$store.dispatch("load").then(() => { // ws初始化 this.$wsApi.connect(process.env.VUE_APP_WS_URL, sessionStorage.getItem("accessToken")); @@ -125,7 +136,7 @@ }, handlePrivateMessage(msg) { // 消息加载标志 - if (msg.type == this.$enums.MESSAGE_TYPE.LOADDING) { + if (msg.type == this.$enums.MESSAGE_TYPE.LOADING) { this.$store.commit("loadingPrivateMsg", JSON.parse(msg.content)) return; } @@ -146,6 +157,11 @@ } // 标记这条消息是不是自己发的 msg.selfSend = msg.sendId == this.$store.state.userStore.userInfo.id; + // 单人webrtc 信令 + if (this.$msgType.isRtcPrivate(msg.type)) { + this.$refs.rtcPrivateVideo.onRTCMessage(msg) + return; + } // 好友id let friendId = msg.selfSend ? msg.recvId : msg.sendId; this.loadFriendInfo(friendId).then((friend) => { @@ -153,21 +169,6 @@ }) }, insertPrivateMessage(friend, msg) { - // webrtc 信令 - if (msg.type >= this.$enums.MESSAGE_TYPE.RTC_CALL_VOICE && - msg.type <= this.$enums.MESSAGE_TYPE.RTC_CANDIDATE) { - let rtcInfo = this.$store.state.userStore.rtcInfo; - // 呼叫 - if (msg.type == this.$enums.MESSAGE_TYPE.RTC_CALL_VOICE || - msg.type == this.$enums.MESSAGE_TYPE.RTC_CALL_VIDEO || - rtcInfo.state == this.$enums.RTC_STATE.FREE || - rtcInfo.state == this.$enums.RTC_STATE.WAIT_ACCEPT) { - this.$refs.videoAcceptor.onRTCMessage(msg,friend) - } else { - this.$refs.privateVideo.onRTCMessage(msg) - } - return; - } let chatInfo = { type: 'PRIVATE', @@ -180,13 +181,14 @@ // 插入消息 this.$store.commit("insertMessage", msg); // 播放提示音 - if (!msg.selfSend && msg.status != this.$enums.MESSAGE_STATUS.READED) { + if (!msg.selfSend && this.$msgType.isNormal(msg.type) && + msg.status != this.$enums.MESSAGE_STATUS.READED) { this.playAudioTip(); } }, handleGroupMessage(msg) { // 消息加载标志 - if (msg.type == this.$enums.MESSAGE_TYPE.LOADDING) { + if (msg.type == this.$enums.MESSAGE_TYPE.LOADING) { this.$store.commit("loadingGroupMsg", JSON.parse(msg.content)) return; } @@ -214,12 +216,20 @@ } // 标记这条消息是不是自己发的 msg.selfSend = msg.sendId == this.$store.state.userStore.userInfo.id; + // 群视频信令 + if (this.$msgType.isRtcGroup(msg.type)) { + this.$nextTick(() => { + this.$refs.rtcGroupVideo.onRTCMessage(msg); + }) + return; + } this.loadGroupInfo(msg.groupId).then((group) => { // 插入群聊消息 this.insertGroupMessage(group, msg); }) }, insertGroupMessage(group, msg) { + let chatInfo = { type: 'GROUP', targetId: group.id, @@ -231,7 +241,8 @@ // 插入消息 this.$store.commit("insertMessage", msg); // 播放提示音 - if (!msg.selfSend && msg.status != this.$enums.MESSAGE_STATUS.READED) { + if (!msg.selfSend && msg.type <= this.$enums.MESSAGE_TYPE.VIDEO && + msg.status != this.$enums.MESSAGE_STATUS.READED) { this.playAudioTip(); } }, @@ -335,15 +346,16 @@ background-color: #19082f !important; padding: 0 !important; text-align: center; + .link { text-decoration: none; - + &.router-link-active .icon { color: #ba785a; } } - - .icon { + + .icon { font-size: 26px !important; color: #ddd; } @@ -376,7 +388,7 @@ .icon { font-size: 28px; } - + &:hover { color: white; } diff --git a/im-ui/src/view/Login.vue b/im-ui/src/view/Login.vue index 5e14eda..2ab0468 100644 --- a/im-ui/src/view/Login.vue +++ b/im-ui/src/view/Login.vue @@ -8,35 +8,20 @@
  • 加入uniapp移动端,支持移动端和web端同时在线,多端消息同步
  • 目前uniapp移动端支持安卓、ios、h5、微信小程序
  • 聊天窗口支持粘贴截图、@群成员、已读未读显示
  • -
  • 支持群聊已读显示(回执消息)
  • -
  • 语雀文档 - 盒子IM详细介绍文档,目前限时免费开放中 +
  • 语雀文档: + 盒子IM详细介绍文档
  • -

    最近更新(2024-03-17):

    +

    最近更新(2024-06-22):

      -
    • web端音视频功能优化:支持语音呼叫、会话中加入通话状态消息
    • -
    • uniapp端支持音视频通话,并与web端打通
    • -
    • uniapp端音视频源码通话源码暂未开源,需付费获取: - uniapp端音视频通源码购买说明 +
    • 群语音通话功能上线,且同时支持web端和uniapp端
    • +
    • 音视频通话部分源码未开源,可付费获取: + 音视频源码购买说明
    -
    -

    最近更新(2024-03-31):

    -
      -
    • uniapp移动端支持发送语音消息
    • -
    -
    -
    -

    最近更新(2024-04-27):

    -
      -
    • uniapp端加载离线消息慢以及卡顿问题优化
    • -
    • web端样式风格调整
    • -
    -

    如果本项目对您有帮助,请在gitee上帮忙点个star

    diff --git a/im-uniapp/App.vue b/im-uniapp/App.vue index 9c5ceb2..7bd7d24 100644 --- a/im-uniapp/App.vue +++ b/im-uniapp/App.vue @@ -15,8 +15,6 @@ init() { // 加载数据 store.dispatch("load").then(() => { - // 审核 - this.initAudit(); // 初始化websocket this.initWebSocket(); }).catch((e) => { @@ -50,6 +48,7 @@ } }); wsApi.onClose((res) => { + console.log("ws断开",res); // 1000是客户端正常主动关闭 if (res.code != 1000) { // 重新连接 @@ -82,7 +81,7 @@ }, handlePrivateMessage(msg) { // 消息加载标志 - if (msg.type == enums.MESSAGE_TYPE.LOADDING) { + if (msg.type == enums.MESSAGE_TYPE.LOADING) { store.commit("loadingPrivateMsg", JSON.parse(msg.content)) return; } @@ -109,32 +108,32 @@ }, insertPrivateMessage(friend, msg) { - // webrtc 信令 - if (msg.type >= enums.MESSAGE_TYPE.RTC_CALL_VOICE && - msg.type <= enums.MESSAGE_TYPE.RTC_CANDIDATE) { + // 单人视频信令 + if (this.$msgType.isRtcPrivate(msg.type)) { // #ifdef MP-WEIXIN // 小程序不支持音视频 return; // #endif // 被呼叫,弹出视频页面 + let delayTime = 100; if(msg.type == enums.MESSAGE_TYPE.RTC_CALL_VOICE || msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO){ let mode = msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO? "video":"voice"; let pages = getCurrentPages(); let curPage = pages[pages.length-1].route; - if(curPage != "pages/chat/chat-video"){ + if(curPage != "pages/chat/chat-private-video"){ const friendInfo = encodeURIComponent(JSON.stringify(friend)); uni.navigateTo({ - url: `/pages/chat/chat-video?mode=${mode}&friend=${friendInfo}&isHost=false` + url: `/pages/chat/chat-private-video?mode=${mode}&friend=${friendInfo}&isHost=false` }) + delayTime = 500; } } setTimeout(() => { - uni.$emit('WS_RTC',msg); - },500) + uni.$emit('WS_RTC_PRIVATE',msg); + },delayTime) return; } - let chatInfo = { type: 'PRIVATE', targetId: friend.id, @@ -146,12 +145,12 @@ // 插入消息 store.commit("insertMessage", msg); // 播放提示音 - !msg.selfSend && this.playAudioTip(); + this.playAudioTip(); }, handleGroupMessage(msg) { // 消息加载标志 - if (msg.type == enums.MESSAGE_TYPE.LOADDING) { + if (msg.type == enums.MESSAGE_TYPE.LOADING) { store.commit("loadingGroupMsg",JSON.parse(msg.content)) return; } @@ -186,6 +185,35 @@ }, insertGroupMessage(group, msg) { + // 群视频信令 + if (this.$msgType.isRtcGroup(msg.type)) { + // #ifdef MP-WEIXIN + // 小程序不支持音视频 + return; + // #endif + // 被呼叫,弹出视频页面 + let delayTime = 100; + if(msg.type == enums.MESSAGE_TYPE.RTC_GROUP_SETUP){ + let pages = getCurrentPages(); + let curPage = pages[pages.length-1].route; + if(curPage != "pages/chat/chat-group-video"){ + const userInfos = encodeURIComponent(msg.content); + const inviterId = msg.sendId; + const groupId = msg.groupId + uni.navigateTo({ + url: `/pages/chat/chat-group-video?groupId=${groupId}&isHost=false + &inviterId=${inviterId}&userInfos=${userInfos}` + }) + delayTime = 500; + } + } + // 消息转发到chat-group-video页面进行处理 + setTimeout(() => { + uni.$emit('WS_RTC_GROUP',msg); + },delayTime) + return; + } + let chatInfo = { type: 'GROUP', targetId: group.id, @@ -197,7 +225,7 @@ // 插入消息 store.commit("insertMessage", msg); // 播放提示音 - !msg.selfSend && this.playAudioTip(); + this.playAudioTip(); }, loadFriendInfo(id) { return new Promise((resolve, reject) => { @@ -251,21 +279,6 @@ return true; } return loginInfo.expireTime < new Date().getTime(); - }, - initAudit() { - if (store.state.userStore.userInfo.type == 1) { - // 显示群组功能 - uni.setTabBarItem({ - index: 2, - text: "群聊" - }) - } else { - // 隐藏群组功能 - uni.setTabBarItem({ - index: 2, - text: "搜索" - }) - } } }, onLaunch() { diff --git a/im-uniapp/common/enums.js b/im-uniapp/common/enums.js index b695b21..6dddc3b 100644 --- a/im-uniapp/common/enums.js +++ b/im-uniapp/common/enums.js @@ -5,14 +5,14 @@ const MESSAGE_TYPE = { FILE:2, AUDIO:3, VIDEO:4, - RT_VOICE:5, - RT_VIDEO:6, RECALL:10, READED:11, RECEIPT:12, TIP_TIME:20, TIP_TEXT:21, - LOADDING:30, + LOADING:30, + ACT_RT_VOICE:40, + ACT_RT_VIDEO:41, RTC_CALL_VOICE: 100, RTC_CALL_VIDEO: 101, RTC_ACCEPT: 102, @@ -20,7 +20,19 @@ const MESSAGE_TYPE = { RTC_CANCEL: 104, RTC_FAILED: 105, RTC_HANDUP: 106, - RTC_CANDIDATE: 107 + RTC_CANDIDATE: 107, + RTC_GROUP_SETUP:200, + RTC_GROUP_ACCEPT:201, + RTC_GROUP_REJECT:202, + RTC_GROUP_FAILED:203, + RTC_GROUP_CANCEL:204, + RTC_GROUP_QUIT:205, + RTC_GROUP_INVITE:206, + RTC_GROUP_JOIN:207, + RTC_GROUP_OFFER:208, + RTC_GROUP_ANSWER:209, + RTC_GROUP_CANDIDATE:210, + RTC_GROUP_DEVICE:211 } const USER_STATE = { diff --git a/im-uniapp/common/messageType.js b/im-uniapp/common/messageType.js new file mode 100644 index 0000000..4ab602c --- /dev/null +++ b/im-uniapp/common/messageType.js @@ -0,0 +1,40 @@ + +// 是否普通消息 +let isNormal = function(type){ + return type>=0 && type < 10; +} + +// 是否状态消息 +let isStatus = function(type){ + return type>=10 && type < 20; +} + +// 是否提示消息 +let isTip = function(type){ + return type>=20 && type < 30; +} + +// 操作交互类消息 +let isAction = function(type){ + return type>=40 && type < 50; +} + +// 单人通话信令 +let isRtcPrivate = function(type){ + return type>=100 && type < 300; +} + +// 多人通话信令 +let isRtcGroup = function(type){ + return type>=200 && type < 400; +} + + +export { + isNormal, + isStatus, + isTip, + isAction, + isRtcPrivate, + isRtcGroup +} \ No newline at end of file diff --git a/im-uniapp/components/chat-message-item/chat-message-item.vue b/im-uniapp/components/chat-message-item/chat-message-item.vue index 7fc701e..6520813 100644 --- a/im-uniapp/components/chat-message-item/chat-message-item.vue +++ b/im-uniapp/components/chat-message-item/chat-message-item.vue @@ -7,7 +7,7 @@ {{$date.toTimeText(msgInfo.sendTime)}} - @@ -52,13 +52,13 @@ - - - + + {{msgInfo.content}} - + 已读 + + + 选择成员 + + + + + + + + + + + + + + + + + + + {{ m.aliasName}} + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/im-uniapp/components/group-rtc-join/group-rtc-join.vue b/im-uniapp/components/group-rtc-join/group-rtc-join.vue new file mode 100644 index 0000000..0c0081f --- /dev/null +++ b/im-uniapp/components/group-rtc-join/group-rtc-join.vue @@ -0,0 +1,89 @@ + + + + + \ No newline at end of file diff --git a/im-uniapp/components/user-search/user-search.vue b/im-uniapp/components/user-search/user-search.vue deleted file mode 100644 index fadd7f6..0000000 --- a/im-uniapp/components/user-search/user-search.vue +++ /dev/null @@ -1,118 +0,0 @@ - - - - - \ No newline at end of file diff --git a/im-uniapp/hybrid/html/rtc-group/index.html b/im-uniapp/hybrid/html/rtc-group/index.html new file mode 100644 index 0000000..be64c86 --- /dev/null +++ b/im-uniapp/hybrid/html/rtc-group/index.html @@ -0,0 +1,13 @@ + + + + + + + + 语音通话 + + +
    音视频通话为付费功能,有需要请联系作者...
    + + \ No newline at end of file diff --git a/im-uniapp/hybrid/html/index.html b/im-uniapp/hybrid/html/rtc-private/index.html similarity index 100% rename from im-uniapp/hybrid/html/index.html rename to im-uniapp/hybrid/html/rtc-private/index.html diff --git a/im-uniapp/main.js b/im-uniapp/main.js index f3f3774..fb1398c 100644 --- a/im-uniapp/main.js +++ b/im-uniapp/main.js @@ -4,6 +4,7 @@ import emotion from './common/emotion.js'; import * as enums from './common/enums.js'; import * as date from './common/date'; import * as socketApi from './common/wssocket'; +import * as messageType from './common/messageType'; import store from './store'; import { createSSRApp } from 'vue' // #ifdef H5 @@ -19,6 +20,7 @@ export function createApp() { app.use(store); app.config.globalProperties.$http = request; app.config.globalProperties.$wsApi = socketApi; + app.config.globalProperties.$msgType = messageType; app.config.globalProperties.$emo = emotion; app.config.globalProperties.$enums = enums; app.config.globalProperties.$date = date; diff --git a/im-uniapp/manifest.json b/im-uniapp/manifest.json index e5d7646..d69c7ad 100644 --- a/im-uniapp/manifest.json +++ b/im-uniapp/manifest.json @@ -100,7 +100,7 @@ /* 小程序特有相关 */ "mp-weixin" : { "appid" : "wxda94f40bfad0262c", - "libVersion": "latest", + "libVersion" : "latest", "setting" : { "urlCheck" : false }, diff --git a/im-uniapp/pages.json b/im-uniapp/pages.json index 520b23c..4a4f870 100644 --- a/im-uniapp/pages.json +++ b/im-uniapp/pages.json @@ -17,7 +17,9 @@ }, { "path": "pages/chat/chat-box" },{ - "path": "pages/chat/chat-video" + "path": "pages/chat/chat-private-video" + },{ + "path": "pages/chat/chat-group-video" }, { "path": "pages/friend/friend-add" }, { @@ -61,7 +63,7 @@ "pagePath": "pages/group/group", "iconPath": "static/tarbar/group.png", "selectedIconPath": "static/tarbar/group_active.png", - "text": "搜索" + "text": "群聊" }, { "pagePath": "pages/mine/mine", diff --git a/im-uniapp/pages/chat/chat-box.vue b/im-uniapp/pages/chat/chat-box.vue index 08702a6..bb906e7 100644 --- a/im-uniapp/pages/chat/chat-box.vue +++ b/im-uniapp/pages/chat/chat-box.vue @@ -6,14 +6,13 @@
    - - - + + + @@ -31,14 +30,14 @@ - + - + @@ -82,11 +81,15 @@ - + 视频通话 - + + + 语音通话 + + 语音通话 @@ -101,8 +104,14 @@ + + + +
    @@ -124,17 +133,18 @@ keyboardHeight: 322, atUserIds: [], recordText: "", + needScrollToBottom: false, // 需要滚动到底部 showMinIdx: 0 // 下标小于showMinIdx的消息不显示,否则可能很卡 } }, methods: { onRecorderInput() { this.showRecord = true; - this.switchChatTabBox('none',true); + this.switchChatTabBox('none', true); }, onKeyboardInput() { this.showRecord = false; - this.switchChatTabBox('none',false); + this.switchChatTabBox('none', false); }, onSendRecord(data) { let msgInfo = { @@ -161,26 +171,66 @@ // 滚动到底部 this.scrollToBottom(); this.isReceipt = false; - + }) }, onRtCall(msgInfo) { - if (msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VOICE) { - this.onVoiceCall(); - } else if (msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VIDEO) { - this.onVideoCall(); + if (msgInfo.type == this.$enums.MESSAGE_TYPE.ACT_RT_VOICE) { + this.onPriviteVoice(); + } else if (msgInfo.type == this.$enums.MESSAGE_TYPE.ACT_RT_VIDEO) { + this.onPriviteVideo(); } }, - onVideoCall() { + onPriviteVideo() { const friendInfo = encodeURIComponent(JSON.stringify(this.friend)); uni.navigateTo({ - url: `/pages/chat/chat-video?mode=video&friend=${friendInfo}&isHost=true` + url: `/pages/chat/chat-private-video?mode=video&friend=${friendInfo}&isHost=true` }) }, - onVoiceCall() { + onPriviteVoice() { const friendInfo = encodeURIComponent(JSON.stringify(this.friend)); uni.navigateTo({ - url: `/pages/chat/chat-video?mode=voice&friend=${friendInfo}&isHost=true` + url: `/pages/chat/chat-private-video?mode=voice&friend=${friendInfo}&isHost=true` + }) + }, + onGroupVideo() { + this.$http({ + url: "/webrtc/group/info?groupId="+this.group.id, + method: 'GET' + }).then((rtcInfo)=>{ + if(rtcInfo.isChating){ + // 已在通话中,可以直接加入通话 + this.$refs.rtcJoin.open(rtcInfo); + }else { + // 邀请成员发起通话 + let ids = [this.mine.id]; + this.$refs.selBox.init(ids, ids); + this.$refs.selBox.open(); + } + }) + }, + onInviteOk(ids) { + if(ids.length < 2){ + return; + } + let users = []; + ids.forEach(id => { + let m = this.groupMembers.find(m => m.userId == id); + // 只取部分字段,压缩url长度 + users.push({ + id: m.userId, + nickName: m.aliasName, + headImage: m.headImage, + isCamera: false, + isMicroPhone: true + }) + }) + const groupId = this.group.id; + const inviterId = this.mine.id; + const userInfos = encodeURIComponent(JSON.stringify(users)); + uni.navigateTo({ + url: `/pages/chat/chat-group-video?groupId=${groupId}&isHost=true + &inviterId=${inviterId}&userInfos=${userInfos}` }) }, moveChatToTop() { @@ -302,13 +352,13 @@ }); }, - onShowEmoChatTab(){ + onShowEmoChatTab() { this.showRecord = false; - this.switchChatTabBox('emo',true) + this.switchChatTabBox('emo', true) }, - onShowToolsChatTab(){ + onShowToolsChatTab() { this.showRecord = false; - this.switchChatTabBox('tools',true) + this.switchChatTabBox('tools', true) }, switchChatTabBox(chatTabBox, hideKeyBoard) { this.chatTabBox = chatTabBox; @@ -496,11 +546,11 @@ }); }, onScrollToTop() { - if(this.showMinIdx==0){ + if (this.showMinIdx == 0) { console.log("消息已滚动到顶部") return; } - + // #ifndef H5 // 防止滚动条定格在顶部,不能一直往上滚 this.scrollToMsgIdx(this.showMinIdx); @@ -541,7 +591,8 @@ }); }, readedMessage() { - if(this.unreadCount == 0){ + console.log("readedMessage") + if (this.unreadCount == 0) { return; } let url = "" @@ -642,7 +693,14 @@ messageSize: function(newSize, oldSize) { // 接收到消息时滚动到底部 if (newSize > oldSize) { - this.scrollToBottom(); + console.log("messageSize",newSize,oldSize) + let pages = getCurrentPages(); + let curPage = pages[pages.length-1].route; + if(curPage == "pages/chat/chat-box"){ + this.scrollToBottom(); + }else { + this.needScrollToBottom = true; + } } }, unreadCount: { @@ -673,11 +731,16 @@ this.$store.commit("activeChat", options.chatIdx); // 复位回执消息 this.isReceipt = false; - // 页面滚到底部 - this.scrollToBottom(); }, onUnload() { this.$store.commit("activeChat", -1); + }, + onShow(){ + if(this.needScrollToBottom){ + // 页面滚到底部 + this.scrollToBottom(); + this.needScrollToBottom = false; + } } } @@ -718,7 +781,6 @@ } } - .chat-msg { flex: 1; padding: 0; diff --git a/im-uniapp/pages/chat/chat-group-video.vue b/im-uniapp/pages/chat/chat-group-video.vue new file mode 100644 index 0000000..de42480 --- /dev/null +++ b/im-uniapp/pages/chat/chat-group-video.vue @@ -0,0 +1,144 @@ + + + + + \ No newline at end of file diff --git a/im-uniapp/pages/chat/chat-video.vue b/im-uniapp/pages/chat/chat-private-video.vue similarity index 80% rename from im-uniapp/pages/chat/chat-video.vue rename to im-uniapp/pages/chat/chat-private-video.vue index ab8b2e7..b72d22c 100644 --- a/im-uniapp/pages/chat/chat-video.vue +++ b/im-uniapp/pages/chat/chat-private-video.vue @@ -1,6 +1,6 @@ @@ -20,16 +20,6 @@ onMessage(e) { this.onWebviewMessage(e.detail.data[0]); }, - onInsertMessage(msgInfo){ - let chat = { - type: 'PRIVATE', - targetId: this.friend.id, - showName: this.friend.nickName, - headImage: this.friend.headImage, - }; - this.$store.commit("openChat",chat); - this.$store.commit("insertMessage", msgInfo); - }, onWebviewMessage(event) { console.log("来自webview的消息:" + JSON.stringify(event)) switch (event.key) { @@ -39,9 +29,6 @@ case "WV_CLOSE": uni.navigateBack(); break; - case "INSERT_MESSAGE": - this.onInsertMessage(event.data); - break; } }, sendMessageToWebView(key, message) { @@ -72,20 +59,21 @@ // #endif }, initUrl(){ - this.url = "/hybrid/html/index.html"; + this.url = "/hybrid/html/rtc-private/index.html"; this.url += "?mode="+this.mode; this.url += "&isHost="+this.isHost; this.url += "&baseUrl="+UNI_APP.BASE_URL; this.url += "&loginInfo="+JSON.stringify(uni.getStorageSync("loginInfo")); this.url += "&userInfo="+JSON.stringify(this.$store.state.userStore.userInfo); this.url += "&friend="+JSON.stringify(this.friend); + this.url += "&config=" + JSON.stringify(this.$store.state.configStore.webrtc); }, }, onBackPress() { this.sendMessageToWebView("NAV_BACK",{}) }, onLoad(options) { - uni.$on('WS_RTC', msg => { + uni.$on('WS_RTC_PRIVATE', msg => { // 推送给web-view处理 this.sendMessageToWebView("RTC_MESSAGE", msg); }) @@ -104,7 +92,7 @@ this.initUrl(); }, onUnload() { - uni.$off('WS_RTC') + uni.$off('WS_RTC_PRIVATE') } } diff --git a/im-uniapp/pages/group/group.vue b/im-uniapp/pages/group/group.vue index a215665..19ba5a8 100644 --- a/im-uniapp/pages/group/group.vue +++ b/im-uniapp/pages/group/group.vue @@ -1,5 +1,5 @@