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

阅读前

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




一、简介

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

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

  1. 未读消息数:特定会话中用户未阅读的消息数量
  2. 总未读数:所有会话的未读消息总数
  3. 红点状态:是否有未读内容的布尔状态
  4. 免打扰会话:用户设置为免打扰的会话,其未读数不计入总未读数
  5. 多端同步:在不同设备间保持未读数的一致性

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

  • 准确的未读数帮助用户了解需要处理的消息量
  • 红点提示用户有新内容,避免错过重要信息
  • 多端一致性确保用户在不同设备上获得一致的体验





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

方案一:服务端管理方案

设计思路

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

这种方案适用于:

  • 多端应用场景
  • 对数据一致性要求高的场景
  • 需要进行服务端统计和分析的场景

流程图
其他设备(接收方)客户端(接收方)数据库服务端客户端(发送方)其他设备(接收方)客户端(接收方)数据库服务端客户端(发送方)1. 新消息接收流程alt[非免打扰会话][免打扰会话]2. 获取未读数流程3. 标记已读流程(含多端同步)4. WebSocket断线重连流程5. 消息撤回流程alt[消息未读][消息已读]6. 离线消息处理流程发送消息 (HTTP)处理消息查询接收方最后阅读位置返回阅读位置查询会话是否免打扰返回免打扰状态更新未读数(+1)更新成功推送未读数更新通知 (WebSocket)更新UI显示更新未读数(+1)但不计入总未读数更新成功推送未读数更新通知 (WebSocket)更新UI显示(仅会话未读数)请求获取未读数 (HTTP)查询所有会话未读数返回未读数数据计算总未读数(排除免打扰会话)返回未读数汇总 (HTTP响应)更新UI显示请求标记消息已读 (HTTP)更新最后阅读位置重新计算未读数更新成功查询最新未读数返回未读数返回更新后的未读数 (HTTP响应)更新UI显示推送未读数更新 (WebSocket)更新UI显示WebSocket重连成功 (WebSocket)查询用户所有未读数返回未读数数据推送最新未读数 (WebSocket)更新本地未读数更新UI显示请求撤回消息 (HTTP)查询消息是否已读返回已读状态减少未读数(-1)更新成功推送未读数更新 (WebSocket)更新UI显示不更新未读数重新连接并请求离线消息 (HTTP)查询离线期间的未读消息返回离线消息列表计算离线期间未读数增量更新未读数更新成功返回离线消息和未读数 (HTTP响应)更新本地未读数更新UI显示

服务端流程:

  1. 消息接收处理

    • 服务端接收发送的消息(HTTP请求)
    • 查询接收方的最后阅读位置
    • 查询会话是否免打扰
    • 如果是非免打扰会话,更新数据库中的未读数
    • 如果是免打扰会话,更新未读数但不计入总未读数
    • 推送未读数更新通知(WebSocket推送)
  2. 获取未读数

    • 客户端请求获取未读数(HTTP请求)
    • 服务端查询所有会话未读数
    • 计算总未读数(排除免打扰会话)
    • 返回未读数数据给客户端(HTTP响应)
  3. 消息已读处理

    • 客户端请求标记消息已读(HTTP请求)
    • 服务端更新消息的已读状态
    • 重新计算该会话的未读数
    • 更新数据库
    • 推送新的未读数给客户端(WebSocket推送)
  4. 离线消息处理

    • 客户端重新连接时,查询离线期间的未读消息
    • 计算离线期间未读数增量
    • 更新数据库中的未读数
    • 返回离线消息和未读数给客户端
  5. 消息撤回处理

    • 接收撤回消息请求
    • 查询消息是否已读
    • 如果消息未读,减少未读数
    • 推送未读数更新给客户端

客户端流程:

  1. 登录成功后初始化

    • 建立WebSocket连接,监听实时未读数更新推送
    • 通过HTTP请求获取用户所有会话的未读数汇总
    • 初始化完成后,主要依赖WebSocket推送进行未读数更新
  2. 获取未读数(HTTP请求执行时机)

    • 登录成功后:用户登录成功后,通过HTTP请求获取用户所有会话的未读数汇总
    • WebSocket重连时:当WebSocket连接断开并重新连接成功后,通过HTTP请求拉取最新的未读数数据,确保数据一致性
    • 手动刷新时:用户主动刷新会话列表时,可通过HTTP请求获取最新未读数(可选)
  3. 标记消息已读(HTTP请求执行时机)

    • 打开会话时:用户点击打开某个会话时,立即发送HTTP请求标记该会话的所有消息为已读
    • 会话可见时:当会话窗口从后台切换到前台时,发送HTTP请求标记会话已读(可选)
    • 滚动到底部时:用户在会话中滚动查看消息到底部时,发送HTTP请求标记已读(可选)
  4. WebSocket监听

    • 监听服务端推送的未读数更新消息
    • 收到推送后立即更新本地未读数数据
    • 更新UI显示,包括会话列表未读数和应用图标红点
  5. 离线消息处理

    • 重新连接时,请求获取离线消息和未读数
    • 更新本地未读数
    • 更新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长连接,在服务端未读数发生变化时,主动推送给所有在线客户端。客户端维护本地未读数,并实时响应服务端的推送更新。这种方案强调实时性,确保用户第一时间看到未读数变化。

这种方案适用于:

  • 对实时性要求极高的场景
  • 多端应用场景
  • 需要即时通知的场景

流程图
其他设备(接收方)客户端(接收方)WebSocket管理器数据库服务端客户端(发送方)其他设备(接收方)客户端(接收方)WebSocket管理器数据库服务端客户端(发送方)1. 新消息实时推送流程alt[非免打扰会话][免打扰会话]2. 标记已读实时推送流程3. 断线重连流程4. 离线消息处理流程5. 消息撤回流程alt[消息未读][消息已读]发送消息 (HTTP)处理消息查询会话是否免打扰返回免打扰状态更新未读数(+1)更新成功获取接收方所有在线连接返回连接列表推送未读数更新 (WebSocket)更新本地未读数更新UI显示推送未读数更新 (WebSocket)更新本地未读数更新UI显示更新未读数(+1)但不计入总未读数更新成功获取接收方所有在线连接返回连接列表推送未读数更新 (WebSocket)更新本地未读数更新UI显示(仅会话未读数)推送未读数更新 (WebSocket)更新本地未读数更新UI显示(仅会话未读数)请求标记消息已读 (HTTP)更新最后阅读位置重新计算未读数更新成功查询最新未读数返回未读数获取用户所有在线连接返回连接列表推送更新后的未读数 (WebSocket)更新本地未读数更新UI显示推送更新后的未读数 (WebSocket)更新本地未读数更新UI显示WebSocket重连成功 (WebSocket)查询用户所有未读数返回未读数数据推送最新未读数 (WebSocket)更新本地未读数更新UI显示重新连接并请求离线消息 (HTTP)查询离线期间的未读消息返回离线消息列表计算离线期间未读数增量更新未读数更新成功返回离线消息和未读数 (HTTP响应)更新本地未读数更新UI显示请求撤回消息 (HTTP)查询消息是否已读返回已读状态减少未读数(-1)更新成功获取接收方所有在线连接返回连接列表推送未读数更新 (WebSocket)更新本地未读数更新UI显示推送未读数更新 (WebSocket)更新本地未读数更新UI显示不更新未读数

服务端流程:

  1. 消息接收处理

    • 服务端接收发送的消息(HTTP请求)
    • 查询会话是否免打扰
    • 如果是非免打扰会话,更新数据库中的未读数
    • 如果是免打扰会话,更新未读数但不计入总未读数
    • 查询接收方的所有在线连接
    • 通过WebSocket向所有在线客户端推送未读数更新
  2. 消息已读处理

    • 接收客户端的已读请求(HTTP请求)
    • 更新数据库中的已读状态和未读数
    • 向该用户的所有在线客户端推送未读数更新
  3. WebSocket连接管理

    • 维护用户ID到WebSocket连接的映射
    • 支持多设备同时在线
    • 处理连接断开和重连
  4. 离线消息处理

    • 客户端重新连接时,查询离线期间的未读消息
    • 计算离线期间未读数增量
    • 更新数据库中的未读数
    • 返回离线消息和未读数给客户端
  5. 消息撤回处理

    • 接收撤回消息请求
    • 查询消息是否已读
    • 如果消息未读,减少未读数
    • 向所有在线客户端推送未读数更新

客户端流程:

  1. 登录成功后初始化

    • 建立WebSocket长连接
    • 发送心跳保持连接
    • 处理断线重连
  2. 接收推送

    • 监听未读数更新推送
    • 更新本地未读数
    • 更新UI显示
  3. 获取未读数(HTTP请求执行时机)

    • 登录成功后:用户登录成功后,通过HTTP请求获取用户所有会话的未读数汇总
    • WebSocket重连时:当WebSocket连接断开并重新连接成功后,通过HTTP请求拉取最新的未读数数据,确保数据一致性
    • 手动刷新时:用户主动刷新会话列表时,可通过HTTP请求获取最新未读数(可选)
  4. 标记消息已读(HTTP请求执行时机)

    • 打开会话时:用户点击打开某个会话时,立即发送HTTP请求标记该会话的所有消息为已读
    • 会话可见时:当会话窗口从后台切换到前台时,发送HTTP请求标记会话已读(可选)
    • 滚动到底部时:用户在会话中滚动查看消息到底部时,发送HTTP请求标记已读(可选)
  5. WebSocket监听

    • 监听未读数更新推送
    • 更新本地未读数
    • 更新UI显示
  6. 离线消息处理

    • 重新连接时,请求获取离线消息和未读数
    • 更新本地未读数
    • 更新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)提高性能
  2. API设计

    • 提供获取未读数的API
    • 提供标记已读的API
    • 提供离线消息拉取的API
  3. WebSocket设计

    • 使用WebSocket进行实时推送
    • 实现心跳机制,保持连接
    • 实现断线重连机制
  4. 异常处理

    • 处理网络异常、断线重连等场景
    • 确保数据准确性和用户体验
    • 实现消息补发机制
  5. 性能优化

    • 使用缓存减少数据库查询
    • 使用消息队列保证推送的可靠性
    • 实现增量同步机制





五、总结

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

  1. 服务端管理方案:数据一致性好,适合多端企业应用,但服务端压力大
  2. 实时推送方案:实时性最高,用户体验最好,适合对实时性要求极高的场景

方案选择建议

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

  • 多端企业IM:选择服务端管理方案,保证数据一致性
  • 对实时性要求极高的IM:选择实时推送方案,提供最佳用户体验

实现注意事项

  1. 免打扰会话处理:免打扰会话的未读数不应计入总未读数
  2. 多端同步:确保用户在不同设备上看到一致的未读数
  3. 离线支持:离线场景下也要保持未读数状态
  4. 性能优化:大量会话时需要优化查询和更新性能
  5. 异常处理:网络异常、断线重连等场景的处理
  6. 数据准确性:确保未读数计算的准确性,避免出现错误





版权声明

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值