微信聊天小程序-微聊(uniapp+springboot+wbsocket+rocketMq)

 一、ER设计

        涉及两个库:crm、base,其中crm里2张表(consumer、consumer_wx_info);base里3张表(chat_apply_record、chat_friend_role、chat_msg_record)

图:

详细表结构:

consumer  ---- 主要存用户注册信息

CREATE TABLE `consumer` (
  `c_id` int NOT NULL AUTO_INCREMENT COMMENT 'cid',
  `w_code` varchar(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '账号',
  `name` varchar(25) DEFAULT NULL COMMENT '姓名',
  `avatar_url` varchar(255) DEFAULT NULL COMMENT '头像',
  `sex` varchar(5) DEFAULT NULL COMMENT '性别',
  `birth` date DEFAULT NULL COMMENT '生日',
  `email` varchar(30) DEFAULT NULL COMMENT '邮箱',
  `phone` varchar(11) DEFAULT NULL COMMENT '手机',
  `address` varchar(255) DEFAULT NULL COMMENT '地址',
  `status` tinyint DEFAULT '1' COMMENT '状态',
  `w_code_update_time` date DEFAULT NULL COMMENT 'wCode更改时间',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `create_by` varchar(25) NOT NULL COMMENT '创建人',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `update_by` varchar(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '更新人',
  `dr` tinyint NOT NULL DEFAULT '0' COMMENT '删除',
  PRIMARY KEY (`c_id`),
  UNIQUE KEY `cid_cus_index` (`c_id`) USING BTREE,
  UNIQUE KEY `cus_wxCode_index` (`w_code`) USING BTREE,
  UNIQUE KEY `cus_phone_index` (`phone`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

consumer_wx_info  --主要存用户微信相关信息,从微信获取到的openid、用户信息等

CREATE TABLE `consumer_wx_info` (
  `u_id` int NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `c_id` int DEFAULT NULL COMMENT 'cid',
  `nick_name` varchar(50) DEFAULT NULL COMMENT '昵称',
  `avatar_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '头像',
  `openid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'openid',
  `unionid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'unionid',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `dr` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除状态',
  PRIMARY KEY (`u_id`) USING BTREE,
  UNIQUE KEY `openid_index` (`openid`) USING BTREE COMMENT 'openid索引',
  KEY `cid_key` (`c_id`),
  CONSTRAINT `cid_key` FOREIGN KEY (`c_id`) REFERENCES `consumer` (`c_id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

chat_apply_record -- 好友申请记录表,主要存好友申请记录

CREATE TABLE `chat_apply_record` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `apply_c_id` int NOT NULL COMMENT '申请人',
  `receive_c_id` int NOT NULL COMMENT '接收人',
  `info` text COMMENT '备注信息',
  `apply_status` int NOT NULL DEFAULT '0' COMMENT '是否同意(0-待审核,1-已同意,2-已过期)',
  `apply_time` datetime DEFAULT NULL COMMENT '同意时间',
  `create_time` datetime NOT NULL COMMENT '申请时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `cpr_applyId_receive_index` (`apply_c_id`,`receive_c_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

chat_friend_role -- 好友关系联系表

CREATE TABLE `chat_friend_role` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `c_id_a` int NOT NULL COMMENT '申请人用户ID',
  `remark_a` varchar(25) DEFAULT NULL COMMENT '申请人备注',
  `c_id_b` int NOT NULL COMMENT '接收人用户ID',
  `remark_b` varchar(25) DEFAULT NULL COMMENT '接收人备注',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '申请加为好友时间',
  `dr` int NOT NULL DEFAULT '0' COMMENT '删除',
  PRIMARY KEY (`id`),
  UNIQUE KEY `cfr_cid_index` (`c_id_a`,`c_id_b`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

chat_msg_record --聊天记录表,主要存聊天信息

CREATE TABLE `chat_msg_record` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
  `s_c_id` int NOT NULL COMMENT '发送用户ID',
  `r_c_id` int NOT NULL COMMENT '接收用户ID',
  `type` varchar(10) NOT NULL COMMENT '消息类型',
  `content` text COMMENT '内容',
  `voice_length` int DEFAULT NULL COMMENT '语音时间长度',
  `send_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '发送时间',
  `dr` int NOT NULL DEFAULT '0' COMMENT '删除',
  `read` int NOT NULL DEFAULT '0' COMMENT '已读',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

然后还有一个存储过程以及函数方法,两者都放在base库里面

        存储过程用来使好友申请记录过期,函数是用来获取用户名称首字母

存储过程 -- expire_apply_record

CREATE DEFINER=`root`@`%` PROCEDURE `expire_apply_record`(
	IN  p_datetime DATETIME,
  OUT p_status TINYINT,
	OUT p_cost_time VARCHAR(20),
	OUT p_msg VARCHAR(100),
	OUT p_exception VARCHAR(2000))
BEGIN
	DECLARE p_state_msg VARCHAR(255) DEFAULT 'N/A';
  DECLARE p_error_msg1 VARCHAR(255);
  DECLARE p_error_msg2 VARCHAR(255);
  DECLARE p_begin_time DATETIME;

	-- SQLEXCEPTION
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
      GET DIAGNOSTICS CONDITION 1 p_error_msg1 = RETURNED_SQLSTATE, p_error_msg2 = MESSAGE_TEXT;
      SET p_status = FALSE;
			SET p_exception = CONCAT('执行异常,在【',p_state_msg,'】出错,详细:',p_error_msg1, ' : ', p_error_msg2);
    END;

  SET p_status = TRUE;
  SET p_begin_time = CURRENT_TIMESTAMP(3);
	
		
	-- NEXTWORK
	UPDATE base.chat_apply_record 
	SET apply_status = 2 
	WHERE
		create_time < SUBDATE( now( ), INTERVAL 7 DAY );		
		
	SET p_msg = CONCAT('本次总共更新:',ROW_COUNT(),'条数据');
	SET p_cost_time = TIMEDIFF(CURRENT_TIMESTAMP(3), p_begin_time);

END

2个函数:

FIRSTLETTER

CREATE DEFINER=`root`@`%` FUNCTION `FIRSTLETTER`(P_NAME VARCHAR(255)) RETURNS varchar(255) CHARSET utf8mb3
BEGIN
    DECLARE V_RETURN VARCHAR(255);
	DECLARE V_FIRST_CHAR VARCHAR(255);
	#这块主要意思是假如传入的是英文串的话,只取首字母
	set V_FIRST_CHAR =UPPER(LEFT(CONVERT(P_NAME USING gbk),1));
	set V_RETURN = V_FIRST_CHAR;
#如果是这些特殊符号,直接返回#	
IF V_FIRST_CHAR in ('(',')','《','》','1','2','3','4','5','6','7','8','9','0','*','+','-','=','/','\\','{','}','[',']','(',')','(',')')
THEN SET V_RETURN = '#';
#两个不相等只有一个情况,V_FIRST_CHAR是中文汉字或者中文符号。
elseif LENGTH( V_FIRST_CHAR) <> CHARACTER_LENGTH( V_FIRST_CHAR )
			then	
				SET V_RETURN = ELT(INTERVAL(CONV(HEX(left(CONVERT(P_NAME USING gbk),1)),16,10), 
					0xB0A1,0xB0C5,0xB2C1,0xB4EE,0xB6EA,0xB7A2,0xB8C1,0xB9FE,0xBBF7, 
					0xBFA6,0xC0AC,0xC2E8,0xC4C3,0xC5B6,0xC5BE,0xC6DA,0xC8BB,
					0xC8F6,0xCBFA,0xCDDA,0xCEF4,0xD1B9,0xD4D1),    
					'A','B','C','D','E','F','G','H','J','K','L','M','N','O','P','Q','R','S','T','W','X','Y','Z');
#如果是下面的直接原样输出					
elseif V_FIRST_CHAR in ('A','B','C','D','E','F','G','H','J','K','L','M','N','O','P','Q','R','S','T','W','X','Y','Z')
												
			then SET V_RETURN = V_RETURN;
#其他的输出#				
else 
			SET V_RETURN = '#';
END IF;
		#为空的话输出#
		RETURN IFNULL(V_RETURN,'#');
END

PINYIN

CREATE DEFINER=`root`@`%` FUNCTION `PINYIN`(P_NAME VARCHAR(255)) RETURNS varchar(255) CHARSET utf8mb4
BEGIN
    DECLARE V_COMPARE VARCHAR(255);
    DECLARE V_RETURN VARCHAR(255);
    DECLARE I INT;
    SET I = 1;
    SET V_RETURN = '';
	#循环截取字符
    while I < LENGTH(P_NAME) do
        SET V_COMPARE = SUBSTR(P_NAME, I, 1);
        IF (V_COMPARE != '') THEN
		    #字符串拼接
            SET V_RETURN = CONCAT(V_RETURN, base.FIRSTLETTER(V_COMPARE));
        END IF;
        SET I = I + 1;
    end while;
    IF (ISNULL(V_RETURN) or V_RETURN = '') THEN
        SET V_RETURN = P_NAME;
    END IF;
    RETURN V_RETURN;
END

数据库全部准备完毕 。。。。。。。。。。。

二、后端代码块

        后端代码主要就三大功能组 --- 用户消息系统

        主要讲解消息系统,其他模块代码已上传 ,可自行查看

        在消息系统中我们为了保证消息的实时性需要使用websocket实现实时通信,而mq的作用就相当于消息队列,为了让消息准确发送,所以接下来注重讲解消息通过mq消息队列通知websocket发送实时消息

        一、mq消息队列模块

               主要两大功能块-- 消费者、生产者;生产者是在发送消息时,把消息放入mq消息队列,消费者是在用户进入系统后连接上websocket后进行消费消息;

                生产者模块这儿我们主要使用顺序队列消息,主要是为了保证消息发送的顺序性,然后按照消息发送的时间按序发送给在线用户

                消费者主要是实现一个监听作用

       下面是整个MQ代码结构,包含4个方法,生产者、消费者、配置、枚举类(定义topic)

                

MQConsumer -- 提供消费者消费方法,也算是一个配置类

package com.wm.mq;

import lombok.extern.log4j.Log4j2;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.protocol.heartbeat.MessageModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Log4j2
@Component
public class MQConsumer {

    @Value("${rocketMqConfig.groupName}")  //组名
    private String groupName;

    @Value("${rocketMqConfig.nameSrvAddr}")  //mqserver地址
    private String nameSrvAddr;


    public DefaultMQPushConsumer consumer(MQTopic mqTopic) {
        try {
            DefaultMQPushConsumer consumer = new DefaultMQPushConsumer();
            consumer.setConsumerGroup(groupName);
            consumer.setNamesrvAddr(nameSrvAddr);
            consumer.subscribe(mqTopic.topic,mqTopic.tags);
            consumer.setMessageModel(MessageModel.CLUSTERING);
            return consumer;  //上面是配置项,然后返回一个consumer容器,在需要消费时可直接使用该容器的一些消费方法
        } catch (MQClientException e) {
            log.error(e);
        }
        return null;
    }

}

MQProducer -- 提供生产消息方法,本系统就只用到其中的顺序消息

package com.wm.mq;

import jakarta.annotation.Resource;
import lombok.extern.log4j.Log4j2;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.MessageQueueSelector;
import org.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageQueue;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;


@Log4j2
@Component
public class MQProducer {

    private DefaultMQProducer defaultMQProducer;

    @Resource
    public void setDefaultMQProducer(DefaultMQProducer defaultMQProducer_) {
        defaultMQProducer = defaultMQProducer_;
    }

    /**
     * 发送同步消息
     * @param mqTopic
     * @param msg
     * @return
     */
    public void send(MQTopic mqTopic, Map<String,Object> msg){
        try {
            Message sendMsg = new Message(mqTopic.topic, mqTopic.tags, msg.toString().getBytes());
            SendResult sendResult = defaultMQProducer.send(sendMsg);
            log.info("==> send MqMsg: {}", sendResult);
        } catch (Exception e) {
            log.error(e);
        }
    }

    /**
     * 发送异步消息
     * @param mqTopic
     * @param msg
     */
    public void asyncSend(MQTopic mqTopic, Map<String,Object> msg){
        try {
            Message sendMsg = new Message(mqTopic.topic, mqTopic.tags, msg.toString().getBytes());
            defaultMQProducer.send(sendMsg, new SendCallback() {
                @Override
                public void onSuccess(SendResult sendResult) {
                    log.info("==> send AsyncMqMsg:{} ", sendResult);
                }

                @Override
                public void onException(Throwable e) {
                    log.error(e);
                }
            });
        }catch (Exception e){
            log.error(e);
        }
    }

    /**
     * 顺序发送消息(分区key-cid)  只用到该方法生产消息
     * @param mqTopic
     * @param cid
     * @param msg
     */
    public void sendOrderMsg(MQTopic mqTopic, int cid, Map<String,Object> msg) {
        try {
            Message sendMsg = new Message(mqTopic.topic, mqTopic.tags, msg.toString().getBytes());
            defaultMQProducer.send(sendMsg, (list, message, o) -> {
                int cid1 = (int) o;
                int index = cid1 % list.size();
                return list.get(index);
            },cid);
        } catch (Exception e) {
            log.error(e);
        }
    }
}

MQProducerConf -- 生存者配置类

package com.wm.mq;

import lombok.extern.log4j.Log4j2;
import org.apache.rocketmq.client.exception.MQClientException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Log4j2
@Configuration
public class MQProducerConf {

    @Value("${rocketMqConfig.groupName}") //组名
    private String groupName;

    @Value("${rocketMqConfig.nameSrvAddr}") //mqserver地址
    private String nameSrvAddr;

    @Value("${rocketMqConfig.maxMessageSize}") //消息体最大可接受的大小
    private Integer maxMessageSize;

    @Value("${rocketMqConfig.sendMsgTimeOut}") // 消息发送超时时间
    private Integer sendMsgTimeOut;

    @Value("${rocketMqConfig.retryTimesWhenSendFailed}") //消息发送失败后重试次数
    private Integer retryTimesWhenSendFailed;

    /**
     * mq 生产者配置
     * @return producer
     * @throws MQClientException
     */
    @Bean
    public org.apache.rocketmq.client.producer.DefaultMQProducer defaultProducer() throws MQClientException {
        org.apache.rocketmq.client.producer.DefaultMQProducer producer = new org.apache.rocketmq.client.producer.DefaultMQProducer();
        producer.setProducerGroup(groupName);
        producer.setNamesrvAddr(nameSrvAddr);
        producer.setVipChannelEnabled(false);
        producer.setMaxMessageSize(maxMessageSize);
        producer.setSendMsgTimeout(sendMsgTimeOut);
        producer.setRetryTimesWhenSendAsyncFailed(retryTimesWhenSendFailed);
        producer.start();
        log.info("==> rocketmq producer server register");
        return producer;
    }


}

MQTopic -- topic枚举类,定义topic,实现topic统一管理,方便后期维护

package com.wm.mq;

import lombok.Getter;

@Getter
public enum MQTopic {

    FRIEND_MSG("TEST","FRIEND_MSG","还有消息"),

    SYS_MSG("TEST","SYS_MSG","系统消息");

    public final String topic;
    public final String tags;
    public final String info;

    MQTopic(String topic, String tags, String info) {
        this.topic = topic;
        this.tags = tags;
        this.info = info;
    }
}

注:对MQ不熟悉的的可以去了解一下mq相关内容,就不在本文做过多叙述

        二、websocket模块

        websocket模块主要三大功能块:监听方法类、配置类、鉴权类

        架构图:

        

        Conf 配置类 --- 说明:这个配置如果是在idea或者其他开发工具上运行时,也或者打包成jar包启动时必须要有这个配置类,因为没有这个配置类将导致websocket服务无法正常监听,如果是打包成war包在Tomcat内运行,那么久不能加这个配置项,所以需要注释掉,不然Tomcat会起不了,直接报错,原因是Tomcat自带websocket监听服务,如果这时候加上配置项,那么方法之间会起冲突

package com.wm.webSocket;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class Conf {

    /**
     * 仅供idea或jar包运行时启用,部署到Tomcat时一定要注释掉,不然会报错
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

Server类 -- 监听方法类

package com.wm.webSocket;

import com.alibaba.fastjson2.JSON;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;


@Log4j2
@ServerEndpoint("/websocket/{uid}")
@Component
public class Server {

    private static int onlineCount = 0;

    private Session session;

    private int cid;
    
    //用户连接websocekt信息保存map类
    private static final ConcurrentHashMap<Object, Server> webSocketMap = new ConcurrentHashMap<>();



    /**
     * 连接
     * @param session
     * @param cid
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("uid") int cid) {
        this.session = session;
        this.cid = cid;
        if(webSocketMap.containsKey(cid)) {
            webSocketMap.remove(cid);
            webSocketMap.put(cid,this);
        }else {
            webSocketMap.put(cid,this);
            onlineCount++;
        }
        log.info("==> user:{} is connected,countNum:{}",cid,onlineCount);
    }

    /**
     * 错误连接
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error(error);
    }

    /**
     * 关闭连接
     */
    @OnClose
    public void onClose() {
        if(webSocketMap.containsKey(cid))
            onlineCount--;
        webSocketMap.remove(cid);
        log.info("==> user:{} is logout,countNum:{}",cid,onlineCount);
    }

    /**
     * 监听消息
     * @param content
     * @param session
     */
    @OnMessage
    public void onMessage(String content,Session session) {
        log.info("==> {} sendMsg -> {}",cid,content);
    }



    /**
     * 全局消息推送
     * @param content
     * @throws IOException
     */
    public void sendMessage(String content) throws IOException {
        this.session.getBasicRemote().sendText(content);
    }

    /**
     * 发送消息
     * @param scid
     * @param rcid
     * @param content
     * @throws IOException
     */
    public boolean send(int scid, int rcid, String type, Object content, Integer voiceLength) throws IOException {
        if(webSocketMap.containsKey(rcid)) {
            Map<String,Object> msgInfo = new HashMap<>();
            msgInfo.put("scid",scid);
            msgInfo.put("rcid",rcid);
            msgInfo.put("type",type);
            msgInfo.put("content",content);
            msgInfo.put("voiceLength",voiceLength);
            webSocketMap.get(rcid).sendMessage(JSON.toJSONString(msgInfo));
            log.info("==> {} -> {} sendMsg -> {}",scid,rcid,content);
            return true;
        }
        else {
            log.info("==> {} not online",rcid);
            return false;
        }
    }

}

Auth 鉴权类 -- websocket没有自己的拦截方法,这儿可以使用filter过滤器直接拦截带有websocket的链接,拦截到后会对websocket上的签名进行验证,当然还是直接使用系统统一的token进行鉴权;涉及到2个鉴权点,第一Token本身鉴权、以及Token带有的用户信息鉴权;Token本身鉴权就是使用jwt本身方法进行验签;用户信息鉴权针对Token带有的用户信息和websocket连接用户信息是否一致

package com.wm.webSocket;

import com.auth0.jwt.exceptions.JWTDecodeException;
import com.wm.components.JwtUtil;
import com.wm.exception.BusinessException;
import com.wm.exception.BusinessExceptionEnum;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Arrays;
import java.util.Objects;

@Log4j2
@Component
public class Auth  implements Filter{

    private static final String[] FILTER_LIST = {"/websocket/"}; //指定要拦截的链接

    private JwtUtil jwtUtil;

    @Autowired
    public void setJwtUtil(JwtUtil jwtUtil_) {
        jwtUtil = jwtUtil_;
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
        String servletPath = httpServletRequest.getServletPath();
        if (Arrays.stream(FILTER_LIST).anyMatch(servletPath::startsWith)) {
            String token = httpServletRequest.getHeader("Sec-WebSocket-Protocol");
            if (token!=null) {
                String cid = jwtUtil.parseToken(token).get("cid").asString();  //获取Token中用户信息
                if (Objects.equals(servletPath.split("/")[2], cid)) { // 连接websocket的用户信息和Token中用户信息进行比对
                    try {
                        jwtUtil.verifyToken(token);
                    } catch (JWTDecodeException e) {
                        throw new BusinessException(BusinessExceptionEnum.TokenExpiredException);
                    }
                    /* web需要设置socket请求头,APP和小程序不用所以要注掉 */
//                    httpServletResponse.setHeader("Sec-WebSocket-Protocol",token);
                    filterChain.doFilter(servletRequest, servletResponse);
                } else {
                    throw new BusinessException(BusinessExceptionEnum.TokenUserNotExistException);
                }
            } else {
                throw new BusinessException(BusinessExceptionEnum.NullTokenException);
            }
        } else {
            filterChain.doFilter(servletRequest, servletResponse); //放行
        }
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }

}

        三、service 方法

        service层主要方法是消息的发送、监听消息后通过websocket发送给指定用户

        发送消息方法 -- 1、发送的消息存入数据库 2、发送的消息放入mq消息队列

    @Override
    public Map<String,Object> sendMsg(SendMsgVO sendMsgVO) {
        Map<String,Object> msg = new HashMap<>();
        ChatMsgRecordPO chatMsgRecordPO = new ChatMsgRecordPO();

        /*消息存入数据库*/
        BeanUtils.copyProperties(sendMsgVO,chatMsgRecordPO);
        messageDao.saveMsg(chatMsgRecordPO);

        /*MQ消息实体类赋值*/
        msg.put("id",chatMsgRecordPO.getId());
        msg.put("scid",sendMsgVO.getScid());
        msg.put("rcid",sendMsgVO.getRcid());
        msg.put("content",sendMsgVO.getContent());
        msg.put("type",sendMsgVO.getType());
        msg.put("sendTime", Time.getDateTime());

        /*消息通过MQ发送*/
        try {
            mqProducer.sendOrderMsg(MQTopic.FRIEND_MSG, sendMsgVO.getRcid(), msg);
            msg.put("status",true);
        } catch (Exception e) {
            log.error(e);
            msg.put("status",false);
        }

        /*如不需要经过MQ中间件,直接调用websocket进行消息发送,需要把MQ发送代码注释掉,反之则租掉下方websocket调用代码*/

         try {
             server.send(sendMsgVO.getScid(),sendMsgVO.getRcid(),sendMsgVO.getType(),sendMsgVO.getContent(),0);
             msg.put("status",true);
         } catch (IOException e) {
             log.error(e);
             msg.put("status",false);
         }


        return msg;
    }

        监听消息 --该方法最终要的功能发送消息给指定的用户,然后对消息进行消费,当用户不在线时(没有连接上websocket)消息不会消费,只有当用户在线时消息才会被消费。 该方法还需要在项目启动时就需要启动,有非常多方法可以做到,有兴趣可以去看一下,这儿使用的是springboot自带的@EventListener注解,该注解可监听到springboot启动后自动启动有@EventListener注解的方法

    @EventListener
    @Override
    public void listenMsg(ContextRefreshedEvent event) {
        try {
            DefaultMQPushConsumer consumer = mqConsumer.consumer(MQTopic.FRIEND_MSG);
            consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
            consumer.registerMessageListener((MessageListenerOrderly) (list, consumeOrderlyContext) -> {
                consumeOrderlyContext.setAutoCommit(true);
                //mq消费消息状态;ConsumeOrderlyStatus.SUCCESS - 成功消费 SUSPEND_CURRENT_QUEUE_A_MOMENT - 队列消息消费失败,持续等待消费者上线然后进行消费
                ConsumeOrderlyStatus status = ConsumeOrderlyStatus.SUCCESS;
                for (MessageExt msg : list) {
                    try {
                        Map<String,String> msgInfo = Transfer.mapStrToMap(new String(msg.getBody()));
                        int voiceLength = Objects.equals(msgInfo.get("type"), "voice") ? Integer.parseInt(msgInfo.get("voiceLength"))  : 0;
                        //调用websocket发送消息方法,如果发送失败,证明用户不在线,所以消费消息失败
                        if (!server.send(Integer.parseInt(msgInfo.get("scid")), Integer.parseInt(msgInfo.get("rcid")), msgInfo.get("type"), msgInfo.get("content"),voiceLength)) { 
                            status = ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
                        }
                        TimeUnit.MILLISECONDS.sleep(100); 
                    } catch (Exception e) {
                        status = ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
                        log.error(e);
                    }
                }
                return status;
            });
            consumer.start();
        } catch (Exception e) {
            log.error(e);
        }
    }

整个MessageService代码

package com.wm.service;

import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.wm.components.UploadFile;
import com.wm.dao.MessageDao;
import com.wm.entity.dto.GetChatMsgListDTO;
import com.wm.entity.po.ChatMsgRecordPO;
import com.wm.entity.vo.GetChatMsgVO;
import com.wm.entity.vo.SendMsgVO;
import com.wm.mq.MQConsumer;
import com.wm.mq.MQProducer;
import com.wm.mq.MQTopic;
import com.wm.tools.Time;
import com.wm.tools.Transfer;
import com.wm.webSocket.Server;
import jakarta.annotation.Resource;
import lombok.extern.log4j.Log4j2;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerOrderly;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.beans.BeanUtils;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

@Log4j2
@Service
public class MessageServiceImpl implements MessageService{

    @Resource
    private MessageDao messageDao;

    @Resource
    private MQProducer mqProducer;

    @Resource
    private MQConsumer mqConsumer;

    @Resource
    private Server server;

    @Resource
    private UploadFile uploadFile;

    /**
     * 获取聊天好友
     * @param cid
     * @return
     */
    @Override
    public List<GetChatMsgListDTO> getChatMsgList(int cid) {
        return messageDao.getChatMsgList(cid);
    }

    /**
     * 获取消息
     * @param getChatMsgVO
     * @return
     */
    @Override
    public Map<String,Object> getChatMsg(GetChatMsgVO getChatMsgVO) {
        Map<String,Object> map = new HashMap<>();
        PageHelper.startPage(getChatMsgVO.getPageNum(),getChatMsgVO.getPageSize());
        PageInfo<ChatMsgRecordPO> info = new PageInfo<>(messageDao.getChatMsg(getChatMsgVO.getScid(), getChatMsgVO.getRcid()));
        map.put("pagesNum",info.getPages());
        map.put("totalNum",info.getTotal());
        map.put("size",info.getSize());
        map.put("list", info.getList());
        return map;
    }

    /**
     * 监听MQ消息
     * @param event
     */
    @EventListener
    @Override
    public void listenMsg(ContextRefreshedEvent event) {
        try {
            DefaultMQPushConsumer consumer = mqConsumer.consumer(MQTopic.FRIEND_MSG);
            consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
            consumer.registerMessageListener((MessageListenerOrderly) (list, consumeOrderlyContext) -> {
                consumeOrderlyContext.setAutoCommit(true);
                ConsumeOrderlyStatus status = ConsumeOrderlyStatus.SUCCESS;
                for (MessageExt msg : list) {
                    try {
                        Map<String,String> msgInfo = Transfer.mapStrToMap(new String(msg.getBody()));
                        int voiceLength = Objects.equals(msgInfo.get("type"), "voice") ? Integer.parseInt(msgInfo.get("voiceLength"))  : 0;
                        if (!server.send(Integer.parseInt(msgInfo.get("scid")), Integer.parseInt(msgInfo.get("rcid")), msgInfo.get("type"), msgInfo.get("content"),voiceLength)) {
                            status = ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
                        }
                        TimeUnit.MILLISECONDS.sleep(100);
                    } catch (Exception e) {
                        status = ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
                        log.error(e);
                    }
                }
                return status;
            });
            consumer.start();
        } catch (Exception e) {
            log.error(e);
        }
    }

    /**
     * 发送消息
     * @param sendMsgVO
     * @return
     */
    @Override
    public Map<String,Object> sendMsg(SendMsgVO sendMsgVO) {
        Map<String,Object> msg = new HashMap<>();
        ChatMsgRecordPO chatMsgRecordPO = new ChatMsgRecordPO();

        /*消息存入数据库*/
        BeanUtils.copyProperties(sendMsgVO,chatMsgRecordPO);
        messageDao.saveMsg(chatMsgRecordPO);

        /*MQ消息实体类赋值*/
        msg.put("id",chatMsgRecordPO.getId());
        msg.put("scid",sendMsgVO.getScid());
        msg.put("rcid",sendMsgVO.getRcid());
        msg.put("content",sendMsgVO.getContent());
        msg.put("type",sendMsgVO.getType());
        msg.put("sendTime", Time.getDateTime());

        /*消息通过MQ发送*/
        try {
            mqProducer.sendOrderMsg(MQTopic.FRIEND_MSG, sendMsgVO.getRcid(), msg);
            msg.put("status",true);
        } catch (Exception e) {
            log.error(e);
            msg.put("status",false);
        }

        /*不需要经过MQ中间件,直接调用websocket进行消息发送,需要把上面MQ发送代码注释掉*/
            
            /* 直接调用websocekt发送消息 -- 不能和上面mq消息共存,不然消息会重复发送 */
//         try {
//             server.send(sendMsgVO.getScid(),sendMsgVO.getRcid(),sendMsgVO.getType(),sendMsgVO.getContent(),0);
//             msg.put("status",true);
//         } catch (IOException e) {
//             log.error(e);
//             msg.put("status",false);
//         }


        return msg;
    }

    /**
     * 发送文件消息
     * @param file
     * @param scid
     * @param rcid
     * @param type
     * @return
     */
    @Override
    public Map<String, Object> sendFileMsg(MultipartFile file, int scid, int rcid, String type, Integer voiceLength) {
        Map<String,Object> msg = new HashMap<>();

        /*上传文件获取文件url*/
        Map<String,Object> res = uploadFile.doRemoteUpload(file,"/file/");
        if ((boolean) res.get("status")) {

            /*消息存入数据库*/
            ChatMsgRecordPO chatMsgRecordPO = new ChatMsgRecordPO();
            chatMsgRecordPO.setScid(scid);
            chatMsgRecordPO.setRcid(rcid);
            chatMsgRecordPO.setType(type);
            chatMsgRecordPO.setContent(res.get("fileUrl").toString());
            chatMsgRecordPO.setVoiceLength(voiceLength);
            messageDao.saveMsg(chatMsgRecordPO);

            /*MQ消息实体类赋值*/
            msg.put("id",chatMsgRecordPO.getId());
            msg.put("rcid",rcid);
            msg.put("scid",scid);
            msg.put("type",type);
            msg.put("content",res.get("fileUrl"));
            msg.put("voiceLength",voiceLength);
            msg.put("sendTime", Time.getDateTime());


            /*MQ消息发送消息*/
            try {
                mqProducer.sendOrderMsg(MQTopic.FRIEND_MSG, rcid, msg);
                msg.put("status",true);
            } catch (Exception e) {
                log.error(e);
                msg.put("status",false);
            }

            /*不需要经过MQ中间件,直接调用websocket进行消息发送,需要把上面MQ发送代码注释掉*/
            
            /* 直接调用websocekt发送消息 -- 不能和上面mq消息共存,不然消息会重复发送 */
//            try {
//                server.send(scid,rcid,type,res.get("fileUrl"),voiceLength);
//                msg.put("status",true);
//            } catch (IOException e) {
//                log.error(e);
//                msg.put("status",false);
//            }

        } else {
            msg.put("status",false);
        }
        return msg;
    }

    /**
     * 已读消息
     * @param scid
     * @param rcid
     * @return
     */
    @Override
    public boolean readMsg(int scid, int rcid) {
        return messageDao.readMsg(scid,rcid);
    }
}

三、前端代码

        一、websocket 

        websocket.js 功能 -提供连接以及连接重试功能,当websocket断开连接时需要不断重试连接,因为我们使用的uniapp,所以需要使用uniapp自带websocket连接方法,当然也可以使用js的websocket连接方法,但是考虑到兼容性,所以还是使用uniapp自带websocket连接方法

import conf from "@/properties/index.js" //配置类,可自定义
class WebSocket {
	
	
	constructor() {
		this.isConnect = false;
		this.timer;
		this.connect();
	}
	
	/**
	 * 连接
	 */
	connect() {
		uni.connectSocket({
			url: `${conf.appConf.websocketUrl}/${uni.getStorageSync("cid")}`,
			header: {
				'content-type': 'application/json',
				'Sec-WebSocket-Protocol': uni.getStorageSync('Authorization')
			},
			method: 'GET',
			success() {
				console.log("socket开始连接!");
			},
			fail() {
				console.log("socket连接失败!");
			}
		});
		this.onOpen();
		this.onError();
		this.onClose();
	}
	
	/**
	 * 连接
	 */
	onOpen() {
		uni.onSocketOpen(res => {
			console.log("监测到已连接上websocket");
			this.isConnect = false;
		})
	}
	
	/**
	 * 出错
	 */
	onError() {
		uni.onSocketError(res => {
			console.log("监测到连接websocket错误");
			this.isConnect = true;
			this.reConnect();
		})
	}
	
	/**
	 * 关闭
	 */
	onClose() {
		uni.onSocketClose(res => {
			console.log("监测到连接websocket已关闭");
			this.isConnect = true;
			this.reConnect();
		})
	}
	
	/**
	 * 重连
	 */
	reConnect() {
		if (this.isConnect) {
			clearTimeout(this.timer);
			this.timer = setTimeout(() => {this.connect()},conf.appConf.webSocketRetryTimeOut);
		}
	}
}

export default new WebSocket();

                注意:websocket定义完成后需要在小程序启动时运行,但是因为需要鉴权,所以需要在鉴权后才能启用websocket,这儿我放在进入小程序首页时调用websocket,因为必须要鉴权成功才能进入小程序首页,可以根据实际情况引入

import '@/websocket/websocket';

二、聊天页面

        websocket消息监听方法,因为我们使用了uniapp自带的websocket方法,所以直接使用uni.onSocketMessage()方法监听消息

const listenMsg = () => {
	uni.onSocketMessage((res)=>{
		let resData = JSON.parse(res.data);
		if (resData.rcid !== resData.scid)
			data.msgInfoList.push(resData);
	})
}

整个聊天页面代码:

<template>
	<view class="chat-index">
		<scroll-view
			id="scrollview"
			class="scroll-style"
			:style="{height: `${windowHeight - inputHeight}rpx`}"
			scroll-y="true" 
			:scroll-top="conf.scrollTop"
			@scrolltoupper="topRefresh"
			@click="touchClose"
		>
			<view id="msglistview" class="chat-body">
				<view v-for="item,index in data.msgInfoList" :key="index">
					
					<!-- 消息发送时间 -->
					<view class="time-box" v-if="item.showTime">
						<view class="time-style">
							<view>
								{{ timeFormat(item.sendTime) }}
							</view>
						</view>
					</view>
					
					<!-- 自己 -->
					<view class="item self" v-if="item.scid == userInfo.scid">
						
						<!-- 文本消息 -->
						<view class="content-text right" v-if="item.type=='text'">
							{{item.content}}
						</view>
						
						<!-- 语音消息 -->
						<view class="content-text right" v-else-if="item.type=='voice'">
							<view style="display: flex;" @click="playSound(item.content)">
								<text>{{ item.voiceLength }}''</text>
								<image v-if="conf.playVoice" style="width: 42rpx;height: 42rpx;" src="../../static/icon/voice_play_on.png"/>
								<image v-else style="width: 42rpx;height: 42rpx;" src="../../static/icon/voice_play.png"/>
							</view>
						</view>
						
						<!-- 图片消息 -->
						<view class="content-img" v-else-if="item.type=='img'">
							<image class="img-style" :src="item.content"  mode="widthFix" :lazy-load="true"/>
						</view>
						
						<!-- 视频消息 -->
						<view class="content-video" v-else>
							<video class="video-style" :src="item.content" />
						</view>
						
						<!-- 头像 -->
						<image class="avatar" :src="userInfo.s_avatar" @click="toUserInfoPage(userInfo.scid)"/>
					</view>
					
					<!-- 好友 -->
					<view class="item Ai" v-else>
						
						<!-- 头像 -->
						<image class="avatar" :src="userInfo.r_avatar" @click="toUserInfoPage(userInfo.rcid)"/>
						
						<!-- 文本消息 -->
						<view class="content-text left" v-if="item.type=='text'">
							{{item.content}}
						</view>
						
						<!-- 语音消息 -->
						<view class="content-text left" v-else-if="item.type=='voice'">
							<view style="display: flex;" @click="playSound(item.content)">
								<text>{{ item.voiceLength }}''</text>
								<image v-if="conf.playVoice" style="width: 42rpx;height: 42rpx;" src="../../static/icon/voice_play_on.png"/>
								<image v-else style="width: 42rpx;height: 42rpx;" src="../../static/icon/voice_play.png"/>
							</view>
						</view>
						
						<!-- 图片消息 -->
						<view class="content-img" v-else-if="item.type=='img'">
							<image class="img-style" :src="item.content"  mode="widthFix" :lazy-load="true"/>
						</view>
						
						<!-- 视频消息 -->
						<view class="content-video" v-else>
							<video class="video-style" :src="item.content" />
						</view>

					</view>
				</view>
			</view>
		</scroll-view>
		
		<!-- 消息发送框 -->
		<view class="chat-bottom" :style="{height:`${inputHeight}rpx`}">
			<view class="input-msg-box" :style="{bottom:`${conf.keyboardHeight}rpx`}">
				
				<!-- 输入框区域 -->
				<view class="textarea-style">
					<!-- 语音/文字输入 -->
					<view class="voice-btn" @click="isVoice">
						<image class="icon-style"  v-if="conf.isVoice" src="../../static/icon/keyboard.png" />
						<image class="icon-style" v-else src="../../static/icon/voice.png" />	
					</view>
					
					<!-- textarea输入框 -->
					<view class="out_textarea_box" @click="() => conf.showMoreMenu=false">
						<textarea
							placeholder-class="textarea_placeholder"
							:style="{textAlign:(conf.textAreaDisabled?'center':'')}"
							v-model="sendMsg.text"
							maxlength="250"
							confirm-type="send"
							auto-height
							:placeholder="conf.textAreaText"
							:show-confirm-bar="false"
							:adjust-position="false"
							:disabled="conf.textAreaDisabled"
							@confirm="handleSend"
							@linechange="listenTextAreaHeight"
							@focus="scrollToBottom" 
							@blur="scrollToBottom"
							@touchstart="handleTouchStart"
							@touchmove="handleTouchMove"
							@touchend="handleTouchEnd"
						   />
					</view>	
						
					<!-- 输入菜单 -->
					<view class="more-btn">
						<image class="icon-style" src="../../static/icon/emoji.png" @click="handleSend"/>
						<image class="icon-style" style="margin-left: 20rpx;" src="../../static/icon/more.png" @click="showMoreMenuFunc"/>				
					</view>
			
				</view>
				
				<!-- 更多菜单 -->
				<view :class="{'more-menu-box-max': conf.showMoreMenu,'more-menu-box-min': !conf.showMoreMenu}">
					<view class="inner-menu-box">
						<view class="menu-box" @click="sendFile('choose','')">
							<view class="out-icon-area">
								<image class="i-style" src="../../static/icon/photo.png" />
							</view>
							<view class="t-style">照片</view>
						</view>
						<view class="menu-box" @click="sendFile('shoot','')">
							<view class="out-icon-area">
								<image class="i-style" src="../../static/icon/takePhoto.png" />
							</view>
							<view class="t-style">拍摄</view>
						</view>
					</view>
				</view>
				
			</view>
		</view>
		
		<!-- 语音输入 -->
		<view class="voice-mask" v-show="voice.mask">
			<view class="inner-mask">
				<view class="voice-progress-box" :style="{width:`${progressNum}`+'rpx'}">
					<view class="third-icon"/>
					<view class="progress-num">
						{{ voice.length }}s
					</view>
				</view>
				<view class="cancel-btn" :class="{cancelBtn : voice.cancel}">
					<image style="width: 60rpx;height: 60rpx;" src="../../static/icon/cancel-voice.png"></image>
				</view>
				<view class="show-tips">
					上滑取消发送
				</view>
				<view class="bottom-area">
					<image class="img-style" src="../../static/icon/icon-voice.png" />
				</view>
			</view>
		</view>
	</view>
</template>

<script setup>
import { computed, getCurrentInstance, reactive, ref, onUpdated } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import properties from '@/properties/index.js';
import timeMethod from '@/utils/timeMethod.js';

const { proxy } = getCurrentInstance();
const _this = proxy;
const sendMsg = reactive({
	text: ''
})
/* 接口数据 */
const data = reactive({
	msgInfoList: [],
	pageNum: 1,
	pageSize: 20,
	pageNumCount: 0
})
/* 用户信息 */
const userInfo = reactive({
	scid: null,
	rcid: null,
	s_avatar: '',
	r_avatar: ''
})
/* 配置项 */
const conf = reactive({
	keyboardHeight: 0,
	bottomHeight: 150,
	scrollTop: 0,
	moreMenuHeight: 0,
	judgeScrollToBottom: true,
	showMoreMenu: false,
	loading: false,
	showMsgMenuBoxId: null,
	showMoreMenu: false,
	textAreaDisabled: false,
	textAreaText: '',
	isVoice: false,
	showMoreMenu: false,
	playVoice: false
})
/* 语音输入配置项 */
const voice = reactive({
	mask: false,
	length: 0,
	cancel: false,
	startX: "",
	startY: "",
	timer: "",
	recordInstance: "",
	finished: false,
})
 /* msg配置项 */		
const msgConf = reactive({
	timeSpace: 120,
	initMsgTime: '',
	msgId: 0,
	latestTime: ''
})	



/**
 * 页面加载时调用
 */
onLoad((e) => {
	userInfo.scid =parseInt(uni.getStorageSync('cid'));
	userInfo.rcid = parseInt(e.rcid);
	voice.recordInstance = uni.getRecorderManager();
	keyboardHeightChange();
	listenMsg();
	getAiUserInfo(parseInt(e.rcid));
	getSelfUserInfo(uni.getStorageSync('cid'));
	getAllMsg(parseInt(e.rcid));
	readMsg(parseInt(e.rcid))
})

/**
 * 数据更新时调用
 */
onUpdated(() => {
	/* 页面更新时调用聊天消息定位到最底部 */
	if (conf.judgeScrollToBottom) scrollToBottom();

})

/**
 * 计算属性
 */
const windowHeight = computed(() => rpxTopx(uni.getSystemInfoSync().windowHeight))
const inputHeight = computed(() => conf.bottomHeight + conf.keyboardHeight + conf.moreMenuHeight)
const progressNum = computed(() => voice.length * 2 + 250)

/**
 * px 转换 rpx
 */  
const rpxTopx = (px) => {
	const deviceWidth = uni.getSystemInfoSync().windowWidth;
	let rpx = ( 750 / deviceWidth ) * Number(px);
	return Math.floor(rpx);
}

/**
 * 监听聊天发送栏高度
 */
const listenTextAreaHeight = () => {
	setTimeout(()=>{
		let query = uni.createSelectorQuery();
		query.select('.input-msg-box').boundingClientRect();
		query.exec(res =>{
			conf.bottomHeight = rpxTopx(res[0].height);
		})
	},200)
}

/**
 * 监听键盘高度
 */
const keyboardHeightChange = () => {
	uni.onKeyboardHeightChange(res => {
		conf.keyboardHeight = rpxTopx(res.height);
		if(conf.keyboardHeight <= 0) {
			conf.keyboardHeight = 0;
			conf.showMoreMenu = false;
		}
	})
}

/**
 * 滑动到底部
 */
const scrollToBottom = (e) => {
	setTimeout(()=>{
		let query = uni.createSelectorQuery().in(_this);
		query.select('#scrollview').boundingClientRect();
		query.select('#msglistview').boundingClientRect();
		query.exec((res) =>{
			if(res[1].height > res[0].height){
				conf.scrollTop = rpxTopx(res[1].height - res[0].height);
			}
		})
	},200);
}

/**
 * 弹出更多菜单弹窗
 */
const showMoreMenuFunc = () => {
	conf.showMoreMenu = true;
	conf.isVoice = false;
	conf.textAreaText = '';
	conf.moreMenuHeight = 350;
}

/**
 * websocket监听
 */
const listenMsg = () => {
	uni.onSocketMessage((res)=>{
		let resData = JSON.parse(res.data);
		if (resData.rcid !== resData.scid)
			data.msgInfoList.push(resData);
	})
}

/**
 * 语音与输入切换
 */
const isVoice = () => {
	if (conf.isVoice) {
		conf.isVoice = false;
		conf.textAreaDisabled = false;
		conf.textAreaText = '';
	} else {
		conf.isVoice = true;
		conf.textAreaDisabled = true;
		conf.textAreaText = '按住 说话';
		conf.showMoreMenu = false;
		conf.moreMenuHeight = 0;
	}
		
}


/**
 * 获取用户信息(自己)
 */
const getSelfUserInfo = (cid) => _this.$http('/user/getUserInfo','GET',{'cid':cid}).then(res => {
	userInfo.scid = cid;
	userInfo.s_avatar = res.data.avatarUrl;
})

/**
 * 获取用户信息(好友)
 */
const getAiUserInfo = (cid) => _this.$http('/user/getUserInfo','GET',{'cid':cid}).then(res => {
	userInfo.rcid = cid;
	userInfo.r_avatar = res.data.avatarUrl;
	uni.setNavigationBarTitle({title:res.data.name});
})

/**
 * 进入好友信息页面
 */
const toUserInfoPage = (cid) => {
	uni.navigateTo({
		url: `/pages/user/friendUserInfo?cid=${cid}`
	})
}

/**
 * 上拉加载消息
 */
const topRefresh = () => {
	if (data.pageNum < data.pageNumCount) {
		data.pageNum++;
		conf.judgeScrollToBottom = false;
		conf.loading = true;
		getAllMsg(userInfo.rcid);
	}
}

/**
 * 获取消息
 */
const getAllMsg = (rcid) => {
	_this.$http('/msg/getChatMsg','POST',{'scid':uni.getStorageSync('cid'),'rcid':rcid,'pageNum':data.pageNum,'pageSize':data.pageSize}).then(res => {
		data.pageNumCount = res.data.pagesNum;
		showMsgTime(res.data.list);
		if (data.msgInfoList.length !== 0)
			msgConf.latestTime = data.msgInfoList.slice(-1)[0].sendTime; 
	})
}

/**
 * 已读消息
 */
const readMsg = (rcid) => {
	_this.$http('/msg/readMsg','POST',{'scid':rcid,'rcid':uni.getStorageSync('cid')})
}

/**
 * 控制消息时间是否展示
 */
const showMsgTime = (msgData) => {
	msgData.forEach(e => {
		e.showTime = false;
		data.msgInfoList.unshift(e);
		if (msgConf.msgId !== 0) {
			if (timeMethod.calculateTime(msgConf.initMsgTime,e.sendTime)/1000 > msgConf.timeSpace) {
				data.msgInfoList.slice(0 - msgConf.msgId)[0].showTime = true;
			}
		}
		msgConf.initMsgTime = e.sendTime;
		msgConf.msgId++;
	});
	if (data.msgInfoList.length !== 0)
		data.msgInfoList.slice(0 - (msgConf.msgId + 1))[0].showTime = true;
}

/**
 * 日期转换
 */
const timeFormat = (time) => {
	//时间格式化
	const Time = timeMethod.getTime(time).split("T");
	//当前消息日期属于周
	const week = timeMethod.getDateToWeek(time); 
	//当前日期0时
	const nti = timeMethod.setTimeZero(timeMethod.getNowTime());
	//消息日期当天0时
	const mnti = timeMethod.setTimeZero(timeMethod.getTime(time));
	//计算日期差值
	const diffDate = timeMethod.calculateTime(nti,mnti);
	//本周一日期0时 (后面+1是去除当天时间)
	const fwnti = timeMethod.setTimeZero(timeMethod.countDateStr(-timeMethod.getDateToWeek(timeMethod.getNowTime()).weekID + 1));
	//计算周日期差值
	const diffWeek = timeMethod.calculateTime(mnti,fwnti);
	
	if (diffDate === 0) { 				//消息发送日期减去当天日期如果等于0则是当天时间
		return Time[1].slice(0,5);
	} else if (diffDate < 172800000) { //当前日期减去消息发送日期小于2天(172800000ms)则是昨天-  一天最大差值前天凌晨00:00:00到今天晚上23:59:59
		return "昨天 " + Time[1].slice(0,5);
	} else if (diffWeek >= 0) { 		//消息日期减去本周一日期大于0则是本周
		return week.weekName;
	} else { 							//其他时间则是日期
		return Time[0].slice(5,10);
	}
}


/**
 * 关闭消息操作菜单
 */
const touchClose = () => {
	conf.showBoxId = null;
	conf.showMoreMenu = false;
	conf.keyboardHeight = 0;
	conf.moreMenuHeight = 0;
}

/**
 * 发送消息
 */
const handleSend = () => {
	conf.judgeScrollToBottom = true;
	data.pageNum = 1;
	/* 如果消息不为空 */
	if(sendMsg.text.length !== 0){
		_this.$http("/msg/sendMsg","POST",{
			"scid":userInfo.scid,
			"rcid":userInfo.rcid,
			"type": "text",
			"content":sendMsg.text}).then(res => {
			if (res.status) {
				if (timeMethod.calculateTime(res.data.sendTime,msgConf.latestTime)/1000 > msgConf.timeSpace) {
					res.data.showTime = true;
				} else {
					res.data.showTime = false;
				}
				data.msgInfoList.push(res.data);
				sendMsg.text = '';
			} 
		})
	}
}

/**
 * 长按开始录制语音
 */
const handleTouchStart = (e) => {
	if (conf.textAreaDisabled) {
		voice.finished = false;
		uni.getSetting({
			success(res) {
				if (res.authSetting['scope.record'] === undefined) {
					console.log("第一次授权")
				} else if (!res.authSetting['scope.record']) {
					uni.showToast({
						icon: "none",
						title: "点击右上角···进入设置开启麦克风授权!",
						duration: 2000
					})
				} else {						
					voice.recordInstance.start();
					voice.mask = true;
					voice.isRecord = true;
					voice.length = 1;
					voice.startX = e.touches[0].pageX;
					voice.startY = e.touches[0].pageY;
					voice.timer = setInterval(() => {
						voice.length += 1;
						if(voice.length >= 60) {
							clearInterval(voice.timer);
							handleTouchEnd();
						}
					},1000)	
					//判断先结束按钮但是录制才开始时不会结束录制的条件;因为获取授权这儿存在延时;所以结束录制时可能还没开始录制
					if (voice.finished && voice.mask) {
						handleTouchEnd();
					}
				}
			}
		})
	}			
}

/**
 * 长按滑动
 */
const handleTouchMove = (e) => {
	if (conf.textAreaDisabled) {
		if (voice.startY - e.touches[0].pageY > 80) {
			voice.cancel = true;
		}else {
			voice.cancel = false;
		}
	}
}

/**
 * 语音录制结束
 */
const handleTouchEnd = () => {
	if (conf.textAreaDisabled) {
		voice.finished = true;
		voice.mask = false;
		clearInterval(voice.timer);
		voice.recordInstance.stop();
		voice.recordInstance.onStop((res) => {
			const message = {
				voice:res.tempFilePath,
				length:voice.length
			}
			if (!voice.cancel) {
				if (voice.length>1) {
					sendFile("voice",message);
				} else {
					uni.showToast({
						icon: 'none',
						title: "语音时间太短",
						duration: 1000
					})
				}
			}else {
				voice.cancel = false;
			}
		})													
	}
}

/**
 * 语音播放
 */
const playSound = (url) => {
	conf.playVoice = true;
	let music = null;
	music = uni.createInnerAudioContext(); 
	music.src = url;
	music.play(); 
	music.onEnded(()=>{
		music = null;
		conf.playVoice = false;
	})
}


/**
 * 发送文件
 */
const sendFile = (type,data) => {
	if (type === "choose") {
		uni.chooseMedia({
			count: 1,
			mediaType: ['image', 'video'],
			sourceType: ['album'],
			maxDuration: 30,
			success(res) {
				let type = 'img';
				if (res.tempFiles[0].fileType === 'image') {
					type = 'img'
				} else {
					type = 'video'
				}
				uploadFile(res.tempFiles[0].tempFilePath,type)
			}
		})	
	} else if (type === "shoot") {
		uni.chooseMedia({
			count: 1,
			mediaType: ['image', 'video'],
			sourceType: ['camera'],
			maxDuration: 30,
			success(res) {
				let type = 'img';
				if (res.tempFiles[0].fileType === 'image') {
					type = 'img'
				} else {
					type = 'video'
				}
				uploadFile(res.tempFiles[0].tempFilePath,type)
			}
		})	
	} else {
		uploadFile(data.voice,'voice')
	}
}

/**
 * 上传文件
 */
const uploadFile = (path,type) => {
	let param = {"scid":userInfo.scid,"rcid":userInfo.rcid,"type":type};
	if (type=='voice') {
		param = {"scid":userInfo.scid,"rcid":userInfo.rcid,"type":type,"voiceLength":voice.length};
	}
	uni.uploadFile({
		url: properties.appConf.url + "/msg/sendFileMsg",
		filePath: path,
		name: 'file',
		formData: param,
		header: {"Authorization": uni.getStorageSync('Authorization')},
		success(res) {
			let newMsg = JSON.parse(res.data)
			if (newMsg.status) {
				if (timeMethod.calculateTime(newMsg.data.sendTime,msgConf.latestTime)/1000 > msgConf.timeSpace) {
					newMsg.data.showTime = true;
				} else {
					newMsg.data.showTime = false;
				}
				data.msgInfoList.push(newMsg.data)
			}
		}
	})
}

</script>

<style lang="scss">
	
$chatContentbgc: #00ff7f;
$chatBackground: #f0f0f0;

center {
	display: flex;
	align-items: center;
	justify-content: center;
}
	
.chat-index {
	height: 100vh;
	background-color: $chatBackground;
	
	.scroll-style {
		
		.chat-body {
			display: flex;
			flex-direction: column;
			padding-top: 23rpx;
			
			.time-box {
				width: 100%;
				height: 100rpx;
				display: flex;
				justify-content: center;
				align-items: center;
				
				.time-style {
					font-size: 22rpx;
					background-color: rgba(213, 213, 213, 0.3);;
					padding: 5rpx 10rpx;
					border-radius: 8rpx;
					color: black;
				}
			}
			
			.self {
				justify-content: flex-end;
				position: relative;
			}
			
			.Ai {
				position: relative;
			}
			
			.item {
				display: flex;
				padding: 23rpx 30rpx;
				
				.right {
					background-color: $chatContentbgc;
				}
				
				.left {
					background-color: #FFFFFF;
				}
				
				.right::after {
					position: absolute;
					display: inline-block;
					content: '';
					width: 0;
					height: 0;
					left: 100%;
					top: 10px;
					border: 12rpx solid transparent;
					border-left: 12rpx solid $chatContentbgc;
				}
				
				.left::after {
					position: absolute;
					display: inline-block;
					content: '';
					width: 0;
					height: 0;
					top: 10px;
					right: 100%;
					border: 12rpx solid transparent;
					border-right: 12rpx solid #FFFFFF;
				}
				
				.content-text {
					position: relative;
					max-width: 486rpx;
					border-radius: 8rpx;
					word-wrap: break-word;
					padding: 24rpx 24rpx;
					margin: 0 24rpx;
					border-radius: 5px;
					font-size: 32rpx;
					font-family: PingFang SC;
					font-weight: 500;
					color: #333333;
					line-height: 42rpx;	
				}
				
				.content-img {
					margin: 0 24rpx;
				}
				
				.content-video {
					margin: 0 24rpx;
				}
				
				.img-style {
					width: 400rpx;
					height: auto;
					border-radius: 10rpx;
				}
				
				.video-style {
					width: 400rpx;
					height: 400rpx;
				}
				
				.avatar {
					display: flex;
					justify-content: center;
					width: 78rpx;
					height: 78rpx;
					background: #fff;
					border-radius: 50rpx;
					overflow: hidden;
					
					image {
						align-self: center;
					}
				}
			}
		}
	}
	
	.chat-bottom {
		width: 100%;
		
		.input-msg-box {
			width: 100% ;
			min-height: 150rpx;
			position: fixed;
			bottom: 0;
			background: #e6e6e6;
			
			.textarea-style {
				width: 100%;
				padding-top: 20rpx;
				display: flex;
				
				.out_textarea_box {
					width:65%;
					min-height: 70rpx;
					border-radius: 10rpx;
					margin-left: 10rpx;
					background: #f0f0f0;
					display: flex;
					align-items: center;
					
					textarea {
						width: 94%;
						padding: 0 3%;
						min-height: 42rpx;
						max-height: 200rpx;
						font-size: 32rpx;
						font-family: PingFang SC;
						color: #333333;
					}
				}
				
				.voice-btn {
					width: 10%;
					@extend center;
				}
				
				.more-btn {
					width: calc(25% - 25rpx);
					margin-left: 10rpx;
					@extend center;
				}
				
				.icon-style {
					width: 50rpx;
					height: 50rpx;
				}
			}
			
			.more-menu-box-min {
				width: 100%;
				height: 0rpx;
				display: none;
			}
			
			.more-menu-box-max {
				height: 400rpx;
				margin-top: 10rpx;
				border-top: 1rpx solid #d6d6d6;
				transition: height 1ms linear;
				display: block;
				
				.inner-menu-box {
					width: calc(100% - 20rpx);
					height: calc(360rpx - 10rpx);
					padding: 10rpx;
					
					.menu-box {
						width: 150rpx;
						height: 150rpx;
						margin: 12rpx calc((100% - 600rpx) / 8);
						float: left;
						
						.out-icon-area {
							width: 110rpx;
							height: 110rpx;
							background-color: #fff;
							border-radius: 20rpx;
							margin: 0 20rpx;
							@extend center;
							
							.i-style {
								width: 60rpx;
								height: 60rpx;
							}
						}
						
						.t-style {
							font-size: 24rpx;
							font-weight: 400;
							text-align: center;
							margin-top: 10rpx;
							color: #717171;
						}
					}
				}
			}
			
		}
	}
	
	.voice-mask{
		position:fixed;
		top:0;
		right:0;
		bottom:0;
		left:0;
		background-color: rgba(0,0,0,0.8);
	
		.inner-mask {
			display: flex;
			flex-direction: column;
			align-items: center;
			
			.voice-progress-box {
				min-width: 250rpx;
				height: 150rpx;
				margin-top: 60%;
				border-radius: 50rpx;
				background: #4df861;
				position: relative;
				@extend center; 
				
				.third-icon {
					width: 0;
					height: 0;
					border: 15rpx solid transparent;
					border-top: 15rpx solid #4df861;
					position: absolute;
					top: 100%;
					left: 45%;
					
					.progress-num {
						font-size: 50rpx;
						font-weight: 600;
					}
				}
			
			}
			
			.cancel-btn {
				width: 120rpx;
				height: 120rpx;
				clip-path: circle();
				margin-top: 50%;
				background: #080808;
				@extend center;
			}
			
			.cancelBtn {
				width: 150rpx;
				height: 150rpx;
				background-color: #ff0004;
				
			}
			
			.show-tips {
				width: 100%;
				margin-top: 50rpx;
				text-align: center;
				color: white;
				animation: 4s opacity2 1s infinite; 
				font-size: 30rpx;
				font-weight: 400;
				font-family: sans-serif;
			}
			
			@keyframes opacity2{
				0%{opacity:0}
				50%{opacity:.8;}
				100%{opacity:0;}
			}
			
			.bottom-area {
				position: fixed;
				bottom: 0rpx;
				width: 100%;
				height:190rpx;
				border-top: #BABABB 8rpx solid;
				border-radius: 300rpx 300rpx 0 0;
				background-image: linear-gradient(#949794,#e1e3e1);
				@extend center;
				
				.img-style {
					width: 50rpx;
					height: 50rpx;
				}
			}	
		}
	}
}
</style>

实际效果图:

主要代码就是这样,整个项目也已上传到资源模块,感兴趣可下载查看

  • 20
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

二九筒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值