MQTT 接入&Nestjs后端实现IM聊天室

2,289 阅读13分钟

📖故事的开始~

由于使用腾讯云的IM聊天费用成本太高,免费版本仅支持500人聊天,所以决定自己实现一个IM聊天降低成本

🤔️数据库设计

ChatConversation

该表用于记录会话ID,用于拉取聊天会话时的会话列表

以下为该表的数据结构:

  • conversation_id 为会话id设置为唯一key (id数据组成为:user_id_1_user_id_2,如:1_2)
  • user_id_1 为发起人或者接收人设置为索引(按大小排序,用户id小的放到 user_id_1,如:1)
  • user_id_2 为发起人或者接收人设置为索引(按大小排序,用户id大的放到 user_id_2,如:2)
  • user_id_1_unread 为未读消息数量
  • user_id_2_unread 为未读消息数量
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';

@Entity()
export class ChatConversation {
  // id
  @PrimaryGeneratedColumn({ unsigned: true, comment: 'id' })
  @Index({ unique: true })
  id?: number;

  // 创建时间
  @Column({
    type: 'datetime',
    comment: '创建时间',
    default: null,
  })
  create_time: string;

  // 删除标志 0代表存在 1代表删除
  @Column({
    type: 'tinyint',
    width: 1,
    comment: '删除标志 0代表存在 1代表删除',
    default: 0,
  })
  deleted?: number;

  // 会话id
  @Column({
    type: 'varchar',
    length: 256,
    comment: '会话id',
    default: null,
  })
  @Index('idx_conversation_id', ['conversation_id'], {
    unique: true,
  })
  conversation_id: string;

  // 用户id1
  @Column({
    type: 'int',
    comment: '用户id1',
    default: null,
  })
  @Index('idx_user_id_1', ['user_id_1'])
  user_id_1: number;

  // 用户id2
  @Column({
    type: 'int',
    comment: '用户id2',
    default: null,
  })
  @Index('idx_user_id_2', ['user_id_2'])
  user_id_2: number;

  // 最后消息内容
  @Column({
    type: 'json',
    comment: '最后消息内容',
    default: null,
  })
  last_msg_content: string;

  // 最后消息时间
  @Column({
    type: 'datetime',
    comment: '最后消息时间',
    default: null,
  })
  last_msg_time: string;

  // 最后消息id
  @Column({
    type: 'bigint',
    comment: '最后消息id',
    default: null,
  })
  last_msg_id: number;

  // 最后消息类型
  @Column({
    type: 'int',
    comment: '最后消息类型',
    default: null,
  })
  last_msg_type: number;

  // 最后发送人
  @Column({
    type: 'bigint',
    comment: '最后发送人',
    default: null,
  })
  last_msg_sender: number;

  // 用户id1未读数量
  @Column({
    type: 'int',
    comment: '用户id1未读数量',
    default: 0,
  })
  user_id_1_unread?: number;

  // 用户id1未读数量
  @Column({
    type: 'int',
    comment: '用户id1未读数量',
    default: 0,
  })
  user_id_2_unread?: number;
}

ChatMessage+动态月份

该表用于记录所有的聊天消息,该表为动态创建 如:chat_message_202406

我们使用一个定时任务去执行,每个月的最后一天创建下一个月的表

定时任务代码如下:

import { Injectable } from '@nestjs/common';
import * as dayjs from 'dayjs';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { ChatMessage } from 'src/chat-message/entities/chat-message.entity';
import { scheduledTasksDisabled } from 'src/scheduled.config';

@Injectable()
export class ScheduledChatMessageService {
  constructor(
    @InjectRepository(ChatMessage)
    private readonly chatMessageRepository: Repository<ChatMessage>,
    private readonly entityManager: EntityManager,
  ) {}

  // 动态创建表 EVERY_DAY_AT_MIDNIGHT
  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, {
    disabled: scheduledTasksDisabled,
  })
  async createDb() {
    // 当前日期
    const currentDate = dayjs(); 
    // 后一天的日期
    const tomorrowDate = dayjs().add(1, 'day'); 

    const isSameMonth = currentDate.isSame(tomorrowDate, 'month');

    // 后一天与当前月份相同
    if (isSameMonth) {
      return;
    }

    // 下一个月日期
    const nextMonthDate = dayjs().add(0, 'month');

    // 表名称
    const tableName = `chat_message_${nextMonthDate.format('YYYYMM')}`;
    
    // 查询要创建的表是否已经存在
    const hasTable = await this.chatMessageRepository.query(`
      SHOW TABLES LIKE '${tableName}'
    `);

    if (hasTable.length === 0) {
      await this.chatMessageRepository.query(`
        CREATE TABLE ${tableName} (
          id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'id',
          create_time DATETIME DEFAULT NULL COMMENT '创建时间',
          update_time DATETIME DEFAULT NULL COMMENT '更新时间',
          deleted TINYINT(1) DEFAULT 0 COMMENT '删除标志 0代表存在 1代表删除',
          conversation_id VARCHAR(256) DEFAULT NULL COMMENT '会话id',
          sender_id BIGINT DEFAULT 0 COMMENT '发送人id',
          msg_type SMALLINT DEFAULT NULL COMMENT '消息类型',
          msg_content JSON DEFAULT NULL COMMENT '消息内容',
          status TINYINT(1) DEFAULT 0 COMMENT '消息状态 0代表未发送 1已发送 2失败 3已撤回',
          is_read TINYINT(1) DEFAULT 0 COMMENT '是否已读',
          weight BIGINT DEFAULT 0 COMMENT '权重',
          PRIMARY KEY (id),
          INDEX idx_conversation_id (conversation_id)
        )
      `);
    }
  }
}

ChatMessageMonth

记录当前那个会话ID对应月份有聊天消息记录,用于拉去消息历史

以下为该表的数据结构:

import { Entity, PrimaryGeneratedColumn, Column, Index, Unique } from 'typeorm';

@Entity()
@Unique('idx_combination_key', ['conversation_id', 'month'])
export class ChatMessageMonth {
  // id
  @PrimaryGeneratedColumn({ unsigned: true, comment: 'id' })
  @Index({ unique: true })
  id?: number;

  // 创建时间
  @Column({
    type: 'datetime',
    comment: '创建时间',
    default: null,
  })
  create_time: string;

  // 会话id
  @Column({
    type: 'varchar',
    length: 256,
    comment: '会话id',
    default: null,
  })
  conversation_id: string;

  // 会话id
  @Column({
    type: 'varchar',
    length: 256,
    comment: '会话id',
    default: null,
  })
  month: string;
}

MqttUser

记录用户登录mqtt的用户名和密码

import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class MqttUser {
  // id
  @PrimaryGeneratedColumn({ unsigned: true, comment: 'id' })
  @Index({ unique: true })
  id?: number;

  // 创建时间
  @Column({
    type: 'datetime',
    comment: '创建时间',
    default: null,
  })
  create_time: string;

  // 更新时间
  @Column({
    type: 'datetime',
    comment: '更新时间',
    default: null,
  })
  update_time?: string;

  // 用户id
  @Column({
    type: 'int',
    comment: '用户id',
    default: null,
  })
  @Index('idx_user_id', ['user_id'])
  user_id: number;

  // 密码
  @Column({
    type: 'varchar',
    comment: '密码',
    default: null,
  })
  password: string;
}

🍚启动Mqtt

下载镜像

我们使用的是 MQTT 的开源版本 EMQX

我们直接在Docker上下载emqx/emqx的镜像即可

image.png

也可以运行以下命令获取 Docker 镜像:

docker pull emqx/emqx

运行镜像

下载镜像完成后,指定对应的端口运行即可

image.png

也可以运行以下命令启动 Docker 容器

docker run -d --name emqx -p 1883:1883 -p 8083:8083 -p 8084:8084 -p 8883:8883 -p 18083:18083 emqx/emqx

查看EMQX是否启动成功

打开浏览器查看

image.png

image.png

打开成功说明已启动

用户名:admin
密码:public

设置wss访问

如果需要 wss前缀进行访问需要我们自己到监听器中设置域名证书

image.png

设置自己的域名证书

image.png

设置客户端认证

另外我们还需要设置EMQX的客户端认证,防止任意用户都能访问得到

image.png

设置对应的URLURL为你的后端接口地址用于认证访问的用户名密码,另外你还需要设置对应域名的证书,启动验证服务器证书,启用TLS

可根据下图进行设置:

image.png

🌰客户端认证接口

以下为客户端认证接口的例子:

校验方式为客户端连接Mqtt时传入的用户名和密码,与数据库中的用户名和密码进行校验匹配是否正确,如果正确将通过认证允许连接Mqtt,不正确则拒绝连接

// mqtt.controller.ts

// 授权
@Post('/auth/:peercert')
@Header('X-Request-Source', 'EMQX')
@HttpCode(HttpStatus.OK)
async auth(@Body() data: AuthzDto, @Param('id') peercert: string) {
    try {
      const res = await this.mqttService.auth(data);

      return res;
    } catch (e) {
      this.logger.error(e, `mqtt授权错误 ${peercert}`);

      throw new HttpException('服务器错误', HttpStatus.BAD_REQUEST);
    }
}
// mqtt.service.ts

 // 授权
// allow:允许发布或订阅。
// deny:拒绝发布或订阅。
async auth(data: AuthzDto) {
    this.logger.log(data, 'mqtt授权');

    const _mqttUser = await this.entityManager.findOne(MqttUser, {
      where: {
        user_id: Number(data.username),
        password: data.password,
      },
    });

    if (!_mqttUser) {
      return {
        result: 'deny',
      };
    }

    return {
      result: 'allow',
    };
}

💻服务端逻辑

接下来我们来编写客户端逻辑

  1. 客户端Mqtt连接配置接口
  2. 发送消息接口
  3. 撤回消息接口
  4. 消息已读接口
  5. 重新发送消息接口

获取Mqtt连接配置

该接口设置客户端连接的配置,包含(用户名,密码,客户端id,订阅主题)

为了确保安全性,客户端每次获取配置都需要重新生成一次密码,用于刷新之前的密码,防止被盗用

  • client_id:客户端id为用户id即可保持唯一性
  • sub_topics:订阅主题为用户id,即订阅自身的消息,获取那个用户给你发送了消息
  • password:密码通过crypto 包的 randomBytes生成32位随机密码
import { randomBytes } from 'crypto';
// 随机密码
export const generateRandomPassword = (length: number): string => {
  const buffer = randomBytes(length);
  const password = buffer.toString('base64').slice(0, length);

  return password;
}

以下获取Mqtt连接配置接口具体逻辑:

  // mqtt连接配置
  async connConfig(userId: number) {
    // 查找是否创建过
    const _mqttUser = await this.entityManager.findOne(MqttUser, {
      where: {
        user_id: userId,
      },
    });

    // 用户数据
    let _userData: {
      user_id: number;
      password: string;
    } = _mqttUser;

    // 用户没有连接过创建用户
    if (!_mqttUser) {
      let newMqttUser = new MqttUser();

      newMqttUser = {
        create_time: dayjs().format('YYYY-MM-DD HH:mm:ss'),
        user_id: userId,
        password: generateRandomPassword(32),
      };

      _userData = newMqttUser;

      await this.entityManager.save(MqttUser, newMqttUser);
    } 
    // 已创建过
    else {
      _userData.password = generateRandomPassword(32);

      // 更新密码
      await this.entityManager
        .createQueryBuilder()
        .update(MqttUser)
        .set({
          password: _userData.password,
        })
        .where('id = :id', { id: _mqttUser.id })
        .execute();
    }

    return {
      ..._userData,
      server: this.configService.get('mqtt_host'),
      port: this.configService.get('mqtt_port'),
      client_id: `chat_${userId}`,
      sub_topics: [`chat_${userId}`]
    };
  }

客户端发送消息

接下来我们还需编写客户端发送消息给其他用户的接口

服务端转发

在发送消息前我们还需要在服务端创建多一个Mqtt的连接,用于转发客户端发送过来的消息给对应用户,使用NestjsonApplicationBootstrap生命周期,服务挂载成功后执行Mqtt服务

登陆的用户名和密码需要固定,因为启动的Mqtt是在后端,所以不需要动态密码,只需要在数据库中定义好固定的用户名和密码即可

如:username:adminpassword:123456

  // 服务启动自动连接
  onApplicationBootstrap() {
    // 连接的配置 
    const connectOptions = {
      protocol: wss,
      port: 8084,
      host: 127.0.0.1,
    };

    const { protocol, host, port } = connectOptions;

    const connectUrl = `${protocol}://${host}:${port}/mqtt`;

    const options = {
      clientId: `emqx_nestjs_${Math.random().toString(16).substr(2, 8)}`,
      // 设置为 false 以在离线时接收 QoS 1 和 2 消息
      clean: true,
      // 30 * 1000毫秒,收到 CONNACK 之前等待的时间
      connectTimeout: 4000,
      // 登陆用户名称
      username: 'admin',
      // 密码
      password: '123456',
      // 1000毫秒,两次重新连接的间隔。设置为 可禁用自动重新连接0
      reconnectPeriod: 1000,
      // key
      key: 域名证书.key,
      cert: 域名证书.crt,
      // 允许自签名证书
      rejectUnauthorized: false,
    };

    this.client = mqtt.connect(connectUrl, options);

    // 连接成功
    this.client.on('connect', () => {
      console.log(`${protocol}: Connected`);
    });

    // 客户端重连
    this.client.on('reconnect', () => {
      console.log(`Reconnecting(${protocol}):`);
    });

    // 当客户端无法连接(即 connack rc != 0)或发生解析错误时发出
    this.client.on('error', (error) => {
      console.log(`Cannot connect(${protocol}):`, error);
    });

    // 当客户端收到发布数据包时发出
    this.client.on('message', (topic, payload) => {
      console.log('Received Message:', topic, payload.toString());
    });

    // 当客户端收到发布数据包时发出
    this.client.on('close', () => {
      console.log('Received close:');
    });
  }

以上就是一个简单的后端Mqtt连接逻辑

发送消息接口逻辑

创建完后端Mqtt连接后,我们就可以开始编写客户端发送消息给后端,然后后端通过自身的Mqtt进行转发的逻辑了,该接口用于接收,文本消息图片消息视频消息

  • conversation_id:会话id用于记录谁和谁进行聊天,按用户id进行组合,id小的放在前面,如:1_2
  • msg_content:接收文本消息
  • msg_url:接收图片连接或视频连接
  • msg_cover:接收视频封面
  • weight:客户端传入时间戳,用于消息的排序(因为客户端发送消息时调用消息接口是异步的,使用消息的创建时间进行排序会不准确,此外我们还需要写一个后端接口对前端的时间进行一个校准
  • status:记录该消息是否发送成功
  • publishAsync:使用后端的Mqtt转发消息给对应用户
  • user_id_1:有可能是自身id,也有可能是接收者id,具体要看那个先发起聊天,并且那个id比较小
  • user_id_2:有可能是自身id,也有可能是接收者id,具体要看那个先发起聊天,并且那个id比较大
  // 发送消息
  async sendMessage(data: SendMessageDto, userId: number) {
    // 判断发送人是否是自己
    if (data.receiver_id === userId) {
      return {
        code: -1,
        msg: '消息发送错误',
      };
    }

    // 用户信息
    const user = await this.entityManager.findOne(User, {
      where: {
        id: userId,
      },
    });

    // 会话id
    let conversation_id = '';

    const last_msg_content = {} as any;
    
    // 文本消息
    if (data.msg_content) {
      last_msg_content.content = data.msg_content;
    } else {
      // 连接
      if (data.msg_url) {
        last_msg_content.url = data.msg_url;
      }

      // 封面
      if (data.msg_cover) {
        last_msg_content.cover = data.msg_cover;
      }
    }

    const addData = {
      // 最后消息内容
      last_msg_content: last_msg_content,
      // 最后消息时间
      last_msg_time: dayjs().format('YYYY-MM-DD HH:mm:ss'),
      // 最后发送人
      last_msg_sender: userId,
    } as any;

    // 排序数值小的在前
    if (userId > data.receiver_id) {
      conversation_id = `${data.receiver_id}_${userId}`;

      // 接收者
      addData.user_id_1 = data.receiver_id;
      // 未读数量
      addData.user_id_1_unread = () => 'GREATEST(user_id_1_unread + 1, 0)';

      addData.user_id_2 = userId;
    } else {
      conversation_id = `${userId}_${data.receiver_id}`;

      addData.user_id_1 = userId;

      // 接收者
      addData.user_id_2 = data.receiver_id;
      // 未读数量
      addData.user_id_2_unread = () => 'GREATEST(user_id_2_unread + 1, 0)';
    }

    // 会话id
    addData.conversation_id = conversation_id;
    
    // 查找双方是否已经创建过会话
    const conversations = await this.entityManager
      .getRepository(ChatConversation)
      .createQueryBuilder('c')
      .where({
        conversation_id: conversation_id,
      })
      .getOne();

    // 添加消息记录-可查看下方会话消息逻辑
    const _message = await this.chatMessageService.add({
      conversation_id: conversation_id,
      sender_id: userId,
      msg_type: data.msg_type,
      msg_content: addData.last_msg_content,
      status: StatusEnum['成功'],
      create_time: addData.last_msg_time,
      weight: data.weight,
    });

    // 最后消息id
    addData.last_msg_id = _message.insertId;
    // 最后消息类型
    addData.last_msg_type = data.msg_type;

    // 第一次创建会话
    if (!conversations) {
      if (addData.user_id_1_unread) {
        addData.user_id_1_unread = 1;
      }

      if (addData.user_id_2_unread) {
        addData.user_id_2_unread = 1;
      }

      this.chatConversationService.add(addData);
    }
    // 更新会话
    else {
      this.chatConversationService.update(addData, conversations.id);
    }

    // 添加会话月份-判断该月份是否生成过会话
    this.chatMessageMonthService.add({
      conversation_id: conversation_id,
      month: dayjs().format('YYYYMM'),
    });
    
    // 消息内容
    const content = {
      id: addData.last_msg_id,
      msg_content: addData.last_msg_content,
      msg_type: addData.last_msg_type,
      receiver_id: data.receiver_id,
      create_time: dayjs(Number(data.weight)).format('YYYY-MM-DD HH:mm:ss'),
      is_read: 0,
      sender_id: userId,
      conversation_id: addData.conversation_id,
      avatar: user.avatar,
      nick_name: user.nick_name,
      weight: data.weight,
    };

    let status = 0;

    // 调用后端的mqtt转发消息
    try {
      await this.client.publishAsync(
        `chat_${data.receiver_id}`,
        JSON.stringify(content),
        {
          qos: 0,
        },
      );
        
      status = StatusEnum['成功'];
    } catch (e) {
      this.logger.error(e, 'mqtt publishAsync 错误');

      this.chatMessageService.updateStatus({
        status: StatusEnum['失败'],
        id: _message.insertId,
      });

      status = StatusEnum['失败'];
    }

    return {
      data: {
        id: addData.last_msg_id,
        conversation_id: addData.conversation_id,
        create_time: addData.last_msg_time,
        msg_type: addData.last_msg_type,
        msg_content: addData.last_msg_content,
        status: status,
        sender_id: addData.last_msg_sender,
        is_read: 0,
        weight: data.weight,
      },
    };
  }

以下为:发送消息接口的DTO

import { IsNotEmpty, IsOptional, Validate } from 'class-validator';

export class SendMessageDto {
  // 发送人
  @IsNotEmpty()
  receiver_id: number;

  // 消息内容
  @IsOptional()
  msg_content: string;

  // 连接
  @IsOptional()
  msg_url: string;

  // 封面
  @IsOptional()
  msg_cover: string;

  // 消息类型
  @IsNotEmpty()
  msg_type: MsgTypeEnum;

  // 权重
  @IsNotEmpty()
  weight: number;
}

export enum MsgTypeEnum {
  '文本' = 1,
  '图片' = 2,
  '视频' = 3,
  '撤回' = 5,
  '已读' = 6,
}

记录聊天消息逻辑

以下逻辑为添加发送的消息到ChatMessage+动态月份动态表中

  • add:添加消息记录到ChatMessage+动态月份动态表中
  • updateStatus:更新消息记录状态,可以是:成功,失败,撤回
  • updateRead:更新消息已读状态
  • resendMessage:重新发送消息,更新消息状态,并且更新消息的发送时间,确保重新发送的消息在最前面
import { Injectable } from '@nestjs/common';
import * as dayjs from 'dayjs';
import { EntityManager } from 'typeorm';
import { AddDto } from './dto/add.dto';
import { InjectEntityManager } from '@nestjs/typeorm';

@Injectable()
export class ChatMessageService {
  @InjectEntityManager()
  private entityManager: EntityManager;

  // 添加消息记录
  async add(obj: AddDto) {
    // 查询聊天消息动态表
    const sqlQuery = `INSERT INTO ${`chat_message_${dayjs().format('YYYYMM')}`} (conversation_id, sender_id, msg_type, msg_content, status, create_time, weight) VALUES (?, ?, ?, ?, ?, ?, ?)`;

    const res = await this.entityManager.query(sqlQuery, [
      obj.conversation_id,
      obj.sender_id,
      obj.msg_type,
      JSON.stringify(obj.msg_content),
      obj.status,
      obj.create_time,
      obj.weight,
    ]);

    return res;
  }

  // 更新消息状态
  async updateStatus(obj) {
    const sqlQuery = `UPDATE ${`chat_message_${dayjs().format('YYYYMM')}`} SET status = ?, update_time = ? WHERE id = ?`;

    const res = await this.entityManager.query(sqlQuery, [
      obj.status,
      dayjs().format('YYYY-MM-DD HH:mm:ss'),
      obj.id,
    ]);

    return res;
  }

  // 更新消息已读状态
  async updateRead(obj: {
    is_read: number;
    conversation_id: string;
    sender_id: number;
  }) {
    const sqlQuery = `UPDATE ${`chat_message_${dayjs().format('YYYYMM')}`} SET is_read = ?, update_time = ? WHERE conversation_id = ? AND sender_id = ?`;

    const res = await this.entityManager.query(sqlQuery, [
      obj.is_read,
      dayjs().format('YYYY-MM-DD HH:mm:ss'),
      obj.conversation_id,
      obj.sender_id,
    ]);

    return res;
  }

  // 重新发送消息
  async resendMessage(obj: {
    sender_id: number;
    conversation_id: string;
    status: number;
    weight: number;
    id: number;
  }) {
    const sqlQuery = `UPDATE ${`chat_message_${dayjs().format('YYYYMM')}`} SET status = ?, update_time = ?, weight = ? WHERE id = ?`;

    const res = await this.entityManager.query(sqlQuery, [
      obj.status,
      dayjs().format('YYYY-MM-DD HH:mm:ss'),
      obj.weight,
      obj.id,
    ]);

    return res;
  }
}

时间校准接口(用于前端矫正时间)

该接口只需返回服务时间接口,用于前端的时间校准

  // 时间校准
  async timeCalibration() {
    return {
      time: new Date().getTime(),
    };
  }

前端时间校准逻辑为:

当前时间 - 服务器时间,获取到的是服务器和当前本地时间之间的差值

调用发送消息接口的时候,weight传入:当前时间-时间差值,即可

时间校准接口为每次进入页面调用一次

// 时间校准
const initTimeCalibration = async () => {
  const res = await timeCalibration();

  if (res.code === 0) {
    timeDifference.value = dayjs().valueOf() - res.time;
  }
};

// 前端发送消息接口
await sendMessage({
  receiver_id: 接收人id
  msg_content: 消息文本,
  msg_type: 消息类型,
  // 消息排序
  weight: dayjs(dayjs().valueOf() - unref(timeDifference)).valueOf(),
});

撤回消息接口逻辑

该接口用于修改消息的状态,撤回客户端显示的消息,此外还要判断消息是否超过2分钟,超过2分钟不可撤回,如果不添加判断客户端则可以一直进行撤回

此外我们还需要通知客户端是那个用户撤回了消息,通过后端Mqtt进行转发,详细可查看下方的sendRevokeMessage方法

  // 撤回消息
  async revokeMessage(data: RevokeMessageDto, userId: number) {
    const tableName = `chat_message_${dayjs().format('YYYYMM')}`;

    // 动态表查询
    const query = `
      SELECT *
      FROM ${tableName}
      WHERE id = ? AND sender_id = ?
    `;

    // 查询是否有该消息
    const res = await this.entityManager.query(query, [data.id, userId]);

    if (res.length === 0) {
      return {
        code: 100,
        msg: '撤回失败',
      };
    }

    const message = res[0] as any;

    const startTime = dayjs(message.create_time);

    const endTime = dayjs();

    // 只能撤回2分钟内的消息
    const minutesDifference = endTime.diff(startTime, 'minute');
    
    // 判断发送消息时间
    if (minutesDifference < 2) {
      await this.chatMessageService.updateStatus({
        status: StatusEnum['撤回'],
        id: data.id,
      });

      this.sendRevokeMessage(message.conversation_id, message.id, userId);
    } else {
      return {
        code: 100,
        msg: '只能撤回2分钟以内的消息',
      };
    }

    return {
      data: {},
    };
  }

下发撤回消息到客户端,告诉客户端是那个用户撤回了,并且客户端不展示这条消息

  // 发送撤回mqtt
  async sendRevokeMessage(
    conversationId: string,
    messageId: number,
    userId: number,
  ) {
  
   // 查询当前撤回的消息是那个会话
    const res = await this.entityManager
      .getRepository(ChatConversation)
      .createQueryBuilder('c')
      .where({
        conversation_id: conversationId,
      })
      .getOne();
    
    // 更新聊天消息表记录
    this.chatConversationService.update(
      {
        last_msg_time: dayjs().format('YYYY-MM-DD HH:mm:ss'),
        last_msg_sender: userId,
        last_msg_type: MsgTypeEnum['撤回'],
      },
      res.id,
    );
    
    // 接收者
    let receiver_id = null;

    // 判断接收者id,此处判断 user_id_1,user_id_2 那个是自己从而判断知道接收者的id是那个
    if (userId === res.user_id_1) {
      receiver_id = res.user_id_2;
    } else {
      receiver_id = res.user_id_1;
    }

    // 下发消息给客户端
    await this.client.publishAsync(
      `chat_${receiver_id}`,
      JSON.stringify({
        msg_id: messageId,
        msg_type: MsgTypeEnum['撤回'],
        conversation_id: conversationId,
      }),
      {
        qos: 0,
      },
    );
  }

消息已读接口逻辑

以下接口为,客户端查看了消息,通知对方已读的接口,通过传入conversation_id会话id,来批量设置消息已读

更新ChatConversation会话表自己的未读数量,并且告诉对方已读,并且客户端界面显示已读标识

  // 消息已读
  async messageRead(data: MessageReadDto, userId: number) {
  
    // 获取当前会话信息
    const res = await this.entityManager
      .getRepository(ChatConversation)
      .createQueryBuilder('c')
      .where({
        conversation_id: data.conversation_id,
      })
      .andWhere('user_id_1 = :userId OR user_id_2 = :userId', { userId })
      .getOne();

    if (!res) {
      return;
    }

    const update = {} as any;

    // 发送人id
    let senderId = null;

    // 读取自己的消息
    if (userId === res.user_id_1) {
      update.user_id_1_unread = 0;
    
      senderId = res.user_id_2;
    } else {
      update.user_id_2_unread = 0;
      senderId = res.user_id_1;
    }

    // 更新会话记录
    await this.chatConversationService.update(update, res.id);
    
    // 更新聊天消息记录更新已读
    await this.chatMessageService.updateRead({
      is_read: 1,
      sender_id: senderId,
      conversation_id: data.conversation_id,
    });

    // Mqtt下发给发送人-消息已读
    await this.client.publishAsync(
      `chat_${senderId}`,
      JSON.stringify({
        sender_id: senderId,
        msg_type: MsgTypeEnum['已读'],
        is_read: 1,
        conversation_id: data.conversation_id,
      }),
      {
        qos: 0,
      },
    );
  }
chatConversationService.update方法逻辑

更新ChatConversation表中的我的未读数量

  // 更新会话记录
  async update(obj: any, id: number) {
    return await this.entityManager
      .createQueryBuilder()
      .update(ChatConversation)
      .set(obj)
      .where('id = :id', { id: id })
      .execute();
  }
chatMessageService.updateRead方法逻辑

更新ChatMessage+动态月份表中未读聊天消息记录的状态

  // 更新消息已读状态
  async updateRead(obj: {
    is_read: number;
    conversation_id: string;
    sender_id: number;
  }) {
    const _chatMessageMonth = await this.entityManager.find(ChatMessageMonth, {
      where: {
        conversation_id: obj.conversation_id,
      },
      order: {
        month: 'DESC',
      },
    });

    for (let i = 0; i < _chatMessageMonth.length; i++) {
      const _chatMessageMonthData = _chatMessageMonth[i];

      const tableName = `chat_message_${_chatMessageMonthData.month}`;

      // 分页查询
      const query = `
         SELECT c.id, c.is_read
         FROM ${tableName} c
         JOIN user u ON c.sender_id = u.id
         WHERE c.status <> 3 AND c.conversation_id = '${obj.conversation_id}' AND c.is_read = 0 AND c.sender_id = '${obj.sender_id}'
       `;

      const result = await this.entityManager.query(query);

      if (result.length > 0) {
        const ids = result.map((item) => item.id);

        const sqlQuery = `UPDATE ${tableName} SET is_read = ?, update_time = ? WHERE conversation_id = ? AND sender_id = ? AND id IN (${ids.join(',')})`;

        await this.entityManager.query(sqlQuery, [
          obj.is_read,
          dayjs().format('YYYY-MM-DD HH:mm:ss'),
          obj.conversation_id,
          obj.sender_id,
        ]);
      }
    }
  }

重新发送消息接口逻辑

该接口用于客户端消息发送失败,重新发送消息的接口

重新设置消息发送时间,并且使用后端Mqtt转发给对应的接收者

  // 重新发送消息
  async resendMessage(data: ResendMessageDto, userId: number) {
    const tableName = `chat_message_${dayjs().format('YYYYMM')}`;

    // 聊天消息记录表查询是否有该消息
    const query = `
        SELECT *
        FROM ${tableName}
        WHERE id = ? AND sender_id = ?
      `;

    const res = await this.entityManager.query(query, [data.id, userId]);

    if (res.length === 0) {
      return;
    }

    const message = res[0];
    
    // 重新设置聊天消息记录状态
    await this.chatMessageService.resendMessage({
      status: StatusEnum['成功'],
      id: data.id,
      weight: data.weight,
      sender_id: userId,
      conversation_id: data.conversation_id,
    });
    
    // 后端mqtt转发消息给对应接收者
    await this.client.publishAsync(
      `chat_${data.receiver_id}`,
      JSON.stringify({
        id: message.id,
        msg_content: message.msg_content,
        msg_type: message.msg_type,
        receiver_id: data.receiver_id,
        create_time: dayjs(Number(message.weight)).format(
          'YYYY-MM-DD HH:mm:ss',
        ),
        is_read: 0,
        sender_id: userId,
        conversation_id: data.conversation_id,
      }),
      {
        qos: 0,
      },
    );

    return {
      status: 1,
      weight: data.weight,
    };
  }
}

到此已经基本实现了,客户端需要的全部接口逻辑了,接下来我们需要到客户端中去接入服务端中的接口

🌞客户端逻辑

因为我使用的是微信小程序接入,所以此处以小程序举例🌰:

onLaunch中分包异步加载mqtt.min.js,获取实例,并且我们把获取Mqtt配置的逻辑抽离出来封装成方法useMqtt.js

微信Mqtt使用版本

export default {
  onLaunch() {
    // 分包加载
    require
      .async('./mqtt-call/static/mqtt.min.js')
      .then((res) => {
        wx.$Mqtt = res;

        this.initCallManagerOk = true;
      })
      .catch((error) => {
        console.log('error', error);
        console.error(`path: ${error}`);
      });
  },
  data() {
    return {
      /**
       * 分包初始化完成
       *
       * @type {Boolean}
       */
      initCallManagerOk: false,
    };
  },
  methods: {
    // 初始化mqtt
    async initMqtt() {
        if (!wx.mqtt) {
          const mqtt = useMqtt();

          wx.mqtt = mqtt;
        }
    },
  },
  watch: {
    // 分包初始化完成
    initCallManagerOk(value) {
      if (value) {
          // 初始化mqtt
          this.initMqtt();
      }
    },
  },
};

初始化Mqtt

把获取Mqtt配置的接口,断开重连,消息监听保存到storesMqtt连接状态的逻辑封装成方法

// api
import { getMqttConnConfig } from '@/api/base';

// stores
import { useChatStore } from '@/stores/chat';

class MQTT {
  constructor() {
    // mqtt连接
    this.client = null;
    // 订阅id
    this.subTopics = [];
    // 配置
    this.config = {};
    // 超时次数
    this.timeOutNumber = 0;
    // 最大超时次数
    this.maxTimeOutNumber = 5;
    // 发送消息时间队列
    this.publishTimeList = [];
    // 重试方法
    this.retryFn = null;

    // 获取配置
    this.handleGetConfig();
  }

  // 获取配置
  async handleGetConfig(callback = () => {}) {
    console.warn('获取配置');
    this.timeOutNumber = 0;

    const useChat = useChatStore();
    useChat.setMqttStatus(0);

    this.unsubscribe(async () => {
      this.client = null;

      try {
        const res = await getMqttConnConfig();

        if (res.code === 0) {
          const data = res.data;

          console.log('getMqttConnConfig', data);

          // 配置
          this.config = {
            url: `wxs://${data.server}:${data.port}/mqtt`,
            clientId: data.client_id,
            password: data.password,
            username: `${data.user_id}`,
            // 连接超时时长,收到 CONNACK 前的等待时间,单位为毫秒,默认 30000 毫秒
            connectTimeout: 5000,
            //  默认为 true,是否清除会话。当设置为 true 时,断开连接后将清除会话,订阅过的 Topics 也将失效。当设置为 false 时,离线状态下也能收到 QoS 为 1 和 2 的消息
            clean: false,
            // 单位为秒,数值类型,默认为 60 秒,设置为 0 时禁止
            keepalive: 60,
            // 重连间隔时间,单位为毫秒,默认为 1000 毫秒,注意:当设置为 0 以后将取消自动重连
            reconnectPeriod: 1000,
            protocolVersion: 4,
          };

          this.subTopics = data.sub_topics;

          // 连接mqtt
          this.linkMqtt();

          const useChat = useChatStore();
          useChat.setMqttStatus(1);

          callback();
        }
      } catch (error) {
        console.warn('handleGetConfig', error);
      }
    });
  }

  //连接mqtt
  linkMqtt() {
    try {
      const { url, ...options } = this.config;
      console.warn('连接mqtt', this.config);

      this.client = wx.$Mqtt.connect(url, options);

      // 注册监听事件
      this.addEventListener();
    } catch (error) {
      console.warn('连接错误', error);
    }
  }

  //注册监听事件
  addEventListener() {
    // 当连接成功时触发,参数为 connack
    this.client.on('connect', (res) => {
      console.warn('连接成功', res);

      const useChat = useChatStore();
      useChat.setMqttStatus(10);

      this.timeOutNumber = 0;

      // 订阅一个名为 testtopic QoS 为 0 的 Topic
      const topicData = {};
      this.subTopics.forEach((v) => {
        topicData[v] = { qos: 0 };
      });

      // 订阅一个或者多个 topic 的方法,当连接成功需要订阅主题来获取消息,该方法包含三个参数:
      this.client.subscribe(topicData, (error) => {
        if (error) {
          console.warn('订阅失败', error);
        } else {
          console.warn('订阅成功', this.subTopics);

          // 订阅成功回调
          if (this.onSubscrive) {
            this.onSubscrive();
          }
        }
      });
    });

    // 当断开连接后,经过重连间隔时间重新自动连接到 Broker 时触发 - 重连间隔 1000
    this.client.on('reconnect', () => {
      console.warn('重新连接...');

      // 获取配置
      // this.handleGetConfig();
    });

    // 在断开连接以后触发-重新发起配置连接
    this.client.on('close', (e) => {
      console.warn('断开连接', e);

      // 重新获取配置
      this.handleGetConfig();
    });

    // 在收到 Broker 发送过来的断开连接的报文时触发,参数 packet 即为断开连接时接收到的报文,MQTT 5.0 中的功能
    this.client.on('disconnect', (e) => {
      console.log('disconnect', e);
    });

    // 当客户端下线时触发
    this.client.on('offline', (e) => {
      console.warn('当客户端下线', e);

      const useChat = useChatStore();
      useChat.setMqttStatus(20);
    });

    // 当客户端无法成功连接时或发生解析错误时触发,参数 error 为错误信息
    this.client.on('error', (e) => {
      console.warn('当客户端无法成功连接时或发生解析错误时触发', e);

      // 获取配置
      this.handleGetConfig();

      this.retryFn = setTimeout(() => {
        this.timeOutNumber += 1;

        if (this.timeOutNumber > this.maxTimeOutNumber) {
          clearTimeout(this.retryFn);

          return;
        }

        console.warn('重试mqtt');

        // 获取配置
        this.handleGetConfig();
      }, 3000);
    });

    // 当客户端收到一个发布过来的 Payload 时触发,
    // 其中包含三个参数,topic、payload 和 packet,其中 topic 为接收到的消息的 topic,payload 为接收到的消息内容,packet 为 MQTT 报文信息,
    // 其中包含 QoS、retain 等信息
    this.client.on('message', (topic, message) => {
      console.log('message', topic, JSON.parse(message.toString()));

      const useChat = useChatStore();

      useChat.setNewMessage(JSON.parse(message.toString()));
    });
  }

  // 断开mqtt
  closeMqtt() {
    console.warn('断开mqtt');

    if (this.client) {
      this.client.end(true);

      const useChat = useChatStore();
      useChat.setMqttStatus(20);
    }
  }

  // mqtt发送消息
  // topic: 要发送的主题,为字符串
  // message: 要发送的主题的下的消息,可以是字符串或者是 Buffer
  // options: 可选值,发布消息时的配置信息,主要是设置发布消息时的 QoS、Retain 值等。
  // callback: 发布消息后的回调函数,参数为 error,当发布失败时,该参数才存在
  publish(topic, msg, time) {
    console.warn('mqtt发送消息', topic, msg);

    this.client.publish(topic, JSON.stringify(msg), {}, (error) => {
      // 发送失败
      if (error) {
        console.warn('mqtt发送消息失败', error);
      } else {
        console.warn('mqtt发送消息成功');

        if (time) {
          // 上一次发送时间
          console.warn('上一次发送时间', Date.now());
        }
      }
    });
  }

  // 立刻获取配置
  immediatelyGet() {
    //页面销毁结束订阅
    this.closeMqtt();
    // 发送消息时间队列
    this.publishTimeList = [];
    // 发送消息时间队列
    this.subTopics = [];
    // 配置
    this.config = {};

    // 获取配置
    this.handleGetConfig();
  }

  // 取消订阅
  unsubscribe(callback = () => {}) {
    console.log('subTopics', this.subTopics);
    // 有订阅过
    if (this.subTopics.length > 0 && this.client) {
      // 取消订阅
      this.client.unsubscribe(this.subTopics, (error) => {
        if (error) {
          console.warn('取消订阅错误', error);
        } else {
          console.warn('取消订阅成功');

          callback();
        }
      });
    } else {
      callback();
    }
  }

  // 重置所有状态
  reset(callback = () => {}) {
    console.warn('subTopics', this.subTopics);

    // 有订阅过
    if (this.subTopics.length > 0) {
      console.warn('开始取消订阅client', this.client);
      console.warn('开始取消订阅subTopics', this.subTopics);
      
      // 取消订阅
      this.client.unsubscribe(this.subTopics, (error) => {
        if (error) {
          console.warn('取消订阅错误', error);
        } else {
          console.warn('取消订阅成功');

          // 发送消息时间队列
          this.publishTimeList = [];
          // -1连接失败 0未连接 1连接成功 10连接中 2订阅成功 -3连接关闭
          // 配置
          this.config = {};

          //页面销毁结束订阅
          this.closeMqtt();

          callback();
        }
      });
    } else {
      //页面销毁结束订阅
      this.closeMqtt();

      callback();
    }
  }
}

export default function useMqtt() {
  const mqtt = new MQTT();

  return {
    mqtt,
    // 主动断开mqtt方法
    close: (callback = () => {}) => {
      if (wx.mqtt && wx.mqtt.mqtt.reset) {
        wx.mqtt.mqtt.reset(callback);
      } else {
        callback();
      }
    },
  };
}

聊天室展示

以下展示了接入服务端接口完成后的聊天室效果

发送者视角

chat.gif

接收者视角

chat2.gif

接收者已读消息视角

chat3.gif

🌰后端代码例子

chat_message 聊天消息表逻辑

定时任务创建ChatMessage+动态月份

chat_conversation 聊天会话表逻辑

chat-conversation

chat_message_month 消息月份表逻辑

chat_message_month

mqtt_user 用户登录账号表

mqtt-user

mqtt逻辑

mqtt

🌰前端Mqtt方法封装例子

init-mqtt.js

Mqtt版本

微信Mqtt使用版本

🎉结语

到这里一个简单的1v1聊天的功能已经完全实现了,更多的功能扩展掘友们可自行扩展,也可以在下方评论区讨论。