📖故事的开始~
由于使用腾讯云的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
的镜像即可
也可以运行以下命令获取 Docker
镜像:
docker pull emqx/emqx
运行镜像
下载镜像完成后,指定对应的端口运行即可
也可以运行以下命令启动 Docker
容器
docker run -d --name emqx -p 1883:1883 -p 8083:8083 -p 8084:8084 -p 8883:8883 -p 18083:18083 emqx/emqx
查看EMQX是否启动成功
打开浏览器查看
打开成功说明已启动
用户名:admin
密码:public
设置wss访问
如果需要 wss
前缀进行访问需要我们自己到监听器
中设置域名证书
设置自己的域名证书
设置客户端认证
另外我们还需要设置EMQX
的客户端认证,防止任意用户都能访问得到
设置对应的URL
,URL
为你的后端接口地址用于认证访问的用户名和密码,另外你还需要设置对应域名的证书,启动验证服务器证书,启用TLS
可根据下图进行设置:
🌰客户端认证接口
以下为客户端认证接口的例子:
校验方式为客户端连接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',
};
}
💻服务端逻辑
接下来我们来编写客户端逻辑
- 客户端
Mqtt
连接配置接口 - 发送消息接口
- 撤回消息接口
- 消息已读接口
- 重新发送消息接口
获取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
的连接,用于转发客户端发送过来的消息给对应用户,使用Nestjs
的onApplicationBootstrap
生命周期,服务挂载成功后执行Mqtt
服务
登陆的用户名和密码需要固定,因为启动的Mqtt
是在后端,所以不需要动态密码,只需要在数据库中定义好固定的用户名和密码即可
如:username:admin
,password: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
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
配置的接口,断开重连,消息监听保存到stores
,Mqtt
连接状态的逻辑封装成方法
// 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_message 聊天消息表逻辑
chat_conversation 聊天会话表逻辑
chat_message_month 消息月份表逻辑
mqtt_user 用户登录账号表
mqtt逻辑
🌰前端Mqtt方法封装例子
Mqtt版本
🎉结语
到这里一个简单的1v1聊天的功能已经完全实现了,更多的功能扩展掘友们可自行扩展,也可以在下方评论区讨论。