diff --git a/im-client/src/main/java/com/bx/imclient/IMClient.java b/im-client/src/main/java/com/bx/imclient/IMClient.java index e13518e..1630d64 100644 --- a/im-client/src/main/java/com/bx/imclient/IMClient.java +++ b/im-client/src/main/java/com/bx/imclient/IMClient.java @@ -26,6 +26,17 @@ public class IMClient { return imSender.isOnline(userId); } + /** + * 判断用户是否在线 + * + * @param userId 用户id + * @param terminal 终端可惜 + */ + public Boolean isOnline(Long userId,IMTerminalType terminal){ + return imSender.isOnline(userId,terminal); + } + + /** * 判断多个用户是否在线 * diff --git a/im-client/src/main/java/com/bx/imclient/sender/IMSender.java b/im-client/src/main/java/com/bx/imclient/sender/IMSender.java index 61d0a4d..1ced811 100644 --- a/im-client/src/main/java/com/bx/imclient/sender/IMSender.java +++ b/im-client/src/main/java/com/bx/imclient/sender/IMSender.java @@ -246,6 +246,11 @@ public class IMSender { return onlineMap; } + public Boolean isOnline(Long userId, IMTerminalType terminal) { + String key = String.join(":", IMRedisKey.IM_USER_SERVER_ID, userId.toString(), terminal.code().toString()); + return redisMQTemplate.hasKey(key); + } + public Boolean isOnline(Long userId) { String key = String.join(":", IMRedisKey.IM_USER_SERVER_ID, userId.toString(), "*"); return !Objects.requireNonNull(redisMQTemplate.keys(key)).isEmpty(); 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 f62e80f..50cd4cb 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 @@ -158,13 +158,17 @@ public class GroupMessageServiceImpl extends ServiceImpl wrapper = Wrappers.lambdaQuery(); - wrapper.gt(GroupMessage::getId, minId).gt(GroupMessage::getSendTime, minDate) - .in(GroupMessage::getGroupId, groupIds).orderByAsc(GroupMessage::getId); + wrapper.gt(GroupMessage::getId, minId); + wrapper.gt(GroupMessage::getSendTime, minDate); + wrapper.in(GroupMessage::getGroupId, groupIds); + wrapper.orderByDesc(GroupMessage::getId); + if (minId <= 0) { + // 首次拉取限制消息数量大小,防止内存溢出 + wrapper.last("limit 100000"); + } List messages = this.list(wrapper); // 通过群聊对消息进行分组 Map> messageGroupMap = @@ -173,9 +177,11 @@ public class GroupMessageServiceImpl extends ServiceImpl 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()).orderByAsc(GroupMessage::getId); + wrapper.gt(GroupMessage::getId, minId); + wrapper.between(GroupMessage::getSendTime, minDate, quitMember.getQuitTime()); + wrapper.eq(GroupMessage::getGroupId, quitMember.getGroupId()); + wrapper.ne(GroupMessage::getStatus, MessageStatus.RECALL.code()); + wrapper.orderByDesc(GroupMessage::getId); List groupMessages = this.list(wrapper); messageGroupMap.put(quitMember.getGroupId(), groupMessages); groupMemberMap.put(quitMember.getGroupId(), quitMember); @@ -184,19 +190,28 @@ public class GroupMessageServiceImpl extends ServiceImpl { - // 第一次拉取时,一个群最多推送1w条消息,防止前端接收能力溢出导致卡顿 + int sendCount = 0; + for (Map.Entry> entry : messageGroupMap.entrySet()) { + Long groupId = entry.getKey(); + List groupMessages = entry.getValue(); + // 第一次拉取时,一个群最多推送3000条消息,防止前端接收能力溢出导致卡顿 List sendMessages = groupMessages; - if (minId <= 0 && groupMessages.size() > 10000) { - sendMessages = groupMessages.subList(groupMessages.size() - 10000, groupMessages.size()); + if (minId <= 0 && groupMessages.size() > 3000) { + sendMessages = groupMessages.subList(0, 3000); } + // id从小到大排序 + CollectionUtil.reverse(sendMessages); // 填充消息状态 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 : sendMessages) { + // 推送过程如果用户下线了,则不再推送 + if (!imClient.isOnline(session.getUserId(), IMTerminalType.fromCode(session.getTerminal()))) { + log.info("用户已下线,停止推送离线群聊消息,用户id:{}", session.getUserId()); + return; + } // 排除加群之前的消息 GroupMember member = groupMemberMap.get(m.getGroupId()); if (DateUtil.compare(member.getCreatedTime(), m.getSendTime()) > 0) { @@ -231,12 +246,12 @@ public class GroupMessageServiceImpl extends ServiceImpl sendMessage = new IMPrivateMessage<>(); sendMessage.setSender(new IMUserInfo(m.getSendId(), IMTerminalType.WEB.code())); diff --git a/im-uniapp/common/str.js b/im-uniapp/common/str.js new file mode 100644 index 0000000..3f6de92 --- /dev/null +++ b/im-uniapp/common/str.js @@ -0,0 +1,14 @@ +let html2Escape = (strText) => { + return strText.replace(/[<>&"]/g, function(c) { + return { + '<': '<', + '>': '>', + '&': '&', + '"': '"' + } [c]; + }); +} + +export default { + html2Escape +} \ No newline at end of file diff --git a/im-uniapp/common/url.js b/im-uniapp/common/url.js index ff04275..c95d834 100644 --- a/im-uniapp/common/url.js +++ b/im-uniapp/common/url.js @@ -1,7 +1,14 @@ + +// 使用正则表达式匹配更广泛的URL格式(此正则由deepseek生成) +const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]|\bwww\.[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; + +let containUrl = (content) => { + return regex.test(content) +} + + let replaceURLWithHTMLLinks = (content, color) => { - // 使用正则表达式匹配更广泛的URL格式(此正则由deepseek生成) - const urlRegex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]|\bwww\.[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; - return content.replace(urlRegex, (url) => { + return content.replace(regex, (url) => { // 如果URL不以http(s)://开头,则添加http://前缀 if (!url.startsWith("http")) { url = "http://" + url; @@ -11,5 +18,6 @@ let replaceURLWithHTMLLinks = (content, color) => { } export default { + containUrl, replaceURLWithHTMLLinks } \ No newline at end of file diff --git a/im-uniapp/components/chat-item/chat-item.vue b/im-uniapp/components/chat-item/chat-item.vue index f8002ce..9c9b1c3 100644 --- a/im-uniapp/components/chat-item/chat-item.vue +++ b/im-uniapp/components/chat-item/chat-item.vue @@ -15,8 +15,8 @@ {{ atText }} {{ chat.sendNickName + ': ' }} - + {{chat.lastContent}} + @@ -77,6 +77,15 @@ export default { return "[@全体成员]" } return ""; + }, + isTextMessage() { + let idx = this.chat.messages.length - 1; + let messageType = this.chat.messages[idx].type; + return messageType == this.$enums.MESSAGE_TYPE.TEXT; + }, + nodesText() { + let text = this.$str.html2Escape(this.chat.lastContent); + return this.$emo.transform(text, 'emoji-small') } } } @@ -171,7 +180,7 @@ export default { overflow: hidden; text-overflow: ellipsis; } - + .icon { font-size: $im-font-size; } 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 aa7441c..88d6db8 100644 --- a/im-uniapp/components/chat-message-item/chat-message-item.vue +++ b/im-uniapp/components/chat-message-item/chat-message-item.vue @@ -16,11 +16,11 @@ - - - - + + + + @@ -39,8 +39,8 @@ - + {{ fileSize }} @@ -236,7 +236,8 @@ export default { }, nodesText() { let color = this.msgInfo.selfSend ? 'white' : ''; - let text = this.$url.replaceURLWithHTMLLinks(this.msgInfo.content, color) + let text = this.$str.html2Escape(this.msgInfo.content) + text = this.$url.replaceURLWithHTMLLinks(text, color) return this.$emo.transform(text, 'emoji-normal') } } diff --git a/im-uniapp/main.js b/im-uniapp/main.js index 59de0a9..8fa350a 100644 --- a/im-uniapp/main.js +++ b/im-uniapp/main.js @@ -2,7 +2,8 @@ import App from './App' import request from './common/request'; import emotion from './common/emotion.js'; import url from './common/url.js'; -import * as enums from './common/enums.js'; +import str from './common/str.js'; +import * as enums from './common/enums.js'; import * as date from './common/date'; import * as socketApi from './common/wssocket'; import * as messageType from './common/messageType'; @@ -21,10 +22,10 @@ import switchBar from '@/components/bar/switch-bar' // #ifdef H5 import * as recorder from './common/recorder-h5'; import ImageResize from "quill-image-resize-mp"; -import Quill from "quill"; +import Quill from "quill"; // 以下组件用于兼容部分手机聊天边框无法输入的问题 -window.Quill = Quill; -window.ImageResize = { default: ImageResize }; +window.Quill = Quill; +window.ImageResize = { default: ImageResize }; // 调试器 // import VConsole from 'vconsole' // new VConsole(); @@ -33,31 +34,32 @@ window.ImageResize = { default: ImageResize }; import * as recorder from './common/recorder-app'; // #endif export function createApp() { - const app = createSSRApp(App) - app.use(uviewPlus); - app.use(pinia.createPinia()); - app.component('bar-group', barGroup); - app.component('arrow-bar', arrowBar); - app.component('btn-bar', btnBar); - app.component('switch-bar', switchBar); - app.config.globalProperties.$http = request; - app.config.globalProperties.$wsApi = socketApi; - app.config.globalProperties.$msgType = messageType; - app.config.globalProperties.$emo = emotion; - app.config.globalProperties.$url = url; - app.config.globalProperties.$enums = enums; - app.config.globalProperties.$date = date; - app.config.globalProperties.$rc = recorder; - // 初始化时再挂载store对象 - app.config.globalProperties.$mountStore = () => { - app.config.globalProperties.chatStore = useChatStore(); - app.config.globalProperties.friendStore = useFriendStore(); - app.config.globalProperties.groupStore = useGroupStore(); - app.config.globalProperties.configStore = useConfigStore(); - app.config.globalProperties.userStore = useUserStore(); - } - return { - app, - pinia - } -} + const app = createSSRApp(App) + app.use(uviewPlus); + app.use(pinia.createPinia()); + app.component('bar-group', barGroup); + app.component('arrow-bar', arrowBar); + app.component('btn-bar', btnBar); + app.component('switch-bar', switchBar); + app.config.globalProperties.$http = request; + app.config.globalProperties.$wsApi = socketApi; + app.config.globalProperties.$msgType = messageType; + app.config.globalProperties.$emo = emotion; + app.config.globalProperties.$url = url; + app.config.globalProperties.$str = str; + app.config.globalProperties.$enums = enums; + app.config.globalProperties.$date = date; + app.config.globalProperties.$rc = recorder; + // 初始化时再挂载store对象 + app.config.globalProperties.$mountStore = () => { + app.config.globalProperties.chatStore = useChatStore(); + app.config.globalProperties.friendStore = useFriendStore(); + app.config.globalProperties.groupStore = useGroupStore(); + app.config.globalProperties.configStore = useConfigStore(); + app.config.globalProperties.userStore = useUserStore(); + } + return { + app, + pinia + } +} \ No newline at end of file diff --git a/im-uniapp/pages/chat/chat-box.vue b/im-uniapp/pages/chat/chat-box.vue index d2bd260..0a6c342 100644 --- a/im-uniapp/pages/chat/chat-box.vue +++ b/im-uniapp/pages/chat/chat-box.vue @@ -288,7 +288,9 @@ export default { sendText += op.insert ) }) - if (!sendText.trim() && this.atUserIds.length == 0) { + // 去除最后的换行符 + sendText = sendText.trim(); + if (!sendText && this.atUserIds.length == 0) { return uni.showToast({ title: "不能发送空白信息", icon: "none" @@ -297,7 +299,7 @@ export default { let receiptText = this.isReceipt ? "【回执消息】" : ""; let atText = this.createAtText(); let msgInfo = { - content: receiptText + this.html2Escape(sendText) + atText, + content: receiptText + sendText + atText, atUserIds: this.atUserIds, receipt: this.isReceipt, type: 0 @@ -307,9 +309,11 @@ export default { this.isReceipt = false; // 填充对方id this.fillTargetId(msgInfo, this.chat.targetId); + // 防止发送期间用户切换会话导致串扰 + const chat = this.chat; this.sendMessageRequest(msgInfo).then((m) => { m.selfSend = true; - this.chatStore.insertMessage(m, this.chat); + this.chatStore.insertMessage(m, chat); // 会话置顶 this.moveChatToTop(); }).finally(() => { @@ -610,7 +614,7 @@ export default { query.select('.chat-wrap').boundingClientRect(); query.exec(data => { this.scrollTop = data[0].height - scrollViewHeight; - if(this.scrollTop < 10){ + if (this.scrollTop < 10) { // 未渲染完成,重试一次 this.holdingScrollBar(); } @@ -731,16 +735,6 @@ export default { let px = info.windowWidth * rpx / 750; return Math.floor(rpx); }, - html2Escape(strHtml) { - return strHtml.replace(/[<>&"]/g, function(c) { - return { - '<': '<', - '>': '>', - '&': '&', - '"': '"' - } [c]; - }); - }, sendMessageRequest(msgInfo) { return new Promise((resolve, reject) => { // 请求入队列,防止请求"后发先至",导致消息错序 diff --git a/im-web/src/api/str.js b/im-web/src/api/str.js new file mode 100644 index 0000000..3f6de92 --- /dev/null +++ b/im-web/src/api/str.js @@ -0,0 +1,14 @@ +let html2Escape = (strText) => { + return strText.replace(/[<>&"]/g, function(c) { + return { + '<': '<', + '>': '>', + '&': '&', + '"': '"' + } [c]; + }); +} + +export default { + html2Escape +} \ No newline at end of file diff --git a/im-web/src/components/chat/ChatBox.vue b/im-web/src/components/chat/ChatBox.vue index 67a5f8c..bcf1add 100644 --- a/im-web/src/components/chat/ChatBox.vue +++ b/im-web/src/components/chat/ChatBox.vue @@ -456,13 +456,12 @@ export default { msgInfo.receipt = this.isReceipt; } this.lockMessage = true; + const chat = this.chat; this.sendMessageRequest(msgInfo).then((m) => { m.selfSend = true; - this.chatStore.insertMessage(m, this.chat); - // 会话置顶 + this.chatStore.insertMessage(m, chat); this.moveChatToTop(); }).finally(() => { - // 解除锁定 this.scrollToBottom(); this.isReceipt = false; resolve(); diff --git a/im-web/src/components/chat/ChatInput.vue b/im-web/src/components/chat/ChatInput.vue index 37be300..a597661 100644 --- a/im-web/src/components/chat/ChatInput.vue +++ b/im-web/src/components/chat/ChatInput.vue @@ -367,16 +367,6 @@ export default { // 记录光标所在位置 this.updateRange(); }, - html2Escape(strHtml) { - return strHtml.replace(/[<>&"]/g, function(c) { - return { - '<': '<', - '>': '>', - '&': '&', - '"': '"' - } [c]; - }); - }, submit() { let nodes = this.$refs.content.childNodes; let fullList = []; @@ -389,7 +379,7 @@ export default { continue; } if (node.nodeType === 3) { - tempText += this.html2Escape(node.textContent); + tempText += node.textContent; continue; } let nodeName = node.nodeName.toLowerCase(); diff --git a/im-web/src/components/chat/ChatItem.vue b/im-web/src/components/chat/ChatItem.vue index 07ee6b2..c050020 100644 --- a/im-web/src/components/chat/ChatItem.vue +++ b/im-web/src/components/chat/ChatItem.vue @@ -16,7 +16,7 @@
{{ atText }}
{{ chat.sendNickName + ': ' }}
-
+
diff --git a/im-web/src/components/chat/ChatMessageItem.vue b/im-web/src/components/chat/ChatMessageItem.vue index c6d719e..07244d5 100644 --- a/im-web/src/components/chat/ChatMessageItem.vue +++ b/im-web/src/components/chat/ChatMessageItem.vue @@ -193,7 +193,8 @@ export default { }, htmlText() { let color = this.msgInfo.selfSend ? 'white' : ''; - let text = this.$url.replaceURLWithHTMLLinks(this.msgInfo.content, color) + let text = this.$str.html2Escape(this.msgInfo.content) + text = this.$url.replaceURLWithHTMLLinks(text, color) return this.$emo.transform(text, 'emoji-normal') } } diff --git a/im-web/src/main.js b/im-web/src/main.js index 6f48d62..9a95449 100644 --- a/im-web/src/main.js +++ b/im-web/src/main.js @@ -10,6 +10,7 @@ import * as socketApi from './api/wssocket'; import * as messageType from './api/messageType'; import emotion from './api/emotion.js'; import url from './api/url.js'; +import str from './api/str.js'; import element from './api/element.js'; import * as enums from './api/enums.js'; import * as date from './api/date.js'; @@ -31,6 +32,7 @@ Vue.prototype.$date = date; Vue.prototype.$http = httpRequest // http请求方法 Vue.prototype.$emo = emotion; // emo表情 Vue.prototype.$url = url; // url转换 +Vue.prototype.$str = str; // 字符串相关 Vue.prototype.$elm = element; // 元素操作 Vue.prototype.$enums = enums; // 枚举 Vue.prototype.$eventBus = new Vue(); // 全局事件