从 0 开始实现一个网页聊天室 (小型项目)

实现功能

  1. 用户注册和登录
  2. 好友列表展示
  3. 会话列表展示: 显示当前正在进行哪些会话 (单聊 / 群聊) , 选中好友列表中的某个好友, 会生成对应的会话
  4. 实时通信, A给B发送消息, B的聊天界面 / 会话界面能立刻显示新的消息

TODO:

  1. 添加好友功能
  2. 用户头像显示
  3. 传输图片 / 表情包
  4. 历史消息搜索
  5. 消息撤回

相关技术

网络通信: WebSocket
Spring + SpringBoot + SpringMVC + MyBatis
HTML + CSS + JS

数据库设计

在这里插入图片描述

项目的基本框架

在这里插入图片描述

前端页面

注册和登录页面在这里插入图片描述

在这里插入图片描述
聊天界面
在这里插入图片描述

在这里插入图片描述

后端代码

实体类

User

本类表示一个用户的信息, 对应数据库的 user 表

@Data
public class User {

    private int userId;
    private String username = "";
    private String password = "";

    public User() {
    }

    public User(String username, String password) {
        this.username = username;
        this.password = password;
    }
}

Friend

使用一个 Friend 对象表示一个好友

// 使用一个 Friend 对象表示一个好友, 对应数据库的 friend 表
@Data
public class Friend {
    private int friendId;
    private String friendName;

    public Friend() {
    }

    public Friend(int friendId, String friendName) {
        this.friendId = friendId;
        this.friendName = friendName;
    }
}

Message

本类表示一条消息的相关信息, 对应数据库的表 message + 字段: fromname
(没有 postTime 是因为: 在查询的时候就是一次性查出所有的时间, 按照时间结果排序后返回, 我们这里就不需要再获取时间了)

// 本类表示一条消息的相关信息
// (没有 postTime 是因为: 在查询的时候就是一次性查出所有的时间, 按照时间结果排序后返回, 我们这里就不需要再获取时间了)
@Data
public class Message {
    private Integer messageId;
    private int fromId;
    private String fromName;
    private int sessionId;
    private String content;

    public Message() {
    }

    public Message( int fromId, String fromName, int sessionId, String content) {
        this.fromId = fromId;
        this.fromName = fromName;
        this.sessionId = sessionId;
        this.content = content;
    }
}

MessageSession

使用该类表示一个会话, 对应数据库的 message_session + message_session_user

// 使用该类表示一个会话
@Data
public class MessageSession {
    private int sessionId;
    private List<Friend> friends;
    private String lastMessage;
}

MessageSessionUserItem

该类对象表示 message_session_user 表里的一个记录

// 该类对象表示 message_session_user 表里的一个记录
@Data
public class MessageSessionUserItem {
    private int sessionId;
    private int userId;

    public MessageSessionUserItem() {
    }

    public MessageSessionUserItem(int sessionId, int userId) {
        this.sessionId = sessionId;
        this.userId = userId;
    }
}

MessageRequest

WebSocket 请求
自定义格式, 用于网络通信中接受请求

// WebSocket请求
@Data
public class MessageRequest {
    private String type = "message";
    private int sessionId;
    private String content;
}

MessageResponse

WebSocket 响应
自定义格式, 用于网络通信中返回响应

// WebSocket响应
@Data
public class MessageResponse {
    private String type = "message";
    private int fromId;
    private String fromName;
    private int sessionId;
    private String content;

    public MessageResponse() {
    }

    public MessageResponse(int fromId, String fromName, int sessionId, String content) {
        this.fromId = fromId;
        this.fromName = fromName;
        this.sessionId = sessionId;
        this.content = content;
    }
}

数据库

FriendMapper

用户好友的相关操作

@Mapper
public interface FriendMapper {
    // 查询用户好友列表
    List<Friend> selectFriendList(@Param("userId") int userId);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.java_chatroom.model.FriendMapper">

    <select id="selectFriendList" resultType="com.example.java_chatroom.model.Friend">
        select userId as friendId, username as friendName
        from user
        where userId in
        (select friendId from friend where userId = #{userId})
    </select>
</mapper>

MessageMapper

消息的相关操作

@Mapper
public interface MessageMapper {

    // 获取指定会话的最后一条消息
    String getLastMessageBySessionId(@Param("sessionId") int sessionId);

    // 获取指定会话的历史消息 (限制100条)
    List<Message> getMessagesBySessionId(@Param("sessionId") int sessionId);

    // 插入一条消息到数据库表中
    void add(@Param("message") Message message);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.java_chatroom.model.MessageMapper">

    <select id="getLastMessageBySessionId" resultType="java.lang.String">
        select content from message
        where sessionId = #{sessionId}
        order by postTime desc
        limit 1
    </select>

    <select id="getMessagesBySessionId" resultType="com.example.java_chatroom.model.Message">
        select
            messageId, sessionId, fromId, content, username as fromName
        from
            message, user
        where
            sessionId = #{sessionId}
            and fromId = userId
        order by
            postTime desc
        limit 100 offset 0
    </select>

    <insert id="add">
        insert into message values(null, #{message.fromId}, #{message.sessionId}, #{message.content}, now());
    </insert>
</mapper>

MessageSessionMapper

会话的相关操作

@Mapper
public interface MessageSessionMapper {
    // 1.根据 userId 获取到该用户在哪些会话中存在, 返回结果是一组 sessionId.
    List<Integer> getSessionIdsByUserId(@Param("userId") int userId);

    // 2. 根据 sessionId 查询这个会话包含哪些用户(刨除掉最初的 user)
    List<Friend> getFriendsBySessionId(@Param("sessionId") int sessionId,@Param("selfUserId") int selfUserId);

    // 3. 新增会话记录, 返回会话 id
    int addMessageSession(@Param("messageSession") MessageSession messageSession);

    // 4.给 message_session_user 表新增对应记录
    int addMessageSessionUser(@Param("messageSessionUserItem") MessageSessionUserItem messageSessionUserItem);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.java_chatroom.model.MessageSessionMapper">

    <select id="getSessionIdsByUserId" resultType="java.lang.Integer">
        select sessionId from message_session
        where sessionId in
            ( select sessionId from message_session_user
             where userId = #{userId} )
        order by lastTime desc
    </select>

    <select id="getFriendsBySessionId" resultType="com.example.java_chatroom.model.Friend">
        select userId as friendId, username as friendName
        from user
        where userId in
            ( select userId from message_session_user
                where sessionId = #{sessionId}
                and userId != #{selfUserId} )
    </select>

    <insert id="addMessageSession" useGeneratedKeys="true" keyProperty="messageSession.sessionId">
        insert into message_session values(null, now())
    </insert>

    <insert id="addMessageSessionUser">
        insert into message_session_user values(
            #{messageSessionUserItem.sessionId},
            #{messageSessionUserItem.userId}
        )
    </insert>
</mapper>

UserMapper

用户的相关操作

@Mapper
public interface UserMapper {
    // 把用户插入到数据库中 -> 注册
    int insert(@Param("user") User user);

    // 根据用户名查询用户信息 -> 登录
    @Select("select * from user where username = #{username}")
    User selectByName(@Param("username") String username);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.java_chatroom.model.UserMapper">
    <insert id="insert" useGeneratedKeys="true" keyProperty="userId">
        insert into user values(null, #{user.username}, #{user.password})
    </insert>
</mapper>

WebSocket 通讯模块

前端

主要是 JS 中的代码

先放一个 demo

// 编写 js 使用 websocket 的代码. 
// 创建一个 websocket 实例
let websocket = new WebSocket("ws://127.0.0.1:8080/test");

// 给这个 websocket 注册上一些回调函数. 
websocket.onopen = function() {
    // 连接建立完成后, 就会自动执行到. 
    console.log("websocket 连接成功!");
}

websocket.onclose = function() {
    // 连接断开后, 自动执行到. 
    console.log("websocket 连接断开!");
} 

websocket.onerror = function() {
    // 连接异常时, 自动执行到
    console.log("websocket 连接异常!");
}

websocket.onmessage = function(e) {
    // 收到消息时, 自动执行到
    console.log("websocket 收到消息! " + e.data);
}

// 发送消息 (点击发送按钮之后触发的事件)
let messageInput = document.querySelector('#message');
let sendButton = document.querySelector('#send-button');
sendButton.onclick = function() {
    console.log("websocket 发送消息: " + messageInput.value);
    websocket.send(messageInput.value);
}

这里就是本项目前端使用 WebSocket 进行网络通信的逻辑

/
// 操作 websocket
/

// 创建 websocket 实例
// let websocket = new WebSocket("ws://127.0.0.1:8080/WebSocketMessage");
// let websocket = new WebSocket("ws://152.136.56.110:9090/WebSocketMessage");
let websocket = new WebSocket("ws://" + location.host + "/WebSocketMessage");

websocket.onopen = function() {
    console.log("websocket 连接成功!");
}

websocket.onmessage = function(e) {
    console.log("websocket 收到消息! " + e.data);
    // 此时收到的 e.data 是个 json 字符串, 需要转成 js 对象
    let resp = JSON.parse(e.data);
    if (resp.type == 'message') {
        // 处理消息响应
        handleMessage(resp);
    } else {
        // resp 的 type 出错!
        console.log("resp.type 不符合要求!");
    }
}

websocket.onclose = function() {
    console.log("websocket 连接关闭!");
}

websocket.onerror = function() {
    console.log("websocket 连接异常!");
}

function handleMessage(resp) {
    // 把客户端收到的消息, 给展示出来. 
    // 展示到对应的会话预览区域, 以及右侧消息列表中. 

    // 1. 根据响应中的 sessionId 获取到当前会话对应的 li 标签. 
    //    如果 li 标签不存在, 则创建一个新的
    let curSessionLi = findSessionLi(resp.sessionId);
    if (curSessionLi == null) {
        // 就需要创建出一个新的 li 标签, 表示新会话. 
        curSessionLi = document.createElement('li');
        curSessionLi.setAttribute('message-session-id', resp.sessionId);
        // 此处 p 标签内部应该放消息的预览内容. 一会后面统一完成, 这里先置空
        curSessionLi.innerHTML = '<h3>' + resp.fromName + '</h3>'
            + '<p></p>';
        // 给这个 li 标签也加上点击事件的处理
        curSessionLi.onclick = function() {
            clickSession(curSessionLi);
        }
    }
    // 2. 把新的消息, 显示到会话的预览区域 (li 标签里的 p 标签中)
    //    如果消息太长, 就需要进行截断. 
    let p = curSessionLi.querySelector('p');
    p.innerHTML = resp.content;
    if (p.innerHTML.length > 10) {
        p.innerHTML = p.innerHTML.substring(0, 10) + '...';
    }
    // 3. 把收到消息的会话, 给放到会话列表最上面. 
    let sessionListUL = document.querySelector('#session-list');
    sessionListUL.insertBefore(curSessionLi, sessionListUL.children[0]);
    // 4. 如果当前收到消息的会话处于被选中状态, 则把当前的消息给放到右侧消息列表中. 
    //    新增消息的同时, 注意调整滚动条的位置, 保证新消息虽然在底部, 但是能够被用户直接看到. 
    if (curSessionLi.className == 'selected') {
        // 把消息列表添加一个新消息. 
        let messageShowDiv = document.querySelector('.right .message-show');
        addMessage(messageShowDiv, resp);
        scrollBottom(messageShowDiv);
    }
    // 其他操作, 还可以在会话窗口上给个提示 (红色的数字, 有几条消息未读), 还可以播放个提示音.  
    // 这些操作都是纯前端的. 实现也不难, 不是咱们的重点工作. 暂时不做了. 
}

function findSessionLi(targetSessionId) {
    // 获取到所有的会话列表中的 li 标签
    let sessionLis = document.querySelectorAll('#session-list li');
    for (let li of sessionLis) {
        let sessionId = li.getAttribute('message-session-id');
        if (sessionId == targetSessionId) {
            return li;
        }
    }
    // 啥时候会触发这个操作, 就比如如果当前新的用户直接给当前用户发送消息, 此时没存在现成的 li 标签
    return null;
}

后端

同样先上 Demo

@Component
public class TestWebSocketAPI extends TextWebSocketHandler {
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 该方法会在 websocket 连接建立之后, 被自动调用
        System.out.println("Test 连接成功!");
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 该方法会在 websocket 收到消息的时候, 被自动调用
        System.out.println("Test 收到消息!" + message.toString());
        // session 是个会话, 里面记录通信双方的信息 (session 中持有 websocket 的通信连接)
        session.sendMessage(message);
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 这个方法实在 连接出现异常的时候, 被自动调用
        System.out.println("Test 连接异常!");
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 这个方法是在连接正常关闭后, 会被自动调用
        System.out.println("Test 连接关闭!");
    }
}

下面是本项目中后端使用 WebSocket 实现网络通信

创建 Handler 对象

@Slf4j
@Component
public class WebSocketAPI extends TextWebSocketHandler {
    @Autowired
    private OnlineUserMapper onlineUserMapper;

    @Autowired
    private MessageSessionMapper messageSessionMapper;

    @Autowired
    private MessageMapper messageMapper;

    // 自己创建对象也行, 使用 @Autowired 注入也行, spring 本身就有内置对象 ObjectMapper
    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("[WebSocketAPI] 连接成功!");
        User user = (User) session.getAttributes().get("user");
        if(user == null) {
            return;
        }
        log.info("获取到的 userId: {}, username: {}",user.getUserId(), user.getUsername());
        // 连接建立成功之后, 将 上线用户 和 session 进行绑定
        onlineUserMapper.online(user.getUserId(), session);
    }

    /**
     * 数据处理
     * @param session
     * @param message
     * @throws Exception
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        log.info("[WebSocketAPI] 收到消息! " + message.toString());
        // 先获取到当前用户的信息, 后续要转发的消息等
        User user = (User) session.getAttributes().get("user");
        if(user == null){
            log.info("[WebSocketAPI] user == null, 未登录用户, 无法进行消息转发");
            return;
        }

        // 针对请求进行解析, 把 json 格式字符串转换成 Java 对象
        MessageRequest req = objectMapper.readValue(message.getPayload(), MessageRequest.class);
        if("message".equals(req.getType())) {
            // 进行消息转发
            transferMessage(user, req);
        }else {
            log.info("[WebSocketAPI] req.type 有误! {}", message.getPayload());
        }
    }

    /**
     * 通过该方法来完成消息的实际转发过程
     * @param user 发送消息的对象
     * @param req 内含 sessionId, content
     */
    private void transferMessage(User user, MessageRequest req) throws IOException {
        // 先构造一个待转发的响应对象. MessageResponse
        MessageResponse resp = new MessageResponse(user.getUserId(), user.getUsername(), req.getSessionId(), req.getContent());

        // 把这个响应对象转换成 JSON 格式字符串,以待备用
        String respJson = objectMapper.writeValueAsString(resp);
        log.info("[transferMessage] respJson: {}", respJson);

        // 根据请求中的 sessionId, 获取到 MessageSession 里有哪些用户 (查询数据库)
        List<Friend> friends =  messageSessionMapper.getFriendsBySessionId(req.getSessionId(), user.getUserId());
        // 此处响应返回的对象中, 应该包含发送方
        Friend myself = new Friend(user.getUserId(), user.getUsername());
        friends.add(myself);

        // 循环遍历 friends, 给其中每一个对象都发送一份响应
        //   这里是为了满足群聊的设定(即使前端还未实现,但是后端接口和数据库都是支持群聊的)
        for(Friend friend : friends) {
            // 已知 userId, 进一步查询 OnlineUserMapper, 获取对应的 WebSocketSession, 从而进行消息转发
            WebSocketSession webSocketSession = onlineUserMapper.getSession(friend.getFriendId());
            if(webSocketSession != null) {
                webSocketSession.sendMessage(new TextMessage(respJson));
            }
        }

        // 转发的消息还要在数据库备份
        Message message = new Message(user.getUserId(), user.getUsername(), req.getSessionId(), resp.getContent());
        // 自增主键为 null或为空, 数据库会自动生成
        messageMapper.add(message);
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        log.info("[WebSocketAPI] 连接异常! " + exception.toString());
        User user = (User) session.getAttributes().get("user");
        if(user != null) {
            onlineUserMapper.offline(user.getUserId(), session);
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        log.info("[WebSocketAPI] 连接关闭! " + status.toString());
        User user = (User) session.getAttributes().get("user");
        if(user != null) {
            onlineUserMapper.offline(user.getUserId(), session);
        }
    }
}

将 Handler 注册到 Config 里面

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Autowired
    private TestWebSocketAPI testWebSocketAPI;

    @Autowired
    private WebSocketAPI webSocketAPI;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 通过本方法, 将创建好的 Handler 类给注册到具体路径上.
        // 此时浏览器可通过 请求路径, 调用到绑定的 Handler 类.
        registry.addHandler(testWebSocketAPI, "/test");
        registry.addHandler(webSocketAPI, "/WebSocketMessage")
                // 通过注册这个特定的 HttpSession 拦截器, 可以把用户在
                // HttpSession 中添加的 Attribute 键值对
                // 往 WebSocketSession 中添加一份
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}

OnlineUserMapper

本类用来记录当前用户在线的状态. (维护 userId 和 WebSocketSession 之间的映射)

// 本类用来记录当前用户在线的状态. (维护 userId 和 WebSocketSession 之间的映射)
@Slf4j
@Component
public class OnlineUserMapper {
    // 此处这个哈希表要考虑 线程安全 问题
    private ConcurrentHashMap<Integer, WebSocketSession> sessions = new ConcurrentHashMap<>();

    /**
     * 用户上线, 给哈希表里插入键值对
     * @param userId
     * @param webSocketSession
     */
    public void online(int userId, WebSocketSession webSocketSession) {
        if(sessions.get(userId) != null) {
            // 针对用户多开, 这里的处理是不记录后面登录用户的 session, 即后续登录用户做不到消息的收发
            // (毕竟这里是根据映射关系来实现消息转发的)
            log.info("[{}] 已登录, 登录失败",userId);
            return;
        }
        sessions.put(userId, webSocketSession);
        log.info("[{}] 上线!", userId);
    }


    /**
     * 用户下线, 根据 userId 删除键值对
     * @param userId
     * @param webSocketSession
     */
    public void offline(int userId, WebSocketSession webSocketSession) {
        if(sessions.get(userId) == webSocketSession) {
            // 如果键值对中 session和调用该方法的 session 相同, 才允许删除键值对
            sessions.remove(userId);
            log.info("[{}] 下线!", userId);
        }
    }

    /**
     * 根据 userId 获取键值对
     *
     * @param userId
     * @return
     */
    public WebSocketSession getSession(int userId) {
        return sessions.get(userId);
    }
}

功能处理

用户注册

调用接口: register

@Slf4j
@RestController
@Controller
@ResponseBody
public class UserAPI {
    @Resource
    private UserMapper userMapper;

    /**
     * 用户注册
     * 返回 User 对象
     * 注册成功, 返回的 User 对象包含用户信息
     * 注册失败, 返回的 User 对象无内容
     */
    @RequestMapping("/register")
    public Object register(String username, String password) {
        User user = new User();
        // 判空
        if(!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
            return user;
        }

        try {
            user = new User(username, password);
            int ret = userMapper.insert(user);
            log.info("注册 ret :{}", ret);
            user.setPassword("");
        } catch (DuplicateKeyException e) {
            // 抛出该异常说明用户名重复, 注册失败
            user = new User();
            log.error("用户名重复, 注册失败");
        }

        return user;
    }
}

在这里插入图片描述

用户登录

调用接口: login

@Slf4j
@RestController
@Controller
@ResponseBody
public class UserAPI {
    @Resource
    private UserMapper userMapper;

    /**
     * 用户登录
     * 返回 User 对象
     * 登录成功, 返回的 User 对象包含用户信息, 并且将 User 对象存储在 session 中
     * 登录失败, 返回的 User 对象无内容
     */
    @RequestMapping("/login")
    public Object login(String username, String password, HttpServletRequest request) {
        // 判空
        if(!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
            return new User();
        }

        // 校验用户名密码
        User user = userMapper.selectByName(username);
        if(user == null || !password.equals(user.getPassword())) {

            return new User();
        }

        // 校验成功, 则登陆成功, 创建会话
        // true 表示会话不存在则创建会话, false 表示会话不存在就返回空
        HttpSession session = request.getSession(true);
        session.setAttribute("user",user);
        user.setPassword("");
        return user;
    }
}

在这里插入图片描述

用户登录后, 聊天界面会自动获取登录用户的好友列并展示

调用接口: friendList

// 处理好友信息
@Slf4j
@RestController
public class FriendAPI {
    @Resource
    private FriendMapper friendMapper;

    @RequestMapping("/friendList")
    public Object getFriendList(HttpServletRequest req) {
        // 1. 先从会话中, 获取到 userId
        HttpSession session = req.getSession(false);
        if(session == null) {
            log.info("[getFriendList] session 不存在");
            return new ArrayList<Friend>();
        }
        User user = (User) session.getAttribute("user");
        if(user == null) {
            log.info("[getFriendList] user 不存在");
            return new ArrayList<Friend>();
        }

        // 根据 userId 查询数据库
        List<Friend> list = friendMapper.selectFriendList(user.getUserId());
        return list;
    }
}

在这里插入图片描述

用户登录后, 聊天界面会自动获取登录用户的会话列并展示

调用接口: sessionList

@Slf4j
@RestController
public class MessageSessionAPI {
    @Resource
    private MessageSessionMapper messageSessionMapper;

    @Resource
    private MessageMapper messageMapper;


    /**
     * 获取登录用户 的 所有会话信息 (会话id, 最后一条信息)
     * @param req
     * @return
     */
    @RequestMapping("/sessionList")
    public Object getMessageSessionList(HttpServletRequest req) {
        List<MessageSession> messageSessionList = new ArrayList<>();
        // 1. 获取当前用户的 userId (从 Spring 的 session 中获取)
        HttpSession session = req.getSession(false);
        if(session == null) {
            log.info("[getMessageSessionList] session == null");
            return messageSessionList;
        }
        User user = (User) session.getAttribute("user");
        if(user == null) {
            log.info("[getMessageSessionList] user == null");
            return messageSessionList;
        }
        int userId = user.getUserId();

        // 2. 根据 userId 查询数据库, 查出包含该用户的 会话 id
        List<Integer> sessionIdList = messageSessionMapper.getSessionIdsByUserId(user.getUserId());

        //3. 遍历会话id, 查询出每个会话里涉及的好友有谁
        for(int sessionId : sessionIdList) {
            MessageSession messageSession = new MessageSession();
            messageSession.setSessionId(sessionId);
            // 查询每个会话涉及的好友有谁
            List<Friend> friends = messageSessionMapper.getFriendsBySessionId(sessionId, user.getUserId());
            messageSession.setFriends(friends);

            // 查询出每个会话的最后一条消息
            String lastMessage = messageMapper.getLastMessageBySessionId(sessionId);
            if (lastMessage == null) {
                lastMessage = "";
            }
            messageSession.setLastMessage(lastMessage);

            messageSessionList.add(messageSession);
        }
        // 最终目标是构造出一个 MessageSession 对象数组
        return messageSessionList;
    }
}

在这里插入图片描述

好友列表中, 点击某一个好友之后, 会在会话列创建出一个新会话

调用接口: session

@Slf4j
@RestController
public class MessageSessionAPI {
    @Resource
    private MessageSessionMapper messageSessionMapper;

    @Resource
    private MessageMapper messageMapper;

    /**
     * 创建会话, 并给会话表中插入两条信息 -- 我和好友绑定的会话信息
     * @param toUserId 好友id
     * @param user 登录用户信息
     * @return
     */
    @Transactional
    @RequestMapping("/session")
    public Object addMessageSession(int toUserId, @SessionAttribute("user") User user) {
        Map<String, Integer> resp = new HashMap<>();

        // 先给 message_session 表插入数据, 获取 messageId , messageId 放在 MessionSession 对象里
        MessageSession messageSession = new MessageSession();
        messageSessionMapper.addMessageSession(messageSession); //通过先插入一个空的 messageSession, 可以获取自增主键 messionId

        // 往 message_session_user 表里插入数据 -- 自己
        MessageSessionUserItem item1 = new MessageSessionUserItem(messageSession.getSessionId(), user.getUserId());
        messageSessionMapper.addMessageSessionUser(item1);
        // 往 message_session_user 表里插入数据 -- 好友
        MessageSessionUserItem item2 = new MessageSessionUserItem(messageSession.getSessionId(), toUserId);
        messageSessionMapper.addMessageSessionUser(item2);

        resp.put("sessionId", messageSession.getSessionId());
        // JSON 对于普通对象和 Map 都能处理
//        return messageSession;
        return resp;
    }
}

在这里插入图片描述

会话列表中, 点击某一个会话之后, 右侧消息栏会显示出该会话的最近100条消息

调用接口: message

@RestController
public class MessageAPI {
    @Resource
    private MessageMapper messageMapper;

    @RequestMapping("/message")
    public Object getMessage(int sessionId) {
        List<Message> messages = messageMapper.getMessagesBySessionId(sessionId);
        // 针对查询结果, 进行逆置操作
        Collections.reverse(messages);
        return messages;
    }
}

在这里插入图片描述

编辑消息后, 点击发送按钮会发送消息到对应会话, 该会话的所有用户的消息列表中都会出现新的消息

这里应用的 WebSocket 技术, handleTextMessage 方法能够感知到消息发送, 并获取消息信息进行处理

@Slf4j
@Component
public class WebSocketAPI extends TextWebSocketHandler {
    @Autowired
    private OnlineUserMapper onlineUserMapper;

    @Autowired
    private MessageSessionMapper messageSessionMapper;

    @Autowired
    private MessageMapper messageMapper;

    // 自己创建对象也行, 使用 @Autowired 注入也行, spring 本身就有内置对象 ObjectMapper
    private ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 数据处理
     * @param session
     * @param message
     * @throws Exception
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        log.info("[WebSocketAPI] 收到消息! " + message.toString());
        // 先获取到当前用户的信息, 后续要转发的消息等
        User user = (User) session.getAttributes().get("user");
        if(user == null){
            log.info("[WebSocketAPI] user == null, 未登录用户, 无法进行消息转发");
            return;
        }

        // 针对请求进行解析, 把 json 格式字符串转换成 Java 对象
        MessageRequest req = objectMapper.readValue(message.getPayload(), MessageRequest.class);
        if("message".equals(req.getType())) {
            // 进行消息转发
            transferMessage(user, req);
        }else {
            log.info("[WebSocketAPI] req.type 有误! {}", message.getPayload());
        }
    }

    /**
     * 通过该方法来完成消息的实际转发过程
     * @param user 发送消息的对象
     * @param req 内含 sessionId, content
     */
    private void transferMessage(User user, MessageRequest req) throws IOException {
        // 先构造一个待转发的响应对象. MessageResponse
        MessageResponse resp = new MessageResponse(user.getUserId(), user.getUsername(), req.getSessionId(), req.getContent());

        // 把这个响应对象转换成 JSON 格式字符串,以待备用
        String respJson = objectMapper.writeValueAsString(resp);
        log.info("[transferMessage] respJson: {}", respJson);

        // 根据请求中的 sessionId, 获取到 MessageSession 里有哪些用户 (查询数据库)
        List<Friend> friends =  messageSessionMapper.getFriendsBySessionId(req.getSessionId(), user.getUserId());
        // 此处响应返回的对象中, 应该包含发送方
        Friend myself = new Friend(user.getUserId(), user.getUsername());
        friends.add(myself);

        // 循环遍历 friends, 给其中每一个对象都发送一份响应
        //   这里是为了满足群聊的设定(即使前端还未实现,但是后端接口和数据库都是支持群聊的)
        for(Friend friend : friends) {
            // 已知 userId, 进一步查询 OnlineUserMapper, 获取对应的 WebSocketSession, 从而进行消息转发
            WebSocketSession webSocketSession = onlineUserMapper.getSession(friend.getFriendId());
            if(webSocketSession != null) {
                webSocketSession.sendMessage(new TextMessage(respJson));
            }
        }

        // 转发的消息还要在数据库备份
        Message message = new Message(user.getUserId(), user.getUsername(), req.getSessionId(), resp.getContent());
        // 自增主键为 null或为空, 数据库会自动生成
        messageMapper.add(message);
    }
}

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/656045.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

数据结构(七)递归、快速排序

文章目录 一、递归&#xff08;一&#xff09;使用递归实现1~n求和1. 代码实现&#xff1a;2. 调用过程&#xff1a;3. 输出结果&#xff1a; &#xff08;二&#xff09;青蛙跳台阶问题1. 问题分析2. 代码实现3. 输出结果4. 代码效率优化5. 优化后的输出结果 二、快速排序&…

MySQL进阶之(九)数据库的设计规范

九、数据库的设计规范 9.1 范式的概念9.1.1 范式概述9.1.2 键和相关属性 9.2 常见的范式9.2.1 第一范式9.2.2 第二范式9.2.3 第三范式9.2.4 第四范式9.2.5 第五范式&#xff08;域键范式&#xff09; 9.3 反范式化9.3.1 概述9.3.2 举例9.3.3 反范式化新问题9.3.4 通用场景 9.4 …

互联网的利

在互联网没发明之前&#xff0c;人类说话要近距离的说&#xff0c;玩游戏要近距离的玩&#xff0c;十分麻烦。于是&#xff0c;互联网解决了这个问题。聊天可以在电脑上聊&#xff0c;玩游戏可以用游戏软件查找玩家来玩&#xff0c;实现了时时可聊&#xff0c;时时可玩的生活。…

K210 数字识别 笔记

一、烧写固件 连接k210开发板&#xff0c;点开烧录固件工具&#xff0c;选中固件&#xff0c;并下载 二、模型训练 网站&#xff1a;MaixHub 1、上传文件 2、开始标记数据 添加9个标签&#xff0c;命名为1~9&#xff0c;按键盘w开始标记&#xff0c;键盘D可以下一张图片&…

NLP技术发展和相关书籍分享

自然语言处理&#xff08;Natural Language Processing&#xff0c;NLP&#xff09;是计算机科学领域和人工智能领域的重要研究方向之一&#xff0c;旨在探索实现人与计算机之间用自然语言进行有效交流的理论与方法。它融合了语言学、计算机科学、机器学习、数学、认知心理学等…

装机必备——Bandizip7.33安装教程

装机必备——Bandizip7.33安装教程 软件下载 软件名称&#xff1a;Bandizip7.33 软件语言&#xff1a;简体中文 软件大小&#xff1a;8.42M 系统要求&#xff1a;Windows7或更高&#xff0c; 64位操作系统 硬件要求&#xff1a;CPU2GHz &#xff0c;RAM4G或更高 下载通道①迅…

Nature Communications 南京大学开发智能隐形眼镜用于人机交互

近日&#xff0c;南京大学的研究人员研制了一种微型、难以察觉且生物相容的智能隐形眼镜&#xff08;smart contact lenses &#xff0c;SCL&#xff09;&#xff0c;可用于原位眼球追踪和无线眼机交互。采用频率编码策略&#xff0c;无芯片、无电池的镜头成功地检测眼球运动和…

机器学习之聚类学习

聚类算法 概念 根据样本之间相似性&#xff0c;将样本划分到不同类别种&#xff0c;不同相似度计算方法&#xff0c;会得到不同聚类结果&#xff0c;常用相似度计算方法为&#xff1a;欧氏距离 目的是在没有先验知识情况下&#xff0c;自动发现数据集种内在结构和模式 无监督…

告别裸奔,聊聊主流消息队列的认证和鉴权!

大家好&#xff0c;我是君哥。 我们在使用消息队列时&#xff0c;经常关注的是消息队列收发消息的功能。但好多时候需要对客户端有一定的限制&#xff0c;比如只有持有令牌的客户端才能访问集权&#xff0c;不允许 Producer 发送消息到某一个 Topic&#xff0c;或者某一个 Top…

Spring源码编译常见问题解决方案

Spring源码编译常见问题 gradle下载太慢 使用镜像下载。 在gradle-wrappert.prtopertties文件中&#xff0c;将distributionUrl的值修改为镜像地址&#xff0c;这里使用了腾讯的gtrale镜像。 distributionUrlhttps\://mirrors.cloud.tencent.com/gradle/gradle-7.5.1-bin.zi…

H4022 12V24V36V40V4A同步降压芯片 Buck-DCDC 高效率95%

H4022 40V4A同步降压芯片是一款Buck-DCDC转换器&#xff0c;其高效率、高稳定性。以下是对该产品的详细分析&#xff1a; 一、产品优势 高效率&#xff1a;H4022的转换效率高达95%&#xff0c;这主要得益于其同步降压技术。同步降压技术相较于传统的异步降压技术&#xff0c;能…

区块链系统开发测试----链码部署开发、系统开发验证

一.检查配置环境 检查虚拟机环境&#xff0c;确保有正在运行的Hyperledger Fabric区块链&#xff0c;并且其中chaincode_basic、credit_chaincode链码可以正常调用 查看chaincode_basic、credit_chaincode链码调用 二.开发征信链码代码 基于现有征信链码&#xff0c;开发征信…

Debug-012-el-popover 使用 doClose() 关闭窗口不生效的处理方案

前言&#xff1a; 今天上午碰见一个非常奇怪的情况&#xff1a;一样的方法实现的功能&#xff0c;效果却不一样。 两个页面都是使用的doClose()去关闭的el-popover&#xff0c;其中有一个就是不生效&#xff0c;找不同找了半天&#xff0c;始终不得其解。请看效果吧&#xff1…

百度页面奔跑的白熊html、css

一、相关知识-动画 1.基本使用&#xff1a;先定义再调用 2. 调用动画 用keyframes定义动画&#xff08;类似定义类选择器&#xff09; keyframes动画名称{ 0%{ width:100px&#xff1b; } 100%{ width:200px; } } 使用动画 div { width:200px; height:200px; background-…

从华为云Redis到AWS ElastiCache的操作方法

越来越多企业选择出海&#xff0c;那么就涉及到IT系统的迁移&#xff0c;本文将详细介绍如何将华为云Redis顺利迁移到AWS ElastiCache的操作方法&#xff0c;九河云将为您介绍迁移步骤以帮助您顺利完成这一重要任务。 **1. 确定迁移计划** 在开始迁移之前&#xff0c;首先要制…

身为UI设计老鸟,不学点3D,好像要被潮流抛弃啦,卷起来吧。

当前3D原则在UI设计中运用的越来越多&#xff0c;在UI设计中&#xff0c;使用3D元素可以为界面带来以下几个价值&#xff1a; 增强视觉冲击力&#xff1a;3D元素可以通过立体感和逼真的效果&#xff0c;为界面增添视觉冲击力&#xff0c;使得设计更加生动、吸引人&#xff0c;并…

在VS Code中进行Java的单元测试

在VS Code中可以使用 Test Runner for Java扩展进行Java的测试执行和调试。 Test Runner for Java的功能 Test Runner for Java 结合 Language Support for Java by Red Hat 和 Debugger for Java这两个插件提供如下功能&#xff1a; 运行测试&#xff1a; Test Runner for …

protobuf —— 快速上手

protobuf —— 快速上手 创建 .proto 文件添加注释指定proto3语法package 声明符定义消息&#xff08;message&#xff09; 定义消息字段字段定义基本格式字段名称命名规范字段类型字段唯一编号示例 转换关系示例&#xff1a;增加姓名和年龄字段 字段唯一编号字段编号范围编码效…

短视频真人配音:成都科成博通文化传媒公司

短视频真人配音&#xff1a;情感传递的新维度 随着数字化媒体的飞速发展&#xff0c;短视频已经成为人们日常生活中不可或缺的一部分。而在这个视觉盛宴的时代&#xff0c;真人配音的加入为短视频注入了新的活力&#xff0c;不仅丰富了内容形式&#xff0c;更使得情感传递达到…

Oracle EBS API创建AP发票报错:ZX_TAX_STATUS_NOT_EFFECTIVE和ZX_REGIME_NOT_EFF_IN_SUBSCR-

背景 由创建国外业务实体财务未能提供具体国家地区会计税制&#xff0c;而是实施人员随便选择其它国外国家地区会计税制。导致客户化创建AP发票程序报错&#xff1a;UNEXPECTED TAX ERROR-导入时出现意外的税务错误ZX_TAX_STATUS_NOT_EFFECTIVE-ZX_REGIME_NOT_EFF_IN_SUBSCR-ZX…