From f019c7541d8a2233221ac42ee7b0bc7db15b033b Mon Sep 17 00:00:00 2001 From: xsx <825657193@qq.com> Date: Sun, 29 Sep 2024 23:23:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +- .../implatform/config/MinIoClientConfig.java | 17 +- .../config/{ => props}/JwtProperties.java | 2 +- .../config/props/MinioProperties.java | 32 + .../controller/WebrtcGroupController.java | 124 ---- .../interceptor/AuthInterceptor.java | 2 +- .../service/WebrtcGroupService.java | 78 --- .../service/impl/UserServiceImpl.java | 2 +- .../service/impl/WebrtcGroupServiceImpl.java | 583 ------------------ .../service/thirdparty/FileService.java | 36 +- .../src/main/resources/application-dev.yml | 2 +- .../src/main/resources/application-prod.yml | 2 +- .../src/main/resources/application-test.yml | 2 +- im-uniapp/hybrid/html/rtc-group/index.html | 2 +- im-uniapp/hybrid/html/rtc-private/index.html | 2 +- im-web/src/components/rtc/RtcGroupVideo.vue | 6 +- 16 files changed, 69 insertions(+), 833 deletions(-) rename im-platform/src/main/java/com/bx/implatform/config/{ => props}/JwtProperties.java (92%) create mode 100644 im-platform/src/main/java/com/bx/implatform/config/props/MinioProperties.java delete mode 100644 im-platform/src/main/java/com/bx/implatform/controller/WebrtcGroupController.java delete mode 100644 im-platform/src/main/java/com/bx/implatform/service/WebrtcGroupService.java delete mode 100644 im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcGroupServiceImpl.java diff --git a/README.md b/README.md index d0c2822..d757844 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,22 @@ #### 在线体验 -账号:张三/123456 李四/123456,也可以在网页端自行注册账号 +账号:张三/123456 李四/123456,也可以在自行注册账号 网页端:https://www.boxim.online 移动安卓端:https://www.boxim.online/download/boxim.apk +移动ios端: 已上架至app store,搜索"盒子IM",下载安装即可 + 移动H5端: https://www.boxim.online/h5/ ,或扫码: ![输入图片说明](%E6%88%AA%E5%9B%BE/h5%E4%BA%8C%E7%BB%B4%E7%A0%81.png) -由于微信小程序每次发布审核过于严苛和繁琐,暂时不再提供体验环境,但uniapp端依然会继续兼容小程序 - +说明: +1.由于微信小程序每次发布审核过于严苛和繁琐,暂时不再提供体验环境,但uniapp端依然会继续兼容小程序 +2.体验环境部署的是商业版本,与开源版本功能存在一定差异,具体请参考: +https://www.yuque.com/u1475064/imk5n2/qtezcg32q1d0dr29 #### 项目结构 | 模块 | 功能 | diff --git a/im-platform/src/main/java/com/bx/implatform/config/MinIoClientConfig.java b/im-platform/src/main/java/com/bx/implatform/config/MinIoClientConfig.java index 46ed4ae..16017b2 100644 --- a/im-platform/src/main/java/com/bx/implatform/config/MinIoClientConfig.java +++ b/im-platform/src/main/java/com/bx/implatform/config/MinIoClientConfig.java @@ -1,5 +1,6 @@ package com.bx.implatform.config; +import com.bx.implatform.config.props.MinioProperties; import io.minio.MinioClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -8,20 +9,12 @@ import org.springframework.context.annotation.Configuration; @Configuration public class MinIoClientConfig { - @Value("${minio.endpoint}") - private String endpoint; - @Value("${minio.accessKey}") - private String accessKey; - @Value("${minio.secretKey}") - private String secretKey; - - @Bean - public MinioClient minioClient() { + public MinioClient minioClient(MinioProperties minioProps) { // 注入minio 客户端 return MinioClient.builder() - .endpoint(endpoint) - .credentials(accessKey, secretKey) - .build(); + .endpoint(minioProps.getEndpoint()) + .credentials(minioProps.getAccessKey(), minioProps.getSecretKey()) + .build(); } } \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/config/JwtProperties.java b/im-platform/src/main/java/com/bx/implatform/config/props/JwtProperties.java similarity index 92% rename from im-platform/src/main/java/com/bx/implatform/config/JwtProperties.java rename to im-platform/src/main/java/com/bx/implatform/config/props/JwtProperties.java index f0c0c05..4642b8d 100644 --- a/im-platform/src/main/java/com/bx/implatform/config/JwtProperties.java +++ b/im-platform/src/main/java/com/bx/implatform/config/props/JwtProperties.java @@ -1,4 +1,4 @@ -package com.bx.implatform.config; +package com.bx.implatform.config.props; import lombok.Data; import org.springframework.beans.factory.annotation.Value; diff --git a/im-platform/src/main/java/com/bx/implatform/config/props/MinioProperties.java b/im-platform/src/main/java/com/bx/implatform/config/props/MinioProperties.java new file mode 100644 index 0000000..2bfbf7f --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/config/props/MinioProperties.java @@ -0,0 +1,32 @@ +package com.bx.implatform.config.props; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * @author: Blue + * @date: 2024-09-28 + * @version: 1.0 + */ +@Data +@Component +@ConfigurationProperties(prefix = "minio") +public class MinioProperties { + + private String endpoint; + + private String accessKey; + + private String secretKey; + + private String domain; + + private String bucketName; + + private String imagePath; + + private String filePath; + + private String videoPath; +} 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 deleted file mode 100644 index ab38634..0000000 --- a/im-platform/src/main/java/com/bx/implatform/controller/WebrtcGroupController.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.bx.implatform.controller; - -import com.bx.implatform.dto.*; -import com.bx.implatform.result.Result; -import com.bx.implatform.result.ResultUtils; -import com.bx.implatform.service.WebrtcGroupService; -import com.bx.implatform.vo.WebrtcGroupInfoVO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -/** - * @author: Blue - * @date: 2024-06-01 - * @version: 1.0 - */ -@Tag(name = "多人通话") -@RestController -@RequestMapping("/webrtc/group") -@RequiredArgsConstructor -public class WebrtcGroupController { - - private final WebrtcGroupService webrtcGroupService; - - @Operation(summary = "发起群视频通话") - @PostMapping("/setup") - public Result setup(@Valid @RequestBody WebrtcGroupSetupDTO dto) { - webrtcGroupService.setup(dto); - return ResultUtils.success(); - } - - @Operation(summary = "接受通话") - @PostMapping("/accept") - public Result accept(@RequestParam("groupId") Long groupId) { - webrtcGroupService.accept(groupId); - return ResultUtils.success(); - } - - @Operation(summary = "拒绝通话") - @PostMapping("/reject") - public Result reject(@RequestParam("groupId") Long groupId) { - webrtcGroupService.reject(groupId); - return ResultUtils.success(); - } - - @Operation(summary = "通话失败") - @PostMapping("/failed") - public Result failed(@Valid @RequestBody WebrtcGroupFailedDTO dto) { - webrtcGroupService.failed(dto); - return ResultUtils.success(); - } - - @Operation(summary = "进入视频通话") - @PostMapping("/join") - public Result join(@RequestParam("groupId") Long groupId) { - webrtcGroupService.join(groupId); - return ResultUtils.success(); - } - - @Operation(summary = "取消通话") - @PostMapping("/cancel") - public Result cancel(@RequestParam("groupId") Long groupId) { - webrtcGroupService.cancel(groupId); - return ResultUtils.success(); - } - - @Operation(summary = "离开视频通话") - @PostMapping("/quit") - public Result quit(@RequestParam("groupId") Long groupId) { - webrtcGroupService.quit(groupId); - return ResultUtils.success(); - } - - @Operation(summary = "推送offer信息") - @PostMapping("/offer") - public Result offer(@Valid @RequestBody WebrtcGroupOfferDTO dto) { - webrtcGroupService.offer(dto); - return ResultUtils.success(); - } - - @Operation(summary = "推送answer信息") - @PostMapping("/answer") - public Result answer(@Valid @RequestBody WebrtcGroupAnswerDTO dto) { - webrtcGroupService.answer(dto); - return ResultUtils.success(); - } - - @Operation(summary = "邀请用户进入视频通话") - @PostMapping("/invite") - public Result invite(@Valid @RequestBody WebrtcGroupInviteDTO dto) { - webrtcGroupService.invite(dto); - return ResultUtils.success(); - } - - @Operation(summary = "同步candidate") - @PostMapping("/candidate") - public Result candidate(@Valid @RequestBody WebrtcGroupCandidateDTO dto) { - webrtcGroupService.candidate(dto); - return ResultUtils.success(); - } - - @Operation(summary = "设备操作") - @PostMapping("/device") - public Result device(@Valid @RequestBody WebrtcGroupDeviceDTO dto) { - webrtcGroupService.device(dto); - return ResultUtils.success(); - } - - @Operation(summary = "获取通话信息") - @GetMapping("/info") - public Result info(@RequestParam("groupId") Long groupId) { - return ResultUtils.success(webrtcGroupService.info(groupId)); - } - - @Operation(summary = "心跳") - @PostMapping("/heartbeat") - public Result heartbeat(@RequestParam("groupId") Long groupId) { - webrtcGroupService.heartbeat(groupId); - return ResultUtils.success(); - } - -} diff --git a/im-platform/src/main/java/com/bx/implatform/interceptor/AuthInterceptor.java b/im-platform/src/main/java/com/bx/implatform/interceptor/AuthInterceptor.java index 3cd0f43..7b8d308 100644 --- a/im-platform/src/main/java/com/bx/implatform/interceptor/AuthInterceptor.java +++ b/im-platform/src/main/java/com/bx/implatform/interceptor/AuthInterceptor.java @@ -3,7 +3,7 @@ package com.bx.implatform.interceptor; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; import com.bx.imcommon.util.JwtUtil; -import com.bx.implatform.config.JwtProperties; +import com.bx.implatform.config.props.JwtProperties; import com.bx.implatform.enums.ResultCode; import com.bx.implatform.exception.GlobalException; import com.bx.implatform.session.UserSession; diff --git a/im-platform/src/main/java/com/bx/implatform/service/WebrtcGroupService.java b/im-platform/src/main/java/com/bx/implatform/service/WebrtcGroupService.java deleted file mode 100644 index 08e6760..0000000 --- a/im-platform/src/main/java/com/bx/implatform/service/WebrtcGroupService.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.bx.implatform.service; - -import com.bx.implatform.dto.*; -import com.bx.implatform.vo.WebrtcGroupInfoVO; - -public interface WebrtcGroupService { - - /** - * 发起通话 - */ - 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/impl/UserServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java index 7ea154b..a7d570a 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java @@ -8,7 +8,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bx.imclient.IMClient; import com.bx.imcommon.enums.IMTerminalType; import com.bx.imcommon.util.JwtUtil; -import com.bx.implatform.config.JwtProperties; +import com.bx.implatform.config.props.JwtProperties; import com.bx.implatform.dto.LoginDTO; import com.bx.implatform.dto.ModifyPwdDTO; import com.bx.implatform.dto.RegisterDTO; 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 deleted file mode 100644 index 519080d..0000000 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/WebrtcGroupServiceImpl.java +++ /dev/null @@ -1,583 +0,0 @@ -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.GroupMemberService; -import com.bx.implatform.service.GroupMessageService; -import com.bx.implatform.service.GroupService; -import com.bx.implatform.service.WebrtcGroupService; -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 WebrtcGroupService { - private final GroupService groupService; - private final GroupMemberService groupMemberService; - private final GroupMessageService 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(); - groupService.getAndCheckById(dto.getGroupId()); - 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 (Boolean.TRUE.equals(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.getGroupId(), 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().equals(userSession.getTerminal())) { - throw new GlobalException("已在其他设备加入通话"); - } - WebrtcUserInfo userInfo = new WebrtcUserInfo(); - userInfo.setId(userSession.getUserId()); - userInfo.setNickName(member.getShowNickName()); - 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()); - } 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.warn("对方未加入群通话,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.warn("对方未加入群通话,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.warn("对方未加入群通话,无法同步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.getShowNickName()); - 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/thirdparty/FileService.java b/im-platform/src/main/java/com/bx/implatform/service/thirdparty/FileService.java index 8baaaa6..189bfbc 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/thirdparty/FileService.java +++ b/im-platform/src/main/java/com/bx/implatform/service/thirdparty/FileService.java @@ -1,5 +1,6 @@ package com.bx.implatform.service.thirdparty; +import com.bx.implatform.config.props.MinioProperties; import com.bx.implatform.contant.Constant; import com.bx.implatform.enums.FileType; import com.bx.implatform.enums.ResultCode; @@ -13,7 +14,6 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -21,7 +21,7 @@ import java.io.IOException; import java.util.Objects; /** - * 通过校验文件MD5实现重复文件秒传 + * todo 通过校验文件MD5实现重复文件秒传 * 文件上传服务 * * @author Blue @@ -32,25 +32,17 @@ import java.util.Objects; @RequiredArgsConstructor public class FileService { private final MinioUtil minioUtil; - @Value("${minio.public}") - private String minIoServer; - @Value("${minio.bucketName}") - private String bucketName; - @Value("${minio.imagePath}") - private String imagePath; - @Value("${minio.filePath}") - private String filePath; - @Value("${minio.videoPath}") - private String videoPath; + + private final MinioProperties minioProps; @PostConstruct public void init() { - if (!minioUtil.bucketExists(bucketName)) { + if (!minioUtil.bucketExists(minioProps.getBucketName())) { // 创建bucket - minioUtil.makeBucket(bucketName); + minioUtil.makeBucket(minioProps.getBucketName()); // 公开bucket - minioUtil.setBucketPublic(bucketName); + minioUtil.setBucketPublic(minioProps.getBucketName()); } } @@ -62,7 +54,7 @@ public class FileService { throw new GlobalException(ResultCode.PROGRAM_ERROR, "文件大小不能超过20M"); } // 上传 - String fileName = minioUtil.upload(bucketName, filePath, file); + String fileName = minioUtil.upload(minioProps.getBucketName(), minioProps.getFilePath(), file); if (StringUtils.isEmpty(fileName)) { throw new GlobalException(ResultCode.PROGRAM_ERROR, "文件上传失败"); } @@ -84,7 +76,7 @@ public class FileService { } // 上传原图 UploadImageVO vo = new UploadImageVO(); - String fileName = minioUtil.upload(bucketName, imagePath, file); + String fileName = minioUtil.upload(minioProps.getBucketName(), minioProps.getImagePath(), file); if (StringUtils.isEmpty(fileName)) { throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败"); } @@ -92,7 +84,7 @@ public class FileService { // 大于30K的文件需上传缩略图 if (file.getSize() > 30 * 1024) { byte[] imageByte = ImageUtil.compressForScale(file.getBytes(), 30); - fileName = minioUtil.upload(bucketName, imagePath, Objects.requireNonNull(file.getOriginalFilename()), imageByte, file.getContentType()); + fileName = minioUtil.upload(minioProps.getBucketName(), minioProps.getImagePath(), Objects.requireNonNull(file.getOriginalFilename()), imageByte, file.getContentType()); if (StringUtils.isEmpty(fileName)) { throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败"); } @@ -108,16 +100,16 @@ public class FileService { public String generUrl(FileType fileTypeEnum, String fileName) { - String url = minIoServer + "/" + bucketName; + String url = minioProps.getDomain() + "/" + minioProps.getBucketName(); switch (fileTypeEnum) { case FILE: - url += "/" + filePath + "/"; + url += "/" + minioProps.getFilePath() + "/"; break; case IMAGE: - url += "/" + imagePath + "/"; + url += "/" + minioProps.getImagePath() + "/"; break; case VIDEO: - url += "/" + videoPath + "/"; + url += "/" + minioProps.getVideoPath() + "/"; break; default: break; diff --git a/im-platform/src/main/resources/application-dev.yml b/im-platform/src/main/resources/application-dev.yml index 21e0cee..618bcbb 100644 --- a/im-platform/src/main/resources/application-dev.yml +++ b/im-platform/src/main/resources/application-dev.yml @@ -12,7 +12,7 @@ spring: minio: endpoint: http://127.0.0.1:9000 #内网地址 - public: http://127.0.0.1:9000 #外网访问地址 + domain: http://127.0.0.1:9000 #外网访问地址 accessKey: minioadmin secretKey: minioadmin bucketName: box-im diff --git a/im-platform/src/main/resources/application-prod.yml b/im-platform/src/main/resources/application-prod.yml index cec86c0..dc8e1b6 100644 --- a/im-platform/src/main/resources/application-prod.yml +++ b/im-platform/src/main/resources/application-prod.yml @@ -12,7 +12,7 @@ spring: minio: endpoint: http://127.0.0.1:9001 #内网地址 - public: https://www.boxim.online/file #外网访问地址 + domain: https://www.boxim.online/file #外网访问地址 accessKey: admin secretKey: 3fBSt6AkgFuD77D6 bucketName: box-im diff --git a/im-platform/src/main/resources/application-test.yml b/im-platform/src/main/resources/application-test.yml index 8b8cc83..b9b852d 100644 --- a/im-platform/src/main/resources/application-test.yml +++ b/im-platform/src/main/resources/application-test.yml @@ -12,7 +12,7 @@ spring: minio: endpoint: http://127.0.0.1:9001 #内网地址 - public: https://www.boxim.online/file #外网访问地址 + domain: https://www.boxim.online/file #外网访问地址 accessKey: admin secretKey: 3fBSt6AkgFuD77D6 bucketName: box-im diff --git a/im-uniapp/hybrid/html/rtc-group/index.html b/im-uniapp/hybrid/html/rtc-group/index.html index be64c86..dd2cf84 100644 --- a/im-uniapp/hybrid/html/rtc-group/index.html +++ b/im-uniapp/hybrid/html/rtc-group/index.html @@ -8,6 +8,6 @@ 语音通话 -
音视频通话为付费功能,有需要请联系作者...
+
音视频通话功能需升级至商业版,如有需要请联系作者...
\ No newline at end of file diff --git a/im-uniapp/hybrid/html/rtc-private/index.html b/im-uniapp/hybrid/html/rtc-private/index.html index 6733f82..33d3afe 100644 --- a/im-uniapp/hybrid/html/rtc-private/index.html +++ b/im-uniapp/hybrid/html/rtc-private/index.html @@ -8,6 +8,6 @@ 视频通话 -
音视频通话为付费功能,有需要请联系作者...
+
音视频通话功能需升级至商业版,如有需要请联系作者...
\ No newline at end of file diff --git a/im-web/src/components/rtc/RtcGroupVideo.vue b/im-web/src/components/rtc/RtcGroupVideo.vue index 96abad3..952bc70 100644 --- a/im-web/src/components/rtc/RtcGroupVideo.vue +++ b/im-web/src/components/rtc/RtcGroupVideo.vue @@ -3,14 +3,14 @@ :visible.sync="isShow" width="50%">
- 多人音视频通话为付费功能,有需要请联系作者... + 多人音视频通话需升级至商业版,如有需要请联系作者购买...
点击下方文档了解详细信息: