深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

2024 桐庐半程马拉松
00:00:00
时间
0.00
距离(公里)
--:--
配速
--
步频
--
心率 (bpm)
--
配速
步频
|
share-image
ESC

为 Hexo 博客添加实时 WebSocket 在线聊天系统

前言

作为一名技术博主,我一直希望能与读者建立更直接的联系。虽然留言板是传统的异步沟通方式,但实时聊天的即时性和互动感是留言无法替代的。经过一番调研和开发,我为我的博客添加了一套完整的 WebSocket 实时在线聊天系统

这篇文章将详细记录整个系统的设计思路、技术选型、实现过程以及踩过的坑。希望对有类似需求的开发者有所帮助。

需求分析

在开始编码之前,我明确了系统的核心需求:

功能需求

  1. 双端支持

    • 访客端:匿名访问,无需注册即可聊天
    • 管理端:我需要能实时收到消息并回复
  2. 消息功能

    • 文本消息实时收发
    • 图片消息支持( base64 编码)
    • 消息历史记录(换页面不丢失对话)
  3. 状态显示

    • 管理员在线/离线状态
    • 对方正在输入提示
    • 连接状态显示
  4. 交互设计

    • 右下角浮动消息气泡
    • 选择弹窗:在线沟通 / 留言
    • 全站可用(不仅是简历页)

非功能需求

  • 安全性:防止 XSS、CSRF,CSP 合规
  • 性能:消息秒达,支持重连
  • 兼容性:支持主流浏览器
  • 部署简单:单二进制文件 + Nginx

技术架构

整体架构

┌─────────────────────────────────────────────────────────────┐
│ 前端层 (Hexo) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 消息气泡按钮 │ │ 选择弹窗 │ │ WebSocket 聊天窗口 │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
└─────────┼─────────────────┼───────────────────┼───────────┘
│ │ │
└─────────────────┴───────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Nginx 反向代理 │
│ (SSL 终止 / WebSocket 升级 / 静态资源) │
└─────────────────────────┬───────────────────────────────────┘

┌───────────┴───────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ blogapi.awen.me │ │ www.awen.me
│ (Go + WebSocket) │ │ (Hexo 静态站点) │
│ - JWT 认证 │ │ │
│ - 消息存储 │ │ │
│ - 在线状态管理 │ │ │
└─────────────────────┘ └─────────────────────┘

技术栈选择

层级 技术 选择理由
前端 Hexo + EJS 博客本身就是 Hexo,直接改造主题
后端 Go + Gin 性能优秀,WebSocket 支持好,部署简单
数据库 MySQL 存储消息历史和会话信息
缓存 Redis 管理员在线状态、会话管理
部署 Nginx + Systemd 成熟的生产环境方案

后端实现(Go)

项目结构

blog_api/
├── internal/
│ ├── handlers/
│ │ ├── chat_ws.go # WebSocket 核心逻辑
│ │ └── chat_admin.go # 管理端接口
│ ├── models/
│ │ ├── chat_session.go # 会话模型
│ │ └── chat_message.go # 消息模型
│ ├── middleware/
│ │ └── auth.go # JWT 认证
│ └── ws/
│ └── hub.go # WebSocket 连接管理
├── cmd/
│ └── main.go
└── config.yaml

WebSocket Hub 设计

采用经典的 “Hub” 模式管理所有连接:

// internal/ws/hub.go
type Hub struct {
// 注册请求通道
Register chan *Client
// 注销请求通道
Unregister chan *Client
// 所有客户端连接
Clients map[string]*Client // session_id -> Client
// 管理员连接(支持多设备登录)
AdminClients map[uint]*Client // user_id -> Client
// 消息广播通道
Broadcast chan *Message
}

type Client struct {
Hub *Hub
Conn *websocket.Conn
Send chan []byte
SessionID string
IsAdmin bool
UserID uint
}

设计亮点

  • 访客通过 session_id 识别,刷新页面不丢失对话
  • 管理员通过 user_id 识别,支持多设备同时在线
  • 使用 Channel 实现并发安全的连接管理

消息协议设计

使用 JSON 作为消息格式:

type Message struct {
Type string `json:"type"` // message/image/typing/admin_status
From string `json:"from"` // visitor/admin/system
Content string `json:"content"` // 文本内容或图片 base64
SessionID string `json:"session_id"`
HasImage bool `json:"has_image"`
CreatedAt time.Time `json:"created_at"`
}

核心 Handler 实现

// 访客连接(匿名)
func (h *ChatWSHandler) VisitorConnect(c *gin.Context) {
// 升级 HTTP 为 WebSocket
conn, err := h.Upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}

// 获取或创建 session
sessionID := c.Query("session_id")
if sessionID == "" {
sessionID = generateSessionID()
}

client := &ws.Client{
Hub: h.Hub,
Conn: conn,
Send: make(chan []byte, 256),
SessionID: sessionID,
IsAdmin: false,
}

// 注册到 Hub
h.Hub.Register <- client

// 启动读写协程
go client.WritePump()
go client.ReadPump()

// 发送连接成功消息
client.Send <- (&Message{
Type: "connected",
SessionID: sessionID,
Content: "连接成功",
}).ToJSON()
}

// 管理员连接(需认证)
func (h *ChatWSHandler) AdminConnect(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}

conn, err := h.Upgrader.Upgrade(c.Writer, c.Request, nil)
// ... 类似访客连接,但 IsAdmin = true
}

JWT 认证的特殊处理

WebSocket 连接时浏览器无法自定义 Header,所以采用 Query 参数传递 Token:

func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")

// WebSocket 连接 fallback 到 query param
if token == "" && c.Query("token") != "" {
token = "Bearer " + c.Query("token")
}

// 解析 JWT ...
c.Set("userID", userID)
c.Next()
}
}

前端连接示例:

const token = localStorage.getItem('admin_token');
const ws = new WebSocket(`wss://blogapi.awen.me/api/v1/chat/ws/admin?token=${token}`);

数据库模型

// 会话表
type ChatSession struct {
ID uint `gorm:"primaryKey"`
SessionID string `gorm:"uniqueIndex;size:64"` // 访客标识
Source string `gorm:"size:100"` // 来源页面标题
URL string `gorm:"size:500"` // 来源页面 URL
Status int `gorm:"default:1"` // 1:活跃 2:关闭
CreatedAt time.Time
UpdatedAt time.Time
}

// 消息表
type ChatMessage struct {
ID uint `gorm:"primaryKey"`
SessionID string `gorm:"index;size:64"`
Type int `gorm:"default:1"` // 1:访客 2:管理员
Content string `gorm:"type:text"`
HasImage bool `gorm:"default:false"`
IsRead bool `gorm:"default:false"`
CreatedAt time.Time
}

前端实现(Hexo 主题改造)

整体思路

Hexo 是静态博客,无法直接运行服务端代码。我的方案是:

  1. 布局层:在 layout.ejs 注入全局聊天组件
  2. 样式隔离:使用 Shadow DOM 思想,CSS 类名加前缀避免冲突
  3. CDN 资源:图标使用内联 SVG,不依赖外部字体

组件结构

layout.ejs
├── 消息气泡按钮 (message-bubble)
├── 选择弹窗 (contact-choice-modal)
│ ├── 在线沟通按钮
│ └── 留言按钮
├── WebSocket 聊天窗口 (chat-modal)
│ ├── 聊天头部(头像、状态)
│ ├── 消息列表
│ └── 输入框 + 图片发送
└── 留言表单弹窗 (message-modal)

关键代码实现

1. 动态创建聊天窗口

为了不污染 HTML 结构,聊天窗口完全用 JavaScript 动态创建:

(function() {
// 创建聊天对话框
var chatModal = document.createElement('div');
chatModal.className = 'chat-modal';
chatModal.id = 'chatModal';
chatModal.innerHTML = `
<div class="chat-overlay"></div>
<div class="chat-dialog">
<div class="chat-header">
<div class="chat-header-info">
<div class="chat-avatar">
<img src="https://file.awen.me/blog/avatar.jpg" alt="博主">
</div>
<div class="chat-header-text">
<h3>方文俊</h3>
<p id="chatStatusText">连接中...</p>
</div>
</div>
<button type="button" class="chat-close" id="chatClose">...</button>
</div>
<div class="chat-body" id="chatBody">...</div>
<div class="chat-footer">...</div>
</div>
`;
document.body.appendChild(chatModal);
})();

2. WebSocket 连接管理

var ws = null;
var sessionID = localStorage.getItem('chat_session_id');
var reconnectTimer = null;

function connectWebSocket() {
var wsUrl = 'wss://blogapi.awen.me/api/v1/chat/ws';
var params = [];

if (sessionID) {
params.push('session_id=' + encodeURIComponent(sessionID));
}
params.push('source=' + encodeURIComponent(document.title));
params.push('url=' + encodeURIComponent(window.location.href));

ws = new WebSocket(wsUrl + '?' + params.join('&'));

ws.onopen = function() {
console.log('[WebSocket] 已连接');
updateConnectionStatus(true);
};

ws.onmessage = function(event) {
var msg = JSON.parse(event.data);
handleMessage(msg);
};

ws.onclose = function() {
console.log('[WebSocket] 连接断开,3秒后重连');
updateConnectionStatus(false);
reconnectTimer = setTimeout(connectWebSocket, 3000);
};

ws.onerror = function(error) {
console.error('[WebSocket] 错误:', error);
};
}

3. 消息处理逻辑

function handleMessage(msg) {
switch (msg.type) {
case 'connected':
// 保存会话 ID
sessionID = msg.session_id;
localStorage.setItem('chat_session_id', sessionID);
// 加载历史消息
loadChatHistory(sessionID);
break;

case 'message':
if (msg.from === 'admin') {
addMessage(msg.content, false); // false = 对方消息
playNotificationSound();
}
break;

case 'image':
if (msg.from === 'admin') {
addImageMessage(msg.content, false);
playNotificationSound();
}
break;

case 'typing':
showTypingStatus();
break;

case 'admin_status':
updateAdminStatus(msg.content === 'online');
break;
}
}

4. 图片发送实现

图片使用 base64 编码传输,虽然会增加体积,但对于聊天场景足够:

function sendImage(file) {
if (file.size > 5 * 1024 * 1024) {
showToast('图片大小不能超过 5MB', 'error');
return;
}

var reader = new FileReader();
reader.onload = function(e) {
var base64 = e.target.result;

// 先显示在本地
addImageMessage(base64, true);

// 发送到服务器
ws.send(JSON.stringify({
type: 'image',
content: base64
}));
};
reader.readAsDataURL(file);
}

CSS 样式设计

使用 Stylus 编写,支持深色模式:

/* 聊天弹窗 */
.chat-modal
position fixed
top 0
left 0
right 0
bottom 0
z-index 9999
display flex
align-items center
justify-content center
opacity 0
pointer-events none
transition opacity 0.3s ease

&.active
opacity 1
pointer-events auto

.chat-dialog
width 90%
max-width 480px
height 600px
max-height 80vh
background var(--card-bg)
border-radius 20px
box-shadow 0 20px 60px rgba(0, 0, 0, 0.3)
display flex
flex-direction column
overflow hidden

/* 深色模式适配 */
[data-theme="dark"] .chat-dialog
background #1e1e1e

[data-theme="dark"] .chat-bubble
background #2a2a2a
color #e0e0e0

管理端实现

管理端需要:

  1. 登录认证
  2. 访客列表(谁在跟我聊天)
  3. 实时消息收发
  4. 快捷回复

管理端界面

我将其做成了独立的单页应用,放在 source/admin/index.html,通过 GitHub Pages 或 Hexo 一起部署:

<!-- 简化版结构 -->
<div class="admin-layout">
<aside class="visitor-list">
<div class="visitor-item active" data-session="xxx">
<img src="avatar.png" class="visitor-avatar">
<div class="visitor-info">
<h4>访客 #1234</h4>
<p>在线</p>
</div>
<span class="unread-badge">3</span>
</div>
</aside>

<main class="chat-area">
<div class="chat-messages" id="messages"></div>
<div class="chat-input">
<input type="text" id="msgInput" placeholder="输入回复...">
<button id="sendBtn">发送</button>
</div>
</main>
</div>

登录流程

// 登录获取 JWT
async function login(username, password) {
const res = await fetch('https://blogapi.awen.me/api/v1/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});

const data = await res.json();
localStorage.setItem('admin_token', data.token);
connectAdminWebSocket(data.token);
}

// 连接管理端 WebSocket
function connectAdminWebSocket(token) {
const ws = new WebSocket(
`wss://blogapi.awen.me/api/v1/chat/ws/admin?token=${token}`
);

ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'visitor_list') {
updateVisitorList(msg.data);
} else if (msg.type === 'message') {
appendMessage(msg);
}
};
}

部署配置

Nginx 配置

关键是 WebSocket 的升级支持:

# /etc/nginx/conf.d/blog-api.conf

server {
listen 443 ssl http2;
server_name blogapi.awen.me;

ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;

# WebSocket 端点
location /api/v1/chat/ws {
proxy_pass http://localhost:8083;
proxy_http_version 1.1;

# 关键:Upgrade 头
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# 超时设置
proxy_read_timeout 86400;
}

# 其他 API
location /api/ {
proxy_pass http://localhost:8083;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

Systemd 服务

# /etc/systemd/system/blog-api.service
[Unit]
Description=Blog API Service
After=network.target

[Service]
Type=simple
User=fangwenjun
WorkingDirectory=/opt/blog-api
ExecStart=/opt/blog-api/blog-api
Restart=always
RestartSec=5
Environment="ADMIN_INITIAL_PASSWORD=xxx"
Environment="DB_HOST=localhost"
Environment="JWT_SECRET=xxx"

[Install]
WantedBy=multi-user.target

踩坑记录

1. CSP 阻止 WebSocket 连接

问题:浏览器报错

Connecting to 'wss://blogapi.awen.me/api/v1/chat/ws' violates 
the following Content Security Policy directive:
"connect-src 'self' https://blogapi.awen.me ..."

原因:CSP 的 connect-src 只允许了 https://,没有允许 wss://

解决

<meta http-equiv="Content-Security-Policy" 
content="...; connect-src 'self' https://blogapi.awen.me wss://blogapi.awen.me; ...">

2. 弹窗无法关闭

问题:点击遮罩层或关闭按钮,选择弹窗不消失

原因openChoiceModal() 中手动设置了 style.display = 'block',但 closeChoiceModal() 只移除了 CSS 类,没有清除内联样式

解决

function closeChoiceModal() {
contactChoiceModal.classList.remove('active');
contactChoiceModal.style.display = ''; // 清除内联样式
document.body.style.overflow = '';
}

3. 简历页重复代码

问题:一开始只在简历页实现聊天,其他页面提示”请前往简历页”

解决:将聊天代码抽到 layout.ejs,全站共享

4. WebSocket 认证问题

问题:WebSocket 连接时无法携带 Header,JWT 无法传递

解决:使用 Query 参数传递 token,后端 middleware 兼容处理:

if token == "" && c.Query("token") != "" {
token = "Bearer " + c.Query("token")
}

效果展示

访客端体验

  1. 任意页面点击右下角气泡
  2. 选择”在线沟通”或”留言”
  3. 聊天窗口弹出,自动连接 WebSocket
  4. 消息实时收发,支持图片

访客端聊天

管理端体验

  1. 访问 https://www.awen.me/admin
  2. 登录后自动连接 WebSocket
  3. 左侧访客列表,右侧聊天窗口
  4. 未读消息红点提醒

管理端界面


总结

这次为博客添加实时聊天系统的开发,让我收获了很多:

  1. WebSocket 实践:从理论到落地,理解了连接管理、心跳保活、重连机制
  2. 前后端分离:Hexo 静态站 + Go API 的组合很灵活
  3. 安全考虑:CSP、XSS 防护、JWT 认证缺一不可
  4. 用户体验:消息气泡、状态提示、历史记录等细节很重要

完整代码已开源,如果你也想为博客添加类似功能,欢迎参考:

有任何问题,可以通过文章顶部的聊天按钮直接找我聊聊 😄


参考资源


本文完,感谢阅读!

文章作者:阿文
文章链接: https://www.awen.me/post/5dfb2b61.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
您的评论由 AI 智能审核,一般1分钟内会展示,若不展示请确认你的评论是否符合社区和法律规范
加载中...

选择联系方式

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️