前言
作为一名技术博主,我一直希望能与读者建立更直接的联系。虽然留言板是传统的异步沟通方式,但实时聊天的即时性和互动感是留言无法替代的。经过一番调研和开发,我为我的博客添加了一套完整的 WebSocket 实时在线聊天系统。
这篇文章将详细记录整个系统的设计思路、技术选型、实现过程以及踩过的坑。希望对有类似需求的开发者有所帮助。


需求分析
在开始编码之前,我明确了系统的核心需求:
功能需求
双端支持
- 访客端:匿名访问,无需注册即可聊天
- 管理端:我需要能实时收到消息并回复
消息功能
- 文本消息实时收发
- 图片消息支持( base64 编码)
- 消息历史记录(换页面不丢失对话)
状态显示
- 管理员在线/离线状态
- 对方正在输入提示
- 连接状态显示
交互设计
- 右下角浮动消息气泡
- 选择弹窗:在线沟通 / 留言
- 全站可用(不仅是简历页)
非功能需求
- 安全性:防止 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 │ │ └── chat_admin.go │ ├── models/ │ │ ├── chat_session.go │ │ └── chat_message.go │ ├── middleware/ │ │ └── auth.go │ └── ws/ │ └── hub.go ├── cmd/ │ └── main.go └── config.yaml
|
WebSocket Hub 设计
采用经典的 “Hub” 模式管理所有连接:
type Hub struct { Register chan *Client Unregister chan *Client Clients map[string]*Client AdminClients map[uint]*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"` From string `json:"from"` Content string `json:"content"` SessionID string `json:"session_id"` HasImage bool `json:"has_image"` CreatedAt time.Time `json:"created_at"` }
|
核心 Handler 实现
func (h *ChatWSHandler) VisitorConnect(c *gin.Context) { conn, err := h.Upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { log.Printf("WebSocket upgrade failed: %v", err) return }
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, }
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) }
|
JWT 认证的特殊处理
WebSocket 连接时浏览器无法自定义 Header,所以采用 Query 参数传递 Token:
func Auth() gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" && c.Query("token") != "" { token = "Bearer " + c.Query("token") } 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"` Status int `gorm:"default:1"` CreatedAt time.Time UpdatedAt time.Time }
type ChatMessage struct { ID uint `gorm:"primaryKey"` SessionID string `gorm:"index;size:64"` Type int `gorm:"default:1"` Content string `gorm:"type:text"` HasImage bool `gorm:"default:false"` IsRead bool `gorm:"default:false"` CreatedAt time.Time }
|
前端实现(Hexo 主题改造)
整体思路
Hexo 是静态博客,无法直接运行服务端代码。我的方案是:
- 布局层:在
layout.ejs 注入全局聊天组件
- 样式隔离:使用 Shadow DOM 思想,CSS 类名加前缀避免冲突
- 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': sessionID = msg.session_id; localStorage.setItem('chat_session_id', sessionID); loadChatHistory(sessionID); break; case 'message': if (msg.from === 'admin') { addMessage(msg.content, 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
|
管理端实现
管理端需要:
- 登录认证
- 访客列表(谁在跟我聊天)
- 实时消息收发
- 快捷回复
管理端界面
我将其做成了独立的单页应用,放在 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>
|
登录流程
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); }
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 的升级支持:
server { listen 443 ssl http2; server_name blogapi.awen.me;
ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem;
location /api/v1/chat/ws { proxy_pass http://localhost:8083; proxy_http_version 1.1; 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; }
location /api/ { proxy_pass http://localhost:8083; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }
|
Systemd 服务
[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") }
|
效果展示
访客端体验
- 任意页面点击右下角气泡
- 选择”在线沟通”或”留言”
- 聊天窗口弹出,自动连接 WebSocket
- 消息实时收发,支持图片

管理端体验
- 访问
https://www.awen.me/admin
- 登录后自动连接 WebSocket
- 左侧访客列表,右侧聊天窗口
- 未读消息红点提醒

总结
这次为博客添加实时聊天系统的开发,让我收获了很多:
- WebSocket 实践:从理论到落地,理解了连接管理、心跳保活、重连机制
- 前后端分离:Hexo 静态站 + Go API 的组合很灵活
- 安全考虑:CSP、XSS 防护、JWT 认证缺一不可
- 用户体验:消息气泡、状态提示、历史记录等细节很重要
完整代码已开源,如果你也想为博客添加类似功能,欢迎参考:
有任何问题,可以通过文章顶部的聊天按钮直接找我聊聊 😄
参考资源
本文完,感谢阅读!
文章作者:阿文
版权声明:本博客所有文章除特别声明外,均采用
CC BY-NC-SA 4.0 许可协议。转载请注明来自
阿文的博客!
评论
0 条评论