Kaynağa Gözat

!154 消息转义优化
Merge pull request !154 from blue/v_3.0.0

blue 8 ay önce
ebeveyn
işleme
8f2b1af205

+ 11 - 0
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);
+    }
+
+
     /**
      * 判断多个用户是否在线
      *

+ 5 - 0
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();

+ 32 - 17
im-platform/src/main/java/com/bx/implatform/service/impl/GroupMessageServiceImpl.java

@@ -158,13 +158,17 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
             this.sendLoadingMessage(false, session);
             return;
         }
-
-        // 只能拉取最近3个月的,移动端只拉最近一个月
-        int months = session.getTerminal().equals(IMTerminalType.APP.code()) ? 1 : 3;
-        Date minDate = DateUtils.addMonths(new Date(), -months);
+        // 只拉最近一个月
+        Date minDate = DateUtils.addMonths(new Date(), -1);
         LambdaQueryWrapper<GroupMessage> 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<GroupMessage> messages = this.list(wrapper);
         // 通过群聊对消息进行分组
         Map<Long, List<GroupMessage>> messageGroupMap =
@@ -173,9 +177,11 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
         List<GroupMember> 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<GroupMessage> groupMessages = this.list(wrapper);
             messageGroupMap.put(quitMember.getGroupId(), groupMessages);
             groupMemberMap.put(quitMember.getGroupId(), quitMember);
@@ -184,19 +190,28 @@ public class GroupMessageServiceImpl extends ServiceImpl<GroupMessageMapper, Gro
             // 开启加载中标志
             this.sendLoadingMessage(true, session);
             // 推送消息
-            AtomicInteger sendCount = new AtomicInteger();
-            messageGroupMap.forEach((groupId, groupMessages) -> {
-                // 第一次拉取时,一个群最多推送1w条消息,防止前端接收能力溢出导致卡顿
+            int sendCount = 0;
+            for (Map.Entry<Long, List<GroupMessage>> entry : messageGroupMap.entrySet()) {
+                Long groupId = entry.getKey();
+                List<GroupMessage> groupMessages = entry.getValue();
+                // 第一次拉取时,一个群最多推送3000条消息,防止前端接收能力溢出导致卡顿
                 List<GroupMessage> 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<Object, Object> 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<GroupMessageMapper, Gro
                     sendMessage.setSendToSelf(false);
                     sendMessage.setData(vo);
                     imClient.sendGroupMessage(sendMessage);
-                    sendCount.getAndIncrement();
+                    sendCount++;
                 }
-            });
+            }
             // 关闭加载中标志
             this.sendLoadingMessage(false, session);
-            log.info("拉取离线群聊消息,用户id:{},数量:{}", session.getUserId(), sendCount.get());
+            log.info("拉取离线群聊消息,用户id:{},数量:{}", session.getUserId(), sendCount++);
         });
     }
 

+ 5 - 0
im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java

@@ -154,6 +154,11 @@ public class PrivateMessageServiceImpl extends ServiceImpl<PrivateMessageMapper,
             // 开启加载中标志
             this.sendLoadingMessage(true, session);
             for (PrivateMessage m : messages) {
+                // 推送过程如果用户下线了,则不再推送
+                if (!imClient.isOnline(session.getUserId(), IMTerminalType.fromCode(session.getTerminal()))) {
+                    log.info("用户已下线,停止推送离线私聊消息,用户id:{}", session.getUserId());
+                    return;
+                }
                 PrivateMessageVO vo = BeanUtils.copyProperties(m, PrivateMessageVO.class);
                 IMPrivateMessage<PrivateMessageVO> sendMessage = new IMPrivateMessage<>();
                 sendMessage.setSender(new IMUserInfo(m.getSendId(), IMTerminalType.WEB.code()));

+ 14 - 0
im-uniapp/common/str.js

@@ -0,0 +1,14 @@
+let html2Escape = (strText) => {
+	return strText.replace(/[<>&"]/g, function(c) {
+		return {
+			'<': '&lt;',
+			'>': '&gt;',
+			'&': '&amp;',
+			'"': '&quot;'
+		} [c];
+	});
+}
+
+export default {
+	html2Escape
+}

+ 11 - 3
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
 }

+ 12 - 3
im-uniapp/components/chat-item/chat-item.vue

@@ -15,8 +15,8 @@
 			<view class="chat-content">
 				<view class="chat-at-text">{{ atText }}</view>
 				<view class="chat-send-name" v-if="isShowSendName">{{ chat.sendNickName + ':&nbsp;' }}</view>
-				<rich-text class="chat-content-text"
-					:nodes="$emo.transform(chat.lastContent,'emoji-small')"></rich-text>
+				<view v-if="!isTextMessage" class="chat-content-text">{{chat.lastContent}}</view>
+				<rich-text v-else class="chat-content-text" :nodes="nodesText"></rich-text>
 				<view v-if="chat.isDnd" class="icon iconfont icon-dnd"></view>
 				<uni-badge v-else-if="chat.unreadCount > 0" :max-num="99" :text="chat.unreadCount" />
 			</view>
@@ -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;
 			}

+ 9 - 8
im-uniapp/components/chat-message-item/chat-message-item.vue

@@ -16,11 +16,11 @@
 				<view class="bottom">
 					<view v-if="msgInfo.type == $enums.MESSAGE_TYPE.TEXT">
 						<long-press-menu :items="menuItems" @select="onSelectMenu">
-							<!-- rich-text支持显示表情,但是不支持点击a标签 -->
-							<rich-text v-if="$emo.containEmoji(msgInfo.content)" class="message-text"
-								:nodes="nodesText"></rich-text>
-							<!-- up-parse支持点击a标签,但安卓打包后表情无法显示,原因未知 -->
-							<up-parse v-else class="message-text" :showImgMenu="false" :content="nodesText"></up-parse>
+							<!-- up-parse支持点击a标签,但是不支持显示emo表情,也不支持换行 -->
+							<up-parse v-if="$url.containUrl(msgInfo.content)&&!$emo.containEmoji(msgInfo.content)"
+								class="message-text" :showImgMenu="false" :content="nodesText"></up-parse>
+							<!-- rich-text支持显示emo表情以及消息换行,但是不支持点击a标签 -->
+							<rich-text v-else class="message-text" :nodes="nodesText"></rich-text>
 						</long-press-menu>
 					</view>
 					<view class="message-image" v-if="msgInfo.type == $enums.MESSAGE_TYPE.IMAGE">
@@ -39,8 +39,8 @@
 						<long-press-menu :items="menuItems" @select="onSelectMenu">
 							<view class="file-box">
 								<view class="file-info">
-									<uni-link class="file-name" :text="data.name" showUnderLine="true"
-										color="#007BFF" :href="data.url"></uni-link>
+									<uni-link class="file-name" :text="data.name" showUnderLine="true" color="#007BFF"
+										:href="data.url"></uni-link>
 									<view class="file-size">{{ fileSize }}</view>
 								</view>
 								<view class="file-icon iconfont icon-file"></view>
@@ -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')
 		}
 	}

+ 34 - 32
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
+	}
+}

+ 8 - 14
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 {
-					'<': '&lt;',
-					'>': '&gt;',
-					'&': '&amp;',
-					'"': '&quot;'
-				} [c];
-			});
-		},
 		sendMessageRequest(msgInfo) {
 			return new Promise((resolve, reject) => {
 				// 请求入队列,防止请求"后发先至",导致消息错序

+ 14 - 0
im-web/src/api/str.js

@@ -0,0 +1,14 @@
+let html2Escape = (strText) => {
+	return strText.replace(/[<>&"]/g, function(c) {
+		return {
+			'<': '&lt;',
+			'>': '&gt;',
+			'&': '&amp;',
+			'"': '&quot;'
+		} [c];
+	});
+}
+
+export default {
+	html2Escape
+}

+ 2 - 3
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();

+ 1 - 11
im-web/src/components/chat/ChatInput.vue

@@ -367,16 +367,6 @@ export default {
 			// 记录光标所在位置
 			this.updateRange();
 		},
-		html2Escape(strHtml) {
-			return strHtml.replace(/[<>&"]/g, function(c) {
-				return {
-					'<': '&lt;',
-					'>': '&gt;',
-					'&': '&amp;',
-					'"': '&quot;'
-				} [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();

+ 1 - 1
im-web/src/components/chat/ChatItem.vue

@@ -16,7 +16,7 @@
 			<div class="chat-content">
 				<div class="chat-at-text">{{ atText }}</div>
 				<div class="chat-send-name" v-show="isShowSendName">{{ chat.sendNickName + ':&nbsp;' }}</div>
-				<div class="chat-content-text" v-html="$emo.transform(chat.lastContent, 'emoji-small')"></div>
+				<div class="chat-content-text" v-html="$emo.transform($str.html2Escape(chat.lastContent), 'emoji-small')"></div>
 				<div class="icon iconfont icon-dnd" v-if="chat.isDnd"></div>
 			</div>
 		</div>

+ 2 - 1
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')
 		}
 	}

+ 2 - 0
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(); // 全局事件