网站客服系统代码实战:实现实时聊天与历史记录保存

科技资讯5小时前发布 jdkjadmin
0 00
网站客服系统代码实战:实现实时聊天与历史记录保存

网站客服系统代码实战:实现实时聊天与历史记录保存

网站客服聊天看起来只是“发一句、收一句”,真正落地时却至少涉及四件事:用户身份怎么确认、消息如何实时送达、断线后怎样补回记录,以及如何避免越权读取或脚本注入。本文从一个可运行的最小示例出发,使用 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 适配器传播跨进程事件,并根据部署方式配置负载均衡与连接保持策略。适配器解决的是实例间事件传播,不会替代数据库授权、消息持久化或历史补拉。

本文示例的核心原则可以归纳为:身份由服务端确认,访问权限在服务端执行,消息先持久化再通知,历史记录通过分页接口恢复,用户内容以文本方式安全呈现。先把这条最小闭环做好,再按业务补齐客服分配、可靠投递、数据保留与多实例能力,通常比一开始堆叠大量实时功能更稳妥。

© 版权声明

相关文章