IM会话未读数和红点方案选型

5 阅读13分钟

阅读前

先关注、收藏、不然后面可能找不到了




一、简介

在即时通讯(IM)应用中,会话未读数和红点是用户交互体验的重要组成部分。未读数指的是用户尚未阅读的消息数量,通常以数字形式显示在会话列表、应用图标等位置;红点则是一种视觉提示,用于告知用户有新的内容需要关注,但不显示具体数量。

未读数和红点管理涉及以下几个核心概念:

  1. 未读消息数:特定会话中用户未阅读的消息数量

  2. 总未读数:所有会话的未读消息总数

  3. 红点状态:是否有未读内容的布尔状态

  4. 免打扰会话:用户设置为免打扰的会话,其未读数不计入总未读数

  5. 多端同步:在不同设备间保持未读数的一致性

未读数和红点的准确性直接影响用户体验:

  • 准确的未读数帮助用户了解需要处理的消息量

  • 红点提示用户有新内容,避免错过重要信息

  • 多端一致性确保用户在不同设备上获得一致的体验






二、业界的生产企业级方案

方案一:服务端管理方案

设计思路

服务端管理方案将未读数的计算和存储完全放在服务端。客户端在需要显示未读数时,主动向服务端请求获取。服务端维护每个用户在每个会话中的未读数,以及总未读数。

这种方案适用于:

  • 多端应用场景

  • 对数据一致性要求高的场景

  • 需要进行服务端统计和分析的场景


流程图


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显示

服务端流程:

  1. 消息接收处理
  • 服务端接收发送的消息(HTTP请求)

  • 查询接收方的最后阅读位置

  • 查询会话是否免打扰

  • 如果是非免打扰会话,更新数据库中的未读数

  • 如果是免打扰会话,更新未读数但不计入总未读数

  • 推送未读数更新通知(WebSocket推送)

  1. 获取未读数
  • 客户端请求获取未读数(HTTP请求)

  • 服务端查询所有会话未读数

  • 计算总未读数(排除免打扰会话)

  • 返回未读数数据给客户端(HTTP响应)

  1. 消息已读处理
  • 客户端请求标记消息已读(HTTP请求)

  • 服务端更新消息的已读状态

  • 重新计算该会话的未读数

  • 更新数据库

  • 推送新的未读数给客户端(WebSocket推送)

  1. 离线消息处理
  • 客户端重新连接时,查询离线期间的未读消息

  • 计算离线期间未读数增量

  • 更新数据库中的未读数

  • 返回离线消息和未读数给客户端

  1. 消息撤回处理
  • 接收撤回消息请求

  • 查询消息是否已读

  • 如果消息未读,减少未读数

  • 推送未读数更新给客户端

客户端流程:

  1. 登录成功后初始化
  • 建立WebSocket连接,监听实时未读数更新推送

  • 通过HTTP请求获取用户所有会话的未读数汇总

  • 初始化完成后,主要依赖WebSocket推送进行未读数更新

  1. 获取未读数(HTTP请求执行时机)
  • 登录成功后:用户登录成功后,通过HTTP请求获取用户所有会话的未读数汇总

  • WebSocket重连时:当WebSocket连接断开并重新连接成功后,通过HTTP请求拉取最新的未读数数据,确保数据一致性

  • 手动刷新时:用户主动刷新会话列表时,可通过HTTP请求获取最新未读数(可选)

  1. 标记消息已读(HTTP请求执行时机)
  • 打开会话时:用户点击打开某个会话时,立即发送HTTP请求标记该会话的所有消息为已读

  • 会话可见时:当会话窗口从后台切换到前台时,发送HTTP请求标记会话已读(可选)

  • 滚动到底部时:用户在会话中滚动查看消息到底部时,发送HTTP请求标记已读(可选)

  1. WebSocket监听
  • 监听服务端推送的未读数更新消息

  • 收到推送后立即更新本地未读数数据

  • 更新UI显示,包括会话列表未读数和应用图标红点

  1. 离线消息处理
  • 重新连接时,请求获取离线消息和未读数

  • 更新本地未读数

  • 更新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='会话未读数表,存储每个用户在每个会话中的未读消息数量';

数据库设计说明:

  1. 数据持久化:未读数必须持久化到数据库,不能仅依赖内存存储,否则服务重启会导致数据丢失

  2. 多端一致性:所有设备从同一个数据库读取未读数,确保多端显示一致

  3. 离线恢复:用户离线期间的数据变化会记录在数据库中,重新连接时可以从数据库恢复


优缺点

优点:

  • 多端数据一致性高,所有设备显示的未读数一致

  • 数据存储在服务端,不会因为客户端卸载而丢失

  • 可以进行服务端统计和分析

  • 数据准确性由服务端保证,不易出错

  • 采用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

服务端流程:

  1. 消息接收处理
  • 服务端接收发送的消息(HTTP请求)

  • 查询会话是否免打扰

  • 如果是非免打扰会话,更新数据库中的未读数

  • 如果是免打扰会话,更新未读数但不计入总未读数

  • 查询接收方的所有在线连接

  • 通过WebSocket向所有在线客户端推送未读数更新

  1. 消息已读处理
  • 接收客户端的已读请求(HTTP请求)

  • 更新数据库中的已读状态和未读数

  • 向该用户的所有在线客户端推送未读数更新

  1. WebSocket连接管理
  • 维护用户ID到WebSocket连接的映射

  • 支持多设备同时在线

  • 处理连接断开和重连

  1. 离线消息处理
  • 客户端重新连接时,查询离线期间的未读消息

  • 计算离线期间未读数增量

  • 更新数据库中的未读数

  • 返回离线消息和未读数给客户端

  1. 消息撤回处理
  • 接收撤回消息请求

  • 查询消息是否已读

  • 如果消息未读,减少未读数

  • 向所有在线客户端推送未读数更新

客户端流程:

  1. 登录成功后初始化
  • 建立WebSocket长连接

  • 发送心跳保持连接

  • 处理断线重连

  1. 接收推送
  • 监听未读数更新推送

  • 更新本地未读数

  • 更新UI显示

  1. 获取未读数(HTTP请求执行时机)
  • 登录成功后:用户登录成功后,通过HTTP请求获取用户所有会话的未读数汇总

  • WebSocket重连时:当WebSocket连接断开并重新连接成功后,通过HTTP请求拉取最新的未读数数据,确保数据一致性

  • 手动刷新时:用户主动刷新会话列表时,可通过HTTP请求获取最新未读数(可选)

  1. 标记消息已读(HTTP请求执行时机)
  • 打开会话时:用户点击打开某个会话时,立即发送HTTP请求标记该会话的所有消息为已读

  • 会话可见时:当会话窗口从后台切换到前台时,发送HTTP请求标记会话已读(可选)

  • 滚动到底部时:用户在会话中滚动查看消息到底部时,发送HTTP请求标记已读(可选)

  1. WebSocket监听
  • 监听未读数更新推送

  • 更新本地未读数

  • 更新UI显示

  1. 离线消息处理
  • 重新连接时,请求获取离线消息和未读数

  • 更新本地未读数

  • 更新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 选型原则

根据应用场景和需求,选择合适的方案:

  1. 多端企业IM:选择服务端管理方案,保证数据一致性

  2. 对实时性要求极高的IM:选择实时推送方案,提供最佳用户体验

  3. 混合方案:结合两种方案的优点,HTTP用于初始化和重连,WebSocket用于实时更新

4.2 推荐方案

推荐采用混合方案,结合服务端管理和实时推送的优点:

  1. 服务端存储:未读数完全由服务端计算和存储

  2. 实时推送:使用WebSocket进行实时推送

  3. HTTP请求:用于初始化和重连时获取最新数据

  4. 离线支持:支持离线消息处理和恢复

混合方案的优势:

  • 兼顾实时性和数据一致性

  • 减少客户端主动请求,降低网络开销

  • 提供良好的用户体验

  • 适应各种网络环境

4.3 实现建议

  1. 数据库设计
  • 使用独立的未读数表,以用户+会话为维度

  • 添加必要的索引,提高查询性能

  • 考虑使用缓存(如Redis)提高性能

  1. API设计
  • 提供获取未读数的API

  • 提供标记已读的API

  • 提供离线消息拉取的API

  1. WebSocket设计
  • 使用WebSocket进行实时推送

  • 实现心跳机制,保持连接

  • 实现断线重连机制

  1. 异常处理
  • 处理网络异常、断线重连等场景

  • 确保数据准确性和用户体验

  • 实现消息补发机制

  1. 性能优化
  • 使用缓存减少数据库查询

  • 使用消息队列保证推送的可靠性

  • 实现增量同步机制






五、总结

IM会话未读数和红点是即时通讯应用中至关重要的功能,直接影响用户体验。本文介绍了两种业界主流的实现方案:

  1. 服务端管理方案:数据一致性好,适合多端企业应用,但服务端压力大

  2. 实时推送方案:实时性最高,用户体验最好,适合对实时性要求极高的场景


方案选择建议

根据不同的应用场景和需求,建议如下:

  • 多端企业IM:选择服务端管理方案,保证数据一致性

  • 对实时性要求极高的IM:选择实时推送方案,提供最佳用户体验


实现注意事项

  1. 免打扰会话处理:免打扰会话的未读数不应计入总未读数

  2. 多端同步:确保用户在不同设备上看到一致的未读数

  3. 离线支持:离线场景下也要保持未读数状态

  4. 性能优化:大量会话时需要优化查询和更新性能

  5. 异常处理:网络异常、断线重连等场景的处理

  6. 数据准确性:确保未读数计算的准确性,避免出现错误






版权声明

本文档内容为原创技术文档,仅供学习交流使用。文档中的代码示例、架构设计等技术内容为通用技术实践,不涉及任何特定公司的商业机密。如需引用本文档内容,请注明出处。