基于WEB的开源PHP在线客服系统设计与实现
在线客服系统作为现代企业与用户沟通的重要桥梁,其核心在于实现高效、稳定、可扩展的实时交互能力。基于WEB的开源PHP在线客服系统采用典型的B/S架构模式,从前端界面展示到后端服务处理,再到数据持久化存储,形成完整闭环。系统以浏览器为客户端入口,通过HTTP协议与服务器通信,结合AJAX异步机制实现无刷新交互体验。graph TDA[浏览器客户端] -->|HTTP/AJAX| B(服务器端PHP)
简介:该系统是一款基于WEB的实时在线客服解决方案,采用PHP语言开发,结合MySQL数据库与Ajax技术,实现高效、无刷新的即时通信功能。系统支持访客与客服之间的实时文本交流,提升用户互动体验与服务效率。通过模块化的文件结构与清晰的功能划分,涵盖客户端交互、会话管理、留言处理、邮件通知等核心功能,具备良好的可扩展性与定制化能力。项目开源,便于开发者二次开发与部署,适用于中小企业和个人网站快速集成在线客服功能。 
1. 在线客服系统架构概述
在线客服系统作为现代企业与用户沟通的重要桥梁,其核心在于实现高效、稳定、可扩展的实时交互能力。基于WEB的开源PHP在线客服系统采用典型的B/S架构模式,从前端界面展示到后端服务处理,再到数据持久化存储,形成完整闭环。系统以浏览器为客户端入口,通过HTTP协议与服务器通信,结合AJAX异步机制实现无刷新交互体验。
graph TD
A[浏览器客户端] -->|HTTP/AJAX| B(服务器端PHP)
B --> C[MySQL数据库]
B --> D[view/视图层]
D --> A
C --> B
PHP在动态内容生成和请求处理中具备部署简便、生态成熟的优势,支撑会话管理、消息存取等关键逻辑;MySQL则负责持久化聊天记录、用户状态等数据。 view 目录集中管理HTML模板,实现表现层与业务逻辑分离,提升可维护性。此外, INSTALL.txt 提供清晰的部署指引,增强项目的可复制性与易用性,为后续技术深入奠定基础。
2. 即时消息功能设计与实现实时通信
在现代在线客服系统中,即时消息(Instant Messaging, IM)是核心交互能力的体现。用户期望与客服之间的沟通如同面对面交谈般流畅、低延迟且无中断。然而,在基于HTTP协议的传统Web架构下,实现真正的“实时”通信面临诸多挑战。本章将深入探讨如何在一个开源PHP驱动的B/S结构客服系统中,通过合理的技术选型和机制设计,构建稳定高效的即时消息传输体系。
传统的HTTP请求-响应模式本质上是短连接、无状态的,客户端必须主动发起请求才能获取数据,服务端无法主动推送信息。这导致若要实现消息实时性,需依赖轮询或模拟长连接等技术手段。随着浏览器API的发展,WebSocket等全双工通信协议逐渐普及,为Web端提供了更优解。但在资源受限或兼容性要求较高的场景下,仍需结合项目实际权衡利弊。
为此,本章从底层通信模型出发,分析轮询、长轮询与WebSocket三者在实现成本、性能开销、可维护性和跨平台支持等方面的差异,并据此提出适用于PHP环境的轻量级实时方案。在此基础上,详细拆解客户端与客服端的消息交互流程,涵盖会话建立、并发控制、消息队列管理等关键环节。进一步地,围绕消息拉取策略、增量同步机制及已读状态更新展开具体实现路径的设计说明。最后,针对高并发访问下的系统稳定性问题,引入连接池管理、心跳检测与断线重连机制,确保系统具备良好的容错能力和资源利用率。
整个章节内容不仅关注技术实现细节,更强调系统层面的可扩展性与工程实践中的可落地性,旨在为5年以上经验的开发者提供一套完整的、可在生产环境中复用的即时通信架构参考。
2.1 即时通信的基本原理与模型选择
实现Web端即时消息的核心在于突破HTTP单向通信的限制,使服务端能在有新消息产生时及时通知客户端。目前主流的解决方案主要包括轮询(Polling)、长轮询(Long Polling)和WebSocket三种模式。每种方式都有其适用场景和技术边界,需结合系统规模、开发复杂度、服务器负载等因素进行综合评估。
2.1.1 轮询、长轮询与WebSocket机制对比分析
轮询是最简单直接的实现方式:客户端以固定时间间隔(如每2秒)向服务端发送AJAX请求,查询是否有新消息。虽然实现简单,但存在明显的性能浪费——即使没有新消息,也会频繁创建HTTP连接并返回空响应,造成大量无效IO操作。
长轮询是对普通轮询的优化。客户端发起请求后,服务端并不立即返回,而是挂起请求直到有新消息到达或超时(如30秒),再返回结果。客户端收到响应后立刻发起下一次请求,从而减少空轮询次数,提升实时性。尽管如此,它依然基于HTTP请求-响应模型,每个连接仍是一次性的,且长时间保持连接会占用服务器资源。
相比之下,WebSocket是一种真正的全双工通信协议,允许客户端和服务端在建立一次连接后持续互发消息,无需重复握手。其基于TCP传输层,使用 ws:// 或 wss:// 协议,能显著降低延迟和带宽消耗。尤其适合高频消息交互场景。
以下表格对三种机制进行了系统性对比:
| 特性 | 轮询(Polling) | 长轮询(Long Polling) | WebSocket |
|---|---|---|---|
| 实现复杂度 | 极低 | 中等 | 较高 |
| 实时性 | 差(受间隔影响) | 中等(依赖超时设置) | 高(毫秒级) |
| 服务器资源占用 | 高(频繁请求) | 中等(连接挂起) | 低(持久连接) |
| 客户端兼容性 | 所有浏览器 | 所有浏览器 | IE10+ / 主流现代浏览器 |
| 网络开销 | 高(重复Header) | 中等 | 低(精简帧头) |
| 是否支持服务端主动推送 | 否 | 伪支持(通过延迟响应) | 是 |
从上表可见,若系统面向老旧浏览器或部署环境受限(如仅支持纯PHP+MySQL,无Node.js或Socket.io支持),则长轮询仍是可行选择;而对于追求极致体验的新一代客服系统,应优先考虑集成WebSocket。
此外,还可借助 Mermaid流程图 来直观展示三类机制的工作流程差异:
graph TD
A[客户端: 发送请求] --> B{服务端: 有新消息?}
B -- 是 --> C[立即返回消息]
B -- 否 --> D[等待至超时/消息到达]
D --> E[返回消息]
C --> F[客户端处理后再次请求]
E --> F
F --> A
style A fill:#f9f,stroke:#333
style F fill:#f9f,stroke:#333
该图描述的是 长轮询 的典型工作流:客户端循环发起请求,服务端根据消息状态决定是否阻塞响应。这种“挂起-唤醒”机制有效减少了无意义请求频次,同时保留了HTTP生态的通用性。
2.1.2 基于HTTP的轻量级实时通信方案设计
考虑到许多中小型企业在部署客服系统时倾向于使用标准LAMP栈(Linux + Apache + MySQL + PHP),而PHP本身不擅长维持长连接或处理高并发I/O事件,因此不宜盲目采用WebSocket。此时,可设计一种基于HTTP的轻量级实时通信方案,结合长轮询与智能拉取策略,在有限资源下实现接近实时的用户体验。
该方案的核心思想是: 以时间为维度组织消息状态变更,客户端通过携带时间戳发起增量拉取请求,服务端基于数据库轮询判断是否有新消息生成,并在一定窗口内阻塞等待,形成类长轮询行为 。
具体流程如下:
1. 客户端首次进入聊天页面时,记录当前本地时间 t0 ;
2. 向 /api/poll.php 发起GET请求,参数包含 last_timestamp=t0 ;
3. 服务端接收请求后,执行SQL查询: sql SELECT * FROM messages WHERE thread_id = ? AND created_at > ? ORDER BY created_at ASC LIMIT 1;
4. 若查到数据,则立即返回JSON格式的新消息;
5. 若未查到,服务端进入休眠状态(如sleep(1)),每隔1秒重新查询一次,最多持续25秒;
6. 若期间有新消息写入,立即终止循环并返回;
7. 超时后返回空数组,客户端解析后重新发起下一轮请求。
这种方式既避免了高频轮询造成的资源浪费,又利用了PHP脚本的短期阻塞能力模拟“等待”效果。虽然不如WebSocket高效,但在百万级日活以下的客服系统中完全可用。
以下是客户端JavaScript实现示例:
let lastTimestamp = Date.now();
function startPolling() {
fetch(`/api/poll.php?last_timestamp=${lastTimestamp}`)
.then(response => response.json())
.then(data => {
if (data.messages && data.messages.length > 0) {
// 处理新消息
data.messages.forEach(msg => {
appendMessageToChat(msg);
lastTimestamp = Math.max(lastTimestamp, msg.created_at);
});
}
// 无论是否收到消息,继续下一轮拉取
setTimeout(startPolling, 100); // 防抖,防止过快重试
})
.catch(err => {
console.error("轮询失败:", err);
setTimeout(startPolling, 5000); // 网络异常时延长重试间隔
});
}
// 初始化启动轮询
startPolling();
代码逻辑逐行解读:
- 第1行:定义变量 lastTimestamp 用于记录上次成功获取消息的时间戳。
- 第3–15行:定义 startPolling() 函数,负责发起异步拉取请求。
- 第5行:使用 fetch 构造GET请求,携带 last_timestamp 参数。
- 第6行:将响应解析为JSON对象。
- 第7–12行:若返回消息数组非空,则遍历处理每条消息,调用 appendMessageToChat() 渲染到界面,并更新时间戳为最新值。
- 第14行:使用 setTimeout 延迟100ms后再次调用自身,形成递归轮询,避免阻塞主线程。
- 第16–19行:捕获网络错误,出错时延后5秒重试,增强健壮性。
该机制的关键优势在于:
- 低耦合 :前后端仅通过HTTP接口交互,便于独立部署;
- 易调试 :所有请求均可通过浏览器开发者工具观察;
- 可降级 :当服务端不支持长轮询时,可退化为普通定时轮询;
- 安全性强 :每次请求均可校验Session或Token,防止越权访问。
综上所述,在缺乏专业IM中间件支持的情况下,基于HTTP的长轮询+时间戳增量拉取是一种务实而有效的替代方案,特别适用于以PHP为主的传统Web应用环境。
2.2 客户端与客服端的消息交互流程
在线客服系统的本质是一个双向通信平台,涉及两类角色:访客(Client)和坐席(Agent)。两者之间需要建立起清晰的会话生命周期管理机制,包括会话发起、响应、消息传递、状态同步等多个阶段。本节将深入剖析这一完整交互链条,重点解析触发机制、会话建立过程以及多会话并发控制策略。
2.2.1 用户发起对话请求的触发机制
当访客访问企业官网并点击嵌入式聊天按钮时,前端JavaScript即开始初始化会话流程。该按钮通常由 button.php 动态生成,依据当前用户状态(是否已登录、是否已有活跃会话)返回不同HTML片段。
触发动作主要分为以下几步:
- 页面加载时注入聊天按钮DOM元素;
- 绑定点击事件监听器;
- 触发后加载聊天窗口UI组件;
- 调用
client.php初始化客户身份; - 创建新的会话线程(thread)或恢复历史会话。
其中最关键的一步是调用 client.php 完成客户识别。该脚本会检查是否存在有效的会话Cookie或LocalStorage标记,若不存在则生成唯一客户ID(如UUID),并通过Ajax提交至服务端保存。
示例代码如下:
// client.php
session_start();
if (!isset($_SESSION['client_id'])) {
$_SESSION['client_id'] = uniqid('cli_');
$_SESSION['join_time'] = time();
}
echo json_encode([
'status' => 'success',
'client_id' => $_SESSION['client_id'],
'joined' => $_SESSION['join_time']
]);
参数说明:
- $_SESSION['client_id'] :用于标识本次访问者的唯一身份;
- uniqid('cli_') :生成前缀为 cli_ 的唯一字符串,避免冲突;
- join_time :记录会话起始时间,可用于后续统计停留时长。
该脚本返回的数据将被前端用于后续所有消息请求的身份绑定。
2.2.2 客服响应与会话建立的过程解析
一旦客户发起会话,系统会在后台创建一个待处理任务。客服人员通过管理后台查看“待接入列表”,手动或自动选择接通。
会话建立的关键在于 线程绑定 。系统需确保同一客户的所有消息归属于同一个 thread_id ,以便顺序显示和归属追踪。
流程如下:
sequenceDiagram
participant C as 客户端
participant S as 服务端
participant A as 客服端
C->>S: POST /api/start_thread {client_id}
S->>S: 创建新thread记录
S-->>C: {thread_id, status:"pending"}
A->>S: GET /api/queue
S-->>A: 返回待处理thread列表
A->>S: POST /api/accept_thread {thread_id, agent_id}
S->>S: 更新thread状态为"active"
S-->>A: 确认接通
S-->>C: 推送"客服已上线"通知
上述序列图展示了完整的会话建立流程。服务端通过 thread.php 管理线程状态,字段设计建议如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| client_id | VARCHAR(64) | 客户唯一标识 |
| agent_id | INT | 客服ID,外键关联users表 |
| status | ENUM(‘pending’,’active’,’closed’) | 当前状态 |
| created_at | DATETIME | 创建时间 |
| accepted_at | DATETIME NULL | 接通时间 |
| closed_at | DATETIME NULL | 关闭时间 |
当 status='pending' 时,表示等待客服响应;一旦被接通,状态变更为 active ,双方即可自由发送消息。
2.2.3 消息队列管理与多会话并发控制
在实际运营中,一个客服可能同时处理多个客户会话。系统必须提供有效的会话切换与消息隔离机制,防止混淆。
为此,可引入“当前活跃会话”概念,前端通过Tab或侧边栏展示所有打开的会话窗口,后端则通过 thread_id 路由消息。
例如,在发送消息时,前端必须明确指定目标 thread_id :
function sendMessage(threadId, content) {
fetch('/api/send_message.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thread_id: threadId, msg: content })
}).then(res => res.json())
.then(data => console.log("发送成功", data));
}
服务端接收到请求后,先验证当前客服是否有权限操作该 thread_id ,再插入 messages 表:
INSERT INTO messages (thread_id, sender_type, sender_id, content, created_at)
VALUES (?, 'agent', ?, ?, NOW());
同时,可通过Redis缓存当前客服的活跃会话集合,提升查询效率:
$redis->sAdd("agent:{$agent_id}:threads", $thread_id);
这样在获取未读消息总数时,只需遍历集合即可快速聚合:
SELECT COUNT(*) FROM messages
WHERE thread_id IN (?) AND sender_type='client' AND is_read=0;
通过以上机制,系统实现了对多会话的有效隔离与统一调度,保障了高并发场景下的数据一致性与用户体验。
(注:由于篇幅限制,后续小节将继续深入2.3与2.4部分内容,此处暂止于2.2.3)
3. Ajax异步请求机制在聊天中的应用
现代Web在线客服系统对实时性与交互流畅度提出了极高要求,传统页面刷新式通信模式已无法满足用户期望。在此背景下,Ajax(Asynchronous JavaScript and XML)技术成为支撑动态、无刷新聊天功能的核心驱动力。本章深入探讨Ajax如何在基于PHP的开源客服系统中实现高效消息交互,涵盖其核心作用、典型应用场景、前后端协同逻辑以及性能优化策略。通过分析实际代码结构与运行流程,揭示异步请求在提升用户体验和系统响应效率方面的关键价值。
3.1 Ajax在Web客服系统中的核心作用
Ajax作为一种无需重新加载整个页面即可与服务器交换数据并更新部分网页内容的技术,在在线客服系统的构建中扮演着不可或缺的角色。尤其在高频率、低延迟的即时通讯场景下,Ajax为实现“类原生”聊天体验提供了坚实基础。
3.1.1 异步通信对用户体验的提升价值
在传统的同步请求模型中,每一次用户操作都会导致浏览器向服务器发起完整HTTP请求,并等待响应期间阻塞界面交互,最终以整页刷新的方式呈现结果。这种模式不仅打断了用户的操作连续性,也极易造成上下文丢失。例如,当访客正在输入一条较长的消息时,若因某次状态检查而触发页面重载,则输入内容极有可能被清空。
相比之下,采用Ajax后,前端JavaScript可以在后台独立发送请求,接收响应后仅更新页面局部区域(如聊天窗口中的新消息列表),从而保持当前浏览状态不变。这一特性显著提升了用户感知的系统响应速度与操作流畅度。以典型的客服对话框为例,当用户点击“发送”按钮时,系统通过Ajax将消息内容提交至服务端,成功后立即追加到本地聊天记录中,同时滚动到底部,整个过程毫秒级完成,毫无卡顿感。
更重要的是,Ajax支持并发多个请求处理。在复杂客服系统中,往往需要同时执行多项任务——比如一边轮询获取最新消息,一边上传文件,另一边还要定期上报用户在线状态。这些操作均可通过独立的Ajax调用并行执行,互不干扰,极大增强了系统的多任务处理能力。
此外,由于Ajax请求通常只传输必要数据而非完整HTML文档,网络负载大幅降低。这对于移动端或弱网环境下的访问尤为重要,能够有效减少流量消耗和延迟,提高整体可用性。
3.1.2 减少页面刷新带来的上下文丢失问题
在没有Ajax介入的传统Web表单提交流程中,用户填写完信息点击提交后,浏览器会跳转至目标URL,原有页面的所有DOM状态及临时变量随之销毁。对于客服系统而言,这意味着聊天窗口的历史消息、输入框中的草稿、未确认的操作提示等都将消失,严重影响使用连贯性。
借助Ajax,所有数据交互均在幕后完成,主页面始终保持激活状态。即使是在跨模块切换(如从留言表单跳转到实时聊天)的过程中,也可以通过动态加载内容区域来维持整体布局稳定。以下是一个简化的流程图,展示Ajax如何避免上下文丢失:
graph TD
A[用户打开客服页面] --> B[初始化聊天界面]
B --> C{用户进行操作}
C -->|发送消息| D[AJAX POST 请求发送数据]
D --> E[服务器处理并返回JSON响应]
E --> F[前端解析响应并更新聊天记录]
F --> G[保留输入框内容和其他UI状态]
C -->|查看历史记录| H[AJAX GET 获取历史消息]
H --> I[动态插入消息列表]
I --> J[页面其余部分不受影响]
该流程清晰地表明,无论何种交互行为,都不会引起页面跳转或刷新,确保了用户在整个会话周期内的操作连续性和心理安全感。这种“隐形通信”机制正是现代Web应用区别于早期静态网站的重要标志之一。
为了进一步说明其优势,我们可以通过一个具体对比表格来量化Ajax与传统同步请求之间的差异:
| 对比维度 | 传统同步请求 | 基于Ajax的异步请求 |
|---|---|---|
| 页面刷新 | 是,每次请求都刷新页面 | 否,仅局部更新 |
| 用户体验 | 中断明显,易丢失上下文 | 流畅自然,上下文保留 |
| 网络开销 | 高,需传输完整HTML | 低,仅传输JSON数据 |
| 响应时间感知 | 感知明显,等待时间长 | 快速反馈,接近实时 |
| 并发处理能力 | 差,一次只能处理一个请求 | 强,可并行多个请求 |
| 开发复杂度 | 简单但扩展性差 | 初始复杂但灵活性高 |
综上所述,Ajax不仅是技术手段上的进步,更是用户体验设计理念的一次跃迁。它使得Web客服系统能够在保持轻量级架构的同时,提供接近桌面应用程序的操作质感,是构建现代化在线服务不可或缺的基础组件。
3.2 聊天过程中典型Ajax请求的设计与调用
在实际开发中,一个完整的客服聊天流程涉及多种类型的Ajax请求,每种请求对应特定业务逻辑。本节将围绕消息发送、消息接收和非阻塞表单提交三大典型场景,详细剖析其设计思路与实现方式。
3.2.1 发送消息时的数据封装与POST请求构建
当用户在聊天输入框中输入文字并点击“发送”按钮时,系统需将消息内容安全可靠地传递给服务器。此过程通常通过 XMLHttpRequest 或更现代的 fetch API 发起POST请求完成。以下是一个典型的JavaScript实现示例:
function sendMessage(threadId, messageText) {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'api/send_message.php', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
appendMessageToChat(response.data); // 更新UI
} else {
showErrorMessage(response.error);
}
} catch (e) {
console.error("JSON解析失败:", e);
}
}
};
xhr.send(`thread_id=${encodeURIComponent(threadId)}&message=${encodeURIComponent(messageText)}`);
}
代码逻辑逐行解读:
function sendMessage(...):定义发送消息函数,接受会话ID和消息文本作为参数。const xhr = new XMLHttpRequest():创建一个新的Ajax请求对象。xhr.open('POST', 'api/send_message.php', true):配置请求方式为POST,目标地址为后端处理脚本,true表示异步执行。setRequestHeader:设置请求头,告知服务器数据格式为表单编码。onreadystatechange:监听状态变化事件,当请求完成且状态码为200时处理响应。JSON.parse:尝试解析服务器返回的JSON数据。appendMessageToChat:成功时调用UI更新函数。showErrorMessage:失败时显示错误提示。xhr.send(...):发送经过URL编码的数据,防止特殊字符引发问题。
该请求所对应的PHP接口 send_message.php 大致如下:
<?php
header('Content-Type: application/json');
session_start();
if ($_POST['thread_id'] && $_POST['message']) {
$threadId = intval($_POST['thread_id']);
$message = htmlspecialchars(trim($_POST['message']), ENT_QUOTES, 'UTF-8');
// 模拟数据库插入
$result = insertMessage($threadId, $message, $_SESSION['user_id']);
if ($result) {
echo json_encode([
'success' => true,
'data' => [
'id' => $result['id'],
'content' => $message,
'sender' => 'customer',
'timestamp' => date('Y-m-d H:i:s')
]
]);
} else {
echo json_encode(['success' => false, 'error' => '数据库写入失败']);
}
} else {
echo json_encode(['success' => false, 'error' => '参数缺失']);
}
?>
参数说明:
- thread_id :标识当前会话线程,用于绑定消息归属。
- message :用户输入的原始消息内容,经 htmlspecialchars 过滤XSS攻击。
- session :验证用户身份合法性。
- 返回JSON包含 success 标志位及必要数据字段,便于前端判断处理。
3.2.2 接收消息时的轮询请求设置与响应解析
由于并非所有环境都支持WebSocket,许多轻量级客服系统仍依赖定时轮询(Polling)机制获取新消息。以下是实现自动拉取消息的JavaScript代码:
let lastTimestamp = Date.now();
function pollNewMessages(threadId) {
setInterval(() => {
fetch(`api/poll_messages.php?thread_id=${threadId}&since=${lastTimestamp}`)
.then(res => res.json())
.then(data => {
if (data.messages && data.messages.length > 0) {
data.messages.forEach(msg => {
appendMessageToChat(msg);
});
lastTimestamp = Date.now(); // 更新时间戳
}
})
.catch(err => console.error("轮询出错:", err));
}, 3000); // 每3秒检查一次
}
该方案通过定期GET请求查询自上次以来是否有新消息到达,适用于兼容性要求较高的项目。虽然不如长轮询或WebSocket高效,但在中小型系统中足够胜任。
3.2.3 表单提交(如留言)的非阻塞处理方式
除了实时聊天外,客服系统常设有离线留言功能。此类表单提交同样可通过Ajax实现无刷新处理:
<form id="leaveMessageForm">
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="content" required></textarea>
<button type="submit">提交留言</button>
</form>
<script>
document.getElementById('leaveMessageForm').addEventListener('submit', function(e) {
e.preventDefault(); // 阻止默认提交
const formData = new FormData(this);
fetch('leavemessage.php', {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(res => {
if (res.success) alert('留言成功!');
else alert('提交失败:' + res.error);
});
});
</script>
这种方式既保证了数据提交的安全性,又避免了页面跳转带来的中断体验,完美契合现代Web交互标准。
(后续章节将继续展开3.3与3.4节内容,包括JSON数据交换规范、错误码体系设计、请求频率控制算法、缓存策略等深度实践细节,结合更多代码示例、表格对比与流程图建模,全面揭示Ajax在真实生产环境中的工程化落地路径。)
4. PHP服务器端逻辑处理与请求响应
在基于WEB的开源PHP在线客服系统中,服务端是整个通信链条的核心枢纽。无论是用户发起对话、客服接收消息,还是留言提交、邮件通知触发等操作,最终都依赖于PHP脚本对HTTP请求的解析、业务逻辑的执行以及结构化数据的返回。本章节将深入剖析系统中关键PHP脚本的功能职责划分,分析主入口文件 index.php 的路由调度机制,并探讨服务端安全性保障措施及会话状态管理策略,全面揭示服务端如何高效、安全地支撑高并发实时交互场景。
4.1 核心PHP脚本的功能职责划分
现代Web应用强调模块化设计与单一职责原则,PHP作为服务端语言,在该在线客服系统中被划分为多个功能独立的脚本文件,每个脚本专注于特定的业务流程。这种解耦架构不仅提升了代码可维护性,也便于后期扩展和性能优化。以下是对系统中五个核心PHP脚本的详细解析。
4.1.1 client.php:客户会话初始化与身份识别
当访客首次访问网站并点击聊天按钮时,前端会向 client.php 发送初始化请求。该脚本负责创建或恢复客户会话,生成唯一标识符(如 client_id ),并通过Session或Token机制维持其身份状态。
<?php
session_start();
// 初始化客户端ID
if (!isset($_SESSION['client_id'])) {
$_SESSION['client_id'] = 'cli_' . uniqid();
}
// 返回JSON格式响应
header('Content-Type: application/json');
echo json_encode([
'status' => 'success',
'client_id' => $_SESSION['client_id'],
'timestamp' => time()
]);
?>
逻辑逐行解读:
- 第2行:启动PHP Session,用于跨请求保持用户状态。
- 第5–7行:检查是否已存在
client_id,若不存在则使用uniqid()生成一个以cli_为前缀的唯一ID。 - 第10–12行:设置响应头为JSON类型,并输出包含客户端ID和时间戳的结果对象。
此脚本虽简洁,但承担了身份锚定的关键任务。后续所有消息发送与接收均需携带该 client_id ,以便服务端准确归属会话内容。
4.1.2 thread.php:会话线程管理与消息归属绑定
thread.php 用于管理客户与客服之间的会话线程。每当新对话开始,系统需创建一个新的会话记录,将其关联到特定客户与客服,并维护其生命周期状态(如“等待中”、“进行中”、“已关闭”)。
| 字段名 | 类型 | 描述 |
|---|---|---|
thread_id |
VARCHAR(32) | 会话唯一标识 |
client_id |
VARCHAR(32) | 客户端ID |
operator_id |
INT | 客服人员ID |
status |
ENUM | 状态:pending, active, closed |
created_at |
DATETIME | 创建时间 |
updated_at |
DATETIME | 最后更新时间 |
该脚本通过接收POST请求参数来判断操作类型:
<?php
require_once 'db.php';
$action = $_POST['action'] ?? '';
$client_id = $_POST['client_id'] ?? '';
switch ($action) {
case 'create':
$thread_id = 'thr_' . substr(md5(uniqid()), 0, 16);
$sql = "INSERT INTO threads (thread_id, client_id, status, created_at)
VALUES (?, ?, 'pending', NOW())";
$stmt = $pdo->prepare($sql);
$stmt->execute([$thread_id, $client_id]);
echo json_encode(['thread_id' => $thread_id]);
break;
case 'get_status':
$sql = "SELECT status, operator_id FROM threads WHERE client_id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$client_id]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
echo json_encode($result ?: ['status' => 'not_found']);
break;
}
?>
参数说明:
$action:指定操作行为,支持create(创建会话)、get_status(查询状态)。$client_id:来自客户端的身份标识,用于绑定会话。
逻辑分析:
- 使用预处理语句防止SQL注入。
md5(uniqid())生成更安全的thread_id。- 查询结果以JSON形式返回,供前端动态更新UI状态。
4.1.3 leavemessage.php:离线留言处理流程
当无客服在线时,系统自动引导用户填写离线留言表单。 leavemessage.php 接收表单数据,验证合法性后存入数据库,并可选触发邮件通知。
<?php
$name = filter_input(INPUT_POST, 'name', FILTER_SANITIZE_STRING);
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$message = filter_input(INPUT_POST, 'message', FILTER_SANITIZE_STRING);
if (!$name || !$email || !$message) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input data']);
exit;
}
$sql = "INSERT INTO messages (sender_type, sender_id, content, is_offline, created_at)
VALUES ('guest', ?, ?, 1, NOW())";
$stmt = $pdo->prepare($sql);
$stmt->execute([$email, $message]);
// 触发邮件通知(异步调用mail.php)
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-type: application/x-www-form-urlencoded',
'content' => http_build_query(['to' => ADMIN_EMAIL, 'subject' => 'New Offline Message', 'body' => "$name ($email): $message"])
]
]);
file_get_contents("http://localhost/mail.php", false, $context);
echo json_encode(['status' => 'success']);
?>
扩展说明:
- 使用
filter_input进行输入过滤,避免XSS风险。 is_offline=1标记为离线消息,便于后台分类处理。- 利用
file_get_contents配合流上下文实现非阻塞式HTTP请求,提升响应速度。
4.1.4 mail.php:邮件通知触发与SMTP集成
该脚本专责邮件发送功能,常由其他模块异步调用。它封装了PHPMailer库或原生 mail() 函数,确保告警、留言提醒等信息及时送达管理员邮箱。
use PHPMailer\PHPMailer\PHPMailer;
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = SMTP_HOST;
$mail->SMTPAuth = true;
$mail->Username = SMTP_USER;
$mail->Password = SMTP_PASS;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->setFrom('no-reply@site.com', 'Support System');
$mail->addAddress($_POST['to']);
$mail->Subject = $_POST['subject'];
$mail->Body = $_POST['body'];
$mail->send();
echo json_encode(['status' => 'email_sent']);
} catch (Exception $e) {
error_log("Mail error: " . $mail->ErrorInfo);
http_response_code(500);
echo json_encode(['error' => 'Failed to send email']);
}
参数说明:
SMTP_*为配置常量,应在全局配置文件中定义。- 支持TLS加密传输,符合现代邮件服务器要求。
此脚本体现了“关注点分离”的设计思想——不直接参与业务流程,而是作为独立的服务组件被调用。
4.1.5 button.php:嵌入式聊天按钮状态控制
前端聊天按钮需根据客服在线状态动态显示“在线”或“离线”。 button.php 提供轻量级接口返回当前客服状态,供JavaScript轮询调用。
<?php
// 检查最近5分钟内有无活跃客服
$sql = "SELECT COUNT(*) as count FROM operators WHERE last_active > DATE_SUB(NOW(), INTERVAL 5 MINUTE)";
$stmt = $pdo->prepare($sql);
$stmt->execute();
$result = $stmt->fetch();
$status = $result['count'] > 0 ? 'online' : 'offline';
header('Content-Type: application/json');
echo json_encode(['status' => $status]);
?>
流程图示意(Mermaid):
graph TD
A[前端JS定时请求] --> B(button.php)
B --> C{查询operators表}
C --> D[判断last_active时间]
D --> E[返回online/offline]
E --> F[前端更新按钮样式]
该机制实现了低开销的状态感知,避免频繁查询完整会话列表,仅聚焦于可用性判断。
4.2 index.php主入口文件的路由调度机制
在一个模块众多的系统中, index.php 作为统一入口,承担着请求分发的核心职责。通过解析URL参数或POST数据,决定调用哪个处理模块,从而实现集中管控与灵活扩展。
4.2.1 请求参数解析与模块分发逻辑
典型的RESTful风格路由可通过 ?module=xxx&action=yyy 的形式实现分发。 index.php 首先提取这些参数,然后映射到对应控制器。
<?php
// index.php - 统一入口
require_once 'config.php';
require_once 'security.php';
$module = $_GET['module'] ?? $_POST['module'] ?? 'default';
$action = $_GET['action'] ?? $_POST['action'] ?? 'index';
$router = [
'client' => 'client.php',
'thread' => 'thread.php',
'message' => 'message.php',
'leave' => 'leavemessage.php',
'mail' => 'mail.php',
'button' => 'button.php'
];
if (array_key_exists($module, $router)) {
include $router[$module];
} else {
http_response_code(404);
echo json_encode(['error' => 'Module not found']);
}
?>
逻辑分析:
- 第4–5行:优先从GET获取参数,未果则尝试POST,增强兼容性。
- 第7–13行:定义路由映射数组,清晰表达模块与文件的对应关系。
- 第15–18行:若匹配成功则包含对应脚本,否则返回404错误。
该设计模式类似于微内核架构, index.php 仅做调度,具体逻辑下沉至各子模块,极大提升系统的可测试性和可替换性。
4.2.2 全局配置加载与安全过滤初始化
在路由之前,必须完成基础环境准备。 config.php 和 security.php 分别负责配置加载与安全防护。
// config.php
define('DB_HOST', 'localhost');
define('DB_USER', 'support_user');
define('DB_PASS', 'secure_password');
define('ADMIN_EMAIL', 'admin@company.com');
define('SMTP_HOST', 'smtp.gmail.com');
define('UPLOAD_DIR', '/var/www/uploads/');
// security.php
function sanitize_input($data) {
$data = trim($data);
$data = stripslashes($data);
$data = htmlspecialchars($data, ENT_QUOTES, 'UTF-8');
return $data;
}
// 防止直接访问敏感脚本
if (!defined('ENTRY_POINT')) {
die('Access denied');
}
define('ENTRY_POINT', true);
参数说明:
- 所有敏感信息应通过配置文件集中管理,禁止硬编码于业务逻辑中。
sanitize_input()函数用于清理用户输入,防范XSS攻击。ENTRY_POINT常量防止外部绕过入口直接调用内部脚本。
该机制构成了服务端的第一道防线,确保任何请求都经过标准化处理路径。
4.3 服务端安全性保障措施
随着Web攻击手段日益复杂,PHP服务端必须构建多层次防御体系。以下重点讨论XSS、CSRF防护及输入验证策略。
4.3.1 XSS与CSRF防护机制实现
跨站脚本(XSS)和跨站请求伪造(CSRF)是Web系统最常见的威胁。系统采用双重防御策略:
// 在输出HTML时转义
echo htmlspecialchars($user_content, ENT_QUOTES, 'UTF-8');
// CSRF Token生成与验证
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// 表单中插入隐藏字段
echo '<input type="hidden" name="csrf_token" value="' . $_SESSION['csrf_token'] . '">';
// 提交时验证
if ($_POST['csrf_token'] !== $_SESSION['csrf_token']) {
die('CSRF token mismatch');
}
表格:常见攻击类型及其防御方法
| 攻击类型 | 危害表现 | 防御措施 |
|---|---|---|
| XSS | 注入恶意脚本窃取Cookie | 输出转义、CSP策略 |
| CSRF | 伪造用户请求 | Token验证、SameSite Cookie设置 |
| SQL注入 | 数据泄露或篡改 | 预处理语句、输入过滤 |
| 文件上传漏洞 | 植入后门 | 白名单校验、隔离存储目录 |
4.3.2 输入验证与SQL注入防范策略
所有外部输入均视为不可信来源。系统强制使用PDO预处理语句,从根本上杜绝拼接SQL的风险。
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? AND status = ?");
$stmt->execute([$_POST['email'], 'active']);
$user = $stmt->fetch();
参数说明:
?占位符替代变量插值,避免字符串拼接。- 执行时传入数组参数,由PDO自动转义。
此外,结合正则表达式对手机号、邮箱等字段进行格式校验,进一步提高数据质量。
4.4 会话状态管理与用户身份维持
4.4.1 PHP Session机制的应用与局限
默认情况下,PHP使用文件存储Session数据,路径通常为 /tmp 。对于小型系统足够使用,但在分布式环境中存在明显缺陷:
- 共享问题 :多台服务器无法共享同一Session文件。
- 性能瓶颈 :磁盘I/O成为高频访问瓶颈。
- 清理延迟 :Session过期清理不及时。
解决方案包括:
- 将Session存储改为Redis或Memcached。
- 设置合理的
session.gc_maxlifetime和session.cookie_lifetime。
; php.ini 配置建议
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379"
session.gc_maxlifetime = 1800
4.4.2 自定义Token机制增强跨域兼容性
为了支持前后端分离部署或移动端接入,系统引入JWT(JSON Web Token)作为补充认证方式。
function generate_jwt($payload) {
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$payload = json_encode($payload);
$base64Header = base64url_encode($header);
$base64Payload = base64url_encode($payload);
$signature = hash_hmac('sha256', $base64Header . "." . $base64Payload, JWT_SECRET, true);
$base64Signature = base64url_encode($signature);
return "$base64Header.$base64Payload.$base64Signature";
}
// 中间件验证Token
$token = get_bearer_token();
$parts = explode('.', $token);
if (count($parts) !== 3) die('Invalid token');
if (!verify_signature($parts[0], $parts[1], $parts[2], JWT_SECRET)) die('Tampered token');
优势:
- 无状态,适合水平扩展。
- 可携带自定义声明(如角色、权限)。
- 支持跨域、跨平台认证。
流程图(Mermaid):
sequenceDiagram
participant Client
participant Server
Client->>Server: POST /login {email, pass}
Server->>Client: 返回JWT Token
Client->>Server: 带Token请求API (/api/chat)
Server->>Server: 解码并验证签名
Server->>Client: 返回聊天数据
综上所述,PHP服务端不仅是数据处理中心,更是安全控制、状态管理和业务协调的关键节点。通过对核心脚本的精细化分工、统一入口的智能路由、严格的安全防护以及灵活的会话机制设计,系统能够在高并发、多变环境下稳定运行,为用户提供无缝、可信的在线客服体验。
5. MySQL数据库设计与聊天数据存储
5.1 数据库表结构设计原则与范式应用
在基于PHP的在线客服系统中,MySQL作为核心的数据持久化引擎,承担着消息记录、会话状态、用户身份等关键信息的存储职责。良好的数据库设计不仅影响系统的性能表现,还直接关系到后续扩展性与维护成本。本系统遵循第三范式(3NF)基本原则,在确保数据冗余最小化的前提下,通过合理的外键关联实现逻辑清晰的数据模型。
5.1.1 聊天消息表(messages)字段设计与索引优化
messages 表是整个系统中最频繁读写的表之一,用于存储每一次用户与客服之间的文本交互内容。其典型结构如下:
CREATE TABLE `messages` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`thread_id` INT NOT NULL COMMENT '会话ID,关联threads.id',
`sender_type` ENUM('client', 'agent') NOT NULL COMMENT '发送者类型',
`sender_id` INT DEFAULT NULL COMMENT '发送者用户ID(可为空)',
`content` TEXT NOT NULL COMMENT '消息正文',
`status` ENUM('sent', 'delivered', 'read') DEFAULT 'sent' COMMENT '消息状态',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_thread_created (`thread_id`, `created_at`),
INDEX idx_status_sender (`status`, `sender_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- 字段说明 :
id:全局唯一标识,使用BIGINT防止高并发场景下整型溢出。thread_id:与threads表建立连接,支持快速查询某一会话的所有消息。sender_type+sender_id:灵活区分客户与客服,便于权限控制和展示逻辑处理。status字段支持“已发送”、“已送达”、“已读”三态同步,提升用户体验感知。- 索引策略 :
- 组合索引
(thread_id, created_at)支持按会话拉取消息时的高效范围扫描。 - 状态与发送者类型的联合索引适用于后台未读消息统计与推送判断。
5.1.2 会话记录表(threads)与关联关系建模
每个聊天窗口对应一个独立的会话线程,由 threads 表统一管理:
CREATE TABLE `threads` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`client_session_id` VARCHAR(64) NOT NULL COMMENT '客户端会话标识',
`agent_id` INT DEFAULT NULL COMMENT '分配的客服ID',
`status` ENUM('open', 'closed', 'pending') DEFAULT 'open',
`start_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`end_time` TIMESTAMP NULL DEFAULT NULL,
`last_activity` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_session (client_session_id),
INDEX idx_agent_status (agent_id, status),
INDEX idx_last_activity (last_activity)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
该表实现了会话生命周期管理,并通过 client_session_id 实现无登录状态下的匿名会话追踪。 agent_id 允许动态绑定客服人员,支持多客服轮询分配机制。
5.1.3 用户信息表(users)与权限分级设计
为区分管理员、客服代表与访客角色,设计 users 表如下:
| id | username | password_hash | role | created_at | |
|---|---|---|---|---|---|
| 1 | admin | $2y$… | admin | admin@site.com | 2025-01-01 10:00:00 |
| 2 | support01 | $2y$… | agent | s1@site.com | 2025-01-02 09:30:00 |
| 3 | marketing | $2y$… | readonly | mkt@site.com | 2025-01-03 11:15:00 |
| 4 | techlead | $2y$… | agent | lead@site.com | 2025-01-04 14:20:00 |
| 5 | guest_user | NULL | client | NULL | 2025-01-05 08:45:00 |
| 6 | backup_op | $2y$… | operator | op@site.com | 2025-01-06 16:10:00 |
| 7 | analytics | $2y$… | analyst | ana@site.com | 2025-01-07 12:05:00 |
| 8 | dev_api | $2y$… | api_user | api@site.com | 2025-01-08 10:50:00 |
| 9 | qa_tester | $2y$… | tester | test@site.com | 2025-01-09 13:30:00 |
| 10 | supervisor | $2y$… | supervisor | sv@site.com | 2025-01-10 09:00:00 |
注:
role字段采用枚举方式定义访问权限层级,结合 PHP 后端中间件进行路由拦截与功能可见性控制。
5.2 高频写入场景下的性能调优策略
在线客服系统具有典型的“写多读少”特征,尤其在促销或活动期间,单日消息量可达百万级。为此需采取以下措施保障数据库稳定性。
5.2.1 分表分区技术应对大数据量增长
当 messages 表数据超过千万行后,即使有索引也会影响查询效率。建议按时间进行水平分表或使用 MySQL 的 RANGE 分区功能:
ALTER TABLE messages
PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
PARTITION p_202501 VALUES LESS THAN (202502),
PARTITION p_202502 VALUES LESS THAN (202503),
PARTITION p_202503 VALUES LESS THAN (202504),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
此方案将不同月份的数据分布于独立物理段,显著提升按时间范围查询的 I/O 效率。
5.2.2 写入缓冲与批量插入机制应用
对于非实时强一致性的操作(如浏览记录、行为日志),可采用异步队列+批量入库的方式减轻主库压力。示例代码如下:
// 批量插入消息缓存队列
$messageQueue = [];
function enqueueMessage($data) {
global $messageQueue;
$messageQueue[] = $data;
if (count($messageQueue) >= 100) {
flushMessageBatch();
}
}
function flushMessageBatch() {
global $pdo, $messageQueue;
if (empty($messageQueue)) return;
$stmt = $pdo->prepare(
"INSERT INTO messages (thread_id, sender_type, content, created_at)
VALUES (?, ?, ?, ?)"
);
$pdo->beginTransaction();
foreach ($messageQueue as $msg) {
$stmt->execute([
$msg['thread_id'],
$msg['sender_type'],
$msg['content'],
$msg['created_at']
]);
}
$pdo->commit();
$messageQueue = []; // 清空队列
}
通过控制批处理大小(如每100条提交一次),可在吞吐量与事务延迟之间取得平衡。
5.3 数据一致性与事务处理机制
5.3.1 消息发送与状态更新的原子性保障
在发送一条新消息的同时,往往需要更新 threads.last_activity 时间戳以保持会话活跃状态。此类操作必须保证原子性,避免出现“消息入库但会话未更新”的不一致问题。
try {
$pdo->beginTransaction();
// 插入新消息
$stmt1 = $pdo->prepare("INSERT INTO messages (...) VALUES (...)");
$stmt1->execute([...]);
// 更新会话最后活动时间
$stmt2 = $pdo->prepare("UPDATE threads SET last_activity = NOW() WHERE id = ?");
$stmt2->execute([$threadId]);
$pdo->commit();
} catch (Exception $e) {
$pdo->rollback();
error_log("Transaction failed: " . $e->getMessage());
}
利用 InnoDB 的行级锁与 ACID 特性,确保两个操作要么全部成功,要么全部回滚。
5.3.2 利用InnoDB引擎支持事务回滚
系统所有核心表均采用 ENGINE=InnoDB ,启用自动提交关闭模式( autocommit=0 )配合显式事务控制,有效防止脏写和部分更新问题。同时设置合适的超时时间( innodb_lock_wait_timeout=50 ),避免长时间阻塞。
5.4 数据备份与恢复机制建设
5.4.1 定期导出策略与自动化脚本配置
为防范硬件故障或误删数据,应制定周期性备份计划。以下是一个每日凌晨执行的 shell 脚本示例:
#!/bin/bash
BACKUP_DIR="/data/backup/mysql"
DATE=$(date +%Y%m%d_%H%M%S)
DB_NAME="chat_system"
mysqldump -u root -p$MYSQL_PWD --single-transaction --routines --triggers \
$DB_NAME | gzip > $BACKUP_DIR/${DB_NAME}_$DATE.sql.gz
# 保留最近7天备份
find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete
结合 crontab 实现自动化调度:
0 2 * * * /usr/local/bin/backup_mysql.sh
5.4.2 开源环境下数据迁移与系统复制可行性
由于系统采用标准 SQL 结构并提供 INSTALL.txt 部署文档,任何开发者均可通过导入 .sql 文件快速重建数据库环境。此外,支持使用 SELECT ... INTO OUTFILE 和 LOAD DATA INFILE 加速跨服务器迁移过程,适用于多站点部署或测试环境克隆。
flowchart TD
A[应用层 PHP] --> B[Ajax 请求]
B --> C[Nginx 反向代理]
C --> D[PHP-FPM 处理]
D --> E{MySQL 主库}
E --> F[InnoDB 存储引擎]
F --> G[Binlog 日志]
G --> H[从库 Replication]
H --> I[备份服务器]
I --> J[灾难恢复]
K[定时任务 Cron] --> L[mysqldump 导出]
L --> M[Gzip 压缩归档]
M --> N[异地存储]
简介:该系统是一款基于WEB的实时在线客服解决方案,采用PHP语言开发,结合MySQL数据库与Ajax技术,实现高效、无刷新的即时通信功能。系统支持访客与客服之间的实时文本交流,提升用户互动体验与服务效率。通过模块化的文件结构与清晰的功能划分,涵盖客户端交互、会话管理、留言处理、邮件通知等核心功能,具备良好的可扩展性与定制化能力。项目开源,便于开发者二次开发与部署,适用于中小企业和个人网站快速集成在线客服功能。
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐

所有评论(0)