【Java项目-轻聊-完结】14-实现添加好友、实现防止多开、退出登录、未读

0 阅读15分钟

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨ 🎯 你正在阅读「Java项目-轻聊」系列文章 🎯 ✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

🔥 弹简特 个人主页

❄️ 个人专栏直通车:


靠热爱去书写自己,靠勇敢去书写生活! ✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨


🌟 博主简介:
在这里插入图片描述


@TOC


一、前言

我们前面已经能跟已是好友的人聊天了,这个也是我们项目的核心模块。本期我们就扩展出项目中的其他功能:添加好友,以及退出登录、单点在线、已读未读前端实现等等,完成这些功能之后我们的项目也就完成了,具体的源码我会放到文章末尾。 那咱就开始吧,依旧是先理清楚业务逻辑再下手代码

二、好友模块 — 添加好友

搜索用户、发送请求、同意/拒绝

1、约定前后端交互接口

1.1 搜索用户 GET /search/user

说明
参数keyword:用户名关键字(不能为空)
登录必须已登录,未登录返回空数组 []
返回UserVO 数组:userIdusernameavatarPath

排除规则(SQL的编写时候的设计):

  • 不能搜到自己
  • 不能搜到已是好友的人
  • 不能搜到「已有待处理好友请求」的人(我发过或对方发过我)

1.2 发送好友请求 POST /friend/request

参数说明
toUserId要添加的用户 ID
reason申请理由(可空)

成功:{ "ok": true, "message": "好友请求已发送" }
失败:{ "ok": false, "message": "具体原因" }(已是好友、重复申请等)

1.3 拉取待处理请求 GET /friend/request/list

返回发给当前登录用户、状态为待处理的列表:

[
  { "requestId": 1, "fromUserId": 2, "fromName": "李四", "reason": "想认识一下" }
]

1.4 处理请求 POST /friend/handle

参数说明
requestId请求 ID
actionaccept 同意 / reject 拒绝

1.5 WebSocket 推送(对方在线时)

好友相关推送不走聊天 type: message,单独用 type 区分:

收到好友申请:

{
  "type": "FRIEND_REQUEST",
  "requestId": 1,
  "fromUserId": 2,
  "fromName": "李四",
  "reason": "想认识一下"
}

对方同意成为好友:

{
  "type": "FRIEND_ACCEPTED",
  "friendUserId": 2,
  "friendName": "李四"
}

2、 一图理清楚业务逻辑

2.1 大概的业务流程

整体分四步:

在这里插入图片描述

2.2 涉及到的数据表

数据库两张表:

作用
friend_request待处理的申请(谁发给谁、理由、status)
friend (之前实现过)已是好友关系(userId + friendId,同意时双向各一条)

好友请求表: 在这里插入图片描述


2.3 详细业务逻辑

2.3.1 搜索好友

在这里插入图片描述

2.3.2 发起添加好友请求

在这里插入图片描述

2.3.3 前端同意/拒绝好友的请求

在这里插入图片描述

2.3.4 离线拉取发给我的待处理好友请求

在这里插入图片描述

2.4 模拟流程

2.4.1 张三搜索王五

在这里插入图片描述

2.4.1.1 王五不在线

在这里插入图片描述

2.4.1.2 王五在线

在这里插入图片描述


3、前端实现

对于前端的实现,我们仍然采用AI辅助完成,去实现我们需要的效果。

3.1 搜索框与结果区

在这里插入图片描述

那么这个方法以及对应的子功能我们实现如下:

/**
 * 初始化搜索按钮相关点击、回车事件绑定函数
 * 作用:给搜索按钮绑定点击搜索事件,给输入框绑定回车触发搜索事件
 */
function initSearchButton() {
    // 获取页面上搜索按钮DOM元素
    let searchBtn = document.querySelector('#search-btn');
    // 获取页面上搜索输入框DOM元素
    let searchInput = document.querySelector('#search-input');

    // 给搜索按钮绑定点击事件
    searchBtn.onclick = function() {
        // 获取输入框内容并去除首尾空格
        let keyword = searchInput.value.trim();
        // 判断关键词是否为空
        if (!keyword) {
            // 为空则弹出提示,终止后续搜索逻辑
            alert('请输入搜索关键词');
            return;
        }
        // 关键词不为空,调用用户搜索方法执行查询
        searchUser(keyword);
    };

    // 给搜索输入框绑定键盘按下事件
    searchInput.onkeydown = function(e) {
        // 判断按下的按键是否为回车键
        if (e.key === 'Enter') {
            // 模拟点击搜索按钮,复用上面的搜索逻辑
            searchBtn.click();
        }
    };
}
// 按关键词搜索可添加的用户
function searchUser(keyword) {
    // 发起AJAX GET请求,查询匹配关键词的用户
    $.ajax({
        // 请求方式为GET
        method: 'get',
        // 后端搜索用户接口地址
        url: '/search/user',
        // 传给后端的请求参数:搜索关键词
        data: { keyword: keyword },
        // 请求成功回调函数,body为后端返回的数据
        success: function(body) {
            showSearchPanel(); // 切换页面右侧面板为搜索结果展示区域
            renderSearchResults(body); // 根据返回数据渲染用户搜索列表
        },
        // 请求失败回调(网络/接口报错等情况触发)
        error: function() {
            // 弹窗提示用户搜索异常
            alert('搜索失败,请稍后重试');
        }
    });
}

// 显示好友搜索结果区,隐藏聊天区
function showSearchPanel() {
    // 获取聊天面板元素,添加隐藏类,关闭聊天界面
    document.querySelector('.right .chat-panel').classList.add('hide');
    // 获取搜索结果面板,移除隐藏类,展示搜索列表界面
    document.querySelector('.right .search-result-panel').classList.remove('hide');
    // 修改右侧顶部标题文字为好友查询结果
    document.querySelector('.right .title').innerHTML = '好友查询结果';
    // 切换到搜索页面不清除聊天未读消息,新消息会持续累加未读数量
}

// 将搜索到的用户列表渲染到 #search-result-list 容器中
function renderSearchResults(users) {
    // 获取存放搜索结果的列表容器
    let list = document.querySelector('#search-result-list');
    // 先清空上次渲染的所有内容
    list.innerHTML = '';

    // 判断无用户数据 / 数据为空数组
    if (!users || users.length === 0) {
        // 插入空状态提示文本
        list.innerHTML = '<li class="search-empty">未找到匹配的用户</li>';
        // 终止函数,不再执行后续渲染逻辑
        return;
    }

    // 循环遍历后端返回的用户数组,逐个生成列表项
    for (let user of users) {
        // 创建单个用户li容器
        let li = document.createElement('li');
        // 拼接li内部HTML:头像、用户名、添加备注输入框、添加好友按钮
        li.innerHTML =
            avatarImgHtml(user.userId, 'avatar-md', user.avatarPath) +
            '<span class="search-user-name">' + escapeHtml(user.username) + '</span>' +
            '<input type="text" class="search-reason-input" placeholder="添加理由">' +
            '<button type="button" class="search-add-btn">添加</button>';
        // 给当前条目绑定用户ID自定义属性,方便后续取值
        li.setAttribute('data-user-id', user.userId);
        // 将当前用户条目追加到列表容器
        list.appendChild(li);

        // 获取当前条目的添加按钮
        let addBtn = li.querySelector('.search-add-btn');
        // 获取当前条目的添加理由输入框
        let reasonInput = li.querySelector('.search-reason-input');
        // 绑定添加按钮点击事件
        addBtn.onclick = function() {
            // 发起好友申请,传入目标用户ID、添加备注、按钮元素
            sendFriendRequest(user.userId, reasonInput.value, addBtn);
        };
    }
}

上述代码得到所有结果之后显示的内容是:昵称 + 理由输入框 +「添加」按钮 在这里插入图片描述

此时我们点击添加会调用 sendFriendRequest方法发送请求,对应代码如下👇

3.2 发送请求


// POST 发送好友申请
function sendFriendRequest(toUserId, reason, btn) {
    // 发起jQuery的POST请求,提交添加好友申请
    $.ajax({
        // 请求方式 POST
        method: 'post',
        // 后端提交好友申请接口地址
        url: '/friend/request',
        // 请求参数
        data: {
            // 要添加的目标用户ID
            toUserId: toUserId,
            // 添加好友备注,若无输入则传空字符串
            reason: reason || ''
        },
        // 请求成功回调,body为后端返回的业务数据
        success: function(body) {
            // 判断后端返回业务成功标识
            if (body.ok) {
                // 提示申请发送成功
                alert('好友请求已发送');
                // 将当前添加按钮置为不可点击,防止重复提交
                btn.disabled = true;
                // 修改按钮文字状态
                btn.innerText = '已发送';
            } else {
                // 业务失败,优先展示后端返回提示,无则默认文案
                alert(body.message || '发送失败');
            }
        },
        // 网络异常、接口500/404等请求报错回调
        error: function() {
            alert('发送好友请求失败');
        }
    });
}

接下来就完成接收方接收请求的逻辑

3.3 接收请求:离线拉取 + 在线推送

3.3.1 离线拉取

用户登录后 / 刷新会话列表后拉一次待处理列表: 那想一想我们这个操作在哪里操作呢?由于我们请求的添加好友的消息我们会放到会话列表,所以我们会将这个离线拉取的逻辑写在如下获取好友会话信息中: 在这里插入图片描述

function getSessionList() {
    // 发起AJAX GET请求,请求后端会话列表接口
    $.ajax({
        method: 'get',  // 请求方式:GET
        url: '/sessionList',  // 后端接口地址:获取所有会话数据
        // 接口请求成功后的回调函数,body 为后端返回的会话列表数据(数组)
        success: function(body) {
            // 1. 获取页面上承载会话列表的 UL 容器 DOM
            let sessionListUL = document.querySelector('#session-list');

            // 2. 选中容器内所有【好友请求】类型的列表项(class 为 friend-request-item)
            // TODO 处理好友请求1:此处后续实现添加好友出现好友请求的列表

            // TODO 处理好友请求2:定义数组,用来临时存储好友请求的DOM节点(刷新前暂存)


            // TODO 处理好友请求3:遍历所有好友请求条目,深拷贝DOM并存入数组,防止清空后丢失


            // 清空会话列表容器原有内容(清空旧的会话条目)
            sessionListUL.innerHTML = '';

            // TODO 处理好友请求4:把之前暂存的好友请求条目重新添加回列表,实现【好友请求置顶保留】


            // 定义对象,用作去重标记:key=好友ID,value=布尔值,标记该好友是否已渲染会话
            let seenFriendId = {};

            // 遍历后端返回的所有会话数据
            for (let session of body) {
                // 容错判断:当前会话没有关联好友数据,直接跳过该会话
                if (!session.friends || session.friends.length === 0) {
                    continue;
                }

                // 取出当前会话对应的第一个好友ID
                let friendId = session.friends[0].friendId;
                // 去重:该好友已经渲染过会话,跳过当前重复会话
                if (seenFriendId[friendId]) {
                    continue;
                }
                // 标记该好友已渲染,后续同好友会话不再展示
                seenFriendId[friendId] = true;

                // 获取会话最后一条消息,若无则赋值为空字符串
                let lastMessage = session.lastMessage || '';
                // 消息内容长度超过10个字符,做截断处理,末尾拼接省略号
                if (lastMessage.length > 10) {
                    lastMessage = lastMessage.substring(0, 10) + '...';
                }

                // 创建<li>标签,作为单条会话的容器
                let li = document.createElement('li');
                // 自定义属性:存储当前会话ID,方便后续业务取用
                li.setAttribute('message-session-id', session.sessionId);
                // 自定义属性:存储好友ID
                li.setAttribute('data-friend-id', friendId);

                // 取出好友信息对象
                let friend = session.friends[0];
                // 如果好友有头像路径,存入自定义属性(如果好友有头像,那么就将头像路径存储起来)
                if (friend.avatarPath) {
                    li.setAttribute('data-avatar-path', friend.avatarPath);
                }

                // 获取好友昵称
                let friendName = friend.friendName;
                // 调用工具函数 buildSessionLiHtml,拼接会话项HTML结构,赋值给当前li
                li.innerHTML = buildSessionLiHtml(friendId, friendName, lastMessage, friend.avatarPath);

                // 将当前会话项添加到列表容器中
                sessionListUL.appendChild(li);

                // 调用函数:刷新当前会话的未读消息角标
                // 注释说明:未读数存在全局内存变量 unreadCounts 中,刷新DOM后可直接恢复
                // TODO 预留处理后续消息未读

                // 给当前会话项绑定点击事件,点击后执行会话切换逻辑
                li.onclick = function() {
                    clickSession(li);
                };
            }

            // TODO 预留:处理后续所有会话渲染完成后,重新拉取并加载未处理的好友请求(已完成)
            loadPendingFriendRequests(); // 拉取未处理的好友请求
        }
    });
}

在这里插入图片描述

实现loadPendingFriendRequests方法及对应的功能如下:


// 拉取待处理的好友请求并插入会话列表顶部
function loadPendingFriendRequests() {
    // 发起GET请求,获取所有未处理的好友申请列表
    $.ajax({
        method: 'get',
        // 后端待处理好友申请接口地址
        url: '/friend/request/list',
        // 请求成功回调,body为好友申请数组
        success: function(body) {
            // 无好友申请数据,直接结束函数
            if (!body || body.length === 0) {
                return;
            }
            // 遍历每一条好友申请记录
            for (let req of body) {
                // 创建/更新会话列表中的申请条目
                upsertFriendRequestItem(req);
            }
        }
    });
}


// 插入或跳过已存在的好友请求列表项(避免重复渲染)
function upsertFriendRequestItem(req) {
    // 根据申请ID查找页面上已存在的申请条目li
    let li = findFriendRequestLi(req.requestId);
    // 如果条目已存在,直接返回,不再重复新增
    if (li) {
        return li;
    }
    // 创建新的列表li元素
    li = document.createElement('li');
    // 添加专属样式类,用于区分好友申请条目(橙黄提示样式)
    li.className = 'friend-request-item';
    // 自定义属性:绑定当前好友申请唯一ID
    li.setAttribute('friend-request-id', req.requestId);
    // 自定义属性:绑定发起申请人用户ID
    li.setAttribute('data-from-id', req.fromUserId);
    // 自定义属性:绑定发起申请人昵称
    li.setAttribute('data-from-name', req.fromName);
    // 自定义属性:绑定添加好友备注理由
    li.setAttribute('data-reason', req.reason || '');

    // 处理列表展示的简短备注,无理由则显示“无”
    let reasonPreview = req.reason || '无';
    // 备注超过12个字符做截断,避免列表文字过长
    if (reasonPreview.length > 12) {
        reasonPreview = reasonPreview.substring(0, 12) + '...';
    }

    // 拼接li内部HTML:申请人头像 + 昵称 + 简短添加理由
    li.innerHTML = avatarImgHtml(req.fromUserId, 'avatar-md', req.avatarPath)
        + '<div class="friend-request-text"><h3>' + escapeHtml(req.fromName) + '</h3><p>' + escapeHtml(reasonPreview) + '</p></div>';

    // 绑定条目点击事件,点击弹出好友申请详情弹窗
    li.onclick = function() {
        showFriendRequestModal({
            // 读取自定义属性并转为数字,传给弹窗渲染函数
            requestId: parseInt(li.getAttribute('friend-request-id')),
            fromUserId: parseInt(li.getAttribute('data-from-id')),
            fromName: li.getAttribute('data-from-name'),
            reason: li.getAttribute('data-reason')
        });
    };

    // 获取会话列表容器ul
    let sessionListUL = document.querySelector('#session-list');
    // 将好友申请条目插入列表最顶部,优先展示待处理申请
    sessionListUL.insertBefore(li, sessionListUL.firstChild);
    // 返回创建好的li元素
    return li;
}

// 按 requestId 查找好友请求对应的li条目
function findFriendRequestLi(requestId) {
    // 通过属性选择器精准匹配对应申请ID的列表项并返回
    return document.querySelector('#session-list li[friend-request-id="' + requestId + '"]');
}

// 显示好友请求详情模态框,并自动切换到会话标签页
function showFriendRequestModal(req) {
    // 全局缓存当前正在处理的好友申请信息,供同意/拒绝按钮使用
    currentFriendRequest = req;
    // 弹窗填充申请人昵称
    document.querySelector('#friend-request-modal .modal-from').innerHTML =
        '<strong>来自:</strong>' + req.fromName;
    // 弹窗填充完整添加理由,无理由显示“无”
    document.querySelector('#friend-request-modal .modal-reason').innerHTML =
        '<strong>理由:</strong>' + (req.reason || '无');
    // 移除隐藏类,弹出好友申请详情弹窗
    document.querySelector('#friend-request-modal').classList.remove('hide');

    // 获取会话标签按钮
    let tabSession = document.querySelector('.tab .tab-session');
    // 自动点击切换到会话列表Tab,保证弹窗上下文正确
    tabSession.click();
}

3.3.2 在线WebSocket实时推送

WebSocket 收到实时推送 在我们的initWebSocketonmessage 去完成: 在这里插入图片描述

    websocket.onmessage = function(e) {
        // 控制台打印原始推送报文,用于调试排查
        console.log("WebSocket 收到的消息:" + e.data);
        // TODO: 后面重点实现:收到消息后更新界面(已实现)
        // 将后端JSON字符串反序列化为前端消息响应对象
        let resp = JSON.parse(e.data);
        // 判断为普通聊天文本消息,执行消息渲染逻辑
        if (resp.type == 'message') {
            handleMessage(resp);
        } else if (resp.type === 'FRIEND_REQUEST') { // TODO: 好友模块预留,后续实现好友申请等 WebSocket 推送类型(已完成)
            onFriendRequestNotify(resp); // 收到好友申请
        } else if (resp.type === 'FRIEND_ACCEPTED') {
            getFriendList(); // 对方同意,刷新好友列表
            alert(resp.friendName + ' 已成为你的好友');
        }
    };

在这里插入图片描述

里面的onFriendRequestNotify方法方法如下:

// WebSocket 收到好友申请推送消息时触发:更新会话列表并弹出申请弹窗
function onFriendRequestNotify(resp) {
    // 将推送过来的好友申请数据渲染到会话列表(已存在则不重复添加)
    upsertFriendRequestItem(resp);//(上面已经实现)
    // 弹出好友申请详情弹窗,展示这条新申请
    showFriendRequestModal(resp);//(上面已经实现)
}

到此我们做完了的效果如下: 在这里插入图片描述

打开弹窗之后我们会点击同意或者拒绝进行如下的逻辑👇

3.4 弹窗同意 / 拒绝

此处的逻辑我们写在如下方法中 在这里插入图片描述 对应的代码如下:

// 绑定好友申请弹窗的接受、拒绝按钮点击事件
function initFriendRequestModal() {
    // 获取同意好友申请按钮并绑定点击事件
    document.querySelector('#accept-friend-btn').onclick = function() {
        // 判断存在当前待处理的好友申请数据
        if (currentFriendRequest) {
            // 执行同意操作,action传accept
            handleFriendRequest(currentFriendRequest.requestId, 'accept');
        }
    };
    // 获取拒绝好友申请按钮并绑定点击事件
    document.querySelector('#reject-friend-btn').onclick = function() {
        if (currentFriendRequest) {
            // 执行拒绝操作,action传reject
            handleFriendRequest(currentFriendRequest.requestId, 'reject');
        }
    };
}


// POST 请求处理好友申请,支持同意(accept) / 拒绝(reject)两种操作
function handleFriendRequest(requestId, action) {
    $.ajax({
        method: 'post',
        // 后端处理好友申请接口地址
        url: '/friend/handle',
        // 请求参数
        data: {
            // 待处理的好友申请唯一ID
            requestId: requestId,
            // 操作类型:accept同意 / reject拒绝
            action: action
        },
        // 请求成功回调
        success: function(body) {
            // 后端返回业务处理成功
            if (body.ok) {
                // 从会话列表删除这条好友申请条目
                removeFriendRequestItem(requestId);
                // 关闭好友申请弹窗
                hideFriendRequestModal();
                // 如果是同意好友,重新拉取好友列表刷新好友Tab
                if (action === 'accept') {
                    getFriendList();
                }
                // 弹出后端返回的操作提示文字
                alert(body.message);
            } else {
                // 业务处理失败,展示后端提示,无提示则默认文案
                alert(body.message || '处理失败');
            }
        },
        // 网络/接口异常回调
        error: function() {
            alert('处理好友请求失败');
        }
    });
}

// 处理完好友申请后,从会话列表移除对应的申请条目
function removeFriendRequestItem(requestId) {
    // 根据申请ID查找列表li元素
    let li = findFriendRequestLi(requestId);
    // 找到则直接删除DOM节点
    if (li) {
        li.remove();
    }
}

// 隐藏好友申请弹窗,并清空全局缓存的当前申请数据
function hideFriendRequestModal() {
    // 添加隐藏类关闭弹窗
    document.querySelector('#friend-request-modal').classList.add('hide');
    // 置空全局变量,防止残留旧申请数据
    currentFriendRequest = null;
}

OK,到此我们的前端逻辑就结束了,至于样式或者后续可能出现的bug我们遇到时再做调整。


4、后端实现

4.1 搜索用户

在这里插入图片描述

4.1.1 控制层

我们在用户控制层中添加一个方法 在这里插入图片描述 代码:

    @GetMapping("/search/user") // 按关键字搜索可添加的好友(排除自己)
    public Object searchUser(String keyword, HttpServletRequest req) { // 需登录后使用
        User user = getLoginUser(req); // 当前用户
        if (user == null) { // 未登录
            return new ArrayList<>(); // 返回空列表,不暴露用户数据
        }
        return userService.searchUsers(keyword, user.getUserId()); // 模糊匹配用户名并过滤自己
    }
4.1.2 服务层

接口 在这里插入图片描述

    /**
     * 根据关键词搜索用户
     * @param keyword 关键词
     * @param selfUserId 当前用户 ID
     * @return 用户列表
     */
    List<UserVO> searchUsers(String keyword, int selfUserId);

实体类 在这里插入图片描述

@Data // 用户搜索加好友结果项:不含密码,仅展示可添加的陌生人
public class UserVO {
    private Integer userId; // 被搜到的用户 ID
    private String username; // 用户名
    private String avatarPath; // 头像路径
}

实现类

    @Override // 实现用户搜索
    public List<UserVO> searchUsers(String keyword, int selfUserId) {
        if (keyword == null || keyword.trim().isEmpty()) { // 关键字为空或纯空白
            return new ArrayList<>(); // 返回空列表
        }
        return userMapper.searchByKeyword(keyword.trim(), selfUserId); // 按关键字搜索,排除自身
    }
4.1.3 持久层

在这里插入图片描述

 /**
     * 按关键字搜索用户(搜索好友)
     * @param keyword 关键字
     * @param selfUserId 当前用户 id,用于排除自己
     * @return 搜索结果
     */
    List<UserVO> searchByKeyword(@Param("keyword") String keyword, @Param("selfUserId") Integer selfUserId);

映射文件

<!-- 根据关键词搜索可添加的用户,返回UserVO实体 -->
<select id="searchByKeyword" resultType="com.zhongge.web_chatroom.dao.dataobject.UserVO">
    -- 查询用户ID、昵称、头像路径,别名适配VO字段avatarPath
    select userId, username, avatar_path as avatarPath from user
    -- 昵称模糊匹配输入的搜索关键词
    where username like concat('%', #{keyword}, '%')
    -- 排除当前登录用户自己
    and userId != #{selfUserId}
    -- 排除已经互为好友的用户
    and userId not in (
        -- 当前用户的好友ID
        select friendId from friend where userId = #{selfUserId}
        union
        -- 把当前用户加为好友的用户ID
        select userId from friend where friendId = #{selfUserId}
    )
    -- 排除存在未处理好友申请的用户(已发申请/待同意都不再搜索展示)
    and userId not in (
        -- 当前用户已发起、对方未处理的申请接收人
        select toUserId from friend_request
        where fromUserId = #{selfUserId} and status = 0
        union
        -- 发给当前用户、自己还没处理的申请人
        select fromUserId from friend_request
        where toUserId = #{selfUserId} and status = 0
    )
</select>

4.2 收发请求、同意/拒绝

后续的发送请求、接收请求、拉取申请请求、同意、拒绝请求、WebSocket推送等等,我们都在此处统一完成:

创建一个处理好友请求控制类: 在这里插入图片描述

创建一个处理好友请求服务类 在这里插入图片描述 创建一个处理好友请求持久类 在这里插入图片描述 创建对应的xml文件 在这里插入图片描述

4.2.1 前端发送请求

控制层

@RestController
public class FriendRequestController {
    @Resource // 注入好友申请服务
    private FriendRequestService friendRequestService; // 持久化申请记录并通过 WebSocket 通知对方

    @PostMapping("/friend/request") // 向指定用户发送好友申请
    public Object sendFriendRequest(Integer toUserId, String reason, HttpServletRequest req) { // toUserId 目标用户,reason 附言
        User user = getLoginUser(req); // 申请人
        if (user == null) { // 未登录
            return failMap("未登录"); // 统一失败结构
        }
        return friendRequestService.sendFriendRequest(user, toUserId, reason).toMap(); // 校验是否已是好友等,并推送通知
    }

    /**
     * 构建操作失败的统一返回结果Map
     * @param message 失败提示信息
     * @return 包含ok=false和提示文案的响应Map,前端用于弹窗提示错误
     */
    private Map<String, Object> failMap(String message) {
        // 实例化HashMap存放返回数据
        Map<String, Object> resp = new HashMap<>();
        // 业务操作结果标识:false代表失败
        resp.put("ok", false);
        // 存入失败提示信息
        resp.put("message", message);
        // 返回封装好的失败响应对象
        return resp;
    }

    /**
     * 从请求Session中获取当前登录用户对象
     * @param req HTTP请求对象
     * @return 登录用户实体,未登录/无Session时返回null
     */
    private User getLoginUser(HttpServletRequest req) {
        // 参数false:不存在session时不会自动新建空session,直接返回null
        HttpSession session = req.getSession(false);
        // 判断客户端无有效会话,代表未登录
        if (session == null) {
            return null;
        }
        // 从session中取出登录时存入的user对象,未登录时此处会返回null
        return (User) session.getAttribute("user");
    }
}

在这里插入图片描述 在这里插入图片描述


服务层接口

    /**
     * 发送好友请求,包含各类合法性校验并通过WebSocket实时推送通知对方
     * @param fromUser 当前发起添加好友的登录用户
     * @param toUserId 要添加的目标用户ID
     * @param reason 添加好友备注理由
     * @return 统一业务返回结果,包含成功/失败提示
     */
    ServiceResult sendFriendRequest(User fromUser, Integer toUserId, String reason);

实体类 在这里插入图片描述

/**
 * 全局统一业务返回实体
 * 用于好友请求、聊天、用户操作等所有接口统一响应格式
 * 前端固定识别 ok(成功标记)、message(提示文字)、自定义扩展data数据
 */
@Data
public class ServiceResult {
    // 业务操作是否成功:true成功 / false失败
    private boolean ok;
    // 操作提示文案,成功/失败弹窗展示文字
    private String message;
    // 扩展数据容器,用于额外返回业务数据(列表、ID、对象等)
    private Map<String, Object> data = new HashMap<>();

    /**
     * 快速构建成功响应,仅携带提示信息,无扩展data
     * @param message 成功提示文本
     * @return 封装好的成功ServiceResult对象
     */
    public static ServiceResult ok(String message) {
        ServiceResult r = new ServiceResult();
        r.setOk(true);
        r.setMessage(message);
        return r;
    }

    /**
     * 快速构建失败响应,仅携带错误提示
     * @param message 失败/错误提示文本
     * @return 封装好的失败ServiceResult对象
     */
    public static ServiceResult fail(String message) {
        ServiceResult r = new ServiceResult();
        r.setOk(false);
        r.setMessage(message);
        return r;
    }

    /**
     * 链式调用,往扩展data里存入自定义键值对
     * @param key 自定义数据key
     * @param value 自定义数据值
     * @return 当前ServiceResult对象,支持连续put链式写法
     */
    public ServiceResult put(String key, Object value) {
        data.put(key, value);
        return this;
    }

    /**
     * 将实体转为Map结构,便于Controller直接返回给前端序列化JSON
     * 整合ok、message、所有自定义data字段到同一个Map
     * @return 完整响应数据Map
     */
    public Map<String, Object> toMap() {
        Map<String, Object> map = new HashMap<>();
        // 放入操作成功标识
        map.put("ok", ok);
        // 提示文字不为空才放入,避免前端多出null字段
        if (message != null) {
            map.put("message", message);
        }
        // 将所有自定义扩展数据合并进返回map
        map.putAll(data);
        return map;
    }
}

持久层: 创建好友请求表的实体类

在这里插入图片描述

@Data // 好友申请表 friend_request 实体
public class FriendRequest {
    public static final int STATUS_PENDING = 0; // 待处理状态,同意/拒绝后记录会删除

    private Integer requestId; // 请求主键
    private Integer fromUserId; // 发起人 userId
    private Integer toUserId; // 接收人 userId
    private String reason; // 添加好友理由
    private Integer status; // 状态,MVP 仅使用 0=待处理
    private String createTime; // 创建时间(数据库 datetime)
}

持久层代码:


1、FriendMapper
    /**
     * 双向任一方向已是好友
     * @param userId1 发起者用户ID
     * @param userId2 接收者用户ID
     * @return 好友关系数量
     */
    int countFriendEither(@Param("userId1") Integer userId1, @Param("userId2") Integer userId2); 


//对应的sql
    <select id="countFriendEither" resultType="int">
        select count(*) from friend
        where (userId = #{userId1} and friendId = #{userId2})
           or (userId = #{userId2} and friendId = #{userId1})
    </select>


2、FriendRequestMapper

   /**
     * 是否已有待处理请求(防重复)
     * @param fromUserId 发起者id
     * @param toUserId 接收者id
     * @return
     */
    int countPending(@Param("fromUserId") Integer fromUserId, @Param("toUserId") Integer toUserId);
    
    /**
     * 向好友请求表中插入数据
     * @param friendRequest 封装的好友请求数据
     * @return
     */
    int insert(FriendRequest friendRequest);


//对应的sql
    <select id="countPending" resultType="int">
        select count(*) from friend_request
        where status = 0
        and fromUserId = #{fromUserId}
        and toUserId = #{toUserId}
    </select>

    <insert id="insert" useGeneratedKeys="true" keyProperty="requestId">
        insert into friend_request (fromUserId, toUserId, reason, status, createTime)
        values (#{fromUserId}, #{toUserId}, #{reason}, #{status}, now())
    </insert>

服务层实现类

    @Resource
    private FriendRequestMapper friendRequestMapper; // 好友请求数据访问层

    @Resource
    private FriendMapper friendMapper; // 好友关系数据访问层

    @Resource
    private UserMapper userMapper; // 用户数据访问层
    private WebSocketPushService webSocketPushService; // WebSocket 推送服务


    /**
     * 实现:发起添加好友请求,前置多重校验,入库后实时推送WebSocket消息
     */
    @Override
    public ServiceResult sendFriendRequest(User fromUser, Integer toUserId, String reason) {
        // 校验目标用户ID合法性
        if (toUserId == null || toUserId <= 0) {
            return ServiceResult.fail("目标用户无效");
        }
        // 备注为空则统一赋值空字符串,避免后续空指针
        if (reason == null) {
            reason = "";
        }
        // 禁止自己添加自己
        if (fromUser.getUserId() == toUserId.intValue()) {
            return ServiceResult.fail("不能添加自己为好友");
        }
        // 校验目标用户是否真实存在
        if (userMapper.selectById(toUserId) == null) {
            return ServiceResult.fail("目标用户不存在");
        }
        // 校验双方是否已经是好友(双向关系任意存在即判定为好友)
        if (friendMapper.countFriendEither(fromUser.getUserId(), toUserId) > 0) {
            return ServiceResult.fail("对方已是你的好友");
        }
        // 校验:我已经发给对方未处理的申请,禁止重复发送
        if (friendRequestMapper.countPending(fromUser.getUserId(), toUserId) > 0) {
            return ServiceResult.fail("已发送过好友请求,请勿重复提交");
        }
        // 校验:对方已经给我发了未处理申请,提示用户去处理现有申请
        if (friendRequestMapper.countPending(toUserId, fromUser.getUserId()) > 0) {
            return ServiceResult.fail("对方已向你发送好友请求,请在会话列表中处理");
        }

        // 封装好友请求实体类
        FriendRequest friendRequest = new FriendRequest();
        friendRequest.setFromUserId(fromUser.getUserId());  // 申请人ID
        friendRequest.setToUserId(toUserId);                 // 接收人ID
        friendRequest.setReason(reason);                     // 添加备注
        friendRequest.setStatus(FriendRequest.STATUS_PENDING);// 状态:待处理0
        friendRequestMapper.insert(friendRequest);           // 插入数据库保存申请

        // 组装WebSocket推送消息体
        FriendRequestNotifyParam notify = new FriendRequestNotifyParam();
        notify.setRequestId(friendRequest.getRequestId());
        notify.setFromUserId(fromUser.getUserId());
        notify.setFromName(fromUser.getUsername());
        notify.setReason(reason);
        // 向目标用户推送实时好友申请通知,前端会自动刷新列表并弹窗
        webSocketPushService.pushToUser(toUserId, notify);

        // 全部流程校验、入库、推送完成,返回成功结果
        return ServiceResult.ok("好友请求已发送");
    }

WebSocket消息推送实体类:

@Data // 好友请求 WebSocket 通知:对方在线时实时弹窗/列表置顶
public class FriendRequestNotifyParam {
    private String type = "FRIEND_REQUEST"; // 前端 onmessage 分支识别
    private Integer requestId; // 请求 ID,用于同意/拒绝
    private Integer fromUserId; // 发起人 ID,显示头像
    private String fromName; // 发起人用户名
    private String reason; // 申请理由
}


WebSocketPushService推送组件: 在这里插入图片描述

/**
 * 向指定在线用户推送 WebSocket 消息 // 类说明:好友申请通知、新消息推送等
 */
@Component
public class WebSocketPushService {

    // 在线用户管理器,维护 userId 与 WebSocket长连接Session的映射关系
    @Resource
    private OnlineUserManager onlineUserManager;

    // Jackson序列化工具,统一将通知实体/对象转为前端可解析的JSON字符串
    private final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 给指定用户推送WebSocket实时消息
     * @param userId 接收消息的目标用户ID
     * @param payload 需要推送的数据对象(通知DTO、Map等可序列化实体)
     */
    public void pushToUser(Integer userId, Object payload) {
        // 根据用户ID查询该用户当前在线的WebSocket连接会话
        WebSocketSession session = onlineUserManager.getWebSocketSession(userId);
        // 会话不存在 / 会话已关闭:用户当前离线,直接终止推送
        if (session == null || !session.isOpen()) {
            return;
        }
        try {
            // 将通知对象序列化为JSON字符串
            String json = objectMapper.writeValueAsString(payload);
            // 向前端发送文本类型WebSocket消息
            session.sendMessage(new TextMessage(json));
        } catch (JsonProcessingException e) {
            // 对象转JSON失败,打印错误日志,不向上抛出异常避免打断主业务流程
            System.out.println("[WebSocketPushService] 消息序列化失败 userId=" + userId + " err=" + e.getMessage());
        } catch (IOException e) {
            // IO异常:连接异常、通道断开等发送失败场景
            System.out.println("[WebSocketPushService] 推送消息失败 userId=" + userId + " err=" + e.getMessage());
        }
    }
}
4.2.2 前端同意/拒绝好友请求

控制层

    @PostMapping("/friend/handle") // 同意或拒绝某条好友申请
    public Object handleFriendRequest(Integer requestId, String action, HttpServletRequest req) { // action 如 accept / reject
        User user = getLoginUser(req); // 处理人(须为被申请人)
        if (user == null) { // 未登录
            return failMap("未登录"); // 返回失败 Map
        }
        return friendRequestService.handleFriendRequest(user, requestId, action).toMap(); // 更新状态,同意时建立双向好友
    }

服务层

//接口
    /**
     * 处理好友请求:同意或拒绝
     * @param currentUser 当前操作的登录用户(只能处理发给自己的请求)
     * @param requestId 待处理的好友请求主键ID
     * @param action 操作类型:accept同意 / reject拒绝
     * @return 统一业务返回结果
     */
    ServiceResult handleFriendRequest(User currentUser, Integer requestId, String action);

//实现类
    /**
     * 实现:处理好友申请(同意/拒绝)
     * 加事务注解:同意好友时新增双向好友关系,保证数据库操作原子性
     */
    @Override
    @Transactional
    public ServiceResult handleFriendRequest(User currentUser, Integer requestId, String action) {
        // 校验申请ID合法性
        if (requestId == null || requestId <= 0) {
            return ServiceResult.fail("请求ID无效");
        }
        // 根据主键查询这条好友申请记录
        FriendRequest friendRequest = friendRequestMapper.selectById(requestId);
        // 记录不存在 或 状态不是待处理(已同意/拒绝),无法操作
        if (friendRequest == null || friendRequest.getStatus() != FriendRequest.STATUS_PENDING) {
            return ServiceResult.fail("请求不存在或已处理");
        }
        // 权限校验:只能处理发给自己的好友申请
        if (friendRequest.getToUserId() != currentUser.getUserId()) {
            return ServiceResult.fail("无权处理该请求");
        }

        // 分支1:同意好友申请
        if ("accept".equals(action)) {
            int fromId = friendRequest.getFromUserId(); // 申请人ID
            int toId = friendRequest.getToUserId();     // 当前登录用户ID

            // 建立双向好友关系,防止重复插入先计数判断
            if (friendMapper.countFriend(fromId, toId) == 0) {
                friendMapper.insertFriend(fromId, toId);
            }
            if (friendMapper.countFriend(toId, fromId) == 0) {
                friendMapper.insertFriend(toId, fromId);
            }
            // 申请处理完成,删除本条好友申请记录
            friendRequestMapper.deleteById(requestId);

            // 查询申请人完整信息,用于推送通知
            User fromUser = userMapper.selectById(fromId);

            // 推送消息给【当前用户(接收者)】,提示新增好友
            FriendAcceptedNotifyParam toNotify = new FriendAcceptedNotifyParam();
            toNotify.setFriendUserId(fromId);
            toNotify.setFriendName(fromUser != null ? fromUser.getUsername() : "");
            webSocketPushService.pushToUser(toId, toNotify);

            // 推送消息给【发起申请的人】,告知对方同意了好友申请
            FriendAcceptedNotifyParam fromNotify = new FriendAcceptedNotifyParam();
            fromNotify.setFriendUserId(toId);
            fromNotify.setFriendName(currentUser.getUsername());
            webSocketPushService.pushToUser(fromId, fromNotify);

            return ServiceResult.ok("已同意好友请求");
        }

        // 分支2:拒绝好友申请
        if ("reject".equals(action)) {
            // 直接删除申请记录,不建立好友关系
            friendRequestMapper.deleteById(requestId);
            return ServiceResult.ok("已拒绝好友请求");
        }

        // 操作参数不合法,仅支持accept和reject
        return ServiceResult.fail("action 参数无效,应为 accept 或 reject");
    }

服务层层用到的实体类(WebSocket响应实体) 在这里插入图片描述

@Data // 好友请求被同意后的 WebSocket 通知(双方都会收到)
public class FriendAcceptedNotifyParam {
    private String type = "FRIEND_ACCEPTED"; // 前端刷新好友列表并提示
    private Integer friendUserId; // 新好友 userId
    private String friendName; // 新好友用户名
}


持久层

FriendRequestMapper

    /**
     * 根据申请主键ID单条查询好友申请记录
     * 用于处理好友请求前校验记录是否存在、状态是否为待处理
     * @param requestId 好友申请唯一主键ID
     * @return 完整好友请求实体,无数据返回null
     */
    FriendRequest selectById(Integer requestId);
        /**
     * 根据申请ID删除好友申请记录
     * 同意/拒绝好友请求后调用,清理已处理申请
     * @param requestId 待删除申请主键ID
     * @return 受影响数据库行数
     */
    int deleteById(Integer requestId);

sql

    <select id="selectById" resultType="com.zhongge.web_chatroom.dao.dataobject.FriendRequest">
        select requestId, fromUserId, toUserId, reason, status, createTime
        from friend_request
        where requestId = #{requestId}
    </select>

FriendMapper

    /**
     * 判断单向是否已是好友
     * @param userId 发起者
     * @param friendId 接收者
     * @return 返回的记录数 > 0 则是好友关系
     */
    int countFriend(@Param("userId") Integer userId, @Param("friendId") Integer friendId);
    /**
     * 插入单条单向好友记录
     * 同意好友申请时会调用两次,分别插入双向关系:A-B、B-A
     * @param userId 当前用户ID
     * @param friendId 好友用户ID
     * @return 数据库受影响行数
     */
    int insertFriend(@Param("userId") Integer userId, @Param("friendId") Integer friendId);

sql

    <select id="countFriend" resultType="int">
        select count(*) from friend
        where userId = #{userId} and friendId = #{friendId}
    </select>
    <insert id="insertFriend">
        insert into friend values(#{userId}, #{friendId})
    </insert>

4.2.3 查询发给我的待处理好友请求

控制层

    @GetMapping("/friend/request/list") // 查询发给我的待处理好友申请
    public Object getPendingList(HttpServletRequest req) { // 需登录
        User user = getLoginUser(req); // 解析当前用户
        if (user == null) { // 未登录
            return new ArrayList<>(); // 空列表
        }
        return friendRequestService.getPendingList(user.getUserId()); // 返回待我处理的申请及申请人信息
    }

服务层 接口

    /**
     * 查询发给指定用户的所有待处理好友请求列表
     * @param toUserId 当前登录用户ID(接收请求的人)
     * @return 好友请求VO集合,封装申请人信息
     */
    List<FriendRequestVO> getPendingList(int toUserId);

好友请求实体类(和数据库中对应的实体) 在这里插入图片描述

@Data // 待处理好友请求列表 VO:给前端展示「谁想加你为好友」
public class FriendRequestVO {
    private Integer requestId; // 请求 ID,同意/拒绝时回传
    private Integer fromUserId; // 发起人 ID,用于显示头像
    private String fromName; // 发起人用户名
    private String reason; // 申请理由
}

实现类

    /**
     * 实现:查询发给当前用户的待处理好友请求列表
     */
    @Override
    public List<FriendRequestVO> getPendingList(int toUserId) {
        // 用户ID不合法直接返回空集合,避免数据库无效查询
        if (toUserId <= 0) {
            return new ArrayList<>();
        }
        // 根据接收人ID查询状态为待处理的好友申请记录
        return friendRequestMapper.selectPendingByToUserId(toUserId);
    }

持久层

    /**
     * 查询当前用户收到的所有待处理好友申请,封装为VO携带申请人信息
     * 前端会话列表加载好友申请通知时调用
     * @param toUserId 当前登录用户ID(申请接收人)
     * @return 待处理好友申请VO集合
     */
    List<FriendRequestVO> selectPendingByToUserId(Integer toUserId);

sql

    <!--
        根据接收人ID查询所有待处理好友申请列表
        关联用户表获取申请人昵称,按申请创建时间倒序(最新申请排在前面)
        映射返回 FriendRequestVO 对象
    -->
    <select id="selectPendingByToUserId" resultType="com.zhongge.web_chatroom.dao.dataobject.FriendRequestVO">
        -- 查询申请ID、申请人ID、申请人昵称、添加备注
        select fr.requestId, fr.fromUserId, u.username as fromName, fr.reason
               -- 好友申请表 别名fr,用户表别名u
        from friend_request fr, user u
             -- 关联条件:好友申请发起者ID = 用户主键ID
        where fr.fromUserId = u.userId
          -- 筛选:当前登录用户是这条申请的接收人
          and fr.toUserId = #{toUserId}
          -- 状态0代表待处理,只查询未处理的申请
          and fr.status = 0
          -- 根据申请创建时间倒序,最新收到的好友申请展示在最上方
        order by fr.createTime desc
    </select>

5. 测试

启动服务器,登录我们的项目:张三加王五好友 情况1:王五在线 在这里插入图片描述 在这里插入图片描述 情况2:王五不在线 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 问题1:同意之后 发起者未能实时刷新好友列表 在这里插入图片描述 问题2:拒绝好友的时候接收方没提示


导致上述两个问题的原因: 问题1原因: 对于后端:

OnlineUserManager.java里面 同一用户已有 WebSocket 连接时,新连接会被直接拒绝登记。页面刷新、断线重连后,浏览器认为已连接,但服务端仍把消息推给已失效的旧 session,发起者收不到 FRIEND_ACCEPTED。 这个就是我们后续处理的单点在线,如果同一个用户多次登录就会出现上述的问题1。(我们后续在单点在线中会解决)

对于前端:

发起者仅依赖 WebSocket 刷新好友列表;接收方在同意时还有 HTTP 成功回调里的 getFriendList() 作为兜底,发起者没有。

问题2的原因: 我们后端和前端没有处理对应拒绝时候的处理情况 解决办法如下: 新建一个拒绝响应实体类:

@Data
public class FriendRejectedNotifyParam {
    private String type = "FRIEND_REJECTED";
    private Integer fromUserId;   // 拒绝操作者(原接收方)userId
    private String fromName;      // 拒绝操作者用户名
}

在这里插入图片描述

修改 FriendRequestServiceImpl.java替换拒绝分支

        if ("reject".equals(action)) {
            int fromId = friendRequest.getFromUserId();
            friendRequestMapper.deleteById(requestId);
            // 通知发起者:对方已拒绝好友请求
            FriendRejectedNotifyParam rejectNotify = new FriendRejectedNotifyParam();
            rejectNotify.setFromUserId(currentUser.getUserId());
            rejectNotify.setFromName(currentUser.getUsername());
            webSocketPushService.pushToUser(fromId, rejectNotify);
            return ServiceResult.ok("已拒绝好友请求");
        }

在这里插入图片描述 在这里插入图片描述 前端在 initWebSocket 的 onmessage 中增加分支 在这里插入图片描述

        } else if (resp.type === 'FRIEND_ACCEPTED') {
            onFriendAcceptedNotify(resp);
        } else if (resp.type === 'FRIEND_REJECTED') {
            onFriendRejectedNotify(resp);
        }

对应方法

// 收到好友同意通知:刷新好友列表并提示
function onFriendAcceptedNotify(resp) {
    getFriendList();
    alert((resp.friendName || '对方') + ' 已成为你的好友');
}
// 收到好友拒绝通知:提示发起者
function onFriendRejectedNotify(resp) {
    alert((resp.fromName || '对方') + ' 拒绝了你的好友请求');
}

此时重新测试,我们问题2就修复成功 在这里插入图片描述 接下来我们就解决问题1👇


三、单点在线+退出登录

上述问题1出现原因之一就是同一个用户重复登录,同时为了避免这样的一个问题,我们就必须处理同一用户不能重复登录,防止顶号。 接下来我们就一一实现:

1、约定前后端交互接口

登录 POST /login(改造)

情况返回前端处理
成功{ userId, username, ... }userId > 0client.html
账密错{ userId: 0 } 或无有效 userId提示用户名或密码错误
重复登录{ userId: 0, message: "该账号已在其他地方登录..." }alert(body.message)

退出 POST /logout(新增)

说明
返回{ "ok": true }
服务端关 WebSocket → session.invalidate() → 清登记 Map

WebSocket 层(消息模块已有)

连接建立时 OnlineUserManager.online();同一 userId 第二条连接会被 关闭,不踢先登录的。


2、理清楚业务逻辑

单点在线要管 两张表

在这里插入图片描述

完整流程: 单点在线:策略:拒绝后登录,不踢先登录的(和 PRD 一致)。 在这里插入图片描述

退出登录: 在这里插入图片描述


3、前端实现

① login.html — 识别重复登录

在登录成功回调里加 message 分支: 在这里插入图片描述

success: function(body) {
    if (body && body.userId > 0) {
        location.assign('/client.html');
    } else if (body && body.message) {
        alert(body.message);
    } else {
        alert('登录失败,请检查用户名或密码');
    }
}

② client.js — 退出按钮

/**
 * 退出登录:关闭 WebSocket,请求后端销毁 Session,回到登录页。
 */
function initLogoutButton() {
    document.querySelector('#logout-btn').onclick = function() {
        if (websocket) {
            websocket.close(); // 断开实时连接
            websocket = null; // 释放引用
        }
        $.ajax({
            method: 'post',
            url: '/logout', // 销毁服务端 Session
            complete: function() {
                location.assign('/login.html'); // 无论成功失败都回登录页
            }
        });
    };
}

$(document).ready 里调用:

initLogoutButton();

在这里插入图片描述


4、后端实现

4.1 新建 service/dto/LoginResult.java

在这里插入图片描述

登录结果封装,Controller 按 status 分支:

/**
 * 登录接口统一返回实体
 */
@Data
public class LoginResult {

    /**
     * 登录状态枚举
     */
    public enum Status {
        SUCCESS,         // 登录成功
        INVALID_CREDENTIALS, // 账号密码错误
        DUPLICATE_LOGIN  // 重复登录
    }

    private Status status; // 登录状态
    private User user;     // 登录用户信息
    private String message;// 提示文案

    /**
     * 账号密码错误
     */
    public static LoginResult invalidCredentials() {
        LoginResult r = new LoginResult();
        r.setStatus(Status.INVALID_CREDENTIALS);
        return r;
    }

    /**
     * 重复登录
     * @param message 提示文字
     */
    public static LoginResult duplicateLogin(String message) {
        LoginResult r = new LoginResult();
        r.setStatus(Status.DUPLICATE_LOGIN);
        r.setMessage(message);
        return r;
    }

    /**
     * 登录成功
     * @param user 登录用户
     */
    public static LoginResult success(User user) {
        LoginResult r = new LoginResult();
        r.setStatus(Status.SUCCESS);
        r.setUser(user);
        return r;
    }
}

4.2 新建 component/UserSessionRegistry.java

在这里插入图片描述

HTTP 登录登记表:

/**
 * 用户在线Session注册表
 * 记录用户ID与HttpSession绑定关系,判断账号是否重复登录
 */
@Component
public class UserSessionRegistry {

    // key:用户ID  value:对应用户的会话对象,并发安全容器
    private final ConcurrentHashMap<Integer, HttpSession> sessions = new ConcurrentHashMap<>();

    /**
     * 判断用户是否已登录(存在有效会话)
     * @param userId 用户ID
     * @return true已登录 / false未登录/会话失效
     */
    public boolean isLoggedIn(Integer userId) {
        // 根据用户ID取出绑定的会话
        HttpSession session = sessions.get(userId);
        // 无会话直接判定未登录
        if (session == null) {
            return false;
        }
        try {
            // 会话内存在user属性代表登录有效
            return session.getAttribute("user") != null;
        } catch (IllegalStateException e) {
            // 会话已销毁,清除无效记录
            sessions.remove(userId);
            return false;
        }
    }

    /**
     * 绑定用户ID和当前登录会话
     * @param userId 用户ID
     * @param session 用户登录会话
     */
    public void register(Integer userId, HttpSession session) {
        sessions.put(userId, session);
    }

    /**
     * 解除会话绑定,用户退出/会话销毁时调用
     * @param session 待解绑的会话对象
     */
    public void unbind(HttpSession session) {
        // 遍历所有存储项,移除value匹配当前session的记录
        Set<Map.Entry<Integer, HttpSession>> entrySet = sessions.entrySet();
        for (Map.Entry<Integer, HttpSession> entry : entrySet) {
            if (entry.getValue() == session) {
                sessions.remove(entry.getKey());
                break;
            }
        }
    }
}

4.3 新建 config/UserHttpSessionListener.java

关浏览器 / Session 超时后清 Map,否则账号永远「占着登录位」: 在这里插入图片描述

import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.servlet.http.HttpSessionEvent;
import javax.servlet.http.HttpSessionListener;

/**
 * Session销毁监听器
 * 会话过期/手动登出时自动解绑用户在线记录
 */
@Component
public class UserHttpSessionListener implements HttpSessionListener {

    // 用户在线会话管理工具
    @Resource
    private UserSessionRegistry userSessionRegistry;

    /**
     * 会话销毁回调
     * 会话超时、主动退出都会触发,清除该会话绑定的用户登录记录
     */
    @Override
    public void sessionDestroyed(HttpSessionEvent se) {
        // 解绑失效session
        userSessionRegistry.unbind(se.getSession());
    }

    /**
     * 会话创建,当前业务无需处理,空实现保留
     */
    @Override
    public void sessionCreated(HttpSessionEvent se) {
        // 无需操作
    }
}

4.4 完善 OnlineUserManager

在这里插入图片描述

我们之前预留的TODO:换成下面逻辑 关键三点:putIfAbsent 登记、isOnline 给登录校验、logout 给退出用:


    /** 拒绝重复连接时,关闭「新连接」使用的原因说明 */ 
    private static final CloseStatus DUPLICATE_LOGIN = // 策略违规类关闭码
            CloseStatus.POLICY_VIOLATION.withReason("该账号已在其他浏览器在线"); // 附带给客户端的中文原因
    
    

/**
 * 判断用户是否WebSocket在线
 * @param userId 用户ID
 * @return 在线返回true,离线false
 */
public boolean isOnline(Integer userId) {
    // 根据用户ID取出对应WebSocket连接
    WebSocketSession session = sessions.get(userId);
    // 连接存在且通道开启代表在线
    return session != null && session.isOpen();
}

/**
 * 用户建立WebSocket连接,上线注册
 * @param userId 用户ID
 * @param session 本次新建的WebSocket会话
 * @return 上线成功true;账号重复登录本次连接被关闭返回false
 */
public boolean online(Integer userId, WebSocketSession session) {
    // 原子存入,已有记录则返回旧会话
    WebSocketSession existing = sessions.putIfAbsent(userId, session);
    if (existing != null) {
        // 账号已在别处登录,关闭当前新连接
        closeQuietly(session, DUPLICATE_LOGIN);
        return false;
    }
    return true;
}

/**
 * 用户退出登录,清除在线记录并关闭长连接
 * @param userId 退出的用户ID
 */
public void logout(Integer userId) {
    // 移除缓存中的会话
    WebSocketSession session = sessions.remove(userId);
    // 正常关闭连接,附带退出原因
    closeQuietly(session, CloseStatus.NORMAL.withReason("用户退出登录"));
}

    /** 安全关闭 WebSocket,避免异常向外抛出 */ // 私有工具方法说明
    private void closeQuietly(WebSocketSession session, CloseStatus status) { // 内部统一关连接
        if (session == null || !session.isOpen()) { // 无需关闭
            return; // 直接返回
        }
        try { // 尝试关闭
            session.close(status); // 带状态码关闭,客户端可区分原因
        } catch (IOException e) { // 连接已断等 IO 问题
            System.out.println("[OnlineUserManager] 关闭 WebSocket 失败: " + e.getMessage()); // 吞掉异常仅打日志
        }
    }

4.5 服务层 — 改 UserService / UserServiceImpl

接口改签名(Session 写入挪到 Controller): 在这里插入图片描述

LoginResult login(String username, String password);
void logoutOnline(Integer userId);

实现类注入 + login:

@Autowired
private UserSessionRegistry userSessionRegistry;
@Autowired
private OnlineUserManager onlineUserManager;

/**
 * 账号密码登录校验
 * @param username 用户名
 * @param password 密码
 * @return 登录结果对象
 */
@Override
public LoginResult login(String username, String password) {
    // 根据用户名查询用户
    User user = userMapper.selectByName(username);
    // 用户不存在 或 密码不匹配,返回账号密码错误
    if (user == null || !user.getPassword().equals(password)) {
        return LoginResult.invalidCredentials();
    }
    // Http会话已登录 或 WebSocket长连接在线,判定重复登录
    if (userSessionRegistry.isLoggedIn(user.getUserId())
            || onlineUserManager.isOnline(user.getUserId())) {
        return LoginResult.duplicateLogin("该账号已在其他地方登录,请勿重复登录");
    }
    // 清空密码,避免前端返回敏感字段
    user.setPassword("");
    // 返回登录成功与用户信息
    return LoginResult.success(user);
}

/**
 * 下线用户WebSocket连接
 * @param userId 待下线用户ID
 */
@Override
public void logoutOnline(Integer userId) {
    // 用户ID非空才执行下线操作
    if (userId != null) {
        onlineUserManager.logout(userId);
    }
}

4.6 控制层 — 改 UserController

login(成功才写 Session + register):

// 用户会话注册管理
@Resource
private UserSessionRegistry userSessionRegistry;

/**
 * 用户登录接口
 * @param username 用户名
 * @param password 密码
 * @param req 请求对象,用于获取session
 * @return 登录结果数据
 */
@PostMapping("/login")
public Object login(String username, String password, HttpServletRequest req) {
    // 调用业务层校验账号密码
    LoginResult result = userService.login(username, password);
    // 账号密码错误,返回空用户对象
    if (result.getStatus() == LoginResult.Status.INVALID_CREDENTIALS) {
        return new User();
    }
    // 重复登录,返回提示map
    if (result.getStatus() == LoginResult.Status.DUPLICATE_LOGIN) {
        Map<String, Object> resp = new HashMap<>();
        resp.put("userId", 0);
        resp.put("message", result.getMessage());
        return resp;
    }
    // 登录成功拿到用户信息
    User user = result.getUser();
    // 创建/获取session
    HttpSession session = req.getSession(true);
    // session存入登录用户
    session.setAttribute("user", user);
    // 注册用户与session绑定关系,用于重复登录校验
    userSessionRegistry.register(user.getUserId(), session);
    // 返回登录用户信息给前端
    return user;
}

logout(新增): 在这里插入图片描述

/**
 * 退出登录接口
 * @param req 请求对象,获取当前用户Session
 * @return 操作结果Map
 */
@PostMapping("/logout")
public Object logout(HttpServletRequest req) {
    // 封装返回结果
    Map<String, Object> resp = new HashMap<>();
    // 获取已有Session,不存在则不新建
    HttpSession session = req.getSession(false);
    if (session != null) {
        // 取出登录用户
        User user = (User) session.getAttribute("user");
        if (user != null) {
            // 关闭该用户WebSocket在线连接
            userService.logoutOnline(user.getUserId());
        }
        // 销毁当前会话
        session.invalidate();
    }
    // 标记退出成功
    resp.put("ok", true);
    return resp;
}

5、测试

在这里插入图片描述

五、未读消息角标

此处是纯前端,我们借助AI实现润色即可: 下面我们只会给出小小的思路:

1、约定

未读不走 HTTP、不走 WebSocket 协议,没有后端接口。
约定全部在 前端内存 里完成:

约定项说明
存储unreadCounts 对象,key 是 sessionId 字符串,value 是条数
何时 +1收到别人消息,且当前没正在看该会话
何时清零用户点击进入该会话
刷新页面角标会丢(PRD 允许,不要求服务端存未读)

2、业务逻辑

WebSocket 推来一条消息(type=message)
        │
        ▼
  是不是别人发的? ──否──► 不处理未读(自己发的本地已显示)
        │是
        ▼
  isCurrentChatSession? ──是──► 右侧追加气泡,未读不变
        │否
        ▼
  incrementUnread(sessionId)  →  unreadCounts +1  →  刷新红色角标

用户点击某个会话 li
        │
        ▼
  clearUnread(sessionId)  →  delete unreadCounts[key]  →  角标隐藏

和微信一样的直觉:看着聊就不角标,没看着就 +1,点进去就清零


3、前端实现

4、前端实现(按你代码结构逐步补 TODO)

步骤 1:全局计数表

let currentFriendRequest = null; 下面加一行: 在这里插入图片描述

/**
 * 【未读消息 — 纯前端实现】
 * unreadCounts:键为 sessionId(字符串),值为未读条数。
 * 1. 收到别人消息且当前没在该会话聊天页 → +1
 * 2. 正在看该会话 → 不累计,右侧直接出气泡
 * 3. 点击会话 → 清零
 * 4. 好友 Tab / 搜索页时聊天区 hide → 仍计未读
 */
let unreadCounts = {}; // 未读计数表:key 为 sessionId 字符串,value 为条数

步骤 2:四个工具函数(整块新增)

放在 buildSessionLiHtml 后面clickSession 前面(和

// 读取某会话的未读条数
function getUnreadCount(sessionId) {
    if (sessionId == null || sessionId === '') return 0;
    return unreadCounts[String(sessionId)] || 0;
}

/**
 * 根据 unreadCounts 刷新某一会话项上的红色数字角标
 * @param li 会话列表中的 li(尽量传引用,DOM 刚插入时 query 可能找不到)
 */
function refreshUnreadBadge(li) {
    if (!li || li.classList.contains('friend-request-item')) {
        return; // 好友请求项不显示未读角标
    }
    let badge = li.querySelector('.unread-badge');
    if (!badge) {
        return; // buildSessionLiHtml 没加角标节点时会走到这里
    }
    let sid = li.getAttribute('message-session-id');
    let count = getUnreadCount(sid);
    if (count > 0) {
        badge.textContent = count > 99 ? '99+' : String(count);
        badge.classList.remove('hide');
        li.classList.add('has-unread');
    } else {
        badge.textContent = '';
        badge.classList.add('hide');
        li.classList.remove('has-unread');
    }
}

/** 未读 +1(仅前端计数,不请求后端) */
function incrementUnread(sessionId, liOpt) {
    if (sessionId == null || sessionId === '') {
        return;
    }
    let key = String(sessionId);
    unreadCounts[key] = (unreadCounts[key] || 0) + 1;
    let li = liOpt || findSessionLi(sessionId); // handleMessage 里传 curSessionLi 更稳
    refreshUnreadBadge(li);
}

/** 进入会话后清零未读 */
function clearUnread(sessionId, liOpt) {
    if (sessionId == null || sessionId === '') {
        return;
    }
    delete unreadCounts[String(sessionId)];
    let li = liOpt || findSessionLi(sessionId);
    refreshUnreadBadge(li);
}

步骤 3:补 buildSessionLiHtml 里的 TODO

在这里插入图片描述

以前的结尾是:

        // TODO:此处消息未读,后续实现未读消息角标,默认添加hide类隐藏
        + '</div>';

现在改成(在 </div> 前插入角标 span,client.css.unread-badge 样式已有):

        + '<span class="unread-badge hide" aria-label="未读消息数"></span>' // 角标默认隐藏
        + '</div>';

完整函数:

function buildSessionLiHtml(friendId, friendName, preview, avatarPath) {
    return '<div class="session-item-inner">'
        + avatarImgHtml(friendId, 'avatar-md', avatarPath)
        + '<div class="session-main">'
        + '<div class="session-row-top">'
        + '<span class="session-name">' + escapeHtml(friendName) + '</span>'
        + '</div>'
        + '<p class="session-preview">' + escapeHtml(preview || '') + '</p>'
        + '</div>'
        + '<span class="unread-badge hide" aria-label="未读消息数"></span>'
        + '</div>';
}

步骤 4:补 getSessionList 里的 TODO

以前的代码: 在这里插入图片描述

完整代码:

// GET /sessionList 渲染会话列表,保留已插入的好友请求项
function getSessionList() {
    // 发起AJAX GET请求,请求后端会话列表接口
    $.ajax({
        method: 'get',  // 请求方式:GET
        url: '/sessionList',  // 后端接口地址:获取所有会话数据
        // 接口请求成功后的回调函数,body 为后端返回的会话列表数据(数组)
        success: function(body) {
            // 1. 获取页面上承载会话列表的 UL 容器 DOM
            let sessionListUL = document.querySelector('#session-list');

            // 2. 选中容器内所有【好友请求】类型的列表项(class 为 friend-request-item)
            // TODO 处理好友请求1:此处后续实现添加好友出现好友请求的列表(已实现)
            let requestItems = sessionListUL.querySelectorAll('.friend-request-item');

            // TODO 处理好友请求2:定义数组,用来临时存储好友请求的DOM节点(刷新前暂存)(已实现)
            let savedRequests = []; // 刷新前暂存好友请求 DOM


            // TODO 处理好友请求3:遍历所有好友请求条目,深拷贝DOM并存入数组,防止清空后丢失(已实现)
            requestItems.forEach(function(li) {
                savedRequests.push(li.cloneNode(true));
            });

            // 清空会话列表容器原有内容(清空旧的会话条目)
            sessionListUL.innerHTML = '';

            // TODO 处理好友请求4:把之前暂存的好友请求条目重新添加回列表,实现【好友请求置顶保留】(已实现)
            savedRequests.forEach(function(li) {
                sessionListUL.appendChild(li); // 请求项仍置顶
            });


            // 定义对象,用作去重标记:key=好友ID,value=布尔值,标记该好友是否已渲染会话
            let seenFriendId = {};

            // 遍历后端返回的所有会话数据
            for (let session of body) {
                // 容错判断:当前会话没有关联好友数据,直接跳过该会话
                if (!session.friends || session.friends.length === 0) {
                    continue;
                }

                // 取出当前会话对应的第一个好友ID
                let friendId = session.friends[0].friendId;
                // 去重:该好友已经渲染过会话,跳过当前重复会话
                if (seenFriendId[friendId]) {
                    continue;
                }
                // 标记该好友已渲染,后续同好友会话不再展示
                seenFriendId[friendId] = true;

                // 获取会话最后一条消息,若无则赋值为空字符串
                let lastMessage = session.lastMessage || '';
                // 消息内容长度超过10个字符,做截断处理,末尾拼接省略号
                if (lastMessage.length > 10) {
                    lastMessage = lastMessage.substring(0, 10) + '...';
                }

                // 创建<li>标签,作为单条会话的容器
                let li = document.createElement('li');
                // 自定义属性:存储当前会话ID,方便后续业务取用
                li.setAttribute('message-session-id', session.sessionId);
                // 自定义属性:存储好友ID
                li.setAttribute('data-friend-id', friendId);

                // 取出好友信息对象
                let friend = session.friends[0];
                // 如果好友有头像路径,存入自定义属性(如果好友有头像,那么就将头像路径存储起来)
                if (friend.avatarPath) {
                    li.setAttribute('data-avatar-path', friend.avatarPath);
                }

                // 获取好友昵称
                let friendName = friend.friendName;
                // 调用工具函数 buildSessionLiHtml,拼接会话项HTML结构,赋值给当前li
                li.innerHTML = buildSessionLiHtml(friendId, friendName, lastMessage, friend.avatarPath);

                // 将当前会话项添加到列表容器中
                sessionListUL.appendChild(li);

                // 调用函数:刷新当前会话的未读消息角标
                // 注释说明:未读数存在全局内存变量 unreadCounts 中,刷新DOM后可直接恢复
                // TODO 预留处理后续消息未读(已完成)
                refreshUnreadBadge(li);
                
                // 给当前会话项绑定点击事件,点击后执行会话切换逻辑
                li.onclick = function() {
                    clickSession(li);
                };
            }

            // TODO 预留:处理后续所有会话渲染完成后,重新拉取并加载未处理的好友请求(已完成)
            loadPendingFriendRequests(); // 拉取未处理的好友请求
        }
    });
}

步骤 5:补 clickSession 里的 TODO

在这里插入图片描述

完整代码:

function clickSession(currentLi) {
    // TODO:后续先处理你点击的会话是否是好友请求(已实现)
    // 这里需要判断当前点击的会话是否是好友请求类型
    // 如果是好友请求,需要特殊处理显示不同的界面和逻辑
    if (currentLi.classList.contains('friend-request-item')) {
        return;
    }

    // TODO:后续展示右侧聊天面板(已实现)
    // 需要根据点击的会话展示对应的聊天面板
    // 可能包括聊天窗口、消息输入区域等功能组件
    showChatPanel();


    //筛选出所有普通会话项(排除好友请求条目)
    let allLis = document.querySelectorAll('#session-list>li:not(.friend-request-item)');
    // 统一处理会话选中样式:取消其他项选中、给当前项添加选中样式
    activeSession(allLis, currentLi);
    // 调用activeSession函数处理会话的选中状态
    // 参数:所有普通会话项列表和当前点击的会话项
    // 实现功能:取消其他会话的选中状态,同时给当前点击的会话添加选中样式


    // TODO:获取当前会话唯一ID,后续用去请求会话中的历史消息(已实现)
    let sessionId = currentLi.getAttribute("message-session-id");
    // 需要从currentLi元素中提取会话的唯一标识符
    // 这个ID将用于后续获取该会话的历史消息记录


    // 用户主动点击会话-->未读清零-->角标消失
    // TODO:后续实现点击出现角标的样式(已实现)
    // 需要实现点击会话时显示未读消息角标的功能
    // 可能包括角标的显示/隐藏逻辑、位置计算等
    clearUnread(sessionId, currentLi);

    // TODO:后续实现根据会话ID请求并加载历史聊天消息(已实现)
    // 需要实现通过会话ID向后端API发送请求
    // 获取并加载该会话的历史聊天消息到聊天面板中
    // 可能包括消息的分页加载、滚动定位等功能
    getHistoryMessage(sessionId);
}

步骤 6:补 handleMessage 里的 TODO(最关键)

在这里插入图片描述

    let selfUserId = document.querySelector('.left .user').getAttribute('user-id');
    let fromOthers = String(resp.fromId) !== String(selfUserId);
    let viewingThisChat = isCurrentChatSession(resp.sessionId);

    // 他人发来消息,但当前没打开对应会话:预留未读消息角标逻辑
    if (fromOthers && !viewingThisChat) {
        // TODO: 未读消息模块预留,后续实现角标累计与清零
    }

    if (viewingThisChat && fromOthers) {
        let messageShowDiv = document.querySelector('.message-show');
        addMessage(messageShowDiv, resp);
        scrollBottom(messageShowDiv);
    }

只改 if 块内部,变成:

    if (fromOthers && !viewingThisChat) {
        // 例:李四在好友 Tab,张三连发两条 → 张三会话角标 +2
        incrementUnread(resp.sessionId, curSessionLi);
    }
    

5、后端实现

无。 未读角标本期纯前端,不建表、不写接口。

6、测试

在这里插入图片描述

六、项目总结+源码

最后,各位大佬们,咱们的 轻聊 项目到此就结束了。

简单回顾一下咱们 实现的功能用到的技术栈

1、功能

功能模块说明
用户认证注册、登录、退出;HttpSession 维持登录态;禁止同一账号重复登录(HTTP + WebSocket 双重校验)
用户信息获取当前登录用户、刷新头像路径
头像管理上传 jpg/jpeg/png(≤2MB),磁盘存储;访问 /avatars/**/user/getAvatar
好友列表展示已添加好友(含头像)
搜索加好友用户名模糊搜索;排除已是好友、已有待处理请求的用户
好友请求发送/接收/同意/拒绝;在线 WebSocket 推送;离线登录后拉取待处理列表
会话管理创建两人单聊会话;列表展示最近消息预览;同好友去重
实时聊天WebSocket 发消息;服务端转发并落库;canonical sessionId 合并
历史消息按会话查询最近 100 条,时间正序展示
未读角标非当前会话收到消息时左侧数字 +1;进入会话清零(纯前端 client.js
界面交互Tab 切换(会话/好友)、搜索面板、好友请求弹窗、气泡左右布局

页面:

页面路径
登录/login.html
注册/register.html
主界面/client.html
WebSocket 测试页/test.html(回显测试,路径 /chat

2、技术栈

层次技术版本/说明
语言Java1.8
后端框架Spring Boot2.7.6
持久层MyBatis Spring Boot Starter2.3.0
数据库MySQL8.x,库名 web_chatroom
实时通信Spring WebSocket文本消息 JSON
JSONJacksonSpring 内置
工具Lombok简化实体
前端HTML5 + jQuery2.0.3
样式CSS3common.css / auth.css / client.css
构建Maven

3、项目源码地址 📦

源码我放在 码云 Gitee 了,跟专栏代码是对得上的,Clone 下来就能跑 🚀

内容
📁 工程名web_chatroom2
🔗 仓库地址点我看源码
🗄️ 建库脚本src/main/java/db.sql
👤 测试账号张三 / 123456、李四 / 123456

写到这儿,心里挺踏实的。

轻聊 V1.0 该做的主流程咱们都撸完了:能注册登录、能加好友、能单聊、消息能实时推、离线能拉历史、左侧还有未读小红点。不大,但是 从头到尾自己搭出来的,课设也好、练 Spring Boot + WebSocket 也好,够用了 💪

当然离「真·微信」还差得远呢 😂 后面要是还有时间,我想慢慢加:群聊、聊天里 发文件发图片、消息撤回啥的……路还长,不着急,有一篇写一篇。

老铁们如果要是这套东西对你有帮助,可以支持一下作者 ,给个小小的关注和点赞,评论区逛一逛,感谢各位老铁

轻聊项目咱那就先这样啦,咱 下一系列见 ~~~~ 👋👋👋