
在线客服系统源代码怎么搭建:从项目结构到本地运行
在线客服系统看起来像一个聊天窗口,真正需要处理的却不只是“把消息发过去”:访客要进入会话,客服要看到待处理队列,消息要路由到正确会话,输入还要受到限制。若再加入账号、历史记录、文件传输与多客服分配,复杂度会继续增加。
本文从一个能在本地运行的最小示例开始,使用 Node.js、Express 和 Socket.IO 实现访客发起会话、客服查看队列并回复的基本流程。它适合学习实时通信、验证产品原型或作为内部演示的起点;不适合直接作为生产系统部署。示例刻意不实现登录、数据库、客服排班和消息持久化,相关边界会在后文说明。
一、先明确要搭建的是什么
“在线客服系统源代码”可能指完整的客服平台,也可能只是网站里的聊天组件。两者的范围差异很大。本文实现的是一个最小的实时客服闭环:
- 访客填写称呼,创建一条会话。
- 客服端能看到当前待处理会话。
- 访客发送消息,客服端收到对应会话的消息。
- 客服回复,访客端实时收到消息。
本例适合在本机学习 Socket.IO 事件、会话房间和前后端协作,也可以用来演示交互流程。它没有身份认证和数据持久化,访客与客服页面都可由任何访问者打开;服务器重启后,会话和消息全部消失。因此不要把它直接接入真实客户数据,也不要仅靠隐藏客服页面来保护客服功能。
二、运行环境与项目结构
需要安装受支持版本的 Node.js。可先用以下命令检查环境:
node --version
npm --version
创建项目目录,初始化 npm,并安装 Express 与 Socket.IO:
mkdir online-support-demo
cd online-support-demo
npm init -y
npm install express socket.io
项目结构如下:
online-support-demo/
├── package.json
├── server.js
└── public/
├── customer.html
└── agent.html
Express 用来提供静态页面,Socket.IO 用来传输实时事件。两个页面通过同一个服务访问,因而默认使用同源连接,不需要额外配置跨域白名单。
三、编写服务端:会话、队列与消息路由
在项目根目录新建 server.js。服务器为每条新会话生成随机 ID,并把访客连接加入以该 ID 命名的房间。客服连接加入 agents 房间,用于接收新会话和访客消息。
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const { randomUUID } = require('crypto');
const path = require('path');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
const PORT = Number(process.env.PORT) || 3000;
const tickets = new Map();
app.use(express.static(path.join(__dirname, 'public')));
function cleanText(value, maxLength) {
if (typeof value !== 'string') return '';
return value.trim().slice(0, maxLength);
}
function publicTicket(ticket) {
return {
id: ticket.id,
name: ticket.name,
createdAt: ticket.createdAt
};
}
function ticketList() {
return Array.from(tickets.values()).map(publicTicket);
}
io.on('connection', (socket) => {
socket.on('customer:start', (payload, acknowledge) => {
const name = cleanText(payload && payload.name, 40);
if (!name) {
if (typeof acknowledge === 'function') {
acknowledge({ ok: false, error: '请填写称呼(最多 40 个字符)' });
}
return;
}
const ticket = {
id: randomUUID(),
name,
ownerSocketId: socket.id,
createdAt: new Date().toISOString()
};
tickets.set(ticket.id, ticket);
socket.join(ticket.id);
if (typeof acknowledge === 'function') {
acknowledge({ ok: true, ticket: publicTicket(ticket) });
}
io.to('agents').emit('queue:update', ticketList());
});
socket.on('customer:message', (payload) => {
const ticketId = cleanText(payload && payload.ticketId, 80);
const text = cleanText(payload && payload.text, 2000);
const ticket = tickets.get(ticketId);
if (!ticket || ticket.ownerSocketId !== socket.id || !text) return;
const message = {
ticketId,
sender: 'customer',
text,
sentAt: new Date().toISOString()
};
io.to(ticketId).emit('message:new', message);
io.to('agents').emit('message:new', {
...message,
customerName: ticket.name
});
});
socket.on('agent:join', () => {
// 仅用于本地演示:此处没有验证客服身份。
socket.data.isAgent = true;
socket.join('agents');
socket.emit('queue:update', ticketList());
});
socket.on('agent:reply', (payload) => {
if (!socket.data.isAgent) return;
const ticketId = cleanText(payload && payload.ticketId, 80);
const text = cleanText(payload && payload.text, 2000);
const ticket = tickets.get(ticketId);
if (!ticket || !text) return;
io.to(ticketId).emit('message:new', {
ticketId,
sender: 'agent',
text,
sentAt: new Date().toISOString()
});
});
});
server.listen(PORT, () => {
console.log(`客服演示服务已启动:http://localhost:${PORT}`);
});
这里的限制是示例代码主动实施的校验,而不是 Socket.IO 或 Node.js 的默认限制:称呼经过 trim() 后最多保留 40 个 JavaScript 字符,消息最多保留 2000 个 JavaScript 字符,事件负载中的会话 ID 最多保留 80 个字符。超过上限的文本会被截断,不是拒绝整个连接。JavaScript 字符长度也不等于 UTF-8 字节数;若生产系统要限制请求体或存储大小,还应单独按字节数、速率和业务规则校验。
客服发送消息前,服务器会检查该 Socket 是否执行过 agent:join。这只是流程标记,不是认证:任何人都能调用这个事件,把自己的连接标成客服。访客消息则额外检查会话所有者 Socket ID,避免普通连接仅凭猜测会话 ID 冒充该会话的访客。
四、访客页面
在 public/customer.html 中加入以下内容。页面先建立会话,拿到服务器生成的 ID 后才允许发送消息。演示中消息只保存在页面内存里,刷新页面就会丢失。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>访客客服窗口</title>
<h1>联系在线客服</h1>
<form id="start-form">
<label>称呼
<input id="name" maxlength="40" required>
</label>
<button>开始咨询</button>
</form>
<p id="status">请先创建会话。</p>
<ol id="messages"></ol>
<form id="message-form">
<label>消息
<input id="text" maxlength="2000" required>
</label>
<button>发送</button>
</form>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
const status = document.querySelector('#status');
const messages = document.querySelector('#messages');
let ticketId = null;
function addMessage(message) {
const item = document.createElement('li');
item.textContent = `${message.sender}:${message.text}`;
messages.appendChild(item);
}
document.querySelector('#start-form').addEventListener('submit', (event) => {
event.preventDefault();
const name = document.querySelector('#name').value;
socket.emit('customer:start', { name }, (result) => {
if (!result || !result.ok) {
status.textContent = result && result.error
? result.error
: '无法创建会话,请重试。';
return;
}
ticketId = result.ticket.id;
status.textContent = `会话已创建:${ticketId}`;
});
});
document.querySelector('#message-form').addEventListener('submit', (event) => {
event.preventDefault();
if (!ticketId) {
status.textContent = '请先创建会话。';
return;
}
const input = document.querySelector('#text');
const text = input.value.trim();
if (!text) return;
socket.emit('customer:message', { ticketId, text });
input.value = '';
});
socket.on('message:new', (message) => {
if (message.ticketId === ticketId) addMessage(message);
});
</script>
</html>
客户端的 maxlength 只改善输入体验,不能代替服务端校验,因为请求可以绕过网页直接构造。消息展示使用 textContent,而不是把用户内容拼进 innerHTML。这样,消息里的 HTML 标记会按文本显示,降低该页面因消息内容导致脚本注入的风险。
五、客服页面
在 public/agent.html 中加入客服队列和回复表单。选择一个会话后,发送消息时附带会话 ID,服务端将回复发到对应房间。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>客服工作台(本地演示)</title>
<h1>客服工作台</h1>
<p>本页面没有登录认证,不可直接用于生产。</p>
<h2>待处理会话</h2>
<ul id="queue"></ul>
<h2 id="selected">尚未选择会话</h2>
<ol id="messages"></ol>
<form id="reply-form">
<label>回复
<input id="reply" maxlength="2000" required>
</label>
<button>发送回复</button>
</form>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
const queue = document.querySelector('#queue');
const messages = document.querySelector('#messages');
let selectedTicketId = null;
function addMessage(message) {
if (message.ticketId !== selectedTicketId) return;
const item = document.createElement('li');
item.textContent = `${message.sender}:${message.text}`;
messages.appendChild(item);
}
function renderQueue(tickets) {
queue.replaceChildren();
for (const ticket of tickets) {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = `${ticket.name}(${ticket.id})`;
button.addEventListener('click', () => {
selectedTicketId = ticket.id;
document.querySelector('#selected').textContent =
`当前会话:${ticket.name}`;
messages.replaceChildren();
});
item.appendChild(button);
queue.appendChild(item);
}
}
socket.on('connect', () => {
socket.emit('agent:join');
});
socket.on('queue:update', renderQueue);
socket.on('message:new', addMessage);
document.querySelector('#reply-form').addEventListener('submit', (event) => {
event.preventDefault();
if (!selectedTicketId) return;
const input = document.querySelector('#reply');
const text = input.value.trim();
if (!text) return;
socket.emit('agent:reply', { ticketId: selectedTicketId, text });
input.value = '';
});
</script>
</html>
这个客服页面没有请求历史消息,因此客服必须在访客发消息后才会看到消息;切换会话时,页面也会清空当前列表。代码展示的是事件路由,不是完整的工作台交互。
六、启动并验证
在项目根目录执行:
node server.js
看到启动地址后,在浏览器分别打开:
http://localhost:3000/customer.html
http://localhost:3000/agent.html
推荐按以下顺序验证:
- 先打开客服页面,确保连接成功并加入客服房间。
- 再打开访客页面,填写称呼并创建会话。
- 客服队列出现新会话后,点击会话。
- 访客发送一条消息,检查客服页面是否收到。
- 客服发送回复,检查访客页面是否收到。
若页面显示连接错误,先确认服务端仍在运行、访问地址和端口正确;浏览器开发者工具的 Console 与 Network 面板可帮助定位脚本加载或 Socket.IO 握手问题。不要直接用文件管理器打开 HTML 文件:示例依赖服务器提供的 /socket.io/socket.io.js 和实时连接。
七、常见错误与排查方向
1. 使用了错误的启动目录
node server.js 要在包含 server.js 的项目根目录运行。若出现模块找不到,先检查当前路径、文件名和 npm install 是否完成。
2. 直接打开 HTML 文件
以 file:// 打开页面时,没有 Express 服务提供 Socket.IO 客户端脚本。应通过 http://localhost:3000/... 访问。
3. 忘记让客服加入队列房间
服务端只向 agents 房间广播队列与访客消息。客服连接必须发送 agent:join;示例在 Socket.IO 的 connect 事件里执行,以便断线重连后再次加入。
4. 把“收到事件”误当成“消息已保存”
socket.emit() 表示客户端尝试发送事件,不代表消息已经写入数据库或对方已经阅读。本示例没有数据库,也没有送达、已读确认和离线补发机制。
5. 只在浏览器限制输入
输入框的 maxlength 可以被绕过。服务端还要校验类型、长度、会话归属和发送权限。进一步扩展时,也要考虑事件频率、连接数量和异常负载,避免滥用。
6. 用 innerHTML 展示消息
不要把访客或客服输入当成可信 HTML 渲染。本例用 textContent 显示纯文本。若业务确实需要富文本,应采用经过审查的清洗策略,并清楚界定允许的标签和属性。
八、关键安全边界:示例不是生产认证方案
当前代码中的 socket.data.isAgent 是客户端可触发的流程标记,不是可靠的客服权限控制。真实系统必须在服务端验证登录身份,并在每次客服操作时检查其权限及会话分配关系。身份凭证应通过合适的认证流程建立;不能因为 URL 不公开、按钮被隐藏或前端没有提供某个操作,就认为权限已得到保护。
示例只验证访客 Socket 是否为会话创建者,但 Socket 断开重连会生成新的 Socket ID。由于本例没有访客身份凭证和重新绑定流程,访客刷新页面后无法继续原会话。这是示例边界,不应通过放宽校验来“修复”。正式实现应设计可靠的访客会话标识与续接机制,评估其有效期、泄露风险和服务端校验方式。
此外,生产系统通常还需要数据库持久化、会话状态与关闭流程、客服分配和并发控制、消息排序与幂等处理、断线重连后的历史补拉、速率限制、日志审计、监控告警,以及 HTTPS/WSS 部署。若服务运行在多台实例上,Socket.IO 房间状态也需要配合适当的跨实例适配器与共享数据设计;单机内的 Map 不会自动同步到其他进程。
消息内容也可能含有个人信息。应根据实际业务制定访问权限、保留期限和删除规则,控制日志中记录的内容,避免把敏感数据写入公开可见的位置。以上是设计时必须评估的事项;仅运行本文代码并不会自动获得这些安全能力。
九、从原型走向真实服务的顺序
在基本闭环跑通后,建议按业务需求逐步扩展,而不是一次引入所有组件:
- 先确定身份与权限:明确访客、客服、主管分别可以查看和操作哪些会话。
- 再确定数据模型:至少区分会话、消息、参与者和会话状态,并决定需要保存多久。
- 补足断线恢复:连接恢复后由服务端校验身份,并按会话与消息游标拉取历史,而非依赖浏览器内存。
- 加入可靠性措施:为重要操作设计确认、重试与幂等规则,避免网络抖动造成重复或丢失。
- 评估部署规模:根据连接数、消息量和可用性要求选择部署方式,并测试限流、监控和故障恢复。
核心原则是把“实时传输”和“客服业务能力”分开看。Socket.IO 能提供事件通信与房间广播,但不会替应用完成身份认证、权限判断、消息持久化、内容安全或多实例状态协调。先用小型示例理解连接与路由,再逐项补齐这些能力,才是把在线客服原型发展为可维护服务的稳妥路径。









