【Java项目-轻聊】10-实现会话管理模块

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

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

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

🔥 弹简特 个人主页

❄️ 个人专栏直通车:

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

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨


🌟 博主简介:


文章目录:


一、数据库设计

1、数据库设计-1【设计逻辑思路】

此处的会话要想管理起来,我们势必要放到数据库中进行保存。为什么我们需要存在数据库中,因为数据库可以进行持久化存储,等你再次打开我们的聊天程序的时候,我们之前的聊天记录,很显然是应该存在的。

那么关于会话的数据库,我们该怎么设计?

我们设计数据库,主要有两个核心步骤:

  1. 找到实体
  2. 找出实体之间的关联关系。

我们涉及到的实体:

  • 会话
  • 用户
  • 消息

那么现在我们分析上述三个实体之间,他们有什么关联关系?

会话和用户

如何找关联关系,我们往那三个公式中一个一个的去套:

  • 1对1:一个用户只有一个会话,一个会话里只有一个用户,很显然是不符合的,我们怎么可能自己给自己聊呢?那还叫聊天程序吗?
  • 1对n:一个会话里可以多个用户,一个用户只在一个会话里面,这很显然也是不符合的。我们的一个用户,他是可以在多个会话里面嘛。
  • n对n:一个会话里面可以有多个用户,那一个用户可以出现在多个会话中,这是符合我们的实际的,所以我们会话和用户实体的实体之间的关联关系是多对多

会话和消息

按照上述的逻辑,一个一个的套,那得出一个会话里面是得包含多条消息的,一条消息只能从属于一个会话,我们得出他们之间是一对多的关系。


用户和消息

我们上述把会话和用户以及会话和消息之间的关系理清楚了,那么就无需刻意的去理用户和消息之间的关系,我们使用会话作为用户和消息的一个中间媒介,通过它可以反映出二者之间的关系。


2、数据库设计-2【数据库表的设计】

我们先创建一个消息会话表

message_session

名称 含义
sessionId 主键
lastTime 上次访问时间,通过这个时间针对会话列表排序

再创建一个会话和用户之间的关联表【因为会话和用户是多对多的,得用一个中间表来进行关联】

message_session_user

名称 含义
sessionId 会话id
userId 用户Id

那对于消息表我们后续再去设计,后续写到消息模块的功能的时候,再考虑消息表和会话表之间的关系。

那么一个会话里面可以有两个用户,也可以有多个用户,有两个用户的会话称之为单聊,有多个用户的会话称之为群聊。


二、代码实现

我们会话管理需要实现两个核心功能:

第1个就是获取会话信息

第2个就是新增会话

1、获取会话列表

1.1 约定前后端交互接口

请求

GET/sessionList

我们后端写的时候是根据用户id查询的,只不过前端不需要传递这个参数,因为后端是可以从session里面去获取到当前登录的是这个用户,就是查询该用户对应的会话列表。

响应

http/1.1 200 ok

Content-type:application/json

body:

返回当前用户的所有会话,同时按照这些会话的最后访问时间进行降序排序,针对每一个会话都要获取到这个会话和哪些用户产生的?同时另外还需要获取到这个会话里面最后一条消息是啥?这个消息我们用于放在界面上显示。

具体的约定如下所示:

内容
URL GET /sessionList
是否需登录

成功响应

json 复制代码
[
  {
    "sessionId": 1,
    "friends": [
      {
        "friendId": 2,
        "friendName": "李四",
        "avatarPath": null
      }
    ],
    "lastMessage": "晚上见"
  }
]
字段 说明
friends 会话中除自己外的成员(单聊为 1 人)
lastMessage 该会话最新一条消息内容,无则 ""

1.2 一图理清楚业务逻辑

1.3 前端实现

我们前端的实现思路就是解决,你这个会话信息,到底什么时候去获取?

我们计划在获取用户信息的时候,就去获取这个会话信息,那么在我们之前的或去用户信息中新增一行代码👇

js 复制代码
function getUserInfo() {
    $.ajax({
        method: 'get',
        url: '/userInfo', // Session 中的用户信息
        success: function(body) {
            if (body.userId && body.userId > 0) {
                //获取好友列表
                getFriendList(); //我们在获取用户信息的时候就获取好友列表
                //此处新增这个方法的调用
                getSessionList(); // 左侧会话列表



                let userBar = document.querySelector('.main .left .user');
                userBar.querySelector('.user-name').textContent = body.username;
                //将用户id存起来,以便于后续我们的使用:比如上传头像或者消息未读取等等
                userBar.setAttribute('user-id', body.userId);

                if (body.avatarPath) {
                    userBar.setAttribute('data-avatar-path', body.avatarPath);
                }
                refreshMyAvatar(body.userId, body.avatarPath, false);

            } else {
                alert("当前用户未登录");
                location.assign("/login.html"); // 未登录跳转
            }
        }
    });
}

那么接下来就是实现两步:获取后端的数据,然后渲染到前端

我们就先去获取数据

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


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


            // 定义对象,用作去重标记: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);

            }
            
        }
    });
}

/**
 * 构造单条会话列表项的完整 HTML 片段
 * 结构包含:头像、昵称、消息预览、未读角标容器,同时做内容转义防止 XSS 攻击
 * @param {string|number} friendId 好友ID
 * @param {string} friendName 好友昵称
 * @param {string} preview 消息预览文本
 * @param {string} avatarPath 头像图片地址
 * @returns {string} 拼接完成的 HTML 字符串
 */
function buildSessionLiHtml(friendId, friendName, preview, avatarPath) {
    // 整体外层容器,使用 flex 布局统一排版整行内容
    return '<div class="session-item-inner">'
        // 调用头像生成函数,传入好友ID、头像尺寸样式、头像地址,拼接头像HTML (此处这个函数我们在用户头像就已经处理过了)
        + avatarImgHtml(friendId, 'avatar-md', avatarPath)
        // 中间主体区域:存放昵称和消息预览
        + '<div class="session-main">'
        // 上一行:好友昵称行
        + '<div class="session-row-top">'
        // 对昵称进行HTML转义,过滤特殊字符,防止XSS注入攻击
        + '<span class="session-name">' + escapeHtml(friendName) + '</span>'
        + '</div>'
        // 下一行:最后一条消息预览文本,空内容也做转义兼容
        + '<p class="session-preview">' + escapeHtml(preview || '') + '</p>'
        + '</div>'
        + '</div>';
}

我们的会话列表中是可以被点击的。那么针对这一个,我们该怎么去处理呢?👇

如何实现点击会话

当用户点击会话的时候:

1.把当前会话设置成高亮(activie状态)。

2.获取到该会话中的历史消息,并显示到右侧的消息区。【这里边我们得留到后面才能实现,因为我们此处消息表还未去处理,我们等后面实现消息模块的时候再实现】

所以这里边我们就把高亮这个模块实现再说,首先我们就给会话列表加一个点击事件:

那么如何设置高亮的效果呢?

1)先获取到所有的会话的li标签。【已实现】

2)循环遍历这些li标签,依次对比

3)看现在这个li标签是否是正在点击的标签。如果是,那么就给className设置属性为selected的,如果不是,就清空className。【//class标签的值的设置使用的是className】

js 复制代码
/**
 * 点击左侧会话列表项的事件处理函数
 * 功能:切换选中状态、展示聊天面板、清空未读消息、加载历史聊天记录
 * @param {HTMLElement} currentLi 当前点击的会话li元素
 */
function clickSession(currentLi) {
    // TODO:后续先处理你点击的会话是否是好友请求

    // TODO:后续展示右侧聊天面板

    //筛选出所有普通会话项(排除好友请求条目)
    let allLis = document.querySelectorAll('#session-list>li:not(.friend-request-item)');
    // 统一处理会话选中样式:取消其他项选中、给当前项添加选中样式
    activeSession(allLis, currentLi);
    
    // TODO:获取当前会话唯一ID,后续用去请求会话中的消息
    
    // TODO:后续实现点击出现角标的样式
   
    // TODO:后续实现根据会话ID请求并加载历史聊天消息
}

/**
 * 统一管理会话选中样式
 * 为当前点击会话添加选中高亮,同时移除其他所有会话的选中状态
 * @param {NodeList} allLis 所有普通会话项 DOM 集合
 * @param {HTMLElement} currentLi 当前需要设为选中的会话 li 元素
 */
function activeSession(allLis, currentLi) {
    // 遍历全部会话列表项
    for (let li of allLis) {
        // 匹配当前点击项:添加 selected 样式类,实现高亮选中
        if (li === currentLi) {
            li.classList.add('selected');
        }
        // 非当前项:移除选中样式,取消高亮
        else {
            li.classList.remove('selected');
        }
    }
}

1.4 后端实现

持久层实现

实现后端部分的代码一方面要考虑实现数据库操作,一方面要实现服务器的api。

那我们看我们约定的响应数据格式:

这个结果告诉我们:

一方面表示当前用户有哪些的会话。(后端根据存在session中的登录用户信息id来获取对应的会话)

另一方面,每一个会话都包含哪些好友的信息?


我们先创建数据库实体类:

在dataobject包中建一个messageSession实体类。用它来表示一个会话信息:

代码:

java 复制代码
package com.zhongge.web_chatroom.dao.dataobject;

import java.util.List;

/**
 * @ClassName MessageSession
 * @Description TODO
 * @Author zhongge
 * @Version 1.0
 */
 @Data
public class MessageSession {
    private int sessionId; // 会话主键 message_session.sessionId
    private List<Friend> friends; // 本会话中除自己外的成员(单聊为对方一人)
    private String lastMessage; // 最近一条消息预览,无消息则为空串
}

接下来就是将我们这个messageSession对象给凑出来我们如何查询数据库,才能把我们需要的信息给拿到呢?

先创建mapper接口

代码:

java 复制代码
package com.zhongge.web_chatroom.dao.mapper;

import com.zhongge.web_chatroom.dao.dataobject.Friend;
import org.apache.ibatis.annotations.Param;

import java.util.List;

/**
 * @InterfaceName MessageSessionMapper
 * @Description TODO 消息会话模块持久层
 * @Author zhongge
 * @Version 1.0
 */
public interface MessageSessionMapper {
    /**
     *  1.根据userId获取到该用户都在哪些会话中存在,返回结果是一组sessionId
     * @param userId 用户id
     * @return 一组会话id
     */
    List<Integer> getSessionIdByUserId(Integer userId);

    /**
     * 2.根据sessionId再来查询这个会话都包含了哪些用户
     * 注意:需要刨除最初的自己,比如张三在 1,2,3会话中出现过
     * 然后我们再反查1会话里面包含哪些用户,那么1会话里面包含张三和李四
     * 实际上此时我们就只需要得到李四就行了 因为我们正在针对张三来查,那么就将张三自己给排除掉了
     * @param sessionId 会话Id - 查询出该会话中包含哪些用户
     * @param selfUserId 自己Id - 排除自己
     * @return 返回该会话中除了自己以外的其他用户
     */
    List<Friend> getFriendsBySessionId(@Param("sessionId") Integer sessionId,
                                       @Param("selfUserId") Integer selfUserId); // 会话中除自己外的成员
}

实现xml的配置

代码:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!-- MyBatis Mapper 文件标准头部声明 -->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!-- 
    mapper 根标签
    namespace:绑定对应的 Mapper 接口全类名
    对应接口:com.zhongge.web_chatroom.dao.mapper.MessageSessionMapper
    MyBatis 通过该命名空间找到对应的 DAO 接口方法
-->
<mapper namespace="com.zhongge.web_chatroom.dao.mapper.MessageSessionMapper">

    <!--
        接口方法:getSessionIdByUserId
        功能:根据当前登录用户ID,查询该用户所有参与的会话ID
        结果类型:java.lang.Integer(返回单列 sessionId)
        业务用途:前端拉取会话列表时,第一步先拿到用户全部会话ID集合
    -->
    <select id="getSessionIdByUserId" resultType="java.lang.Integer">
        -- 查询会话主表 message_session 中的 sessionId
        -- 筛选条件:该会话存在于当前用户的会话关联表中
        select sessionId from message_session
        where sessionId in
              (
                  -- 子查询:根据用户ID,查询该用户关联的所有会话ID
                  select sessionId from message_session_user where userId = #{userId}
              )
              -- 按会话最后消息时间倒序,最新会话排在最前面
        order by lastTime desc
    </select>


    <!--
        接口方法:getFriendsBySessionId
        功能:根据会话ID + 当前登录人ID,查询该会话内的**对方好友信息**
        返回实体:com.zhongge.web_chatroom.dao.dataobject.Friend(好友DO实体)
        业务用途:拿到会话ID后,查询会话对应的好友昵称、头像、好友ID,用于渲染前端会话列表
    -->
    <select id="getFriendsBySessionId" resultType="com.zhongge.web_chatroom.dao.dataobject.Friend">
        -- 查询用户表 user,字段别名与 Friend 实体属性一一对应
        -- userId  → friendId(好友ID)
        -- username → friendName(好友昵称)
        -- avatar_path → avatarPath(头像路径)
        select userId as friendId, username as friendName, avatar_path as avatarPath from user
        where userId in
              (
                  -- 子查询:根据会话ID,查询该会话下所有参与用户ID
                  select userId from message_session_user
                  where sessionId = #{sessionId}
                    and userId != #{selfUserId} -- 排除当前登录用户,只查聊天对方
            )
    </select>

</mapper>

那么我们具体怎么做的?

如何查询会话中的用户数据?


表现层实现(控制层实现)

首先创建出外面的消息会话控制层:

代码:

java 复制代码
    /**
     * GET /sessionList
     * 接口功能:获取当前登录用户的全部聊天会话列表(附带好友信息、最后消息)
     * 对应前端 JS 方法:getSessionList()
     * @param req HTTP请求对象,用于获取服务端Session,校验登录状态
     * @return 会话列表数据,未登录/登录失效返回空集合
     */
    @GetMapping("/sessionList")
    public Object getMessageSessionList(HttpServletRequest req) {
        // 获取当前请求对应的服务端Session,参数false:不存在则不自动新建Session
        HttpSession session = req.getSession(false);
        // Session不存在 → 用户未登录,直接返回空列表
        if (session == null) {
            return new ArrayList<>();
        }
        // 从Session中取出登录用户对象(登录接口已存入)
        User user = (User) session.getAttribute("user");
        // 取到null = 登录状态失效,返回空列表
        if (user == null) {
            return new ArrayList<>();
        }
        // 调用业务层:根据当前用户ID查询所有会话及关联好友、消息摘要数据
        return messageSessionService.getSessionList(user.getUserId());
    }
服务层实现

创建服务层接口和实现类:

代码:

java 复制代码
//接口:
public interface MessageSessionService {
    // 获取用户的会话列表(按好友去重,含最后一条消息)
    List<MessageSession> getSessionList(int userId);

}
//实现类:
    /**
     * 根据用户ID查询该用户所有会话列表(最终返回给前端)
     * 对应接口:GET /sessionList
     * @param userId 当前登录用户ID
     * @return 组装完成的会话实体列表
     */
    @Override // 重写接口抽象方法
    public List<MessageSession> getSessionList(int userId) {
        // 定义最终要返回给前端的会话列表
        List<MessageSession> messageSessionList = new ArrayList<>();
        // 边界校验:用户ID不合法,直接返回空列表
        if (userId <= 0) {
            System.out.println("用户ID不合法,无法查询会话列表");
            return messageSessionList;
        }

        // 1. 调用Mapper:查询当前用户关联的所有会话ID集合(按最后聊天时间倒序)
        List<Integer> sessionIdList = messageSessionMapper.getSessionIdByUserId(userId);
        /**
         * 使用LinkedHashMap:
         * 使用这个数据结构可以实现去重处理
         * 1. 实现按【好友ID】去重:同一好友只保留一条会话
         * 2. 保留插入顺序,维持数据库查询的时间排序
         * key:好友ID  value:对应会话实体
         */
        Map<Integer, MessageSession> sessionByFriendId = new LinkedHashMap<>();

        // 遍历每一个会话ID,逐个封装会话数据
        for (int sessionId : sessionIdList) {
            // 2. 根据会话ID + 当前用户ID,查询会话内的对方好友信息
            // 根据会话id 将我们会话表中除了用户自己的好友id给查询出来
            // 但是你放心,你查询出的结果一个会话只会有一个好友,因为你是单聊
            List<Friend> friends = messageSessionMapper.getFriendsBySessionId(sessionId, userId);
            // 容错:会话无有效聊天对象,跳过当前会话
            if (friends == null || friends.isEmpty()) {
                continue;
            }
            // 取第一个好友(你也只能取出第一个好友,因为你是单聊,那么你这个会话中就只有一个好友)
            int friendId = friends.get(0).getFriendId();

            // 组装会话实体对象
            MessageSession messageSession = new MessageSession();
            messageSession.setSessionId(sessionId); // 设置会话ID
            messageSession.setFriends(friends); // 绑定好友信息(昵称、头像、ID)

            // TODO 3. 查询该会话的最后一条消息内容(目前随机设置一个,到时候处理消息模块的时候就需要设置具体值)
            messageSession.setLastMessage("你好,晚上我们一起去吃火锅吧~");


            // 根据好友ID查找Map中是否已存在该好友的会话(这一步的作用就是用于去重的)
            MessageSession existing = sessionByFriendId.get(friendId);
            if (existing == null) {
                // 该好友首次出现,直接存入Map
                sessionByFriendId.put(friendId, messageSession);
            } else {
                // 如果这个好友已经存在在我们的列表中的话,那么我们就会使用最新的数据进行覆盖
                // 他的场景就是,你创建出了多个会话id的时候,我们怎么办的问题(多考虑特殊情况)
                // 该好友存在多条重复会话,调用方法筛选出最优会话进行覆盖
                sessionByFriendId.put(friendId, pickBetterSession(existing, messageSession));
            }
        }
        // 将去重、筛选完成后的会话集合转为List,作为最终结果
        // sessionByFriendId.values() 我们会有多个会话,那么每个会话中都会有一个好友
        // 所以此时我们sessionByFriendId集合中势必会有多个好友的信息,那么sessionByFriendId.values()就是将所有的key对应的会话信息返回(包括好友信息)
        messageSessionList.addAll(sessionByFriendId.values());
        return messageSessionList;
    }

    /**
     * 私有工具方法:同一好友多条重复会话时,筛选出优先级更高的会话
     * 筛选规则:
     * 1. 有最后消息的会话 优先于 无消息的会话
     * 2. 两者状态相同时,选择会话ID更小(创建时间更早)的会话
     * @param a 会话实例A
     * @param b 会话实例B
     * @return 筛选后的最优会话
     */
    private MessageSession pickBetterSession(MessageSession a, MessageSession b) {
        // 判断两个会话是否存在最后一条消息
        boolean aHas = a.getLastMessage() != null && !a.getLastMessage().isEmpty();
        boolean bHas = b.getLastMessage() != null && !b.getLastMessage().isEmpty();

        // A有消息、B无消息 → 选A
        if (aHas && !bHas) {
            return a;
        }
        // A无消息、B有消息 → 选B
        if (!aHas && bHas) {
            return b;
        }
        // 都有消息 / 都无消息:选择sessionId更小(更早创建)的会话
        return a.getSessionId() <= b.getSessionId() ? a : b;
    }

1.5 测试

启动我们的服务器,登录张三账号

测试过程如下:

OK,那么到这里我们的获取用户会话信息,就完成了,接下来我们就实现创建会话了。


2、创建会话

2.1 约定前后端交互接口

内容
URL POST /session?toUserId={toUserId}
是否需登录 是(依赖 Session 属性 user

请求参数

参数 说明
toUserId 对方用户 ID

成功响应

json 复制代码
{
  "sessionId": 3
}

规则 :两人已有会话则直接返回已有 sessionId,不重复创建。


2.2 一图理清楚业务逻辑

2.3 业务

我们创建话,怎么创建?无非就是向会话表中塞入一条数据,然后再在我们的会话永华关联表中塞入两条数据:一条是当前用户和会话关系,另一条是当前用户好友和会话的关系

2.4 前端实现

那么,什么时候触发创建用户呢?

点击一个好友列表中好友的时候:触发的操作有两种情况👇

1:如果会话不存在就创建会话

1)那么你需要在客户端创建出一个对应的li标签,把这个标签放到会话列表中,这个标签应该属于被选中的高亮状态,同时置顶,同时切换到会话列表标签页里面。

2)要给服务器发送一个请求,告诉服务器,咱们有了个新的会话,让服务器保存这个会话的信息,保存信息之后需要从服务器获取我们的消息(后去消息这个动作,我们后续在消息模块中实现)

2:如果会话已经存在,则把之前的会话找到

1)把标签页切换到会话列表,找到指定的会话,置顶并且设为选中状态。

2)给服务器发送个请求,获取到该会话的历史消息列表,显示到右侧区域。【历史消息我们后续再实现 这一步我们后续再实现】


OK,那么我们理清楚我们要做什么之后,我们来看:创建会话这个过程本质从哪里触发的?

答:是你在好友列表中点击好友的时候才会触发的

所以在之前我们获取好友列表的代码基础上加一个一个好友的点击事件:

我们之前获取好友列表的时候,已经将好友ID存在我们的属性中了,那么次数我们就可以使用好友ID或者好友名称来查找对应的li

js 复制代码
/**
 * 好友列表中点击好友触发事件
 * 逻辑:打开已有会话 / 新建会话,会话自动置顶并切换到会话标签页
 * @param {Object} friend 好友对象,包含 friendId、friendName、avatarPath 等信息
 */
// 好友 Tab 点击好友:打开已有会话或创建新会话
function clickFriend(friend) {
    //TODO 后续在消息模块中:先展示消息的聊天面板

    // 先根据好友ID、好友名称,在会话列表中查找对应的会话li元素
    // 优先按ID查找,找不到再按昵称匹配
    let sessionLi = findSessionByFriendId(friend.friendId) || findSessionByName(friend.friendName);
    // 获取整个会话列表容器
    let sessionListUL = document.querySelector('#session-list');

    // 分支1:该好友已有会话记录
    if (sessionLi) {
        // 将当前会话项移动到普通会话区域最顶部
        // getFirstChatSessionLi:获取第一个普通会话节点(跳过顶部好友请求项)
        sessionListUL.insertBefore(sessionLi, getFirstChatSessionLi(sessionListUL));
        // 主动触发点击事件,执行选中会话、清空未读、加载历史消息逻辑
        sessionLi.click();
    }
    // 分支2:暂无对应会话,需要新建会话
    else {
        // 创建空的会话li节点,作为临时占位项
        sessionLi = document.createElement('li');
        // 绑定好友ID,用于后续识别
        sessionLi.setAttribute('data-friend-id', friend.friendId);
        // 存在头像路径则绑定头像属性
        if (friend.avatarPath) {
            sessionLi.setAttribute('data-avatar-path', friend.avatarPath);
        }
        // 拼接会话项HTML,消息预览传空字符串(新建会话暂无消息)
        sessionLi.innerHTML = buildSessionLiHtml(friend.friendId, friend.friendName, '', friend.avatarPath);
        // 将新建的占位会话插入到普通会话最顶部
        sessionListUL.insertBefore(sessionLi, getFirstChatSessionLi(sessionListUL));

        // 新会话绑定点击事件
        sessionLi.onclick = function() {
            clickSession(sessionLi);
        };
        // TODO 后续实现 为先触发点击,打开聊天界面,提升交互体验

        // 调用方法向后端请求创建会话,成功后回填会话ID
        createSession(friend.friendId, sessionLi);
    }

    // 自动切换页面标签,从【好友】Tab 切回【会话】Tab
    document.querySelector('.tab .tab-session').click();
}


/**
 * 根据好友ID查找对应的会话列表项
 * 仅检索普通会话,排除顶部的好友请求条目
 * @param {string|number} friendId 好友ID
 * @returns {HTMLElement|null} 匹配到的li元素,无匹配返回null
 */
function findSessionByFriendId(friendId) {
    // 选取会话列表下所有li,过滤掉好友请求项
    let sessionLis = document.querySelectorAll('#session-list>li:not(.friend-request-item)');
    // 遍历所有会话项
    for (let sessionLi of sessionLis) {
        // 统一转为字符串比对,避免数字、字符串类型不一致导致匹配失败
        if (String(sessionLi.getAttribute('data-friend-id')) === String(friendId)) {
            return sessionLi;
        }
    }
    // 遍历完毕未找到,返回空
    return null;
}

/**
 * 根据昵称查找对应的会话列表项(兜底匹配)
 * 仅检索普通会话,排除好友请求条目
 * @param {string} username 好友昵称
 * @returns {HTMLElement|null} 匹配到的li元素,无匹配返回null
 */
function findSessionByName(username) {
    // 选取所有普通会话项,排除好友请求
    let sessionLis = document.querySelectorAll('#session-list>li:not(.friend-request-item)');
    // 遍历会话项
    for (let sessionLi of sessionLis) {
        // 调用公共方法获取会话展示名称,和传入昵称做比对
        if (getSessionDisplayName(sessionLi) === username) {
            return sessionLi;
        }
    }
    // 未匹配到则返回空
    return null;
}


/**
 * 获取会话列表里第一个普通会话节点
 * 自动跳过顶部置顶的好友请求项,用于实现会话置顶插入逻辑
 * @param {HTMLElement} ul 会话列表根容器ul元素
 * @returns {HTMLElement|null} 第一个普通会话li,全是好友请求则返回null
 */
// 取会话列表中第一个「普通会话」li(跳过置顶的好友请求)
function getFirstChatSessionLi(ul) {
    // 遍历列表所有直接子节点
    for (let i = 0; i < ul.children.length; i++) {
        // 判断当前节点不是好友请求项
        if (!ul.children[i].classList.contains('friend-request-item')) {
            // 找到第一个普通会话,作为 insertBefore 的参考节点
            return ul.children[i];
        }
    }
    // 列表内全部都是好友请求项,无普通会话,返回null
    return null;
}

/**
 * 发起POST请求,创建/获取与指定好友的会话ID
 * 创建成功后为会话DOM绑定会话ID,并清理同名重复会话项
 * @param {string|number} friendId 对方好友ID
 * @param {HTMLElement} sessionLi 当前会话对应的li节点
 */
// POST 与指定好友创建或获取会话 ID
function createSession(friendId, sessionLi) {
    // 发起jQuery POST异步请求
    $.ajax({
        method: 'post',                // 请求方式为POST
        url: '/session?toUserId=' + friendId,  // 请求地址,拼接对方用户ID作为参数
        // 请求成功回调
        success: function(body) {
            // 校验响应数据,判断是否成功拿到会话ID
            if (body && body.sessionId) {
                // 给当前会话li绑定会话ID自定义属性,供给后续使用
                sessionLi.setAttribute("message-session-id", body.sessionId);
                // 按名称合并同名会话,并删除重复无效项
                mergeDuplicateSessionByName(sessionLi);
            }
        },
        // 请求失败回调(网络/服务端异常)
        error: function() {
            // 控制台打印错误日志,方便调试
            console.log("会话创建失败");
        }
    });
}

/**
 * 删除重复会话项
 * 规则:移除和当前会话为同一好友 / 同名且未绑定会话ID 的无效占位li节点
 * @param {HTMLElement} keepLi 需要保留的目标会话li元素
 */
function mergeDuplicateSessionByName(keepLi) {
    // 获取当前保留项绑定的好友ID
    let friendId = keepLi.getAttribute('data-friend-id');
    // 获取当前会话的名称节点
    let name = keepLi.querySelector('.session-name');
    // 筛选所有普通会话项,排除好友请求条目
    let sessionLis = document.querySelectorAll('#session-list>li:not(.friend-request-item)');

    // 遍历所有会话节点,逐个判断是否为重复项
    for (let li of sessionLis) {
        // 跳过当前需要保留的节点,不做删除
        if (li === keepLi) {
            continue;
        }

        // 判断是否为同一个好友:存在好友ID 且 两边ID字符串完全一致
        let sameFriend = friendId && String(li.getAttribute('data-friend-id')) === String(friendId);
        // 判断是否同名:名称节点存在 且 会话展示文本相同
        let sameName = name && getSessionDisplayName(li) === name.textContent;

        // 满足任一条件则判定为重复,执行删除:
        // 1. 属于同一好友,直接删除重复项
        // 2. 名称一致,并且该节点没有绑定会话ID(前端临时占位项),予以删除
        if (sameFriend || (sameName && !li.getAttribute('message-session-id'))) {
            li.remove();
        }
    }
}
/**
 * 统一获取会话/好友请求项的展示名称
 * 兼容两种DOM结构:普通会话取 .session-name,好友请求项取 h3 标签
 * @param {HTMLElement} li 会话列表中的li节点
 * @returns {string} 展示名称,无名称则返回空字符串
 */
// 从 li 中取显示名称(会话用 .session-name,好友请求用 h3)
function getSessionDisplayName(li) {
    // 优先查找普通会话的名称节点
    let nameEl = li.querySelector('.session-name');
    // 找到则直接返回文本内容
    if (nameEl) return nameEl.textContent;

    // 未找到普通会话节点,尝试查找好友请求项的h3标题
    let h3 = li.querySelector('h3');
    // 三元判断:存在则返回文本,不存在返回空字符串
    return h3 ? h3.textContent : '';
}

2.5 后端实现

持久层实现

在会话中 我们创建了两张表:

message_session 存储会话信息


message_session_user保存的是session和user之间的关联关系


那么所谓的创建会话让服务层保存,其实就是让服务器的数据库,来这两个表中记录数据。

把数据往这两个表里面去插。

比如我现在是张三这个用户,然后我点击了好友列表中的李四,此时创建了一个新的会话。

其实涉及到三个数据库操作:

1、现在message_session表里面新增一个数据项。新增的这个数据项就表示当前的会话,此时我们就把会话创建出来了,同时我们获取到新会话的主键id=100。

2、插入自己和会话关系 给message_session_user表插入记录比如张三包含在会话编号为100中,那么就插入100,1。

3、插入自己好友和会话关系 给message_session_user表再插入一条记录:(100,2)代表李四也是包含在会话100中。

首先我们有了对应的实体类

新增一个MessageSessionUserItem类表示message_session_user表里面的一个记录

代码:

java 复制代码
package com.zhongge.web_chatroom.dao.dataobject;

import lombok.Data;

/**
 * @ClassName MessageSessionUserItem
 * @Description TODO
 * @Author zhongge
 * @Version 1.0
 */
@Data // 会话-用户关联表 message_session_user 的一行,用于创建会话时插入
public class MessageSessionUserItem {
    private Integer sessionId; // 会话 ID
    private Integer userId; // 参与该会话的用户 ID
}

定义持久层接口

java 复制代码
   /**
     * 3.新增一个会话记录,返回这个会话的id
     *
     * @param messageSession 会话信息
     * @return 表示的是插入的操作被影响的行数
     * <p>
     * 次数获取的会话id 我们是使用messageSession的属性sessionId来得到的,
     * 只需要在添加的时候加入几个配置,那么就可以获取到添加之后的主键值【mybatis中学过的】
     */
    Integer addMessageSession(MessageSession messageSession);


    /**
     * 4.新增一个会话用户关联记录[message_session_user]
     *
     * @param messageSessionUserItem 会话和用户之间的关联关系
     */
    void addMessageSessionUser(MessageSessionUserItem messageSessionUserItem);

    /**
     * 5.根据两个用户id查询是否已经存在会话
     *
     * @param userId1 用户id1
     * @param userId2 用户id2
     * @return 返回会话id
     */
    Integer getSessionIdByUserPair(@Param("userId1") Integer userId1, @Param("userId2") Integer userId2); // 两人是否已有会话

对应的xml文件:

xml 复制代码
    <!--
    方法ID:addMessageSession
    功能:新增一条会话主记录
    useGeneratedKeys="true":开启主键自增回填
    keyProperty="sessionId":将数据库自增生成的主键,回填到入参实体的sessionId属性
    表结构说明:message_session 字段为 sessionId(自增主键)、lastTime
-->
    <insert id="addMessageSession" useGeneratedKeys="true" keyProperty="sessionId">
        -- null 对应自增主键,now() 取当前系统时间作为会话最后时间
        insert into message_session values(null, now())
    </insert>

    <!--
        方法ID:addMessageSessionUser
        功能:新增 会话-用户 关联记录
        作用:建立用户与会话的绑定关系,多对多关联表数据插入
        参数:sessionId 会话ID、userId 用户ID
    -->
    <insert id="addMessageSessionUser">
        insert into message_session_user values(#{sessionId}, #{userId})
    </insert>

    <!--
    方法ID:getSessionIdByUserPair
    功能:根据两个用户ID,查询二人之间已存在的会话ID(用于判断是否重复创建会话)
    结果类型:Integer
    逻辑:查询同时包含 userId1 和 userId2 的会话,只取第一条
    适用场景:创建单聊会话前做幂等判断
-->
    <select id="getSessionIdByUserPair" resultType="java.lang.Integer">
        select sessionId from message_session_user
        where userId = #{userId1}
          and sessionId in (
            -- 子查询:找出所有包含第二个用户的会话ID
            select sessionId from message_session_user where userId = #{userId2}
        )
          -- 按会话ID升序,取最早创建的一条会话
        order by sessionId asc
            limit 1
    </select>
控制层实现(表现层实现)
java 复制代码
    /**
     * POST /session
     * 接口功能:创建单聊会话,若两人已有会话则直接返回原有会话ID(幂等)
     * 业务场景:前端点击好友发起聊天,调用此接口拿到sessionId后打开聊天面板
     * @param toUserId 聊天对方的用户ID
     * @param user 从Session中自动注入当前登录用户对象
     * @return Map集合,返回会话ID给前端
     */
    @PostMapping("/session")
    public Object addMessageSession(int toUserId, @SessionAttribute("user") User user) {
        // 封装响应结果,只向前端返回会话ID
        Map<String, Integer> resp = new HashMap<>();
        // 调用业务层:根据【自己ID + 对方ID】获取/创建会话,返回唯一sessionId
        Integer sessionId = messageSessionService.createSession(user.getUserId(), toUserId);
        resp.put("sessionId", sessionId);
        // 返回JSON结果,前端拿到sessionId后加载历史消息、切换聊天窗口
        return resp;
    }
服务层实现
  • 先看会话是否存在
  • 不存在则创建会话
  • 存储会话和当前用户关系
  • 存储会话和当前用户好友关系
java 复制代码
 /**
     * 创建/获取一对一单聊会话
     * 对应接口:POST /session
     * 多表操作,开启事务保证数据一致性
     * @param userId 当前登录用户ID(自己)
     * @param toUserId 对方好友ID
     * @return 最终可用的会话ID
     */
    @Override // 重写接口抽象方法
    @Transactional // 声明式事务:方法内多表增删操作要么全部成功,要么全部回滚
    public int createSession(int userId, int toUserId) {
        // 先查询:两人之间是否已经存在会话
        Integer existSessionId = messageSessionMapper.getSessionIdByUserPair(userId, toUserId);
        if (existSessionId != null) {
            // 会话已存在,直接返回原有会话ID,不重复创建
            return existSessionId;
        }

        // ===== 不存在历史会话,开始新建会话 =====
        // 1. 创建会话主表实体
        MessageSession messageSession = new MessageSession();
        // 插入会话主表,数据库自增生成sessionId,并回填到实体中
        messageSessionMapper.addMessageSession(messageSession);

        // 2. 构建【当前用户】与会话的关联记录
        MessageSessionUserItem item1 = new MessageSessionUserItem();
        item1.setSessionId(messageSession.getSessionId()); // 绑定新生成的会话ID
        item1.setUserId(userId); // 绑定当前登录用户ID
        messageSessionMapper.addMessageSessionUser(item1); // 插入关联表

        // 3. 构建【对方好友】与会话的关联记录
        MessageSessionUserItem item2 = new MessageSessionUserItem();
        item2.setSessionId(messageSession.getSessionId()); // 同一会话ID
        item2.setUserId(toUserId); // 绑定好友ID
        messageSessionMapper.addMessageSessionUser(item2); // 插入关联表

        // 控制台打印日志,方便调试排查
        System.out.println("[addMessageSession] 新增会话成功!sessionId=" + messageSession.getSessionId()
                + " userId1= " + userId + " userId2=" + toUserId);

        // 返回新创建的会话ID给前端
        return messageSession.getSessionId();
    }

2.6 测试

启动服务器,我们使用测试数据admin


最后,本期搞定【获取会话列表和创建会话】🎉!🚀。

下期开始实现【消息模块】🖥️!

干货持续更新,记得点赞👍关注🌟收藏⭐,追更不迷路~

相关推荐
人道领域1 小时前
Java后端开发者转型AIAgent开发路线指南
java·开发语言
许彰午1 小时前
36_Java设计模式之代理模式
java·设计模式·代理模式
盒马盒马1 小时前
Rust:String
java·前端·rust
许彰午1 小时前
35_Java设计模式之工厂模式
java·开发语言·设计模式
凡人叶枫1 小时前
Effective C++ 条款32:确定你的 public 继承塑模出 is-a(是一种)关系
java·linux·开发语言·c++·嵌入式开发
小杨互联网1 小时前
Jar反编译逆向2.0教程实战
java·jar·java反编译·jar反编译·java逆向·源码还原
爱码少年1 小时前
Spring Boot 文件上传下载完整指南:从基础到高级实践
java·spring boot
码云骑士1 小时前
18-生成器不只是省内存(上)-yield的状态机模型与帧暂停
c语言·开发语言·python
我喜欢就喜欢1 小时前
C++ 连接 Ollama 本地大模型:从原生 HTTP 调用到高性能封装实践
开发语言·c++·http