
网站客服系统代码实战:实现实时聊天与历史记录保存
网站客服聊天看起来只是“发一句、收一句”,真正落地时却至少涉及四件事:用户身份怎么确认、消息如何实时送达、断线后怎样补回记录,以及如何避免越权读取或脚本注入。本文从一个可运行的最小示例出发,使用 Node.js、Express、Socket.IO 和 SQLite,串起登录、会话、消息保存、历史分页和浏览器端渲染。
示例适合单机开发、内部工具或功能原型,不等于完整的生产客服平台。它重点展示可靠的基本路径:先校验权限,再把消息写入数据库,最后广播;客户端断线后通过 HTTP 接口补读数据库中的记录。多实例部署、可靠消息队列、完整账号系统等边界会在后文说明。
一、适用场景与系统边界
这套结构适用于访客与客服一对一沟通、客服处理简单咨询,以及需要保存消息供后续查看的业务页面。Socket.IO 在 WebSocket 不可用时可以回退到其他传输方式,并提供重连等能力;但它本身不负责用户鉴权、消息持久化、客服分配,也不保证断线期间的消息自动补齐。
示例采用 SQLite 保存会话和消息,适合单进程演示。生产环境通常需要进一步考虑:
- 可靠身份:示例用固定演示账号登录。实际项目应接入已有账号体系,并验证用户确实有权代表相应客户或客服操作。
- 多实例:多个应用进程之间需要共享会话存储,并为 Socket.IO 配置适用的跨实例适配器。仅把进程内事件广播到一个实例,不会自动通知其他实例。
- 消息可靠性:数据库写入成功后服务可能在广播前退出。客户端重连时应查询历史记录;对“必须可靠送达”的业务,还要增加消息确认、重试、去重和运维监控。
- 隐私与保留期限:聊天内容可能包含个人信息。应明确访问权限、保留期限、删除策略、备份保护及日志脱敏方式。
二、创建项目
需要安装 Node.js。新建目录后执行以下命令:
mkdir support-chat
cd support-chat
npm init -y
npm install express socket.io express-session better-sqlite3
将下面代码保存为 server.js。这是一个便于本地运行的单文件服务,包含演示登录接口、会话创建、消息历史查询和 Socket.IO 实时消息处理。
const express = require('express');
const session = require('express-session');
const http = require('http');
const { Server } = require('socket.io');
const Database = require('better-sqlite3');
const path = require('path');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
const db = new Database('chat.sqlite');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
sender_id TEXT NOT NULL,
sender_role TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
ON messages(conversation_id, id);
`);
const sessionMiddleware = session({
name: 'support.sid',
secret: process.env.SESSION_SECRET || 'local-demo-secret-change-me',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: false,
maxAge: 8 * 60 * 60 * 1000
}
});
app.use(express.json({ limit: '20kb' }));
app.use(sessionMiddleware);
app.use(express.static(path.join(__dirname, 'public')));
// 仅用于本地演示。真实项目必须替换为已有的身份认证与授权逻辑。
const demoUsers = {
'customer-demo': { password: 'customer-demo', id: 'customer-1', role: 'customer' },
'agent-demo': { password: 'agent-demo', id: 'agent-1', role: 'agent' }
};
function requireLogin(req, res, next) {
if (!req.session.user) {
return res.status(401).json({ error: '请先登录' });
}
next();
}
function canAccessConversation(user, conversation) {
return user.role === 'agent' || conversation.customer_id === user.id;
}
app.post('/api/login', (req, res) => {
const { username, password } = req.body || {};
const account = demoUsers[username];
if (!account || account.password !== password) {
return res.status(401).json({ error: '用户名或密码错误' });
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: '无法创建登录会话' });
req.session.user = {
id: account.id,
role: account.role
};
req.session.save((saveErr) => {
if (saveErr) return res.status(500).json({ error: '无法保存登录状态' });
res.json({ user: req.session.user });
});
});
});
app.post('/api/logout', requireLogin, (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: '退出失败' });
res.clearCookie('support.sid');
res.json({ ok: true });
});
});
app.get('/api/me', requireLogin, (req, res) => {
res.json({ user: req.session.user });
});
app.post('/api/conversations', requireLogin, (req, res) => {
const user = req.session.user;
if (user.role !== 'customer') {
return res.status(403).json({ error: '只有客户可以创建演示会话' });
}
const result = db.prepare(
'INSERT INTO conversations (customer_id) VALUES (?)'
).run(user.id);
res.status(201).json({ conversationId: Number(result.lastInsertRowid) });
});
app.get('/api/conversations/:id/messages', requireLogin, (req, res) => {
const conversationId = Number(req.params.id);
const before = req.query.before === undefined
? Number.MAX_SAFE_INTEGER
: Number(req.query.before);
const requestedLimit = req.query.limit === undefined
? 50
: Number(req.query.limit);
if (!Number.isSafeInteger(conversationId) || conversationId < 1 ||
!Number.isSafeInteger(before) || before < 1 ||
!Number.isInteger(requestedLimit)) {
return res.status(400).json({ error: '参数格式不正确' });
}
// 服务端把实际分页数量限制在 1 到 100,客户端不能要求无限条记录。
const limit = Math.max(1, Math.min(requestedLimit, 100));
const conversation = db.prepare(
'SELECT id, customer_id FROM conversations WHERE id = ?'
).get(conversationId);
if (!conversation) {
return res.status(404).json({ error: '会话不存在' });
}
if (!canAccessConversation(req.session.user, conversation)) {
return res.status(403).json({ error: '无权查看此会话' });
}
// 倒序取最近一页,再反转为从旧到新的显示顺序。
const rows = db.prepare(`
SELECT id, sender_id AS senderId, sender_role AS senderRole,
body, created_at AS createdAt
FROM messages
WHERE conversation_id = ? AND id < ?
ORDER BY id DESC
LIMIT ?
`).all(conversationId, before, limit).reverse();
res.json({
messages: rows,
nextBefore: rows.length ? rows[0].id : null
});
});
function roomFor(conversationId) {
return 'conversation:' + conversationId;
}
// Socket.IO 使用与 Express 相同的 session 中间件。
// session 中间件读取浏览器随握手发送的 Cookie。
io.engine.use((req, res, next) => {
sessionMiddleware(req, res, next);
});
io.use((socket, next) => {
if (!socket.request.session || !socket.request.session.user) {
return next(new Error('unauthorized'));
}
next();
});
io.on('connection', (socket) => {
const user = socket.request.session.user;
let lastMessageAt = 0;
socket.on('joinConversation', (input, acknowledge = () => {}) => {
const conversationId = Number(input && input.conversationId);
if (!Number.isSafeInteger(conversationId) || conversationId < 1) {
return acknowledge({ error: '会话编号无效' });
}
const conversation = db.prepare(
'SELECT id, customer_id FROM conversations WHERE id = ?'
).get(conversationId);
if (!conversation) {
return acknowledge({ error: '会话不存在' });
}
if (!canAccessConversation(user, conversation)) {
return acknowledge({ error: '无权加入此会话' });
}
socket.join(roomFor(conversationId));
acknowledge({ ok: true });
});
socket.on('sendMessage', (input, acknowledge = () => {}) => {
const conversationId = Number(input && input.conversationId);
const body = typeof (input && input.body) === 'string'
? input.body.trim()
: '';
if (!Number.isSafeInteger(conversationId) || conversationId < 1) {
return acknowledge({ error: '会话编号无效' });
}
// 这里按 JavaScript 字符串长度限制为 2000 个 UTF-16 代码单元;
// 它不是字节数限制,也不等同于可见字符数量。
if (!body || body.length > 2000) {
return acknowledge({ error: '消息不能为空且不能超过 2000 个代码单元' });
}
// 演示用的单 Socket 简单限速,不是完整的反滥用方案。
const now = Date.now();
if (now - lastMessageAt < 300) {
return acknowledge({ error: '发送过于频繁,请稍后再试' });
}
const conversation = db.prepare(
'SELECT id, customer_id, status FROM conversations WHERE id = ?'
).get(conversationId);
if (!conversation) {
return acknowledge({ error: '会话不存在' });
}
if (!canAccessConversation(user, conversation)) {
return acknowledge({ error: '无权发送到此会话' });
}
if (conversation.status !== 'open') {
return acknowledge({ error: '该会话已关闭' });
}
lastMessageAt = now;
// 先落库,再广播。广播失败时,历史记录仍可通过 HTTP 查询恢复。
const result = db.prepare(`
INSERT INTO messages (conversation_id, sender_id, sender_role, body)
VALUES (?, ?, ?, ?)
`).run(conversationId, user.id, user.role, body);
const message = db.prepare(`
SELECT id, sender_id AS senderId, sender_role AS senderRole,
body, created_at AS createdAt
FROM messages WHERE id = ?
`).get(result.lastInsertRowid);
io.to(roomFor(conversationId)).emit('newMessage', message);
acknowledge({ ok: true, message });
});
});
const port = Number(process.env.PORT) || 3000;
server.listen(port, () => {
console.log(`客服演示服务已启动:http://localhost:${port}`);
});
三、创建浏览器页面
创建 public 目录,并在其中保存 index.html。页面提供登录、创建会话、历史记录加载和实时发送。客服账号也能登录并查看某个会话,但这个演示页面没有做客服会话列表;可以使用客户创建会话后返回的编号进行验证。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>客服聊天演示</title>
<h1>客服聊天演示</h1>
<section>
<label>用户名
<input id="username" value="customer-demo">
</label>
<label>密码
<input id="password" type="password" value="customer-demo">
</label>
<button id="login">登录</button>
<button id="create">创建会话</button>
</section>
<section>
<label>会话编号
<input id="conversationId" inputmode="numeric">
</label>
<button id="open">打开会话并加载记录</button>
</section>
<ol id="messages"></ol>
<form id="sendForm">
<input id="body" maxlength="2000" autocomplete="off"
placeholder="输入消息">
<button>发送</button>
</form>
<p id="status" role="status"></p>
<script src="/socket.io/socket.io.js"></script>
<script>
let socket;
let activeConversationId = null;
const seen = new Set();
const status = document.getElementById('status');
const list = document.getElementById('messages');
async function api(url, options = {}) {
const response = await fetch(url, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
...options
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || '请求失败');
return data;
}
function showMessage(message) {
if (seen.has(message.id)) return;
seen.add(message.id);
const item = document.createElement('li');
const time = document.createElement('time');
const text = document.createElement('span');
time.textContent = message.createdAt + ' ';
text.textContent = message.senderRole + ':' + message.body;
item.append(time, text);
list.appendChild(item);
}
function connectSocket() {
if (socket) socket.disconnect();
socket = io();
socket.on('connect', () => {
status.textContent = '实时连接已建立';
if (activeConversationId !== null) joinActiveConversation();
});
socket.on('disconnect', () => {
status.textContent = '连接已断开,等待重连';
});
socket.on('connect_error', () => {
status.textContent = '连接失败,请先登录或重新登录';
});
socket.on('newMessage', (message) => {
showMessage(message);
});
}
function joinActiveConversation() {
if (!socket || !socket.connected || activeConversationId === null) return;
socket.emit('joinConversation',
{ conversationId: activeConversationId },
(result) => {
if (result.error) status.textContent = result.error;
}
);
}
document.getElementById('login').addEventListener('click', async () => {
try {
await api('/api/login', {
method: 'POST',
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
status.textContent = '登录成功';
connectSocket();
} catch (error) {
status.textContent = error.message;
}
});
document.getElementById('create').addEventListener('click', async () => {
try {
const result = await api('/api/conversations', { method: 'POST' });
document.getElementById('conversationId').value = result.conversationId;
status.textContent = '会话已创建,编号:' + result.conversationId;
} catch (error) {
status.textContent = error.message;
}
});
document.getElementById('open').addEventListener('click', async () => {
const id = Number(document.getElementById('conversationId').value);
if (!Number.isSafeInteger(id) || id < 1) {
status.textContent = '请输入有效的会话编号';
return;
}
activeConversationId = id;
list.replaceChildren();
seen.clear();
try {
const result = await api(
'/api/conversations/' + encodeURIComponent(id) + '/messages?limit=50'
);
result.messages.forEach(showMessage);
joinActiveConversation();
} catch (error) {
status.textContent = error.message;
}
});
document.getElementById('sendForm').addEventListener('submit', (event) => {
event.preventDefault();
if (!socket || !socket.connected || activeConversationId === null) {
status.textContent = '请先登录并打开会话';
return;
}
const input = document.getElementById('body');
const body = input.value;
socket.emit('sendMessage',
{ conversationId: activeConversationId, body },
(result) => {
if (result.error) {
status.textContent = result.error;
return;
}
input.value = '';
}
);
});
</script>
</html>
启动服务:
node server.js
打开 http://localhost:3000,使用 customer-demo / customer-demo 登录,创建会话,再打开会话并发送消息。可以在另一个浏览器配置文件或隐私窗口中使用 agent-demo / agent-demo 登录,并在会话编号处填写客户创建时得到的编号。两个页面都打开该会话后,就能看到实时消息;刷新页面仍可从 SQLite 加载记录。
四、关键实现说明
1. 身份必须来自服务端会话
消息事件中,客户端提交会话编号和正文,但不提交可信的 senderId 或 senderRole。发送者身份从已经验证的登录 session 中取得。若直接相信客户端传来的用户编号,攻击者就能冒充其他客户或客服。
加入房间时和发送消息时都检查会话权限。只在 HTTP 历史接口检查是不够的:攻击者可能跳过页面,直接构造 Socket.IO 事件。房间名称不是访问控制机制,随机或难猜的会话编号也不能代替授权。
示例中的客服角色可访问所有会话,只是为了演示。真实系统通常还要检查客服是否已被分配到该会话、所属团队是否有权访问,以及会话状态是否允许操作。
2. 数据库是历史记录的来源
实时广播适合通知当前在线的客户端,不适合承担唯一存储职责。示例先插入 messages,取得数据库生成的消息编号和时间,再广播。消息编号用作稳定的分页游标;客户端断线后重新查询历史,因此不依赖浏览器一直在线。
查询接口以消息编号而非偏移量分页。页面只请求最近 50 条,接口最多返回 100 条,并按编号倒序取数后再反转,方便按时间顺序显示。返回的 nextBefore 是更早一页可以使用的游标;需要“加载更早消息”时,用它请求 ?before=游标值&limit=50,并将结果插入列表顶部。需要注意避免把已显示的消息重复插入。
分页限制是服务端控制资源消耗的手段,不是完整的防滥用保护。会话访问权限、数据库查询成本、请求频率及并发连接都还需要根据实际业务设计。示例的 body.length <= 2000 限制针对 JavaScript UTF-16 代码单元,不是网络字节数; emoji 等字符可能占多个代码单元。如果业务限制依据是 UTF-8 字节数,应在服务端另行计算并制定一致规则。
3. 用 textContent 展示消息,避免 XSS
消息正文是用户输入,不能直接拼到 innerHTML 中。示例通过 textContent 写入 DOM,浏览器会把文本作为文本处理,而不是执行其中的 HTML 或脚本。这是此处选择的安全渲染方式,不代表所有上下文都能靠同一种转义解决;若要支持富文本,应采用经过审查的白名单解析和净化流程。
4. SQLite 与索引
消息表按递增编号保存消息,索引覆盖 (conversation_id, id),便于按会话和游标查询。示例采用 SQLite WAL 模式,适合本地演示和部分单机应用,但它仍是单机数据库文件:不能把数据库文件直接放在多个应用实例同时写入的共享目录上,就认为获得了可靠的分布式数据库。
五、安全与上线前检查
- 替换演示账号:代码里的账号和密码是公开写在源代码中的固定值,只能用于本地演示。生产环境应使用真实认证系统,密码应由成熟方案安全存储和校验,不能保留这些账号,也不能把它们作为可配置的“临时默认值”带到线上。
- 替换 session 存储:
express-session默认的内存存储不适用于生产部署,也无法在多进程之间共享。应选择满足业务要求的共享 session 存储,并设置高强度随机密钥。代码中的备用密钥仅为了本地启动方便。 - 正确设置 Cookie:演示代码的
secure: false方便通过本地 HTTP 测试。正式 HTTPS 环境应启用 Secure Cookie,并正确配置反向代理信任设置。httpOnly可减少脚本读取 Cookie 的风险,sameSite: 'lax'提供一定的跨站请求约束,但这些属性不能代替完整的 CSRF 评估。 - 校验 WebSocket 来源:若服务接受跨域连接,应明确允许的来源并在握手阶段校验;不要为了“解决连接问题”就无条件放开任意来源。跨域策略和身份认证是不同问题,来源校验也不能代替授权。
- 限制与监控:示例每个 Socket 用 300 毫秒间隔作简单节流。这不是按账号、IP 或全站计算的速率限制;用户重连或另开连接即可改变这个计数范围。生产系统还应针对连接数、发送频率、消息大小、登录失败等设置适当的服务端限制与告警。
- 文件与日志保护:聊天数据库包含用户内容。应限制数据库文件、备份和日志的访问,避免把消息正文、Cookie、session 标识等写入不必要的日志,并按业务规则删除过期记录。
- 传输与密钥:公网生产环境应使用 HTTPS/WSS,并通过环境配置管理密钥。代码里的本地 secret 不具有可供生产使用的安全强度。
六、常见错误
只用 Socket.IO,不保存数据库
这会导致用户刷新、断线或服务重启后无法可靠恢复聊天记录。Socket.IO 是实时通信层,不是历史记录数据库。应把消息保存到持久化存储,并提供受权限保护的历史查询接口。
加入房间时授权,发送时不授权
客户端可以绕过界面直接发事件,也可能尝试提交另一个会话编号。加入房间和每次执行敏感操作时都要在服务端验证身份、会话归属及业务状态。
收到消息后先显示,失败时不处理
乐观显示可以改善体验,但需要设计临时消息状态和失败回滚。这个示例等服务端确认后,由广播事件显示已保存的消息;如果追求更低延迟,可以先显示临时状态,再用服务端消息编号进行替换与去重。
把数据库写入失败当作静默丢消息
示例没有捕获所有数据库异常并返回统一错误,实际服务应处理存储故障,避免进程因未处理异常退出,并向客户端返回不泄露内部信息的错误。也要记录必要的服务端诊断信息。不要在数据库写入失败时仍广播一条“成功消息”,否则客户端和历史记录会不一致。
把 Socket.IO 重连当作补消息
重连恢复的是通信连接,不等于自动补齐断线期间遗漏的业务消息。重新连接后重新加入会话,并按最后一条已确认消息编号查询增量记录,才是可控的恢复方式。实际实现时还要用消息 ID 去重,因为实时事件和历史补拉可能包含同一条消息。
以客户端传入的消息时间排序
客户端时钟可能不准确,也可以被伪造。应以服务端或数据库产生的编号、时间作为记录依据。示例按数据库编号分页;时间字段用于展示,不应直接作为可信身份或排序游标。
七、从原型走向生产
扩展客服分配时,可以增加客服团队、坐席状态、分配记录和会话状态流转,并在每次加入房间、读取历史、发送消息时验证分配关系。消息送达确认可使用唯一消息 ID、客户端幂等键和服务端 ACK;如果业务要求在服务器故障后仍可靠投递,还要设计持久化队列或事务性发件箱,而不是只依赖进程内广播。
多实例部署时,除共享数据库和 session 外,还要使用 Socket.IO 适配器传播跨进程事件,并根据部署方式配置负载均衡与连接保持策略。适配器解决的是实例间事件传播,不会替代数据库授权、消息持久化或历史补拉。
本文示例的核心原则可以归纳为:身份由服务端确认,访问权限在服务端执行,消息先持久化再通知,历史记录通过分页接口恢复,用户内容以文本方式安全呈现。先把这条最小闭环做好,再按业务补齐客服分配、可靠投递、数据保留与多实例能力,通常比一开始堆叠大量实时功能更稳妥。








