chat-widget

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Live Support Chat Widget

实时客服聊天组件

Build a real-time support chat system with a floating widget for users and an admin dashboard for support staff.
构建一个面向用户的悬浮聊天组件+面向客服人员的管理后台的实时客服聊天系统。

When to Use This Skill

适用场景

Use when the user wants to:
  • Add a live chat widget to their app
  • Build customer support chat functionality
  • Create real-time messaging between users and admins
  • Add an in-app support channel
适用于以下需求场景:
  • 为应用添加在线聊天组件
  • 构建客户支持聊天功能
  • 实现用户与管理员之间的实时消息通信
  • 添加应用内支持渠道

Architecture Overview

架构概览

┌─────────────────────────────────────────────────────────────────┐
│                        FRONTEND                                 │
├─────────────────────────────┬───────────────────────────────────┤
│   User Widget               │   Admin Dashboard                 │
│   - Floating chat button    │   - Chat list (active/archived)   │
│   - Message panel           │   - Conversation view             │
│   - Unread badge            │   - Archive/restore controls      │
│   - Connection indicator    │   - User info display             │
└─────────────┬───────────────┴───────────────┬───────────────────┘
              │                               │
              │     WebSocket + REST API      │
              ▼                               ▼
┌─────────────────────────────────────────────────────────────────┐
│                        BACKEND                                  │
├─────────────────────────────────────────────────────────────────┤
│   Channels                  │   Controllers                     │
│   - ChatChannel (per chat)  │   - User: get/create chat         │
│   - AdminChannel (global)   │   - Admin: list, view, archive    │
├─────────────────────────────┼───────────────────────────────────┤
│   Models                    │   Jobs                            │
│   - Chat (1 per user)       │   - Email notification (delayed)  │
│   - Message (many per chat) │                                   │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                        FRONTEND                                 │
├─────────────────────────────┬───────────────────────────────────┤
│   用户端悬浮组件               │   管理员后台                     │
│   - 悬浮聊天按钮             │   - 聊天列表(活跃/已归档)       │
│   - 消息面板                 │   - 会话详情视图                 │
│   - 未读消息徽章             │   - 归档/恢复控制按钮             │
│   - 连接状态指示器           │   - 用户信息展示                 │
└─────────────┬───────────────┴───────────────┬───────────────────┘
              │                               │
              │     WebSocket + REST API      │
              ▼                               ▼
┌─────────────────────────────────────────────────────────────────┐
│                        BACKEND                                  │
├─────────────────────────────────────────────────────────────────┤
│   通道模块                  │   控制器模块                     │
│   - ChatChannel(单会话专属)│   - 用户端:获取/创建聊天会话     │
│   - AdminChannel(全局)    │   - 管理端:列表、查看、归档会话   │
├─────────────────────────────┼───────────────────────────────────┤
│   数据模型                  │   后台任务                       │
│   - Chat(每个用户一个会话)│   - 延迟发送邮件通知               │
│   - Message(每个会话多条消息)│                                 │
└─────────────────────────────────────────────────────────────────┘

Implementation Guide

实现指南

Step 1: Data Models

步骤1:数据模型

Create two tables:
support_chats
and
support_messages
.
support_chats
id              - primary key (UUID recommended)
user_id         - foreign key to users (UNIQUE - one chat per user)
last_message_at - timestamp (for sorting chats by recency)
admin_viewed_at - timestamp (tracks when admin last viewed)
archived_at     - timestamp (null = active, set = archived)
created_at
updated_at
support_messages
id              - primary key (UUID recommended)
chat_id         - foreign key to support_chats
content         - text (required)
sender_type     - enum: 'user' | 'admin'
read_at         - timestamp (null = unread)
created_at
updated_at
Key indexes:
  • support_chats.user_id
    (unique)
  • support_chats.last_message_at
    (for sorting)
  • support_chats.archived_at
    (for filtering)
  • support_messages.chat_id
  • support_messages.(chat_id, created_at)
    (composite, for ordering)
Model relationships:
User has_one SupportChat
SupportChat belongs_to User
SupportChat has_many SupportMessages
SupportMessage belongs_to SupportChat
Model methods to implement:
Chat model:
pseudo
function touch_last_message()
  update last_message_at = now()

function unread_for_admin?()
  return exists message where sender_type = 'user'
    and created_at > admin_viewed_at

function mark_viewed_by_admin()
  update admin_viewed_at = now()

function archive()
  update archived_at = now()

function unarchive()
  update archived_at = null

function archived?()
  return archived_at != null
Message model:
pseudo
after_create:
  chat.touch_last_message()
  if sender_type == 'user' and chat.archived?:
    chat.unarchive()  // Auto-reactivate on new user message

after_create_commit:
  broadcast_to_chat_channel(message_data)
  if sender_type == 'user':
    broadcast_to_admin_notification_channel(message_data, chat_info)
  if sender_type == 'admin':
    schedule_email_notification(delay: 5.minutes)
创建两张表:
support_chats
support_messages
support_chats
id              - 主键(推荐使用UUID)
user_id         - 关联用户表的外键(唯一约束:每个用户对应一个聊天会话)
last_message_at - 时间戳(用于按最新消息排序会话)
admin_viewed_at - 时间戳(记录管理员最后查看时间)
archived_at     - 时间戳(null表示活跃,有值表示已归档)
created_at
updated_at
support_messages
id              - 主键(推荐使用UUID)
chat_id         - 关联support_chats的外键
content         - 文本(必填)
sender_type     - 枚举值:'user' | 'admin'
read_at         - 时间戳(null表示未读)
created_at
updated_at
关键索引:
  • support_chats.user_id
    (唯一索引)
  • support_chats.last_message_at
    (用于排序)
  • support_chats.archived_at
    (用于过滤)
  • support_messages.chat_id
  • support_messages.(chat_id, created_at)
    (复合索引,用于消息排序)
模型关联关系:
User 一对一关联 SupportChat
SupportChat 属于 User
SupportChat 一对多关联 SupportMessages
SupportMessage 属于 SupportChat
需要实现的模型方法:
Chat模型:
pseudo
function touch_last_message()
  update last_message_at = now()

function unread_for_admin?()
  return exists message where sender_type = 'user'
    and created_at > admin_viewed_at

function mark_viewed_by_admin()
  update admin_viewed_at = now()

function archive()
  update archived_at = now()

function unarchive()
  update archived_at = null

function archived?()
  return archived_at != null
Message模型:
pseudo
after_create:
  chat.touch_last_message()
  if sender_type == 'user' and chat.archived?:
    chat.unarchive()  // 用户发送新消息时自动重新激活归档会话

after_create_commit:
  broadcast_to_chat_channel(message_data)
  if sender_type == 'user':
    broadcast_to_admin_notification_channel(message_data, chat_info)
  if sender_type == 'admin':
    schedule_email_notification(delay: 5.minutes)

Step 2: API Endpoints

步骤2:API接口

User-facing:
GET  /support_chat       - Get or create user's chat with messages
PATCH /support_chat/mark_read - Mark admin messages as read
Admin-facing:
GET  /admin/chats              - List chats (query: archived=true/false)
GET  /admin/chats/:id          - Get chat with messages
POST /admin/chats/:id/archive  - Archive chat
POST /admin/chats/:id/unarchive - Restore chat
Controller logic:
User GET /support_chat:
pseudo
function show()
  chat = current_user.support_chat || create_chat(user: current_user)
  return {
    id: chat.id,
    messages: chat.messages.map(m => serialize_message(m))
  }
Admin GET /admin/chats:
pseudo
function index()
  chats = SupportChat
    .where(archived_at: params.archived ? not_null : null)
    .includes(:user, :messages)
    .order(last_message_at: desc)

  return chats.map(c => {
    id: c.id,
    user_email: c.user.email,
    last_message_preview: c.messages.last?.content.truncate(100),
    last_message_sender: c.messages.last?.sender_type,
    message_count: c.messages.count,
    unread: c.unread_for_admin?,
    archived: c.archived?
  })
用户端接口:
GET  /support_chat       - 获取或创建用户的聊天会话及消息
PATCH /support_chat/mark_read - 标记管理员消息为已读
管理端接口:
GET  /admin/chats              - 列出聊天会话(支持查询参数:archived=true/false)
GET  /admin/chats/:id          - 获取指定会话及消息
POST /admin/chats/:id/archive  - 归档会话
POST /admin/chats/:id/unarchive - 恢复会话
控制器逻辑:
用户端GET /support_chat:
pseudo
function show()
  chat = current_user.support_chat || create_chat(user: current_user)
  return {
    id: chat.id,
    messages: chat.messages.map(m => serialize_message(m))
  }
管理端GET /admin/chats:
pseudo
function index()
  chats = SupportChat
    .where(archived_at: params.archived ? not_null : null)
    .includes(:user, :messages)
    .order(last_message_at: desc)

  return chats.map(c => {
    id: c.id,
    user_email: c.user.email,
    last_message_preview: c.messages.last?.content.truncate(100),
    last_message_sender: c.messages.last?.sender_type,
    message_count: c.messages.count,
    unread: c.unread_for_admin?,
    archived: c.archived?
  })

Step 3: WebSocket Channels

步骤3:WebSocket通道

Create two channels for real-time communication.
ChatChannel (specific to each chat):
pseudo
class ChatChannel
  on_subscribe(chat_id):
    chat = find_chat(chat_id)
    if not authorized(chat):
      reject()
      return
    stream_from "support_chat:#{chat_id}"

  function authorized(chat):
    return chat.user_id == current_user.id OR current_user.is_admin

  action send_message(content):
    if content.blank: return
    sender_type = current_user.is_admin ? 'admin' : 'user'
    chat.messages.create(content: content, sender_type: sender_type)
AdminNotificationChannel (global for all admins):
pseudo
class AdminNotificationChannel
  on_subscribe:
    if not current_user.is_admin:
      reject()
      return
    stream_from "admin_support_notifications"
Broadcasting (from Message model):
pseudo
function broadcast_message():
  message_data = {
    id: id,
    content: content,
    sender_type: sender_type,
    read_at: read_at,
    created_at: created_at
  }

  // Broadcast to chat subscribers (user + any viewing admins)
  broadcast("support_chat:#{chat.id}", {
    type: "new_message",
    message: message_data
  })

  // Notify all admins when user sends message
  if sender_type == 'user':
    broadcast("admin_support_notifications", {
      type: "new_user_message",
      chat_id: chat.id,
      user_email: chat.user.email,
      message: message_data
    })
创建两个用于实时通信的通道。
ChatChannel(单会话专属):
pseudo
class ChatChannel
  on_subscribe(chat_id):
    chat = find_chat(chat_id)
    if not authorized(chat):
      reject()
      return
    stream_from "support_chat:#{chat_id}"

  function authorized(chat):
    return chat.user_id == current_user.id OR current_user.is_admin

  action send_message(content):
    if content.blank: return
    sender_type = current_user.is_admin ? 'admin' : 'user'
    chat.messages.create(content: content, sender_type: sender_type)
AdminNotificationChannel(全局管理员通道):
pseudo
class AdminNotificationChannel
  on_subscribe:
    if not current_user.is_admin:
      reject()
      return
    stream_from "admin_support_notifications"
消息广播(来自Message模型):
pseudo
function broadcast_message():
  message_data = {
    id: id,
    content: content,
    sender_type: sender_type,
    read_at: read_at,
    created_at: created_at
  }

  // 广播给会话订阅者(用户+查看该会话的管理员)
  broadcast("support_chat:#{chat.id}", {
    type: "new_message",
    message: message_data
  })

  // 用户发送消息时通知所有管理员
  if sender_type == 'user':
    broadcast("admin_support_notifications", {
      type: "new_user_message",
      chat_id: chat.id,
      user_email: chat.user.email,
      message: message_data
    })

Step 4: Frontend - User Widget

步骤4:前端 - 用户端悬浮组件

Create a floating chat widget with these components:
Component structure:
ChatWidget (root container)
├── ChatButton (fixed position, bottom-right)
│   ├── Icon (message bubble when closed, X when open)
│   └── UnreadBadge (shows count, caps at "9+")
└── ChatPanel (slides up when open)
    ├── Header (title + connection status dot)
    ├── MessageList (scrollable)
    │   └── MessageBubble (styled by sender_type)
    └── InputArea
        ├── Textarea (auto-expanding)
        └── SendButton
State management hook:
pseudo
function useSupportChat():
  state:
    chat: Chat | null
    connected: boolean
    loading: boolean

  refs:
    consumer: WebSocketConsumer
    subscription: ChannelSubscription
    seenMessageIds: Set<string>  // For deduplication

  on_mount:
    fetch('/support_chat')
      .then(data => {
        chat = data
        seenMessageIds.addAll(data.messages.map(m => m.id))
      })

  when chat.id changes:
    subscription = consumer.subscribe('ChatChannel', { chat_id: chat.id })
    subscription.on_received(data => {
      if data.type == 'new_message':
        if seenMessageIds.has(data.message.id): return  // Dedupe
        seenMessageIds.add(data.message.id)
        chat.messages.push(data.message)
        if data.message.sender_type == 'admin':
          play_notification_sound()
    })
    subscription.on_connected(() => connected = true)
    subscription.on_disconnected(() => connected = false)

  on_unmount:
    subscription.unsubscribe()

  function sendMessage(content):
    subscription.perform('send_message', { content: content.trim() })

  function markAsRead():
    fetch('/support_chat/mark_read', { method: 'PATCH' })
    // Update local state to mark admin messages as read

  return { chat, connected, loading, sendMessage, markAsRead }
Widget behavior:
  • Show floating button at bottom-right corner (fixed position)
  • Display unread count badge (count messages where sender_type='admin' and read_at=null)
  • Toggle panel open/closed on button click
  • Auto-call markAsRead() when panel opens
  • Auto-scroll to bottom when new messages arrive
  • Show connection status indicator (green dot = connected)
  • Keyboard: Enter to send, Shift+Enter for newline
Message styling:
  • User messages: right-aligned, primary color background
  • Admin messages: left-aligned, secondary/muted background
  • Show timestamp on each message
创建一个悬浮聊天组件,包含以下子组件:
组件结构:
ChatWidget(根容器)
├── ChatButton(固定定位,右下角)
│   ├── Icon(关闭状态显示消息气泡,打开状态显示X)
│   └── UnreadBadge(显示未读数量,超过9时显示"9+")
└── ChatPanel(点击按钮后滑出)
    ├── Header(标题 + 连接状态圆点)
    ├── MessageList(可滚动)
    │   └── MessageBubble(根据sender_type区分样式)
    └── InputArea
        ├── Textarea(自动高度)
        └── SendButton
状态管理Hook:
pseudo
function useSupportChat():
  state:
    chat: Chat | null
    connected: boolean
    loading: boolean

  refs:
    consumer: WebSocketConsumer
    subscription: ChannelSubscription
    seenMessageIds: Set<string>  // 用于消息去重

  on_mount:
    fetch('/support_chat')
      .then(data => {
        chat = data
        seenMessageIds.addAll(data.messages.map(m => m.id))
      })

  when chat.id changes:
    subscription = consumer.subscribe('ChatChannel', { chat_id: chat.id })
    subscription.on_received(data => {
      if data.type == 'new_message':
        if seenMessageIds.has(data.message.id): return  // 去重
        seenMessageIds.add(data.message.id)
        chat.messages.push(data.message)
        if data.message.sender_type == 'admin':
          play_notification_sound()
    })
    subscription.on_connected(() => connected = true)
    subscription.on_disconnected(() => connected = false)

  on_unmount:
    subscription.unsubscribe()

  function sendMessage(content):
    subscription.perform('send_message', { content: content.trim() })

  function markAsRead():
    fetch('/support_chat/mark_read', { method: 'PATCH' })
    // 更新本地状态,标记管理员消息为已读

  return { chat, connected, loading, sendMessage, markAsRead }
组件行为:
  • 悬浮按钮固定显示在右下角
  • 显示未读消息数量徽章(统计sender_type='admin'且read_at=null的消息数)
  • 点击按钮切换面板的展开/收起状态
  • 面板展开时自动调用markAsRead()
  • 新消息到达时自动滚动到底部
  • 显示连接状态指示器(绿色圆点表示已连接)
  • 键盘快捷键:Enter发送消息,Shift+Enter换行
消息样式:
  • 用户消息:右对齐,主色调背景
  • 管理员消息:左对齐,次要/浅色调背景
  • 每条消息显示时间戳

Step 5: Frontend - Admin Dashboard

步骤5:前端 - 管理员后台

Create two pages: chat list and chat detail.
Chat List Page:
Header: "Support Chats"
Tabs: [Active] [Archived]

Chat cards (sorted by last_message_at desc):
┌─────────────────────────────────────────┐
│ [Unread indicator] user@example.com     │
│ Last message preview text...            │
│ 5 messages · 2 minutes ago              │
└─────────────────────────────────────────┘
Features:
  • Tab filtering (active vs archived)
  • Unread indicator (highlight border or badge)
  • Click to navigate to detail
  • Show "You: " prefix if last message was from admin
Chat Detail Page:
Header: user@example.com [Archive/Restore button]
Back link

Messages (grouped by date):
──── Monday, January 29 ────
[User bubble]  Message content
               10:30 AM

          [Admin bubble] Reply content
                         10:35 AM

Input area (same as widget)
Features:
  • Group messages by date with dividers
  • User messages left, admin messages right (opposite of user widget)
  • Show sender label ("You" for admin, user email/name for user)
  • Archive/restore toggle button
  • Same WebSocket subscription as user widget for real-time updates
  • Call mark_viewed_by_admin() when page loads (server-side)
创建两个页面:会话列表页和会话详情页。
会话列表页:
页头:"支持会话"
标签页:[活跃会话] [已归档会话]

会话卡片(按last_message_at倒序排列):
┌─────────────────────────────────────────┐
│ [未读指示器] user@example.com          │
│ 最后一条消息预览...                     │
│ 5条消息 · 2分钟前                       │
└─────────────────────────────────────────┘
功能特性:
  • 标签页过滤(活跃/已归档)
  • 未读指示器(高亮边框或徽章)
  • 点击卡片跳转到详情页
  • 最后一条消息为管理员发送时,显示"您:"前缀
会话详情页:
页头:user@example.com [归档/恢复按钮]
返回链接

消息列表(按日期分组):
──── 1月29日 星期一 ────
[用户消息气泡] 消息内容
               10:30 AM

          [管理员消息气泡] 回复内容
                         10:35 AM

输入区域(与用户端组件一致)
功能特性:
  • 消息按日期分组,显示日期分隔线
  • 用户消息左对齐,管理员消息右对齐(与用户端组件相反)
  • 显示发送者标签(管理员显示"您",用户显示邮箱/名称)
  • 归档/恢复切换按钮
  • 与用户端组件使用相同的WebSocket订阅,实现实时更新
  • 页面加载时调用mark_viewed_by_admin()(服务端处理)

Step 6: Email Notifications

步骤6:邮件通知

Send email to user when admin replies and user hasn't seen it.
Job/worker:
pseudo
class SupportReplyNotificationJob
  perform(message):
    if message.sender_type != 'admin': return
    if message.read_at != null: return  // Already read, skip

    send_email(
      to: message.chat.user.email,
      subject: "New reply from Support",
      body: "You have a new message from our support team..."
    )
Scheduling:
  • Schedule job with 5-minute delay when admin sends message
  • This gives user time to see message in-app before email
  • Job checks if still unread before sending
当管理员回复消息且用户未查看时,发送邮件通知用户。
任务/Worker:
pseudo
class SupportReplyNotificationJob
  perform(message):
    if message.sender_type != 'admin': return
    if message.read_at != null: return  // 已读,跳过发送

    send_email(
      to: message.chat.user.email,
      subject: "来自客服的新回复",
      body: "您收到了来自我们客服团队的新消息..."
    )
调度策略:
  • 管理员发送消息后延迟5分钟调度任务
  • 给用户留足在应用内查看消息的时间,避免垃圾邮件
  • 发送前检查消息是否仍未读

Step 7: TypeScript Types

步骤7:TypeScript类型定义

typescript
interface SupportMessage {
  id: string
  content: string
  sender_type: 'user' | 'admin'
  read_at: string | null  // ISO8601
  created_at: string      // ISO8601
}

interface SupportChat {
  id: string
  messages: SupportMessage[]
}

interface SupportChatListItem {
  id: string
  user_id: string
  user_email: string
  last_message_at: string | null
  last_message_preview: string | null
  last_message_sender: 'user' | 'admin' | null
  message_count: number
  unread: boolean
  archived: boolean
}

interface AdminSupportChat {
  id: string
  user_id: string
  user_email: string
  archived: boolean
  messages: SupportMessage[]
}

// WebSocket message types
interface ChatChannelMessage {
  type: 'new_message'
  message: SupportMessage
}

interface AdminNotificationMessage {
  type: 'new_user_message'
  chat_id: string
  user_email: string
  message: SupportMessage
}
typescript
interface SupportMessage {
  id: string
  content: string
  sender_type: 'user' | 'admin'
  read_at: string | null  // ISO8601格式
  created_at: string      // ISO8601格式
}

interface SupportChat {
  id: string
  messages: SupportMessage[]
}

interface SupportChatListItem {
  id: string
  user_id: string
  user_email: string
  last_message_at: string | null
  last_message_preview: string | null
  last_message_sender: 'user' | 'admin' | null
  message_count: number
  unread: boolean
  archived: boolean
}

interface AdminSupportChat {
  id: string
  user_id: string
  user_email: string
  archived: boolean
  messages: SupportMessage[]
}

// WebSocket消息类型
interface ChatChannelMessage {
  type: 'new_message'
  message: SupportMessage
}

interface AdminNotificationMessage {
  type: 'new_user_message'
  chat_id: string
  user_email: string
  message: SupportMessage
}

Key Design Decisions

核心设计决策

  1. One chat per user - Simplifies UX, user always has same conversation history
  2. Soft-delete via archiving - Preserves history, allows restore
  3. Auto-unarchive - When user sends message to archived chat, reactivate it
  4. Delayed email notifications - 5 min delay prevents spam for rapid replies
  5. Message deduplication - Track seen IDs to prevent duplicates from send + broadcast echo
  6. Separate admin channel - Allows future features like global unread count, desktop notifications
  1. 每个用户一个会话 - 简化用户体验,用户始终拥有相同的对话历史
  2. 通过归档实现软删除 - 保留历史记录,支持恢复
  3. 自动取消归档 - 用户向已归档会话发送消息时,自动重新激活会话
  4. 延迟邮件通知 - 5分钟延迟避免快速回复时发送垃圾邮件
  5. 消息去重 - 记录已查看的消息ID,防止发送+广播导致的重复消息
  6. 独立管理员通道 - 支持未来扩展全局未读计数、桌面通知等功能

Testing Checklist

测试检查清单

After implementation:
  • User can open widget and send message
  • Admin sees message in real-time on dashboard
  • Admin can reply and user sees it instantly
  • Unread badge shows correct count
  • Badge clears when widget opens
  • Connection indicator reflects actual status
  • Archive/restore works correctly
  • Auto-unarchive triggers on user message
  • Email sends after 5 min if message unread
  • Email does NOT send if user already read message
  • Messages appear in chronological order
  • No duplicate messages appear
实现完成后需验证:
  • 用户可打开组件并发送消息
  • 管理员在后台实时收到消息
  • 管理员回复后用户立即收到
  • 未读徽章显示正确数量
  • 面板打开时徽章清零
  • 连接状态指示器反映真实连接状态
  • 归档/恢复功能正常
  • 用户发送消息时自动取消归档
  • 消息未读时5分钟后发送邮件
  • 用户已读消息时不发送邮件
  • 消息按时间顺序显示
  • 无重复消息出现

Common Pitfalls

常见陷阱

  1. Forgetting deduplication - Messages sent by current user echo back via broadcast
  2. Race conditions on read status - Use database transactions
  3. WebSocket auth - Verify user can access the specific chat
  4. Stale connection status - Handle reconnection gracefully
  5. Missing indexes - Add composite index on (chat_id, created_at)
  6. Email timing - Use background job, not synchronous send

  1. 忘记消息去重 - 当前用户发送的消息会通过广播回显
  2. 已读状态竞争条件 - 使用数据库事务处理
  3. WebSocket权限验证 - 验证用户是否有权访问指定会话
  4. 连接状态过期 - 优雅处理重连逻辑
  5. 缺失索引 - 添加(chat_id, created_at)复合索引
  6. 邮件发送时机 - 使用后台任务,避免同步发送

Framework-Specific Guidance

框架专属实现指南

Ruby on Rails

Ruby on Rails

Models:
ruby
undefined
模型:
ruby
undefined

app/models/support_chat.rb

app/models/support_chat.rb

class SupportChat < ApplicationRecord belongs_to :user has_many :support_messages, dependent: :destroy
scope :active, -> { where(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) } scope :recent_first, -> { order(last_message_at: :desc) }
def touch_last_message update_column(:last_message_at, Time.current) end
def unread_for_admin? support_messages.where(sender_type: :user) .where("created_at > ?", admin_viewed_at || Time.at(0)).exists? end
def archive! update_column(:archived_at, Time.current) end
def unarchive! update_column(:archived_at, nil) end end
class SupportChat < ApplicationRecord belongs_to :user has_many :support_messages, dependent: :destroy
scope :active, -> { where(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) } scope :recent_first, -> { order(last_message_at: :desc) }
def touch_last_message update_column(:last_message_at, Time.current) end
def unread_for_admin? support_messages.where(sender_type: :user) .where("created_at > ?", admin_viewed_at || Time.at(0)).exists? end
def archive! update_column(:archived_at, Time.current) end
def unarchive! update_column(:archived_at, nil) end end

app/models/support_message.rb

app/models/support_message.rb

class SupportMessage < ApplicationRecord belongs_to :support_chat enum :sender_type, { user: 0, admin: 1 } validates :content, presence: true
after_create :update_chat_timestamp after_create :auto_unarchive, if: :user? after_create_commit :broadcast_message after_create_commit :schedule_notification, if: :admin?
private
def broadcast_message ActionCable.server.broadcast("support_chat:#{support_chat_id}", { type: "new_message", message: { id:, content:, sender_type:, read_at:, created_at: } }) end
def schedule_notification SupportReplyNotificationJob.set(wait: 5.minutes).perform_later(self) end end

**Channel:**
```ruby
class SupportMessage < ApplicationRecord belongs_to :support_chat enum :sender_type, { user: 0, admin: 1 } validates :content, presence: true
after_create :update_chat_timestamp after_create :auto_unarchive, if: :user? after_create_commit :broadcast_message after_create_commit :schedule_notification, if: :admin?
private
def broadcast_message ActionCable.server.broadcast("support_chat:#{support_chat_id}", { type: "new_message", message: { id:, content:, sender_type:, read_at:, created_at: } }) end
def schedule_notification SupportReplyNotificationJob.set(wait: 5.minutes).perform_later(self) end end

**通道:**
```ruby

app/channels/support_chat_channel.rb

app/channels/support_chat_channel.rb

class SupportChatChannel < ApplicationCable::Channel def subscribed @chat = SupportChat.find(params[:chat_id]) reject unless @chat.user_id == current_user.id || current_user.admin? stream_from "support_chat:#{@chat.id}" end
def send_message(data) @chat.support_messages.create!( content: data["content"], sender_type: current_user.admin? ? :admin : :user ) end end

**Migration:**
```ruby
create_table :support_chats, id: :uuid do |t|
  t.references :user, type: :uuid, null: false, foreign_key: true, index: { unique: true }
  t.datetime :last_message_at
  t.datetime :admin_viewed_at
  t.datetime :archived_at
  t.timestamps
end

create_table :support_messages, id: :uuid do |t|
  t.references :support_chat, type: :uuid, null: false, foreign_key: true
  t.text :content, null: false
  t.integer :sender_type, default: 0
  t.datetime :read_at
  t.timestamps
end
add_index :support_messages, [:support_chat_id, :created_at]
class SupportChatChannel < ApplicationCable::Channel def subscribed @chat = SupportChat.find(params[:chat_id]) reject unless @chat.user_id == current_user.id || current_user.admin? stream_from "support_chat:#{@chat.id}" end
def send_message(data) @chat.support_messages.create!( content: data["content"], sender_type: current_user.admin? ? :admin : :user ) end end

**迁移文件:**
```ruby
create_table :support_chats, id: :uuid do |t|
  t.references :user, type: :uuid, null: false, foreign_key: true, index: { unique: true }
  t.datetime :last_message_at
  t.datetime :admin_viewed_at
  t.datetime :archived_at
  t.timestamps
end

create_table :support_messages, id: :uuid do |t|
  t.references :support_chat, type: :uuid, null: false, foreign_key: true
  t.text :content, null: false
  t.integer :sender_type, default: 0
  t.datetime :read_at
  t.timestamps
end
add_index :support_messages, [:support_chat_id, :created_at]

React (with any backend)

React(适配任意后端)

Hook:
typescript
// hooks/useSupportChat.ts
import { useEffect, useState, useRef, useCallback } from 'react'

export function useSupportChat(websocketUrl: string) {
  const [chat, setChat] = useState<Chat | null>(null)
  const [connected, setConnected] = useState(false)
  const wsRef = useRef<WebSocket | null>(null)
  const seenIds = useRef(new Set<string>())

  useEffect(() => {
    fetch('/api/support_chat').then(r => r.json()).then(data => {
      setChat(data)
      data.messages.forEach((m: Message) => seenIds.current.add(m.id))
    })
  }, [])

  useEffect(() => {
    if (!chat?.id) return
    const ws = new WebSocket(`${websocketUrl}?chat_id=${chat.id}`)
    wsRef.current = ws

    ws.onopen = () => setConnected(true)
    ws.onclose = () => setConnected(false)
    ws.onmessage = (event) => {
      const data = JSON.parse(event.data)
      if (data.type === 'new_message' && !seenIds.current.has(data.message.id)) {
        seenIds.current.add(data.message.id)
        setChat(prev => prev ? { ...prev, messages: [...prev.messages, data.message] } : prev)
      }
    }
    return () => ws.close()
  }, [chat?.id])

  const sendMessage = useCallback((content: string) => {
    wsRef.current?.send(JSON.stringify({ action: 'send_message', content }))
  }, [])

  return { chat, connected, sendMessage }
}
Widget Component:
tsx
// components/ChatWidget.tsx
export function ChatWidget() {
  const [isOpen, setIsOpen] = useState(false)
  const { chat, connected, sendMessage } = useSupportChat('/ws/chat')
  const [input, setInput] = useState('')
  const messagesEndRef = useRef<HTMLDivElement>(null)

  const unreadCount = chat?.messages.filter(
    m => m.sender_type === 'admin' && !m.read_at
  ).length ?? 0

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [chat?.messages])

  const handleSend = () => {
    if (!input.trim()) return
    sendMessage(input.trim())
    setInput('')
  }

  return (
    <div className="fixed bottom-4 right-4 z-50">
      {isOpen ? (
        <div className="w-80 h-96 bg-white rounded-lg shadow-xl flex flex-col">
          <header className="p-3 border-b flex justify-between items-center">
            <span>Support Chat</span>
            <span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-gray-400'}`} />
          </header>
          <div className="flex-1 overflow-y-auto p-3 space-y-2">
            {chat?.messages.map(m => (
              <div key={m.id} className={`p-2 rounded ${m.sender_type === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'}`}>
                {m.content}
              </div>
            ))}
            <div ref={messagesEndRef} />
          </div>
          <div className="p-3 border-t flex gap-2">
            <input value={input} onChange={e => setInput(e.target.value)}
              onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
              className="flex-1 border rounded px-2" placeholder="Type a message..." />
            <button onClick={handleSend} className="px-3 py-1 bg-blue-500 text-white rounded">Send</button>
          </div>
        </div>
      ) : (
        <button onClick={() => setIsOpen(true)} className="w-14 h-14 bg-blue-500 rounded-full text-white relative">
          💬
          {unreadCount > 0 && (
            <span className="absolute -top-1 -right-1 bg-red-500 text-xs w-5 h-5 rounded-full flex items-center justify-center">
              {unreadCount > 9 ? '9+' : unreadCount}
            </span>
          )}
        </button>
      )}
    </div>
  )
}
Hook:
typescript
// hooks/useSupportChat.ts
import { useEffect, useState, useRef, useCallback } from 'react'

export function useSupportChat(websocketUrl: string) {
  const [chat, setChat] = useState<Chat | null>(null)
  const [connected, setConnected] = useState(false)
  const wsRef = useRef<WebSocket | null>(null)
  const seenIds = useRef(new Set<string>())

  useEffect(() => {
    fetch('/api/support_chat').then(r => r.json()).then(data => {
      setChat(data)
      data.messages.forEach((m: Message) => seenIds.current.add(m.id))
    })
  }, [])

  useEffect(() => {
    if (!chat?.id) return
    const ws = new WebSocket(`${websocketUrl}?chat_id=${chat.id}`)
    wsRef.current = ws

    ws.onopen = () => setConnected(true)
    ws.onclose = () => setConnected(false)
    ws.onmessage = (event) => {
      const data = JSON.parse(event.data)
      if (data.type === 'new_message' && !seenIds.current.has(data.message.id)) {
        seenIds.current.add(data.message.id)
        setChat(prev => prev ? { ...prev, messages: [...prev.messages, data.message] } : prev)
      }
    }
    return () => ws.close()
  }, [chat?.id])

  const sendMessage = useCallback((content: string) => {
    wsRef.current?.send(JSON.stringify({ action: 'send_message', content }))
  }, [])

  return { chat, connected, sendMessage }
}
组件:
tsx
// components/ChatWidget.tsx
export function ChatWidget() {
  const [isOpen, setIsOpen] = useState(false)
  const { chat, connected, sendMessage } = useSupportChat('/ws/chat')
  const [input, setInput] = useState('')
  const messagesEndRef = useRef<HTMLDivElement>(null)

  const unreadCount = chat?.messages.filter(
    m => m.sender_type === 'admin' && !m.read_at
  ).length ?? 0

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [chat?.messages])

  const handleSend = () => {
    if (!input.trim()) return
    sendMessage(input.trim())
    setInput('')
  }

  return (
    <div className="fixed bottom-4 right-4 z-50">
      {isOpen ? (
        <div className="w-80 h-96 bg-white rounded-lg shadow-xl flex flex-col">
          <header className="p-3 border-b flex justify-between items-center">
            <span>客服聊天</span>
            <span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-gray-400'}`} />
          </header>
          <div className="flex-1 overflow-y-auto p-3 space-y-2">
            {chat?.messages.map(m => (
              <div key={m.id} className={`p-2 rounded ${m.sender_type === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'}`}>
                {m.content}
              </div>
            ))}
            <div ref={messagesEndRef} />
          </div>
          <div className="p-3 border-t flex gap-2">
            <input value={input} onChange={e => setInput(e.target.value)}
              onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
              className="flex-1 border rounded px-2" placeholder="输入消息..." />
            <button onClick={handleSend} className="px-3 py-1 bg-blue-500 text-white rounded">发送</button>
          </div>
        </div>
      ) : (
        <button onClick={() => setIsOpen(true)} className="w-14 h-14 bg-blue-500 rounded-full text-white relative">
          💬
          {unreadCount > 0 && (
            <span className="absolute -top-1 -right-1 bg-red-500 text-xs w-5 h-5 rounded-full flex items-center justify-center">
              {unreadCount > 9 ? '9+' : unreadCount}
            </span>
          )}
        </button>
      )}
    </div>
  )
}

Next.js (App Router)

Next.js(App Router)

API Route:
typescript
// app/api/support-chat/route.ts
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'

export async function GET() {
  const session = await getServerSession()
  if (!session?.user) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  let chat = await prisma.supportChat.findUnique({
    where: { userId: session.user.id },
    include: { messages: { orderBy: { createdAt: 'asc' } } }
  })

  if (!chat) {
    chat = await prisma.supportChat.create({
      data: { userId: session.user.id },
      include: { messages: true }
    })
  }

  return Response.json(chat)
}
WebSocket with Pusher/Ably (serverless-friendly):
typescript
// For serverless, use Pusher, Ably, or similar
import Pusher from 'pusher'
const pusher = new Pusher({ appId, key, secret, cluster })

// When message is created:
await pusher.trigger(`support-chat-${chatId}`, 'new-message', messageData)

// Client-side with pusher-js:
const channel = pusher.subscribe(`support-chat-${chatId}`)
channel.bind('new-message', (data) => { /* update state */ })
API路由:
typescript
// app/api/support-chat/route.ts
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'

export async function GET() {
  const session = await getServerSession()
  if (!session?.user) return Response.json({ error: '未授权' }, { status: 401 })

  let chat = await prisma.supportChat.findUnique({
    where: { userId: session.user.id },
    include: { messages: { orderBy: { createdAt: 'asc' } } }
  })

  if (!chat) {
    chat = await prisma.supportChat.create({
      data: { userId: session.user.id },
      include: { messages: true }
    })
  }

  return Response.json(chat)
}
使用Pusher/Ably实现WebSocket(适配Serverless):
typescript
// Serverless环境推荐使用Pusher、Ably等服务
import Pusher from 'pusher'
const pusher = new Pusher({ appId, key, secret, cluster })

// 消息创建时触发广播:
await pusher.trigger(`support-chat-${chatId}`, 'new-message', messageData)

// 客户端使用pusher-js:
const channel = pusher.subscribe(`support-chat-${chatId}`)
channel.bind('new-message', (data) => { /* 更新状态 */ })

PHP/Laravel

PHP/Laravel

Models:
php
// app/Models/SupportChat.php
class SupportChat extends Model
{
    protected $casts = ['last_message_at' => 'datetime', 'archived_at' => 'datetime'];

    public function user() { return $this->belongsTo(User::class); }
    public function messages() { return $this->hasMany(SupportMessage::class); }

    public function scopeActive($query) { return $query->whereNull('archived_at'); }
    public function scopeArchived($query) { return $query->whereNotNull('archived_at'); }

    public function isUnreadForAdmin(): bool {
        return $this->messages()
            ->where('sender_type', 'user')
            ->where('created_at', '>', $this->admin_viewed_at ?? '1970-01-01')
            ->exists();
    }
}

// app/Models/SupportMessage.php
class SupportMessage extends Model
{
    protected static function booted() {
        static::created(function ($message) {
            $message->supportChat->update(['last_message_at' => now()]);
            broadcast(new NewSupportMessage($message))->toOthers();

            if ($message->sender_type === 'admin') {
                SendSupportReplyNotification::dispatch($message)->delay(now()->addMinutes(5));
            }
        });
    }
}
Broadcasting Event:
php
// app/Events/NewSupportMessage.php
class NewSupportMessage implements ShouldBroadcast
{
    public function __construct(public SupportMessage $message) {}

    public function broadcastOn() {
        return new PrivateChannel('support-chat.' . $this->message->support_chat_id);
    }

    public function broadcastAs() { return 'new-message'; }
}
模型:
php
// app/Models/SupportChat.php
class SupportChat extends Model
{
    protected $casts = ['last_message_at' => 'datetime', 'archived_at' => 'datetime'];

    public function user() { return $this->belongsTo(User::class); }
    public function messages() { return $this->hasMany(SupportMessage::class); }

    public function scopeActive($query) { return $query->whereNull('archived_at'); }
    public function scopeArchived($query) { return $query->whereNotNull('archived_at'); }

    public function isUnreadForAdmin(): bool {
        return $this->messages()
            ->where('sender_type', 'user')
            ->where('created_at', '>', $this->admin_viewed_at ?? '1970-01-01')
            ->exists();
    }
}

// app/Models/SupportMessage.php
class SupportMessage extends Model
{
    protected static function booted() {
        static::created(function ($message) {
            $message->supportChat->update(['last_message_at' => now()]);
            broadcast(new NewSupportMessage($message))->toOthers();

            if ($message->sender_type === 'admin') {
                SendSupportReplyNotification::dispatch($message)->delay(now()->addMinutes(5));
            }
        });
    }
}
广播事件:
php
// app/Events/NewSupportMessage.php
class NewSupportMessage implements ShouldBroadcast
{
    public function __construct(public SupportMessage $message) {}

    public function broadcastOn() {
        return new PrivateChannel('support-chat.' . $this->message->support_chat_id);
    }

    public function broadcastAs() { return 'new-message'; }
}

Vue.js

Vue.js

Composable:
typescript
// composables/useSupportChat.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useSupportChat() {
  const chat = ref<Chat | null>(null)
  const connected = ref(false)
  let ws: WebSocket | null = null
  const seenIds = new Set<string>()

  onMounted(async () => {
    const res = await fetch('/api/support-chat')
    chat.value = await res.json()
    chat.value?.messages.forEach(m => seenIds.add(m.id))

    ws = new WebSocket(`/ws/chat?id=${chat.value?.id}`)
    ws.onopen = () => connected.value = true
    ws.onclose = () => connected.value = false
    ws.onmessage = (e) => {
      const data = JSON.parse(e.data)
      if (data.type === 'new_message' && !seenIds.has(data.message.id)) {
        seenIds.add(data.message.id)
        chat.value?.messages.push(data.message)
      }
    }
  })

  onUnmounted(() => ws?.close())

  const sendMessage = (content: string) => {
    ws?.send(JSON.stringify({ action: 'send_message', content }))
  }

  return { chat, connected, sendMessage }
}

组合式函数:
typescript
// composables/useSupportChat.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useSupportChat() {
  const chat = ref<Chat | null>(null)
  const connected = ref(false)
  let ws: WebSocket | null = null
  const seenIds = new Set<string>()

  onMounted(async () => {
    const res = await fetch('/api/support-chat')
    chat.value = await res.json()
    chat.value?.messages.forEach(m => seenIds.add(m.id))

    ws = new WebSocket(`/ws/chat?id=${chat.value?.id}`)
    ws.onopen = () => connected.value = true
    ws.onclose = () => connected.value = false
    ws.onmessage = (e) => {
      const data = JSON.parse(e.data)
      if (data.type === 'new_message' && !seenIds.has(data.message.id)) {
        seenIds.add(data.message.id)
        chat.value?.messages.push(data.message)
      }
    }
  })

  onUnmounted(() => ws?.close())

  const sendMessage = (content: string) => {
    ws?.send(JSON.stringify({ action: 'send_message', content }))
  }

  return { chat, connected, sendMessage }
}

Database Recommendations

数据库推荐

PostgreSQL (Recommended)

PostgreSQL(推荐)

  • Use UUID primary keys for security (non-guessable IDs)
  • Use
    timestamptz
    for all datetime columns
  • Add GIN index on content for full-text search (optional)
  • 使用UUID作为主键,提升安全性(不可猜测)
  • 所有日期时间列使用
    timestamptz
    类型
  • 可选:为content字段添加GIN索引支持全文搜索

MySQL

MySQL

  • Use
    CHAR(36)
    or
    BINARY(16)
    for UUIDs
  • Use
    DATETIME(6)
    for microsecond precision
  • Consider
    utf8mb4
    charset for emoji support
  • 使用
    CHAR(36)
    BINARY(16)
    存储UUID
  • 使用
    DATETIME(6)
    类型支持微秒精度
  • 考虑使用
    utf8mb4
    字符集支持emoji

SQLite (Development/Small Scale)

SQLite(开发/小规模场景)

  • Works fine for prototyping
  • Store UUIDs as TEXT
  • No native datetime type, store as ISO8601 strings
  • 适合原型开发
  • 以TEXT类型存储UUID
  • 无原生日期时间类型,存储为ISO8601字符串

MongoDB (Document Store)

MongoDB(文档型数据库)

  • Embed messages in chat document if message count is bounded
  • Or use separate collection with chat_id reference
  • Use TTL index on archived chats for auto-cleanup (optional)

  • 如果消息数量有限,可将消息嵌入聊天会话文档中
  • 或使用独立集合,通过chat_id关联
  • 可选:为已归档会话添加TTL索引自动清理

Email Processing Recommendations

邮件处理推荐

Transactional Email Services

事务邮件服务

  • Postmark - Best deliverability, simple API
  • SendGrid - Good free tier, robust
  • AWS SES - Cheapest at scale
  • Resend - Modern DX, React email templates
  • Postmark - 送达率最高,API简洁
  • SendGrid - 免费额度充足,功能强大
  • AWS SES - 大规模场景下成本最低
  • Resend - 现代化开发体验,支持React邮件模板

Implementation Pattern

实现模式

pseudo
// Always use background jobs for email
Job: SendSupportReplyNotification
  delay: 5 minutes after admin message

  perform(message_id):
    message = find_message(message_id)

    // Guard clauses - don't send if:
    if message.sender_type != 'admin': return
    if message.read_at != null: return        // Already read
    if message.chat.archived?: return         // Chat archived

    send_email(
      to: message.chat.user.email,
      template: 'support_reply',
      data: { message_preview: message.content.truncate(200) }
    )
pseudo
// 始终使用后台任务发送邮件
任务:SendSupportReplyNotification
  延迟:管理员发送消息后5分钟

  perform(message_id):
    message = find_message(message_id)

    // 前置校验 - 满足以下条件则不发送:
    if message.sender_type != 'admin': return
    if message.read_at != null: return        // 消息已读
    if message.chat.archived?: return         // 会话已归档

    send_email(
      to: message.chat.user.email,
      template: 'support_reply',
      data: { message_preview: message.content.truncate(200) }
    )

Email Template Tips

邮件模板技巧

  • Include message preview (truncated)
  • Add direct link to open chat (if web app)
  • Keep subject simple: "New reply from [App] Support"
  • Include unsubscribe link for compliance

  • 包含消息预览(截断显示)
  • 添加直接打开聊天的链接(如果是Web应用)
  • 主题简洁:"来自[应用名称]客服的新回复"
  • 包含退订链接以符合合规要求

Real-Time Technology Options

实时技术选型

TechnologyBest ForServerless?
ActionCable (Rails)Rails appsNo
Socket.IONode.js appsNo
PusherAny stackYes
AblyAny stackYes
Supabase RealtimeSupabase usersYes
Firebase RTDBFirebase usersYes
Server-Sent EventsSimple one-wayYes
技术方案最佳适用场景支持Serverless?
ActionCable (Rails)Rails应用
Socket.IONode.js应用
Pusher任意技术栈
Ably任意技术栈
Supabase RealtimeSupabase用户
Firebase RTDBFirebase用户
Server-Sent Events简单单向通信场景

Fallback Strategy

降级策略

If WebSocket unavailable, implement polling:
pseudo
// Poll every 5 seconds when disconnected
if (!websocket.connected) {
  setInterval(() => {
    fetch('/api/support-chat/messages?since=' + lastMessageTime)
      .then(newMessages => appendMessages(newMessages))
  }, 5000)
}
如果WebSocket不可用,实现轮询方案:
pseudo
// 断开连接时每5秒轮询一次
if (!websocket.connected) {
  setInterval(() => {
    fetch('/api/support-chat/messages?since=' + lastMessageTime)
      .then(newMessages => appendMessages(newMessages))
  }, 5000)
}