diff --git a/im-platform/src/main/java/com/bx/implatform/service/UserService.java b/im-platform/src/main/java/com/bx/implatform/service/UserService.java index abc4a5a..3043e56 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/UserService.java +++ b/im-platform/src/main/java/com/bx/implatform/service/UserService.java @@ -130,13 +130,6 @@ public interface UserService extends IService { */ List getOnlineTerminals(String userIds); - /** - * 记录ip与ip地址 - * - * @param user 用户 - */ - void updateIpAndAddress(User user); - /** * 转接客服 * @param customerId 客服id targetId 转接的客服id userId 转接的用户id diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java index 36f4b5a..683ad8b 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java @@ -11,6 +11,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bx.imclient.IMClient; import com.bx.imcommon.enums.IMTerminalType; +import com.bx.imcommon.model.IMSystemMessage; import com.bx.imcommon.util.JwtUtil; import com.bx.implatform.config.props.JwtProperties; import com.bx.implatform.dto.LoginDTO; @@ -20,17 +21,14 @@ import com.bx.implatform.entity.*; import com.bx.implatform.enums.ResultCode; import com.bx.implatform.exception.GlobalException; import com.bx.implatform.mapper.UserMapper; -import com.bx.implatform.result.ResultUtils; 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.util.IpUtils; import com.bx.implatform.util.SensitiveFilterUtil; import com.bx.implatform.vo.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,8 +37,6 @@ import org.springframework.util.StringUtils; import java.util.*; import java.util.stream.Collectors; -import static com.bx.implatform.enums.ResultCode.XSS_PARAM_ERROR; - @Slf4j @Service @RequiredArgsConstructor @@ -193,38 +189,39 @@ public class UserServiceImpl extends ServiceImpl implements Us /** - * 通过uniqueToken查询客服ID,如果有多条匹配则随机返回一条 + * 通过uniqueToken查询客服ID,【优先分配在线客服】,在线客服为空则随机分配离线客服 */ public Long getCustomerServiceIdByUniqueToken(String uniqueToken) { - // 1. 构建查询条件 LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(); - queryWrapper.eq(User::getIsCustomer, 2); // 只查客服 - - if (StrUtil.isNotBlank(uniqueToken)) { - queryWrapper.eq(User::getUniqueToken, uniqueToken); - } - + queryWrapper.eq(User::getIsCustomer, 2); + queryWrapper.eq(User::getUniqueToken, uniqueToken); queryWrapper.select(User::getId); - // 2. 查询所有匹配的客服 List customerList = this.list(queryWrapper); - // 3. 如果没有匹配的客服,抛出异常 if (customerList == null || customerList.isEmpty()) { throw new GlobalException("未找到对应的客服,请检查 uniqueToken 是否正确"); } - // 4. 如果只有一条,直接返回 - if (customerList.size() == 1) { - Long customerId = customerList.get(0).getId(); - return customerId; - } + List allCustomerIds = customerList.stream() + .map(User::getId) + .collect(Collectors.toList()); + List onlineCustomerIds = imClient.getOnlineUser(allCustomerIds); - // 5. 如果有多条,随机选择一条返回 - int randomIndex = new Random().nextInt(customerList.size()); - Long randomCustomerId = customerList.get(randomIndex).getId(); + List onlineCustomerList = customerList.stream() + .filter(user -> onlineCustomerIds.contains(user.getId())) + .collect(Collectors.toList()); - return randomCustomerId; + List targetList; + if (!onlineCustomerList.isEmpty()) { + targetList = onlineCustomerList; + } else { + targetList = customerList; + } + + // 6. 随机选择一个 + int randomIndex = new Random().nextInt(targetList.size()); + return targetList.get(randomIndex).getId(); } @Override @@ -320,11 +317,18 @@ public class UserServiceImpl extends ServiceImpl implements Us throw new GlobalException(ResultCode.PASSWOR_ERROR); } - //未设置套餐或套餐过期 if(imAgentService.isPackageExpire(user.getUniqueToken())) { throw new GlobalException("套餐已过期"); } + SystemMessageVO msgVo = new SystemMessageVO(); + msgVo.setType(2); + + IMSystemMessage systemMessage = new IMSystemMessage<>(); + systemMessage.getRecvIds().add(user.getId()); + systemMessage.setData(msgVo); + imClient.sendSystemMessage(systemMessage); + // 生成token UserSession session = BeanUtils.copyProperties(user, UserSession.class); session.setUserId(user.getId()); @@ -339,9 +343,50 @@ public class UserServiceImpl extends ServiceImpl implements Us vo.setAccessTokenExpiresIn(jwtProperties.getAccessTokenExpireIn()); vo.setRefreshToken(refreshToken); vo.setRefreshTokenExpiresIn(jwtProperties.getRefreshTokenExpireIn()); + vo.setUser(user); return vo; } + +// @Override +// public LoginVO loginCustom(LoginDTO dto) { +// User user = this.findUserByUserName(dto.getUserName()); +// if (Objects.isNull(user)) { +// throw new GlobalException("用户不存在"); +// } +// if(user.getIsCustomer() == 1){ +// throw new GlobalException("用户不存在"); +// } +// if (user.getIsBanned()) { +// String tip = String.format("您的账号因'%s'已被管理员封禁,请联系客服!",user.getReason()); +// throw new GlobalException(tip); +// } +// if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) { +// throw new GlobalException(ResultCode.PASSWOR_ERROR); +// } +// +// //未设置套餐或套餐过期 +// if(imAgentService.isPackageExpire(user.getUniqueToken())) { +// throw new GlobalException("套餐已过期"); +// } +// +// // 生成token +// UserSession session = BeanUtils.copyProperties(user, UserSession.class); +// session.setUserId(user.getId()); +// session.setTerminal(dto.getTerminal()); +// String strJson = JSON.toJSONString(session); +// String accessToken = JwtUtil.sign(user.getId(), strJson, jwtProperties.getAccessTokenExpireIn(), +// jwtProperties.getAccessTokenSecret()); +// String refreshToken = JwtUtil.sign(user.getId(), strJson, jwtProperties.getRefreshTokenExpireIn(), +// jwtProperties.getRefreshTokenSecret()); +// LoginVO vo = new LoginVO(); +// vo.setAccessToken(accessToken); +// vo.setAccessTokenExpiresIn(jwtProperties.getAccessTokenExpireIn()); +// vo.setRefreshToken(refreshToken); +// vo.setRefreshTokenExpiresIn(jwtProperties.getRefreshTokenExpireIn()); +// return vo; +// } + @Override public LoginVO refreshToken(String refreshToken) { //验证 token @@ -614,16 +659,6 @@ public class UserServiceImpl extends ServiceImpl implements Us return vos; } - @Override - public void updateIpAndAddress(User user) { - String ip = user.getLastLoginIp(); - String address = IpUtils.getIpAddress(ip); - this.update(new UpdateWrapper().lambda() - .eq(User::getId, user.getId()) - .set(User::getLastLoginIp, ip) - .set(User::getIpAddress, address)); - } - @Override public void changeCustomer(Long customerId, Long targetId, Long userId) { // 更新好友关系 diff --git a/im-platform/src/main/java/com/bx/implatform/util/IpLocationUtil.java b/im-platform/src/main/java/com/bx/implatform/util/IpLocationUtil.java index f40b5c0..1744d9a 100644 --- a/im-platform/src/main/java/com/bx/implatform/util/IpLocationUtil.java +++ b/im-platform/src/main/java/com/bx/implatform/util/IpLocationUtil.java @@ -36,7 +36,7 @@ public class IpLocationUtil { String city = result.getStr("city", ""); // 拼接中文地址 - return (country + " " + regionName + " " + city).trim(); + return country + " " + regionName + " " + city; } catch (Exception e) { log.error("IP地理位置查询异常, ip:{}", ip, e); diff --git a/im-platform/src/main/java/com/bx/implatform/util/IpUtils.java b/im-platform/src/main/java/com/bx/implatform/util/IpUtils.java deleted file mode 100644 index ebee756..0000000 --- a/im-platform/src/main/java/com/bx/implatform/util/IpUtils.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.bx.implatform.util; - -import cn.hutool.http.HttpUtil; -import com.alibaba.fastjson.JSON; -import com.alibaba.fastjson.JSONObject; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -/** - * IP 地址查询工具类 - * 使用 ipify 和 httpbin 获取 IP 地址的地理位置信息 - */ -@Slf4j -@Component -public class IpUtils { - - private static final String IP_API = "http://ip-api.com/json/"; - - - /** - * 根据 IP 地址查询地理位置 - * - * @param ip IP 地址 - * @return 地理位置信息,格式如:"中国广东省深圳市" - */ - public static String getIpAddress(String ip) { - System.out.println("333333333"); - System.out.println(ip); - - - if (ip == null || ip.trim().isEmpty() || "unknown".equalsIgnoreCase(ip)) { - return ""; - } - - try { - // 使用 ip-api.com 查询 IP 归属地 - String url = IP_API + ip; - String response = HttpUtil.get(url, 5000); - - System.out.println("2222222222222222"); - System.out.println( response); - - if (response != null && !response.isEmpty()) { - JSONObject json = JSON.parseObject(response); - String status = json.getString("status"); - - if ("success".equals(status)) { - String country = json.getString("country"); - String regionName = json.getString("regionName"); - String city = json.getString("city"); - - StringBuilder address = new StringBuilder(); - if (country != null && !country.isEmpty()) { - address.append(country); - } - if (regionName != null && !regionName.isEmpty() && !regionName.equals(country)) { - address.append(regionName); - } - if (city != null && !city.isEmpty() && !city.equals(regionName)) { - address.append(city); - } - -// log.info("IP 地址查询成功:ip={}, address={}", ip, address); - return address.toString(); - } - } - } catch (Exception e) { - log.error("IP 地址查询失败:ip={}, error={}", ip, e.getMessage()); - } - - // 如果查询失败,返回空字符串 - return ""; - } - -} diff --git a/im-web/src/components/chat/ChatBox.vue b/im-web/src/components/chat/ChatBox.vue index 8fcac66..5b136d0 100644 --- a/im-web/src/components/chat/ChatBox.vue +++ b/im-web/src/components/chat/ChatBox.vue @@ -871,7 +871,6 @@ export default { this.ipLocation = location; } - // 然后再设置 userInfo,这样IP和地址会一起显示 this.userInfo = userInfo; this.updateFriendInfo(); diff --git a/im-web/src/store/chatStore.js b/im-web/src/store/chatStore.js index 410f722..a86f15a 100644 --- a/im-web/src/store/chatStore.js +++ b/im-web/src/store/chatStore.js @@ -503,15 +503,43 @@ export default defineStore('chatStore', { let userStore = useUserStore(); let userId = userStore.userInfo.id; let key = "chats-" + userId; + + // ✅ 获取清理名单 + let cleanedFriendIds = []; + try { + const saved = sessionStorage.getItem("cleanedOfflineFriends"); + if (saved) { + cleanedFriendIds = JSON.parse(saved); + } + } catch (e) { + console.error("读取清理名单失败", e); + } + localForage.getItem(key).then((chatsData) => { if (!chatsData) { resolve(); } else if (chatsData.chatKeys) { const promises = []; - chatsData.chatKeys.forEach(key => { + + // ✅ 过滤掉需要清理的会话key + const validChatKeys = chatsData.chatKeys.filter(chatKey => { + // 检查是否是私聊会话且需要被清理 + if (cleanedFriendIds.length > 0) { + for (let cleanedId of cleanedFriendIds) { + if (chatKey.includes(`-PRIVATE-${cleanedId}`)) { + console.log(`跳过清理的会话: ${chatKey}`); + return false; + } + } + } + return true; + }); + + validChatKeys.forEach(key => { promises.push(localForage.getItem(key)) promises.push(localForage.getItem(key + "-hot")) }) + Promise.all(promises).then(chats => { chatsData.chats = []; // 偶数下标为冷消息,奇数下标为热消息 @@ -530,14 +558,20 @@ export default defineStore('chatStore', { // 冷热消息合并 let chat = Object.assign({}, coldChat, hotChat); if (hotChat && coldChat) { - chat.messages = coldChat.messages.concat(hotChat - .messages) + chat.messages = coldChat.messages.concat(hotChat.messages) } // 历史版本没有readedMessageIdx字段,做兼容一下 chat.readedMessageIdx = chat.readedMessageIdx || 0; chatsData.chats.push(chat); } this.initChats(chatsData); + + const cleanedKeys = chatsData.chatKeys.filter(key => !validChatKeys.includes(key)); + cleanedKeys.forEach(key => { + localForage.removeItem(key); + localForage.removeItem(key + "-hot"); + }); + resolve(); }) } @@ -546,7 +580,7 @@ export default defineStore('chatStore', { reject(e); }) }) - } + }, }, getters: { isLoading: (state) => () => { diff --git a/im-web/src/view/Home.vue b/im-web/src/view/Home.vue index 7d2e02f..aa6e375 100644 --- a/im-web/src/view/Home.vue +++ b/im-web/src/view/Home.vue @@ -111,7 +111,9 @@ export default { lastPlayAudioTime: new Date().getTime() - 1000, reconnecting: false, privateMessagesBuffer: [], - groupMessagesBuffer: [] + groupMessagesBuffer: [], + cleanedFriendIds: [], + switchingAccount: false } }, methods: { @@ -170,6 +172,14 @@ export default { }); this.$wsApi.onClose((e) => { + console.log('WebSocket 关闭,状态码:', e.code); // ✅ 添加日志 + + // 如果是切换账号触发的关闭,不重连 + if (this.switchingAccount) { + console.log('切换账号中,不重连'); + return; + } + if (e.code != 3000) { if (!this.reconnecting) { this.reconnectWs(); @@ -204,6 +214,20 @@ export default { // 切换到其他账号 async onSwitchAccount(targetUser) { this.showAccountMenu = false; + + try { + await this.$confirm( + `确定要切换到账号 ${targetUser.nickName} 吗?\n如果该账号在其他地方已登录,将被强制下线。`, + '切换账号', + { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + } + ); + } catch { + return; + } const loading = this.$loading({ lock: true, @@ -226,23 +250,36 @@ export default { if (loginData.user) { this.setCookie('username', loginData.user.userName); - sessionStorage.setItem("accessToken", loginData.accessToken); - sessionStorage.setItem("refreshToken", loginData.refreshToken); + this.switchingAccount = true; + + sessionStorage.setItem("newAccessToken", loginData.accessToken); + sessionStorage.setItem("newRefreshToken", loginData.refreshToken); localStorage.setItem('userInfo', JSON.stringify(loginData.user)); - this.userStore.setUserInfo(loginData.user); } loading.close(); this.$message.success(`已切换到客服账号:${targetUser.nickName}`); - // 关闭 WebSocket 连接 - this.$wsApi.close(3000); - setTimeout(() => { + const newToken = sessionStorage.getItem("newAccessToken"); + const newRefreshToken = sessionStorage.getItem("newRefreshToken"); + + if (newToken) { + sessionStorage.setItem("accessToken", newToken); + sessionStorage.setItem("refreshToken", newRefreshToken); + } + + sessionStorage.removeItem("newAccessToken"); + sessionStorage.removeItem("newRefreshToken"); + + sessionStorage.setItem("needCleanOffline", "true"); + sessionStorage.removeItem("cleanedOfflineFriends"); + + this.switchingAccount = false; window.location.reload(); - }, 300); - + }, 1500); + } catch (error) { loading.close(); if (error !== 'cancel') { @@ -336,13 +373,46 @@ export default { this.groupMessagesBuffer = []; this.chatStore.setLoading(false); this.chatStore.refreshChats(); + + if (sessionStorage.getItem("needCleanOffline") === "true") { + sessionStorage.removeItem("needCleanOffline"); + this.cleanOfflineChats(); + } + }).catch((e) => { console.log(e); this.$message.error("拉取离线消息失败"); this.onExit(); }); }, - + + cleanOfflineChats() { + const onlineFriendIds = this.friendStore.onlineFriendIds || []; + const removedIds = []; + + this.chatStore.chats.forEach(chat => { + if (chat.type === 'PRIVATE' && !onlineFriendIds.includes(chat.targetId)) { + removedIds.push(chat.targetId); + } + }); + + if (removedIds.length > 0) { + sessionStorage.setItem("cleanedOfflineFriends", JSON.stringify(removedIds)); + this.cleanedFriendIds = removedIds; + + this.chatStore.chats = this.chatStore.chats.filter(chat => { + return !(chat.type === 'PRIVATE' && removedIds.includes(chat.targetId)); + }); + + this.chatStore.chats.forEach(chat => { + chat.stored = false; + }); + this.chatStore.saveToStorage(true); + + console.log(`登录清理完成: 移除 ${removedIds.length} 个离线会话`, removedIds); + } + }, + pullPrivateOfflineMessage(minId) { return this.$http({ url: "/message/private/loadOfflineMessage?minId=" + minId, @@ -400,21 +470,31 @@ export default { } }, - insertPrivateMessage(friend, msg) { - let chatInfo = { + insertPrivateMessage(friend, msg) { + // ✅ 检查清理名单 + if (this.cleanedFriendIds.includes(friend.id)) { + // 仍然更新 maxId,避免重复拉取旧消息 + if (msg.id && msg.id > this.chatStore.privateMsgMaxId) { + this.chatStore.privateMsgMaxId = msg.id; + } + return; + } + + let chatInfo = { type: 'PRIVATE', targetId: friend.id, showName: friend.nickName, headImage: friend.headImage, isDnd: friend.isDnd - }; - this.chatStore.openChat(chatInfo); - this.chatStore.insertMessage(msg, chatInfo); - if (!friend.isDnd && !this.chatStore.loading && !msg.selfSend && - this.$msgType.isNormal(msg.type) && msg.status != this.$enums.MESSAGE_STATUS.READED) { + }; + this.chatStore.openChat(chatInfo); + this.chatStore.insertMessage(msg, chatInfo); + if (!friend.isDnd && !this.chatStore.loading && !msg.selfSend && + this.$msgType.isNormal(msg.type) && msg.status != this.$enums.MESSAGE_STATUS.READED) { this.playAudioTip(); - } - }, + } +}, + handleGroupMessage(msg) { msg.selfSend = msg.sendId == this.userStore.userInfo.id; @@ -483,17 +563,40 @@ export default { }, handleSystemMessage(msg) { - if (msg.type == this.$enums.MESSAGE_TYPE.USER_BANNED) { + console.log('系统消息完整内容:', JSON.stringify(msg)); // ✅ 完整打印 + console.log('系统消息 type:', msg.type); // ✅ 打印 type + + if (msg.type == this.$enums.MESSAGE_TYPE.USER_BANNED) { this.$wsApi.close(3000); this.$alert("您的账号已被管理员封禁,原因:" + msg.content, "账号被封禁", { - confirmButtonText: '确定', - callback: () => { - this.onExit(); - } + confirmButtonText: '确定', + callback: () => { + this.onExit(); + } }); return; - } - }, + } + + // ✅ 处理强制下线消息(type=2) + if (msg.type == 2) { + console.log('收到强制下线通知'); // ✅ 添加日志 + + // 如果正在切换账号,不需要提示 + if (this.switchingAccount) { + console.log('正在切换账号,忽略强制下线通知'); + return; + } + + this.$wsApi.close(3000); + this.$alert("您已在其他地方登录,将被强制下线", "强制下线通知", { + confirmButtonText: '确定', + callback: () => { + this.onExit(); + } + }); + return; + } +}, closeUserInfo() { if (this.$refs.userInfo) { @@ -512,13 +615,16 @@ export default { onExit() { this.showAccountMenu = false; + this.cleanedFriendIds = []; + sessionStorage.removeItem("cleanedOfflineFriends"); this.unloadStore(); this.configStore.setAppInit(false); this.$wsApi.close(3000); sessionStorage.removeItem("accessToken"); localStorage.removeItem('switchable_accounts_cache'); location.href = "/"; - }, + }, + playAudioTip() { if (new Date().getTime() - this.lastPlayAudioTime > 1000) { @@ -583,11 +689,19 @@ export default { immediate: true } }, - mounted() { + mounted() { const savedFullScreen = localStorage.getItem('im_full_screen') === 'true'; this.configStore.setFullScreen(savedFullScreen); + + // ✅ 加载之前登录时保存的清理记录 + const saved = sessionStorage.getItem("cleanedOfflineFriends"); + if (saved) { + this.cleanedFriendIds = JSON.parse(saved); + } + this.init(); - }, +}, + unmounted() { this.$wsApi.close(); } diff --git a/im-web/src/view/Login.vue b/im-web/src/view/Login.vue index 0803d09..38889d4 100644 --- a/im-web/src/view/Login.vue +++ b/im-web/src/view/Login.vue @@ -81,61 +81,32 @@ export default { async submitForm(formName) { const valid = await this.$refs[formName].validate().catch(() => false); if (!valid) return; - + try { const data = await this.$http({ url: "/loginCustom", method: 'post', data: this.loginForm }); - - // 保存cookie + this.setCookie('username', this.loginForm.userName); this.setCookie('password', this.loginForm.password); - - // 保存token + sessionStorage.setItem("accessToken", data.accessToken); sessionStorage.setItem("refreshToken", data.refreshToken); - + + sessionStorage.setItem("needCleanOffline", "true"); + sessionStorage.removeItem("cleanedOfflineFriends"); + this.$message.success("登录成功"); - - // 先跳转,再异步初始化 this.$router.push("/home/chat"); - - // 延迟执行初始化,确保页面已渲染 - this.$nextTick(() => { - this.initAfterLogin(); - }); - + } catch (error) { console.error('登录失败:', error); this.$message.error('登录失败'); } }, - - async initAfterLogin() { - const friendStore = useFriendStore(); - const chatStore = useChatStore(); - - try { - - - await friendStore.loadFriend(); - - await chatStore.loadChat(); - chatStore.refreshChats(); - - chatStore.setLoading(false); - - const removedCount = chatStore.cleanOfflineChats(); - - console.log(`=== 初始化完成: 清理了 ${removedCount} 个离线会话 ===`); - - } catch (error) { - console.error('初始化失败:', error); - } - }, resetForm(formName) { this.$refs[formName].resetFields();