当前位置: 首页 > news >正文

Java+uniapp+websocket实现实时聊天,并保存聊天记录

   序言

一些社交类app移动开发中,经常会用到实时在线聊天的功能,本文通过Java+uniapp+websocket实现实时聊天,并保存聊天记录。

 一、数据库设计

DROP TABLE IF EXISTS `message`;
CREATE TABLE `message`  (`message_id` bigint(255) NOT NULL AUTO_INCREMENT,`session_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '会话id',`from_user_id` bigint(255) NULL DEFAULT NULL COMMENT '聊天发起者id',`to_user_id` bigint(255) NULL DEFAULT NULL COMMENT '目标用户id',`message` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '消息内容',`create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',`create_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,`update_by` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,PRIMARY KEY (`message_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 255 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

  如图,基于mysql数据库,表结构就是这么设计的,主键id自增,保存发信人id和收信人id,消息,创建时间。

二、java后端设计

后端负责实现websocket功能,session初始化,为每个用户建立websocket通道,处理消息。

userId参数,前端拼接发信人id加“-”收信人id,传到后端,后端通过split方法分割开来处理。

WebSocketConfig.java类如下:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;@Configuration // 这个类为配置类,Spring 将扫描这个类中定义的 Beans
public class WebSocketConfig {/*** serverEndpointExporter 方法的作用是将 ServerEndpointExporter 注册为一个 Bean,* 这个 Bean 负责自动检测带有 @ServerEndpoint 注解的类,并将它们注册为 WebSocket 服务器端点,* 这样,这些端点就可以接收和处理 WebSocket 请求**/@Bean // 这个方法返回的对象应该被注册为一个 Bean 在 Spring 应用上下文中public ServerEndpointExporter serverEndpointExporter() {// 创建并返回 ServerEndpointExporter 的实例,其中ServerEndpointExporter 是用来处理 WebSocket 连接的关键组件return new ServerEndpointExporter();}}

WebSocketServer.java处理类:

import cn.hutool.json.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.hunjie.entity.Message;
import com.pig4cloud.pigx.hunjie.mapper.MessageMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;//为 ConcurrentHashMap<String,WebSocketServer> 加入一个会话userId
@ServerEndpoint("/chatWebSocket/{userId}")
@Component
@Slf4j
public class WebSocketServer {private static MessageMapper messageMapper;@Resourcepublic void setMessageMapper(MessageMapper messageMapper) {WebSocketServer.messageMapper = messageMapper;}/***  [关于@OnOpen、@OnMessage、@OnClose、@OnError 中 Session session 的用意]**  Session session: 主要用于代表一个单独的 WebSocket 连接会话.每当一个 WebSocket 客户端与服务器端点建立连接时,都会创建一个新的 Session 实例*      标识连接:每个 Session 对象都有一个唯一的 ID,可以用来识别和跟踪每个单独的连接。   ——>     可以使用 session.getId() 方法来获取这个 ID.对于日志记录、跟踪用户会话等方面非常有用。*      管理连接:可以通过 Session 对象来管理对应的 WebSocket 连接,例如发送消息给客户端、关闭连接等    ——>     session.getBasicRemote().sendText(message) 同步地发送文本消息,*                                                                                                 或者使用 session.getAsyncRemote().sendText(message) 异步地发送.可以调用 session.close() 来关闭 WebSocket 连接。*      获取连接信息:Session 对象提供了方法来获取连接的详细信息,比如连接的 URI、用户属性等。    ——>     可以使用 session.getRequestURI() 获取请求的 URI* **///存储所有用户会话//ConcurrentHashMap<String,WebSocketServer> 中String 键(String类型)通常是用户ID或其他唯一标识符。允许服务器通过这个唯一标识符快速定位到对应的 WebSocketServer 实例,从而进行消息发送、接收或其他与特定客户端相关的操作//ConcurrentHashMap<String,WebSocketServer> 中为什么写 WebSocketServer 而不是其他,因为 WebSocketServer 作为一个实例,用于存储每个客户端连接。//所以在接下来@Onopen等使用中,当使用 ConcurrentHashMap<String,WebSocketServer> 时候,就不能单独使用 session, 需要添加一个诸如 userId 这样的会话来作为键。private static ConcurrentHashMap<String,WebSocketServer> webSocketMap = new ConcurrentHashMap<>();private Session session;private String fromUserId = "";private String toUserId="";private String userId="";//建立连接时@OnOpen//获取会话userId//@PathParam: 是Java JAX-RS API(Java API for RESTful Web Services)的一部分,用于WebSocket和RESTful Web服务. 在WebSocket服务器端,@PathParam 注解用于提取客户端连接URL中的参数值。public void onOpen(Session session, @PathParam("userId") String userId){this.session = session;             //当前WebSocket连接的 Session 对象存储在 WebSocketServer 实例 【这样做是为了在后续的通信过程中(例如在处理消息、关闭连接时),您可以使用 this.session 来引用当前连接的 Session 对象。】this.fromUserId= userId.split("-")[0];this.toUserId = userId.split("-")[1];this.userId = userId;//存储前端传来的 userId;webSocketMap.put(userId,this);      //WebSocketServer 实例与用户userId关联,并将这个关联存储在 webSocketMap 中。【其中this: 指的是当前的 WebSocketServer 实例】log.info("会话id:" + session.getId() + "对应的会话用户userId:" + userId + "【进行链接】");log.info("【websocket消息】有新的连接, 总数:{}", webSocketMap.size());System.out.println("会话id:" + session.getId() +   " 对应的会话用户userId:" + userId + " 【进行链接】");System.out.println("【websocket消息】有新的连接, 总数: "+webSocketMap.size());}//接收客户端消息@OnMessagepublic void onMessage(String message,Session session) throws IOException {//当从客户端接收到消息时调用log.info("onMessage会话id"+ session.getId() +"对应的会话用户:" + userId + "的消息:" + message);System.out.println("onMessage会话id: "+ session.getId()  +" 对应的会话用户:" + userId + " 的消息: " + message);//当从客户端接收到消息时调用log.info("onMessage会话id:" + session.getId() + ": 的消息" + message);
//        session.getBasicRemote().sendText("回应" + "[" + message + "]");JSONObject obj = new JSONObject();obj.put("fromUserId", fromUserId);obj.put("toUserId", toUserId);obj.put("userId", userId);obj.put("message", message);//历史聊天记录if(null != fromUserId && null != toUserId){obj.put("histroyMessage", messageMapper.selectList(new QueryWrapper<Message>().eq("from_user_id", fromUserId).eq("to_user_id", toUserId)));}// 封装成 JSON (Java对象转换成JSON格式的字符串。)
//        String json = new ObjectMapper().writeValueAsString(obj);
//        session.getBasicRemote().sendText(json);//修改 onMessage 方法来实现广播: 当服务器接收到消息时,不是只发送给消息的发送者,而是广播给所有连接的客户端。 ——> (实现群聊)if(message != null && !message.isEmpty()){// 封装成 JSON (Java对象转换成JSON格式的字符串。)String json = new ObjectMapper().writeValueAsString(obj);for(WebSocketServer client :webSocketMap.values()){client.session.getBasicRemote().sendText(json);}}//保存到数据库message表Message messageC = new Message();messageC.setSessionId(session.getId());if(null != fromUserId && fromUserId != "undefined"){messageC.setFromUserId(fromUserId);}if(null != toUserId && toUserId != "undefined"){messageC.setToUserId(toUserId);}messageC.setMessage(message);if(null != messageMapper){messageMapper.insertMessage(messageC);}log.info("保存到数据库message表成功");System.out.println("保存到数据库message表成功");}//链接关闭时@OnClosepublic void onClose(Session session){//关闭浏览器时清除存储在 webSocketMap 中的会话对象。webSocketMap.remove(userId);log.info("会话id:" + session.getId() + "对应的会话用户:" + userId + "【退出链接】");log.info("【websocket消息】有新的连接, 总数:{}", webSocketMap.size());System.out.println("会话id:" + session.getId() + " 对应的会话用户:" + userId + " 【退出链接】");System.out.println("【websocket消息】有新的连接, 总数: "+ webSocketMap.size());}//链接出错时@OnErrorpublic void onError(Session session,Throwable throwable){//错误提示log.error("出错原因 " + throwable.getMessage());System.out.println("出错原因 " + throwable.getMessage());//抛出异常throwable.printStackTrace();}
}

消息记录接口:

三、前端uniapp

dialogBox.vue

<template><div class="iChat"><div class="container"><uni-nav-bar shadow left-icon="left" leftText="返回" @clickLeft="back"/><div class="content"><div class="item item-center"><span>{{ currentTime }}</span></div><!--历史记录 --><div v-for="(item1, index1) in histroyMessage" :key="item1.messageId"><div class="item" :class="{'item-right': isHistroyCurrentUser(item1), 'item-left': !isHistroyCurrentUser(item1)}"><!-- 右结构 历史记录 --><div v-if="isHistroyCurrentUser(item1)" style="display: flex; flex-direction: column; align-items: flex-end;"><div class="username">{{ currentNameRight }}</div><div style="display: flex"><div class="bubble bubble-right">{{ item1.message }}</div><div class="avatar"><img :src="currentAvatarRight" /></div></div></div><!-- 左结构 历史记录 --><div v-else style="display: flex; flex-direction: column; align-items: flex-start;"><div class="username">{{ currentNameLeft }}</div><div style="display: flex"><div class="avatar"><img :src="currentAvatarLeft" /></div><div class="bubble bubble-left">{{ item1.message }}</div></div></div></div></div><divclass="item"v-for="(item, index) in receivedMessage":key="index":class="{'item-right': isCurrentUser(item), 'item-left': !isCurrentUser(item)}"><!-- 右结构 实时消息 --><div v-if="isCurrentUser(item)" style="display: flex; flex-direction: column; align-items: flex-end;"><div class="username">{{ currentNameRight }}</div><div style="display: flex"><div class="bubble" :class="{'bubble-right': isCurrentUser(item), 'bubble-left': !isCurrentUser(item)}">{{ getMessageContent(item) }}</div><div class="avatar"><img :src="currentAvatarRight" /></div></div></div><!-- 左结构 实时消息--><div v-else style="display: flex; flex-direction: column; align-items: flex-start;"><div class="username">{{ currentNameLeft }}</div><div style="display: flex"><div class="avatar"><img :src="currentAvatarLeft" /></div><div class="bubble" :class="{'bubble-right': isCurrentUser(item), 'bubble-left': !isCurrentUser(item)}">{{ getMessageContent(item) }}</div></div></div></div></div><div class="input-area"><textarea v-model="message" id="textarea"></textarea><div class="button-area"><button id="send-btn" @click="sendMessage">发 送</button></div></div></div></div>
</template><script>
export default {data() {return {ws: null,message: '',receivedMessage: [],histroyMessage: [],fromUserId:'',toUserId:'',currentUserId: '',currentNameLeft: '',currentAvatarLeft: '',currentNameRight: uni.getStorageSync('name') ||'',currentAvatarRight: uni.getStorageSync('avatar') ||'',src1: '../../static/imgs/shuiping.png',src2: '../../static/imgs/jinniu.png',currentTime: '', // 新增当前时间变量timer: null // 新增定时器变量// currentUserId: "用户" + Math.floor(Math.random() * 1000)};},computed: {userName() {return uni.getStorageSync('name') || '昵称';}},mounted() {// this.initWebSocket(); // 这里不要再调用this.updateTime(); // 初始化时间this.timer = setInterval(this.updateTime, 120000); // 每2分钟更新一次},beforeDestroy() {if (this.timer) {clearInterval(this.timer);this.timer = null;}},onUnload() {if (this.timer) {clearInterval(this.timer);this.timer = null;}},onLoad(options) {console.log('详情options:',options)this.fromUserId = uni.getStorageSync('userId');this.currentUserId = options.userId;this.toUserId = options.userId;this.currentNameLeft = options.name;this.currentAvatarLeft = options.avatar;this.initWebSocket(); // 赋值后再初始化this.updateAvatar(); // 更新头像this.getHistroys();},onShow() {},methods: {// 获取消息内容的方法 getMessageContent(item) {// 只返回message字段的内容return item.message;},//查询历史聊天记录getHistroys(){const token = uni.getStorageSync('token');const fromUserId = uni.getStorageSync('userId')console.log('fromUserId:::',fromUserId)const toUserId = this.currentUserId;console.log('toUserId:::',toUserId)uni.request({url: '/api/jsonflow/message/selectMessages',method: 'GET',header: {'Authorization': `Bearer ${token}`},data: {fromUserId,toUserId},success: (res) => {console.log("res.data",res.data)if (res.data.code === 0) {const histroyMessage = res.data.data.map(item => ({message: item.message,messageId: item.messageId,fromUserId: item.fromUserId,toUserId: item.toUserId,createTime: item.createTime,sessionId: item.sessionId}));this.histroyMessage = histroyMessage;}}});	},	getHistroyMessage(item) {this.histroyMessage = item.histroyMessage;return this.histroyMessage;},back() {uni.navigateBack({delta: 1})},updateAvatar() {const cachedAvatar = uni.getStorageSync('avatar');if (cachedAvatar) {this.src1 = cachedAvatar;}},initWebSocket() {const userId = this.currentUserId;console.log('userId:::',userId)const fromUserId = uni.getStorageSync('userId')console.log('fromUserId:::',fromUserId)this.ws = new WebSocket('ws://192.168.1.107:8000/jsonflow/chatWebSocket/' + fromUserId +'-' + userId);// this.ws = new WebSocket('ws://192.168.1.107:8000/jsonflow/chatWebSocket/' + userId);console.log('ws:',this.ws)this.ws.onopen = () => {console.log("websocket已打开");// alert("WebSocket 连接成功!");};this.ws.onmessage = (event) => {console.log("event:::",event);this.receivedMessage.push(JSON.parse(event.data));};this.ws.onclose = () => {console.log("websocket已关闭,尝试重连");setTimeout(() => {this.initWebSocket();}, 2000);};this.ws.onerror = () => {console.log("websocket发生了错误");};},sendMessage() {// console.log('this.message:',this.message)if (this.ws && this.ws.readyState === WebSocket.OPEN) {this.ws.send(this.message);this.message = '';} else if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {alert('WebSocket正在连接中,请稍后再试');} else {alert('WebSocket未连接');}},isCurrentUser(item) {console.log('item:',item)console.log('userId666:', this.fromUserId + '-' +this.currentUserId)return item.userId === this.fromUserId + '-' +this.currentUserId;},checkWS() {if (this.ws && this.ws.readyState === 1) {alert('WebSocket 已连接');} else {alert('WebSocket 未连接');}},updateTime() {const now = new Date();const hours = now.getHours().toString().padStart(2, '0');const minutes = now.getMinutes().toString().padStart(2, '0');this.currentTime = `${hours}:${minutes}`;},isHistroyCurrentUser(item) {return item.fromUserId === this.fromUserId;}}
};
</script><style lang="scss" scoped>
.iChat {width: 100%;height: 100%;min-height: 100vh;background: #f5f5f5;display: flex;justify-content: center;align-items: flex-end;overflow: hidden;.container {width: 100%;max-width: 600px;height: 100vh;min-height: 100vh;display: flex;flex-direction: column;justify-content: flex-end;background: #fff;box-shadow: 0 0 10px rgba(0,0,0,0.05);overflow: hidden;}.content {flex: 1 1 auto;overflow-y: auto;padding: 1rem 0.8rem 0.5rem 0.8rem;display: flex;flex-direction: column;gap: 0.8rem;min-width: 0;}.item {display: flex;align-items: flex-end;margin-bottom: 0.5rem;font-size: 1rem;min-width: 0;&.item-center {justify-content: center;color: #aaa;font-size: 0.85rem;margin: 0.5rem 0;}&.item-right {justify-content: flex-end;}&.item-left {justify-content: flex-start;}}.username {font-size: 0.8rem;color: #666;margin-bottom: 0.3rem;font-weight: 500;}.bubble {max-width: calc(100vw - 100px);padding: 0.7rem 1rem;border-radius: 1.2rem;font-size: 1rem;word-break: break-all;line-height: 1.5;background: #f0f0f0;&.bubble-right {background: #aee1f9;color: #222;border-bottom-right-radius: 0.3rem;}&.bubble-left {background: #ececec;color: #222;border-bottom-left-radius: 0.3rem;}}.avatar {width: 2.2rem;height: 2.2rem;border-radius: 50%;overflow: hidden;margin: 0 0.5rem;display: flex;flex-direction: column;align-items: center;font-size: 0.75rem;color: #888;img {width: 2.2rem;height: 2.2rem;border-radius: 50%;object-fit: cover;margin-bottom: 0.2rem;}}.input-area {width: 100vw;max-width: 100vw;box-sizing: border-box;padding: 0.7rem 0.8rem;background: #fafafa;border-top: 1px solid #eee;display: flex;flex-direction: column;gap: 0.5rem;textarea {width: 100%;min-height: 2.5rem;max-height: 6rem;resize: none;border: 1px solid #ddd;border-radius: 0.7rem;padding: 0.6rem 0.8rem;font-size: 1rem;background: #fff;outline: none;box-sizing: border-box;}.button-area {display: flex;justify-content: flex-end;button {padding: 0.5rem 1.2rem;font-size: 1rem;border: none;border-radius: 1.2rem;background: #4fc3f7;color: #fff;cursor: pointer;transition: background 0.2s;&:active {background: #0288d1;}}}}
}// 响应式优化
@media (max-width: 400px) {.iChat .bubble {max-width: calc(100vw - 40px);font-size: 0.95rem;padding: 0.5rem 0.7rem;}.iChat .avatar {width: 1.7rem;height: 1.7rem;font-size: 0.65rem;img {width: 1.7rem;height: 1.7rem;}}.iChat .input-area textarea {font-size: 0.95rem;padding: 0.4rem 0.5rem;}.iChat .input-area .button-area button {font-size: 0.95rem;padding: 0.4rem 0.8rem;}
}
</style>

最终效果:

http://www.lryc.cn/news/613953.html

相关文章:

  • mac笔记本如何重新设置ssh key
  • React Hooks 完全指南:从概念到内置 Hooks 全解析
  • 五种IO模型与⾮阻塞IO
  • leetcode1456:定长子串中元音的最大数目(定长滑动窗口)
  • 云平台运维工具 —— 阿里云原生工具
  • 云原生时代的 Linux:容器、虚拟化与分布式的基石
  • react的form.resetFields()
  • 人工智能之数学基础:事件独立性
  • Java中重写和重载有哪些区别
  • MySQL vs PostgreSQL 深度对比:为你的新项目选择正确的开源数据库 (2025)
  • LVS高可靠
  • Java-注解
  • Azure OpenAI gpt5和AWS Secrets Manager构建智能对话系统
  • Windows10中wls2因网络问题无法拉取Docker/Podman容器镜像
  • mysql复制连接下的所有表+一次性拷贝到自己的库
  • 深入解析C++流运算符(>>和<<)重载:为何必须使用全局函数与友元机制
  • 专利服务系统平台|个人专利服务系统|基于java和小程序的专利服务系统设计与实现(源码+数据库+文档)
  • 基于Flask + Vue3 的新闻数据分析平台源代码+数据库+使用说明,爬取今日头条新闻数据,采集与清洗、数据分析、建立数据模型、数据可视化
  • 在 Debian 系统上安装 Redis服务
  • 驾驭数据库迁移:在 Django 与 Flask 中的全流程实战指南
  • Spark01-初识Spark
  • 柠檬笔试——野猪骑士
  • apache cgi测试
  • Docker容器部署前端Vue服务
  • Spring Boot + Angular 实现安全登录注册系统:全栈开发指南
  • 【AI】从零开始的文本分类模型实战:从数据到部署的全流程指南
  • BBH详解:面向大模型的高阶推理评估基准与数据集分析
  • C++信息学奥赛一本通-第一部分-基础一-第3章-第1节
  • 支持向量机(SVM)全解析:原理、类别与实践
  • MySQL数据库操作练习