feat(WebSocket): 添加数据库连接检查和文件预览功能

- 在DefaultWebSocketController中添加数据库连接检查功能
- 实现文件预览和下载功能及相关API接口
- 更新测试页面支持文件预览和下载操作
- 移除旧的数据库维护子进程机制,改为函数检查
- 在构建请求数据时添加文件字段支持
This commit is contained in:
2026-01-26 08:40:07 +08:00
parent 0a7301f39d
commit ef708e6b40
3 changed files with 417 additions and 167 deletions

View File

@@ -112,8 +112,15 @@ class WebSocket extends WebSocketBase
return;
}
// 处理文件预览
if (isset($data['action']) && $data['action'] === 'file_preview') {
$this->handleFilePreview($conn, $data);
return;
}
$conn->send(json_encode(['type' => 'error', 'message' => 'Unknown action']));
} catch (\Exception $e) {
$conn->send(json_encode(['type' => 'error', 'message' => $e->getMessage(), 'line' => $e->getLine(), 'file' => $e->getFile(), 'trace' => $e->getTraceAsString()]));
}
@@ -268,7 +275,7 @@ class WebSocket extends WebSocketBase
$enable_stream = $stream || $response_mode == 'streaming';
// 构建请求数据和请求头
$requestData = $this->buildRequestData($query, $user_id, $conversation_id, $enable_stream);
$requestData = $this->buildRequestData($query, $user_id, $conversation_id, $enable_stream, $data);
$headers = $this->buildRequestHeaders($config['api_key']);
// 发送请求到Dify API
@@ -493,7 +500,7 @@ class WebSocket extends WebSocketBase
// 提取HTTP错误码和Dify错误信息
if (preg_match('/HTTP请求失败状态码(\d+),响应:(.*)/', $errorMessage, $matches)) {
$errorCode = (int)$matches[1];
$errorCode = (int) $matches[1];
$errorResponse = $matches[2];
try {
@@ -606,6 +613,144 @@ class WebSocket extends WebSocketBase
}
}
/**
* 处理文件预览
* @param ConnectionInterface $conn
* @param array $data
*/
private function handleFilePreview(ConnectionInterface $conn, $data)
{
try {
// 获取客户端信息
$clientInfo = $this->clientData[$conn->resourceId];
// 获取预览相关参数
$file_id = $data['file_id'] ?? '';
$as_attachment = $data['as_attachment'] ?? false;
$user_id = $data['user_id'] ?? $clientInfo['user_id'];
$site_id = $data['uniacid'] ?? $clientInfo['site_id'];
$token = $data['token'] ?? $clientInfo['token'];
// 验证参数
if (empty($file_id)) {
throw new \Exception('文件ID不能为空');
}
// 验证参数并获取配置,与 Kefu.php 保持一致
$config = $this->validateAndGetConfig([
'file_id' => ['required' => true, 'message' => '文件ID不能为空', 'description' => '文件ID'],
'user_id' => ['required' => true, 'message' => '请求参数 `user_id` 不能为空', 'description' => '用户ID']
], [
'file_id' => $file_id,
'user_id' => $user_id,
'uniacid' => $site_id,
'token' => $token
]);
// 构建请求URL
$url = $config['base_url'] . '/files/' . $file_id . '/preview';
if ($as_attachment) {
$url .= '?as_attachment=true';
}
// 构建请求头
$headers = [
'Authorization: Bearer ' . $config['api_key'],
'Accept: */*'
];
// 发送请求到Dify API
$response = $this->curlGetFile($url, $headers);
// 发送预览成功响应
$conn->send(json_encode([
'type' => 'file_preview_success',
'file_id' => $file_id,
'file_url' => $url,
'message' => '文件预览请求成功'
]));
$this->log('文件预览请求成功文件ID' . $file_id, 'info');
} catch (\Exception $e) {
// 解析错误信息
$errorMessage = $e->getMessage();
$errorCode = 500;
$errorType = 'preview_failed';
// 提取HTTP错误码和Dify错误信息
if (preg_match('/HTTP请求失败状态码(\d+),响应:(.*)/', $errorMessage, $matches)) {
$errorCode = (int) $matches[1];
$errorResponse = $matches[2];
try {
$errorData = json_decode($errorResponse, true);
if (isset($errorData['code'])) {
$errorType = $errorData['code'];
}
if (isset($errorData['message'])) {
$errorMessage = $errorData['message'];
}
} catch (\Exception $decodeEx) {
// 解析失败,使用原始错误信息
}
}
$conn->send(json_encode([
'type' => 'error',
'code' => $errorCode,
'error_type' => $errorType,
'message' => '文件预览失败:' . $errorMessage
]));
}
}
/**
* 封装文件预览的curl请求方法
* @param string $url 请求URL
* @param array $headers 请求头
* @return string 响应内容
*/
private function curlGetFile($url, $headers = [])
{
$ch = curl_init();
// 设置URL
curl_setopt($ch, CURLOPT_URL, $url);
// 设置请求方法
curl_setopt($ch, CURLOPT_HTTPGET, true);
// 设置请求头
if (!empty($headers)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
// 设置返回值
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
// 执行请求
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// 关闭连接
curl_close($ch);
if ($response === false) {
throw new \Exception('Curl请求失败');
}
if ($httpCode >= 400) {
throw new \Exception('HTTP请求失败状态码' . $httpCode . ',响应:' . $response);
}
return $response;
}
/**
* 封装文件上传的curl请求方法适用于 Dify 1.9.0 版本)
* @param string $url 请求URL
@@ -696,7 +841,7 @@ class WebSocket extends WebSocketBase
{
try {
// 记录开始处理流式请求
$this->log('AI客服WebSocket流式请求开始处理用户ID' . $user_id . ',请求消息' . $query, 'info');
$this->log('AI客服WebSocket流式请求开始处理用户ID' . $user_id . ',请求内容' . json_encode($requestData), 'info');
// 初始化模型
$kefu_conversation_model = new KefuConversationModel();
@@ -1316,9 +1461,10 @@ class WebSocket extends WebSocketBase
* @param string $user_id 用户ID
* @param string $conversation_id 会话ID
* @param bool $stream 是否使用流式响应
* @param array $origin_data 原始数据
* @return array
*/
private function buildRequestData($message, $user_id, $conversation_id, $stream)
private function buildRequestData($message, $user_id, $conversation_id, $stream, $origin_data)
{
$requestData = [
'inputs' => [],
@@ -1330,6 +1476,12 @@ class WebSocket extends WebSocketBase
// 如果有会话ID添加到请求中
if (!empty($conversation_id)) {
$requestData['conversation_id'] = $conversation_id;
// ----- 只有会话ID的情况下下列情况才添加相关的数据
// 如果有files字段添加到请求中
if (!empty($origin_data['files']) && count($origin_data['files']) > 0) {
$requestData['files'] = $origin_data['files'];
}
}
return $requestData;

View File

@@ -150,17 +150,28 @@
</div>
<div style="margin-top: 10px;">
<input type="file" :id="'file-input-' + addon.name" :disabled="addon.status !== 'connected'">
<button @click="uploadFile(addon.name)"
:disabled="addon.status !== 'connected'">
<button @click="uploadFile(addon.name)" :disabled="addon.status !== 'connected'">
上传文件
</button>
<div style="margin-top: 5px; display: none;" :id="'upload-progress-' + addon.name">
<div style="font-size: 12px; margin-bottom: 2px;">上传进度: <span :id="'progress-text-' + addon.name">0%</span></div>
<div style="font-size: 12px; margin-bottom: 2px;">上传进度: <span
:id="'progress-text-' + addon.name">0%</span></div>
<div style="width: 100%; height: 10px; background-color: #f0f0f0; border-radius: 5px;">
<div style="height: 100%; background-color: #4CAF50; border-radius: 5px; width: 0%;" :id="'progress-bar-' + addon.name"></div>
<div style="height: 100%; background-color: #4CAF50; border-radius: 5px; width: 0%;"
:id="'progress-bar-' + addon.name"></div>
</div>
</div>
</div>
<div style="margin-top: 10px;">
<input type="text" :id="'preview-file-id-' + addon.name" placeholder="输入文件ID"
:disabled="addon.status !== 'connected'">
<button @click="previewFile(addon.name)" :disabled="addon.status !== 'connected'">
预览文件
</button>
<button @click="downloadFile(addon.name)" :disabled="addon.status !== 'connected'">
下载文件
</button>
</div>
</div>
</div>
@@ -207,6 +218,9 @@
// 聊天区域引用
const chatAreas = ref([]);
// 上传成功的文件
const uploadFiles = reactive([]);
// 设置WebSocket服务器地址
const setWebsocketUrl = () => {
if (websocketUrl.value.trim() && (websocketUrl.value.startsWith('ws://') || websocketUrl.value.startsWith('wss://'))) {
@@ -409,7 +423,52 @@
};
wsConnections[name].onmessage = (event) => {
addMessage(name, '服务器', event.data);
try {
// 尝试解析JSON消息
const message = JSON.parse(event.data);
// 处理文件上传成功响应
if (message.type === 'upload_success') {
addMessage(name, '服务器', event.data);
// 更新上传文件列表
uploadFiles.push({
file_id: message.file_id,
file_name: message.file_name,
file_size: message.file_size,
file_extension: message.file_extension,
file_mime_type: message.file_mime_type,
file_created_by: message.file_created_by,
file_created_at: message.file_created_at,
file_url: message.file_url
});
console.log('上传文件列表更新:', uploadFiles);
} else if (message.type === 'file_preview_success') {
addMessage(name, '服务器', `文件预览成功: ${message.file_id}\n文件URL: ${message.file_url}`);
console.log('文件预览成功:', message);
// 打开文件预览
window.open(message.file_url, '_blank');
} else if (message.type === 'error' && message.code === 403 && message.error_type === 'file_access_denied') {
addMessage(name, '服务器', `文件访问被拒绝: ${message.message}`);
console.log('文件访问被拒绝:', message);
} else if (message.type === 'error' && message.code === 404 && message.error_type === 'file_not_found') {
addMessage(name, '服务器', `文件未找到: ${message.message}`);
console.log('文件未找到:', message);
} else if (message.type === 'error' && message.code === 400 && message.error_type === 'invalid_params') {
addMessage(name, '服务器', `参数输入异常: ${message.message}`);
console.log('参数输入异常:', message);
} else if (message.type === 'error' && message.code === 400 && message.error_type === 'unsupported_preview') {
addMessage(name, '服务器', `该文件不支持预览: ${message.message}`);
console.log('该文件不支持预览:', message);
} else {
// 其他消息,直接添加到聊天区域
addMessage(name, '服务器', event.data);
}
} catch (e) {
// 不是JSON消息直接添加到聊天区域
addMessage(name, '服务器', event.data);
}
};
wsConnections[name].onclose = () => {
@@ -457,6 +516,16 @@
const message = addon.inputMessage.trim();
addMessage(name, '用户', message);
const fileList = uploadFiles.map(item => {
return {
type: item?.file_mime_type ?? '',
transfer_method: 'local_file',
upload_file_id: item?.file_id ?? '',
}
});
console.log(`加载已经上传的文件`, fileList);
// 发送聊天消息,与 WebSocket.php 保持一致使用 query 参数
const chatMsg = JSON.stringify({
action: 'chat',
@@ -465,7 +534,8 @@
uniacid: 1,
stream: true,
response_mode: 'streaming',
conversation_id: addon.conversation_id
conversation_id: addon.conversation_id,
files: fileList
});
if (wsConnections[name] && wsConnections[name].readyState === WebSocket.OPEN) {
@@ -797,6 +867,68 @@
}
};
// 预览文件
const previewFile = async (name) => {
const addon = addons.find(a => a.name === name);
if (!addon || addon.status !== 'connected') return;
const fileIdInput = document.getElementById('preview-file-id-' + name);
const fileId = fileIdInput.value.trim();
if (!fileId) {
addMessage(name, '系统', '请输入文件ID');
return;
}
// 添加文件预览消息
addMessage(name, '用户', `正在预览文件: ${fileId}`);
// 构建文件预览请求
const previewMsg = JSON.stringify({
action: 'file_preview',
file_id: fileId,
as_attachment: false,
user_id: 1,
uniacid: 1,
});
if (wsConnections[name] && wsConnections[name].readyState === WebSocket.OPEN) {
wsConnections[name].send(previewMsg);
} else {
addMessage(name, '系统', 'WebSocket未连接无法发送预览请求');
}
};
// 下载文件
const downloadFile = async (name) => {
const addon = addons.find(a => a.name === name);
if (!addon || addon.status !== 'connected') return;
const fileIdInput = document.getElementById('preview-file-id-' + name);
const fileId = fileIdInput.value.trim();
if (!fileId) {
addMessage(name, '系统', '请输入文件ID');
return;
}
// 添加文件下载消息
addMessage(name, '用户', `正在下载文件: ${fileId}`);
// 构建文件下载请求
const downloadMsg = JSON.stringify({
action: 'file_preview',
file_id: fileId,
as_attachment: true,
user_id: 1,
uniacid: 1,
});
if (wsConnections[name] && wsConnections[name].readyState === WebSocket.OPEN) {
wsConnections[name].send(downloadMsg);
} else {
addMessage(name, '系统', 'WebSocket未连接无法发送下载请求');
}
};
// 恢复上传
const resumeUpload = async (name, fileId) => {
const addon = addons.find(a => a.name === name);
@@ -854,7 +986,9 @@
setWebsocketUrl,
initConnections,
sendMessage,
uploadFile
uploadFile,
previewFile,
downloadFile
};
}
}).mount('#app');

View File

@@ -371,10 +371,14 @@ class DefaultWebSocketController implements MessageComponentInterface
]));
}
public function onMessage(ConnectionInterface $conn, $msg)
{
public function onMessage(ConnectionInterface $conn, $msg) {
ws_echo("[默认路径] Received message from {$conn->resourceId}: $msg");
try {
// 检查数据库连接状态
if (function_exists('checkDatabaseConnection')) {
checkDatabaseConnection();
}
$data = json_decode($msg, true);
if (isset($data['action']) && $data['action'] === 'ping') {
$conn->send(json_encode(['type' => 'pong']));
@@ -409,6 +413,52 @@ class DefaultWebSocketController implements MessageComponentInterface
$ratchetApp->route('/ws', new DefaultWebSocketController(), array('*'), '');
ws_echo("已注册默认WebSocket测试控制器到路径 /ws");
/**
* 检查数据库连接状态并在需要时重新初始化
* @return bool 连接是否有效
*/
function checkDatabaseConnection() {
global $app, $cache;
try {
// 检查缓存中的连接状态
$connStatus = $cache->get('db_connection_status');
if ($connStatus === 'error') {
ws_echo("[WebSocket服务器] 检测到数据库连接错误,尝试重新初始化...");
$app->initialize();
$cache = $app->cache;
}
// 测试连接
$addon_model = new \app\model\system\Addon();
$addon_model->getAddonList([], 'name', 1, 1);
// 更新连接状态
$cache->set('db_connection_status', 'active', 60);
return true;
} catch (\Exception $e) {
ws_echo("[WebSocket服务器] 数据库连接检查失败: {$e->getMessage()}", 'warning');
// 尝试重新初始化
try {
$app->initialize();
$cache = $app->cache;
// 再次测试
$addon_model = new \app\model\system\Addon();
$addon_model->getAddonList([], 'name', 1, 1);
$cache->set('db_connection_status', 'active', 60);
ws_echo("[WebSocket服务器] 数据库连接已重新初始化成功");
return true;
} catch (\Exception $retryEx) {
ws_echo("[WebSocket服务器] 数据库连接重新初始化失败: {$retryEx->getMessage()}", 'error');
$cache->set('db_connection_status', 'error', 60);
return false;
}
}
}
// 缓存WebSocket服务器信息可选用于其他服务查询
$serverInfoKey = 'websocket_server_info';
$cache->set($serverInfoKey, [
@@ -419,6 +469,11 @@ $cache->set($serverInfoKey, [
'registered_addons' => $registeredAddons
], 0); // 0表示永久缓存直到手动删除
// 启动时检查数据库连接
ws_echo("[WebSocket服务器] 启动时检查数据库连接...");
checkDatabaseConnection();
ws_echo("[WebSocket服务器] 数据库连接检查完成");
ws_echo("WebSocket服务器已启动监听地址: ws://{$httpHost}:{$port}");
// 显示已注册WebSocket控制器的addon路径
@@ -461,105 +516,14 @@ if (!empty($missingDirAddons)) {
ws_echo(" - 所有已启用的addon目录都存在");
}
// 添加定期检查数据库连接的机制
// 创建一个单独的进程来定期检查和维护数据库连接
// 添加信号处理,确保当父进程停止时,子进程也会被终止
// 设置基本的信号处理
if (extension_loaded('pcntl')) {
// 记录子进程PID
$dbMaintenancePid = null;
// 创建数据库连接维护子进程
$pid = pcntl_fork();
if ($pid == -1) {
ws_echo("[WebSocket服务器] 无法创建子进程来维护数据库连接", 'error');
} elseif ($pid == 0) {
// 子进程:定期检查数据库连接
ws_echo("[WebSocket服务器] 启动数据库连接维护进程");
// 每30秒检查一次数据库连接
$checkInterval = 30; // 秒
// 检查是否在Windows平台
$isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
// 保存父进程PID仅在非Windows平台
$parentPid = null;
if (!$isWindows && function_exists('getppid')) {
$parentPid = getppid();
ws_echo("[数据库维护子进程] 父进程PID: {$parentPid}");
} else {
ws_echo("[数据库维护子进程] 运行在Windows平台跳过父进程PID检查");
}
// 设置子进程的信号处理
pcntl_signal(SIGTERM, function() {
ws_echo("[数据库维护子进程] 收到终止信号,正在退出...");
exit(0);
});
while (true) {
// 检查是否有信号需要处理
pcntl_signal_dispatch();
// 检查父进程是否仍然存在仅在非Windows平台
if (!$isWindows && function_exists('getppid')) {
$currentParentPid = getppid();
if ($currentParentPid === 1) {
ws_echo("[数据库维护子进程] 父进程已退出,正在退出...");
exit(0);
}
}
try {
// 尝试执行一个简单的数据库查询来测试连接
$addon_model = new Addon();
$addon_model->getAddonList([], 'name', 1, 1); // 只查询一条记录
ws_echo("[数据库维护子进程] 数据库连接正常");
} catch (\Exception $e) {
ws_echo("[数据库维护子进程] 数据库连接异常: {$e->getMessage()}", 'error');
ws_echo("[数据库维护子进程] 尝试重新初始化数据库连接...");
try {
// 重新初始化应用和数据库连接
$app->initialize();
$cache = $app->cache;
ws_echo("[数据库维护子进程] 重新初始化应用成功");
} catch (\Exception $retryEx) {
ws_echo("[数据库维护子进程] 重新初始化应用失败: {$retryEx->getMessage()}", 'error');
}
}
// 等待指定的时间间隔
sleep($checkInterval);
}
} else {
// 父进程记录子进程PID并设置信号处理
$dbMaintenancePid = $pid;
// 检查是否在Windows平台
$isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
// 设置父进程的信号处理
pcntl_signal(SIGINT, function() use ($dbMaintenancePid, $isWindows) {
ws_echo("[WebSocket服务器] 收到终止信号,正在停止...");
// 如果子进程存在,发送终止信号
if ($dbMaintenancePid) {
ws_echo("[WebSocket服务器] 停止数据库连接维护进程");
if (!$isWindows && function_exists('posix_kill')) {
posix_kill($dbMaintenancePid, SIGTERM);
// 等待子进程退出
pcntl_wait($status);
} else {
ws_echo("[WebSocket服务器] 运行在Windows平台跳过子进程终止信号发送");
}
}
ws_echo("[WebSocket服务器] 已停止");
exit(0);
});
}
}
pcntl_signal(SIGINT, function() {
ws_echo("[WebSocket服务器] 收到终止信号,正在停止...");
ws_echo("[WebSocket服务器] 已停止");
exit(0);
});
}
// 运行服务器
ws_echo("[WebSocket服务器] 启动主服务器进程");