
在线客服系统源码二次开发入门:如何新增工单和会话管理功能
给已有在线客服系统增加工单和会话功能,看上去像是“加两张表、写几个接口”,实际要先厘清数据归属、用户权限和状态流转。否则常见结果是:用户能看见别人的会话,工单状态被任意修改,聊天记录在并发时顺序混乱,或者关闭会话后仍可继续发送消息。
本文以常见的 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 外键默认启用:需对连接设置外键检查;其他数据库则按各自配置确认。
- 把推送当作消息存储:实时通道不保证长期可靠,消息持久化和通知传输应分开处理。
- 只测成功流程:还要测试越权读取、关闭后发送、非法分页、超长输入、重复提交和并发状态更新。
十二、落地顺序建议
首次改造可按以下顺序推进:先确认现有认证和数据模型,再编写数据库迁移;随后实现创建、读取等最小接口,并补上权限范围;接着加入状态更新、消息发送和分页;最后接入实时推送、附件、审计和运营统计。每一步都应配合自动化测试,并在测试环境验证旧数据迁移和回滚方案。
工单和会话功能的核心不在于接口数量,而在于每条数据由谁拥有、谁可以看、状态如何变化,以及失败后如何恢复。把这些边界放在数据模型和服务端逻辑里,再逐步接入现有登录、客服工作台和消息系统,二次开发才能既可用,也方便后续维护。





