IM 会话同步企业级方案选型

导读

  1. 阅读本文档需要有一定的后端基础和客户端基础,才能更好地理解本文档的核心内容。
  2. 本文档的设计是基于后端的视角来撰写可选方案,让客户端更加清楚明白要如何配合后端实现。
  3. 方案在移动端(Ios、Android、HarmonyOS)和桌面端(Electron、Flutter)都是通用的!!
  4. 本文档涉及到的相关技术栈有:electron、mysql、sqlite、ts、nestjs、typeorm
  5. 适用人群:leader、架构师、超级个体

一、简介

会话同步是即时通讯(IM)系统的核心机制之一,负责在不同设备、不同客户端之间同步会话列表及其元数据状态。

1.1 会话同步的核心目标

  • 数据一致性:确保用户在不同设备上登录时,看到的会话列表(包括会话基本信息、未读计数、置顶/免打扰状态等)都是一致的
  • 实时性:会话状态的变化能够及时同步到所有在线设备
  • 可靠性:在网络不稳定的情况下,保证数据不丢失、不重复
  • 高效性:最小化数据传输量,降低带宽消耗和服务器负载

二、业界生产企业级方案

2.1 方案一:增量拉取 + WebSocket推送

2.1.1 设计思路

本方案采用混合同步机制,结合HTTP请求和WebSocket推送两种方式:

  1. 增量拉取:客户端定期通过HTTP接口拉取会话更新,基于时间戳实现增量同步,减少数据传输量
  2. WebSocket推送:服务端通过WebSocket实时推送会话变化事件,保证关键事件的实时性
  3. 兜底机制:定期同步作为兜底,确保即使WebSocket推送失败,数据也能最终一致

这种设计既保证了实时性,又兼顾了可靠性和效率,是业界最主流的方案。

2.1.2 流程图

服务端数据库 服务端 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 服务端数据库 服务端 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 初始化阶段 定期同步兜底 loop [每30秒] 实时推送(WebSocket) 会话更新(其他客户端触发) HTTP POST /sessions/sync?lastSyncTime=0 查询所有会话 返回会话列表 返回全量会话数据 IPC调用保存会话数据 保存到本地数据库 建立WebSocket连接(通过HTTP升级协议) 发送认证消息 HTTP POST /sessions/sync(增量拉取) 查询更新的会话(update_time > lastSyncTime) 返回增量数据 返回增量会话数据 IPC调用更新会话数据 更新本地数据 刷新UI 更新会话 更新成功 WebSocket推送session.updated事件 IPC调用更新会话数据 更新本地数据 刷新UI

2.1.3 流程描述

【客户端流程】

  1. 初始化阶段

    • 客户端启动时,检查本地存储的lastSyncTime
    • 如果是首次启动(lastSyncTime = 0),发送HTTP请求全量拉取会话列表
    • 接收到会话数据后,保存到本地数据库
    • 更新本地存储的lastSyncTime为服务器返回的currentTime
  2. 建立WebSocket连接

    • 客户端与服务端建立WebSocket连接
    • 连接成功后,发送认证消息(包含用户Token)
    • 监听WebSocket消息,处理各种会话事件
  3. 增量同步阶段

    • 客户端定期(如每30秒)发送增量拉取请求,携带lastSyncTime
    • 服务端返回该时间点之后所有更新的会话数据
    • 客户端更新本地数据库中的会话数据
    • 更新lastSyncTime为新的时间戳
  4. 实时推送处理

    • 监听WebSocket推送的会话更新事件
    • 接收到session_update事件时,更新本地对应的会话
    • 接收到session_created事件时,添加新会话到本地
    • 接收到session_deleted事件时,删除本地对应的会话
    • 触发UI刷新,显示最新的会话列表
  5. 断线重连

    • 检测到WebSocket连接断开时,自动尝试重连
    • 使用指数退避策略,避免频繁重连
    • 重连成功后,重新发送认证消息

【服务端流程】

  1. 处理增量拉取请求

    • 接收客户端的同步请求,包含lastSyncTimepageSize
    • 如果lastSyncTime = 0,执行全量拉取,查询所有未删除的会话
    • 如果lastSyncTime > 0,执行增量拉取,查询update_time > lastSyncTime的会话
    • 合并会话设置(置顶、免打扰等)
    • 返回会话数据和当前服务器时间currentTime
  2. WebSocket连接管理

    • 接收客户端的WebSocket连接请求
    • 验证客户端认证信息,提取用户ID
    • 保存用户ID到WebSocket连接的映射关系
    • 监听连接断开事件,清理映射关系
  3. 事件监听和推送

    • 监听会话相关的事件(创建、更新、删除、设置变更、未读数变更)
    • 当事件触发时,根据会话的user_id找到对应的WebSocket连接
    • 通过WebSocket推送事件数据给客户端
    • 推送失败时(连接已断开),不做处理,依赖定期同步兜底
  4. 定时任务

    • 定期清理过期的快照或日志数据
    • 定期检查WebSocket连接状态,清理无效连接
2.1.4 优缺点

优点:

  • 实时性好:WebSocket推送保证关键事件能够实时到达客户端
  • 可靠性高:定期同步作为兜底机制,确保数据最终一致性
  • 实现简单:相对其他方案,实现复杂度适中,易于理解和维护
  • 适应性强:适用于大多数IM场景,从小型到中型系统都能胜任
  • 数据传输量小:增量拉取只传输更新的数据,减少带宽消耗
  • 扩展性好:可以方便地扩展其他实时功能(如消息推送)

缺点:

  • 网络依赖:需要稳定的网络连接,网络不佳时用户体验会受影响
  • 推送可能丢失:WebSocket推送可能因网络问题丢失,依赖定期同步补偿
  • 服务器负载:需要维护大量的WebSocket长连接,增加服务器资源消耗
  • 实现WebSocket:需要额外实现WebSocket服务和连接管理
  • 心跳开销:需要定期发送心跳保持连接,增加少量网络开销
2.1.5 代码示例

【前端代码】会话同步服务完整实现

typescript 复制代码
/**
 * 会话同步服务
 * 采用增量拉取 + WebSocket推送的混合同步机制
 */
class SessionSyncService {
  private lastSyncTime: number = 0
  private readonly syncInterval = 30000 // 30秒定期同步
  private wsConnection: WebSocket | null = null
  private reconnectAttempts: number = 0
  private readonly maxReconnectAttempts: number = 5
  private isInitialized: boolean = false

  /**
   * 初始化会话同步服务
   */
  async initialize() {
    if (this.isInitialized) {
      return
    }

    // 1. 加载本地保存的同步时间
    this.lastSyncTime = await this.loadLastSyncTime()
    
    // 2. 首次全量拉取或增量拉取
    await this.syncSessions()
    
    // 3. 建立WebSocket连接接收实时更新
    this.connectWebSocket()
    
    // 4. 开启定期同步作为兜底
    this.startPeriodicSync()
    
    this.isInitialized = true
  }

  /**
   * 同步会话(全量或增量)
   */
  private async syncSessions() {
    try {
      const response = await httpClient.request({
        url: '/saas-reim-im/saas/data/getSession',
        method: 'post',
        data: {
          lastSyncTime: this.lastSyncTime || 0,
          pageSize: 500
        }
      })

      const sessions = response.data.sessions
      
      // 存储到主进程数据库
      await this.saveToLocalDB(sessions)
      
      // 更新同步时间戳
      this.lastSyncTime = response.data.currentTime || Date.now()
      await this.saveLastSyncTime(this.lastSyncTime)
      
      // 通知UI更新
      this.emit('sessions:synced', sessions)
      
      console.log(`[会话同步] 成功同步 ${sessions.length} 个会话`)
      
    } catch (error) {
      console.error('[会话同步] 同步失败:', error)
      throw error
    }
  }

  /**
   * 建立WebSocket连接
   */
  private connectWebSocket() {
    const wsUrl = 'wss://api.example.com/ws/sessions'
    
    this.wsConnection = new WebSocket(wsUrl)

    this.wsConnection.onopen = () => {
      console.log('[WebSocket] 连接已建立')
      
      // 重置重连计数
      this.reconnectAttempts = 0
      
      // 发送认证消息
      this.sendAuthMessage()
    }

    this.wsConnection.onmessage = (event) => {
      try {
        const message = JSON.parse(event.data)
        this.handleWebSocketMessage(message)
      } catch (error) {
        console.error('[WebSocket] 消息解析失败:', error)
      }
    }

    this.wsConnection.onerror = (error) => {
      console.error('[WebSocket] 连接错误:', error)
    }

    this.wsConnection.onclose = () => {
      console.log('[WebSocket] 连接已关闭')
      
      // 尝试重连
      this.attemptReconnect()
    }
  }

  /**
   * 处理WebSocket消息
   */
  private async handleWebSocketMessage(message: any) {
    switch (message.type) {
      case 'session_update':
        await this.handleSessionUpdate(message.data)
        break
      case 'session_created':
        await this.handleSessionCreated(message.data)
        break
      case 'session_deleted':
        await this.handleSessionDeleted(message.data)
        break
      case 'session_settings_changed':
        await this.handleSessionSettingsChanged(message.data)
        break
      case 'unread_count_changed':
        await this.handleUnreadCountChanged(message.data)
        break
      default:
        console.warn('[WebSocket] 未知消息类型:', message.type)
    }
  }

  /**
   * 处理会话更新
   */
  private async handleSessionUpdate(data: any) {
    const session = data.session
    
    // 更新本地数据库
    await this.updateLocalSession(session)
    
    // 通知UI更新
    this.emit('session:updated', session)
    
    console.log(`[会话更新] 会话 ${session.session_id} 已更新`)
  }

  /**
   * 处理会话创建
   */
  private async handleSessionCreated(data: any) {
    const session = data.session
    
    // 添加到本地数据库
    await this.addLocalSession(session)
    
    // 通知UI更新
    this.emit('session:created', session)
    
    console.log(`[会话创建] 会话 ${session.session_id} 已创建`)
  }

  /**
   * 处理会话删除
   */
  private async handleSessionDeleted(data: any) {
    const sessionId = data.sessionId
    
    // 从本地数据库删除
    await this.deleteLocalSession(sessionId)
    
    // 通知UI更新
    this.emit('session:deleted', { sessionId })
    
    console.log(`[会话删除] 会话 ${sessionId} 已删除`)
  }

  /**
   * 处理会话设置变化
   */
  private async handleSessionSettingsChanged(data: any) {
    const settings = data.settings
    
    // 更新本地会话设置
    await this.updateLocalSessionSettings(settings)
    
    // 通知UI更新
    this.emit('session:settings_changed', settings)
    
    console.log(`[会话设置] 会话 ${settings.session_id} 设置已更新`)
  }

  /**
   * 处理未读数变化
   */
  private async handleUnreadCountChanged(data: any) {
    const { sessionId, unreadCount } = data
    
    // 更新本地未读数
    await this.updateLocalUnreadCount(sessionId, unreadCount)
    
    // 通知UI更新
    this.emit('session:unread_changed', { sessionId, unreadCount })
    
    console.log(`[未读数] 会话 ${sessionId} 未读数为 ${unreadCount}`)
  }

  /**
   * 发送认证消息
   */
  private sendAuthMessage() {
    const token = this.getAuthToken()
    
    if (this.wsConnection && this.wsConnection.readyState === WebSocket.OPEN) {
      this.wsConnection.send(JSON.stringify({
        type: 'auth',
        token: token,
        timestamp: Date.now()
      }))
    }
  }

  /**
   * 尝试重连
   */
  private attemptReconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('[WebSocket] 已达到最大重连次数,停止重连')
      return
    }

    this.reconnectAttempts++
    const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000) // 指数退避,最大30秒
    
    console.log(`[WebSocket] ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连`)
    
    setTimeout(() => {
      this.connectWebSocket()
    }, delay)
  }

  /**
   * 开启定期同步
   */
  private startPeriodicSync() {
    setInterval(() => {
      this.syncSessions().catch(error => {
        console.error('[会话同步] 定期同步失败:', error)
      })
    }, this.syncInterval)
  }

  /**
   * 保存到本地数据库(主进程)
   */
  private async saveToLocalDB(sessions: any[]) {
    await window.electron.ipcRenderer.invoke('session:save', sessions)
  }

  /**
   * 更新本地会话(主进程)
   */
  private async updateLocalSession(session: any) {
    await window.electron.ipcRenderer.invoke('session:update', session)
  }

  /**
   * 添加本地会话(主进程)
   */
  private async addLocalSession(session: any) {
    await window.electron.ipcRenderer.invoke('session:add', session)
  }

  /**
   * 删除本地会话(主进程)
   */
  private async deleteLocalSession(sessionId: string) {
    await window.electron.ipcRenderer.invoke('session:delete', sessionId)
  }

  /**
   * 更新本地会话设置(主进程)
   */
  private async updateLocalSessionSettings(settings: any) {
    await window.electron.ipcRenderer.invoke('session:update-settings', settings)
  }

  /**
   * 更新本地未读数(主进程)
   */
  private async updateLocalUnreadCount(sessionId: string, unreadCount: number) {
    await window.electron.ipcRenderer.invoke('session:update-unread', { sessionId, unreadCount })
  }

  /**
   * 加载本地保存的同步时间
   */
  private async loadLastSyncTime(): Promise<number> {
    const time = localStorage.getItem('session_last_sync_time')
    return time ? parseInt(time) : 0
  }

  /**
   * 保存同步时间
   */
  private async saveLastSyncTime(time: number) {
    localStorage.setItem('session_last_sync_time', time.toString())
  }

  /**
   * 获取认证Token
   */
  private getAuthToken(): string {
    return localStorage.getItem('auth_token') || ''
  }

  /**
   * 触发事件
   */
  private emit(event: string, data: any) {
    window.dispatchEvent(new CustomEvent(event, { detail: data }))
  }

  /**
   * 销毁服务
   */
  destroy() {
    if (this.wsConnection) {
      this.wsConnection.close()
      this.wsConnection = null
    }
    
    this.isInitialized = false
  }
}

// 导出单例
export const sessionSyncService = new SessionSyncService()

【后端代码】会话服务完整实现

typescript 复制代码
/**
 * 会话服务
 * 提供会话数据的CRUD操作和同步功能
 */
@Injectable()
export class SessionService {
  constructor(
    @InjectRepository(SessionEntity)
    private readonly sessionRepository: Repository<SessionEntity>,
    
    @InjectRepository(SessionSettingsEntity)
    private readonly settingsRepository: Repository<SessionSettingsEntity>,
    
    @InjectRepository(SessionUnreadsEntity)
    private readonly unreadsRepository: Repository<SessionUnreadsEntity>,
    
    private readonly eventEmitter: EventEmitter2
  ) {}

  /**
   * 获取用户会话列表(支持全量和增量)
   */
  async getUserSessions(
    userId: string,
    lastSyncTime: number = 0,
    pageSize: number = 500
  ): Promise<{ sessions: Session[], currentTime: number }> {
    const currentTime = Date.now()

    if (lastSyncTime === 0) {
      // 全量拉取
      const sessions = await this.sessionRepository.find({
        where: { user_id: userId, is_deleted: false },
        order: { last_message_time: 'DESC' },
        take: pageSize
      })

      return {
        sessions: await this.mergeSessionSettings(sessions),
        currentTime
      }
    } else {
      // 增量拉取
      const sessions = await this.sessionRepository.find({
        where: {
          user_id: userId,
          is_deleted: false,
          update_time: MoreThan(new Date(lastSyncTime))
        },
        order: { update_time: 'DESC' },
        take: pageSize
      })

      return {
        sessions: await this.mergeSessionSettings(sessions),
        currentTime
      }
    }
  }

  /**
   * 创建或更新会话
   */
  async saveSession(sessionData: any): Promise<Session> {
    const { session_id, user_id, ...rest } = sessionData

    let session: SessionEntity

    if (session_id) {
      // 更新现有会话
      session = await this.sessionRepository.findOneBy({ session_id })
      
      if (!session) {
        throw new Error(`会话 ${session_id} 不存在`)
      }

      Object.assign(session, rest)
      session.update_time = new Date()

      session = await this.sessionRepository.save(session)

      // 触发更新事件
      this.eventEmitter.emit('session.updated', {
        sessionId: session.session_id,
        session: await this.toSessionDTO(session)
      })

    } else {
      // 创建新会话
      session = this.sessionRepository.create({
        ...rest,
        user_id,
        session_id: this.generateSessionId(user_id, rest.to_id, rest.scene),
        create_time: new Date(),
        update_time: new Date()
      })

      session = await this.sessionRepository.save(session)

      // 触发创建事件
      this.eventEmitter.emit('session.created', {
        sessionId: session.session_id,
        session: await this.toSessionDTO(session)
      })
    }

    return this.toSessionDTO(session)
  }

  /**
   * 合并会话设置
   */
  private async mergeSessionSettings(sessions: SessionEntity[]): Promise<Session[]> {
    const sessionIds = sessions.map(s => s.session_id)
    
    const settings = await this.settingsRepository.find({
      where: { session_id: In(sessionIds) }
    })

    const settingsMap = new Map(settings.map(s => [s.session_id, s]))

    return sessions.map(session => this.toSessionDTO(session, settingsMap.get(session.session_id)))
  }

  /**
   * 生成会话ID
   */
  private generateSessionId(userId: string, toId: string, scene: string): string {
    if (scene === 'private') {
      // 单聊:两个用户ID拼接,按用户ID从小到大排序
      const [id1, id2] = [userId, toId].sort()
      return `${id1}_${id2}`
    } else {
      // 群聊:直接使用群ID作为会话ID
      return toId
    }
  }

  /**
   * 转换为DTO
   */
  private async toSessionDTO(
    session: SessionEntity,
    settings?: SessionSettingsEntity
  ): Promise<Session> {
    return {
      ...session,
      pinned: settings?.is_pinned || false,
      muted: settings?.is_disturb || false
    } as Session
  }
}

/**
 * 会话控制器
 */
@Controller('sessions')
export class SessionController {
  constructor(private readonly sessionService: SessionService) {}

  /**
   * 获取会话列表
   */
  @Post('sync')
  async syncSessions(
    @Body('userId') userId: string,
    @Body('lastSyncTime') lastSyncTime: number = 0,
    @Body('pageSize') pageSize: number = 500
  ) {
    return this.sessionService.getUserSessions(userId, lastSyncTime, pageSize)
  }
}

【后端代码】WebSocket推送服务完整实现

typescript 复制代码
/**
 * WebSocket服务
 * 实现会话实时推送
 */
@Injectable()
export class SessionWebSocketService {
  private clients: Map<string, WebSocket> = new Map()
  private heartbeatInterval: NodeJS.Timeout | null = null

  constructor(private readonly eventEmitter: EventEmitter2) {
    this.setupEventListeners()
    this.startHeartbeat()
  }

  /**
   * 用户连接
   */
  handleConnection(client: WebSocket, userId: string) {
    this.clients.set(userId, client)
    console.log(`[WebSocket] 用户 ${userId} 已连接,当前连接数: ${this.clients.size}`)
  }

  /**
   * 用户断开
   */
  handleDisconnect(userId: string) {
    this.clients.delete(userId)
    console.log(`[WebSocket] 用户 ${userId} 已断开,当前连接数: ${this.clients.size}`)
  }

  /**
   * 推送会话更新
   */
  pushSessionUpdate(userId: string, session: Session) {
    this.sendMessage(userId, {
      type: 'session_update',
      data: { session },
      timestamp: Date.now()
    })
  }

  /**
   * 推送会话创建
   */
  pushSessionCreated(userId: string, session: Session) {
    this.sendMessage(userId, {
      type: 'session_created',
      data: { session },
      timestamp: Date.now()
    })
  }

  /**
   * 推送会话删除
   */
  pushSessionDeleted(userId: string, sessionId: string) {
    this.sendMessage(userId, {
      type: 'session_deleted',
      data: { sessionId },
      timestamp: Date.now()
    })
  }

  /**
   * 发送消息
   */
  private sendMessage(userId: string, message: any) {
    const client = this.clients.get(userId)
    
    if (client && client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(message))
    }
  }

  /**
   * 设置事件监听器
   */
  private setupEventListeners() {
    this.eventEmitter.on('session.created', (event) => {
      this.pushSessionCreated(event.session.user_id, event.session)
    })

    this.eventEmitter.on('session.updated', (event) => {
      this.pushSessionUpdate(event.session.user_id, event.session)
    })

    this.eventEmitter.on('session.deleted', (event) => {
      this.pushSessionDeleted(event.userId, event.sessionId)
    })
  }

  /**
   * 开始心跳
   */
  private startHeartbeat() {
    this.heartbeatInterval = setInterval(() => {
      this.clients.forEach((client, userId) => {
        if (client.readyState === WebSocket.OPEN) {
          client.send(JSON.stringify({
            type: 'heartbeat',
            timestamp: Date.now()
          }))
        } else {
          // 连接已断开,移除
          this.clients.delete(userId)
        }
      })
    }, 30000) // 每30秒发送一次心跳
  }
}

/**
 * WebSocket网关
 */
@WebSocketGateway({
  cors: {
    origin: '*'
  }
})
export class SessionWebSocketGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(
    private readonly wsService: SessionWebSocketService,
    private readonly jwtService: JwtService
  ) {}

  /**
   * 处理连接
   */
  async handleConnection(client: WebSocket, request: IncomingMessage) {
    try {
      // 验证Token
      const token = this.extractToken(request)
      const payload = this.jwtService.verify(token)
      const userId = payload.sub

      // 保存连接
      this.wsService.handleConnection(client, userId)

    } catch (error) {
      console.error('[WebSocket] 连接失败:', error)
      client.close(4001, '认证失败')
    }
  }

  /**
   * 处理断开
   */
  handleDisconnect(client: WebSocket) {
    // WebSocketService已经通过userId管理连接
  }

  /**
   * 提取Token
   */
  private extractToken(request: IncomingMessage): string {
    const authHeader = request.headers.authorization
    if (!authHeader) {
      throw new UnauthorizedException('未提供认证信息')
    }

    const [type, token] = authHeader.split(' ')
    if (type !== 'Bearer' || !token) {
      throw new UnauthorizedException('认证信息格式错误')
    }

    return token
  }
}

2.2 方案二:全量拉取 + 增量推送

2.2.1 设计思路

本方案采用最简单的同步机制,结合全量拉取和增量推送:

  1. 全量拉取:客户端启动时,从服务端拉取完整的会话列表
  2. 增量推送:服务端通过WebSocket实时推送会话变化事件
  3. 简单可靠:实现简单,适用于数据量不大的场景
  4. 实时性好:WebSocket推送保证关键事件的实时性
  5. 上限设定:通过分页机制限制单次拉取的数据量,通常设置1000-5000条的上限,防止数据量过大导致网络传输慢或客户端内存溢出

这种设计适合小型IM系统或快速原型开发,数据量小,追求快速实现。

2.2.2 流程图

服务端数据库 服务端 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 服务端数据库 服务端 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 初始化阶段 实时推送(WebSocket) 会话更新(其他客户端触发) 重新连接恢复 alt [网络断开后重连] HTTP GET /sessions(全量拉取) 查询所有会话 返回所有会话 返回完整会话列表 IPC调用保存会话数据 保存到本地数据库 建立WebSocket连接(通过HTTP升级协议) 发送认证消息 更新会话 更新成功 WebSocket推送更新 IPC调用更新会话数据 更新本地数据 刷新UI HTTP GET /sessions(全量拉取) 返回完整会话列表 IPC调用保存会话数据 覆盖本地数据 重新建立WebSocket连接

2.2.3 流程描述

【客户端流程】

  1. 初始化阶段

    • 客户端启动时,发送HTTP请求拉取完整的会话列表
    • 接收所有会话数据,保存到本地数据库
    • 建立WebSocket连接,接收实时推送
  2. 实时推送处理

    • 监听WebSocket推送的会话事件
    • 接收到事件后,更新本地对应的会话数据
    • 触发UI刷新,显示最新的会话列表
  3. 重新连接恢复

    • 检测到WebSocket连接断开时,尝试重新连接
    • 如果重新连接失败,退回到定期全量拉取
    • 重新连接成功后,重新拉取全量数据确保一致性
  4. 错误处理

    • 如果全量拉取失败,使用本地缓存数据
    • 记录错误日志,定期重试
    • 提供手动刷新按钮,让用户手动触发同步

【服务端流程】

  1. 全量数据接口

    • 提供接口返回用户的所有会话数据
    • 支持分页,避免单次返回数据量过大,默认上限设置为1000条,可根据业务需求调整
    • 实现会话列表全量拉取的上限设定,防止数据量过大导致网络传输慢或客户端内存溢出
    • 包含会话基本信息和设置信息
  2. WebSocket推送

    • 维护客户端的WebSocket连接
    • 监听会话相关的事件(创建、更新、删除等)
    • 通过WebSocket实时推送给对应的客户端
  3. 连接管理

    • 管理WebSocket连接的生命周期
    • 处理连接断开和重连
    • 验证客户端身份,确保安全性
  4. 数据一致性保证

    • 保证全量拉取和增量推送的数据一致性
    • 使用事务确保操作的原子性
    • 提供版本号机制,避免数据冲突
2.2.4 优缺点

优点:

  • 实现简单:逻辑简单,开发周期短
  • 可靠性高:全量拉取保证数据完整性
  • 实时性好:WebSocket推送保证关键事件实时性
  • 易于调试:数据流清晰,易于排查问题
  • 适合小数据量:数据量小的时候性能表现好
  • 快速原型:适合快速验证产品想法

缺点:

  • 数据传输量大:每次全量拉取都传输所有数据
  • 网络消耗大:数据量大时网络消耗显著
  • 服务器压力大:全量拉取对数据库压力大
  • 不适合大数据量:数据量大时性能急剧下降
  • 实时性依赖WebSocket:WebSocket断开时实时性丧失
  • 数据冗余:每次拉取都包含未变化的数据
  • 需要合理设定上限值:上限值设置过小可能导致数据不完整,设置过大则无法有效控制数据传输量
2.2.5 业界代表
  • 小型IM应用:初创公司或小团队开发的IM应用
  • 快速原型:产品验证阶段的快速实现
  • 内部工具:企业内部使用的简单通讯工具
  • 早期版本应用:产品早期版本采用的简单方案
  • 教育项目:教学演示项目采用的简单实现
2.2.6 代码示例

【前端代码】全量拉取+增量推送实现

typescript 复制代码
/**
 * 会话全量拉取+增量推送服务
 */
class SessionFullSyncService {
  private wsConnection: WebSocket | null = null
  private readonly reconnectInterval = 5000 // 5秒重连间隔
  private reconnectTimer: NodeJS.Timeout | null = null

  /**
   * 初始化同步服务
   */
  async initialize() {
    // 1. 全量拉取会话数据
    await this.fullSync()
    
    // 2. 建立WebSocket连接
    this.connectWebSocket()
    
    // 3. 设置断线重连
    this.setupReconnection()
  }

  /**
   * 全量拉取会话数据
   */
  private async fullSync() {
    try {
      const response = await httpClient.request({
        url: '/api/sessions',
        method: 'get',
        params: {
          page: 1,
          // 会话列表全量拉取的上限设定,默认1000条,防止数据量过大
          pageSize: 1000
        }
      })

      const sessions = response.data.sessions
      
      // 保存到本地数据库
      await this.saveToLocalDB(sessions)
      
      // 通知UI更新
      this.emit('sessions:synced', sessions)
      
      console.log(`[全量同步] 成功拉取 ${sessions.length} 个会话`)
      
    } catch (error) {
      console.error('[全量同步] 拉取失败:', error)
      
      // 使用本地缓存
      const localSessions = await this.loadFromLocalDB()
      this.emit('sessions:synced', localSessions)
    }
  }

  /**
   * 建立WebSocket连接
   */
  private connectWebSocket() {
    const wsUrl = 'wss://api.example.com/ws/sessions'
    
    this.wsConnection = new WebSocket(wsUrl)

    this.wsConnection.onopen = () => {
      console.log('[WebSocket] 连接已建立')
      
      // 发送认证消息
      this.sendAuthMessage()
    }

    this.wsConnection.onmessage = (event) => {
      try {
        const message = JSON.parse(event.data)
        this.handleWebSocketMessage(message)
      } catch (error) {
        console.error('[WebSocket] 消息解析失败:', error)
      }
    }

    this.wsConnection.onerror = (error) => {
      console.error('[WebSocket] 连接错误:', error)
    }

    this.wsConnection.onclose = () => {
      console.log('[WebSocket] 连接已关闭')
      
      // 触发重连
      this.scheduleReconnection()
    }
  }

  /**
   * 处理WebSocket消息
   */
  private async handleWebSocketMessage(message: any) {
    switch (message.type) {
      case 'session_update':
        await this.handleSessionUpdate(message.data)
        break
      case 'session_created':
        await this.handleSessionCreated(message.data)
        break
      case 'session_deleted':
        await this.handleSessionDeleted(message.data)
        break
      case 'heartbeat':
        // 心跳响应
        break
      default:
        console.warn('[WebSocket] 未知消息类型:', message.type)
    }
  }

  /**
   * 处理会话更新
   */
  private async handleSessionUpdate(data: any) {
    const session = data.session
    
    // 更新本地数据库
    await this.updateLocalSession(session)
    
    // 通知UI更新
    this.emit('session:updated', session)
    
    console.log(`[实时推送] 会话 ${session.session_id} 已更新`)
  }

  /**
   * 处理会话创建
   */
  private async handleSessionCreated(data: any) {
    const session = data.session
    
    // 添加到本地数据库
    await this.addLocalSession(session)
    
    // 通知UI更新
    this.emit('session:created', session)
    
    console.log(`[实时推送] 会话 ${session.session_id} 已创建`)
  }

  /**
   * 处理会话删除
   */
  private async handleSessionDeleted(data: any) {
    const sessionId = data.sessionId
    
    // 从本地数据库删除
    await this.deleteLocalSession(sessionId)
    
    // 通知UI更新
    this.emit('session:deleted', { sessionId })
    
    console.log(`[实时推送] 会话 ${sessionId} 已删除`)
  }

  /**
   * 发送认证消息
   */
  private sendAuthMessage() {
    const token = this.getAuthToken()
    
    if (this.wsConnection && this.wsConnection.readyState === WebSocket.OPEN) {
      this.wsConnection.send(JSON.stringify({
        type: 'auth',
        token: token,
        timestamp: Date.now()
      }))
    }
  }

  /**
   * 设置重连机制
   */
  private setupReconnection() {
    // 监听网络状态
    window.addEventListener('online', () => {
      console.log('[网络] 网络已恢复')
      this.scheduleReconnection()
    })

    window.addEventListener('offline', () => {
      console.log('[网络] 网络已断开')
      this.clearReconnection()
    })
  }

  /**
   * 安排重连
   */
  private scheduleReconnection() {
    this.clearReconnection()
    
    this.reconnectTimer = setTimeout(() => {
      console.log('[WebSocket] 尝试重新连接')
      this.connectWebSocket()
    }, this.reconnectInterval)
  }

  /**
   * 清除重连定时器
   */
  private clearReconnection() {
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer)
      this.reconnectTimer = null
    }
  }

  /**
   * 保存到本地数据库
   */
  private async saveToLocalDB(sessions: any[]) {
    // 清空本地数据库
    await this.db.sessions.clear()
    
    // 保存新数据
    for (const session of sessions) {
      await this.db.sessions.add(session)
    }
  }

  /**
   * 从本地数据库加载
   */
  private async loadFromLocalDB(): Promise<any[]> {
    return await this.db.sessions.toArray()
  }

  /**
   * 更新本地会话
   */
  private async updateLocalSession(session: any) {
    await this.db.sessions.put(session)
  }

  /**
   * 添加本地会话
   */
  private async addLocalSession(session: any) {
    await this.db.sessions.add(session)
  }

  /**
   * 删除本地会话
   */
  private async deleteLocalSession(sessionId: string) {
    await this.db.sessions.delete(sessionId)
  }

  /**
   * 获取认证Token
   */
  private getAuthToken(): string {
    return localStorage.getItem('auth_token') || ''
  }

  /**
   * 触发事件
   */
  private emit(event: string, data: any) {
    window.dispatchEvent(new CustomEvent(event, { detail: data }))
  }

  /**
   * 手动刷新
   */
  async manualRefresh() {
    console.log('[手动刷新] 触发全量同步')
    await this.fullSync()
  }

  /**
   * 销毁服务
   */
  destroy() {
    this.clearReconnection()
    
    if (this.wsConnection) {
      this.wsConnection.close()
      this.wsConnection = null
    }
  }
}

// 导出单例
export const sessionFullSyncService = new SessionFullSyncService()

【后端代码】全量拉取+增量推送实现

typescript 复制代码
/**
 * 会话全量服务
 */
@Injectable()
export class SessionFullService {
  constructor(
    @InjectRepository(SessionEntity)
    private readonly sessionRepository: Repository<SessionEntity>
  ) {}

  /**
   * 获取用户所有会话
   */
  async getUserSessions(
    userId: string,
    page: number = 1,
    // 会话列表全量拉取的上限设定,默认1000条,防止数据量过大
    pageSize: number = 1000
  ): Promise<{ sessions: Session[], total: number }> {
    const [sessions, total] = await this.sessionRepository.findAndCount({
      where: { user_id: userId, is_deleted: false },
      order: { last_message_time: 'DESC' },
      skip: (page - 1) * pageSize,
      take: pageSize
    })

    return { sessions, total }
  }

  /**
   * 创建会话
   */
  async createSession(sessionData: any): Promise<Session> {
    const session = this.sessionRepository.create({
      ...sessionData,
      create_time: new Date(),
      update_time: new Date()
    })

    const savedSession = await this.sessionRepository.save(session)
    
    // 触发事件
    this.eventEmitter.emit('session.created', {
      sessionId: savedSession.session_id,
      session: savedSession
    })
    
    return savedSession
  }

  /**
   * 更新会话
   */
  async updateSession(sessionId: string, updates: any): Promise<Session> {
    const session = await this.sessionRepository.findOneBy({ session_id: sessionId })
    
    if (!session) {
      throw new Error(`会话 ${sessionId} 不存在`)
    }

    Object.assign(session, updates)
    session.update_time = new Date()
    
    const updatedSession = await this.sessionRepository.save(session)
    
    // 触发事件
    this.eventEmitter.emit('session.updated', {
      sessionId: updatedSession.session_id,
      session: updatedSession
    })
    
    return updatedSession
  }

  /**
   * 删除会话
   */
  async deleteSession(sessionId: string, userId: string): Promise<void> {
    await this.sessionRepository.softDelete({ session_id: sessionId })
    
    // 触发事件
    this.eventEmitter.emit('session.deleted', {
      sessionId,
      userId
    })
  }
}

/**
 * 会话全量控制器
 */
@Controller('sessions')
export class SessionFullController {
  constructor(
    private readonly sessionService: SessionFullService
  ) {}

  /**
   * 获取用户所有会话
   */
  @Get()
  async getSessions(
    @Query('userId') userId: string,
    @Query('page') page: number = 1,
    @Query('pageSize') pageSize: number = 1000
  ) {
    return this.sessionService.getUserSessions(userId, page, pageSize)
  }
}

/**
 * WebSocket推送服务
 */
@Injectable()
export class SessionWebSocketPushService {
  private clients: Map<string, WebSocket> = new Map()

  constructor(private readonly eventEmitter: EventEmitter2) {
    this.setupEventListeners()
  }

  /**
   * 处理连接
   */
  handleConnection(client: WebSocket, userId: string) {
    this.clients.set(userId, client)
    console.log(`[WebSocket推送] 用户 ${userId} 已连接`)
  }

  /**
   * 处理断开
   */
  handleDisconnect(userId: string) {
    this.clients.delete(userId)
    console.log(`[WebSocket推送] 用户 ${userId} 已断开`)
  }

  /**
   * 推送会话更新
   */
  private pushSessionUpdate(userId: string, session: Session) {
    this.sendMessage(userId, {
      type: 'session_update',
      data: { session },
      timestamp: Date.now()
    })
  }

  /**
   * 推送会话创建
   */
  private pushSessionCreated(userId: string, session: Session) {
    this.sendMessage(userId, {
      type: 'session_created',
      data: { session },
      timestamp: Date.now()
    })
  }

  /**
   * 推送会话删除
   */
  private pushSessionDeleted(userId: string, sessionId: string) {
    this.sendMessage(userId, {
      type: 'session_deleted',
      data: { sessionId },
      timestamp: Date.now()
    })
  }

  /**
   * 发送消息
   */
  private sendMessage(userId: string, message: any) {
    const client = this.clients.get(userId)
    
    if (client && client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(message))
    }
  }

  /**
   * 设置事件监听器
   */
  private setupEventListeners() {
    this.eventEmitter.on('session.created', (event) => {
      this.pushSessionCreated(event.session.user_id, event.session)
    })

    this.eventEmitter.on('session.updated', (event) => {
      this.pushSessionUpdate(event.session.user_id, event.session)
    })

    this.eventEmitter.on('session.deleted', (event) => {
      this.pushSessionDeleted(event.userId, event.sessionId)
    })
  }
}

2.3 方案三:增量快照 + 差异同步

2.3.1 设计思路

本方案基于快照和差异对比的同步机制,通过定期生成数据快照并比较差异来实现高效同步:

  1. 快照机制:服务端定期生成会话数据的完整快照,每个快照有唯一版本号
  2. 差异对比:客户端通过对比本地快照和云端快照的差异,只同步变化的部分
  3. 增量更新:基于差异结果,只传输需要更新的数据,极大减少传输量
  4. 版本管理:通过版本号管理快照,支持断点续传和回滚

这种设计适合大规模IM系统,数据量大但变化相对缓慢的场景。

2.3.2 流程图

服务端数据库 快照管理器(服务端) 服务端(业务逻辑) 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 服务端数据库 快照管理器(服务端) 服务端(业务逻辑) 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 初始化阶段(客户端) 定期同步(客户端) alt [版本有更新] [版本无更新] loop [每5分钟] 快照生成(服务端定时任务) 定时任务触发 请求初始快照(version=0) 获取初始快照 查询当前会话数据 返回会话数据 生成快照(version=1) 存储快照 返回快照数据 返回完整快照数据 IPC调用保存快照数据 保存快照到本地数据库 请求最新快照版本号 查询最新版本号 查询最新版本号 返回最新版本号 返回最新版本号 返回最新版本号 请求版本差异(fromVersion=1, toVersion=2) 计算版本差异 查询快照差异数据 返回差异数据 返回差异数据 返回差异数据 IPC调用应用差异 应用差异更新 刷新UI IPC调用更新本地版本号 更新本地快照版本 无需同步 查询当前所有会话数据 返回会话数据 生成新快照(version=2) 计算与前一个快照的差异 存储快照并存储差异数据

2.3.3 流程描述

【客户端流程】

  1. 初始化阶段

    • 客户端首次启动时,请求服务端的初始快照(version=0)
    • 接收完整的快照数据,保存到本地数据库
    • 记录当前快照版本号
  2. 定期同步阶段

    • 客户端定期(如每5分钟)向服务端请求最新版本号
    • 比较本地版本号和服务器版本号
    • 如果版本号不同,请求两个版本之间的差异数据
    • 接收差异数据,应用到本地数据库
    • 更新本地快照版本号
  3. 差异应用阶段

    • 解析差异数据,包含新增、更新、删除的操作
    • 按照顺序应用这些操作到本地数据库
    • 保证操作的原子性和一致性
  4. 错误处理阶段

    • 如果差异应用失败,回滚到上一个版本
    • 重新请求完整快照作为恢复机制
    • 记录错误日志,便于调试

【服务端流程】

  1. 快照管理器(服务端组件)

    • 快照生成阶段:定时任务(如每小时)触发快照生成,查询数据库中的当前所有会话数据,生成新快照并分配递增的版本号
    • 差异计算阶段:比较两个快照之间的所有数据,识别新增的会话、更新的会话、删除的会话,生成差异数据集,包含操作类型和数据
    • 请求处理阶段:接收客户端的版本号查询请求,返回当前最新的快照版本号,接收差异数据请求,根据fromVersion和toVersion返回差异,如果版本跨度太大,返回完整快照
    • 存储管理阶段:管理快照存储,定期清理过期的快照,维护快照索引,提高查询效率,监控存储使用情况,避免存储溢出
  2. 业务逻辑层(服务端组件)

    • 接收客户端的同步请求
    • 调用快照管理器获取快照数据或差异数据
    • 返回数据给客户端
2.3.4 优缺点

优点:

  • 传输效率高:只传输差异数据,极大减少网络传输量
  • 适合大规模数据:对于数据量大但变化缓慢的场景非常高效
  • 版本管理完善:支持版本回滚和历史查看
  • 断点续传支持:基于版本号支持断点续传
  • 数据一致性高:快照机制保证数据一致性
  • 适合离线同步:可以预先下载快照,离线使用

缺点:

  • 实现复杂度高:需要实现快照生成和差异计算
  • 存储开销大:需要存储多个版本的快照
  • 实时性较差:依赖定期快照生成,实时性不如WebSocket
  • 计算资源消耗:差异计算需要较多的CPU和内存资源
  • 延迟同步:数据更新有延迟,不是实时同步
  • 版本冲突可能:多端同时修改可能导致版本冲突
2.3.5 业界代表
  • WhatsApp:在大规模消息同步中使用快照机制
  • Telegram:采用快照+差异同步优化数据传输
  • Signal:使用快照机制保证数据一致性
  • 企业网盘系统:如Dropbox、Google Drive使用类似机制
  • 数据库备份系统:如MySQL binlog机制类似
2.3.6 代码示例

【前端代码】快照差异同步服务实现

typescript 复制代码
/**
 * 会话快照差异同步服务
 */
class SessionSnapshotSyncService {
  private currentSnapshotVersion: number = 0
  private readonly syncInterval = 300000 // 5分钟同步一次

  /**
   * 初始化同步服务
   */
  async initialize() {
    // 1. 加载本地快照版本
    this.currentSnapshotVersion = await this.getLocalSnapshotVersion()
    
    // 2. 如果本地没有快照,请求初始快照
    if (this.currentSnapshotVersion === 0) {
      await this.requestInitialSnapshot()
    }
    
    // 3. 开启定期同步
    this.startPeriodicSync()
  }

  /**
   * 请求初始快照
   */
  private async requestInitialSnapshot() {
    try {
      const response = await httpClient.request({
        url: '/api/sessions/snapshot/initial',
        method: 'get'
      })

      const snapshot = response.data.snapshot
      
      // 保存快照到本地
      await this.saveSnapshot(snapshot)
      
      // 更新本地版本号
      this.currentSnapshotVersion = snapshot.version
      await this.saveLocalSnapshotVersion(snapshot.version)
      
      console.log(`[快照同步] 初始快照加载完成,版本: ${snapshot.version}`)
      
    } catch (error) {
      console.error('[快照同步] 初始快照请求失败:', error)
      throw error
    }
  }

  /**
   * 定期同步
   */
  private async periodicSync() {
    try {
      // 1. 获取最新版本号
      const latestVersion = await this.getLatestSnapshotVersion()
      
      // 2. 比较版本号
      if (latestVersion > this.currentSnapshotVersion) {
        console.log(`[快照同步] 发现新版本: ${this.currentSnapshotVersion} -> ${latestVersion}`)
        
        // 3. 请求差异数据
        const diff = await this.getSnapshotDiff(this.currentSnapshotVersion, latestVersion)
        
        // 4. 应用差异
        await this.applyDiff(diff)
        
        // 5. 更新版本号
        this.currentSnapshotVersion = latestVersion
        await this.saveLocalSnapshotVersion(latestVersion)
        
        console.log(`[快照同步] 成功同步到版本: ${latestVersion}`)
      } else {
        console.log('[快照同步] 当前已是最新版本')
      }
      
    } catch (error) {
      console.error('[快照同步] 同步失败:', error)
    }
  }

  /**
   * 获取最新版本号
   */
  private async getLatestSnapshotVersion(): Promise<number> {
    const response = await httpClient.request({
      url: '/api/sessions/snapshot/latest-version',
      method: 'get'
    })
    
    return response.data.version
  }

  /**
   * 获取快照差异
   */
  private async getSnapshotDiff(fromVersion: number, toVersion: number): Promise<SnapshotDiff> {
    const response = await httpClient.request({
      url: '/api/sessions/snapshot/diff',
      method: 'post',
      data: {
        fromVersion,
        toVersion
      }
    })
    
    return response.data.diff
  }

  /**
   * 应用差异
   */
  private async applyDiff(diff: SnapshotDiff): Promise<void> {
    // 应用新增的会话
    for (const session of diff.added) {
      await this.db.sessions.add(session)
      this.emit('session:created', session)
    }
    
    // 应用更新的会话
    for (const session of diff.updated) {
      await this.db.sessions.put(session)
      this.emit('session:updated', session)
    }
    
    // 应用删除的会话
    for (const sessionId of diff.deleted) {
      await this.db.sessions.delete(sessionId)
      this.emit('session:deleted', { sessionId })
    }
    
    console.log(`[快照同步] 应用差异: 新增${diff.added.length}个, 更新${diff.updated.length}个, 删除${diff.deleted.length}个`)
  }

  /**
   * 保存快照
   */
  private async saveSnapshot(snapshot: Snapshot): Promise<void> {
    // 清空本地数据库
    await this.db.sessions.clear()
    
    // 保存快照数据
    for (const session of snapshot.sessions) {
      await this.db.sessions.add(session)
    }
    
    // 保存快照元数据
    await this.db.snapshotMetadata.add({
      version: snapshot.version,
      timestamp: snapshot.timestamp,
      sessionCount: snapshot.sessions.length
    })
  }

  /**
   * 开启定期同步
   */
  private startPeriodicSync() {
    setInterval(() => {
      this.periodicSync()
    }, this.syncInterval)
  }

  /**
   * 获取本地快照版本
   */
  private async getLocalSnapshotVersion(): Promise<number> {
    const version = localStorage.getItem('snapshot_version')
    return version ? parseInt(version) : 0
  }

  /**
   * 保存本地快照版本
   */
  private async saveLocalSnapshotVersion(version: number) {
    localStorage.setItem('snapshot_version', version.toString())
  }

  /**
   * 触发事件
   */
  private emit(event: string, data: any) {
    window.dispatchEvent(new CustomEvent(event, { detail: data }))
  }
}

// 导出单例
export const sessionSnapshotSyncService = new SessionSnapshotSyncService()

【后端代码】快照管理服务实现

typescript 复制代码
/**
 * 快照实体
 */
@Entity({ name: 'session_snapshot' })
export class SessionSnapshotEntity {
  @PrimaryColumn({ type: 'bigint' })
  version: number // 快照版本号

  @Column({ type: 'json' })
  data: any // 快照数据

  @Column({ type: 'datetime' })
  create_time: Date // 创建时间

  @Column({ type: 'bigint' })
  session_count: number // 会话数量
}

/**
 * 快照差异实体
 */
@Entity({ name: 'session_snapshot_diff' })
export class SessionSnapshotDiffEntity {
  @PrimaryColumn({ type: 'bigint' })
  id: number

  @Column({ type: 'bigint' })
  from_version: number // 源版本

  @Column({ type: 'bigint' })
  to_version: number // 目标版本

  @Column({ type: 'json' })
  diff_data: any // 差异数据

  @Column({ type: 'datetime' })
  create_time: Date // 创建时间
}

/**
 * 会话快照管理服务
 */
@Injectable()
export class SessionSnapshotService {
  private currentVersion: number = 0

  constructor(
    @InjectRepository(SessionSnapshotEntity)
    private readonly snapshotRepository: Repository<SessionSnapshotEntity>,
    @InjectRepository(SessionSnapshotDiffEntity)
    private readonly diffRepository: Repository<SessionSnapshotDiffEntity>,
    @InjectRepository(SessionEntity)
    private readonly sessionRepository: Repository<SessionEntity>
  ) {
    this.initCurrentVersion()
  }

  /**
   * 初始化当前版本号
   */
  private async initCurrentVersion() {
    const latestSnapshot = await this.snapshotRepository.findOne({
      order: { version: 'DESC' }
    })
    
    this.currentVersion = latestSnapshot?.version || 0
  }

  /**
   * 生成新快照
   */
  async generateSnapshot(): Promise<number> {
    // 1. 获取当前所有会话数据
    const sessions = await this.sessionRepository.find({
      where: { is_deleted: false },
      order: { session_id: 'ASC' }
    })

    // 2. 递增版本号
    this.currentVersion++
    
    // 3. 保存快照
    const snapshot = this.snapshotRepository.create({
      version: this.currentVersion,
      data: sessions,
      create_time: new Date(),
      session_count: sessions.length
    })
    
    await this.snapshotRepository.save(snapshot)
    
    // 4. 计算与前一个快照的差异
    if (this.currentVersion > 1) {
      await this.calculateDiff(this.currentVersion - 1, this.currentVersion, sessions)
    }
    
    console.log(`[快照服务] 生成快照完成,版本: ${this.currentVersion}, 会话数: ${sessions.length}`)
    
    return this.currentVersion
  }

  /**
   * 计算差异
   */
  private async calculateDiff(
    fromVersion: number,
    toVersion: number,
    currentSessions: SessionEntity[]
  ): Promise<void> {
    // 1. 获取前一个快照
    const previousSnapshot = await this.snapshotRepository.findOneBy({
      version: fromVersion
    })
    
    if (!previousSnapshot) {
      return
    }
    
    const previousSessions: SessionEntity[] = previousSnapshot.data
    
    // 2. 计算差异
    const diff = this.computeDiff(previousSessions, currentSessions)
    
    // 3. 保存差异
    const diffEntity = this.diffRepository.create({
      from_version: fromVersion,
      to_version: toVersion,
      diff_data: diff,
      create_time: new Date()
    })
    
    await this.diffRepository.save(diffEntity)
    
    console.log(`[快照服务] 计算差异完成: ${fromVersion} -> ${toVersion}`)
  }

  /**
   * 计算两个快照的差异
   */
  private computeDiff(
    previous: SessionEntity[],
    current: SessionEntity[]
  ): SnapshotDiff {
    const previousMap = new Map(previous.map(s => [s.session_id, s]))
    const currentMap = new Map(current.map(s => [s.session_id, s]))
    
    const added: SessionEntity[] = []
    const updated: SessionEntity[] = []
    const deleted: string[] = []
    
    // 检查新增和更新
    for (const [sessionId, session] of currentMap) {
      const prevSession = previousMap.get(sessionId)
      
      if (!prevSession) {
        // 新增
        added.push(session)
      } else if (!this.isEqual(prevSession, session)) {
        // 更新
        updated.push(session)
      }
    }
    
    // 检查删除
    for (const [sessionId, session] of previousMap) {
      if (!currentMap.has(sessionId)) {
        deleted.push(sessionId)
      }
    }
    
    return { added, updated, deleted }
  }

  /**
   * 比较两个会话是否相等
   */
  private isEqual(session1: SessionEntity, session2: SessionEntity): boolean {
    // 简单比较关键字段
    const fields = ['session_name', 'avatar', 'last_message_content', 'last_message_time', 'unread_count']
    
    for (const field of fields) {
      if (session1[field] !== session2[field]) {
        return false
      }
    }
    
    return true
  }

  /**
   * 获取最新版本号
   */
  async getLatestVersion(): Promise<number> {
    return this.currentVersion
  }

  /**
   * 获取快照差异
   */
  async getDiff(fromVersion: number, toVersion: number): Promise<SnapshotDiff> {
    // 1. 检查是否可以直接获取差异
    const diff = await this.diffRepository.findOneBy({
      from_version: fromVersion,
      to_version: toVersion
    })
    
    if (diff) {
      return diff.diff_data
    }
    
    // 2. 如果差异不存在,计算差异
    const fromSnapshot = await this.snapshotRepository.findOneBy({
      version: fromVersion
    })
    
    const toSnapshot = await this.snapshotRepository.findOneBy({
      version: toVersion
    })
    
    if (!fromSnapshot || !toSnapshot) {
      throw new Error('快照不存在')
    }
    
    const previousSessions: SessionEntity[] = fromSnapshot.data
    const currentSessions: SessionEntity[] = toSnapshot.data
    
    return this.computeDiff(previousSessions, currentSessions)
  }

  /**
   * 获取初始快照
   */
  async getInitialSnapshot(): Promise<Snapshot> {
    const latestSnapshot = await this.snapshotRepository.findOne({
      order: { version: 'DESC' }
    })
    
    if (!latestSnapshot) {
      // 如果没有快照,生成一个
      const version = await this.generateSnapshot()
      return this.getSnapshotByVersion(version)
    }
    
    return {
      version: latestSnapshot.version,
      sessions: latestSnapshot.data,
      timestamp: latestSnapshot.create_time.getTime()
    }
  }

  /**
   * 根据版本获取快照
   */
  async getSnapshotByVersion(version: number): Promise<Snapshot> {
    const snapshot = await this.snapshotRepository.findOneBy({ version })
    
    if (!snapshot) {
      throw new Error(`快照 ${version} 不存在`)
    }
    
    return {
      version: snapshot.version,
      sessions: snapshot.data,
      timestamp: snapshot.create_time.getTime()
    }
  }

  /**
   * 清理过期快照
   */
  async cleanupOldSnapshots(keepCount: number = 10): Promise<void> {
    const snapshots = await this.snapshotRepository.find({
      order: { version: 'DESC' },
      skip: keepCount
    })
    
    for (const snapshot of snapshots) {
      // 删除快照
      await this.snapshotRepository.delete({ version: snapshot.version })
      
      // 删除相关的差异记录
      await this.diffRepository.delete([
        { from_version: snapshot.version },
        { to_version: snapshot.version }
      ])
    }
    
    console.log(`[快照服务] 清理了 ${snapshots.length} 个过期快照`)
  }
}

/**
 * 快照控制器
 */
@Controller('sessions/snapshot')
export class SessionSnapshotController {
  constructor(
    private readonly snapshotService: SessionSnapshotService
  ) {}

  /**
   * 获取最新版本号
   */
  @Get('latest-version')
  async getLatestVersion() {
    const version = await this.snapshotService.getLatestVersion()
    return { version }
  }

  /**
   * 获取初始快照
   */
  @Get('initial')
  async getInitialSnapshot() {
    const snapshot = await this.snapshotService.getInitialSnapshot()
    return { snapshot }
  }

  /**
   * 获取快照差异
   */
  @Post('diff')
  async getDiff(
    @Body('fromVersion') fromVersion: number,
    @Body('toVersion') toVersion: number
  ) {
    const diff = await this.snapshotService.getDiff(fromVersion, toVersion)
    return { diff }
  }
}

2.4 方案四:操作日志 + 版本号同步

2.4.1 设计思路

本方案基于操作日志和版本号的同步机制,通过记录所有会话操作的日志,客户端根据版本号拉取对应的操作:

  1. 操作日志:服务端记录所有会话操作的日志,包括创建、更新、删除、设置变更等
  2. 版本号机制:每个操作分配一个递增的全局版本号,客户端维护自己的当前版本号
  3. 顺序执行:客户端按照版本号顺序执行操作,保证数据一致性
  4. 断点续传:基于版本号实现断点续传,支持中断后继续同步

这种设计对数据一致性要求极高,适合企业级IM系统。

2.4.2 流程图

服务端数据库 操作日志管理器(服务端) 服务端(业务逻辑) 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 服务端数据库 操作日志管理器(服务端) 服务端(业务逻辑) 客户端(数据库) 客户端(主进程) 客户端(渲染进程) 初始化阶段(客户端) 定期同步(客户端) loop [每30秒] 会话操作(其他客户端触发) 用户创建会话 HTTP GET /operations/sync?fromVersion=0 查询用户当前版本号 查询用户版本号 返回当前版本号 查询操作日志(version > 0) 返回操作列表 返回操作列表和最新版本号 返回操作列表和最新版本号 IPC调用执行操作 按顺序执行操作 IPC调用更新本地版本号 更新本地版本号 HTTP GET /operations/sync?fromVersion=100 查询操作日志(version > 100) 查询操作日志 返回增量操作列表 返回增量操作和最新版本号 返回增量操作和最新版本号 IPC调用执行操作 按顺序执行操作 刷新UI IPC调用更新本地版本号 更新本地版本号 创建会话 记录操作日志(version=101) 保存操作日志 更新用户版本号

2.4.3 流程描述

【客户端流程】

  1. 初始化阶段

    • 客户端启动时,从本地存储读取当前版本号
    • 如果是首次启动(版本号=0),发送请求拉取所有操作日志
    • 接收到操作日志后,按照版本号顺序执行每个操作
    • 更新本地存储的版本号为服务器返回的最新版本号
  2. 增量同步阶段

    • 客户端定期(如每30秒)发送同步请求,携带当前版本号
    • 服务端返回该版本号之后的所有操作日志
    • 客户端按照版本号顺序执行每个操作
    • 更新本地版本号为新的版本号
  3. 操作执行

    • 根据操作类型执行对应的本地操作
    • CREATE_SESSION:创建会话到本地数据库
    • UPDATE_SESSION:更新本地会话数据
    • DELETE_SESSION:删除本地会话
    • UPDATE_SESSION_SETTINGS:更新会话设置
    • UPDATE_UNREAD_COUNT:更新未读数
  4. 错误处理

    • 如果某个操作执行失败,记录失败的版本号
    • 下次同步时,从失败的版本号开始重新拉取
    • 保证所有操作最终都能正确执行

【服务端流程】

  1. 操作日志管理器(服务端组件)

    • 记录操作日志:接收用户操作请求(创建、更新、删除会话等),执行数据库操作更新会话数据,递增全局版本号生成新的版本号,记录操作日志包含版本号、用户ID、操作类型、操作数据,更新用户的当前版本号
    • 处理同步请求 :接收客户端的同步请求包含fromVersionlimit,查询该用户的操作日志版本号大于fromVersion的记录,按照版本号升序返回操作日志,同时返回最新的版本号
    • 用户版本号管理 :维护每个用户的当前版本号,首次同步时返回当前用户的版本号,后续同步时根据fromVersion返回增量操作
    • 定时清理:定期清理过期的操作日志(如30天前的日志),定期清理不活跃用户的版本号记录
  2. 业务逻辑层(服务端组件)

    • 接收客户端的同步请求
    • 调用操作日志管理器获取操作日志
    • 返回操作日志给客户端
2.4.4 优缺点

优点:

  • 数据一致性强:版本号机制保证数据严格一致性
  • 可靠性高:顺序执行保证不丢失,断点续传保证不重复
  • 可追溯:完整的操作日志便于审计和调试
  • 支持离线:客户端可以在离线状态下缓存操作,上线后同步
  • 冲突处理:版本号机制天然支持冲突解决
  • 扩展性好:可以方便地扩展到其他数据类型的同步

缺点:

  • 实现复杂:需要维护操作日志和版本号系统
  • 服务器负载高:需要存储和查询大量操作日志
  • 性能影响:日志记录和查询可能影响性能
  • 存储开销大:操作日志会占用大量存储空间
  • 清理复杂:需要合理规划日志清理策略
  • 延迟较高:相比WebSocket推送,实时性略差
2.4.5 业界代表
  • Slack:采用操作日志机制,保证多端数据一致性
  • Microsoft Teams:使用版本号同步,支持离线操作
  • Notion:基于操作日志实现实时协作
  • Confluence:使用操作日志记录所有变更
  • Google Docs:采用操作日志+版本号实现协作编辑
2.4.6 代码示例

【前端代码】操作日志同步服务实现

typescript 复制代码
/**
 * 会话操作日志同步服务
 */
class SessionOperationSyncService {
  private currentVersion: number = 0

  /**
   * 初始化同步服务
   */
  async initialize() {
    // 1. 获取当前版本号
    this.currentVersion = await this.getLocalVersion()
    
    // 2. 拉取操作日志
    await this.syncOperations()
    
    // 3. 开启定期同步
    this.startPeriodicSync()
  }

  /**
   * 同步操作日志
   */
  private async syncOperations() {
    try {
      const response = await httpClient.request({
        url: '/api/sessions/operations/sync',
        method: 'post',
        data: {
          fromVersion: this.currentVersion,
          limit: 100
        }
      })

      // 按顺序执行操作日志
      for (const operation of response.data.operations) {
        await this.applyOperation(operation)
      }

      // 更新本地版本号
      this.currentVersion = response.data.toVersion
      await this.saveLocalVersion(this.currentVersion)

      console.log(`[操作日志同步] 成功同步 ${response.data.operations.length} 个操作`)
    } catch (error) {
      console.error('[操作日志同步] 同步失败:', error)
      throw error
    }
  }

  /**
   * 应用操作日志
   */
  private async applyOperation(operation: SessionOperation) {
    try {
      switch (operation.type) {
        case 'CREATE_SESSION':
          await this.createSession(operation.data)
          break
        case 'UPDATE_SESSION':
          await this.updateSession(operation.data)
          break
        case 'DELETE_SESSION':
          await this.deleteSession(operation.data.sessionId)
          break
        case 'UPDATE_SESSION_SETTINGS':
          await this.updateSessionSettings(operation.data)
          break
        case 'UPDATE_UNREAD_COUNT':
          await this.updateUnreadCount(operation.data)
          break
        default:
          console.warn('[操作日志] 未知操作类型:', operation.type)
      }
    } catch (error) {
      console.error(`[操作日志] 执行操作失败 (version=${operation.version}):`, error)
      throw error
    }
  }

  /**
   * 创建会话
   */
  private async createSession(data: any) {
    // 存储到本地数据库
    await this.db.sessions.add(data)
    
    // 触发UI更新
    this.emit('session:created', data)
    
    console.log(`[操作日志] 创建会话: ${data.session_id}`)
  }

  /**
   * 更新会话
   */
  private async updateSession(data: any) {
    // 更新本地数据库
    await this.db.sessions.put(data)
    
    // 触发UI更新
    this.emit('session:updated', data)
    
    console.log(`[操作日志] 更新会话: ${data.session_id}`)
  }

  /**
   * 删除会话
   */
  private async deleteSession(sessionId: string) {
    // 从本地数据库删除
    await this.db.sessions.delete(sessionId)
    
    // 触发UI更新
    this.emit('session:deleted', { sessionId })
    
    console.log(`[操作日志] 删除会话: ${sessionId}`)
  }

  /**
   * 更新会话设置
   */
  private async updateSessionSettings(data: any) {
    // 更新本地会话设置
    await this.db.session_settings.put(data)
    
    // 触发UI更新
    this.emit('session:settings_changed', data)
    
    console.log(`[操作日志] 更新会话设置: ${data.session_id}`)
  }

  /**
   * 更新未读数
   */
  private async updateUnreadCount(data: any) {
    // 更新本地未读数
    await this.db.session_unreads.put(data)
    
    // 触发UI更新
    this.emit('session:unread_changed', data)
    
    console.log(`[操作日志] 更新未读数: ${data.session_id}`)
  }

  /**
   * 开启定期同步
   */
  private startPeriodicSync() {
    setInterval(() => {
      this.syncOperations().catch(error => {
        console.error('[操作日志同步] 定期同步失败:', error)
      })
    }, 30000) // 每30秒同步一次
  }

  /**
   * 加载本地版本号
   */
  private async getLocalVersion(): Promise<number> {
    const version = localStorage.getItem('operation_version')
    return version ? parseInt(version) : 0
  }

  /**
   * 保存本地版本号
   */
  private async saveLocalVersion(version: number) {
    localStorage.setItem('operation_version', version.toString())
  }

  /**
   * 触发事件
   */
  private emit(event: string, data: any) {
    window.dispatchEvent(new CustomEvent(event, { detail: data }))
  }
}

// 导出单例
export const sessionOperationSyncService = new SessionOperationSyncService()

【后端代码】操作日志管理服务实现

typescript 复制代码
/**
 * 会话操作日志实体
 */
@Entity({ name: 'session_operation_log' })
export class SessionOperationLogEntity {
  @PrimaryColumn({ type: 'bigint' })
  version: number // 操作版本号(自增)

  @Column({ type: 'varchar', length: 64 })
  user_id: string // 用户ID

  @Column({ type: 'varchar', length: 32 })
  operation_type: string // 操作类型

  @Column({ type: 'json' })
  operation_data: any // 操作数据

  @Column({ type: 'datetime' })
  create_time: Date // 操作时间
}

/**
 * 用户版本号实体
 */
@Entity({ name: 'session_user_version' })
export class SessionUserVersionEntity {
  @PrimaryColumn({ type: 'varchar', length: 64 })
  user_id: string // 用户ID

  @Column({ type: 'bigint' })
  version: number // 当前版本号

  @Column({ type: 'datetime' })
  update_time: Date // 更新时间
}

/**
 * 会话操作日志服务
 */
@Injectable()
export class SessionOperationLogService {
  private globalVersion: number = 0

  constructor(
    @InjectRepository(SessionOperationLogEntity)
    private readonly logRepository: Repository<SessionOperationLogEntity>,
    @InjectRepository(SessionUserVersionEntity)
    private readonly userVersionRepository: Repository<SessionUserVersionEntity>
  ) {
    this.initGlobalVersion()
  }

  /**
   * 初始化全局版本号
   */
  private async initGlobalVersion() {
    const latestLog = await this.logRepository.findOne({
      order: { version: 'DESC' }
    })
    this.globalVersion = latestLog?.version || 0
  }

  /**
   * 记录操作日志
   */
  async logOperation(
    userId: string,
    operationType: string,
    operationData: any
  ): Promise<number> {
    // 递增全局版本号
    this.globalVersion++

    // 保存操作日志
    const log = this.logRepository.create({
      version: this.globalVersion,
      user_id: userId,
      operation_type: operationType,
      operation_data: operationData,
      create_time: new Date()
    })

    await this.logRepository.save(log)

    // 更新用户版本号
    await this.updateUserVersion(userId, this.globalVersion)

    console.log(`[操作日志] 记录操作: version=${this.globalVersion}, type=${operationType}, userId=${userId}`)

    return this.globalVersion
  }

  /**
   * 获取用户操作日志
   */
  async getUserOperations(
    userId: string,
    fromVersion: number,
    limit: number
  ): Promise<{ operations: SessionOperationLog[], toVersion: number }> {
    const operations = await this.logRepository.find({
      where: {
        user_id: userId,
        version: MoreThan(fromVersion)
      },
      order: { version: 'ASC' },
      take: limit
    })

    const toVersion = operations.length > 0 
      ? operations[operations.length - 1].version 
      : fromVersion

    return { operations, toVersion }
  }

  /**
   * 获取用户当前版本号
   */
  async getUserVersion(userId: string): Promise<number> {
    const userVersion = await this.userVersionRepository.findOneBy({
      user_id: userId
    })

    return userVersion?.version || 0
  }

  /**
   * 更新用户版本号
   */
  private async updateUserVersion(userId: string, version: number) {
    const userVersion = await this.userVersionRepository.findOneBy({
      user_id: userId
    })

    if (userVersion) {
      await this.userVersionRepository.update(
        { user_id: userId },
        { 
          version,
          update_time: new Date()
        }
      )
    } else {
      await this.userVersionRepository.save({
        user_id: userId,
        version,
        update_time: new Date()
      })
    }
  }

  /**
   * 清理过期日志
   */
  async cleanExpiredLogs(days: number = 30) {
    const expireDate = new Date()
    expireDate.setDate(expireDate.getDate() - days)

    await this.logRepository.delete({
      create_time: LessThan(expireDate)
    })

    console.log(`[操作日志] 清理了 ${days} 天前的过期日志`)
  }
}

/**
 * 会话操作日志控制器
 */
@Controller('sessions/operations')
export class SessionOperationController {
  constructor(
    private readonly operationLogService: SessionOperationLogService
  ) {}

  /**
   * 同步操作日志
   */
  @Post('sync')
  async syncOperations(
    @Body('userId') userId: string,
    @Body('fromVersion') fromVersion: number = 0,
    @Body('limit') limit: number = 100
  ) {
    const { operations, toVersion } = await this.operationLogService.getUserOperations(
      userId,
      fromVersion,
      limit
    )

    return {
      operations,
      toVersion
    }
  }

  /**
   * 获取当前版本号
   */
  @Get('version')
  async getCurrentVersion(@Query('userId') userId: string) {
    const version = await this.operationLogService.getUserVersion(userId)
    return { version }
  }
}

三、方案对比总结

3.1 对比表格

维度 方案一:增量拉取+WebSocket推送 方案二:全量拉取+增量推送 方案三:增量快照+差异同步 方案四:操作日志+版本号同步
实时性 ⭐⭐⭐⭐⭐ (WebSocket推送) ⭐⭐⭐⭐ (WebSocket推送) ⭐⭐ (定期快照) ⭐⭐⭐ (定期拉取)
可靠性 ⭐⭐⭐⭐ (兜底机制) ⭐⭐⭐⭐ (全量保证) ⭐⭐⭐⭐⭐ (快照机制) ⭐⭐⭐⭐⭐ (顺序执行)
数据一致性 ⭐⭐⭐⭐ (增量同步) ⭐⭐⭐⭐ (简单一致) ⭐⭐⭐⭐⭐ (差异对比) ⭐⭐⭐⭐⭐ (版本号保证)
网络依赖 ⭐⭐⭐⭐ (定期同步) ⭐⭐⭐⭐⭐ (高依赖) ⭐⭐⭐ (中等依赖) ⭐⭐⭐⭐ (依赖网络)
实现复杂度 ⭐⭐⭐ (中等) ⭐⭐ (低) ⭐⭐⭐⭐⭐ (高) ⭐⭐⭐⭐ (较高)
服务器负载 ⭐⭐⭐ (中等) ⭐⭐⭐⭐ (高) ⭐⭐⭐⭐⭐ (高) ⭐⭐⭐⭐ (较高)
存储开销 ⭐⭐⭐⭐ (低) ⭐⭐⭐⭐⭐ (低) ⭐ (大) ⭐⭐⭐ (中等)
离线支持 ⭐⭐ (有限) ⭐ (有限) ⭐⭐⭐ (中等) ⭐⭐ (有限)
冲突处理 ⭐⭐⭐ (最后写入胜) ⭐⭐⭐ (简单) ⭐⭐⭐ (中等) ⭐⭐⭐⭐ (版本号)
适用场景 通用型IM系统 小型IM系统 大规模IM系统 企业级IM系统

3.2 选择建议

如果您的项目需要:

  • 通用型IM系统 ,追求平衡的性能和可靠性 → 选择方案一
  • 企业级IM系统 ,对数据一致性要求极高 → 选择方案四
  • 大规模IM系统 ,数据量大但变化缓慢 → 选择方案三
  • 小型IM系统 ,追求快速实现和简单维护 → 选择方案二

四、最佳实践方案

4.1 选择理由

基于通用IM应用的特点和需求,我们选择方案一:增量拉取 + WebSocket推送作为最佳实践方案:

  1. 技术成熟度:这是业界最主流、最成熟的方案
  2. 实现复杂度:在保证功能完整的前提下,实现复杂度可控
  3. 适合Electron环境:Electron应用运行在桌面端,网络相对稳定
  4. 扩展性好:后续可以方便地扩展其他功能
  5. 维护成本低:代码结构清晰,易于维护和调试

4.2 关键技术点

  1. 增量同步:使用时间戳实现增量同步,减少数据传输
  2. 实时推送:使用WebSocket推送关键事件,保证实时性
  3. 兜底机制:定期同步作为兜底,确保数据最终一致性
  4. 错误处理:完善的错误处理机制,提高系统稳定性
  5. 性能优化:数据库索引、网络优化、前端优化等多方面优化
  6. 安全性:WebSocket认证、限流、加密等安全措施

4.3 未来展望

随着IM系统的发展,会话同步机制也在不断演进,未来的发展方向包括:

  1. 边缘计算:利用边缘计算降低延迟
  2. P2P同步:实现去中心化的P2P同步
  3. AI优化:利用AI优化同步策略
  4. 跨云同步:实现多云环境的同步
  5. 实时协作:支持实时协作场景

五、参考资源

免责声明

  1. 技术文档性质:本文档为技术方案设计文档,内容基于通用技术实践和业界最佳实践编写
  2. 内容声明:文档中的技术方案、架构设计、代码示例等内容均为通用技术实现,不涉及任何特定公司或项目的商业机密、专利技术或内部架构
  3. 参考性质:本文档仅供技术参考和学习使用,不构成任何商业建议或技术实施承诺
  4. 使用风险:读者应根据自身项目的具体需求对本文档内容进行调整和优化,作者不对因使用本文档内容而造成的任何直接或间接损失承担责任
  5. 第三方引用:本文档引用的第三方技术文章、开源项目、API文档等均为公开资料,引用时已注明出处

版权声明

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

相关推荐
VT.馒头10 分钟前
【力扣】2695. 包装数组
前端·javascript·算法·leetcode·职场和发展·typescript
css趣多多22 分钟前
一个UI内置组件el-scrollbar
前端·javascript·vue.js
C澒42 分钟前
前端整洁架构(Clean Architecture)实战解析:从理论到 Todo 项目落地
前端·架构·系统架构·前端框架
C澒1 小时前
Remesh 框架详解:基于 CQRS 的前端领域驱动设计方案
前端·架构·前端框架·状态模式
Charlie_lll1 小时前
学习Three.js–雪花
前端·three.js
onebyte8bits1 小时前
前端国际化(i18n)体系设计与工程化落地
前端·国际化·i18n·工程化
C澒1 小时前
前端分层架构实战:DDD 与 Clean Architecture 在大型业务系统中的落地路径与项目实践
前端·架构·系统架构·前端框架
BestSongC1 小时前
行人摔倒检测系统 - 前端文档(1)
前端·人工智能·目标检测
0思必得02 小时前
[Web自动化] Selenium处理滚动条
前端·爬虫·python·selenium·自动化
Misnice2 小时前
Webpack、Vite、Rsbuild区别
前端·webpack·node.js