diff --git a/.gitignore b/.gitignore index 4e519b4..5371118 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ /im-server/src/main/resources/logback-prod.xml /im-commom/im-commom.iml /im-uniapp/node_modules/ +/im-ui/jsconfig.json +/package-lock.json diff --git a/README.md b/README.md index 4b2f56f..3b0c4a4 100644 --- a/README.md +++ b/README.md @@ -16,33 +16,33 @@ #### 近期更新 -发布2.0版本,本次更新加入了uniapp版本: +发布2.0版本,本次更新加入了uniapp移动端: - 支持移动端和web端同时在线,多端消息同步 -- 目前仅兼容h5和微信小程序,后续会继续兼容更多终端类型 +- 目前已兼容h5、微信小程序,安卓和IOS - 聊天窗口加入已读未读显示 - 群聊加入@功能 - 界面风格升级,表情包更新、生成文字头像等 #### 在线体验 -web地址:https://www.boxim.online -微信小程序: +账号:张三/123456 李四/123456,也可以在网页端自行注册账号 -![输入图片说明](%E6%88%AA%E5%9B%BE/wx%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%BA%8C%E7%BB%B4%E7%A0%81.jpg) +网页端:https://www.boxim.online + +移动安卓端:https://www.boxim.online/download/boxim.apk -H5地址: https://www.boxim.online/h5/ ,或扫码: +移动H5端: https://www.boxim.online/h5/ ,或扫码: ![输入图片说明](%E6%88%AA%E5%9B%BE/h5%E4%BA%8C%E7%BB%B4%E7%A0%81.png) +微信小程序: + +![输入图片说明](%E6%88%AA%E5%9B%BE/wx%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%BA%8C%E7%BB%B4%E7%A0%81.jpg) -账号: -张三/123456 -李四/123456 -也可以自行注册账号 #### 相关项目 diff --git a/im-commom/src/main/java/com/bx/imcommon/util/CommaTextUtils.java b/im-commom/src/main/java/com/bx/imcommon/util/CommaTextUtils.java new file mode 100644 index 0000000..f1c3354 --- /dev/null +++ b/im-commom/src/main/java/com/bx/imcommon/util/CommaTextUtils.java @@ -0,0 +1,89 @@ +package com.bx.imcommon.util; + + + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; + +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 逗号分格文本处理工具类 + * + * @author: blue + * @date: 2023-11-09 09:52:49 + * @version: 1.0 + */ +public class CommaTextUtils { + + /** + * 文本转列表 + * + * @param strText 文件 + * @return 列表 + */ + public static List asList(String strText) { + if (StrUtil.isEmpty(strText)) { + return new LinkedList<>(); + } + return new LinkedList<>(Arrays.asList(strText.split(","))); + + } + + /** + * 列表转字符串,并且自动清空、去重、排序 + * + * @param texts 列表 + * @return 文本 + */ + public static String asText(Collection texts) { + if (CollUtil.isEmpty(texts)) { + return StrUtil.EMPTY; + } + return texts.stream().map(text -> StrUtil.toString(text)).filter(StrUtil::isNotEmpty).distinct().sorted().collect(Collectors.joining(",")); + } + + /** + * 追加一个单词 + * + * @param strText 文本 + * @param word 单词 + * @return 文本 + */ + public static String appendWord(String strText, T word) { + List texts = asList(strText); + texts.add(StrUtil.toString(word)); + return asText(texts); + } + + /** + * 删除一个单词 + * + * @param strText 文本 + * @param word 单词 + * @return 文本 + */ + public static String removeWord(String strText, T word) { + List texts = asList(strText); + texts.remove(StrUtil.toString(word)); + return asText(texts); + } + + /** + * 合并 + * + * @param strText1 文本1 + * @param strText2 文本2 + * @return 文本 + */ + public static String merge(String strText1, String strText2) { + List texts = asList(strText1); + texts.addAll(asList(strText2)); + return asText(texts); + } + +} diff --git a/im-platform/src/main/java/com/bx/implatform/IMPlatformApp.java b/im-platform/src/main/java/com/bx/implatform/IMPlatformApp.java index ffb8898..e2aabee 100644 --- a/im-platform/src/main/java/com/bx/implatform/IMPlatformApp.java +++ b/im-platform/src/main/java/com/bx/implatform/IMPlatformApp.java @@ -1,11 +1,22 @@ package com.bx.implatform; +import cn.hutool.core.util.StrUtil; +import com.bx.implatform.contant.RedisKey; import lombok.extern.slf4j.Slf4j; import org.mybatis.spring.annotation.MapperScan; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; @Slf4j @EnableAspectJAutoProxy(exposeProxy = true) @@ -16,4 +27,5 @@ public class IMPlatformApp { public static void main(String[] args) { SpringApplication.run(IMPlatformApp.class, args); } + } diff --git a/im-platform/src/main/java/com/bx/implatform/controller/GroupMessageController.java b/im-platform/src/main/java/com/bx/implatform/controller/GroupMessageController.java index 250c249..b7e7e94 100644 --- a/im-platform/src/main/java/com/bx/implatform/controller/GroupMessageController.java +++ b/im-platform/src/main/java/com/bx/implatform/controller/GroupMessageController.java @@ -42,6 +42,12 @@ public class GroupMessageController { return ResultUtils.success(groupMessageService.loadMessage(minId)); } + @GetMapping("/pullOfflineMessage") + @ApiOperation(value = "拉取离线消息", notes = "拉取离线消息,消息将通过webscoket异步推送") + public Result pullOfflineMessage(@RequestParam Long minId) { + groupMessageService.pullOfflineMessage(minId); + return ResultUtils.success(); + } @PutMapping("/readed") @ApiOperation(value = "消息已读", notes = "将群聊中的消息状态置为已读") @@ -50,6 +56,11 @@ public class GroupMessageController { return ResultUtils.success(); } + @GetMapping("/findReadedUsers") + @ApiOperation(value = "获取已读用户id", notes = "获取消息已读用户列表") + public Result> findReadedUsers(@RequestParam Long groupId,@RequestParam Long messageId) { + return ResultUtils.success(groupMessageService.findReadedUsers(groupId,messageId)); + } @GetMapping("/history") @ApiOperation(value = "查询聊天记录", notes = "查询聊天记录") diff --git a/im-platform/src/main/java/com/bx/implatform/controller/PrivateMessageController.java b/im-platform/src/main/java/com/bx/implatform/controller/PrivateMessageController.java index 5f4822e..8c30239 100644 --- a/im-platform/src/main/java/com/bx/implatform/controller/PrivateMessageController.java +++ b/im-platform/src/main/java/com/bx/implatform/controller/PrivateMessageController.java @@ -43,6 +43,13 @@ public class PrivateMessageController { return ResultUtils.success(privateMessageService.loadMessage(minId)); } + @GetMapping("/pullOfflineMessage") + @ApiOperation(value = "拉取离线消息", notes = "拉取离线消息,消息将通过webscoket异步推送") + public Result pullOfflineMessage(@RequestParam Long minId) { + privateMessageService.pullOfflineMessage(minId); + return ResultUtils.success(); + } + @PutMapping("/readed") @ApiOperation(value = "消息已读", notes = "将会话中接收的消息状态置为已读") public Result readedMessage(@RequestParam Long friendId) { diff --git a/im-platform/src/main/java/com/bx/implatform/dto/GroupMessageDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/GroupMessageDTO.java index 556e665..6af8f38 100644 --- a/im-platform/src/main/java/com/bx/implatform/dto/GroupMessageDTO.java +++ b/im-platform/src/main/java/com/bx/implatform/dto/GroupMessageDTO.java @@ -27,6 +27,9 @@ public class GroupMessageDTO { @ApiModelProperty(value = "消息类型") private Integer type; + @ApiModelProperty(value = "是否回执消息") + private Boolean receipt = false; + @Size(max = 20, message = "一次最多只能@20个小伙伴哦") @ApiModelProperty(value = "被@用户列表") private List atUserIds; diff --git a/im-platform/src/main/java/com/bx/implatform/entity/GroupMember.java b/im-platform/src/main/java/com/bx/implatform/entity/GroupMember.java index cf7ddaa..a7d362d 100644 --- a/im-platform/src/main/java/com/bx/implatform/entity/GroupMember.java +++ b/im-platform/src/main/java/com/bx/implatform/entity/GroupMember.java @@ -69,6 +69,11 @@ public class GroupMember extends Model { @TableField("quit") private Boolean quit; + /** + * 退群时间 + */ + @TableField("quit_time") + private Date quitTime; /** * 创建时间 diff --git a/im-platform/src/main/java/com/bx/implatform/entity/GroupMessage.java b/im-platform/src/main/java/com/bx/implatform/entity/GroupMessage.java index fa5fb28..ebe02d5 100644 --- a/im-platform/src/main/java/com/bx/implatform/entity/GroupMessage.java +++ b/im-platform/src/main/java/com/bx/implatform/entity/GroupMessage.java @@ -50,6 +50,12 @@ public class GroupMessage extends Model { @TableField("send_nick_name") private String sendNickName; + /** + * 接受用户id,为空表示全体发送 + */ + @TableField("recv_ids") + private String recvIds; + /** * @用户列表 */ @@ -62,13 +68,25 @@ public class GroupMessage extends Model { private String content; /** - * 消息类型 0:文字 1:图片 2:文件 + * 消息类型 MessageType */ @TableField("type") private Integer type; /** - * 状态 + * 是否回执消息 + */ + @TableField("receipt") + private Boolean receipt; + + /** + * 回执消息是否完成 + */ + @TableField("receipt_ok") + private Boolean receiptOk; + + /** + * 状态 MessageStatus */ @TableField("status") private Integer status; 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 38bb729..1ca0784 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,6 +1,8 @@ package com.bx.implatform.enums; import lombok.AllArgsConstructor; +import lombok.Getter; + @AllArgsConstructor public enum MessageType { @@ -34,6 +36,23 @@ public enum MessageType { */ READED(11, "已读"), + /** + * 消息已读回执 + */ + RECEIPT(12, "消息已读回执"), + /** + * 时间提示 + */ + TIP_TIME(20,"时间提示"), + /** + * 文字提示 + */ + TIP_TEXT(21,"文字提示"), + + /** + * 消息加载标记 + */ + LOADDING(30,"加载中"), /** * 呼叫 */ 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 c8cc646..7836729 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 @@ -24,6 +24,14 @@ public interface IGroupMemberService extends IService { */ List findByUserId(Long userId); + /** + * 根据用户id查询一个月内退的群 + * + * @param userId 用户id + * @return 成员列表 + */ + List findQuitInMonth(Long userId); + /** * 根据群聊id查询群聊成员(包括已退出) * @@ -32,6 +40,8 @@ public interface IGroupMemberService extends IService { */ List findByGroupId(Long groupId); + + /** * 根据群聊id查询没有退出的群聊成员id * diff --git a/im-platform/src/main/java/com/bx/implatform/service/IGroupMessageService.java b/im-platform/src/main/java/com/bx/implatform/service/IGroupMessageService.java index 51f0075..e67f809 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/IGroupMessageService.java +++ b/im-platform/src/main/java/com/bx/implatform/service/IGroupMessageService.java @@ -32,6 +32,13 @@ public interface IGroupMessageService extends IService { */ List loadMessage(Long minId); + /** + * 拉取离线消息,只能拉取最近1个月的消息,最多拉取1000条 + * + * @param minId 消息起始id + */ + void pullOfflineMessage(Long minId); + /** * 消息已读,同步其他终端,清空未读数量 * @@ -39,6 +46,13 @@ public interface IGroupMessageService extends IService { */ void readedMessage(Long groupId); + /** + * 查询群里消息已读用户id列表 + * @param groupId 群里id + * @param messageId 消息id + * @return 已读用户id集合 + */ + List findReadedUsers(Long groupId,Long messageId); /** * 拉取历史聊天记录 * diff --git a/im-platform/src/main/java/com/bx/implatform/service/IPrivateMessageService.java b/im-platform/src/main/java/com/bx/implatform/service/IPrivateMessageService.java index f57061d..4a470d1 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/IPrivateMessageService.java +++ b/im-platform/src/main/java/com/bx/implatform/service/IPrivateMessageService.java @@ -44,6 +44,12 @@ public interface IPrivateMessageService extends IService { */ List loadMessage(Long minId); + /** + * 拉取离线消息,只能拉取最近1个月的消息,最多拉取1000条 + * + * @param minId 消息起始id + */ + void pullOfflineMessage(Long minId); /** * 消息已读,将整个会话的消息都置为已读状态 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 4710b7f..3727add 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 @@ -9,11 +9,13 @@ import com.bx.implatform.contant.RedisKey; import com.bx.implatform.entity.GroupMember; import com.bx.implatform.mapper.GroupMemberMapper; import com.bx.implatform.service.IGroupMemberService; +import com.bx.implatform.util.DateTimeUtils; import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; +import java.util.Date; import java.util.List; import java.util.stream.Collectors; @@ -34,7 +36,7 @@ public class GroupMemberServiceImpl extends ServiceImpl wrapper = new QueryWrapper<>(); wrapper.lambda().eq(GroupMember::getGroupId, groupId) .eq(GroupMember::getUserId, userId); @@ -49,6 +51,16 @@ public class GroupMemberServiceImpl extends ServiceImpl findQuitInMonth(Long userId) { + Date monthTime = DateTimeUtils.addMonths(new Date(),-1); + LambdaQueryWrapper memberWrapper = Wrappers.lambdaQuery(); + memberWrapper.eq(GroupMember::getUserId, userId) + .eq(GroupMember::getQuit, true) + .ge(GroupMember::getQuitTime,monthTime); + return this.list(memberWrapper); + } + @Override public List findByGroupId(Long groupId) { LambdaQueryWrapper memberWrapper = Wrappers.lambdaQuery(); @@ -61,7 +73,8 @@ public class GroupMemberServiceImpl extends ServiceImpl findUserIdsByGroupId(Long groupId) { LambdaQueryWrapper memberWrapper = Wrappers.lambdaQuery(); memberWrapper.eq(GroupMember::getGroupId, groupId) - .eq(GroupMember::getQuit, false); + .eq(GroupMember::getQuit, false) + .select(GroupMember::getUserId); List members = this.list(memberWrapper); return members.stream().map(GroupMember::getUserId).collect(Collectors.toList()); } @@ -71,7 +84,8 @@ public class GroupMemberServiceImpl extends ServiceImpl wrapper = Wrappers.lambdaUpdate(); wrapper.eq(GroupMember::getGroupId, groupId) - .set(GroupMember::getQuit, true); + .set(GroupMember::getQuit, true) + .set(GroupMember::getQuitTime,new Date()); this.update(wrapper); } @@ -81,7 +95,8 @@ public class GroupMemberServiceImpl extends ServiceImpl wrapper = Wrappers.lambdaUpdate(); wrapper.eq(GroupMember::getGroupId, groupId) .eq(GroupMember::getUserId, userId) - .set(GroupMember::getQuit, true); + .set(GroupMember::getQuit, true) + .set(GroupMember::getQuitTime,new Date()); this.update(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 0a2edfd..b785de9 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 @@ -10,8 +10,10 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; 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.IMUserInfo; +import com.bx.imcommon.util.CommaTextUtils; import com.bx.implatform.contant.RedisKey; import com.bx.implatform.dto.GroupMessageDTO; import com.bx.implatform.entity.Group; @@ -39,6 +41,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @Slf4j @@ -63,7 +66,7 @@ public class GroupMessageServiceImpl extends ServiceImpl(); } Map groupMemberMap = CollStreamUtil.toIdentityMap(members, GroupMember::getGroupId); - Set ids = groupMemberMap.keySet(); + Set groupIds = groupMemberMap.keySet(); // 只能拉取最近1个月的 Date minDate = DateUtils.addMonths(new Date(), -1); LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); - wrapper.gt(GroupMessage::getId, minId).gt(GroupMessage::getSendTime, minDate).in(GroupMessage::getGroupId, ids) + wrapper.gt(GroupMessage::getId, minId).gt(GroupMessage::getSendTime, minDate).in(GroupMessage::getGroupId, groupIds) .ne(GroupMessage::getStatus, MessageStatus.RECALL.code()).orderByAsc(GroupMessage::getId).last("limit 100"); List messages = this.list(wrapper); // 转成vo List vos = messages.stream() - .filter(m -> { - //排除加群之前的消息 + .filter(m -> { + //排除加群之前的消息 + GroupMember member = groupMemberMap.get(m.getGroupId()); + return Objects.nonNull(member) && DateUtil.compare(member.getCreatedTime(), m.getSendTime()) <= 0; + }) + .map(m -> { + GroupMessageVO vo = BeanUtils.copyProperties(m, GroupMessageVO.class); + // 被@用户列表 + if (StringUtils.isNotBlank(m.getAtUserIds()) && Objects.nonNull(vo)) { + List atIds = Splitter.on(",").trimResults().splitToList(m.getAtUserIds()); + vo.setAtUserIds(atIds.stream().map(Long::parseLong).collect(Collectors.toList())); + } + return vo; + }).collect(Collectors.toList()); + // 通过群聊对消息进行分组 + Map> messageGroupMap = vos.stream().collect(Collectors.groupingBy(GroupMessageVO::getGroupId)); + messageGroupMap.forEach((groupId, messageVos) -> { + // 填充消息状态 + String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, groupId); + Object o = redisTemplate.opsForHash().get(key, session.getUserId().toString()); + long readedMaxId = Objects.isNull(o) ? -1 : Long.parseLong(o.toString()); + messageVos.forEach(messageVo -> messageVo.setStatus(readedMaxId >= messageVo.getId() ? MessageStatus.READED.code() : MessageStatus.UNSEND.code())); + // 针对回执消息填充已读人数 + List receiptMessageVos = messageVos.stream().filter(GroupMessageVO::getReceipt).collect(Collectors.toList()); + if (!receiptMessageVos.isEmpty()) { + Map maxIdMap = redisTemplate.opsForHash().entries(key); + receiptMessageVos.forEach(receiptMessageVo -> { + int count = getReadedUserIds(maxIdMap, receiptMessageVo.getId(),receiptMessageVo.getSendId()).size(); + receiptMessageVo.setReadedCount(count); + }); + } + }); + return vos; + } + + @Override + public void pullOfflineMessage(Long minId) { + UserSession session = SessionContext.getSession(); + if(!imClient.isOnline(session.getUserId())){ + throw new GlobalException(ResultCode.PROGRAM_ERROR, "网络连接失败,无法拉取离线消息"); + } + + // 查询用户加入的群组 + List members = groupMemberService.findByUserId(session.getUserId()); + Map groupMemberMap = CollStreamUtil.toIdentityMap(members, GroupMember::getGroupId); + Set groupIds = groupMemberMap.keySet(); + if(CollectionUtil.isEmpty(groupIds)){ + return; + } + // 开启加载中标志 + this.sendLoadingMessage(true); + // 只能拉取最近1个月的,最多拉取1000条 + Date minDate = DateUtils.addMonths(new Date(), -1); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + wrapper.gt(GroupMessage::getId, minId) + .gt(GroupMessage::getSendTime, minDate) + .in(GroupMessage::getGroupId, groupIds) + .ne(GroupMessage::getStatus, MessageStatus.RECALL.code()) + .orderByDesc(GroupMessage::getId).last("limit 1000"); + List messages = this.list(wrapper); + // 通过群聊对消息进行分组 + Map> messageGroupMap = messages.stream().collect(Collectors.groupingBy(GroupMessage::getGroupId)); + // 退群前的消息 + List quitMembers = groupMemberService.findQuitInMonth(session.getUserId()); + for(GroupMember quitMember: quitMembers){ + wrapper = Wrappers.lambdaQuery(); + wrapper.gt(GroupMessage::getId, minId) + .between(GroupMessage::getSendTime, minDate,quitMember.getQuitTime()) + .eq(GroupMessage::getGroupId, quitMember.getGroupId()) + .ne(GroupMessage::getStatus, MessageStatus.RECALL.code()) + .orderByDesc(GroupMessage::getId) + .last("limit 100"); + List groupMessages = this.list(wrapper); + messageGroupMap.put(quitMember.getGroupId(),groupMessages); + groupMemberMap.put(quitMember.getGroupId(),quitMember); + } + // 推送消息 + AtomicInteger sendCount = new AtomicInteger(); + messageGroupMap.forEach((groupId, groupMessages) -> { + // id从小到大排序 + CollectionUtil.reverse(groupMessages); + // 填充消息状态 + String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, groupId); + Object o = redisTemplate.opsForHash().get(key, session.getUserId().toString()); + long readedMaxId = Objects.isNull(o) ? -1 : Long.parseLong(o.toString()); + Map maxIdMap = null; + for(GroupMessage m:groupMessages){ + // 排除加群之前的消息 GroupMember member = groupMemberMap.get(m.getGroupId()); - return Objects.nonNull(member) && DateUtil.compare(member.getCreatedTime(), m.getSendTime()) <= 0; - }) - .map(m -> { + if(DateUtil.compare(member.getCreatedTime(), m.getSendTime()) > 0){ + continue; + } + // 排除不需要接收的消息 + List recvIds = CommaTextUtils.asList(m.getRecvIds()); + if(!recvIds.isEmpty() && !recvIds.contains(session.getUserId().toString())){ + continue; + } + // 组装vo GroupMessageVO vo = BeanUtils.copyProperties(m, GroupMessageVO.class); // 被@用户列表 if (StringUtils.isNotBlank(m.getAtUserIds()) && Objects.nonNull(vo)) { List atIds = Splitter.on(",").trimResults().splitToList(m.getAtUserIds()); vo.setAtUserIds(atIds.stream().map(Long::parseLong).collect(Collectors.toList())); } - return vo; - }).collect(Collectors.toList()); - // 消息状态,数据库没有存群聊的消息状态,需要从redis取 - List keys = ids.stream().map(id -> String.join(":", RedisKey.IM_GROUP_READED_POSITION, id.toString(), session.getUserId().toString())) - .collect(Collectors.toList()); - List sendPos = redisTemplate.opsForValue().multiGet(keys); - int idx = 0; - for (Long id : ids) { - Object o = sendPos.get(idx); - Integer sendMaxId = Objects.isNull(o) ? -1 : (Integer) o; - vos.stream().filter(vo -> vo.getGroupId().equals(id)).forEach(vo -> { - if (vo.getId() <= sendMaxId) { - // 已读 - vo.setStatus(MessageStatus.READED.code()); - } else { - // 未推送 - vo.setStatus(MessageStatus.UNSEND.code()); + // 填充状态 + vo.setStatus(readedMaxId >= m.getId() ? MessageStatus.READED.code() : MessageStatus.UNSEND.code()); + // 针对回执消息填充已读人数 + if(m.getReceipt()){ + if(Objects.isNull(maxIdMap)) { + maxIdMap = redisTemplate.opsForHash().entries(key); + } + int count = getReadedUserIds(maxIdMap, m.getId(),m.getSendId()).size(); + vo.setReadedCount(count); } - }); - idx++; - } - return vos; + // 推送 + IMGroupMessage sendMessage = new IMGroupMessage<>(); + sendMessage.setSender(new IMUserInfo(m.getSendId(), IMTerminalType.WEB.code())); + sendMessage.setRecvIds(Arrays.asList(session.getUserId())); + sendMessage.setRecvTerminals(Arrays.asList(session.getTerminal())); + sendMessage.setSendResult(false); + sendMessage.setSendToSelf(false); + sendMessage.setData(vo); + imClient.sendGroupMessage(sendMessage); + sendCount.getAndIncrement(); + } + }); + // 关闭加载中标志 + this.sendLoadingMessage(false); + log.info("拉取离线群聊消息,用户id:{},数量:{}",session.getUserId(),sendCount.get()); } @Override @@ -203,12 +301,15 @@ public class GroupMessageServiceImpl extends ServiceImpl wrapper = Wrappers.lambdaQuery(); - wrapper.eq(GroupMessage::getGroupId, groupId).orderByDesc(GroupMessage::getId).last("limit 1").select(GroupMessage::getId); + wrapper.eq(GroupMessage::getGroupId, groupId) + .orderByDesc(GroupMessage::getId) + .last("limit 1") + .select(GroupMessage::getId); GroupMessage message = this.getOne(wrapper); if (Objects.isNull(message)) { return; } - // 推送消息给自己的其他终端 + // 推送消息给自己的其他终端,同步清空会话列表中的未读数量 GroupMessageVO msgInfo = new GroupMessageVO(); msgInfo.setType(MessageType.READED.code()); msgInfo.setSendTime(new Date()); @@ -220,10 +321,65 @@ public class GroupMessageServiceImpl extends ServiceImpl receiptMessages = this.list(wrapper); + if (CollectionUtil.isNotEmpty(receiptMessages)) { + List userIds = groupMemberService.findUserIdsByGroupId(groupId); + Map maxIdMap = redisTemplate.opsForHash().entries(key); + for (GroupMessage receiptMessage : receiptMessages) { + Integer readedCount = getReadedUserIds(maxIdMap, receiptMessage.getId(),receiptMessage.getSendId()).size(); + // 如果所有人都已读,记录回执消息完成标记 + if(readedCount >= userIds.size() - 1){ + receiptMessage.setReceiptOk(true); + this.updateById(receiptMessage); + } + msgInfo = new GroupMessageVO(); + msgInfo.setId(receiptMessage.getId()); + msgInfo.setGroupId(groupId); + msgInfo.setReadedCount(readedCount); + msgInfo.setReceiptOk(receiptMessage.getReceiptOk()); + msgInfo.setType(MessageType.RECEIPT.code()); + sendMessage = new IMGroupMessage<>(); + sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); + sendMessage.setRecvIds(userIds); + sendMessage.setData(msgInfo); + sendMessage.setSendToSelf(false); + sendMessage.setSendResult(false); + imClient.sendGroupMessage(sendMessage); + } + } + } + @Override + public List findReadedUsers(Long groupId, Long messageId) { + UserSession session = SessionContext.getSession(); + GroupMessage message = this.getById(messageId); + if (Objects.isNull(message)) { + throw new GlobalException(ResultCode.PROGRAM_ERROR, "消息不存在"); + } + // 是否在群聊里面 + GroupMember member = groupMemberService.findByGroupAndUserId(groupId, session.getUserId()); + if (Objects.isNull(member) || member.getQuit()) { + throw new GlobalException(ResultCode.PROGRAM_ERROR, "您已不在群聊里面"); + } + // 已读位置key + String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, groupId); + // 一次获取所有用户的已读位置 + Map maxIdMap = redisTemplate.opsForHash().entries(key); + // 返回已读用户的id集合 + return getReadedUserIds(maxIdMap, message.getId(),message.getSendId()); } @Override @@ -249,4 +405,32 @@ public class GroupMessageServiceImpl extends ServiceImpl getReadedUserIds(Map maxIdMap, Long messageId, Long sendId) { + List userIds = new LinkedList<>(); + maxIdMap.forEach((k, v) -> { + Long userId = Long.valueOf(k.toString()); + long maxId = Long.parseLong(v.toString()); + // 发送者不计入已读人数 + if (!sendId.equals(userId) && maxId >= messageId) { + userIds.add(userId); + } + }); + return userIds; + } + + private void sendLoadingMessage(Boolean isLoadding){ + UserSession session = SessionContext.getSession(); + GroupMessageVO msgInfo = new GroupMessageVO(); + msgInfo.setType(MessageType.LOADDING.code()); + msgInfo.setContent(isLoadding.toString()); + IMGroupMessage sendMessage = new IMGroupMessage<>(); + sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); + sendMessage.setRecvIds(Arrays.asList(session.getUserId())); + sendMessage.setRecvTerminals(Arrays.asList(session.getTerminal())); + sendMessage.setData(msgInfo); + sendMessage.setSendToSelf(false); + sendMessage.setSendResult(false); + imClient.sendGroupMessage(sendMessage); + } + } 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 1639dec..f3b4b59 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 @@ -1,27 +1,30 @@ package com.bx.implatform.service.impl; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bx.imclient.IMClient; +import com.bx.imcommon.model.IMGroupMessage; +import com.bx.imcommon.model.IMUserInfo; +import com.bx.imcommon.util.CommaTextUtils; import com.bx.implatform.contant.Constant; import com.bx.implatform.contant.RedisKey; -import com.bx.implatform.entity.Friend; -import com.bx.implatform.entity.Group; -import com.bx.implatform.entity.GroupMember; -import com.bx.implatform.entity.User; +import com.bx.implatform.entity.*; +import com.bx.implatform.enums.MessageStatus; +import com.bx.implatform.enums.MessageType; import com.bx.implatform.enums.ResultCode; import com.bx.implatform.exception.GlobalException; import com.bx.implatform.mapper.GroupMapper; -import com.bx.implatform.service.IFriendService; -import com.bx.implatform.service.IGroupMemberService; -import com.bx.implatform.service.IGroupService; -import com.bx.implatform.service.IUserService; +import com.bx.implatform.mapper.GroupMessageMapper; +import com.bx.implatform.service.*; import com.bx.implatform.session.SessionContext; import com.bx.implatform.session.UserSession; import com.bx.implatform.util.BeanUtils; import com.bx.implatform.vo.GroupInviteVO; import com.bx.implatform.vo.GroupMemberVO; +import com.bx.implatform.vo.GroupMessageVO; import com.bx.implatform.vo.GroupVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,6 +32,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,9 +46,10 @@ import java.util.stream.Collectors; public class GroupServiceImpl extends ServiceImpl implements IGroupService { private final IUserService userService; private final IGroupMemberService groupMemberService; + private final GroupMessageMapper groupMessageMapper; private final IFriendService friendsService; private final IMClient imClient; - + private final RedisTemplate redisTemplate; @Override public GroupVO createGroup(GroupVO vo) { UserSession session = SessionContext.getSession(); @@ -83,7 +88,7 @@ public class GroupServiceImpl extends ServiceImpl implements } // 更新成员信息 GroupMember member = groupMemberService.findByGroupAndUserId(vo.getId(), session.getUserId()); - if (member == null) { + if (Objects.isNull(member) || member.getQuit()) { throw new GlobalException(ResultCode.PROGRAM_ERROR, "您不是群聊的成员"); } member.setAliasName(StringUtils.isEmpty(vo.getAliasName()) ? session.getNickName() : vo.getAliasName()); @@ -102,11 +107,18 @@ public class GroupServiceImpl extends ServiceImpl implements if (!group.getOwnerId().equals(session.getUserId())) { throw new GlobalException(ResultCode.PROGRAM_ERROR, "只有群主才有权限解除群聊"); } + // 群聊用户id + List userIds = groupMemberService.findUserIdsByGroupId(groupId); // 逻辑删除群数据 group.setDeleted(true); this.updateById(group); // 删除成员数据 groupMemberService.removeByGroupId(groupId); + // 清理已读缓存 + String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, groupId); + redisTemplate.delete(key); + // 推送解散群聊提示 + this.sendTipMessage(groupId,userIds,String.format("'%s'解散了群聊",session.getNickName())); log.info("删除群聊,群聊id:{},群聊名称:{}", group.getId(), group.getName()); } @@ -119,6 +131,11 @@ public class GroupServiceImpl extends ServiceImpl implements } // 删除群聊成员 groupMemberService.removeByGroupAndUserId(groupId, userId); + // 清理已读缓存 + String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, groupId); + redisTemplate.opsForHash().delete(key,userId.toString()); + // 推送退出群聊提示 + this.sendTipMessage(groupId,Arrays.asList(userId),"您已退出群聊"); log.info("退出群聊,群聊id:{},群聊名称:{},用户id:{}", group.getId(), group.getName(), userId); } @@ -130,24 +147,33 @@ public class GroupServiceImpl extends ServiceImpl implements throw new GlobalException(ResultCode.PROGRAM_ERROR, "您不是群主,没有权限踢人"); } if (userId.equals(session.getUserId())) { - throw new GlobalException(ResultCode.PROGRAM_ERROR, "亲,不能自己踢自己哟"); + throw new GlobalException(ResultCode.PROGRAM_ERROR, "亲,不能移除自己哟"); } // 删除群聊成员 groupMemberService.removeByGroupAndUserId(groupId, userId); + // 清理已读缓存 + String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, groupId); + redisTemplate.opsForHash().delete(key,userId.toString()); + // 推送踢出群聊提示 + this.sendTipMessage(groupId,Arrays.asList(userId),"您已被移出群聊"); log.info("踢出群聊,群聊id:{},群聊名称:{},用户id:{}", group.getId(), group.getName(), userId); } @Override public GroupVO findById(Long groupId) { UserSession session = SessionContext.getSession(); - Group group = this.getById(groupId); + Group group = super.getById(groupId); + if (Objects.isNull(group)) { + throw new GlobalException(ResultCode.PROGRAM_ERROR, "群组不存在"); + } GroupMember member = groupMemberService.findByGroupAndUserId(groupId, session.getUserId()); - if (member == null) { + if (Objects.isNull(member)) { throw new GlobalException(ResultCode.PROGRAM_ERROR, "您未加入群聊"); } GroupVO vo = BeanUtils.copyProperties(group, GroupVO.class); vo.setAliasName(member.getAliasName()); vo.setRemark(member.getRemark()); + vo.setQuit(member.getQuit()); return vo; } @@ -155,7 +181,7 @@ public class GroupServiceImpl extends ServiceImpl implements @Override public Group getById(Long groupId) { Group group = super.getById(groupId); - if (group == null) { + if (Objects.isNull(group)) { throw new GlobalException(ResultCode.PROGRAM_ERROR, "群组不存在"); } if (group.getDeleted()) { @@ -169,6 +195,8 @@ public class GroupServiceImpl extends ServiceImpl implements UserSession session = SessionContext.getSession(); // 查询当前用户的群id列表 List groupMembers = groupMemberService.findByUserId(session.getUserId()); + // 一个月内退的群可能存在退群前的离线消息,一并返回作为前端缓存 + groupMembers.addAll(groupMemberService.findQuitInMonth(session.getUserId())); if (groupMembers.isEmpty()) { return new LinkedList<>(); } @@ -183,6 +211,7 @@ public class GroupServiceImpl extends ServiceImpl implements GroupMember member = groupMembers.stream().filter(m -> g.getId().equals(m.getGroupId())).findFirst().get(); vo.setAliasName(member.getAliasName()); vo.setRemark(member.getRemark()); + vo.setQuit(member.getQuit()); return vo; }).collect(Collectors.toList()); } @@ -191,9 +220,13 @@ public class GroupServiceImpl extends ServiceImpl implements public void invite(GroupInviteVO vo) { UserSession session = SessionContext.getSession(); Group group = this.getById(vo.getGroupId()); - if (group == null) { + if (Objects.isNull(group)) { throw new GlobalException(ResultCode.PROGRAM_ERROR, "群聊不存在"); } + GroupMember member = groupMemberService.findByGroupAndUserId(vo.getGroupId(), session.getUserId()); + if (Objects.isNull(group) || member.getQuit()) { + throw new GlobalException(ResultCode.PROGRAM_ERROR, "您不在群聊中,邀请失败"); + } // 群聊人数校验 List members = groupMemberService.findByGroupId(vo.getGroupId()); long size = members.stream().filter(m -> !m.getQuit()).count(); @@ -223,6 +256,11 @@ public class GroupServiceImpl extends ServiceImpl implements if (!groupMembers.isEmpty()) { groupMemberService.saveOrUpdateBatch(group.getId(), groupMembers); } + // 推送进入群聊消息 + List userIds = groupMemberService.findUserIdsByGroupId(vo.getGroupId()); + String memberNames = groupMembers.stream().map(GroupMember::getAliasName).collect(Collectors.joining(",")); + String content = String.format("'%s'邀请'%s'加入了群聊",session.getNickName(), memberNames); + this.sendTipMessage(vo.getGroupId(),userIds,content); log.info("邀请进入群聊,群聊id:{},群聊名称:{},被邀请用户id:{}", group.getId(), group.getName(), vo.getFriendIds()); } @@ -238,4 +276,33 @@ public class GroupServiceImpl extends ServiceImpl implements }).sorted((m1, m2) -> m2.getOnline().compareTo(m1.getOnline())).collect(Collectors.toList()); } + private void sendTipMessage(Long groupId,List recvIds,String content){ + UserSession session = SessionContext.getSession(); + // 消息入库 + GroupMessage message = new GroupMessage(); + message.setContent(content); + message.setType(MessageType.TIP_TEXT.code()); + message.setStatus(MessageStatus.UNSEND.code()); + message.setSendTime(new Date()); + message.setSendNickName(session.getNickName()); + message.setGroupId(groupId); + message.setSendId(session.getUserId()); + message.setRecvIds(CommaTextUtils.asText(recvIds)); + groupMessageMapper.insert(message); + // 推送 + GroupMessageVO msgInfo = BeanUtils.copyProperties(message,GroupMessageVO.class); + IMGroupMessage sendMessage = new IMGroupMessage<>(); + sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); + if(CollUtil.isEmpty(recvIds)){ + // 为空表示向全体发送 + List userIds = groupMemberService.findUserIdsByGroupId(groupId); + sendMessage.setRecvIds(userIds); + }else{ + sendMessage.setRecvIds(recvIds); + } + sendMessage.setData(msgInfo); + sendMessage.setSendResult(false); + sendMessage.setSendToSelf(false); + imClient.sendGroupMessage(sendMessage); + } } 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 2e65510..0d9c2e3 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 @@ -1,5 +1,6 @@ package com.bx.implatform.service.impl; +import cn.hutool.core.collection.CollectionUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; @@ -7,6 +8,8 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; 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; @@ -23,6 +26,7 @@ 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; @@ -172,23 +176,82 @@ public class PrivateMessageServiceImpl extends ServiceImpl BeanUtils.copyProperties(m, PrivateMessageVO.class)).collect(Collectors.toList()); } + @Override + public void pullOfflineMessage(Long minId) { + UserSession session = SessionContext.getSession(); + if(!imClient.isOnline(session.getUserId())){ + throw new GlobalException(ResultCode.PROGRAM_ERROR, "网络连接失败,无法拉取离线消息"); + } + + // 查询用户好友列表 + List friends = friendService.findFriendByUserId(session.getUserId()); + if (friends.isEmpty()) { + return; + } + // 开启加载中标志 + this.sendLoadingMessage(true); + List friendIds = friends.stream().map(Friend::getFriendId).collect(Collectors.toList()); + // 获取当前用户的消息 + LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(); + // 只能拉取最近1个月的1000条消息 + Date minDate = DateUtils.addMonths(new Date(), -1); + queryWrapper.gt(PrivateMessage::getId, minId) + .ge(PrivateMessage::getSendTime, minDate) + .ne(PrivateMessage::getStatus, MessageStatus.RECALL.code()) + .and(wrap -> wrap.and( + wp -> wp.eq(PrivateMessage::getSendId, session.getUserId()) + .in(PrivateMessage::getRecvId, friendIds)) + .or(wp -> wp.eq(PrivateMessage::getRecvId, session.getUserId()) + .in(PrivateMessage::getSendId, friendIds))) + .orderByDesc(PrivateMessage::getId) + .last("limit 1000"); + List messages = this.list(queryWrapper); + // 消息顺序从小到大 + CollectionUtil.reverse(messages); + // 推送消息 + for(PrivateMessage m:messages ){ + PrivateMessageVO vo = BeanUtils.copyProperties(m, PrivateMessageVO.class); + IMPrivateMessage sendMessage = new IMPrivateMessage<>(); + sendMessage.setSender(new IMUserInfo(m.getSendId(), IMTerminalType.WEB.code())); + sendMessage.setRecvId(session.getUserId()); + sendMessage.setRecvTerminals(Arrays.asList(session.getTerminal())); + sendMessage.setSendToSelf(false); + sendMessage.setData(vo); + sendMessage.setSendResult(true); + imClient.sendPrivateMessage(sendMessage); + } + // 关闭加载中标志 + this.sendLoadingMessage(false); + log.info("拉取私聊消息,用户id:{},数量:{}", session.getUserId(), messages.size()); + } @Transactional(rollbackFor = Exception.class) @Override public void readedMessage(Long friendId) { UserSession session = SessionContext.getSession(); - // 推送消息 + // 推送消息给自己,清空会话列表上的已读数量 PrivateMessageVO msgInfo = new PrivateMessageVO(); msgInfo.setType(MessageType.READED.code()); - msgInfo.setSendTime(new Date()); msgInfo.setSendId(session.getUserId()); msgInfo.setRecvId(friendId); IMPrivateMessage sendMessage = new IMPrivateMessage<>(); + sendMessage.setData(msgInfo); + sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); + sendMessage.setRecvId(session.getUserId()); + sendMessage.setSendToSelf(false); + sendMessage.setSendResult(false); + imClient.sendPrivateMessage(sendMessage); + // 推送回执消息给对方,更新已读状态 + msgInfo = new PrivateMessageVO(); + msgInfo.setType(MessageType.RECEIPT.code()); + msgInfo.setSendId(session.getUserId()); + msgInfo.setRecvId(friendId); + sendMessage = new IMPrivateMessage<>(); sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); sendMessage.setRecvId(friendId); - sendMessage.setSendToSelf(true); - sendMessage.setData(msgInfo); + sendMessage.setSendToSelf(false); sendMessage.setSendResult(false); + sendMessage.setData(msgInfo); imClient.sendPrivateMessage(sendMessage); // 修改消息状态为已读 LambdaUpdateWrapper updateWrapper = Wrappers.lambdaUpdate(); @@ -217,4 +280,20 @@ public class PrivateMessageServiceImpl extends ServiceImpl(); + sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal())); + sendMessage.setRecvId(session.getUserId()); + sendMessage.setRecvTerminals(Arrays.asList(session.getTerminal())); + sendMessage.setData(msgInfo); + sendMessage.setSendToSelf(false); + sendMessage.setSendResult(false); + imClient.sendPrivateMessage(sendMessage); + } } diff --git a/im-platform/src/main/java/com/bx/implatform/vo/GroupMessageVO.java b/im-platform/src/main/java/com/bx/implatform/vo/GroupMessageVO.java index 1f5beae..377fd8a 100644 --- a/im-platform/src/main/java/com/bx/implatform/vo/GroupMessageVO.java +++ b/im-platform/src/main/java/com/bx/implatform/vo/GroupMessageVO.java @@ -3,6 +3,7 @@ package com.bx.implatform.vo; import com.bx.imcommon.serializer.DateToLongSerializer; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.swagger.annotations.ApiModelProperty; +import io.swagger.models.auth.In; import lombok.Data; import java.util.Date; @@ -29,6 +30,15 @@ public class GroupMessageVO { @ApiModelProperty(value = "消息内容类型 具体枚举值由应用层定义") private Integer type; + @ApiModelProperty(value = "是否回执消息") + private Boolean receipt; + + @ApiModelProperty(value = "回执消息是否完成") + private Boolean receiptOk; + + @ApiModelProperty(value = "已读消息数量") + private Integer readedCount = 0; + @ApiModelProperty(value = "@用户列表") private List atUserIds; diff --git a/im-platform/src/main/java/com/bx/implatform/vo/GroupVO.java b/im-platform/src/main/java/com/bx/implatform/vo/GroupVO.java index e722c05..cbbbf14 100644 --- a/im-platform/src/main/java/com/bx/implatform/vo/GroupVO.java +++ b/im-platform/src/main/java/com/bx/implatform/vo/GroupVO.java @@ -1,5 +1,6 @@ package com.bx.implatform.vo; +import com.baomidou.mybatisplus.annotation.TableField; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; @@ -40,4 +41,11 @@ public class GroupVO { @ApiModelProperty(value = "群聊显示备注") private String remark; + @ApiModelProperty(value = "是否已删除") + private Boolean deleted; + + @ApiModelProperty(value = "是否已退出") + private Boolean quit; + + } diff --git a/im-platform/src/main/resources/application.yml b/im-platform/src/main/resources/application.yml index 9e28aa4..b195ce1 100644 --- a/im-platform/src/main/resources/application.yml +++ b/im-platform/src/main/resources/application.yml @@ -1,7 +1,5 @@ -#这是配置服务的端口 server: port: 8888 -#配置项目的数据源 spring: application: name: im-platform @@ -17,7 +15,6 @@ spring: redis: host: 127.0.0.1 port: 6379 - database: 1 servlet: multipart: @@ -26,12 +23,10 @@ spring: mybatis-plus: configuration: - # 是否开启自动驼峰命名规则(camel case)映射,即从经典数据库列名 A_COLUMN(下划线命名) 到经典 Java 属性名 aColumn(驼峰命名) 的类似映射 + # 是否开启自动驼峰命名规则 map-underscore-to-camel-case: false - #log-impl: org.apache.ibatis.logging.stdout.StdOutImpl - # mapper + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl mapper-locations: - # *.xml的具体路径 - classpath*:mapper/*.xml minio: endpoint: http://127.0.0.1:9001 #内网地址 diff --git a/im-platform/src/main/resources/db/db.sql b/im-platform/src/main/resources/db/db.sql index ce67f13..1ea4b86 100644 --- a/im-platform/src/main/resources/db/db.sql +++ b/im-platform/src/main/resources/db/db.sql @@ -55,9 +55,10 @@ create table `im_group_member`( `group_id` bigint not null comment '群id', `user_id` bigint not null comment '用户id', `alias_name` varchar(255) DEFAULT '' comment '组内显示名称', - `head_image` varchar(255) default '' comment '用户头像', + `head_image` varchar(255) DEFAULT '' comment '用户头像', `remark` varchar(255) DEFAULT '' comment '备注', `quit` tinyint(1) DEFAULT 0 comment '是否已退出', + `quit_time` datetime DEFAULT NULL comment '退出时间', `created_time` datetime DEFAULT CURRENT_TIMESTAMP comment '创建时间', key `idx_group_id`(`group_id`), key `idx_user_id`(`user_id`) @@ -68,10 +69,13 @@ create table `im_group_message`( `group_id` bigint not null comment '群id', `send_id` bigint not null comment '发送用户id', `send_nick_name` varchar(255) DEFAULT '' comment '发送用户昵称', + `recv_ids` varchar(1024) DEFAULT '' comment '接收用户id,逗号分隔,为空表示发给所有成员', `content` text comment '发送内容', `at_user_ids` varchar(1024) comment '被@的用户id列表,逗号分隔', - `type` tinyint(1) NOT NULL comment '消息类型 0:文字 1:图片 2:文件 3:语音 10:系统提示' , - `status` tinyint(1) DEFAULT 0 comment '状态 0:正常 2:撤回', + `receipt` tinyint DEFAULT 0 comment '是否回执消息', + `receipt_ok` tinyint DEFAULT 0 comment '回执消息是否完成', + `type` tinyint(1) NOT NULL comment '消息类型 0:文字 1:图片 2:文件 3:语音 4:视频 10:系统提示' , + `status` tinyint(1) DEFAULT 0 comment '状态 0:未发出 1:已送达 2:撤回 3:已读', `send_time` datetime DEFAULT CURRENT_TIMESTAMP comment '发送时间', key `idx_group_id` (group_id) )ENGINE=InnoDB CHARSET=utf8mb3 comment '群消息'; diff --git a/im-server/pom.xml b/im-server/pom.xml index f799b0c..e5d0e78 100644 --- a/im-server/pom.xml +++ b/im-server/pom.xml @@ -22,10 +22,6 @@ org.springframework.boot spring-boot - - org.springframework.boot - spring-boot-starter-web - io.netty netty-all diff --git a/im-server/src/main/resources/application.yml b/im-server/src/main/resources/application.yml index e3110a4..827343d 100644 --- a/im-server/src/main/resources/application.yml +++ b/im-server/src/main/resources/application.yml @@ -1,11 +1,7 @@ -server: - port: 8877 - spring: redis: host: 127.0.0.1 port: 6379 - database: 1 websocket: enable: true diff --git a/im-ui/package.json b/im-ui/package.json index 0413271..d31873b 100644 --- a/im-ui/package.json +++ b/im-ui/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "serve": "vue-cli-service serve", - "build": "vue-cli-service build", + "build": "set NODE_OPTIONS=--openssl-legacy-provider & vue-cli-service build", "lint": "vue-cli-service lint" }, "dependencies": { diff --git a/im-ui/src/api/enums.js b/im-ui/src/api/enums.js index d5f2390..3206bba 100644 --- a/im-ui/src/api/enums.js +++ b/im-ui/src/api/enums.js @@ -7,7 +7,10 @@ const MESSAGE_TYPE = { VIDEO:4, RECALL:10, READED:11, + RECEIPT:12, TIP_TIME:20, + TIP_TEXT:21, + LOADDING:30, RTC_CALL: 101, RTC_ACCEPT: 102, RTC_REJECT: 103, diff --git a/im-ui/src/api/wssocket.js b/im-ui/src/api/wssocket.js index 2cb4198..9842697 100644 --- a/im-ui/src/api/wssocket.js +++ b/im-ui/src/api/wssocket.js @@ -105,7 +105,6 @@ let heartCheck = { // 实际调用的方法 let sendMessage = (agentData) => { - // console.log(globalCallback) if (websock.readyState === websock.OPEN) { // 若是ws开启状态 websock.send(JSON.stringify(agentData)) diff --git a/im-ui/src/assets/iconfont/iconfont.css b/im-ui/src/assets/iconfont/iconfont.css index 0b6840e..7092657 100644 --- a/im-ui/src/assets/iconfont/iconfont.css +++ b/im-ui/src/assets/iconfont/iconfont.css @@ -1,8 +1,6 @@ @font-face { font-family: "iconfont"; /* Project id 3791506 */ - src: url('iconfont.woff2?t=1669336625993') format('woff2'), - url('iconfont.woff?t=1669336625993') format('woff'), - url('iconfont.ttf?t=1669336625993') format('truetype'); + src: url('iconfont.ttf?t=1706022894868') format('truetype'); } .iconfont { @@ -13,6 +11,14 @@ -moz-osx-font-smoothing: grayscale; } +.icon-ok:before { + content: "\e6ac"; +} + +.icon-receipt:before { + content: "\e61a"; +} + .icon-biaoqing:before { content: "\e60c"; } diff --git a/im-ui/src/assets/iconfont/iconfont.ttf b/im-ui/src/assets/iconfont/iconfont.ttf index 9450138..79f6e9c 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/assets/iconfont/iconfont.woff b/im-ui/src/assets/iconfont/iconfont.woff deleted file mode 100644 index 70cb168..0000000 Binary files a/im-ui/src/assets/iconfont/iconfont.woff and /dev/null differ diff --git a/im-ui/src/assets/iconfont/iconfont.woff2 b/im-ui/src/assets/iconfont/iconfont.woff2 deleted file mode 100644 index c788739..0000000 Binary files a/im-ui/src/assets/iconfont/iconfont.woff2 and /dev/null differ diff --git a/im-ui/src/components/chat/ChatAtBox.vue b/im-ui/src/components/chat/ChatAtBox.vue index 9e1cbc7..3b07c30 100644 --- a/im-ui/src/components/chat/ChatAtBox.vue +++ b/im-ui/src/components/chat/ChatAtBox.vue @@ -1,26 +1,18 @@ \ No newline at end of file + + .chat-group-side-box { + border: #dddddd solid 1px; + animation: rtl-drawer-in .3s 1ms; + } +} \ No newline at end of file diff --git a/im-ui/src/components/chat/ChatGroupMember.vue b/im-ui/src/components/chat/ChatGroupMember.vue new file mode 100644 index 0000000..589d346 --- /dev/null +++ b/im-ui/src/components/chat/ChatGroupMember.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/im-ui/src/components/chat/ChatGroupReaded.vue b/im-ui/src/components/chat/ChatGroupReaded.vue new file mode 100644 index 0000000..f9e5867 --- /dev/null +++ b/im-ui/src/components/chat/ChatGroupReaded.vue @@ -0,0 +1,187 @@ + + + + + + \ No newline at end of file diff --git a/im-ui/src/components/chat/ChatGroupSide.vue b/im-ui/src/components/chat/ChatGroupSide.vue index 0904663..44e1635 100644 --- a/im-ui/src/components/chat/ChatGroupSide.vue +++ b/im-ui/src/components/chat/ChatGroupSide.vue @@ -1,12 +1,12 @@ \ No newline at end of file diff --git a/im-ui/src/view/Login.vue b/im-ui/src/view/Login.vue index 13c8ee2..973d969 100644 --- a/im-ui/src/view/Login.vue +++ b/im-ui/src/view/Login.vue @@ -19,7 +19,22 @@ -
+
+

最近更新(2024-01-28):

+
    +
  • 支持群聊已读显示(回执消息)
  • +
  • 群聊会话窗口增加邀请、退群、移除、解散提示
  • +
+
+
+

最近更新(2024-02-24):

+
    +
  • uniapp端兼容ios和andriod, + 点击下载安卓客户端 +
  • +
  • uniapp端的启动和打包方式有所变化,具体请参考语雀文档
  • +
+

项目依旧完全开源,可内网部署。如果项目对您有帮助,请帮忙点个star:

diff --git a/im-uniapp/.env.js b/im-uniapp/.env.js new file mode 100644 index 0000000..493d3b4 --- /dev/null +++ b/im-uniapp/.env.js @@ -0,0 +1,16 @@ +//设置环境(打包前修改此变量) +const ENV = "DEV"; +const UNI_APP = {} +if(ENV=="DEV"){ + UNI_APP.BASE_URL = "http://127.0.0.1:8888"; + UNI_APP.WS_URL = "ws://127.0.0.1:8878/im"; + // H5 走本地代理解决跨域问题 + // #ifdef H5 + UNI_APP.BASE_URL = "/api"; + // #endif +} +if(ENV=="PROD"){ + UNI_APP.BASE_URL = "https://www.boxim.online/api"; + UNI_APP.WS_URL = "wss://www.boxim.online:81/im"; +} +export default UNI_APP \ No newline at end of file diff --git a/im-uniapp/App.vue b/im-uniapp/App.vue index 4f2de1e..3984a47 100644 --- a/im-uniapp/App.vue +++ b/im-uniapp/App.vue @@ -3,7 +3,8 @@ import http from './common/request'; import * as enums from './common/enums'; import * as wsApi from './common/wssocket'; - + import UNI_APP from '@/.env.js' + export default { data() { return { @@ -26,11 +27,11 @@ initWebSocket() { let loginInfo = uni.getStorageSync("loginInfo") wsApi.init(); - wsApi.connect(process.env.WS_URL, loginInfo.accessToken); + wsApi.connect(UNI_APP.WS_URL, loginInfo.accessToken); wsApi.onConnect(() => { // 加载离线消息 - this.loadPrivateMessage(store.state.chatStore.privateMsgMaxId); - this.loadGroupMessage(store.state.chatStore.groupMsgMaxId); + this.pullPrivateOfflineMessage(store.state.chatStore.privateMsgMaxId); + this.pullGroupOfflineMessage(store.state.chatStore.groupMsgMaxId); }); wsApi.onMessage((cmd, msgInfo) => { if (cmd == 2) { @@ -49,85 +50,53 @@ } }); wsApi.onClose((res) => { - // 3000是客户端主动关闭 - if (res.code != 3000) { + // 1000是客户端正常主动关闭 + if (res.code != 1000) { // 重新连接 uni.showToast({ title: '连接已断开,尝试重新连接...', icon: 'none', }) let loginInfo = uni.getStorageSync("loginInfo") - wsApi.reconnect(process.env.WS_URL, loginInfo.accessToken); + wsApi.reconnect(UNI_APP.WS_URL, loginInfo.accessToken); } }) }, - loadPrivateMessage(minId) { - store.commit("loadingPrivateMsg", true) + pullPrivateOfflineMessage(minId) { http({ - url: "/message/private/loadMessage?minId=" + minId, - method: 'GET' - }).then((msgInfos) => { - msgInfos.forEach((msgInfo) => { - msgInfo.selfSend = msgInfo.sendId == store.state.userStore.userInfo.id; - let friendId = msgInfo.selfSend ? msgInfo.recvId : msgInfo.sendId; - let friend = store.state.friendStore.friends.find((f) => f.id == friendId); - if (friend) { - this.insertPrivateMessage(friend, msgInfo); - } - }) - if (msgInfos.length == 100) { - // 继续拉取 - this.loadPrivateMessage(msgInfos[99].id); - } else { - store.commit("loadingPrivateMsg", false) - } - }) + url: "/message/private/pullOfflineMessage?minId=" + minId, + method: 'get' + }); }, - loadGroupMessage(minId) { - store.commit("loadingGroupMsg", true) + pullGroupOfflineMessage(minId) { http({ - url: "/message/group/loadMessage?minId=" + minId, - method: 'GET' - }).then((msgInfos) => { - msgInfos.forEach((msgInfo) => { - msgInfo.selfSend = msgInfo.sendId == store.state.userStore.userInfo.id; - let groupId = msgInfo.groupId; - let group = store.state.groupStore.groups.find((g) => g.id == groupId); - if (group) { - this.insertGroupMessage(group, msgInfo); - } - }) - if (msgInfos.length == 100) { - // 继续拉取 - this.loadGroupMessage(msgInfos[99].id); - } else { - store.commit("loadingGroupMsg", false) - } - }) + url: "/message/group/pullOfflineMessage?minId=" + minId, + method: 'get' + }); }, handlePrivateMessage(msg) { + // 消息加载标志 + if (msg.type == enums.MESSAGE_TYPE.LOADDING) { + store.commit("loadingPrivateMsg", JSON.parse(msg.content)) + return; + } + // 消息已读处理,清空已读数量 + if (msg.type == enums.MESSAGE_TYPE.READED) { + store.commit("resetUnreadCount", { + type: 'PRIVATE', + targetId: msg.recvId + }) + return; + } + // 消息回执处理,改消息状态为已读 + if (msg.type == enums.MESSAGE_TYPE.RECEIPT) { + store.commit("readedMessage", { friendId: msg.sendId }) + return; + } // 标记这条消息是不是自己发的 msg.selfSend = msg.sendId == store.state.userStore.userInfo.id; // 好友id let friendId = msg.selfSend ? msg.recvId : msg.sendId; - // 消息已读处理 - if (msg.type == enums.MESSAGE_TYPE.READED) { - if (msg.selfSend) { - // 我已读对方的消息,清空已读数量 - let chatInfo = { - type: 'PRIVATE', - targetId: friendId - } - store.commit("resetUnreadCount", chatInfo) - } else { - // 对方已读我的消息,修改消息状态为已读 - store.commit("readedMessage", { - friendId: friendId - }) - - } - return; - } this.loadFriendInfo(friendId).then((friend) => { this.insertPrivateMessage(friend, msg); }) @@ -153,20 +122,36 @@ }, handleGroupMessage(msg) { - // 标记这条消息是不是自己发的 - msg.selfSend = msg.sendId == store.state.userStore.userInfo.id; - let groupId = msg.groupId; + // 消息加载标志 + if (msg.type == enums.MESSAGE_TYPE.LOADDING) { + store.commit("loadingGroupMsg",JSON.parse(msg.content)) + return; + } // 消息已读处理 if (msg.type == enums.MESSAGE_TYPE.READED) { // 我已读对方的消息,清空已读数量 let chatInfo = { type: 'GROUP', - targetId: groupId + targetId: msg.groupId } store.commit("resetUnreadCount", chatInfo) return; } - this.loadGroupInfo(groupId).then((group) => { + // 消息回执处理 + if (msg.type == enums.MESSAGE_TYPE.RECEIPT) { + // 更新消息已读人数 + let msgInfo = { + id: msg.id, + groupId: msg.groupId, + readedCount: msg.readedCount, + receiptOk: msg.receiptOk + }; + store.commit("updateMessage", msgInfo) + return; + } + // 标记这条消息是不是自己发的 + msg.selfSend = msg.sendId == store.state.userStore.userInfo.id; + this.loadGroupInfo(msg.groupId).then((group) => { // 插入群聊消息 this.insertGroupMessage(group, msg); }) @@ -220,7 +205,7 @@ }, exit() { console.log("exit"); - wsApi.close(); + wsApi.close(1000); uni.removeStorageSync("loginInfo"); uni.reLaunch({ url: "/pages/login/login" @@ -234,7 +219,6 @@ // this.audioTip.play(); }, initAudit() { - console.log("initAudit") if (store.state.userStore.userInfo.type == 1) { // 显示群组功能 uni.setTabBarItem({ diff --git a/im-uniapp/common/enums.js b/im-uniapp/common/enums.js index 6beeb24..caad425 100644 --- a/im-uniapp/common/enums.js +++ b/im-uniapp/common/enums.js @@ -7,7 +7,10 @@ const MESSAGE_TYPE = { VIDEO:4, RECALL:10, READED:11, + RECEIPT:12, TIP_TIME:20, + TIP_TEXT:21, + LOADDING:30, RTC_CALL: 101, RTC_ACCEPT: 102, RTC_REJECT: 103, diff --git a/im-uniapp/common/request.js b/im-uniapp/common/request.js index 606c841..461ff6b 100644 --- a/im-uniapp/common/request.js +++ b/im-uniapp/common/request.js @@ -1,3 +1,5 @@ +import UNI_APP from '@/.env.js' + // 请求队列 let requestList = []; // 是否正在刷新中 @@ -11,7 +13,7 @@ const request = (options) => { } return new Promise(function(resolve, reject) { uni.request({ - url: process.env.BASE_URL + options.url, + url: UNI_APP.BASE_URL + options.url, method: options.method || 'GET', header: header, data: options.data || {}, @@ -71,7 +73,7 @@ const reqRefreshToken = (loginInfo) => { return new Promise(function(resolve, reject) { uni.request({ method: 'PUT', - url: process.env.BASE_URL + '/refreshToken', + url: UNI_APP.BASE_URL + '/refreshToken', header: { refreshToken: loginInfo.refreshToken }, diff --git a/im-uniapp/common/wssocket.js b/im-uniapp/common/wssocket.js index be7ff7f..7c6acff 100644 --- a/im-uniapp/common/wssocket.js +++ b/im-uniapp/common/wssocket.js @@ -46,17 +46,15 @@ let init = () => { uni.onSocketClose((res) => { console.log('WebSocket连接关闭') - isConnect = false; //断开后修改标识 + isConnect = false; closeCallBack && closeCallBack(res); }) uni.onSocketError((e) => { console.log(e) - isConnect = false; //连接断开修改标识 - uni.showModal({ - content: '连接失败,可能是websocket服务不可用,请稍后再试', - showCancel: false, - }) + isConnect = false; + // APP 应用切出超过一定时间(约1分钟)会触发报错,此处回调给应用进行重连 + closeCallBack && closeCallBack({code: 1006}); }) }; @@ -95,12 +93,12 @@ let reconnect = (wsurl, accessToken) => { }; //设置关闭连接 -let close = () => { +let close = (code) => { if (!isConnect) { return; } uni.closeSocket({ - code: 3000, + code: code, complete: (res) => { console.log("关闭websocket连接"); isConnect = false; diff --git a/im-uniapp/components/chat-group-readed/chat-group-readed.vue b/im-uniapp/components/chat-group-readed/chat-group-readed.vue new file mode 100644 index 0000000..6b7452f --- /dev/null +++ b/im-uniapp/components/chat-group-readed/chat-group-readed.vue @@ -0,0 +1,131 @@ + + + + + \ 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 8cc3a20..4ea58a2 100644 --- a/im-uniapp/components/chat-message-item/chat-message-item.vue +++ b/im-uniapp/components/chat-message-item/chat-message-item.vue @@ -1,6 +1,6 @@