本文记录一套不依赖 AI Token 的博客实时聊天系统:访客在博客右侧发起咨询,站长在 ThinkPHP 后台实时接收和回复;站长离线或 WebSocket 暂时不可用时,消息仍通过 HTTP 保存到 MySQL。
示例采用“现有 ThinkPHP 网站 + 独立 Swoole WebSocket 服务”的方式。两者互不绑定:聊天服务停止不会影响博客访问,传统 PHP-FPM 也不需要改成长驻进程模式。
一、需求与成功标准
- 前台右侧显示轻量聊天窗口,手机端自动适配。
- 访客不登录也能留言,但身份 Token 不能被随意伪造。
- 站长登录原有后台后查看会话、未读消息并实时回复。
- WebSocket 断开时自动使用 HTTP 保存消息。
- 刷新页面后可以加载历史记录。
- Swoole 只监听本机端口,由 Nginx 提供 HTTPS/WSS。
- 限制消息长度和发送频率,所有输出按纯文本处理。
- 聊天服务故障或重启不能影响博客主站。
二、整体架构
访客浏览器
├─ HTTPS:创建会话、历史记录、离线留言
└─ WSS:实时发送与接收消息
↓
Nginx 443
├─ PHP 请求 → PHP-FPM → ThinkPHP
└─ /chat-ws → 127.0.0.1:9502 → Swoole
↓
MySQL
↑
ThinkPHP 管理后台 ← HTTPS/WSS ← 管理员浏览器
各组件职责如下:
- ThinkPHP:创建访客会话、后台登录鉴权、历史记录、HTTP 兜底接口。
- Swoole:维护 WebSocket 长连接、在线状态和实时消息推送。
- MySQL:保存会话、消息和短期管理员 WebSocket Token。
- Nginx:复用站点证书,把
wss://blog.example.com/chat-ws代理到本机 Swoole。 - systemd:守护 Swoole,异常退出后自动重启,并设置开机启动。
三、版本选择与兼容性
本次环境是 PHP 7.3,因此使用与旧 PHP 兼容的 Swoole 4.8.13,并且只在 PHP CLI 配置中加载扩展。这样不会改动正在运行的 PHP-FPM。
新项目不要直接照搬这个版本。应优先升级到受支持的 PHP 版本,再根据官方兼容矩阵选择当前 Swoole 或 OpenSwoole。旧版本方案只适用于暂时不能升级的存量项目,同时要限制端口、控制权限并制定后续升级计划。
安装前检查环境:
php -v php --ini php -m | grep -Ei 'swoole|openswoole' free -h /www/server/nginx/sbin/nginx -v
四、安装 Swoole 扩展
PHP 7.3 示例:
/path/to/php/bin/pecl install swoole-4.8.13
只在 CLI 使用时,将下面内容写入 CLI 专用的 php-cli.ini:
extension = swoole.so
验证扩展:
/path/to/php/bin/php -m | grep -i '^swoole$' /path/to/php/bin/php --ri swoole
如果网站的 CLI 与 PHP-FPM 使用不同配置文件,不要误改。可以通过 php --ini 确认实际加载路径。
五、设计数据库表
第一版只需要三张表:
chat_sessions:访客会话和状态。chat_messages:访客及管理员消息。chat_admin_tokens:后台生成的短期 WebSocket 鉴权 Token。
CREATE TABLE `chat_sessions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`public_id` CHAR(32) NOT NULL,
`visitor_token_hash` CHAR(64) NOT NULL,
`visitor_name` VARCHAR(50) NOT NULL,
`ip` VARCHAR(45) NOT NULL DEFAULT '',
`user_agent` VARCHAR(255) NOT NULL DEFAULT '',
`status` TINYINT UNSIGNED NOT NULL DEFAULT 1,
`last_message_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_public_id` (`public_id`),
UNIQUE KEY `uk_visitor_token_hash` (`visitor_token_hash`),
KEY `idx_status_last_message` (`status`, `last_message_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `chat_messages` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`session_id` BIGINT UNSIGNED NOT NULL,
`sender_type` VARCHAR(16) NOT NULL,
`content` TEXT NOT NULL,
`is_read` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_session_id_id` (`session_id`, `id`),
KEY `idx_session_sender_read`
(`session_id`, `sender_type`, `is_read`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `chat_admin_tokens` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`token_hash` CHAR(64) NOT NULL,
`admin_id` INT UNSIGNED NOT NULL,
`expires_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_token_hash` (`token_hash`),
KEY `idx_expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
数据库表存在前缀时,应按项目配置统一添加。例如 ThinkPHP 表前缀为 admin_,实际表名可能是 admin_chat_sessions。
六、为什么只保存 Token 哈希
浏览器保存 64 位随机 Token,数据库只保存:
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
客户端连接时提交原始 Token,服务端重新计算哈希后查询。即使数据库被只读泄露,也不能直接拿表中的哈希建立聊天连接。
七、ThinkPHP 前台接口
新建 application/index/controller/Chat.php。它不继承加载大量侧栏数据的公共控制器,避免每次 AJAX 请求执行无关查询。
1. 创建或恢复访客会话
<?php
namespace app\index\controller;
use think\Controller;
use think\Db;
class Chat extends Controller
{
public function bootstrap()
{
if (!request()->isPost()) {
return json(['status' => 0, 'msg' => '请求方式错误']);
}
$token = trim((string)input('post.token'));
$session = $this->findSessionByToken($token);
if (!$session) {
$token = bin2hex(random_bytes(32));
$now = date('Y-m-d H:i:s');
$publicId = bin2hex(random_bytes(16));
$sessionId = Db::name('chat_sessions')->insertGetId([
'public_id' => $publicId,
'visitor_token_hash' => hash('sha256', $token),
'visitor_name' => '访客' . strtoupper(substr($publicId, 0, 6)),
'ip' => request()->ip(),
'user_agent' => mb_substr(
(string)request()->header('user-agent'), 0, 255
),
'status' => 1,
'last_message_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$session = Db::name('chat_sessions')
->where('id', $sessionId)->find();
}
$messages = Db::name('chat_messages')
->where('session_id', $session['id'])
->field('id,sender_type,content,is_read,created_at')
->order('id desc')
->limit(80)
->select();
return json([
'status' => 1,
'token' => $token,
'session' => [
'id' => $session['public_id'],
'name' => $session['visitor_name'],
],
'messages' => array_reverse($messages ?: []),
'websocket' => 'wss://' . request()->host() . '/chat-ws',
]);
}
private function findSessionByToken($token)
{
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
return null;
}
return Db::name('chat_sessions')
->where('visitor_token_hash', hash('sha256', $token))
->find();
}
}
2. HTTP 离线兜底
WebSocket 不可用时,前端调用普通 HTTP 接口。服务端必须再次验证 Token、消息长度和频率,不能只依赖 JavaScript 校验。
public function send()
{
if (!request()->isPost()) {
return json(['status' => 0, 'msg' => '请求方式错误']);
}
$token = trim((string)input('post.token'));
$content = trim((string)input('post.content'));
$session = $this->findSessionByToken($token);
if (!$session || (int)$session['status'] !== 1) {
return json(['status' => 0, 'msg' => '会话已失效']);
}
if ($content === '' || mb_strlen($content) > 1000) {
return json(['status' => 0, 'msg' => '消息应为 1~1000 字']);
}
$now = date('Y-m-d H:i:s');
$id = Db::name('chat_messages')->insertGetId([
'session_id' => $session['id'],
'sender_type' => 'visitor',
'content' => $content,
'is_read' => 0,
'created_at' => $now,
]);
Db::name('chat_sessions')->where('id', $session['id'])->update([
'last_message_at' => $now,
'updated_at' => $now,
]);
return json([
'status' => 1,
'message' => [
'id' => $id,
'sender_type' => 'visitor',
'content' => $content,
'created_at' => $now,
],
]);
}
八、后台生成短期 WebSocket Token
不能把 ThinkPHP 管理员 Session 直接交给独立的 Swoole 服务。后台先通过已登录的 HTTP 请求生成短期 Token,Swoole 再读取数据库验证。
public function socketToken()
{
if (!request()->isPost()) {
return json(['status' => 0, 'msg' => '请求方式错误']);
}
$token = bin2hex(random_bytes(32));
$now = date('Y-m-d H:i:s');
Db::name('chat_admin_tokens')
->where('expires_at', '<', $now)
->delete();
Db::name('chat_admin_tokens')->insert([
'token_hash' => hash('sha256', $token),
'admin_id' => (int)session('id'),
'expires_at' => date('Y-m-d H:i:s', time() + 43200),
'created_at' => $now,
]);
return json([
'status' => 1,
'token' => $token,
'websocket' => 'wss://' . request()->host() . '/chat-ws',
]);
}
该方法所在控制器必须继承现有后台公共控制器,由公共控制器先检查管理员登录 Session。
九、Swoole WebSocket 服务
服务只监听 127.0.0.1:9502,不允许公网直接访问。下面展示核心实现,实际项目还应增加日志、异常处理与监控。
<?php
$config = require dirname(__DIR__) . '/config/database.php';
$pdo = null;
$clients = [];
$visitorFds = [];
$adminFds = [];
function db(&$pdo, array $config)
{
if ($pdo instanceof PDO) {
try {
$pdo->query('SELECT 1');
return $pdo;
} catch (Throwable $e) {
$pdo = null;
}
}
$port = $config['hostport'] ?: 3306;
$dsn = "mysql:host={$config['hostname']};port={$port};" .
"dbname={$config['database']};charset=utf8mb4";
$pdo = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function pushJson($server, $fd, array $data)
{
if ($server->isEstablished($fd)) {
$server->push(
$fd,
json_encode($data, JSON_UNESCAPED_UNICODE)
);
}
}
$server = new Swoole\WebSocket\Server('127.0.0.1', 9502);
$server->set([
'worker_num' => 1,
'max_conn' => 512,
'heartbeat_check_interval' => 30,
'heartbeat_idle_time' => 90,
'log_file' => dirname(__DIR__) . '/runtime/log/chat-swoole.log',
]);
$server->on('Open', function ($server, $request) use (&$clients) {
$origin = strtolower(rtrim($request->header['origin'] ?? '', '/'));
$allowed = [
'https://blog.example.com',
'https://example.com',
];
if ($origin !== '' && !in_array($origin, $allowed, true)) {
$server->disconnect($request->fd, 1008, 'Origin denied');
return;
}
$clients[$request->fd] = ['role' => 'guest'];
});
1. 首帧完成身份认证
Token 不放在 WebSocket URL 查询参数中,避免进入代理访问日志。连接建立后,客户端发送第一条认证消息:
socket.send(JSON.stringify({
type: 'auth',
role: 'visitor',
token: visitorToken
}));
Swoole 端验证访客:
if ($data['type'] === 'auth' && $data['role'] === 'visitor') {
$stmt = $db->prepare(
'SELECT id,status FROM chat_sessions ' .
'WHERE visitor_token_hash=? LIMIT 1'
);
$stmt->execute([hash('sha256', $data['token'])]);
$session = $stmt->fetch();
if (!$session || (int)$session['status'] !== 1) {
$server->disconnect($frame->fd, 1008, 'Session unavailable');
return;
}
$clients[$frame->fd] = [
'role' => 'visitor',
'session_id' => (int)$session['id'],
];
$visitorFds[(int)$session['id']][$frame->fd] = true;
pushJson($server, $frame->fd, [
'type' => 'ready',
'admin_online' => !empty($adminFds),
]);
}
2. 保存并推送访客消息
if ($client['role'] === 'visitor') {
$sessionId = (int)$client['session_id'];
$content = trim((string)$data['content']);
if ($content === '' || mb_strlen($content) > 1000) {
pushJson($server, $frame->fd, [
'type' => 'error',
'message' => '消息应为 1~1000 字',
]);
return;
}
$now = date('Y-m-d H:i:s');
$stmt = $db->prepare(
'INSERT INTO chat_messages ' .
'(session_id,sender_type,content,is_read,created_at) ' .
'VALUES (?,?,?,?,?)'
);
$stmt->execute([$sessionId, 'visitor', $content, 0, $now]);
$message = [
'id' => (int)$db->lastInsertId(),
'sender_type' => 'visitor',
'content' => $content,
'created_at' => $now,
];
pushJson($server, $frame->fd, [
'type' => 'message',
'session_id' => $sessionId,
'message' => $message,
]);
foreach ($adminFds as $adminFd => $_) {
pushJson($server, $adminFd, [
'type' => 'message',
'session_id' => $sessionId,
'message' => $message,
]);
}
}
3. 关闭连接时清理映射
$server->on('Close', function ($server, $fd)
use (&$clients, &$visitorFds, &$adminFds) {
$client = $clients[$fd] ?? null;
if ($client && $client['role'] === 'visitor') {
$sessionId = (int)$client['session_id'];
unset($visitorFds[$sessionId][$fd]);
if (empty($visitorFds[$sessionId])) {
unset($visitorFds[$sessionId]);
}
}
if ($client && $client['role'] === 'admin') {
unset($adminFds[$fd]);
}
unset($clients[$fd]);
});
$server->start();
十、前台聊天组件
组件在公共页脚引入,因此文章页、首页和分类页可以共用:
{include file="public/chat" /}
聊天组件 HTML:
<div class="blog-chat" id="blogChat"
data-bootstrap-url="{:url('chat/bootstrap')}"
data-send-url="{:url('chat/send')}"
data-history-url="{:url('chat/history')}">
<button id="blogChatToggle" type="button">
在线咨询
</button>
<section id="blogChatPanel" hidden>
<header>
<strong>在线咨询</strong>
<span id="blogChatStatus">点击开始咨询</span>
</header>
<div id="blogChatMessages" aria-live="polite"></div>
<form id="blogChatForm">
<textarea id="blogChatInput"
maxlength="1000"
placeholder="输入消息…"></textarea>
<button type="submit">发送</button>
</form>
</section>
</div>
只在用户点击后创建会话
不要在页面加载时立即调用 bootstrap,否则每次访问都会产生没有消息的空会话。
let initialized = false;
function openPanel() {
panel.hidden = false;
if (!initialized) {
bootstrapChat();
}
}
async function bootstrapChat() {
const token = localStorage.getItem('blog_chat_token') || '';
const response = await fetch(root.dataset.bootstrapUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: new URLSearchParams({ token }),
credentials: 'same-origin'
});
const result = await response.json();
localStorage.setItem('blog_chat_token', result.token);
initialized = true;
connectWebSocket(result.websocket, result.token);
}
WebSocket 失败时使用 HTTP
async function sendMessage(content) {
if (socketReady && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'message',
content
}));
return;
}
await fetch(root.dataset.sendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: new URLSearchParams({
token: localStorage.getItem('blog_chat_token'),
content
}),
credentials: 'same-origin'
});
}
渲染消息时不要使用 innerHTML 拼接访客内容,应使用 textContent,防止存储型 XSS:
const text = document.createElement('div');
text.textContent = message.content;
bubble.appendChild(text);
十一、后台聊天页面
后台页面可以采用左右两栏布局:
左侧:会话列表 ├─ 访客名称 ├─ 最后一条消息 ├─ 未读数量 └─ 最后咨询时间 右侧:当前会话 ├─ 访客信息 ├─ 历史消息 ├─ 回复输入框 └─ 关闭会话
后台同时连接 WebSocket 和定时轮询:
- WebSocket 正常时实时显示消息。
- 每隔数秒刷新会话列表,防止错过 HTTP 离线留言。
- WebSocket 失败时,管理员回复走 HTTP 接口。
- 选择会话后,把访客消息更新为已读。
十二、配置 Nginx WSS 反向代理
将以下配置放入 HTTPS 站点的 server 中:
location = /chat-ws {
proxy_pass http://127.0.0.1:9502;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header Origin $http_origin;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 100s;
proxy_send_timeout 100s;
proxy_buffering off;
}
检查配置后再重新加载:
nginx -t nginx -s reload
Swoole 只绑定 127.0.0.1,不需要开放 9502 公网防火墙端口。
十三、使用 systemd 守护服务
创建 /etc/systemd/system/blog-chat.service:
[Unit] Description=Blog WebSocket Chat After=network.target mysqld.service [Service] Type=simple User=www Group=www WorkingDirectory=/www/wwwroot/blog.example.com ExecStart=/path/to/php/bin/php \ -c /path/to/php-cli.ini \ /www/wwwroot/blog.example.com/chat-server/server.php Restart=on-failure RestartSec=3 LimitNOFILE=4096 NoNewPrivileges=true PrivateTmp=true ProtectSystem=full ReadWritePaths=/www/wwwroot/blog.example.com/runtime [Install] WantedBy=multi-user.target
启动并检查:
systemctl daemon-reload systemctl enable --now blog-chat.service systemctl status blog-chat.service ss -lntp | grep 9502 journalctl -u blog-chat.service -n 100 --no-pager
十四、心跳、重连与在线状态
浏览器每隔 25 秒发送心跳:
setInterval(() => {
if (socketReady && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'ping' }));
}
}, 25000);
Swoole 配置空闲连接检测:
$server->set([
'heartbeat_check_interval' => 30,
'heartbeat_idle_time' => 90,
]);
连接关闭后使用递增或固定延时重新连接,避免网络波动时高频重试。管理员连接数量从 0 变为 1 时,可以向访客推送在线状态;最后一个管理员断开时,再推送离线状态。
十五、限流与安全设计
- Swoole 只监听
127.0.0.1。 - WebSocket 握手后必须先认证,未认证连接不能发消息。
- 检查
Origin,只允许自己的 HTTPS 域名。 - 访客 Token 至少使用 32 字节安全随机数,数据库只存哈希。
- 管理员 Token 短期有效并定时清理。
- 单条消息限制为 1000 字,服务端再次校验。
- 按连接和 IP 限制发送频率。
- 消息使用参数化 SQL,不拼接用户输入。
- 前端使用
textContent,不直接渲染用户 HTML。 - 后台接口必须经过原有管理员 Session 鉴权。
- 数据库、日志和聊天记录按最小权限管理。
十六、部署顺序
- 备份即将修改的模板、Nginx 和 PHP CLI 配置。
- 安装并验证 Swoole CLI 扩展。
- 创建聊天数据表。
- 部署 ThinkPHP 前后台控制器和视图。
- 部署前端 CSS、JavaScript 和 Swoole 服务文件。
- 执行
php -l检查 PHP 语法。 - 执行
nginx -t检查代理配置。 - 启动 systemd 服务,再重新加载 Nginx。
- 完成 HTTPS、WSS、离线留言和后台鉴权测试。
- 删除自动化测试产生的会话和消息。
十七、端到端测试
1. 检查 HTTP 页面
curl -I https://blog.example.com/ curl -I https://blog.example.com/static/index/js/chat.js
2. 检查服务
systemctl is-active blog-chat.service ss -lntp | grep 9502 nginx -t
3. 浏览器测试
- 打开文章页,确认没有点击聊天时不会创建会话。
- 点击聊天按钮后发送消息,后台应立即出现。
- 后台回复,访客端应实时显示。
- 停止 Swoole 后发送消息,应通过 HTTP 保存。
- 重新启动 Swoole,刷新页面后历史消息仍在。
- 退出管理员登录后,后台聊天接口必须拒绝访问。
- 手机端检查弹层宽度、输入框和键盘遮挡。
十八、常见问题
1. 浏览器连接返回 400 或 502
检查 Nginx 是否传递 Upgrade 和 Connection 头,Swoole 是否监听 9502,以及 proxy_pass 地址是否正确。
2. PHP 显示没有 Swoole 扩展
确认运行服务时使用的是哪个 PHP 二进制和哪个 php.ini。CLI 与 PHP-FPM 很可能不是同一个配置文件。
3. 服务启动后立即退出
systemctl status blog-chat.service journalctl -u blog-chat.service -n 100 --no-pager php -l chat-server/server.php
4. 每次访问文章都产生空会话
原因通常是页面加载后立即调用创建会话接口。应改成用户第一次展开聊天窗口时再初始化。
5. 多 worker 后消息推送失败
示例使用单 worker,因此内存中的 FD 映射在同一进程。扩展到多 worker 或多台服务器时,应使用 Redis、Swoole Table、进程间消息或专门的消息总线管理在线连接,不能继续依赖单进程数组。
十九、后续扩展
- Redis 管理在线状态和多 worker 消息分发。
- 多客服分配、转接和客服在线状态。
- 浏览器通知、声音提醒和邮件提醒。
- 图片上传,但必须限制类型、大小和访问权限。
- 敏感词、黑名单和验证码。
- 会话搜索、备注、标签和统计。
- 消息保留周期、匿名化和隐私说明。
二十、回滚方法
上线前保存所有被修改文件。需要回滚时:
systemctl disable --now blog-chat.service rm /etc/systemd/system/blog-chat.service systemctl daemon-reload # 恢复备份的 Nginx、模板和 CLI 配置 nginx -t && nginx -s reload
聊天表中可能已经有真实访客数据,不应直接执行 DROP TABLE。先导出聊天表备份,再根据数据保留要求决定归档或删除。
二十一、官方文档
二十二、总结
这套方案把传统 ThinkPHP 请求、Swoole 长连接和 MySQL 持久化分开:ThinkPHP 负责业务与权限,Swoole 负责实时传输,HTTP 负责断线兜底,Nginx 统一提供 HTTPS/WSS,systemd 负责守护进程。
第一版保持单 worker 和纯文本消息,代码少、资源占用低,也容易回滚。访问量增加后,再引入 Redis 和多进程分发,而不是一开始就把系统设计得过于复杂。