阅读前
先关注、收藏、不然后面可能找不到了
一、简介
在即时通讯(IM)应用中,会话未读数和红点是用户交互体验的重要组成部分。未读数指的是用户尚未阅读的消息数量,通常以数字形式显示在会话列表、应用图标等位置;红点则是一种视觉提示,用于告知用户有新的内容需要关注,但不显示具体数量。
未读数和红点管理涉及以下几个核心概念:
-
未读消息数:特定会话中用户未阅读的消息数量
-
总未读数:所有会话的未读消息总数
-
红点状态:是否有未读内容的布尔状态
-
免打扰会话:用户设置为免打扰的会话,其未读数不计入总未读数
-
多端同步:在不同设备间保持未读数的一致性
未读数和红点的准确性直接影响用户体验:
-
准确的未读数帮助用户了解需要处理的消息量
-
红点提示用户有新内容,避免错过重要信息
-
多端一致性确保用户在不同设备上获得一致的体验
二、业界的生产企业级方案
方案一:服务端管理方案
设计思路
服务端管理方案将未读数的计算和存储完全放在服务端。客户端在需要显示未读数时,主动向服务端请求获取。服务端维护每个用户在每个会话中的未读数,以及总未读数。
这种方案适用于:
-
多端应用场景
-
对数据一致性要求高的场景
-
需要进行服务端统计和分析的场景
流程图
sequenceDiagram
participant Sender as 客户端(发送方)
participant Server as 服务端
participant DB as 数据库
participant Receiver as 客户端(接收方)
participant OtherDevice as 其他设备(接收方)
Note over Sender,Receiver: 1. 新消息接收流程
Sender->>Server: 发送消息 (HTTP)
Server->>Server: 处理消息
Server->>DB: 查询接收方最后阅读位置
DB-->>Server: 返回阅读位置
Server->>DB: 查询会话是否免打扰
DB-->>Server: 返回免打扰状态
alt 非免打扰会话
Server->>DB: 更新未读数(+1)
DB-->>Server: 更新成功
Server->>Receiver: 推送未读数更新通知 (WebSocket)
Receiver->>Receiver: 更新UI显示
else 免打扰会话
Server->>DB: 更新未读数(+1)但不计入总未读数
DB-->>Server: 更新成功
Server->>Receiver: 推送未读数更新通知 (WebSocket)
Receiver->>Receiver: 更新UI显示(仅会话未读数)
end
Note over Sender,Receiver: 2. 获取未读数流程
Receiver->>Server: 请求获取未读数 (HTTP)
Server->>DB: 查询所有会话未读数
DB-->>Server: 返回未读数数据
Server->>Server: 计算总未读数(排除免打扰会话)
Server-->>Receiver: 返回未读数汇总 (HTTP响应)
Receiver->>Receiver: 更新UI显示
Note over Sender,OtherDevice: 3. 标记已读流程(含多端同步)
Receiver->>Server: 请求标记消息已读 (HTTP)
Server->>DB: 更新最后阅读位置
Server->>DB: 重新计算未读数
DB-->>Server: 更新成功
Server->>DB: 查询最新未读数
DB-->>Server: 返回未读数
Server-->>Receiver: 返回更新后的未读数 (HTTP响应)
Receiver->>Receiver: 更新UI显示
Server->>OtherDevice: 推送未读数更新 (WebSocket)
OtherDevice->>OtherDevice: 更新UI显示
Note over Sender,Receiver: 4. WebSocket断线重连流程
Receiver->>Server: WebSocket重连成功 (WebSocket)
Server->>DB: 查询用户所有未读数
DB-->>Server: 返回未读数数据
Server-->>Receiver: 推送最新未读数 (WebSocket)
Receiver->>Receiver: 更新本地未读数
Receiver->>Receiver: 更新UI显示
Note over Sender,Receiver: 5. 消息撤回流程
Sender->>Server: 请求撤回消息 (HTTP)
Server->>DB: 查询消息是否已读
DB-->>Server: 返回已读状态
alt 消息未读
Server->>DB: 减少未读数(-1)
DB-->>Server: 更新成功
Server->>Receiver: 推送未读数更新 (WebSocket)
Receiver->>Receiver: 更新UI显示
else 消息已读
Server->>Server: 不更新未读数
end
Note over Sender,Receiver: 6. 离线消息处理流程
Receiver->>Server: 重新连接并请求离线消息 (HTTP)
Server->>DB: 查询离线期间的未读消息
DB-->>Server: 返回离线消息列表
Server->>Server: 计算离线期间未读数增量
Server->>DB: 更新未读数
DB-->>Server: 更新成功
Server-->>Receiver: 返回离线消息和未读数 (HTTP响应)
Receiver->>Receiver: 更新本地未读数
Receiver->>Receiver: 更新UI显示
服务端流程:
- 消息接收处理
-
服务端接收发送的消息(HTTP请求)
-
查询接收方的最后阅读位置
-
查询会话是否免打扰
-
如果是非免打扰会话,更新数据库中的未读数
-
如果是免打扰会话,更新未读数但不计入总未读数
-
推送未读数更新通知(WebSocket推送)
- 获取未读数
-
客户端请求获取未读数(HTTP请求)
-
服务端查询所有会话未读数
-
计算总未读数(排除免打扰会话)
-
返回未读数数据给客户端(HTTP响应)
- 消息已读处理
-
客户端请求标记消息已读(HTTP请求)
-
服务端更新消息的已读状态
-
重新计算该会话的未读数
-
更新数据库
-
推送新的未读数给客户端(WebSocket推送)
- 离线消息处理
-
客户端重新连接时,查询离线期间的未读消息
-
计算离线期间未读数增量
-
更新数据库中的未读数
-
返回离线消息和未读数给客户端
- 消息撤回处理
-
接收撤回消息请求
-
查询消息是否已读
-
如果消息未读,减少未读数
-
推送未读数更新给客户端
客户端流程:
- 登录成功后初始化
-
建立WebSocket连接,监听实时未读数更新推送
-
通过HTTP请求获取用户所有会话的未读数汇总
-
初始化完成后,主要依赖WebSocket推送进行未读数更新
- 获取未读数(HTTP请求执行时机)
-
登录成功后:用户登录成功后,通过HTTP请求获取用户所有会话的未读数汇总
-
WebSocket重连时:当WebSocket连接断开并重新连接成功后,通过HTTP请求拉取最新的未读数数据,确保数据一致性
-
手动刷新时:用户主动刷新会话列表时,可通过HTTP请求获取最新未读数(可选)
- 标记消息已读(HTTP请求执行时机)
-
打开会话时:用户点击打开某个会话时,立即发送HTTP请求标记该会话的所有消息为已读
-
会话可见时:当会话窗口从后台切换到前台时,发送HTTP请求标记会话已读(可选)
-
滚动到底部时:用户在会话中滚动查看消息到底部时,发送HTTP请求标记已读(可选)
- WebSocket监听
-
监听服务端推送的未读数更新消息
-
收到推送后立即更新本地未读数数据
-
更新UI显示,包括会话列表未读数和应用图标红点
- 离线消息处理
-
重新连接时,请求获取离线消息和未读数
-
更新本地未读数
-
更新UI显示
数据库设计:
-- 会话未读数表(以用户+会话为维度)
CREATE TABLE session_unreads (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
user_id BIGINT NOT NULL COMMENT '用户ID',
session_id VARCHAR(64) NOT NULL COMMENT '会话ID,引用会话基础信息表',
unread_count INT NOT NULL DEFAULT 0 COMMENT '未读消息数量',
last_read_message_id VARCHAR(64) COMMENT '最后阅读的消息ID',
is_disturb TINYINT NOT NULL DEFAULT 0 COMMENT '是否免打扰:0-否,1-是',
updated_at BIGINT NOT NULL COMMENT '更新时间(时间戳,单位:毫秒)',
created_at BIGINT NOT NULL COMMENT '创建时间(时间戳,单位:毫秒)',
UNIQUE KEY uk_user_session (user_id, session_id) COMMENT '用户和会话的联合唯一索引',
INDEX idx_user_id (user_id) COMMENT '用户ID索引,用于查询用户的所有未读数',
INDEX idx_session_id (session_id) COMMENT '会话ID索引,用于查询会话的所有用户未读数',
INDEX idx_updated_at (updated_at) COMMENT '更新时间索引,用于增量同步',
FOREIGN KEY (session_id) REFERENCES 会话基础信息表(session_id) ON DELETE CASCADE
) COMMENT='会话未读数表,存储每个用户在每个会话中的未读消息数量';
数据库设计说明:
-
数据持久化:未读数必须持久化到数据库,不能仅依赖内存存储,否则服务重启会导致数据丢失
-
多端一致性:所有设备从同一个数据库读取未读数,确保多端显示一致
-
离线恢复:用户离线期间的数据变化会记录在数据库中,重新连接时可以从数据库恢复
优缺点
优点:
-
多端数据一致性高,所有设备显示的未读数一致
-
数据存储在服务端,不会因为客户端卸载而丢失
-
可以进行服务端统计和分析
-
数据准确性由服务端保证,不易出错
-
采用HTTP请求+WebSocket推送混合方案,避免轮询,网络开销低
-
实时性较好,通过WebSocket推送实现实时更新
缺点:
-
实现复杂,开发成本高
-
增加服务端压力和数据库负载
-
依赖WebSocket推送,网络不稳定时可能影响实时性
-
需要维护额外的数据库表和索引
-
需要处理WebSocket连接管理和断线重连逻辑
代码示例
后端代码(伪代码)
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SessionUnreads } from '../entities/session-unreads.entity';
import { Message } from '../entities/message.entity';
import { WebSocketService } from '../services/websocket.service';
import { UnreadSummary } from '../dto/unread-summary.dto';
import { WebSocketMessage } from '../dto/websocket-message.dto';
@Injectable()
export class UnreadService {
constructor(
@InjectRepository(SessionUnreads)
private readonly sessionUnreadsRepository: Repository<SessionUnreads>,
@InjectRepository(Message)
private readonly messageRepository: Repository<Message>,
private readonly webSocketService: WebSocketService,
) {}
/**
* 处理新消息,更新未读数
*/
async handleNewMessage(message: Message): Promise<void> {
const receiverId = message.receiverId;
const conversationId = message.conversationId;
let unread = await this.sessionUnreadsRepository.findOne({
where: {
userId: receiverId,
sessionId: conversationId,
},
});
if (!unread) {
unread = this.sessionUnreadsRepository.create({
userId: receiverId,
sessionId: conversationId,
unreadCount: 0,
lastReadMessageId: null,
isDisturb: 0,
});
await this.sessionUnreadsRepository.save(unread);
}
if (await this.shouldCountAsUnread(unread, message)) {
unread.unreadCount += 1;
await this.sessionUnreadsRepository.save(unread);
await this.pushUnreadUpdate(receiverId);
}
}
/**
* 判断消息是否需要计入未读数
*/
private async shouldCountAsUnread(unread: SessionUnreads, message: Message): Promise<boolean> {
if (unread.isDisturb === 1) {
return false;
}
return message.id > unread.lastReadMessageId;
}
/**
* 获取用户的所有未读数
*/
async getUserUnreadSummary(userId: number): Promise<UnreadSummary> {
const unreadList = await this.sessionUnreadsRepository.find({
where: { userId },
});
let totalUnread = 0;
const sessionUnreadMap = new Map<number, number>();
for (const unread of unreadList) {
sessionUnreadMap.set(unread.sessionId, unread.unreadCount);
if (unread.isDisturb === 0) {
totalUnread += unread.unreadCount;
}
}
const summary: UnreadSummary = {
totalUnread,
sessionUnreadMap: Object.fromEntries(sessionUnreadMap),
hasRedDot: totalUnread > 0,
};
return summary;
}
/**
* 标记消息已读
*/
async markMessagesAsRead(userId: number, sessionId: number, lastReadMessageId: string): Promise<void> {
const unread = await this.sessionUnreadsRepository.findOne({
where: {
userId,
sessionId,
},
});
if (unread) {
const unreadCount = await this.messageRepository
.createQueryBuilder('message')
.where('message.sessionId = :sessionId', { sessionId })
.andWhere('message.id > :lastReadMessageId', { lastReadMessageId: unread.lastReadMessageId })
.andWhere('message.id <= :currentReadMessageId', { currentReadMessageId: lastReadMessageId })
.getCount();
unread.lastReadMessageId = lastReadMessageId;
unread.unreadCount = Math.max(0, unread.unreadCount - unreadCount);
await this.sessionUnreadsRepository.save(unread);
await this.pushUnreadUpdate(userId);
}
}
/**
* 推送未读数更新通知
*/
private async pushUnreadUpdate(userId: number): Promise<void> {
const summary = await this.getUserUnreadSummary(userId);
const message: WebSocketMessage = {
type: 'unread_update',
data: summary,
};
await this.webSocketService.sendToUser(userId, message);
}
}
前端代码(伪代码)
// 前端代码 - 服务端管理方案(HTTP请求+WebSocket推送混合)
class UnreadManager {
constructor() {
this.unreadData = {
total_unread: 0,
session_unread_map: {},
has_red_dot: false
};
}
// 初始化(登录成功后调用)
async init() {
// 1. 先通过HTTP获取初始未读数
await this.fetchUnreadSummary();
// 2. 建立WebSocket连接,监听实时更新
this.initWebSocketListener();
}
// HTTP请求获取未读数(只在必要时调用)
async fetchUnreadSummary() {
try {
const response = await api.get('/api/unread/summary');
this.unreadData = response.data;
this.updateUI();
} catch (error) {
console.error('获取未读数失败:', error);
}
}
// WebSocket监听(主要更新方式)
initWebSocketListener() {
websocket.on('unread_update', (data) => {
this.unreadData = data;
this.updateUI();
});
// 监听WebSocket重连
websocket.on('reconnect', () => {
// 重连成功后,主动拉取一次最新数据
this.fetchUnreadSummary();
});
}
// 标记消息已读
async markAsRead(sessionId, lastReadMessageId) {
try {
await api.post('/api/unread/mark-read', {
session_id: sessionId,
last_read_message_id: lastReadMessageId
});
// 注意:这里不需要手动更新UI,因为服务端会通过WebSocket推送更新
} catch (error) {
console.error('标记已读失败:', error);
}
}
// 更新UI显示
updateUI() {
// 更新会话列表未读数
Object.entries(this.unreadData.session_unread_map).forEach(([sessionId, count]) => {
const element = document.getElementById(`unread-${sessionId}`);
if (element) {
element.textContent = count > 0 ? count : '';
element.style.display = count > 0 ? 'block' : 'none';
}
});
// 更新应用图标红点
if (this.unreadData.has_red_dot) {
this.setAppBadge(this.unreadData.total_unread);
} else {
this.clearAppBadge();
}
}
// 设置应用图标角标
setAppBadge(count) {
if (navigator.setAppBadge) {
navigator.setAppBadge(count);
}
}
// 清除应用图标角标
clearAppBadge() {
if (navigator.clearAppBadge) {
navigator.clearAppBadge();
}
}
}
// 使用示例
const unreadManager = new UnreadManager();
// 登录成功后调用init方法初始化未读数管理
async function onLoginSuccess() {
await unreadManager.init();
}
// 用户打开会话
async function openSession(sessionId) {
// 获取当前会话的最后一条消息ID
const lastMessageId = getLastMessageId(sessionId);
await unreadManager.markAsRead(sessionId, lastMessageId);
// 加载会话消息...
}
方案二:实时推送方案
设计思路
实时推送方案通过WebSocket长连接,在服务端未读数发生变化时,主动推送给所有在线客户端。客户端维护本地未读数,并实时响应服务端的推送更新。这种方案强调实时性,确保用户第一时间看到未读数变化。
这种方案适用于:
-
对实时性要求极高的场景
-
多端应用场景
-
需要即时通知的场景
流程图
sequenceDiagram
participant Sender as 客户端(发送方)
participant Server as 服务端
participant DB as 数据库
participant WS as WebSocket管理器
participant Client as 客户端(接收方)
participant OtherDevice as 其他设备(接收方)
Note over Sender,Client: 1. 新消息实时推送流程
Sender->>Server: 发送消息 (HTTP)
Server->>Server: 处理消息
Server->>DB: 查询会话是否免打扰
DB-->>Server: 返回免打扰状态
alt 非免打扰会话
Server->>DB: 更新未读数(+1)
DB-->>Server: 更新成功
Server->>WS: 获取接收方所有在线连接
WS-->>Server: 返回连接列表
Server->>Client: 推送未读数更新 (WebSocket)
Client->>Client: 更新本地未读数
Client->>Client: 更新UI显示
Server->>OtherDevice: 推送未读数更新 (WebSocket)
OtherDevice->>OtherDevice: 更新本地未读数
OtherDevice->>OtherDevice: 更新UI显示
else 免打扰会话
Server->>DB: 更新未读数(+1)但不计入总未读数
DB-->>Server: 更新成功
Server->>WS: 获取接收方所有在线连接
WS-->>Server: 返回连接列表
Server->>Client: 推送未读数更新 (WebSocket)
Client->>Client: 更新本地未读数
Client->>Client: 更新UI显示(仅会话未读数)
Server->>OtherDevice: 推送未读数更新 (WebSocket)
OtherDevice->>OtherDevice: 更新本地未读数
OtherDevice->>OtherDevice: 更新UI显示(仅会话未读数)
end
Note over Sender,Client: 2. 标记已读实时推送流程
Client->>Server: 请求标记消息已读 (HTTP)
Server->>DB: 更新最后阅读位置
Server->>DB: 重新计算未读数
DB-->>Server: 更新成功
Server->>DB: 查询最新未读数
DB-->>Server: 返回未读数
Server->>WS: 获取用户所有在线连接
WS-->>Server: 返回连接列表
Server->>Client: 推送更新后的未读数 (WebSocket)
Client->>Client: 更新本地未读数
Client->>Client: 更新UI显示
Server->>OtherDevice: 推送更新后的未读数 (WebSocket)
OtherDevice->>OtherDevice: 更新本地未读数
OtherDevice->>OtherDevice: 更新UI显示
Note over Sender,Client: 3. 断线重连流程
Client->>Server: WebSocket重连成功 (WebSocket)
Server->>DB: 查询用户所有未读数
DB-->>Server: 返回未读数数据
Server-->>Client: 推送最新未读数 (WebSocket)
Client->>Client: 更新本地未读数
Client->>Client: 更新UI显示
Note over Sender,Client: 4. 离线消息处理流程
Client->>Server: 重新连接并请求离线消息 (HTTP)
Server->>DB: 查询离线期间的未读消息
DB-->>Server: 返回离线消息列表
Server->>Server: 计算离线期间未读数增量
Server->>DB: 更新未读数
DB-->>Server: 更新成功
Server-->>Client: 返回离线消息和未读数 (HTTP响应)
Client->>Client: 更新本地未读数
Client->>Client: 更新UI显示
Note over Sender,Client: 5. 消息撤回流程
Sender->>Server: 请求撤回消息 (HTTP)
Server->>DB: 查询消息是否已读
DB-->>Server: 返回已读状态
alt 消息未读
Server->>DB: 减少未读数(-1)
DB-->>Server: 更新成功
Server->>WS: 获取接收方所有在线连接
WS-->>Server: 返回连接列表
Server->>Client: 推送未读数更新 (WebSocket)
Client->>Client: 更新本地未读数
Client->>Client: 更新UI显示
Server->>OtherDevice: 推送未读数更新 (WebSocket)
OtherDevice->>OtherDevice: 更新本地未读数
OtherDevice->>OtherDevice: 更新UI显示
else 消息已读
Server->>Server: 不更新未读数
end
服务端流程:
- 消息接收处理
-
服务端接收发送的消息(HTTP请求)
-
查询会话是否免打扰
-
如果是非免打扰会话,更新数据库中的未读数
-
如果是免打扰会话,更新未读数但不计入总未读数
-
查询接收方的所有在线连接
-
通过WebSocket向所有在线客户端推送未读数更新
- 消息已读处理
-
接收客户端的已读请求(HTTP请求)
-
更新数据库中的已读状态和未读数
-
向该用户的所有在线客户端推送未读数更新
- WebSocket连接管理
-
维护用户ID到WebSocket连接的映射
-
支持多设备同时在线
-
处理连接断开和重连
- 离线消息处理
-
客户端重新连接时,查询离线期间的未读消息
-
计算离线期间未读数增量
-
更新数据库中的未读数
-
返回离线消息和未读数给客户端
- 消息撤回处理
-
接收撤回消息请求
-
查询消息是否已读
-
如果消息未读,减少未读数
-
向所有在线客户端推送未读数更新
客户端流程:
- 登录成功后初始化
-
建立WebSocket长连接
-
发送心跳保持连接
-
处理断线重连
- 接收推送
-
监听未读数更新推送
-
更新本地未读数
-
更新UI显示
- 获取未读数(HTTP请求执行时机)
-
登录成功后:用户登录成功后,通过HTTP请求获取用户所有会话的未读数汇总
-
WebSocket重连时:当WebSocket连接断开并重新连接成功后,通过HTTP请求拉取最新的未读数数据,确保数据一致性
-
手动刷新时:用户主动刷新会话列表时,可通过HTTP请求获取最新未读数(可选)
- 标记消息已读(HTTP请求执行时机)
-
打开会话时:用户点击打开某个会话时,立即发送HTTP请求标记该会话的所有消息为已读
-
会话可见时:当会话窗口从后台切换到前台时,发送HTTP请求标记会话已读(可选)
-
滚动到底部时:用户在会话中滚动查看消息到底部时,发送HTTP请求标记已读(可选)
- WebSocket监听
-
监听未读数更新推送
-
更新本地未读数
-
更新UI显示
- 离线消息处理
-
重新连接时,请求获取离线消息和未读数
-
更新本地未读数
-
更新UI显示
数据库设计:
实时推送方案同样需要数据库来持久化存储未读数数据,确保数据一致性和离线恢复能力。数据库设计与方案一相同:
-- 会话未读数表(以用户+会话为维度)
CREATE TABLE session_unreads (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
user_id BIGINT NOT NULL COMMENT '用户ID',
session_id VARCHAR(64) NOT NULL COMMENT '会话ID,引用会话基础信息表',
unread_count INT NOT NULL DEFAULT 0 COMMENT '未读消息数量',
last_read_message_id VARCHAR(64) COMMENT '最后阅读的消息ID',
is_disturb TINYINT NOT NULL DEFAULT 0 COMMENT '是否免打扰:0-否,1-是',
updated_at BIGINT NOT NULL COMMENT '更新时间(时间戳,单位:毫秒)',
created_at BIGINT NOT NULL COMMENT '创建时间(时间戳,单位:毫秒)',
UNIQUE KEY uk_user_session (user_id, session_id) COMMENT '用户和会话的联合唯一索引',
INDEX idx_user_id (user_id) COMMENT '用户ID索引,用于查询用户的所有未读数',
INDEX idx_session_id (session_id) COMMENT '会话ID索引,用于查询会话的所有用户未读数',
INDEX idx_updated_at (updated_at) COMMENT '更新时间索引,用于增量同步',
FOREIGN KEY (session_id) REFERENCES 会话基础信息表(session_id) ON DELETE CASCADE
) COMMENT='会话未读数表,存储每个用户在每个会话中的未读消息数量';
优缺点
优点:
-
实时性最高,用户能第一时间看到未读数变化
-
多端数据一致性高
-
减少客户端主动请求,降低网络开销
-
用户体验好,无需手动刷新
缺点:
-
实现复杂,需要维护WebSocket连接
-
服务端压力大,需要处理大量并发连接
-
网络不稳定时可能丢失推送
-
需要处理断线重连和消息补发
代码示例
后端代码(伪代码)
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SessionUnreads } from '../entities/session-unreads.entity';
import { Message } from '../entities/message.entity';
import { WebSocketConnectionManager } from '../services/websocket-connection-manager.service';
import { UnreadSummary } from '../dto/unread-summary.dto';
import { WebSocketMessage } from '../dto/websocket-message.dto';
@Injectable()
export class UnreadService {
constructor(
@InjectRepository(SessionUnreads)
private readonly sessionUnreadsRepository: Repository<SessionUnreads>,
@InjectRepository(Message)
private readonly messageRepository: Repository<Message>,
private readonly connectionManager: WebSocketConnectionManager,
) {}
/**
* 处理新消息,更新未读数并推送
*/
async handleNewMessage(message: Message): Promise<void> {
const receiverId = message.receiverId;
const conversationId = message.conversationId;
let unread = await this.sessionUnreadsRepository.findOne({
where: {
userId: receiverId,
sessionId: conversationId,
},
});
if (!unread) {
unread = this.sessionUnreadsRepository.create({
userId: receiverId,
sessionId: conversationId,
unreadCount: 0,
lastReadMessageId: null,
isDisturb: 0,
});
await this.sessionUnreadsRepository.save(unread);
}
if (await this.shouldCountAsUnread(unread, message)) {
unread.unreadCount += 1;
await this.sessionUnreadsRepository.save(unread);
await this.pushUnreadUpdate(receiverId);
}
}
/**
* 判断消息是否需要计入未读数
*/
private async shouldCountAsUnread(unread: SessionUnreads, message: Message): Promise<boolean> {
if (unread.isDisturb === 1) {
return false;
}
return message.id > unread.lastReadMessageId;
}
/**
* 标记消息已读并推送
*/
async markMessagesAsRead(userId: number, sessionId: number, lastReadMessageId: string): Promise<void> {
const unread = await this.sessionUnreadsRepository.findOne({
where: {
userId,
sessionId,
},
});
if (unread) {
const unreadCount = await this.messageRepository
.createQueryBuilder('message')
.where('message.sessionId = :sessionId', { sessionId })
.andWhere('message.id > :lastReadMessageId', { lastReadMessageId: unread.lastReadMessageId })
.andWhere('message.id <= :currentReadMessageId', { currentReadMessageId: lastReadMessageId })
.getCount();
unread.lastReadMessageId = lastReadMessageId;
unread.unreadCount = Math.max(0, unread.unreadCount - unreadCount);
await this.sessionUnreadsRepository.save(unread);
await this.pushUnreadUpdate(userId);
}
}
/**
* 推送未读数更新给用户的所有在线客户端
*/
private async pushUnreadUpdate(userId: number): Promise<void> {
const summary = await this.getUserUnreadSummary(userId);
const message: WebSocketMessage = {
type: 'unread_update',
data: summary,
};
await this.connectionManager.sendToUser(userId, message);
}
/**
* 获取用户的所有未读数
*/
private async getUserUnreadSummary(userId: number): Promise<UnreadSummary> {
const unreadList = await this.sessionUnreadsRepository.find({
where: { userId },
});
let totalUnread = 0;
const sessionUnreadMap = new Map<number, number>();
for (const unread of unreadList) {
sessionUnreadMap.set(unread.sessionId, unread.unreadCount);
if (unread.isDisturb === 0) {
totalUnread += unread.unreadCount;
}
}
const summary: UnreadSummary = {
totalUnread,
sessionUnreadMap: Object.fromEntries(sessionUnreadMap),
hasRedDot: totalUnread > 0,
};
return summary;
}
}
/**
* WebSocket连接管理器
*/
@Injectable()
export class WebSocketConnectionManager {
private readonly userSessions = new Map<number, Set<WebSocket>>();
/**
* 添加用户连接
*/
addUserConnection(userId: number, session: WebSocket): void {
if (!this.userSessions.has(userId)) {
this.userSessions.set(userId, new Set());
}
this.userSessions.get(userId).add(session);
}
/**
* 移除用户连接
*/
removeUserConnection(userId: number, session: WebSocket): void {
const sessions = this.userSessions.get(userId);
if (sessions) {
sessions.delete(session);
if (sessions.size === 0) {
this.userSessions.delete(userId);
}
}
}
/**
* 向用户的所有连接发送消息
*/
async sendToUser(userId: number, message: WebSocketMessage): Promise<void> {
const sessions = this.userSessions.get(userId);
if (sessions) {
const messageJson = JSON.stringify(message);
sessions.forEach(session => {
try {
session.send(messageJson);
} catch (error) {
console.error('发送WebSocket消息失败', error);
}
});
}
}
}
前端代码(伪代码)
// 前端代码 - 实时推送方案
class UnreadManager {
constructor() {
this.unreadData = {
total_unread: 0,
session_unread_map: {},
has_red_dot: false
};
this.websocket = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectInterval = 3000;
}
// 初始化(登录成功后调用)
init() {
this.connectWebSocket();
}
// 连接WebSocket
connectWebSocket() {
const wsUrl = `wss://your-api-domain.com/ws?token=${getToken()}`;
this.websocket = new WebSocket(wsUrl);
this.websocket.onopen = () => {
console.log('WebSocket连接成功');
this.reconnectAttempts = 0;
// 连接成功后,请求最新的未读数
this.fetchUnreadSummary();
};
this.websocket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'unread_update') {
this.handleUnreadUpdate(message.data);
}
};
this.websocket.onclose = () => {
console.log('WebSocket连接关闭');
this.handleReconnect();
};
this.websocket.onerror = (error) => {
console.error('WebSocket错误:', error);
};
}
// 处理重连
handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
setTimeout(() => {
this.connectWebSocket();
}, this.reconnectInterval);
} else {
console.error('重连失败,超过最大尝试次数');
}
}
// 处理未读数更新
handleUnreadUpdate(data) {
this.unreadData = data;
this.updateUI();
}
// 从服务端获取未读数(用于初始化或重连后)
async fetchUnreadSummary() {
try {
const response = await api.get('/api/unread/summary');
this.unreadData = response.data;
this.updateUI();
} catch (error) {
console.error('获取未读数失败:', error);
}
}
// 标记消息已读
async markAsRead(sessionId, lastReadMessageId) {
try {
await api.post('/api/unread/mark-read', {
session_id: sessionId,
last_read_message_id: lastReadMessageId
});
// 未读数会通过WebSocket推送更新,无需手动处理
} catch (error) {
console.error('标记已读失败:', error);
}
}
// 更新UI显示
updateUI() {
// 更新会话列表未读数
Object.entries(this.unreadData.session_unread_map).forEach(([sessionId, count]) => {
const element = document.getElementById(`unread-${sessionId}`);
if (element) {
element.textContent = count > 0 ? count : '';
element.style.display = count > 0 ? 'block' : 'none';
}
});
// 更新应用图标红点
if (this.unreadData.has_red_dot) {
this.setAppBadge(this.unreadData.total_unread);
} else {
this.clearAppBadge();
}
}
// 设置应用图标角标
setAppBadge(count) {
if (navigator.setAppBadge) {
navigator.setAppBadge(count);
}
}
// 清除应用图标角标
clearAppBadge() {
if (navigator.clearAppBadge) {
navigator.clearAppBadge();
}
}
// 发送心跳
startHeartbeat() {
setInterval(() => {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // 30秒发送一次心跳
}
}
// 使用示例
const unreadManager = new UnreadManager();
// 登录成功后调用init方法初始化未读数管理
async function onLoginSuccess() {
await unreadManager.init();
// 启动心跳
unreadManager.startHeartbeat();
}
// 用户打开会话
async function openSession(sessionId) {
const lastMessageId = getLastMessageId(sessionId);
await unreadManager.markAsRead(sessionId, lastMessageId);
// 加载会话消息...
}
三、方案对比
| 对比维度 | 服务端管理方案 | 实时推送方案 |
|---|---|---|
| 实现复杂度 | 高 | 很高 |
| 开发成本 | 高 | 很高 |
| 实时性 | 中(依赖推送或请求) | 很高(主动推送) |
| 多端一致性 | 高 | 高 |
| 服务端压力 | 中高(主要是查询和更新) | 很高(大量并发连接) |
| 网络依赖 | 中(HTTP请求为主) | 高(依赖WebSocket长连接) |
| 离线支持 | 好 | 好 |
| 数据准确性 | 高 | 高 |
| 用户体验 | 好 | 很好 |
| 可扩展性 | 好 | 中(连接数限制) |
| 适用场景 | 多端企业IM、对一致性要求高 | 对实时性要求极高的IM |
详细对比分析
1. 实现复杂度
-
服务端管理方案:需要设计数据库表、API接口、推送机制,复杂度较高
-
实时推送方案:需要实现WebSocket连接管理、消息推送、断线重连等,复杂度较高
2. 实时性
-
服务端管理方案:依赖客户端请求或推送,有一定延迟
-
实时推送方案:实时性最高,服务端变化立即推送
3. 多端一致性
-
服务端管理方案:一致性最高,所有端显示相同数据
-
实时推送方案:一致性高,所有在线端同时收到更新
4. 服务端压力
-
服务端管理方案:服务端压力大,需要处理大量查询和更新
-
实时推送方案:服务端压力大,需要维护大量WebSocket连接
5. 适用场景
-
服务端管理方案:适用于多端企业IM、对一致性要求高的场景
-
实时推送方案:适用于对实时性要求极高的IM场景
四、方案选型建议
4.1 选型原则
根据应用场景和需求,选择合适的方案:
-
多端企业IM:选择服务端管理方案,保证数据一致性
-
对实时性要求极高的IM:选择实时推送方案,提供最佳用户体验
-
混合方案:结合两种方案的优点,HTTP用于初始化和重连,WebSocket用于实时更新
4.2 推荐方案
推荐采用混合方案,结合服务端管理和实时推送的优点:
-
服务端存储:未读数完全由服务端计算和存储
-
实时推送:使用WebSocket进行实时推送
-
HTTP请求:用于初始化和重连时获取最新数据
-
离线支持:支持离线消息处理和恢复
混合方案的优势:
-
兼顾实时性和数据一致性
-
减少客户端主动请求,降低网络开销
-
提供良好的用户体验
-
适应各种网络环境
4.3 实现建议
- 数据库设计
-
使用独立的未读数表,以用户+会话为维度
-
添加必要的索引,提高查询性能
-
考虑使用缓存(如Redis)提高性能
- API设计
-
提供获取未读数的API
-
提供标记已读的API
-
提供离线消息拉取的API
- WebSocket设计
-
使用WebSocket进行实时推送
-
实现心跳机制,保持连接
-
实现断线重连机制
- 异常处理
-
处理网络异常、断线重连等场景
-
确保数据准确性和用户体验
-
实现消息补发机制
- 性能优化
-
使用缓存减少数据库查询
-
使用消息队列保证推送的可靠性
-
实现增量同步机制
五、总结
IM会话未读数和红点是即时通讯应用中至关重要的功能,直接影响用户体验。本文介绍了两种业界主流的实现方案:
-
服务端管理方案:数据一致性好,适合多端企业应用,但服务端压力大
-
实时推送方案:实时性最高,用户体验最好,适合对实时性要求极高的场景
方案选择建议
根据不同的应用场景和需求,建议如下:
-
多端企业IM:选择服务端管理方案,保证数据一致性
-
对实时性要求极高的IM:选择实时推送方案,提供最佳用户体验
实现注意事项
-
免打扰会话处理:免打扰会话的未读数不应计入总未读数
-
多端同步:确保用户在不同设备上看到一致的未读数
-
离线支持:离线场景下也要保持未读数状态
-
性能优化:大量会话时需要优化查询和更新性能
-
异常处理:网络异常、断线重连等场景的处理
-
数据准确性:确保未读数计算的准确性,避免出现错误
版权声明
本文档内容为原创技术文档,仅供学习交流使用。文档中的代码示例、架构设计等技术内容为通用技术实践,不涉及任何特定公司的商业机密。如需引用本文档内容,请注明出处。