chat-widget
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseLive 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: and .
support_chatssupport_messagessupport_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_atsupport_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_atKey indexes:
- (unique)
support_chats.user_id - (for sorting)
support_chats.last_message_at - (for filtering)
support_chats.archived_at support_messages.chat_id- (composite, for ordering)
support_messages.(chat_id, created_at)
Model relationships:
User has_one SupportChat
SupportChat belongs_to User
SupportChat has_many SupportMessages
SupportMessage belongs_to SupportChatModel 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 != nullMessage 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_chatssupport_messagessupport_chats
id - 主键(推荐使用UUID)
user_id - 关联用户表的外键(唯一约束:每个用户对应一个聊天会话)
last_message_at - 时间戳(用于按最新消息排序会话)
admin_viewed_at - 时间戳(记录管理员最后查看时间)
archived_at - 时间戳(null表示活跃,有值表示已归档)
created_at
updated_atsupport_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 != nullMessage模型:
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 readAdmin-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 chatController 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)
└── SendButtonState 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
核心设计决策
- One chat per user - Simplifies UX, user always has same conversation history
- Soft-delete via archiving - Preserves history, allows restore
- Auto-unarchive - When user sends message to archived chat, reactivate it
- Delayed email notifications - 5 min delay prevents spam for rapid replies
- Message deduplication - Track seen IDs to prevent duplicates from send + broadcast echo
- Separate admin channel - Allows future features like global unread count, desktop notifications
- 每个用户一个会话 - 简化用户体验,用户始终拥有相同的对话历史
- 通过归档实现软删除 - 保留历史记录,支持恢复
- 自动取消归档 - 用户向已归档会话发送消息时,自动重新激活会话
- 延迟邮件通知 - 5分钟延迟避免快速回复时发送垃圾邮件
- 消息去重 - 记录已查看的消息ID,防止发送+广播导致的重复消息
- 独立管理员通道 - 支持未来扩展全局未读计数、桌面通知等功能
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
常见陷阱
- Forgetting deduplication - Messages sent by current user echo back via broadcast
- Race conditions on read status - Use database transactions
- WebSocket auth - Verify user can access the specific chat
- Stale connection status - Handle reconnection gracefully
- Missing indexes - Add composite index on (chat_id, created_at)
- Email timing - Use background job, not synchronous send
- 忘记消息去重 - 当前用户发送的消息会通过广播回显
- 已读状态竞争条件 - 使用数据库事务处理
- WebSocket权限验证 - 验证用户是否有权访问指定会话
- 连接状态过期 - 优雅处理重连逻辑
- 缺失索引 - 添加(chat_id, created_at)复合索引
- 邮件发送时机 - 使用后台任务,避免同步发送
Framework-Specific Guidance
框架专属实现指南
Ruby on Rails
Ruby on Rails
Models:
ruby
undefined模型:
ruby
undefinedapp/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:**
```rubyclass 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
**通道:**
```rubyapp/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 for all datetime columns
timestamptz - Add GIN index on content for full-text search (optional)
- 使用UUID作为主键,提升安全性(不可猜测)
- 所有日期时间列使用类型
timestamptz - 可选:为content字段添加GIN索引支持全文搜索
MySQL
MySQL
- Use or
CHAR(36)for UUIDsBINARY(16) - Use for microsecond precision
DATETIME(6) - Consider charset for emoji support
utf8mb4
- 使用或
CHAR(36)存储UUIDBINARY(16) - 使用类型支持微秒精度
DATETIME(6) - 考虑使用字符集支持emoji
utf8mb4
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
实时技术选型
| Technology | Best For | Serverless? |
|---|---|---|
| ActionCable (Rails) | Rails apps | No |
| Socket.IO | Node.js apps | No |
| Pusher | Any stack | Yes |
| Ably | Any stack | Yes |
| Supabase Realtime | Supabase users | Yes |
| Firebase RTDB | Firebase users | Yes |
| Server-Sent Events | Simple one-way | Yes |
| 技术方案 | 最佳适用场景 | 支持Serverless? |
|---|---|---|
| ActionCable (Rails) | Rails应用 | 否 |
| Socket.IO | Node.js应用 | 否 |
| Pusher | 任意技术栈 | 是 |
| Ably | 任意技术栈 | 是 |
| Supabase Realtime | Supabase用户 | 是 |
| Firebase RTDB | Firebase用户 | 是 |
| 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)
}