✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨ 🎯 你正在阅读「Java项目-轻聊」系列文章 🎯 ✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
🔥 弹简特 个人主页
❄️ 个人专栏直通车:
✨ 靠热爱去书写自己,靠勇敢去书写生活! ✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
🌟 博主简介:
@TOC
一、前言
我们前面已经能跟已是好友的人聊天了,这个也是我们项目的核心模块。本期我们就扩展出项目中的其他功能:添加好友,以及退出登录、单点在线、已读未读前端实现等等,完成这些功能之后我们的项目也就完成了,具体的源码我会放到文章末尾。 那咱就开始吧,依旧是先理清楚业务逻辑再下手代码
二、好友模块 — 添加好友
搜索用户、发送请求、同意/拒绝
1、约定前后端交互接口
1.1 搜索用户 GET /search/user
| 项 | 说明 |
|---|---|
| 参数 | keyword:用户名关键字(不能为空) |
| 登录 | 必须已登录,未登录返回空数组 [] |
| 返回 | UserVO 数组:userId、username、avatarPath |
排除规则(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 |
action | accept 同意 / 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 收到实时推送 在我们的initWebSocket 的 onmessage 去完成:
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 > 0 | 跳 client.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、技术栈
| 层次 | 技术 | 版本/说明 |
|---|---|---|
| 语言 | Java | 1.8 |
| 后端框架 | Spring Boot | 2.7.6 |
| 持久层 | MyBatis Spring Boot Starter | 2.3.0 |
| 数据库 | MySQL | 8.x,库名 web_chatroom |
| 实时通信 | Spring WebSocket | 文本消息 JSON |
| JSON | Jackson | Spring 内置 |
| 工具 | Lombok | 简化实体 |
| 前端 | HTML5 + jQuery | 2.0.3 |
| 样式 | CSS3 | common.css / auth.css / client.css |
| 构建 | Maven | — |
3、项目源码地址 📦
源码我放在 码云 Gitee 了,跟专栏代码是对得上的,Clone 下来就能跑 🚀
| 项 | 内容 |
|---|---|
| 📁 工程名 | web_chatroom2 |
| 🔗 仓库地址 | 点我看源码 |
| 🗄️ 建库脚本 | src/main/java/db.sql |
| 👤 测试账号 | 张三 / 123456、李四 / 123456 |
写到这儿,心里挺踏实的。
轻聊 V1.0 该做的主流程咱们都撸完了:能注册登录、能加好友、能单聊、消息能实时推、离线能拉历史、左侧还有未读小红点。不大,但是 从头到尾自己搭出来的,课设也好、练 Spring Boot + WebSocket 也好,够用了 💪
当然离「真·微信」还差得远呢 😂 后面要是还有时间,我想慢慢加:群聊、聊天里 发文件发图片、消息撤回啥的……路还长,不着急,有一篇写一篇。
老铁们如果要是这套东西对你有帮助,可以支持一下作者 ,给个小小的关注和点赞,评论区逛一逛,感谢各位老铁
轻聊项目咱那就先这样啦,咱 下一系列见 ~~~~ 👋👋👋