在线客服系统源码二次开发入门:如何新增工单和会话管理功能

在线客服系统源码二次开发入门:如何新增工单和会话管理功能

在线客服系统源码二次开发入门:如何新增工单和会话管理功能

给已有在线客服系统增加工单和会话功能,看上去像是“加两张表、写几个接口”,实际要先厘清数据归属、用户权限和状态流转。否则常见结果是:用户能看见别人的会话,工单状态被任意修改,聊天记录在并发时顺序混乱,或者关闭会话后仍可继续发送消息。

本文以常见的 Node.js、Express 和 SQLite 为例,说明如何把功能拆成可维护的模块。示例包含数据表、创建工单、开启会话、读取消息和发送消息等核心代码。代码重点展示服务端逻辑;接入现有源码时,仍需复用项目已有的登录认证、角色权限、日志和错误处理机制。

一、先区分工单和会话

会话适合处理正在进行的即时沟通:客户发消息,客服回复,会话可以分配给客服,也可以转接、关闭。它关注消息流和当前接待状态。

工单适合处理需要跟进、排队或跨时间解决的问题:例如退款申请、故障申报、资料审核。它通常有优先级、负责人、状态、处理记录和完成时间。工单可以由客服或客户创建,也可以关联一次会话,但并非每张工单都必须有会话。

如果产品只需要实时聊天,不必为了“功能齐全”而强行建立工单;如果业务要求问题可追踪、可转派、可统计,就应把工单作为独立业务对象,而不是把会话关闭状态当作工单完成状态。两者生命周期不同,分开建模能减少后续改动。

二、动手前确认已有系统边界

二次开发前先检查源码中已有的用户表、认证方式、客服角色、消息存储和 WebSocket 服务。若项目已有会话表,不要另建一套同义表;先确认旧字段含义、历史数据和调用方,再通过迁移增加能力。特别要查清楚用户 ID 的类型和生成方式,示例里的整数 ID 不一定适合你的项目。

建议先回答以下问题:

  • 客户如何登录?客服和管理员是否使用同一身份体系?
  • 谁能创建、读取、回复、分配和关闭工单或会话?
  • 一个客户能否同时拥有多个开放会话?工单是否可关联多个会话?
  • 消息是否需要实时推送?历史消息保存多久?附件保存在哪里?
  • 系统使用哪种数据库?部署是否有多个应用实例?

下文示例把“客户只能读取自己的数据,客服可以处理有权限的会话”作为基础规则。生产环境还应落实租户隔离、团队范围、审计和数据保留策略。仅有前端隐藏按钮不构成权限控制。

三、数据模型:保留业务对象之间的边界

下面的简化模型包含工单、会话和消息。工单的 conversation_id 可为空,表示关联会话是可选的;实际业务如果允许多个会话对应一张工单,应改为单独的关联表。消息使用自增主键,便于以 ID 作为稳定分页游标。

CREATE TABLE IF NOT EXISTS tickets (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  customer_id INTEGER NOT NULL,
  subject TEXT NOT NULL,
  description TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'open'
    CHECK (status IN ('open', 'in_progress', 'resolved', 'closed')),
  priority TEXT NOT NULL DEFAULT 'normal'
    CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
  assignee_id INTEGER,
  conversation_id INTEGER,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_tickets_customer_created
  ON tickets(customer_id, created_at DESC, id DESC);

CREATE INDEX IF NOT EXISTS idx_tickets_status_updated
  ON tickets(status, updated_at DESC);

CREATE TABLE IF NOT EXISTS conversations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  customer_id INTEGER NOT NULL,
  agent_id INTEGER,
  status TEXT NOT NULL DEFAULT 'open'
    CHECK (status IN ('open', 'closed')),
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_conversations_customer_updated
  ON conversations(customer_id, updated_at DESC, id DESC);

CREATE TABLE IF NOT EXISTS messages (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  conversation_id INTEGER NOT NULL,
  sender_id INTEGER NOT NULL,
  body TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (conversation_id) REFERENCES conversations(id)
);

CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
  ON messages(conversation_id, id);

SQLite 的外键约束需要在每个数据库连接上启用,且建表顺序、删除策略应根据项目迁移工具调整。上面的示例没有让工单对话字段声明外键,是为了避免创建顺序和循环关联带来的额外约束;正式项目应根据数据库和迁移策略补齐约束,或使用独立关联表。

状态字段使用数据库约束限制可选值,但这不等于完整的状态流转校验。例如“已关闭是否允许重新打开”属于业务规则,应在服务层定义。updated_at 也不会因字段存在就自动更新,更新记录时必须显式写入,或使用数据库触发器统一处理。

四、搭建示例项目与身份边界

示例使用 Node.js 18 或更新版本、Express 和 better-sqlite3。创建项目并安装依赖:

npm init -y
npm install express better-sqlite3

以下代码可以作为一个小型示例服务的骨架。它初始化数据库、设置请求体大小限制,并检查身份对象。示例没有提供登录接口:实际项目必须由已有认证中间件在验证会话或令牌后设置 req.user。不能直接信任请求体里的 customerId、请求头里未经验证的用户 ID,或客户端自行提交的角色。

const express = require('express');
const Database = require('better-sqlite3');

const app = express();
const db = new Database('support.db');

db.pragma('foreign_keys = ON');
app.use(express.json({ limit: '32kb' }));

// 在这里先挂载项目已有的认证中间件。
// 它验证凭证后,必须设置 req.user = { id, role }。
// 例如:app.use(existingAuthenticationMiddleware);

function requireUser(req, res, next) {
  const user = req.user;
  if (!user || !Number.isSafeInteger(user.id) ||
      !['customer', 'agent', 'admin'].includes(user.role)) {
    return res.status(401).json({ error: 'unauthorized' });
  }
  next();
}

function isStaff(user) {
  return user.role === 'agent' || user.role === 'admin';
}

function parsePositiveId(value) {
  const id = Number(value);
  return Number.isSafeInteger(id) && id > 0 ? id : null;
}

如果只是把上面的 requireUser 挂上去,而没有真实认证中间件,所有请求都会得到 401,这是预期行为,不是可上线的认证方案。将用户角色从数据库或可信身份服务读取,并确保用户禁用、会话过期和权限撤销能够及时生效。

五、创建工单:字段白名单、服务端归属

客户创建工单时,客户端可以提交主题、描述和优先级,但不应自行指定 customer_id、处理状态或客服负责人。客户身份应取自已认证的 req.user。客服代客户创建的场景需要独立的授权规则和审计记录,不能简单复用客户接口并允许任意传用户 ID。

app.post('/api/tickets', requireUser, (req, res) => {
  if (req.user.role !== 'customer') {
    return res.status(403).json({ error: 'forbidden' });
  }

  const subject = typeof req.body.subject === 'string'
    ? req.body.subject.trim()
    : '';
  const description = typeof req.body.description === 'string'
    ? req.body.description.trim()
    : '';
  const priority = req.body.priority ?? 'normal';
  const allowedPriorities = ['low', 'normal', 'high', 'urgent'];

  if (subject.length < 1 || subject.length > 160) {
    return res.status(400).json({ error: 'invalid_subject' });
  }
  if (description.length < 1 || description.length > 10000) {
    return res.status(400).json({ error: 'invalid_description' });
  }
  if (!allowedPriorities.includes(priority)) {
    return res.status(400).json({ error: 'invalid_priority' });
  }

  const result = db.prepare(`
    INSERT INTO tickets (customer_id, subject, description, priority)
    VALUES (?, ?, ?, ?)
  `).run(req.user.id, subject, description, priority);

  const ticket = db.prepare(`
    SELECT id, customer_id, subject, description, status, priority,
           assignee_id, conversation_id, created_at, updated_at
    FROM tickets WHERE id = ?
  `).get(result.lastInsertRowid);

  res.status(201).json({ ticket });
});

这里的 160 和 10000 是示例应用自行设定的字符数限制,不是 Express、SQLite 或 HTTP 协议的固定上限。团队应结合数据库字段、产品需求、传输成本和存储策略确定限制。JavaScript 的字符串长度按 UTF-16 代码单元计算,不完全等同于用户感知的字符数;如需精确限制 Unicode 字符数量,应使用适合业务的计数方式。

接口使用 SQL 占位符绑定参数,避免把用户输入拼接进 SQL。参数化查询用于降低 SQL 注入风险,但不会自动处理 HTML 输出安全,也不能替代权限判断、输入长度限制或业务校验。

六、读取工单:每次查询都要带访问范围

获取单张工单时,不能先按 ID 查询,再把记录直接返回。查询条件应体现当前用户可访问的范围,避免访问其他客户的工单。管理员或客服的范围通常由团队、队列、租户等规则进一步限制,不能默认“客服角色就能读取全站全部数据”。

app.get('/api/tickets/:id', requireUser, (req, res) => {
  const id = parsePositiveId(req.params.id);
  if (!id) return res.status(400).json({ error: 'invalid_id' });

  const ticket = isStaff(req.user)
    ? db.prepare(`
        SELECT id, customer_id, subject, description, status, priority,
               assignee_id, conversation_id, created_at, updated_at
        FROM tickets WHERE id = ?
      `).get(id)
    : db.prepare(`
        SELECT id, customer_id, subject, description, status, priority,
               assignee_id, conversation_id, created_at, updated_at
        FROM tickets WHERE id = ? AND customer_id = ?
      `).get(id, req.user.id);

  if (!ticket) return res.status(404).json({ error: 'not_found' });
  res.json({ ticket });
});

对无权访问的数据返回 404,能避免接口额外暴露资源是否存在;是否统一使用 404 或 403,应遵守项目现有安全策略。列表接口也必须将客户过滤条件放进数据库查询,不能取出全量记录后再在应用代码中筛选。

七、开启会话与获取会话消息

会话创建时,客户 ID 同样来自登录身份。若产品要求一个客户只能拥有一个开放会话,应明确并发创建时的处理规则。先查询再插入在并发请求下可能创建出重复会话;可通过数据库唯一约束、事务,或数据库支持的原子语句解决。示例为了兼容不同产品规则,不擅自限制开放会话数量。

app.post('/api/conversations', requireUser, (req, res) => {
  if (req.user.role !== 'customer') {
    return res.status(403).json({ error: 'forbidden' });
  }

  const result = db.prepare(`
    INSERT INTO conversations (customer_id)
    VALUES (?)
  `).run(req.user.id);

  const conversation = db.prepare(`
    SELECT id, customer_id, agent_id, status, created_at, updated_at
    FROM conversations WHERE id = ?
  `).get(result.lastInsertRowid);

  res.status(201).json({ conversation });
});

读取消息时,先在 SQL 中验证调用者是否能访问该会话,再按稳定顺序返回。下面采用 ID 游标分页,beforeId 表示只取该消息 ID 之前的记录。为了取最近一页,内部按 ID 倒序查询,返回前再反转为正序。

app.get('/api/conversations/:id/messages', requireUser, (req, res) => {
  const conversationId = parsePositiveId(req.params.id);
  const beforeId = req.query.beforeId === undefined
    ? Number.MAX_SAFE_INTEGER
    : parsePositiveId(req.query.beforeId);

  const rawLimit = req.query.limit === undefined ? 50 : Number(req.query.limit);
  if (!conversationId || !beforeId ||
      !Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) {
    return res.status(400).json({ error: 'invalid_pagination' });
  }

  const conversation = isStaff(req.user)
    ? db.prepare(`
        SELECT id FROM conversations WHERE id = ?
      `).get(conversationId)
    : db.prepare(`
        SELECT id FROM conversations WHERE id = ? AND customer_id = ?
      `).get(conversationId, req.user.id);

  if (!conversation) return res.status(404).json({ error: 'not_found' });

  const rows = db.prepare(`
    SELECT id, conversation_id, sender_id, body, created_at
    FROM messages
    WHERE conversation_id = ? AND id < ?
    ORDER BY id DESC
    LIMIT ?
  `).all(conversationId, beforeId, rawLimit);

  res.json({ messages: rows.reverse() });
});

页大小上限 100 同样是此接口的应用级选择,不是数据库的通用限制。分页参数应验证类型和范围;不能直接接受任意大的 limit,否则请求可能消耗过多内存和数据库资源。游标分页适合消息持续增长的场景;如果按时间戳分页,应加入唯一的次级排序键,避免同一时间戳导致重复或遗漏。

八、发送消息:服务端校验状态和发送者

发送消息时,不接受客户端指定 sender_id。服务器根据认证身份确定发送者,并确认其有权参与该会话。下面允许会话客户本人和客服发送;真实产品还应检查客服是否属于该租户、队列或被分配的团队。

app.post('/api/conversations/:id/messages', requireUser, (req, res) => {
  const conversationId = parsePositiveId(req.params.id);
  const body = typeof req.body.body === 'string'
    ? req.body.body.trim()
    : '';

  if (!conversationId) {
    return res.status(400).json({ error: 'invalid_id' });
  }
  if (body.length < 1 || body.length > 4000) {
    return res.status(400).json({ error: 'invalid_message' });
  }

  const conversation = db.prepare(`
    SELECT id, customer_id, status
    FROM conversations WHERE id = ?
  `).get(conversationId);

  if (!conversation) return res.status(404).json({ error: 'not_found' });

  const customerOwnsIt =
    req.user.role === 'customer' &&
    conversation.customer_id === req.user.id;

  if (!customerOwnsIt && !isStaff(req.user)) {
    return res.status(404).json({ error: 'not_found' });
  }
  if (conversation.status !== 'open') {
    return res.status(409).json({ error: 'conversation_closed' });
  }

  const insert = db.prepare(`
    INSERT INTO messages (conversation_id, sender_id, body)
    VALUES (?, ?, ?)
  `);
  const update = db.prepare(`
    UPDATE conversations
    SET updated_at = CURRENT_TIMESTAMP
    WHERE id = ? AND status = 'open'
  `);

  const sendMessage = db.transaction(() => {
    const result = insert.run(conversationId, req.user.id, body);
    update.run(conversationId);
    return db.prepare(`
      SELECT id, conversation_id, sender_id, body, created_at
      FROM messages WHERE id = ?
    `).get(result.lastInsertRowid);
  });

  const message = sendMessage();
  res.status(201).json({ message });
});

app.listen(3000, () => {
  console.log('Support API listening on port 3000');
});

这段代码可以保存为 server.js 并通过 node server.js 启动服务,但在没有真实认证中间件时接口会返回 401。数据库建表语句应在启动前执行,或改为项目的正式迁移机制。示例的身份字段、角色名称和数据库连接都需要按现有项目适配。

消息写入和会话更新时间放在同一事务中,避免一项成功、一项失败造成状态不一致。需要注意:示例先读取会话状态,再启动写事务。在高并发关闭和发送场景下,生产实现应在同一事务中重新校验状态,并根据数据库的事务隔离与锁定语义处理竞争条件。不能假设一次普通读取就能阻止另一请求同时关闭会话。

九、状态流转、实时推送与幂等

新增“关闭会话”“分配客服”“更新工单状态”等接口时,建议按动作设计,而不是允许客户端提交任意状态字段。比如客户是否有权关闭会话、客服是否能重开工单、哪些状态可转到 resolved,都应由服务端状态机判断。更新时使用带旧状态条件的 SQL,并检查受影响行数,可以减少并发覆盖;需要强一致时,还应将读取、判断、更新放入事务。

实时推送通常通过 WebSocket 或消息队列实现。建议先持久化消息,再向会话参与者推送;推送失败可以重试,不能因为发送某个在线通知失败就丢失已提交消息。多实例部署时,进程内事件机制无法自动通知其他实例,需要共享消息代理或其他跨实例方案。连接建立和订阅频道时也必须检查会话权限,不能只在 HTTP 接口做授权。

网络超时可能使客户端无法判断请求是否成功,随后重试而产生重复工单或重复消息。对有重试需求的写操作,可设计幂等键:由客户端生成唯一请求标识,服务端在数据库中记录该标识及处理结果,并建立唯一约束。幂等键应绑定用户和操作范围,不能只靠前端按钮禁用来防重复。

十、输出安全、附件和隐私

示例将消息正文作为普通文本存储。前端展示时应按文本节点写入,或使用框架默认的文本转义能力;不要把消息直接拼进 innerHTML。SQL 参数化不能防止跨站脚本,HTML 转义也不能防止 SQL 注入,两者解决的是不同问题。若产品允许富文本,应采用经过审查的 HTML 清洗策略,并限制允许的标签和属性。

附件应存储在对象存储或受控文件服务中,数据库仅记录经过校验的元数据和存储键。不要仅依赖扩展名判断文件类型;还需考虑文件大小、内容检测、恶意软件扫描、访问权限和下载链接有效期。用户私密信息不应出现在普通应用日志中,工单和会话查询也应遵循最小权限与数据保留要求。

十一、常见错误检查表

  • 相信请求体里的用户 ID:客户可以伪造 ID。数据归属必须来自服务端验证过的身份。
  • 只在前端做权限控制:直接调用接口仍可能越权。每个读取和写入接口都要验证资源范围。
  • 把客服角色当作全局权限:多租户或多团队系统需要检查租户、队列、分配关系等边界。
  • SQL 字符串拼接:用户输入进入 SQL 时使用参数绑定;动态列名、排序方向则应使用服务端白名单。
  • 列表不分页:消息和工单会不断增长,接口需要有上限和稳定排序。
  • 用时间戳作为唯一顺序:相同时间戳可能造成分页遗漏,应使用 ID 等次级排序键。
  • 接受任意状态值:校验允许的状态和合法流转,并考虑并发更新。
  • 以为 SQLite 外键默认启用:需对连接设置外键检查;其他数据库则按各自配置确认。
  • 把推送当作消息存储:实时通道不保证长期可靠,消息持久化和通知传输应分开处理。
  • 只测成功流程:还要测试越权读取、关闭后发送、非法分页、超长输入、重复提交和并发状态更新。

十二、落地顺序建议

首次改造可按以下顺序推进:先确认现有认证和数据模型,再编写数据库迁移;随后实现创建、读取等最小接口,并补上权限范围;接着加入状态更新、消息发送和分页;最后接入实时推送、附件、审计和运营统计。每一步都应配合自动化测试,并在测试环境验证旧数据迁移和回滚方案。

工单和会话功能的核心不在于接口数量,而在于每条数据由谁拥有、谁可以看、状态如何变化,以及失败后如何恢复。把这些边界放在数据模型和服务端逻辑里,再逐步接入现有登录、客服工作台和消息系统,二次开发才能既可用,也方便后续维护。

© 版权声明

相关文章