聊聊(chatchat)基于SpringBoot的多人在线聊天室

一、项目简介

1.功能介绍

本项目实现了一个多人在线聊天室,拥有以下功能。

  • 支持用户账号密码登录、注册。
  • 实时发起群聊或单独聊天。
  • 实时接收单独聊天消息与群聊消息。
  • 聊天记录存储于数据库,可查看历史消息,翻阅历史记录。
  • 消息接收提醒。

2.技术简介

本项目采用前后端分离架构。

前端基于Vue3框架,使用Element-Plus组件库、Axios、vue-router、pinia等技术开发。

后端基于Spring Boot 3.1.4框架,采用Mybatis-Plus作为持久层框架,采用Spring Security管理页面权限访问,采用Spring websocket实现实时聊天功能,并引入lombok、fastjson2、spring validation等开发工具。

3.代码仓库地址

zhiy yan/chatchat (gitee.com)

二、后端设计与实现

1.项目架构

项目架构如下所示。对各包名进行简要说明。

  • config:配置类包
  • controller:控制层
    • exception:异常控制类
  • entity:实体类包
    • dto:数据库访问类实体包
    • vo:视图对象实例类包。
    • RestBean:前端reponse Json封装类
  • filter:过滤器类包
  • mapper:数据库Mapper类包
  • service:服务层
    • impl:服务层实体类包
  • socket:WebSocket管理类包
  • utils:工具包
    在这里插入图片描述

2.数据库架构

根据需求,建立两张表,一张为账户表,存储注册用户的账户信息,另一张为聊天信息存储表,存储发送的聊天信息。所建表如下所示。

-- 创建库
create database if not exists chatchat;

-- 切换库
use chat;

-- 创建用户表
drop table if exists db_account;
create table db_account
(
    id int auto_increment comment 'id' primary key,
    username varchar(128) not null comment '用户名',
    password varchar(128) not null comment '密码',
    email varchar(128) null comment '电子邮箱',
    avatar varchar(512) null comment '用户链接',
	role varchar(128) not null comment '用户权限',
    register_time datetime default CURRENT_TIMESTAMP not null comment '注册时间'
) comment '账户表' collate = utf8mb4_unicode_ci;

-- 聊天记录表
drop table if exists db_message;
create table db_message
(
    id int auto_increment comment 'id' primary key,
    send_id int not null comment '发送者id',
    to_id int not null comment '发送到id,0表示群发',
    message varchar(512) not null comment '聊天信息'
) comment '聊天记录' collate = utf8mb4_unicode_ci;

3.项目依赖

本项目使用maven架构进行项目管理。所用到的依赖如下所示。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>chatchat-backend</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>chatchat-backend</name>
    <description>chatchat-backend</description>
    <properties>
        <java.version>21</java.version>
    </properties>
    <dependencies>
        <!--spring security-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!--spring web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--mysql jdbc-->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!--spring  JUnit-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!--spring  security JUnit-->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!--fastjson2-->
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.37</version>
        </dependency>
        <!--Mybatis-Plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.2</version>
        </dependency>
        <!--spring  validation-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!--spring  websocket-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

4.全局配置

主要是配置Spring security,控制前端对业务逻辑接口的访问。

@Configuration
public class SecurityConfiguration {
    @Resource
    AccountService accountService;
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .authorizeHttpRequests(conf -> conf
                        .requestMatchers("/api/auth/**","/error", "/websocket/**").permitAll()
                        .anyRequest().authenticated())
                .formLogin(conf->conf
                        .loginProcessingUrl("/api/auth/login")
                        .successHandler(this::onAuthenticationSuccess)
                        .failureHandler(this::onAuthenticationFailure)
                )
                .logout(conf->conf
                        .logoutUrl("/api/auth/logout")
                        .logoutSuccessHandler(this::onLogoutSuccess)
                )
                .exceptionHandling(conf -> conf
                        .authenticationEntryPoint(this::onUnauthorized)
                )
                .csrf(AbstractHttpConfigurer::disable)
                .build();
    }
    /*登录成功返回用户信息给客户端*/
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        User user=(User) authentication.getPrincipal();
        Account account = accountService.findAccountByUsername(user.getUsername());
        AccountVO vo = new AccountVO(account.getId(),user.getUsername(),account.getRole(), account.getAvatar(), account.getRegisterTime());
        response.getWriter().println(RestBean.success(vo).asJSONString());
    }
    /*登录失败返回失败信息*/
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException{
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().println(RestBean.unAuthorized(exception.getMessage()).asJSONString());
    }
    /*退出登录成功*/
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

    }
    /*未认证访问其他页面返回失败信息*/
    public void onUnauthorized(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException{
        response.getWriter().write(RestBean.unAuthorized(exception.getMessage()).asJSONString());
    }
}

5.数据访问层设计

数据访问层主要实现对数据库的增删改查。本项目基于Mybatis-Plus框架实现数据访问层设计。为了使设计更加规范,将数据访问层service类都封装成接口。

  • db_account访问。

    • AccountMapper。

      @Mapper
      public interface AccountMapper extends BaseMapper<Account> {
      }
      
    • AccountService接口。

      public interface AccountService extends IService<Account>, UserDetailsService {
          public Account findAccountByUsername(String username);
          public String registerAccount(RegisterVO RegisterVO);
          public List<FriendsVO> findFriendsById(int id);
      }
      
    • AccountServiceImpl实现类。

      @Slf4j
      @Service
      public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> implements AccountService {
          @Resource
          PasswordEncoder encoder;
          @Resource
          MessageService messageService;
      
          /**
           * 实现UserDetailsService,实现用户登录
           * @param username 账户名
           * @return UserDetails
           * @throws UsernameNotFoundException 用户未找到异常
           */
          @Override
          public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
              Account account = findAccountByUsername(username);
              if(account == null)
                  throw new UsernameNotFoundException("用户名或密码错误");
              return User
                      .withUsername(username)
                      .password(account.getPassword())
                      .roles(account.getRole())
                      .build();
          }
      
          /**
           * 根据用户名查询对应账户
           * @param username 账户名
           * @return Account账户
           */
          @Override
          public Account findAccountByUsername(String username) {
              return this.query()
                      .eq("username", username)
                      .one();
          }
      
          /**
           * 账户注册
           * @param registerVO 注册实体类vo
           * @return success null
           */
          @Override
          public String registerAccount(RegisterVO registerVO) {
              String password = encoder.encode(registerVO.getPassword());
              Account account = new Account(null, registerVO.getUsername(), password, registerVO.getEmail(),
                      "user", null, new Date());
              if(this.save(account))
                  return null;
              return "内部错误,请联系管理员";
          }
      
          /**
           * 根据用户id查询好友列表
           * @param id 用户id
           * @return 好友列表
           */
          @Override
          public List<FriendsVO> findFriendsById(int id) {
              List<Account> accounts = this.query()
                      .ne("id", id)
                      .list();
              List<FriendsVO> friendsVOS = new ArrayList<>();
              for (Account account : accounts) {
                  String message = messageService.getLastMessageById(id, account.getId());
                  friendsVOS.add(new FriendsVO(account.getId(), account.getUsername(), account.getAvatar(), message));
              }
              return friendsVOS;
          }
      }
      
  • db_message访问。

    • MessageMapper。

      @Mapper
      public interface MessageMapper extends BaseMapper<Message> {
      }
      
    • MessageService接口。

      public interface MessageService extends IService<Message> {
          public String getLastMessageById(int userId, int sendId);
          public String saveMessageById(int id, int toId, String message);
          public List<MessageVO> getMessage(int id, int sid);
          public List<MessageVO> getAllMessage(int id);
      }
      
    • MessageServiceImpl实现类。

      @Service
      public class MessageServiceImpl extends ServiceImpl<MessageMapper, Message> implements MessageService {
          /**
           * 根据用户id与发送者id查询最新消息
           * @param userId 用户id
           * @param sendId 发送者id
           * @return 最新消息
           */
          @Override
          public String getLastMessageById(int userId, int sendId) {
              List<Message> messages =  this.query()
                      .eq("to_id", userId)
                      .eq("send_id", sendId)
                      .or(i -> i.eq("to_id", sendId).eq("send_id", userId))
                      .list();
              if (messages.isEmpty())
                  return null;
              return messages.get(messages.size() - 1).getMessage();
          }
      
          /**
           * 保存聊天信息
           * @param id 用户id
           * @param toId 去往id
           * @param message 聊天信息
           * @return success null
           */
          @Override
          public String saveMessageById(int id, int toId, String message) {
              if(!this.save(new Message(null, id, toId, message)))
                  return "信息保存失败";
              return null;
          }
      
          /**
           * 获取聊天信息
           * @param id 用户id
           * @param sid 发送者id
           * @return 聊天信息表
           */
          @Override
          public List<MessageVO> getMessage(int id, int sid) {
              List<Message> messages = this.query()
                      .eq("send_id", id)
                      .eq("to_id", sid)
                      .or(i -> i.eq("to_id", id).eq("send_id", sid))
                      .list();
              List<MessageVO> messageVOList = new ArrayList<>();
              for (Message message : messages) {
                  messageVOList.add(new MessageVO(message.getId(),message.getSendId(),message.getToId(),message.getMessage()));
              }
              return messageVOList;
          }
      
          /**
           * 获取群聊信息
           * @param id 用户id
           * @return 群聊信息
           */
          @Override
          public List<MessageVO> getAllMessage(int id) {
              List<Message> messages = this.query()
                      .eq("to_id", 0)
                      .list();
              List<MessageVO> messageVOList = new ArrayList<>();
              for (Message message : messages) {
                  messageVOList.add(new MessageVO(message.getId(),message.getSendId(),message.getToId(),message.getMessage()));
              }
              return messageVOList;
          }
      }
      

6.业务逻辑层

业务逻辑层是数据访问层与表示层的桥梁。本项目暴露出很多使用的接口供前端进行调用。

  • AccountController。主要是实现对好友列表的访问。

    @RestController
    @RequestMapping("/api/user")
    public class AccountController {
        @Resource
        AccountService accountService;
        @GetMapping("/friends")
        public RestBean<List<FriendsVO>> getFriend(@RequestParam int id){
            return RestBean.success(accountService.findFriendsById(id));
        }
    }
    
  • AuthorizeController。主要是实现用户注册功能。

    @Slf4j
    @RestController
    @RequestMapping("/api/auth")
    public class AuthorizeController {
        @Resource
        AccountService accountService;
        @PostMapping("/register")
        public RestBean<Void> register(@RequestBody RegisterVO registerVO){
            return this.messageHandle(registerVO,accountService::registerAccount);
        }
    
        private <T> RestBean<Void> messageHandle(T vo, Function<T,String> function){
            return messageHandle(()->function.apply(vo));
        }
        //Supplier<T>函数式接口
        private RestBean<Void> messageHandle(Supplier<String> action){
            String message= action.get();
            return message == null ? RestBean.success() : RestBean.failure(400,message);
        }
    }
    
  • MessageController。主要是实现聊天记录的发送,存储与获取。

    @Slf4j
    @RestController
    @RequestMapping(value = "/api/message")
    public class MessageController {
        @Resource
        private WebSocketServer webSocketServer;
        @Resource
        MessageService messageService;
        /*后端往前端发送数据*/
        @PostMapping("/sendAllMessage")
        public RestBean<Void> sendAllMessage(@RequestBody SendOneMessageVO vo){
            try {
                webSocketServer.sendAllMessage(vo.getMessage(), String.valueOf(vo.getSendId()));
                messageService.saveMessageById(vo.getSendId(),vo.getToId(),vo.getMessage());
            } catch (Exception e) {
                log.error("群发消息发送失败" + e.getMessage());
                return RestBean.failure(400,"发送消息失败");
            }
            return RestBean.success();
        }
    
        @PostMapping("/sendOneMessage")
        public RestBean<Void> sendOneMessage(@RequestBody SendOneMessageVO vo){
            try {
                webSocketServer.sendOneMessage(String.valueOf(vo.getToId()),vo.getMessage(), String.valueOf(vo.getSendId()));
                messageService.saveMessageById(vo.getSendId(),vo.getToId(),vo.getMessage());
            } catch (Exception e) {
                log.error("单点发送消息失败" + e.getMessage());
                return RestBean.failure(400, "发送消息失败");
            }
            return RestBean.success();
        }
        @GetMapping("/getMessage")
        public RestBean<List<MessageVO>> getMessage(@RequestParam int id, @RequestParam int sid){
            return RestBean.success(messageService.getMessage(id, sid));
        }
        @GetMapping("/getAllMessage")
        public RestBean<List<MessageVO>> getAllMessage(@RequestParam int id){
            return RestBean.success(messageService.getAllMessage(id));
        }
    }
    
  • ValidationController。实现对各类异常的封装处理。

    @Slf4j
    @RestControllerAdvice
    public class ValidationController {
        @ExceptionHandler(ValidationException.class)
        public RestBean<Void> validationException(ValidationException exception){
            log.warn("Resolve [{}: {}]",exception.getClass().getName(),exception.getMessage());
            return RestBean.failure(400,"请求参数有误");
        }
    }
    

7.Websocket

WebSocket 是一种网络通信协议,它提供了全双工通信通道,允许服务器和客户端之间进行双向通信。这种双向通信能够让服务器主动向客户端发送数据,而不需要客户端发起请求。

使用 WebSocket 的典型步骤如下:

  1. 客户端通过发送一个特殊的 HTTP 请求(称为 WebSocket 握手请求)来建立 WebSocket 连接。
  2. 服务器收到握手请求后,如果接受 WebSocket 连接,会发送一个握手响应。
  3. 握手成功后,WebSocket 连接就建立了,服务器和客户端就可以通过这个连接发送和接收数据。
  4. 当不再需要通信时,服务器或客户端可以选择关闭 WebSocket 连接。
  • Websocket实现类。参考自spring boot如何实现主动推送消息给前端 - 知乎 (zhihu.com)

    @Component
    @ServerEndpoint("/websocket/{userId}")
    @Slf4j
    public class WebSocketServer {
    
        private Logger logger = LoggerFactory.getLogger(WebSocketServer.class);
        /**
         * 与某个客户端的连接会话,需要通过它来给客户端发送数据
         */
        private Session session;
        /**
         * 用户id
         */
        private String userId;
        /**
         * 用来存放每个客户端对应的MyWebSocket对象
         */
        private static CopyOnWriteArraySet<WebSocketServer> webSockets = new CopyOnWriteArraySet<>();
        /**
         * 用来存在线连接用户信息
         */
        private static ConcurrentHashMap<String, Session> sessionPool = new ConcurrentHashMap<String, Session>();
    
        /**
         * 链接成功调用的方法
         */
        @OnOpen
        public void onOpen(Session session, @PathParam(value = "userId") String userId) {
            try {
                this.session = session;
                this.userId = userId;
                webSockets.add(this);
                sessionPool.put(userId, session);
                logger.info("【websocket消息】有新的连接,总数为:" + webSockets.size());
            } catch (Exception e) {
            }
        }
    
        /**
         * 链接关闭调用的方法
         */
        @OnClose
        public void onClose() {
            try {
                webSockets.remove(this);
                sessionPool.remove(this.userId);
                logger.info("【websocket消息】连接断开,总数为:" + webSockets.size());
            } catch (Exception e) {
            }
        }
    
        /**
         * 收到客户端消息后调用的方法
         */
        @OnMessage
        public void onMessage(String message) {
            logger.info("【websocket消息】收到客户端消息:" + message);
    
    
        }
    
        /**
         * 发送错误时的处理
         *
         * @param session
         * @param error
         */
        @OnError
        public void onError(Session session, Throwable error) {
            logger.error("用户错误,原因:" + error.getMessage());
            error.printStackTrace();
        }
    
        /**
         * 此为广播消息
         */
        public void sendAllMessage(String message, String sendId) {
            logger.info("【websocket消息】广播消息:" + message);
            for (WebSocketServer webSocket : webSockets) {
                try {
                    if (webSocket.session.isOpen()) {
                        webSocket.session.getAsyncRemote().sendText("0," + sendId +"," + message);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    
        /**
         * 此为单点消息
         */
        public void sendOneMessage(String userId, String message, String sendId) {
            Session session = sessionPool.get(userId);
            if (session != null && session.isOpen()) {
                try {
                    logger.info("【websocket消息】 单点消息:" + message);
                    session.getAsyncRemote().sendText(userId + "," + sendId + "," +message);
                } catch (Exception e) {
                    log.error("发送失败");
                    e.printStackTrace();
                }
            }
        }
    
        /**
         * 此为单点消息(多人)
         */
        public void sendMoreMessage(String[] userIds, String message) {
            for (String userId : userIds) {
                Session session = sessionPool.get(userId);
                if (session != null && session.isOpen()) {
                    try {
                        logger.info("【websocket消息】 单点消息:" + message);
                        session.getAsyncRemote().sendText(message);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
    
        }
    }
    

三、前端设计与实现

前端主要采用Vue3框架与Element-Plus组件库,使用轻量化存储组件pinia减少前端请求次数。

1.登录界面

登录界面由一个用户账户密码输入框组成。登录成功后会自动跳转到聊天室界面,若账户密码输入错误会弹出提示错误的消息框。若未登录直接访问聊天室页面会强制跳转至登录页面。

在这里插入图片描述

2.注册界面

点击登录界面的立即注册后,将会跳转至注册界面,可在此处注册新用户,注册成功后将自动跳转至登录界面。

在这里插入图片描述

3.聊天页面

聊天界面会显示群聊聊天室以及注册用户聊天室,可以在群聊聊天室内与注册用户一起聊天,同样也可以与注册用户单独聊天。
在这里插入图片描述

4. 群聊聊天室

在群聊聊天室发送消息,所有注册用户都会收到相应消息,并弹出新消息提示。

在这里插入图片描述

5.单独聊天室

在单独聊天室内可以与指定注册用户进行聊天,收到消息时会弹出新消息提示。

在这里插入图片描述

参考文章

  • 20
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值