From b6c133952ba0526d417d4db84c6d7d2efbb4d96c Mon Sep 17 00:00:00 2001 From: molong Date: Wed, 18 Feb 2026 21:50:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=EF=BC=8Cwebsocket=E5=8A=9F=E8=83=BD=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Services/Auth/UserService.php | 61 +- app/Services/WebSocket/WebSocketHandler.php | 867 ++++----- app/Services/WebSocket/WebSocketService.php | 559 +++--- config/laravels.php | 2 +- docs/DICTIONARY_CACHE_UPDATE.md | 415 ---- docs/LOG_IMPLEMENTATION_SUMMARY.md | 337 ---- docs/README_LARAVERS.md | 1500 +++++++++++++++ docs/README_LOG.md | 608 ------ docs/README_SYSTEM.md | 858 ++++++++- docs/README_WEBSOCKET_NOTIFICATION.md | 1666 +++++++++++------ resources/admin/src/App.vue | 112 +- resources/admin/src/api/auth.js | 207 +- resources/admin/src/api/system.js | 360 ++-- resources/admin/src/assets/style/app.scss | 4 +- resources/admin/src/assets/style/auth.scss | 62 +- resources/admin/src/boot.js | 13 +- .../admin/src/components/scCron/index.vue | 78 +- .../src/components/scEditor/UploadAdapter.js | 30 +- .../admin/src/components/scEditor/index.vue | 24 +- .../admin/src/components/scExport/README.md | 177 +- .../admin/src/components/scExport/index.vue | 167 +- .../admin/src/components/scForm/index.vue | 356 +++- .../src/components/scIconPicker/index.vue | 673 +++++-- .../admin/src/components/scImport/README.md | 94 +- .../admin/src/components/scImport/index.vue | 191 +- .../admin/src/components/scSelect/README.md | 516 ++--- .../admin/src/components/scSelect/index.vue | 180 +- .../admin/src/components/scTable/index.vue | 374 ++-- .../admin/src/components/scUpload/file.vue | 138 +- .../admin/src/components/scUpload/index.vue | 307 +-- .../admin/src/composables/useWebSocket.js | 572 +++--- resources/admin/src/config/index.js | 40 +- resources/admin/src/config/routes.js | 4 +- resources/admin/src/config/upload.js | 24 +- resources/admin/src/hooks/useI18n.js | 12 +- resources/admin/src/hooks/useTable.js | 179 +- resources/admin/src/i18n/index.js | 20 +- resources/admin/src/i18n/locales/en-US.js | 487 ++--- resources/admin/src/i18n/locales/zh-CN.js | 487 ++--- .../src/layouts/components/breadcrumb.vue | 49 +- .../admin/src/layouts/components/navMenu.vue | 43 +- .../admin/src/layouts/components/search.vue | 148 +- .../admin/src/layouts/components/setting.vue | 183 +- .../admin/src/layouts/components/sideMenu.vue | 169 +- .../admin/src/layouts/components/tags.vue | 265 +-- .../admin/src/layouts/components/task.vue | 174 +- .../admin/src/layouts/components/userbar.vue | 897 +++++---- resources/admin/src/layouts/index.vue | 276 +-- resources/admin/src/layouts/other/404.vue | 44 +- resources/admin/src/layouts/other/empty.vue | 57 +- resources/admin/src/main.js | 32 +- .../departments/components/SaveDialog.vue | 236 ++- .../src/pages/auth/departments/index.vue | 336 ++-- .../src/pages/auth/online-users/index.vue | 342 ++-- .../src/pages/auth/online-users/sessions.vue | 252 ++- .../auth/permissions/components/SaveForm.vue | 497 +++-- .../src/pages/auth/permissions/index.vue | 483 +++-- .../auth/roles/components/CopyDialog.vue | 144 +- .../roles/components/PermissionDialog.vue | 133 +- .../auth/roles/components/SaveDialog.vue | 186 +- .../admin/src/pages/auth/roles/index.vue | 435 +++-- .../auth/users/components/BatchRoleDialog.vue | 98 +- .../users/components/DepartmentDialog.vue | 102 +- .../auth/users/components/RoleDialog.vue | 114 +- .../auth/users/components/SaveDialog.vue | 388 ++-- .../admin/src/pages/auth/users/index.vue | 634 ++++--- .../pages/home/components/QuickActions.vue | 346 ++-- .../src/pages/home/components/Welcome.vue | 80 +- resources/admin/src/pages/home/index.vue | 8 +- resources/admin/src/pages/login/index.vue | 147 +- .../admin/src/pages/login/resetPassword.vue | 152 +- .../admin/src/pages/login/userRegister.vue | 133 +- .../system/config/components/ConfigDialog.vue | 345 ++-- .../system/config/components/ConfigForm.vue | 237 ++- .../system/config/components/SaveDialog.vue | 286 +-- .../admin/src/pages/system/config/index.vue | 394 ++-- .../components/DictionaryDialog.vue | 254 ++- .../dictionaries/components/ItemDialog.vue | 272 ++- .../src/pages/system/dictionaries/index.vue | 553 ++++-- .../src/pages/system/notifications/index.vue | 539 +++--- .../tasks/components/TaskDetailDialog.vue | 108 +- .../system/tasks/components/TaskDialog.vue | 385 ++-- .../admin/src/pages/system/tasks/index.vue | 367 ++-- .../pages/ucenter/components/BasicInfo.vue | 117 +- .../src/pages/ucenter/components/Password.vue | 139 +- .../pages/ucenter/components/ProfileInfo.vue | 20 +- .../src/pages/ucenter/components/Security.vue | 74 +- resources/admin/src/pages/ucenter/index.vue | 181 +- resources/admin/src/router/index.js | 150 +- resources/admin/src/router/systemRoutes.js | 36 +- resources/admin/src/stores/index.js | 10 +- .../admin/src/stores/modules/dictionary.js | 139 +- resources/admin/src/stores/modules/i18n.js | 59 +- resources/admin/src/stores/modules/layout.js | 114 +- resources/admin/src/stores/modules/message.js | 218 +-- .../admin/src/stores/modules/notification.js | 379 ++-- resources/admin/src/stores/modules/user.js | 80 +- resources/admin/src/stores/persist.js | 14 +- resources/admin/src/style.css | 94 +- resources/admin/src/utils/request.js | 126 +- resources/admin/src/utils/websocket.js | 534 ++++-- 101 files changed, 15829 insertions(+), 10739 deletions(-) delete mode 100644 docs/DICTIONARY_CACHE_UPDATE.md delete mode 100644 docs/LOG_IMPLEMENTATION_SUMMARY.md create mode 100644 docs/README_LARAVERS.md delete mode 100644 docs/README_LOG.md diff --git a/app/Services/Auth/UserService.php b/app/Services/Auth/UserService.php index a1335b2..e8d619a 100644 --- a/app/Services/Auth/UserService.php +++ b/app/Services/Auth/UserService.php @@ -3,6 +3,8 @@ namespace App\Services\Auth; use App\Models\Auth\User; +use App\Models\System\Notification; +use App\Services\System\NotificationService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -16,10 +18,12 @@ use App\Jobs\Auth\UserExportJob; class UserService { protected $departmentService; + protected $notificationService; - public function __construct(DepartmentService $departmentService) + public function __construct(DepartmentService $departmentService, NotificationService $notificationService) { $this->departmentService = $departmentService; + $this->notificationService = $notificationService; } /** @@ -29,6 +33,7 @@ class UserService { return Auth::guard('admin')->id(); } + /** * 获取用户列表 */ @@ -215,6 +220,12 @@ class UserService } DB::commit(); + + // 发送更新通知(如果更新的是其他用户) + // if ($id !== $this->getCurrentUserId()) { + $this->sendUserUpdateNotification($user, $data); + // } + return $user; } catch (\Exception $e) { DB::rollBack(); @@ -352,4 +363,52 @@ class UserService 'updated_at' => $user->updated_at->toDateTimeString(), ]; } + + /** + * 发送用户更新通知 + */ + private function sendUserUpdateNotification(User $user, array $data): void + { + // 收集被更新的字段 + $changes = []; + + $fieldLabels = [ + 'username' => '用户名', + 'real_name' => '姓名', + 'email' => '邮箱', + 'phone' => '手机号', + 'department_id' => '所属部门', + 'avatar' => '头像', + 'status' => '状态', + 'password' => '密码', + 'role_ids' => '角色', + ]; + + foreach ($data as $key => $value) { + if (isset($fieldLabels[$key])) { + $changes[] = $fieldLabels[$key]; + } + } + + if (empty($changes)) { + return; + } + + // 生成通知内容 + $content = '您的账户信息已被管理员更新,更新的内容:' . implode('、', $changes); + + // 发送通知 + $this->notificationService->sendToUser( + $user->id, + '个人信息已更新', + $content, + Notification::TYPE_INFO, + Notification::CATEGORY_SYSTEM, + [ + 'user_id' => $user->id, + 'updated_fields' => $changes, + 'action_type' => Notification::ACTION_NONE, + ] + ); + } } diff --git a/app/Services/WebSocket/WebSocketHandler.php b/app/Services/WebSocket/WebSocketHandler.php index ff58836..2c865f9 100644 --- a/app/Services/WebSocket/WebSocketHandler.php +++ b/app/Services/WebSocket/WebSocketHandler.php @@ -3,513 +3,530 @@ namespace App\Services\WebSocket; use Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface; +use Illuminate\Support\Facades\Log; use Swoole\Http\Request; +use Swoole\Http\Response; use Swoole\WebSocket\Frame; use Swoole\WebSocket\Server; -use Illuminate\Support\Facades\Log; -use App\Services\Auth\UserOnlineService; +use Tymon\JWTAuth\Facades\JWTAuth; /** - * WebSocket Handler + * WebSocket 处理器 * - * Handles WebSocket connections, messages, and disconnections + * 处理 WebSocket 连接事件:onOpen, onMessage, onClose */ class WebSocketHandler implements WebSocketHandlerInterface { /** - * @var UserOnlineService - */ - protected $userOnlineService; - - /** - * Get wsTable instance - * - * @return \Swoole\Table - */ - protected function getWsTable(): \Swoole\Table - { - return app('swoole')->wsTable; - } - - /** - * WebSocketHandler constructor + * WebSocketHandlerInterface 需要的构造函数 + * wsTable 直接从 handler 方法的 $server 参数中访问 */ public function __construct() { - $this->userOnlineService = app(UserOnlineService::class); + // 空构造函数 - wsTable 从 server 参数中访问 } /** - * Handle WebSocket connection open event + * 处理 WebSocket 握手(可选) * - * @param Server $server - * @param Request $request + * @param Request $request 请求对象 + * @param Response $response 响应对象 + * @return void + */ + // public function onHandShake(Request $request, Response $response) + // { + // // 自定义握手逻辑(如果需要) + // // 握手成功后,onOpen 事件会自动触发 + // } + + /** + * 处理连接打开事件 + * + * @param Server $server WebSocket 服务器对象 + * @param Request $request 请求对象 * @return void */ public function onOpen(Server $server, Request $request): void { try { - $fd = $request->fd; - $path = $request->server['path_info'] ?? $request->server['request_uri'] ?? '/'; + // 从服务器获取 wsTable + $wsTable = $server->wsTable; - Log::info('WebSocket connection opened', [ - 'fd' => $fd, - 'path' => $path, - 'ip' => $request->server['remote_addr'] ?? 'unknown' - ]); + // 从查询字符串获取 user_id 和 token + $userId = (int)($request->get['user_id'] ?? 0); + $token = $request->get['token'] ?? ''; - // Extract user ID from query parameters if provided - $userId = $request->get['user_id'] ?? null; - $token = $request->get['token'] ?? null; - - if ($userId && $token) { - // Store user connection mapping - $this->getWsTable()->set('uid:' . $userId, [ - 'value' => $fd, - 'expiry' => time() + 3600, // 1 hour expiry - ]); - - $this->getWsTable()->set('fd:' . $fd, [ - 'value' => $userId, - 'expiry' => time() + 3600 - ]); - - // Update user online status - $this->userOnlineService->updateUserOnlineStatus($userId, $fd, true); - - Log::info('User connected to WebSocket', [ - 'user_id' => $userId, - 'fd' => $fd - ]); - - // Send welcome message to client - $server->push($fd, json_encode([ - 'type' => 'welcome', - 'data' => [ - 'message' => 'WebSocket connection established', - 'user_id' => $userId, - 'timestamp' => time() - ] - ])); - } else { - Log::warning('WebSocket connection without authentication', [ - 'fd' => $fd - ]); - - // Send error message - $server->push($fd, json_encode([ + // 用户认证 + if (!$userId || !$token) { + $server->push($request->fd, json_encode([ 'type' => 'error', 'data' => [ - 'message' => 'Authentication required. Please provide user_id and token.', + 'message' => '认证失败:缺少 user_id 或 token', 'code' => 401 ] ])); + $server->disconnect($request->fd); + return; } - } catch (\Exception $e) { - Log::error('WebSocket onOpen error', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() + + // 验证 JWT token + try { + $payload = JWTAuth::setToken($token)->getPayload(); + + // 验证 token 中的用户 ID 是否匹配 + $tokenUserId = $payload['sub'] ?? null; + if ($tokenUserId != $userId) { + Log::warning('WebSocket 认证失败:用户 ID 不匹配', [ + 'fd' => $request->fd, + 'token_user_id' => $tokenUserId, + 'query_user_id' => $userId + ]); + + $server->push($request->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '认证失败:用户 ID 不匹配', + 'code' => 401 + ] + ])); + $server->disconnect($request->fd); + return; + } + + // 验证 token 是否过期 + if (isset($payload['exp']) && $payload['exp'] < time()) { + Log::warning('WebSocket 认证失败:token 已过期', [ + 'fd' => $request->fd, + 'user_id' => $userId, + 'exp' => $payload['exp'], + 'current_time' => time() + ]); + + $server->push($request->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '认证失败:token 已过期', + 'code' => 401 + ] + ])); + $server->disconnect($request->fd); + return; + } + + Log::info('WebSocket 认证成功', [ + 'fd' => $request->fd, + 'user_id' => $userId + ]); + } catch (\Exception $e) { + Log::warning('WebSocket 认证失败:无效的 token', [ + 'fd' => $request->fd, + 'user_id' => $userId, + 'error' => $e->getMessage() + ]); + + $server->push($request->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '认证失败:无效的 token', + 'code' => 401 + ] + ])); + $server->disconnect($request->fd); + return; + } + + // 存储连接映射:uid:{userId} -> fd + $wsTable->set('uid:' . $userId, [ + 'value' => $request->fd, + 'expiry' => time() + 3600 // 1 小时过期 ]); + + // 存储反向映射:fd:{fd} -> userId + $wsTable->set('fd:' . $request->fd, [ + 'value' => $userId, + 'expiry' => time() + 3600 + ]); + + // 发送欢迎消息 + $server->push($request->fd, json_encode([ + 'type' => 'connected', + 'data' => [ + 'message' => '欢迎连接到 LaravelS WebSocket', + 'user_id' => $userId, + 'fd' => $request->fd, + 'timestamp' => time() + ] + ])); + + Log::info('WebSocket 连接已打开', [ + 'fd' => $request->fd, + 'user_id' => $userId, + 'ip' => $request->server['remote_addr'] + ]); + } catch (\Exception $e) { + Log::error('WebSocket onOpen 错误', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'fd' => $request->fd + ]); + + $server->push($request->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '连接错误:' . $e->getMessage(), + 'code' => 500 + ] + ])); + $server->disconnect($request->fd); } } /** - * Handle WebSocket message event + * 处理接收消息事件 * - * @param Server $server - * @param Frame $frame + * @param Server $server WebSocket 服务器对象 + * @param Frame $frame WebSocket 帧对象 * @return void */ public function onMessage(Server $server, Frame $frame): void { try { - $fd = $frame->fd; - $data = $frame->data; + // 从服务器获取 wsTable + $wsTable = $server->wsTable; - Log::info('WebSocket message received', [ - 'fd' => $fd, - 'data' => $data, - 'opcode' => $frame->opcode - ]); + // 从 fd 映射获取 user_id + $fdInfo = $wsTable->get('fd:' . $frame->fd); + if ($fdInfo === false) { + $server->disconnect($frame->fd); + return; + } - // Parse incoming message - $message = json_decode($data, true); + $userId = (int)$fdInfo['value']; - if (!$message) { - $server->push($fd, json_encode([ + // 解析消息 + $message = json_decode($frame->data, true); + + if (!$message || !isset($message['type'])) { + $server->push($frame->fd, json_encode([ 'type' => 'error', 'data' => [ - 'message' => 'Invalid JSON format', + 'message' => '无效的消息格式', 'code' => 400 ] ])); return; } - // Handle different message types - $this->handleMessage($server, $fd, $message); - } catch (\Exception $e) { - Log::error('WebSocket onMessage error', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() + $type = $message['type']; + $data = $message['data'] ?? []; + + Log::info('收到 WebSocket 消息', [ + 'fd' => $frame->fd, + 'user_id' => $userId, + 'type' => $type ]); - } - } - /** - * Handle WebSocket message based on type - * - * @param Server $server - * @param int $fd - * @param array $message - * @return void - */ - protected function handleMessage(Server $server, int $fd, array $message): void - { - $type = $message['type'] ?? 'unknown'; - $data = $message['data'] ?? []; + // 处理不同类型的消息 + switch ($type) { + case 'ping': + // 响应 ping + $server->push($frame->fd, json_encode([ + 'type' => 'pong', + 'data' => $data + ])); + break; - switch ($type) { - case 'auth': - // Handle authentication confirmation - $this->handleAuth($server, $fd, $data); - break; + case 'heartbeat': + // 心跳确认 + $server->push($frame->fd, json_encode([ + 'type' => 'heartbeat_ack', + 'data' => array_merge($data, [ + 'timestamp' => time() + ]) + ])); + break; - case 'ping': - // Respond to ping with pong - $server->push($fd, json_encode([ - 'type' => 'pong', - 'data' => [ - 'timestamp' => time() - ] - ])); - break; + case 'chat': + // 私聊消息 + $this->handleChatMessage($server, $wsTable, $frame, $userId, $data); + break; - case 'heartbeat': - // Handle heartbeat - $server->push($fd, json_encode([ - 'type' => 'heartbeat_ack', - 'data' => [ - 'timestamp' => time() - ] - ])); - break; + case 'broadcast': + // 广播消息给所有用户 + $this->handleBroadcast($server, $wsTable, $userId, $data); + break; - case 'chat': - // Handle chat message - $this->handleChatMessage($server, $fd, $data); - break; + case 'subscribe': + // 订阅频道 + $this->handleSubscribe($server, $wsTable, $frame, $userId, $data); + break; - case 'broadcast': - // Handle broadcast message (admin only) - $this->handleBroadcast($server, $fd, $data); - break; + case 'unsubscribe': + // 取消订阅频道 + $this->handleUnsubscribe($server, $wsTable, $frame, $userId, $data); + break; - case 'subscribe': - // Handle channel subscription - $this->handleSubscribe($server, $fd, $data); - break; - - case 'unsubscribe': - // Handle channel unsubscription - $this->handleUnsubscribe($server, $fd, $data); - break; - - default: - $server->push($fd, json_encode([ - 'type' => 'error', - 'data' => [ - 'message' => 'Unknown message type: ' . $type, - 'code' => 400 - ] - ])); - break; - } - } - - /** - * Handle authentication confirmation - * - * @param Server $server - * @param int $fd - * @param array $data - * @return void - */ - protected function handleAuth(Server $server, int $fd, array $data): void - { - $userId = $data['user_id'] ?? null; - $token = $data['token'] ?? null; - - // Get the user ID from wsTable (set during connection) - $storedUserId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null; - - if ($storedUserId && $storedUserId == $userId) { - // Authentication confirmed, send success response - $server->push($fd, json_encode([ - 'type' => 'connected', - 'data' => [ - 'user_id' => $storedUserId, - 'message' => 'Authentication confirmed', - 'timestamp' => time() - ] - ])); - - Log::info('WebSocket authentication confirmed', [ - 'fd' => $fd, - 'user_id' => $userId - ]); - } else { - // Authentication failed - $server->push($fd, json_encode([ - 'type' => 'error', - 'data' => [ - 'message' => 'Authentication failed. User ID mismatch.', - 'code' => 401 - ] - ])); - - Log::warning('WebSocket authentication failed', [ - 'fd' => $fd, - 'stored_user_id' => $storedUserId, - 'provided_user_id' => $userId - ]); - } - } - - /** - * Handle chat message - * - * @param Server $server - * @param int $fd - * @param array $data - * @return void - */ - protected function handleChatMessage(Server $server, int $fd, array $data): void - { - $toUserId = $data['to_user_id'] ?? null; - $content = $data['content'] ?? ''; - - if (!$toUserId || !$content) { - $server->push($fd, json_encode([ - 'type' => 'error', - 'data' => [ - 'message' => 'Missing required fields: to_user_id and content', - 'code' => 400 - ] - ])); - return; - } - - // Get target user's connection - $targetFd = $this->getWsTable()->get('uid:' . $toUserId); - - if ($targetFd && $targetFd['value']) { - $server->push((int)$targetFd['value'], json_encode([ - 'type' => 'chat', - 'data' => [ - 'from_user_id' => $this->getWsTable()->get('fd:' . $fd)['value'] ?? null, - 'content' => $content, - 'timestamp' => time() - ] - ])); - - // Send delivery receipt to sender - $server->push($fd, json_encode([ - 'type' => 'message_delivered', - 'data' => [ - 'to_user_id' => $toUserId, - 'content' => $content, - 'timestamp' => time() - ] - ])); - } else { - $server->push($fd, json_encode([ - 'type' => 'error', - 'data' => [ - 'message' => 'Target user is not online', - 'code' => 404 - ] - ])); - } - } - - /** - * Handle broadcast message - * - * @param Server $server - * @param int $fd - * @param array $data - * @return void - */ - protected function handleBroadcast(Server $server, int $fd, array $data): void - { - $message = $data['message'] ?? ''; - $userId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null; - - // TODO: Check if user has admin permission to broadcast - // For now, allow any authenticated user - - if (!$message) { - $server->push($fd, json_encode([ - 'type' => 'error', - 'data' => [ - 'message' => 'Message content is required', - 'code' => 400 - ] - ])); - return; - } - - // Broadcast to all connected clients except sender - $broadcastData = json_encode([ - 'type' => 'broadcast', - 'data' => [ - 'from_user_id' => $userId, - 'message' => $message, - 'timestamp' => time() - ] - ]); - - foreach ($server->connections as $connectionFd) { - if ($server->isEstablished($connectionFd) && $connectionFd !== $fd) { - $server->push($connectionFd, $broadcastData); + default: + // 未知消息类型 + $server->push($frame->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '未知的消息类型:' . $type, + 'code' => 400 + ] + ])); + break; } + } catch (\Exception $e) { + Log::error('WebSocket onMessage 错误', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'fd' => $frame->fd + ]); } - - // Send confirmation to sender - $server->push($fd, json_encode([ - 'type' => 'broadcast_sent', - 'data' => [ - 'message' => $message, - 'timestamp' => time() - ] - ])); } /** - * Handle channel subscription + * 处理连接关闭事件 * - * @param Server $server - * @param int $fd - * @param array $data - * @return void - */ - protected function handleSubscribe(Server $server, int $fd, array $data): void - { - $channel = $data['channel'] ?? ''; - - if (!$channel) { - $server->push($fd, json_encode([ - 'type' => 'error', - 'data' => [ - 'message' => 'Channel name is required', - 'code' => 400 - ] - ])); - return; - } - - // Store subscription in wsTable - $this->getWsTable()->set('channel:' . $channel . ':fd:' . $fd, [ - 'value' => 1, - 'expiry' => time() + 7200 // 2 hours - ]); - - $server->push($fd, json_encode([ - 'type' => 'subscribed', - 'data' => [ - 'channel' => $channel, - 'timestamp' => time() - ] - ])); - - Log::info('User subscribed to channel', [ - 'fd' => $fd, - 'channel' => $channel - ]); - } - - /** - * Handle channel unsubscription - * - * @param Server $server - * @param int $fd - * @param array $data - * @return void - */ - protected function handleUnsubscribe(Server $server, int $fd, array $data): void - { - $channel = $data['channel'] ?? ''; - - if (!$channel) { - $server->push($fd, json_encode([ - 'type' => 'error', - 'data' => [ - 'message' => 'Channel name is required', - 'code' => 400 - ] - ])); - return; - } - - // Remove subscription from wsTable - $this->getWsTable()->del('channel:' . $channel . ':fd:' . $fd); - - $server->push($fd, json_encode([ - 'type' => 'unsubscribed', - 'data' => [ - 'channel' => $channel, - 'timestamp' => time() - ] - ])); - - Log::info('User unsubscribed from channel', [ - 'fd' => $fd, - 'channel' => $channel - ]); - } - - /** - * Handle WebSocket connection close event - * - * @param Server $server - * @param $fd - * @param $reactorId + * @param Server $server WebSocket 服务器对象 + * @param int $fd 文件描述符 + * @param int $reactorId 反应器 ID * @return void */ public function onClose(Server $server, $fd, $reactorId): void { try { - Log::info('WebSocket connection closed', [ - 'fd' => $fd, - 'reactor_id' => $reactorId - ]); + // 从服务器获取 wsTable + $wsTable = $server->wsTable; - // Get user ID from wsTable - $userId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null; + // 从 fd 映射获取 user_id + $fdInfo = $wsTable->get('fd:' . $fd); - if ($userId) { - // Remove user connection mapping - $this->getWsTable()->del('uid:' . $userId); - $this->getWsTable()->del('fd:' . $fd); + if ($fdInfo !== false) { + $userId = (int)$fdInfo['value']; - // Update user online status - $this->userOnlineService->updateUserOnlineStatus($userId, $fd, false); + // 删除 uid 映射 + $wsTable->del('uid:' . $userId); - Log::info('User disconnected from WebSocket', [ + // 删除该用户的所有频道订阅 + $this->removeUserFromAllChannels($wsTable, $userId, $fd); + + Log::info('WebSocket 连接已关闭', [ + 'fd' => $fd, 'user_id' => $userId, - 'fd' => $fd + 'reactor_id' => $reactorId ]); } - // Clean up channel subscriptions - // Note: In production, you might want to iterate through all channel keys - // and remove the ones associated with this fd + // 删除 fd 映射 + $wsTable->del('fd:' . $fd); } catch (\Exception $e) { - Log::error('WebSocket onClose error', [ + Log::error('WebSocket onClose 错误', [ 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() + 'trace' => $e->getTraceAsString(), + 'fd' => $fd ]); } } + + /** + * 处理私聊消息 + * + * @param Server $server WebSocket 服务器对象 + * @param \Swoole\Table $wsTable WebSocket 表 + * @param Frame $frame WebSocket 帧对象 + * @param int $fromUserId 发送者用户 ID + * @param array $data 消息数据 + * @return void + */ + protected function handleChatMessage(Server $server, \Swoole\Table $wsTable, Frame $frame, int $fromUserId, array $data): void + { + $toUserId = $data['to_user_id'] ?? 0; + + if (!$toUserId) { + $server->push($frame->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '缺少 to_user_id', + 'code' => 400 + ] + ])); + return; + } + + // 获取接收者的 fd + $recipientInfo = $wsTable->get('uid:' . $toUserId); + + if ($recipientInfo === false) { + $server->push($frame->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '用户不在线', + 'to_user_id' => $toUserId, + 'code' => 404 + ] + ])); + return; + } + + $toFd = (int)$recipientInfo['value']; + + // 发送消息给接收者 + $server->push($toFd, json_encode([ + 'type' => 'chat', + 'data' => array_merge($data, [ + 'from_user_id' => $fromUserId, + 'timestamp' => time() + ]) + ])); + } + + /** + * 处理广播消息 + * + * @param Server $server WebSocket 服务器对象 + * @param \Swoole\Table $wsTable WebSocket 表 + * @param int $userId 用户 ID + * @param array $data 消息数据 + * @return void + */ + protected function handleBroadcast(Server $server, \Swoole\Table $wsTable, int $userId, array $data): void + { + $excludeUserId = $data['exclude_user_id'] ?? null; + $message = json_encode([ + 'type' => 'broadcast', + 'data' => array_merge($data, [ + 'from_user_id' => $userId, + 'timestamp' => time() + ]) + ]); + + // 发送消息给所有连接的用户 + foreach ($wsTable as $key => $row) { + if (strpos($key, 'uid:') === 0) { + $targetUserId = (int)substr($key, 4); // 移除 'uid:' 前缀 + $fd = (int)$row['value']; + + // 跳过排除的用户 + if ($excludeUserId && $targetUserId == $excludeUserId) { + continue; + } + + if ($server->isEstablished($fd)) { + $server->push($fd, $message); + } + } + } + } + + /** + * 处理频道订阅 + * + * @param Server $server WebSocket 服务器对象 + * @param \Swoole\Table $wsTable WebSocket 表 + * @param Frame $frame WebSocket 帧对象 + * @param int $userId 用户 ID + * @param array $data 消息数据 + * @return void + */ + protected function handleSubscribe(Server $server, \Swoole\Table $wsTable, Frame $frame, int $userId, array $data): void + { + $channel = $data['channel'] ?? ''; + + if (!$channel) { + $server->push($frame->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '缺少频道名称', + 'code' => 400 + ] + ])); + return; + } + + // 存储频道订阅 + $channelKey = 'channel:' . $channel . ':fd:' . $frame->fd; + $wsTable->set($channelKey, [ + 'value' => $userId, + 'expiry' => time() + 3600 + ]); + + $server->push($frame->fd, json_encode([ + 'type' => 'subscribed', + 'data' => [ + 'channel' => $channel, + 'message' => '成功订阅频道:' . $channel, + 'timestamp' => time() + ] + ])); + + Log::info('用户订阅频道', [ + 'user_id' => $userId, + 'channel' => $channel, + 'fd' => $frame->fd + ]); + } + + /** + * 处理频道取消订阅 + * + * @param Server $server WebSocket 服务器对象 + * @param \Swoole\Table $wsTable WebSocket 表 + * @param Frame $frame WebSocket 帧对象 + * @param int $userId 用户 ID + * @param array $data 消息数据 + * @return void + */ + protected function handleUnsubscribe(Server $server, \Swoole\Table $wsTable, Frame $frame, int $userId, array $data): void + { + $channel = $data['channel'] ?? ''; + + if (!$channel) { + $server->push($frame->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '缺少频道名称', + 'code' => 400 + ] + ])); + return; + } + + // 删除频道订阅 + $channelKey = 'channel:' . $channel . ':fd:' . $frame->fd; + $wsTable->del($channelKey); + + $server->push($frame->fd, json_encode([ + 'type' => 'unsubscribed', + 'data' => [ + 'channel' => $channel, + 'message' => '成功取消订阅频道:' . $channel, + 'timestamp' => time() + ] + ])); + + Log::info('用户取消订阅频道', [ + 'user_id' => $userId, + 'channel' => $channel, + 'fd' => $frame->fd + ]); + } + + /** + * 从所有频道中移除用户 + * + * @param \Swoole\Table $wsTable WebSocket 表 + * @param int $userId 用户 ID + * @param int $fd 文件描述符 + * @return void + */ + protected function removeUserFromAllChannels(\Swoole\Table $wsTable, int $userId, int $fd): void + { + foreach ($wsTable as $key => $row) { + if (strpos($key, 'channel:') === 0 && strpos($key, ':fd:' . $fd) !== false) { + $wsTable->del($key); + } + } + } } diff --git a/app/Services/WebSocket/WebSocketService.php b/app/Services/WebSocket/WebSocketService.php index 0e05ec1..4e40b53 100644 --- a/app/Services/WebSocket/WebSocketService.php +++ b/app/Services/WebSocket/WebSocketService.php @@ -6,91 +6,90 @@ use Illuminate\Support\Facades\Log; use Swoole\WebSocket\Server; /** - * WebSocket Service + * WebSocket 服务 * - * Provides helper functions for WebSocket operations + * 提供 WebSocket 操作的便捷方法 */ class WebSocketService { /** - * Get Swoole WebSocket Server instance + * 获取 Swoole Server 实例 * - * @return Server|null + * @return Server */ - public function getServer(): ?Server + protected function getServer(): Server { - // Check if Laravel-S is running - if (!class_exists('Hhxsv5\LaravelS\Illuminate\Laravel') || !defined('IN_LARAVELS')) { - return null; - } - - try { - // Try to get the Swoole server from the Laravel-S container - $laravelS = \Hhxsv5\LaravelS\Illuminate\Laravel::getInstance(); - if ($laravelS && $laravelS->getSwooleServer()) { - return $laravelS->getSwooleServer(); - } - } catch (\Exception $e) { - Log::warning('Failed to get Swoole server instance', [ - 'error' => $e->getMessage() - ]); - } - - return null; + /** @var Server $server */ + $server = app('swoole'); + return $server; } /** - * Send message to a specific user + * 获取 WebSocket 表 * - * @param int $userId - * @param array $data + * @return \Swoole\Table + */ + protected function getWsTable(): \Swoole\Table + { + return app('swoole')->wsTable; + } + + /** + * 发送消息给指定用户 + * + * @param int $userId 用户 ID + * @param array $data 消息数据 * @return bool */ public function sendToUser(int $userId, array $data): bool { - $server = $this->getServer(); + try { + $wsTable = $this->getWsTable(); + $server = $this->getServer(); - if (!$server) { - Log::warning('WebSocket server not available', ['user_id' => $userId]); + // 获取用户的 fd + $fdInfo = $wsTable->get('uid:' . $userId); + + if ($fdInfo === false) { + return false; + } + + $fd = (int)$fdInfo['value']; + + // 检查连接是否仍然建立 + if (!$server->isEstablished($fd)) { + // 删除过期连接 + $wsTable->del('uid:' . $userId); + $wsTable->del('fd:' . $fd); + return false; + } + + // 发送消息 + $result = $server->push($fd, json_encode($data)); + + Log::info('消息已发送给用户', [ + 'user_id' => $userId, + 'fd' => $fd, + 'success' => $result + ]); + + return $result; + } catch (\Exception $e) { + Log::error('发送消息给用户失败', [ + 'user_id' => $userId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); return false; } - - $wsTable = app('swoole')->wsTable; - - $fdInfo = $wsTable->get('uid:' . $userId); - - if (!$fdInfo || !$fdInfo['value']) { - Log::info('User not connected to WebSocket', ['user_id' => $userId]); - return false; - } - - $fd = (int)$fdInfo['value']; - - if (!$server->isEstablished($fd)) { - Log::info('WebSocket connection not established', ['user_id' => $userId, 'fd' => $fd]); - // Clean up stale connection - $wsTable->del('uid:' . $userId); - $wsTable->del('fd:' . $fd); - return false; - } - - $server->push($fd, json_encode($data)); - - Log::info('Message sent to user via WebSocket', [ - 'user_id' => $userId, - 'fd' => $fd, - 'data' => $data - ]); - - return true; } /** - * Send message to multiple users + * 发送消息给多个用户 * - * @param array $userIds - * @param array $data - * @return array Array of user IDs who received the message + * @param array $userIds 用户 ID 数组 + * @param array $data 消息数据 + * @return array 成功发送的用户 ID 数组 */ public function sendToUsers(array $userIds, array $data): array { @@ -106,247 +105,263 @@ class WebSocketService } /** - * Broadcast message to all connected clients + * 广播消息给所有用户 * - * @param array $data - * @param int|null $excludeUserId User ID to exclude from broadcast - * @return int Number of clients the message was sent to + * @param array $data 消息数据 + * @param int|null $excludeUserId 要排除的用户 ID + * @return int 成功发送的用户数量 */ public function broadcast(array $data, ?int $excludeUserId = null): int { - $server = $this->getServer(); + try { + $wsTable = $this->getWsTable(); + $server = $this->getServer(); - if (!$server) { - Log::warning('WebSocket server not available for broadcast'); - return 0; - } + $message = json_encode($data); + $count = 0; - $wsTable = app('swoole')->wsTable; - $message = json_encode($data); - $count = 0; - - foreach ($server->connections as $fd) { - if (!$server->isEstablished($fd)) { - continue; - } - - // Check if we should exclude this user - if ($excludeUserId) { - $fdInfo = $wsTable->get('fd:' . $fd); - if ($fdInfo && $fdInfo['value'] == $excludeUserId) { + foreach ($wsTable as $key => $row) { + // 只处理用户映射(uid:*) + if (strpos($key, 'uid:') !== 0) { continue; } + + $userId = (int)substr($key, 4); // 移除 'uid:' 前缀 + $fd = (int)$row['value']; + + // 跳过排除的用户 + if ($excludeUserId && $userId == $excludeUserId) { + continue; + } + + // 检查连接是否已建立并发送 + if ($server->isEstablished($fd)) { + if ($server->push($fd, $message)) { + $count++; + } + } else { + // 删除过期连接 + $wsTable->del('uid:' . $userId); + $wsTable->del('fd:' . $fd); + } } - $server->push($fd, $message); - $count++; + Log::info('广播消息已发送', [ + 'exclude_user_id' => $excludeUserId, + 'sent_to' => $count + ]); + + return $count; + } catch (\Exception $e) { + Log::error('广播消息失败', [ + 'exclude_user_id' => $excludeUserId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + return 0; } - - Log::info('Broadcast sent via WebSocket', [ - 'data' => $data, - 'exclude_user_id' => $excludeUserId, - 'count' => $count - ]); - - return $count; } /** - * Send message to all subscribers of a channel + * 发送消息到频道 * - * @param string $channel - * @param array $data - * @return int Number of subscribers who received the message + * @param string $channel 频道名称 + * @param array $data 消息数据 + * @return int 成功发送的订阅者数量 */ public function sendToChannel(string $channel, array $data): int { - $server = $this->getServer(); + try { + $wsTable = $this->getWsTable(); + $server = $this->getServer(); - if (!$server) { - Log::warning('WebSocket server not available for channel broadcast', ['channel' => $channel]); + $message = json_encode($data); + $count = 0; + $channelPrefix = 'channel:' . $channel . ':fd:'; + + foreach ($wsTable as $key => $row) { + // 只处理该频道的订阅 + if (strpos($key, $channelPrefix) !== 0) { + continue; + } + + $fd = (int)substr($key, strlen($channelPrefix)); + + // 检查连接是否已建立并发送 + if ($server->isEstablished($fd)) { + if ($server->push($fd, $message)) { + $count++; + } + } else { + // 删除过期订阅 + $wsTable->del($key); + } + } + + Log::info('消息已发送到频道', [ + 'channel' => $channel, + 'sent_to' => $count + ]); + + return $count; + } catch (\Exception $e) { + Log::error('发送消息到频道失败', [ + 'channel' => $channel, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); return 0; } - - $wsTable = app('swoole')->wsTable; - $count = 0; - $message = json_encode($data); - - // Iterate through all connections and check if they're subscribed to the channel - foreach ($server->connections as $fd) { - if (!$server->isEstablished($fd)) { - continue; - } - - $subscription = $wsTable->get('channel:' . $channel . ':fd:' . $fd); - - if ($subscription) { - $server->push($fd, $message); - $count++; - } - } - - Log::info('Channel message sent via WebSocket', [ - 'channel' => $channel, - 'data' => $data, - 'count' => $count - ]); - - return $count; } /** - * Get online user count + * 获取在线用户数量 * * @return int */ public function getOnlineUserCount(): int { - $server = $this->getServer(); + try { + $wsTable = $this->getWsTable(); + $count = 0; - if (!$server || !isset($server->wsTable)) { + foreach ($wsTable as $key => $row) { + if (strpos($key, 'uid:') === 0) { + $count++; + } + } + + return $count; + } catch (\Exception $e) { + Log::error('获取在线用户数量失败', [ + 'error' => $e->getMessage() + ]); return 0; } - - // Count established connections - $count = 0; - foreach ($server->connections as $fd) { - if ($server->isEstablished($fd)) { - $count++; - } - } - - return $count; } /** - * Check if a user is online + * 检查用户是否在线 * - * @param int $userId + * @param int $userId 用户 ID * @return bool */ public function isUserOnline(int $userId): bool { - $server = $this->getServer(); + try { + $wsTable = $this->getWsTable(); + $fdInfo = $wsTable->get('uid:' . $userId); - if (!$server) { - return false; - } + if ($fdInfo === false) { + return false; + } - $wsTable = app('swoole')->wsTable; + $server = $this->getServer(); + $fd = (int)$fdInfo['value']; - $fdInfo = $wsTable->get('uid:' . $userId); - - if (!$fdInfo || !$fdInfo['value']) { - return false; - } - - $fd = (int)$fdInfo['value']; - - return $server->isEstablished($fd); - } - - /** - * Disconnect a user from WebSocket - * - * @param int $userId - * @return bool - */ - public function disconnectUser(int $userId): bool - { - $server = $this->getServer(); - - if (!$server) { - return false; - } - - $wsTable = app('swoole')->wsTable; - - $fdInfo = $wsTable->get('uid:' . $userId); - - if (!$fdInfo || !$fdInfo['value']) { - return false; - } - - $fd = (int)$fdInfo['value']; - - if ($server->isEstablished($fd)) { - $server->push($fd, json_encode([ - 'type' => 'disconnect', - 'data' => [ - 'message' => 'You have been disconnected', - 'timestamp' => time() - ] - ])); - - // Close the connection - $server->disconnect($fd); - - // Clean up - $wsTable->del('uid:' . $userId); - $wsTable->del('fd:' . $fd); - - Log::info('User disconnected from WebSocket by server', [ + return $server->isEstablished($fd); + } catch (\Exception $e) { + Log::error('检查用户在线状态失败', [ 'user_id' => $userId, - 'fd' => $fd + 'error' => $e->getMessage() ]); - - return true; + return false; } - - return false; } /** - * Get all online user IDs + * 获取在线用户 ID 列表 * * @return array */ public function getOnlineUserIds(): array { - $server = $this->getServer(); + try { + $wsTable = $this->getWsTable(); + $userIds = []; - if (!$server) { + foreach ($wsTable as $key => $row) { + if (strpos($key, 'uid:') === 0) { + $userId = (int)substr($key, 4); // 移除 'uid:' 前缀 + $userIds[] = $userId; + } + } + + return $userIds; + } catch (\Exception $e) { + Log::error('获取在线用户 ID 列表失败', [ + 'error' => $e->getMessage() + ]); return []; } - - $wsTable = app('swoole')->wsTable; - - $userIds = []; - - foreach ($server->connections as $fd) { - if (!$server->isEstablished($fd)) { - continue; - } - - $fdInfo = $wsTable->get('fd:' . $fd); - - if ($fdInfo && $fdInfo['value']) { - $userIds[] = (int)$fdInfo['value']; - } - } - - return array_unique($userIds); } /** - * Send system notification to all online users + * 断开用户 WebSocket 连接 * - * @param string $title - * @param string $message - * @param string $type - * @param array $extraData - * @return int + * @param int $userId 用户 ID + * @return bool */ - public function sendSystemNotification(string $title, string $message, string $type = 'info', array $extraData = []): int + public function disconnectUser(int $userId): bool { + try { + $wsTable = $this->getWsTable(); + $server = $this->getServer(); + + // 获取用户的 fd + $fdInfo = $wsTable->get('uid:' . $userId); + + if ($fdInfo === false) { + return false; + } + + $fd = (int)$fdInfo['value']; + + // 断开连接 + $server->disconnect($fd); + + // 删除映射 + $wsTable->del('uid:' . $userId); + $wsTable->del('fd:' . $fd); + + Log::info('用户已断开连接', [ + 'user_id' => $userId, + 'fd' => $fd + ]); + + return true; + } catch (\Exception $e) { + Log::error('断开用户连接失败', [ + 'user_id' => $userId, + 'error' => $e->getMessage() + ]); + return false; + } + } + + /** + * 发送系统通知 + * + * @param string $title 标题 + * @param string $message 消息内容 + * @param string $type 类型 + * @param array $extraData 额外数据 + * @return int 成功发送的用户数量 + */ + public function sendSystemNotification( + string $title, + string $message, + string $type = 'info', + array $extraData = [] + ): int { $data = [ 'type' => 'notification', 'data' => [ 'title' => $title, 'message' => $message, - 'type' => $type, // info, success, warning, error - 'timestamp' => time(), - ...$extraData + 'type' => $type, + 'data' => $extraData, + 'timestamp' => time() ] ]; @@ -354,47 +369,57 @@ class WebSocketService } /** - * Send notification to specific users + * 发送通知给指定用户 * - * @param array $userIds - * @param string $title - * @param string $message - * @param string $type - * @param array $extraData - * @return array + * @param array $userIds 用户 ID 数组 + * @param string $title 标题 + * @param string $message 消息内容 + * @param string $type 类型 + * @param array $extraData 额外数据 + * @return int 成功发送的用户数量 */ - public function sendNotificationToUsers(array $userIds, string $title, string $message, string $type = 'info', array $extraData = []): array - { + public function sendNotificationToUsers( + array $userIds, + string $title, + string $message, + string $type = 'info', + array $extraData = [] + ): int { $data = [ 'type' => 'notification', 'data' => [ 'title' => $title, 'message' => $message, 'type' => $type, - 'timestamp' => time(), - ...$extraData + 'data' => $extraData, + 'timestamp' => time() ] ]; - return $this->sendToUsers($userIds, $data); + $sentTo = $this->sendToUsers($userIds, $data); + return count($sentTo); } /** - * Push data update to specific users + * 推送数据更新 * - * @param array $userIds - * @param string $resourceType - * @param string $action - * @param array $data - * @return array + * @param array $userIds 用户 ID 数组 + * @param string $resourceType 资源类型 + * @param string $action 操作 + * @param array $data 数据 + * @return array 成功推送的用户 ID 数组 */ - public function pushDataUpdate(array $userIds, string $resourceType, string $action, array $data): array - { + public function pushDataUpdate( + array $userIds, + string $resourceType, + string $action, + array $data + ): array { $message = [ 'type' => 'data_update', 'data' => [ - 'resource_type' => $resourceType, // e.g., 'user', 'order', 'product' - 'action' => $action, // create, update, delete + 'resource_type' => $resourceType, + 'action' => $action, 'data' => $data, 'timestamp' => time() ] @@ -404,16 +429,20 @@ class WebSocketService } /** - * Push data update to a channel + * 推送数据更新到频道 * - * @param string $channel - * @param string $resourceType - * @param string $action - * @param array $data - * @return int + * @param string $channel 频道名称 + * @param string $resourceType 资源类型 + * @param string $action 操作 + * @param array $data 数据 + * @return int 成功推送的订阅者数量 */ - public function pushDataUpdateToChannel(string $channel, string $resourceType, string $action, array $data): int - { + public function pushDataUpdateToChannel( + string $channel, + string $resourceType, + string $action, + array $data + ): int { $message = [ 'type' => 'data_update', 'data' => [ diff --git a/config/laravels.php b/config/laravels.php index a42a82b..1dfcfcc 100644 --- a/config/laravels.php +++ b/config/laravels.php @@ -221,7 +221,7 @@ return [ 'swoole_tables' => [ // WebSocket table for storing user connections - 'wsTable' => [ + 'ws' => [ 'size' => 102400, // Maximum number of rows 'column' => [ ['name' => 'value', 'type' => \Swoole\Table::TYPE_STRING, 'size' => 1024], diff --git a/docs/DICTIONARY_CACHE_UPDATE.md b/docs/DICTIONARY_CACHE_UPDATE.md deleted file mode 100644 index e9088aa..0000000 --- a/docs/DICTIONARY_CACHE_UPDATE.md +++ /dev/null @@ -1,415 +0,0 @@ -# 字典缓存更新机制 - -## 概述 - -本文档说明前后端字典缓存的更新逻辑,确保在字典分类和字典项的增删改等操作后,前端字典缓存能够自动更新。 - -## 技术实现 - -### 1. 后端实现 - -#### 1.1 DictionaryService 更新 - -在 `app/Services/System/DictionaryService.php` 中添加了 WebSocket 通知功能: - -**依赖注入:** -```php -protected $webSocketService; - -public function __construct(WebSocketService $webSocketService) -{ - $this->webSocketService = $webSocketService; -} -``` - -**通知方法:** - -1. **字典分类更新通知** (`notifyDictionaryUpdate`) - - 触发时机:创建、更新、删除、批量删除、批量更新状态 - - 消息类型:`dictionary_update` - -2. **字典项更新通知** (`notifyDictionaryItemUpdate`) - - 触发时机:创建、更新、删除、批量删除、批量更新状态 - - 消息类型:`dictionary_item_update` - -**修改的方法列表:** - -- `create()` - 创建字典分类后发送通知 -- `update()` - 更新字典分类后发送通知 -- `delete()` - 删除字典分类后发送通知 -- `batchDelete()` - 批量删除字典分类后发送通知 -- `batchUpdateStatus()` - 批量更新状态后发送通知 -- `createItem()` - 创建字典项后发送通知 -- `updateItem()` - 更新字典项后发送通知 -- `deleteItem()` - 删除字典项后发送通知 -- `batchDeleteItems()` - 批量删除字典项后发送通知 -- `batchUpdateItemsStatus()` - 批量更新字典项状态后发送通知 - -#### 1.2 WebSocket 消息格式 - -**字典分类更新消息:** -```json -{ - "type": "dictionary_update", - "data": { - "action": "create|update|delete|batch_delete|batch_update_status", - "resource_type": "dictionary", - "data": { - // 字典分类数据 - }, - "timestamp": 1234567890 - } -} -``` - -**字典项更新消息:** -```json -{ - "type": "dictionary_item_update", - "data": { - "action": "create|update|delete|batch_delete|batch_update_status", - "resource_type": "dictionary_item", - "data": { - // 字典项数据 - }, - "timestamp": 1234567890 - } -} -``` - -### 2. 前端实现 - -#### 2.1 WebSocket Composable - -创建了 `resources/admin/src/composables/useWebSocket.js` 来处理 WebSocket 连接和消息监听: - -**主要功能:** - -1. **初始化 WebSocket 连接** - - 检查用户登录状态 - - 验证用户信息完整性 - - 建立连接并注册消息处理器 - -2. **消息处理器** - - `handleDictionaryUpdate` - 处理字典分类更新 - - `handleDictionaryItemUpdate` - 处理字典项更新 - -3. **缓存刷新** - - 接收到更新通知后,自动刷新字典缓存 - - 显示成功提示消息 - -#### 2.2 App.vue 集成 - -在 `resources/admin/src/App.vue` 中集成了 WebSocket: - -**生命周期钩子:** - -```javascript -onMounted(async () => { - // ... 其他初始化代码 - - // 初始化 WebSocket 连接 - if (userStore.isLoggedIn()) { - initWebSocket() - } -}) - -onUnmounted(() => { - // 关闭 WebSocket 连接 - closeWebSocket() -}) -``` - -## 工作流程 - -### 完整流程图 - -``` -用户操作(增删改字典) - ↓ -后端 Controller 调用 Service - ↓ -Service 执行数据库操作 - ↓ -Service 清理后端缓存(Redis) - ↓ -Service 发送 WebSocket 广播通知 - ↓ -WebSocket 推送消息到所有在线客户端 - ↓ -前端接收 WebSocket 消息 - ↓ -触发相应的消息处理器 - ↓ -刷新前端字典缓存 - ↓ -显示成功提示 -``` - -### 详细步骤 - -1. **用户操作** - - 管理员在后台管理界面进行字典分类或字典项的增删改操作 - - 例如:创建新字典分类、修改字典项、批量删除等 - -2. **后端处理** - - 接收请求并验证数据 - - 执行数据库操作(INSERT/UPDATE/DELETE) - - 清理 Redis 缓存(`DictionaryService::clearCache()`) - - 通过 WebSocket 广播更新通知 - -3. **WebSocket 通知** - - 服务端向所有连接的 WebSocket 客户端广播消息 - - 消息包含操作类型、资源类型和更新的数据 - -4. **前端接收** - - App.vue 在 onMounted 时初始化 WebSocket 连接 - - 注册消息处理器监听 `dictionary_update` 和 `dictionary_item_update` 事件 - - 接收到消息后调用对应的处理器 - -5. **缓存刷新** - - 处理器调用 `dictionaryStore.refresh(true)` 强制刷新缓存 - - 从后端 API 重新加载所有字典数据 - - 更新 Pinia store 中的字典数据 - - 持久化到本地存储 - -6. **用户反馈** - - 显示 "字典数据已更新" 的成功提示 - - 页面上的字典数据自动更新,无需手动刷新 - -## 使用示例 - -### 示例 1:创建新字典分类 - -```php -// 后端代码 -$dictionary = $dictionaryService->create([ - 'name' => '订单状态', - 'code' => 'order_status', - 'description' => '订单状态字典', - 'value_type' => 'string', - 'sort' => 1, - 'status' => true -]); - -// 自动触发: -// 1. 清理 Redis 缓存 -// 2. 广播 WebSocket 消息 -``` - -前端自动刷新缓存并显示提示。 - -### 示例 2:更新字典项 - -```php -// 后端代码 -$item = $dictionaryService->updateItem(1, [ - 'label' => '已付款', - 'value' => 'paid', - 'sort' => 2 -]); - -// 自动触发: -// 1. 清理对应字典的 Redis 缓存 -// 2. 广播 WebSocket 消息 -``` - -前端自动刷新缓存并显示提示。 - -### 示例 3:批量操作 - -```php -// 后端代码 -$dictionaryService->batchUpdateStatus([1, 2, 3], false); - -// 自动触发: -// 1. 清理所有相关字典的 Redis 缓存 -// 2. 广播 WebSocket 消息(包含批量更新的 ID) -``` - -前端自动刷新缓存并显示提示。 - -## 注意事项 - -### 1. WebSocket 连接 - -- WebSocket 仅在用户登录后建立连接 -- 连接失败会自动重试(最多 5 次) -- 页面卸载时会自动关闭连接 - -### 2. 缓存一致性 - -- 后端缓存使用 Redis,TTL 为 3600 秒(1 小时) -- 前端缓存使用 Pinia + 本地存储持久化 -- WebSocket 通知确保前后端缓存同步更新 - -### 3. 错误处理 - -- WebSocket 连接失败不影响页面正常使用 -- 缓存刷新失败会在控制台输出错误日志 -- 不会阻塞用户操作 - -### 4. 性能考虑 - -- 批量操作会一次性清理相关缓存 -- WebSocket 广播向所有在线用户推送 -- 前端刷新时会重新加载所有字典数据 - -## 扩展建议 - -### 1. 细粒度缓存更新 - -当前实现是全量刷新,未来可以优化为增量更新: - -```javascript -// 只更新受影响的字典 -async function handleDictionaryUpdate(data) { - const { action, data: dictData } = data - - if (action === 'update' && dictData.code) { - // 只更新特定的字典 - await dictionaryStore.getDictionary(dictData.code, true) - } else { - // 全量刷新 - await dictionaryStore.refresh(true) - } -} -``` - -### 2. 权限控制 - -可以只向有权限的用户发送通知: - -```php -// 后端只向有字典管理权限的用户发送 -$adminUserIds = User::whereHas('roles', function($query) { - $query->where('name', 'admin'); -})->pluck('id')->toArray(); - -$this->webSocketService->sendToUsers($adminUserIds, $message); -``` - -### 3. 消息队列 - -对于高并发场景,可以使用消息队列异步发送 WebSocket 通知: - -```php -// 使用 Laravel 队列 -UpdateDictionaryCacheJob::dispatch($action, $data); -``` - -## 测试建议 - -### 1. 单元测试 - -测试后端 WebSocket 通知是否正确发送: - -```php -public function testDictionaryUpdateSendsWebSocketNotification() -{ - $this->mockWebSocketService(); - - $dictionary = DictionaryService::create([ - 'name' => 'Test', - 'code' => 'test' - ]); - - // 验证 WebSocket 广播被调用 -} -``` - -### 2. 集成测试 - -1. 启动后端服务(Laravel-S) -2. 启动前端开发服务器 -3. 在浏览器中登录系统 -4. 打开开发者工具的 Network -> WS 标签查看 WebSocket 消息 -5. 执行字典增删改操作 -6. 验证: - - WebSocket 消息是否正确接收 - - 缓存是否自动刷新 - - 页面数据是否更新 - - 提示消息是否显示 - -### 3. 并发测试 - -1. 打开多个浏览器窗口并登录 -2. 在一个窗口中进行字典操作 -3. 验证所有窗口的缓存是否同步更新 - -## 故障排查 - -### 问题 1:前端未收到 WebSocket 消息 - -**可能原因:** -- WebSocket 服务未启动 -- 网络连接问题 -- 用户未登录 -- Laravel-S 未运行(使用普通 PHP 运行时) - -**解决方法:** -1. 检查 Laravel-S 服务是否启动:`php bin/laravels status` -2. 检查浏览器控制台是否有 WebSocket 错误 -3. 确认用户已登录且有 token -4. 确认是否在 Laravel-S 环境下运行(WebSocket 通知仅在 Laravel-S 环境下有效) - -**注意:** -- WebSocket 通知功能依赖于 Laravel-S (Swoole) 环境 -- 在普通 PHP 环境下运行时,WebSocket 通知会优雅降级(不发送通知,但不影响功能) -- 仍需手动刷新页面或使用 API 轮询来获取最新数据 - -### 问题 2:后端 WebSocket 通知发送失败 - -**可能原因:** -- Laravel-S 未运行 -- Swoole 服务器未启动 -- WebSocket 服务实例获取失败 - -**解决方法:** -1. 确认在 Laravel-S 环境下运行:`php bin/laravels start` -2. 检查 Laravel-S 配置文件 `config/laravels.php` -3. 查看后端日志:`tail -f storage/logs/laravel.log` - -**注意:** -- 如果未在 Laravel-S 环境下运行,后端会记录警告日志,但不会报错 -- 字典数据仍会正常更新到数据库和 Redis 缓存 -- 只是前端不会收到自动更新通知 - -### 问题 3:缓存未更新 - -**可能原因:** -- WebSocket 消息处理失败 -- API 请求失败 -- 前端未连接 WebSocket - -**解决方法:** -1. 查看浏览器控制台错误日志 -2. 检查网络请求是否成功 -3. 手动刷新页面验证 API 是否正常 -4. 确认 WebSocket 连接状态(浏览器开发者工具 Network -> WS) - -### 问题 3:通知频繁弹出 - -**可能原因:** -- 批量操作触发了多次通知 - -**解决方法:** -1. 优化后端批量操作,只发送一次通知 -2. 前端添加防抖/节流逻辑 - -## 总结 - -通过 WebSocket 实现的字典缓存自动更新机制,确保了前后端数据的一致性,提升了用户体验。用户无需手动刷新页面即可获取最新的字典数据。 - -### 优势 - -- ✅ 实时更新,无需手动刷新 -- ✅ 多端同步,所有在线用户自动更新 -- ✅ 操作透明,用户有明确的反馈 -- ✅ 易于扩展,可应用于其他数据类型 - -### 限制 - -- 需要稳定的 WebSocket 连接 -- 当前实现为全量刷新,可以优化为增量更新 -- 依赖后端服务(Laravel-S)正常运行 diff --git a/docs/LOG_IMPLEMENTATION_SUMMARY.md b/docs/LOG_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 9209b35..0000000 --- a/docs/LOG_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,337 +0,0 @@ -# 日志模块实现总结 - -## 实现概述 - -本次优化完善了后端日志模块,实现了自动化的请求日志记录功能,所有后台管理 API 请求都会被自动记录到数据库中。 - -## 实现内容 - -### 1. 新增文件 - -#### 中间件 -- **app/Http/Middleware/LogRequestMiddleware.php** - - 自动拦截所有经过的请求 - - 记录请求和响应信息 - - 计算请求执行时间 - - 提取用户信息和操作详情 - - 自动过滤敏感参数(密码、token等) - - 获取客户端真实 IP(支持代理) - -#### 请求验证 -- **app/Http/Requests/LogRequest.php** - - 统一的请求参数验证 - - 支持列表查询、批量删除、清理等操作的参数验证 - - 自定义错误消息 - - 自动设置默认值 - -#### 文档 -- **docs/README_LOG.md** - - 完整的模块文档 - - API 接口说明 - - 数据库表结构 - - 使用示例 - - 前端集成代码 - - 常见问题解答 - -### 2. 修改文件 - -#### 控制器 -- **app/Http/Controllers/System/Admin/Log.php** - - 添加 `export` 方法:支持导出日志数据为 Excel - - 使用 `LogRequest` 进行参数验证 - - 优化响应格式 - -#### 服务层 -- **app/Services/System/LogService.php** - - 添加 `getListQuery` 方法:提供查询构建器(用于导出等场景) - - 新增 `buildQuery` 方法:统一的查询构建逻辑 - - 代码重构,减少重复代码 - -#### 路由配置 -- **routes/admin.php** - - 添加 `POST /admin/logs/export` 导出路由 - - 在所有需要认证的路由组中应用 `log.request` 中间件 - -#### 中间件配置 -- **bootstrap/app.php** - - 注册 `log.request` 中间件别名 - - 创建 `admin.log` 中间件组 - -## 功能特性 - -### 自动日志记录 -- ✅ 所有后台管理 API 请求自动记录 -- ✅ 记录用户信息(ID、用户名) -- ✅ 记录请求信息(方法、URL、参数) -- ✅ 记录响应信息(状态码、执行时间) -- ✅ 记录客户端信息(IP、User-Agent) -- ✅ 错误请求记录详细错误信息 - -### 敏感信息保护 -- ✅ 自动过滤密码字段 -- ✅ 自动过滤 token 字段 -- ✅ 自动过滤 secret 字段 -- ✅ 自动过滤 key 字段 - -### 日志管理功能 -- ✅ 多维度查询(用户、模块、操作、状态、时间、IP) -- ✅ 分页查询 -- ✅ 日志详情查看 -- ✅ 日志统计(总数、成功数、失败数) -- ✅ 单条删除 -- ✅ 批量删除 -- ✅ 定期清理(按天数) -- ✅ 导出为 Excel - -### 性能优化 -- ✅ 日志记录在请求处理后执行 -- ✅ 不影响业务响应速度 -- ✅ 异常处理,记录失败不影响业务 -- ✅ 支持分页查询,避免一次性加载过多数据 - -## API 接口列表 - -| 接口 | 方法 | 说明 | -|------|------|------| -| `/admin/logs` | GET | 获取日志列表 | -| `/admin/logs/{id}` | GET | 获取日志详情 | -| `/admin/logs/statistics` | GET | 获取日志统计 | -| `/admin/logs/export` | POST | 导出日志(Excel) | -| `/admin/logs/{id}` | DELETE | 删除单条日志 | -| `/admin/logs/batch-delete` | POST | 批量删除日志 | -| `/admin/logs/clear` | POST | 清理历史日志 | - -## 数据库表结构 - -### system_logs 表 - -已存在的表结构,包含以下字段: -- id: 主键 -- user_id: 用户 ID -- username: 用户名 -- module: 模块名称 -- action: 操作名称 -- method: 请求方法 -- url: 请求 URL -- ip: 客户端 IP -- user_agent: 用户代理 -- params: 请求参数(JSON) -- result: 响应结果 -- status_code: HTTP 状态码 -- status: 状态(success/error) -- error_message: 错误信息 -- execution_time: 执行时间(毫秒) -- created_at: 创建时间 -- updated_at: 更新时间 - -## 中间件应用范围 - -### 已应用的路由 -- ✅ 所有 `/admin/*` 路由(除登录接口) -- ✅ 认证相关(登出、刷新、个人信息、修改密码) -- ✅ 用户管理 -- ✅ 角色管理 -- ✅ 权限管理 -- ✅ 部门管理 -- ✅ 在线用户管理 -- ✅ 系统配置管理 -- ✅ 数据字典管理 -- ✅ 任务管理 -- ✅ 城市数据管理 -- ✅ 文件上传管理 - -### 未应用的路由 -- ❌ 登录接口(`POST /admin/auth/login`) -- ❌ 健康检查接口(`GET /up`) - -## 使用示例 - -### 后端使用 - -中间件会自动记录所有请求,无需手动调用: - -```php -// 任何经过 log.request 中间件的请求都会被自动记录 -Route::middleware(['auth.check:admin', 'log.request'])->group(function () { - Route::apiResource('users', UserController::class); - // 其他路由... -}); -``` - -### 前端调用示例 - -```javascript -// 获取日志列表 -const response = await request.get('/admin/logs', { - params: { - username: 'admin', - module: 'users', - status: 'success', - page: 1, - page_size: 20 - } -}) - -// 导出日志 -await request.post('/admin/logs/export', { - username: 'admin', - status: 'error' -}, { - responseType: 'blob' -}) - -// 批量删除 -await request.post('/admin/logs/batch-delete', { - ids: [1, 2, 3, 4, 5] -}) - -// 清理历史日志 -await request.post('/admin/logs/clear', { - days: 30 -}) -``` - -## 日志记录示例 - -### 成功请求日志 -```json -{ - "id": 1, - "user_id": 1, - "username": "admin", - "module": "users", - "action": "创建 users", - "method": "POST", - "url": "http://example.com/admin/users", - "ip": "192.168.1.1", - "user_agent": "Mozilla/5.0...", - "params": { - "name": "test", - "email": "test@example.com", - "password": "******" - }, - "result": null, - "status_code": 200, - "status": "success", - "error_message": null, - "execution_time": 125, - "created_at": "2024-01-01 12:00:00" -} -``` - -### 失败请求日志 -```json -{ - "id": 2, - "user_id": 1, - "username": "admin", - "module": "users", - "action": "删除 users", - "method": "DELETE", - "url": "http://example.com/admin/users/999", - "ip": "192.168.1.1", - "user_agent": "Mozilla/5.0...", - "params": {}, - "result": "{\"code\":404,\"message\":\"用户不存在\"}", - "status_code": 404, - "status": "error", - "error_message": "用户不存在", - "execution_time": 45, - "created_at": "2024-01-01 12:01:00" -} -``` - -## 注意事项 - -### 1. 性能考虑 -- 日志记录在请求处理后执行,不影响响应速度 -- 大量日志会增加数据库写入压力 -- 建议定期清理历史日志 - -### 2. 数据安全 -- 敏感信息已自动过滤 -- 日志数据应妥善保管 -- 建议定期备份重要日志 - -### 3. 权限控制 -- 日志管理接口需要相应权限 -- 建议只允许管理员查看和操作日志 - -### 4. 数据库优化 -- 确保查询字段有索引 -- 使用分页查询避免加载过多数据 -- 定期清理历史日志 - -## 后续优化建议 - -### 1. 异步队列 -考虑使用 Laravel 队列异步处理日志记录,进一步减少对响应时间的影响。 - -### 2. 日志归档 -实现日志归档功能,将历史日志移动到归档表或文件存储。 - -### 3. 日志分析 -集成日志分析工具,提供可视化仪表盘和趋势分析。 - -### 4. 定时清理 -配置 Laravel 任务调度器,自动清理指定天数前的日志: - -```php -// app/Console/Kernel.php -$schedule->call(function () { - app(LogService::class)->clearLogs(90); -})->dailyAt('02:00'); -``` - -### 5. 日志级别 -增加日志级别(info、warning、error、critical),便于分类管理。 - -## 测试建议 - -### 功能测试 -1. 测试各种请求是否被正确记录 -2. 测试敏感信息是否被正确过滤 -3. 测试日志查询和筛选功能 -4. 测试日志导出功能 -5. 测试批量删除和清理功能 - -### 性能测试 -1. 测试日志记录对响应时间的影响 -2. 测试大量日志数据的查询性能 -3. 测试并发写入的性能 - -### 边界测试 -1. 测试异常情况下的日志记录 -2. 测试超长参数的处理 -3. 测试特殊字符的处理 - -## 文件清单 - -### 新增文件 -``` -app/Http/Middleware/LogRequestMiddleware.php -app/Http/Requests/LogRequest.php -docs/README_LOG.md -docs/LOG_IMPLEMENTATION_SUMMARY.md -``` - -### 修改文件 -``` -app/Http/Controllers/System/Admin/Log.php -app/Services/System/LogService.php -routes/admin.php -bootstrap/app.php -``` - -## 总结 - -本次日志模块优化完善实现了: -- ✅ 全自动化的请求日志记录 -- ✅ 完善的日志管理功能 -- ✅ 敏感信息保护 -- ✅ 多维度查询和筛选 -- ✅ 数据导出功能 -- ✅ 批量操作支持 -- ✅ 完整的文档说明 - -日志模块现已完全集成到项目中,所有后台管理 API 请求都会被自动记录,管理员可以通过日志管理功能进行系统监控、审计和问题排查。 diff --git a/docs/README_LARAVERS.md b/docs/README_LARAVERS.md new file mode 100644 index 0000000..0161a84 --- /dev/null +++ b/docs/README_LARAVERS.md @@ -0,0 +1,1500 @@ +
+ LaravelS Logo +

+ 中文文档 | + English Docs +

+

🚀 LaravelS 是 Laravel/Lumen 和 Swoole 之间开箱即用的适配器

+

+ + Latest Version + + + PHP Version + + + Swoole Version + + + Total Downloads + + + Build Status + + + Code Intelligence Status + + + License + +

+
+ +--- + +## 持续更新 +- *请`Watch`此仓库,以获得最新的更新。* +- **QQ交流群**: + - `698480528` [![点击加群](https://pub.idqqimg.com/wpa/images/group.png "点击加群")](//shang.qq.com/wpa/qunwpa?idkey=f949191c8f413a3ecc5fbce661e57d379740ba92172bd50b02d23a5ab36cc7d6) + - `62075835` [![点击加群](https://pub.idqqimg.com/wpa/images/group.png "点击加群")](//shang.qq.com/wpa/qunwpa?idkey=5230f8da0693a812811e21e19d5823ee802ee5d24def177663f42a32a9060e97) + +文档目录 +================= + +* [特性](#特性) +* [Benchmark](#benchmark) +* [要求](#要求) +* [安装](#安装) +* [运行](#运行) +* [部署](#部署) +* [与Nginx配合使用(推荐)](#与nginx配合使用推荐) +* [与Apache配合使用](#与apache配合使用) +* [启用WebSocket服务器](#启用websocket服务器) +* [监听事件](#监听事件) + * [系统事件](#系统事件) + * [自定义的异步事件](#自定义的异步事件) +* [异步的任务队列](#异步的任务队列) +* [毫秒级定时任务](#毫秒级定时任务) +* [修改代码后自动Reload](#修改代码后自动reload) +* [在你的项目中使用SwooleServer实例](#在你的项目中使用swooleserver实例) +* [使用SwooleTable](#使用swooletable) +* [多端口混合协议](#多端口混合协议) +* [协程](#协程) +* [自定义进程](#自定义进程) +* [常用组件](#常用组件) + * [Apollo](#apollo) + * [Prometheus](#prometheus) +* [其他特性](#其他特性) + * [配置Swoole事件](#配置Swoole事件) + * [Serverless](#serverless) +* [注意事项](#注意事项) +* [用户与案例](#用户与案例) +* [其他选择](#其他选择) +* [赞助](#赞助) + * [感谢](#感谢) +* [Star历史](#star历史) +* [License](#license) + +## 特性 + +- 内置Http/[WebSocket](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/README-CN.md#%E5%90%AF%E7%94%A8websocket%E6%9C%8D%E5%8A%A1%E5%99%A8)服务器 + +- [多端口混合协议](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/README-CN.md#%E5%A4%9A%E7%AB%AF%E5%8F%A3%E6%B7%B7%E5%90%88%E5%8D%8F%E8%AE%AE) + +- [自定义进程](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/README-CN.md#%E8%87%AA%E5%AE%9A%E4%B9%89%E8%BF%9B%E7%A8%8B) + +- 常驻内存 + +- [异步的事件监听](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/README-CN.md#%E8%87%AA%E5%AE%9A%E4%B9%89%E7%9A%84%E5%BC%82%E6%AD%A5%E4%BA%8B%E4%BB%B6) + +- [异步的任务队列](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/README-CN.md#%E5%BC%82%E6%AD%A5%E7%9A%84%E4%BB%BB%E5%8A%A1%E9%98%9F%E5%88%97) + +- [毫秒级定时任务](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/README-CN.md#%E6%AF%AB%E7%A7%92%E7%BA%A7%E5%AE%9A%E6%97%B6%E4%BB%BB%E5%8A%A1) + +- [常用组件](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/README-CN.md#%E5%B8%B8%E7%94%A8%E7%BB%84%E4%BB%B6) + +- 平滑Reload + +- [修改代码后自动Reload](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/README-CN.md#%E4%BF%AE%E6%94%B9%E4%BB%A3%E7%A0%81%E5%90%8E%E8%87%AA%E5%8A%A8reload) + +- 同时支持Laravel与Lumen,兼容主流版本 + +- 简单,开箱即用 + +## Benchmark + +- [Which is the fastest web framework?](https://github.com/the-benchmarker/web-frameworks) + +- [TechEmpower Framework Benchmarks](https://www.techempower.com/benchmarks/) + +## 要求 + +| 依赖 | 说明 | +| -------- | -------- | +| [PHP](https://www.php.net/) | `>=8.2` `启用扩展intl` | +| [Swoole](https://www.swoole.com/) | `>=5.0` | +| [Laravel](https://laravel.com/)/[Lumen](https://lumen.laravel.com/) | `>=10` | + +## 安装 + +1.通过[Composer](https://getcomposer.org/)安装([packagist](https://packagist.org/packages/hhxsv5/laravel-s))。有可能找不到`3.0`版本,解决方案移步[#81](https://github.com/hhxsv5/laravel-s/issues/81)。 +```bash +# PHP >=8.2 +composer require "hhxsv5/laravel-s:~3.8.0" + +# PHP >=5.5.9,<=7.4.33 +# composer require "hhxsv5/laravel-s:~3.7.0" + +# 确保你的composer.lock文件是在版本控制中 +``` + +2.注册Service Provider(以下两步二选一)。 + +- `Laravel`: 修改文件`config/app.php`,`Laravel 5.5+支持包自动发现,你应该跳过这步` + ```php + 'providers' => [ + //... + Hhxsv5\LaravelS\Illuminate\LaravelSServiceProvider::class, + ], + ``` + +- `Lumen`: 修改文件`bootstrap/app.php` + ```php + $app->register(Hhxsv5\LaravelS\Illuminate\LaravelSServiceProvider::class); + ``` + +3.发布配置和二进制文件。 +> *每次升级LaravelS后,需重新publish;点击[Release](https://github.com/hhxsv5/laravel-s/releases)去了解各个版本的变更记录。* + +```bash +php artisan laravels publish +# 配置文件:config/laravels.php +# 二进制文件:bin/laravels bin/fswatch bin/inotify +``` + +4.修改配置`config/laravels.php`:监听的IP、端口等,请参考[配置项](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/Settings-CN.md)。 + +5.性能调优 + +- [调整内核参数](https://wiki.swoole.com/#/other/sysctl?id=%e5%86%85%e6%a0%b8%e5%8f%82%e6%95%b0%e8%b0%83%e6%95%b4) + +- [Worker数量](https://wiki.swoole.com/#/server/setting?id=worker_num):LaravelS 使用 Swoole 的`同步IO`模式,`worker_num`设置的越大并发性能越好,但会造成更多的内存占用和进程切换开销。如果`1`个请求耗时`100ms`,为了提供`1000QPS`的并发能力,至少需要配置`100`个Worker进程,计算方法:worker_num = 1000QPS/(1s/1ms) = 100,故需进行增量压测计算出最佳的`worker_num`。 + +- [Task Worker数量](https://wiki.swoole.com/#/server/setting?id=task_worker_num) + + +## 运行 +> `在运行之前,请先仔细阅读:`[注意事项](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/README-CN.md#%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9)(这非常重要)。 + +- 操作命令:`php bin/laravels {start|stop|restart|reload|info|help}`。 + +| 命令 | 说明 | +| --------- | --------- | +| start | 启动LaravelS,展示已启动的进程列表 "*ps -ef|grep laravels*" | +| stop | 停止LaravelS,并触发自定义进程的`onStop`方法 | +| restart | 重启LaravelS:先平滑`Stop`,然后再`Start`;在`Start`完成之前,服务是`不可用的` | +| reload | 平滑重启所有Task/Worker/Timer进程(这些进程内包含了你的业务代码),并触发自定义进程的`onReload`方法,不会重启Master/Manger进程;修改`config/laravels.php`后,你`只有`调用`restart`来完成重启 | +| info | 显示组件的版本信息 | +| help | 显示帮助信息 | + +- 启动选项,针对`start`和`restart`命令。 + +| 选项 | 说明 | +| --------- | --------- | +| -d|--daemonize | 以守护进程的方式运行,此选项将覆盖`laravels.php`中`swoole.daemonize`设置 | +| -e|--env | 指定运行的环境,如`--env=testing`将会优先使用配置文件`.env.testing`,这个特性要求`Laravel 5.2+` | +| -i|--ignore | 忽略检查Master进程的PID文件 | +| -x|--x-version | 记录当前工程的版本号(分支),保存在`$_ENV`/`$_SERVER`中,访问方式:`$_ENV['X_VERSION']` `$_SERVER['X_VERSION']` `$request->server->get('X_VERSION')` | + +- `运行时`文件:`start`时会自动执行`php artisan laravels config`并生成这些文件,开发者一般不需要关注它们,建议将它们加到`.gitignore`中。 + +| 文件 | 说明 | +| --------- | --------- | +| storage/laravels.conf | LaravelS的`运行时`配置文件 | +| storage/laravels.pid | Master进程的PID文件 | +| storage/laravels-timer-process.pid | 定时器Timer进程的PID文件 | +| storage/laravels-custom-processes.pid | 所有自定义进程的PID文件 | + +## 部署 +> 建议通过[Supervisord](http://supervisord.org/)监管主进程,前提是不能加`-d`选项并且设置`swoole.daemonize`为`false`。 + +``` +[program:laravel-s-test] +directory=/var/www/laravel-s-test +command=/usr/local/bin/php bin/laravels start -i +numprocs=1 +autostart=true +autorestart=true +startretries=3 +user=www-data +redirect_stderr=true +stdout_logfile=/var/log/supervisor/%(program_name)s.log +``` + +## 与Nginx配合使用(推荐) +> [示例](https://github.com/hhxsv5/docker/blob/master/nginx/conf.d/laravels.conf)。 + +```nginx +gzip on; +gzip_min_length 1024; +gzip_comp_level 2; +gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml application/x-httpd-php image/jpeg image/gif image/png font/ttf font/otf image/svg+xml; +gzip_vary on; +gzip_disable "msie6"; +upstream swoole { + # 通过 IP:Port 连接 + server 127.0.0.1:5200 weight=5 max_fails=3 fail_timeout=30s; + # 通过 UnixSocket Stream 连接,小诀窍:将socket文件放在/dev/shm目录下,可获得更好的性能 + #server unix:/yourpath/laravel-s-test/storage/laravels.sock weight=5 max_fails=3 fail_timeout=30s; + #server 192.168.1.1:5200 weight=3 max_fails=3 fail_timeout=30s; + #server 192.168.1.2:5200 backup; + keepalive 16; +} +server { + listen 80; + # 别忘了绑Host + server_name laravels.com; + root /yourpath/laravel-s-test/public; + access_log /yourpath/log/nginx/$server_name.access.log main; + autoindex off; + index index.html index.htm; + # Nginx处理静态资源(建议开启gzip),LaravelS处理动态资源。 + location / { + try_files $uri @laravels; + } + # 当请求PHP文件时直接响应404,防止暴露public/*.php + #location ~* \.php$ { + # return 404; + #} + location @laravels { + # proxy_connect_timeout 60s; + # proxy_send_timeout 60s; + # proxy_read_timeout 120s; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-PORT $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header Scheme $scheme; + proxy_set_header Server-Protocol $server_protocol; + proxy_set_header Server-Name $server_name; + proxy_set_header Server-Addr $server_addr; + proxy_set_header Server-Port $server_port; + # “swoole”是指upstream + proxy_pass http://swoole; + } +} +``` + +## 与Apache配合使用 + +```apache +LoadModule proxy_module /yourpath/modules/mod_proxy.so +LoadModule proxy_balancer_module /yourpath/modules/mod_proxy_balancer.so +LoadModule lbmethod_byrequests_module /yourpath/modules/mod_lbmethod_byrequests.so +LoadModule proxy_http_module /yourpath/modules/mod_proxy_http.so +LoadModule slotmem_shm_module /yourpath/modules/mod_slotmem_shm.so +LoadModule rewrite_module /yourpath/modules/mod_rewrite.so +LoadModule remoteip_module /yourpath/modules/mod_remoteip.so +LoadModule deflate_module /yourpath/modules/mod_deflate.so + + + SetOutputFilter DEFLATE + DeflateCompressionLevel 2 + AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml application/x-httpd-php image/jpeg image/gif image/png font/ttf font/otf image/svg+xml + + + + # 别忘了绑Host + ServerName www.laravels.com + ServerAdmin hhxsv5@sina.com + + DocumentRoot /yourpath/laravel-s-test/public; + DirectoryIndex index.html index.htm + + AllowOverride None + Require all granted + + + RemoteIPHeader X-Forwarded-For + + ProxyRequests Off + ProxyPreserveHost On + + BalancerMember http://192.168.1.1:5200 loadfactor=7 + #BalancerMember http://192.168.1.2:5200 loadfactor=3 + #BalancerMember http://192.168.1.3:5200 loadfactor=1 status=+H + ProxySet lbmethod=byrequests + + #ProxyPass / balancer://laravels/ + #ProxyPassReverse / balancer://laravels/ + + # Apache处理静态资源,LaravelS处理动态资源。 + RewriteEngine On + RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-d + RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-f + RewriteRule ^/(.*)$ balancer://laravels%{REQUEST_URI} [P,L] + + ErrorLog ${APACHE_LOG_DIR}/www.laravels.com.error.log + CustomLog ${APACHE_LOG_DIR}/www.laravels.com.access.log combined + +``` +## 启用WebSocket服务器 +> WebSocket服务器监听的IP和端口与Http服务器相同。 + +1.创建WebSocket Handler类,并实现接口`WebSocketHandlerInterface`。start时会自动实例化,不需要手动创建实例。 + +```php +namespace App\Services; +use Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface; +use Swoole\Http\Request; +use Swoole\Http\Response; +use Swoole\WebSocket\Frame; +use Swoole\WebSocket\Server; +/** + * @see https://wiki.swoole.com/#/start/start_ws_server + */ +class WebSocketService implements WebSocketHandlerInterface +{ + // 声明没有参数的构造函数 + public function __construct() + { + } + // public function onHandShake(Request $request, Response $response) + // { + // 自定义握手:https://wiki.swoole.com/#/websocket_server?id=onhandshake + // 成功握手之后会自动触发onOpen事件 + // } + public function onOpen(Server $server, Request $request) + { + // 在触发onOpen事件之前,建立WebSocket的HTTP请求已经经过了Laravel的路由, + // 所以Laravel的Request、Auth等信息是可读的,Session是可读写的,但仅限在onOpen事件中。 + // \Log::info('New WebSocket connection', [$request->fd, request()->all(), session()->getId(), session('xxx'), session(['yyy' => time()])]); + // 此处抛出的异常会被上层捕获并记录到Swoole日志,开发者需要手动try/catch + $server->push($request->fd, 'Welcome to LaravelS'); + } + public function onMessage(Server $server, Frame $frame) + { + // \Log::info('Received message', [$frame->fd, $frame->data, $frame->opcode, $frame->finish]); + // 此处抛出的异常会被上层捕获并记录到Swoole日志,开发者需要手动try/catch + $server->push($frame->fd, date('Y-m-d H:i:s')); + } + public function onClose(Server $server, $fd, $reactorId) + { + // 此处抛出的异常会被上层捕获并记录到Swoole日志,开发者需要手动try/catch + } +} +``` + +2.更改配置`config/laravels.php`。 + +```php +// ... +'websocket' => [ + 'enable' => true, // 注意:设置enable为true + 'handler' => \App\Services\WebSocketService::class, +], +'swoole' => [ + //... + // dispatch_mode只能设置为2、4、5,https://wiki.swoole.com/#/server/setting?id=dispatch_mode + 'dispatch_mode' => 2, + //... +], +// ... +``` + +3.使用`SwooleTable`绑定FD与UserId,可选的,[Swoole Table示例](#使用swooletable)。也可以用其他全局存储服务,例如Redis/Memcached/MySQL,但需要注意多个`Swoole Server`实例时FD可能冲突。 + +4.与Nginx配合使用(推荐) +> 参考 [WebSocket代理](http://nginx.org/en/docs/http/websocket.html) + +```nginx +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +upstream swoole { + # 通过 IP:Port 连接 + server 127.0.0.1:5200 weight=5 max_fails=3 fail_timeout=30s; + # 通过 UnixSocket Stream 连接,小诀窍:将socket文件放在/dev/shm目录下,可获得更好的性能 + #server unix:/yourpath/laravel-s-test/storage/laravels.sock weight=5 max_fails=3 fail_timeout=30s; + #server 192.168.1.1:5200 weight=3 max_fails=3 fail_timeout=30s; + #server 192.168.1.2:5200 backup; + keepalive 16; +} +server { + listen 80; + # 别忘了绑Host + server_name laravels.com; + root /yourpath/laravel-s-test/public; + access_log /yourpath/log/nginx/$server_name.access.log main; + autoindex off; + index index.html index.htm; + # Nginx处理静态资源(建议开启gzip),LaravelS处理动态资源。 + location / { + try_files $uri @laravels; + } + # 当请求PHP文件时直接响应404,防止暴露public/*.php + #location ~* \.php$ { + # return 404; + #} + # Http和WebSocket共存,Nginx通过location区分 + # !!! WebSocket连接时路径为/ws + # Javascript: var ws = new WebSocket("ws://laravels.com/ws"); + location =/ws { + # proxy_connect_timeout 60s; + # proxy_send_timeout 60s; + # proxy_read_timeout:如果60秒内被代理的服务器没有响应数据给Nginx,那么Nginx会关闭当前连接;同时,Swoole的心跳设置也会影响连接的关闭 + # proxy_read_timeout 60s; + proxy_http_version 1.1; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-PORT $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header Scheme $scheme; + proxy_set_header Server-Protocol $server_protocol; + proxy_set_header Server-Name $server_name; + proxy_set_header Server-Addr $server_addr; + proxy_set_header Server-Port $server_port; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_pass http://swoole; + } + location @laravels { + # proxy_connect_timeout 60s; + # proxy_send_timeout 60s; + # proxy_read_timeout 60s; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-PORT $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header Scheme $scheme; + proxy_set_header Server-Protocol $server_protocol; + proxy_set_header Server-Name $server_name; + proxy_set_header Server-Addr $server_addr; + proxy_set_header Server-Port $server_port; + proxy_pass http://swoole; + } +} +``` + +5.心跳配置 + +- Swoole的心跳配置 + + ```php + // config/laravels.php + 'swoole' => [ + //... + // 表示每60秒遍历一次,一个连接如果600秒内未向服务器发送任何数据,此连接将被强制关闭 + 'heartbeat_idle_time' => 600, + 'heartbeat_check_interval' => 60, + //... + ], + ``` + +- Nginx读取代理服务器超时的配置 + + ```nginx + # 如果60秒内被代理的服务器没有响应数据给Nginx,那么Nginx会关闭当前连接 + proxy_read_timeout 60s; + ``` + +6.在控制器中推送数据 + +```php +namespace App\Http\Controllers; +class TestController extends Controller +{ + public function push() + { + $fd = 1; // Find fd by userId from a map [userId=>fd]. + /**@var \Swoole\WebSocket\Server $swoole */ + $swoole = app('swoole'); + $success = $swoole->push($fd, 'Push data to fd#1 in Controller'); + var_dump($success); + } +} +``` + +## 监听事件 + +### 系统事件 +> 通常,你可以在这些事件中重置或销毁一些全局或静态的变量,也可以修改当前的请求和响应。 + +- `laravels.received_request` 将`Swoole\Http\Request`转成`Illuminate\Http\Request`后,在Laravel内核处理请求前。 + + ```php + // 修改`app/Providers/EventServiceProvider.php`, 添加下面监听代码到boot方法中 + // 如果变量$events不存在,你也可以通过Facade调用\Event::listen()。 + $events->listen('laravels.received_request', function (\Illuminate\Http\Request $req, $app) { + $req->query->set('get_key', 'hhxsv5');// 修改querystring + $req->request->set('post_key', 'hhxsv5'); // 修改post body + }); + ``` + +- `laravels.generated_response` 在Laravel内核处理完请求后,将`Illuminate\Http\Response`转成`Swoole\Http\Response`之前(下一步将响应给客户端)。 + + ```php + // 修改`app/Providers/EventServiceProvider.php`, 添加下面监听代码到boot方法中 + // 如果变量$events不存在,你也可以通过Facade调用\Event::listen()。 + $events->listen('laravels.generated_response', function (\Illuminate\Http\Request $req, \Symfony\Component\HttpFoundation\Response $rsp, $app) { + $rsp->headers->set('header-key', 'hhxsv5');// 修改header + }); + ``` + +### 自定义的异步事件 +> 此特性依赖`Swoole`的`AsyncTask`,必须先设置`config/laravels.php`的`swoole.task_worker_num`。异步事件的处理能力受Task进程数影响,需合理设置[task_worker_num](https://wiki.swoole.com/#/server/setting?id=task_worker_num)。 + +1.创建事件类。 + +```php +use Hhxsv5\LaravelS\Swoole\Task\Event; +class TestEvent extends Event +{ + protected $listeners = [ + // 监听器列表 + TestListener1::class, + // TestListener2::class, + ]; + private $data; + public function __construct($data) + { + $this->data = $data; + } + public function getData() + { + return $this->data; + } +} +``` + +2.创建监听器类。 + +```php +use Hhxsv5\LaravelS\Swoole\Task\Event; +use Hhxsv5\LaravelS\Swoole\Task\Task; +use Hhxsv5\LaravelS\Swoole\Task\Listener; +class TestListener1 extends Listener +{ + public function handle(Event $event) + { + \Log::info(__CLASS__ . ':handle start', [$event->getData()]); + sleep(2);// 模拟一些慢速的事件处理 + // 监听器中也可以投递Task,但不支持Task的finish()回调。 + // 注意:config/laravels.php中修改配置task_ipc_mode为1或2,参考 https://wiki.swoole.com/#/server/setting?id=task_ipc_mode + $ret = Task::deliver(new TestTask('task data')); + var_dump($ret); + // 此处抛出的异常会被上层捕获并记录到Swoole日志,开发者需要手动try/catch + // return false; // 停止将事件传播到后面的监听器 + } +} +``` + +3.触发事件。 + +```php +// 实例化TestEvent并通过fire触发,此操作是异步的,触发后立即返回,由Task进程继续处理监听器中的handle逻辑 +use Hhxsv5\LaravelS\Swoole\Task\Event; +$event = new TestEvent('event data'); +// $event->delay(10); // 延迟10秒触发 +// $event->setTries(3); // 出现异常时,累计尝试3次 +$success = Event::fire($event); +var_dump($success);// 判断是否触发成功 +``` + +## 异步的任务队列 +> 此特性依赖`Swoole`的`AsyncTask`,必须先设置`config/laravels.php`的`swoole.task_worker_num`。异步任务的处理能力受Task进程数影响,需合理设置[task_worker_num](https://wiki.swoole.com/#/server/setting?id=task_worker_num)。 + +1.创建任务类。 + +```php +use Hhxsv5\LaravelS\Swoole\Task\Task; +class TestTask extends Task +{ + private $data; + private $result; + public function __construct($data) + { + $this->data = $data; + } + // 处理任务的逻辑,运行在Task进程中,不能投递任务 + public function handle() + { + \Log::info(__CLASS__ . ':handle start', [$this->data]); + sleep(2);// 模拟一些慢速的事件处理 + // 此处抛出的异常会被上层捕获并记录到Swoole日志,开发者需要手动try/catch + $this->result = 'the result of ' . $this->data; + } + // 可选的,完成事件,任务处理完后的逻辑,运行在Worker进程中,可以投递任务 + public function finish() + { + \Log::info(__CLASS__ . ':finish start', [$this->result]); + Task::deliver(new TestTask2('task2')); // 投递其他任务 + } +} +``` + +2.投递任务。 + +```php +// 实例化TestTask并通过deliver投递,此操作是异步的,投递后立即返回,由Task进程继续处理TestTask中的handle逻辑 +use Hhxsv5\LaravelS\Swoole\Task\Task; +$task = new TestTask('task data'); +// $task->delay(3); // 延迟3秒投递任务 +// $task->setTries(3); // 出现异常时,累计尝试3次 +$ret = Task::deliver($task); +var_dump($ret);// 判断是否投递成功 +``` + +## 毫秒级定时任务 +> 基于[Swoole的毫秒定时器](https://wiki.swoole.com/#/timer),封装的定时任务,取代`Linux`的`Crontab`。 + +1.创建定时任务类。 +```php +namespace App\Jobs\Timer; +use App\Tasks\TestTask; +use Swoole\Coroutine; +use Hhxsv5\LaravelS\Swoole\Task\Task; +use Hhxsv5\LaravelS\Swoole\Timer\CronJob; +class TestCronJob extends CronJob +{ + protected $i = 0; + // !!! 定时任务的`interval`和`isImmediate`有两种配置方式(二选一):一是重载对应的方法,二是注册定时任务时传入参数。 + // --- 重载对应的方法来返回配置:开始 + public function interval() + { + return 1000;// 每1秒运行一次 + } + public function isImmediate() + { + return false;// 是否立即执行第一次,false则等待间隔时间后执行第一次 + } + // --- 重载对应的方法来返回配置:结束 + public function run() + { + \Log::info(__METHOD__, ['start', $this->i, microtime(true)]); + // do something + // sleep(1); // Swoole < 2.1 + Coroutine::sleep(1); // Swoole>=2.1 run()方法已自动创建了协程。 + $this->i++; + \Log::info(__METHOD__, ['end', $this->i, microtime(true)]); + + if ($this->i >= 10) { // 运行10次后不再执行 + \Log::info(__METHOD__, ['stop', $this->i, microtime(true)]); + $this->stop(); // 终止此定时任务,但restart/reload后会再次运行 + // CronJob中也可以投递Task,但不支持Task的finish()回调。 + // 注意:修改config/laravels.php,配置task_ipc_mode为1或2,参考 https://wiki.swoole.com/#/server/setting?id=task_ipc_mode + $ret = Task::deliver(new TestTask('task data')); + var_dump($ret); + } + // 此处抛出的异常会被上层捕获并记录到Swoole日志,开发者需要手动try/catch + } +} +``` + +2.注册定时任务类。 + +```php +// 在"config/laravels.php"注册定时任务类 +[ + // ... + 'timer' => [ + 'enable' => true, // 启用Timer + 'jobs' => [ // 注册的定时任务类列表 + // 启用LaravelScheduleJob来执行`php artisan schedule:run`,每分钟一次,替代Linux Crontab + // \Hhxsv5\LaravelS\Illuminate\LaravelScheduleJob::class, + // 两种配置参数的方式: + // [\App\Jobs\Timer\TestCronJob::class, [1000, true]], // 注册时传入参数 + \App\Jobs\Timer\TestCronJob::class, // 重载对应的方法来返回参数 + ], + 'max_wait_time' => 5, // Reload时最大等待时间 + // 打开全局定时器开关:当多实例部署时,确保只有一个实例运行定时任务,此功能依赖 Redis,具体请看 https://laravel.com/docs/7.x/redis + 'global_lock' => false, + 'global_lock_key' => config('app.name', 'Laravel'), + ], + // ... +]; +``` + +3.注意在构建服务器集群时,会启动多个`定时器`,要确保只启动一个定期器,避免重复执行定时任务。 + +4.LaravelS `v3.4.0`开始支持热重启[Reload]`定时器`进程,LaravelS 在收到`SIGUSR1`信号后会等待`max_wait_time`(默认5)秒再结束进程,然后`Manager`进程会重新拉起`定时器`进程。 + +5.如果你仅需要用到`分钟级`的定时任务,建议启用`Hhxsv5\LaravelS\Illuminate\LaravelScheduleJob`来替代Linux Crontab,这样就可以沿用[Laravel任务调度](https://learnku.com/docs/laravel/7.x/scheduling/7492)的编码习惯,配置`Kernel`即可。 + +```php +// app/Console/Kernel.php +protected function schedule(Schedule $schedule) +{ + // runInBackground()方法会新启子进程执行任务,这是异步的,不会影响其他任务的执行时机 + $schedule->command(TestCommand::class)->runInBackground()->everyMinute(); +} +``` + +## 修改代码后自动Reload + +- 基于`inotify`,仅支持Linux。 + + 1.安装[inotify](http://pecl.php.net/package/inotify)扩展。 + + 2.开启[配置项](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/Settings-CN.md#inotify_reloadenable)。 + + 3.注意:`inotify`只有在`Linux`内修改文件才能收到文件变更事件,建议使用最新版Docker,[Vagrant解决方案](https://github.com/mhallin/vagrant-notify-forwarder)。 + +- 基于`fswatch`,支持OS X、Linux、Windows。 + + 1.安装[fswatch](https://github.com/emcrisostomo/fswatch)。 + + 2.在项目根目录下运行命令。 + + ```bash + # 监听当前目录 + ./bin/fswatch + # 监听app目录 + ./bin/fswatch ./app + ``` + +- 基于`inotifywait`,仅支持Linux。 + + 1.安装[inotify-tools](https://github.com/rvoicilas/inotify-tools)。 + + 2.在项目根目录下运行命令。 + + ```bash + # 监听当前目录 + ./bin/inotify + # 监听app目录 + ./bin/inotify ./app + ``` + +- 当以上方法都不行时,终极解决方案:配置`max_request=1,worker_num=1`,这样`Worker`进程处理完一个请求就会重启,这种方法的性能非常差,`故仅限在开发环境使用`。 + +## 在你的项目中使用`SwooleServer`实例 + +```php +/** + * 如果启用WebSocket server,$swoole是`Swoole\WebSocket\Server`的实例,否则是是`Swoole\Http\Server`的实例 + * @var \Swoole\WebSocket\Server|\Swoole\Http\Server $swoole + */ +$swoole = app('swoole'); +var_dump($swoole->stats()); +$swoole->push($fd, 'Push WebSocket message'); +``` + +## 使用`SwooleTable` + +1.定义Table,支持定义多个Table。 +> Swoole启动之前会创建定义的所有Table。 + +```php +// 在"config/laravels.php"配置 +[ + // ... + 'swoole_tables' => [ + // 场景:WebSocket中UserId与FD绑定 + 'ws' => [// Key为Table名称,使用时会自动添加Table后缀,避免重名。这里定义名为wsTable的Table + 'size' => 102400,//Table的最大行数 + 'column' => [// Table的列定义 + ['name' => 'value', 'type' => \Swoole\Table::TYPE_INT, 'size' => 8], + ], + ], + //...继续定义其他Table + ], + // ... +]; +``` + +2.访问Table:所有的Table实例均绑定在`SwooleServer`上,通过`app('swoole')->xxxTable`访问。 + +```php +namespace App\Services; +use Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface; +use Swoole\Http\Request; +use Swoole\WebSocket\Frame; +use Swoole\WebSocket\Server; +class WebSocketService implements WebSocketHandlerInterface +{ + /**@var \Swoole\Table $wsTable */ + private $wsTable; + public function __construct() + { + $this->wsTable = app('swoole')->wsTable; + } + // 场景:WebSocket中UserId与FD绑定 + public function onOpen(Server $server, Request $request) + { + // var_dump(app('swoole') === $server);// 同一实例 + /** + * 获取当前登录的用户 + * 此特性要求建立WebSocket连接的路径要经过Authenticate之类的中间件。 + * 例如: + * 浏览器端:var ws = new WebSocket("ws://127.0.0.1:5200/ws"); + * 那么Laravel中/ws路由就需要加上类似Authenticate的中间件。 + * Route::get('/ws', function () { + * // 响应状态码200的任意内容 + * return 'websocket'; + * })->middleware(['auth']); + */ + // $user = Auth::user(); + // $userId = $user ? $user->id : 0; // 0 表示未登录的访客用户 + $userId = mt_rand(1000, 10000); + // if (!$userId) { + // // 未登录用户直接断开连接 + // $server->disconnect($request->fd); + // return; + // } + $this->wsTable->set('uid:' . $userId, ['value' => $request->fd]);// 绑定uid到fd的映射 + $this->wsTable->set('fd:' . $request->fd, ['value' => $userId]);// 绑定fd到uid的映射 + $server->push($request->fd, "Welcome to LaravelS #{$request->fd}"); + } + public function onMessage(Server $server, Frame $frame) + { + // 广播 + foreach ($this->wsTable as $key => $row) { + if (strpos($key, 'uid:') === 0 && $server->isEstablished($row['value'])) { + $content = sprintf('Broadcast: new message "%s" from #%d', $frame->data, $frame->fd); + $server->push($row['value'], $content); + } + } + } + public function onClose(Server $server, $fd, $reactorId) + { + $uid = $this->wsTable->get('fd:' . $fd); + if ($uid !== false) { + $this->wsTable->del('uid:' . $uid['value']); // 解绑uid映射 + } + $this->wsTable->del('fd:' . $fd);// 解绑fd映射 + $server->push($fd, "Goodbye #{$fd}"); + } +} +``` + +## 多端口混合协议 + +> 更多的信息,请参考[Swoole增加监听的端口](https://wiki.swoole.com/#/server/methods?id=addlistener)与[多端口混合协议](https://wiki.swoole.com/#/server/port) + +为了使我们的主服务器能支持除`HTTP`和`WebSocket`外的更多协议,我们引入了`Swoole`的`多端口混合协议`特性,在LaravelS中称为`Socket`。现在,可以很方便地在Laravel上构建`TCP/UDP`应用。 + +1. 创建`Socket`处理类,继承`Hhxsv5\LaravelS\Swoole\Socket\{TcpSocket|UdpSocket|Http|WebSocket}` + + ```php + namespace App\Sockets; + use Hhxsv5\LaravelS\Swoole\Socket\TcpSocket; + use Swoole\Server; + class TestTcpSocket extends TcpSocket + { + public function onConnect(Server $server, $fd, $reactorId) + { + \Log::info('New TCP connection', [$fd]); + $server->send($fd, 'Welcome to LaravelS.'); + } + public function onReceive(Server $server, $fd, $reactorId, $data) + { + \Log::info('Received data', [$fd, $data]); + $server->send($fd, 'LaravelS: ' . $data); + if ($data === "quit\r\n") { + $server->send($fd, 'LaravelS: bye' . PHP_EOL); + $server->close($fd); + } + } + public function onClose(Server $server, $fd, $reactorId) + { + \Log::info('Close TCP connection', [$fd]); + $server->send($fd, 'Goodbye'); + } + } + ``` + + 这些连接和主服务器上的HTTP/WebSocket连接共享`Worker`进程,因此可以在这些事件回调中使用LaravelS提供的`异步任务投递`、`SwooleTable`、Laravel提供的组件如`DB`、`Eloquent`等。同时,如果需要使用该协议端口的`Swoole\Server\Port`对象,只需要像如下代码一样访问`Socket`类的成员`swoolePort`即可。 + + ```php + public function onReceive(Server $server, $fd, $reactorId, $data) + { + $port = $this->swoolePort; // 获得`Swoole\Server\Port`对象 + } + ``` + + ```php + namespace App\Http\Controllers; + class TestController extends Controller + { + public function test() + { + /**@var \Swoole\Http\Server|\Swoole\WebSocket\Server $swoole */ + $swoole = app('swoole'); + // $swoole->ports:遍历所有Port对象,https://wiki.swoole.com/#/server/properties?id=ports + $port = $swoole->ports[1]; // 获得`Swoole\Server\Port`对象,$port[0]是主服务器的端口 + foreach ($port->connections as $fd) { // 遍历所有连接 + // $swoole->send($fd, 'Send tcp message'); + // if($swoole->isEstablished($fd)) { + // $swoole->push($fd, 'Send websocket message'); + // } + } + } + } + ``` + +2. 注册套接字。 + + ```php + // 修改文件 config/laravels.php + // ... + 'sockets' => [ + [ + 'host' => '127.0.0.1', + 'port' => 5291, + 'type' => SWOOLE_SOCK_TCP,// 支持的嵌套字类型:https://wiki.swoole.com/#/consts?id=socket-%e7%b1%bb%e5%9e%8b + 'settings' => [// Swoole可用的配置项:https://wiki.swoole.com/#/server/port?id=%e5%8f%af%e9%80%89%e5%8f%82%e6%95%b0 + 'open_eof_check' => true, + 'package_eof' => "\r\n", + ], + 'handler' => \App\Sockets\TestTcpSocket::class, + 'enable' => true, // 是否启用,默认为true + ], + ], + ``` + + 关于心跳配置,只能设置在`主服务器`上,不能配置在`套接字`上,但`套接字`会继承`主服务器`的心跳配置。 + + 对于TCP协议,`dispatch_mode`选项设为`1/3`时,底层会屏蔽`onConnect`/`onClose`事件,原因是这两种模式下无法保证`onConnect`/`onClose`/`onReceive`的顺序。如果需要用到这两个事件,请将`dispatch_mode`改为`2/4/5`,[参考](https://wiki.swoole.com/#/server/setting?id=dispatch_mode)。 + + ```php + 'swoole' => [ + //... + 'dispatch_mode' => 2, + //... + ]; + ``` + +3. 测试。 + +- TCP:`telnet 127.0.0.1 5291` + +- UDP:Linux下 `echo "Hello LaravelS" > /dev/udp/127.0.0.1/5292` + +4. 其他协议的注册示例。 + + - UDP + ```php + 'sockets' => [ + [ + 'host' => '0.0.0.0', + 'port' => 5292, + 'type' => SWOOLE_SOCK_UDP, + 'settings' => [ + 'open_eof_check' => true, + 'package_eof' => "\r\n", + ], + 'handler' => \App\Sockets\TestUdpSocket::class, + ], + ], + ``` + + - Http + ```php + 'sockets' => [ + [ + 'host' => '0.0.0.0', + 'port' => 5293, + 'type' => SWOOLE_SOCK_TCP, + 'settings' => [ + 'open_http_protocol' => true, + ], + 'handler' => \App\Sockets\TestHttp::class, + ], + ], + ``` + + - WebSocket:主服务器必须`开启WebSocket`,即需要将`websocket.enable`置为`true`。 + ```php + 'sockets' => [ + [ + 'host' => '0.0.0.0', + 'port' => 5294, + 'type' => SWOOLE_SOCK_TCP, + 'settings' => [ + 'open_http_protocol' => true, + 'open_websocket_protocol' => true, + ], + 'handler' => \App\Sockets\TestWebSocket::class, + ], + ], + ``` + + +## 协程 + +> [Swoole原始文档](https://wiki.swoole.com/#/start/coroutine) + +- 警告:协程下代码执行顺序是乱序的,请求级的数据应该以协程ID隔离,但Laravel/Lumen中存在很多单例、静态属性,不同请求间的数据会相互影响,这是`不安全`的。比如数据库连接就是单例,同一个数据库连接共享同一个PDO资源,这在同步阻塞模式下是没问题的,但在异步协程下是不行的,每次查询需要创建不同的连接,维护不同的IO状态,这就需要用到连接池。 + +- `不要`使用协程,仅`自定义进程`中可使用协程。 + +## 自定义进程 + +> 支持开发者创建一些特殊的工作进程,用于监控、上报或者其他特殊的任务,参考[addProcess](https://wiki.swoole.com/#/server/methods?id=addprocess)。 + +1. 创建Proccess类,实现CustomProcessInterface接口。 + + ```php + namespace App\Processes; + use App\Tasks\TestTask; + use Hhxsv5\LaravelS\Swoole\Process\CustomProcessInterface; + use Hhxsv5\LaravelS\Swoole\Task\Task; + use Swoole\Coroutine; + use Swoole\Http\Server; + use Swoole\Process; + class TestProcess implements CustomProcessInterface + { + /** + * @var bool 退出标记,用于Reload更新 + */ + private static $quit = false; + + public static function callback(Server $swoole, Process $process) + { + // 进程运行的代码,不能退出,一旦退出Manager进程会自动再次创建该进程。 + while (!self::$quit) { + \Log::info('Test process: running'); + // sleep(1); // Swoole < 2.1 + Coroutine::sleep(1); // Swoole>=2.1 已自动为callback()方法创建了协程并启用了协程Runtime,注意所使用的组件与协程兼容性,不兼容时可仅开启部分协程,如:\Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_TCP | SWOOLE_HOOK_SLEEP | SWOOLE_HOOK_FILE); + // 自定义进程中也可以投递Task,但不支持Task的finish()回调。 + // 注意:修改config/laravels.php,配置task_ipc_mode为1或2,参考 https://wiki.swoole.com/#/server/setting?id=task_ipc_mode + $ret = Task::deliver(new TestTask('task data')); + var_dump($ret); + // 上层会捕获callback中抛出的异常,并记录到Swoole日志,然后此进程会退出,3秒后Manager进程会重新创建进程,所以需要开发者自行try/catch捕获异常,避免频繁创建进程。 + // throw new \Exception('an exception'); + } + } + // 要求:LaravelS >= v3.4.0 并且 callback() 必须是异步非阻塞程序。 + public static function onReload(Server $swoole, Process $process) + { + // Stop the process... + // Then end process + \Log::info('Test process: reloading'); + self::$quit = true; + // $process->exit(0); // 强制退出进程 + } + // 要求:LaravelS >= v3.7.4 并且 callback() 必须是异步非阻塞程序。 + public static function onStop(Server $swoole, Process $process) + { + // Stop the process... + // Then end process + \Log::info('Test process: stopping'); + self::$quit = true; + // $process->exit(0); // 强制退出进程 + } + } + ``` + +2. 注册TestProcess。 + + ```php + // 修改文件 config/laravels.php + // ... + 'processes' => [ + 'test' => [ // Key为进程名 + 'class' => \App\Processes\TestProcess::class, + 'redirect' => false, // 是否重定向输入输出 + 'pipe' => 0, // 管道类型:0不创建管道,1创建SOCK_STREAM类型管道,2创建SOCK_DGRAM类型管道 + 'enable' => true, // 是否启用,默认true + //'num' => 3, // 创建多个进程实例,默认为1 + //'queue' => [ // 启用消息队列作为进程间通信,配置空数组表示使用默认参数 + // 'msg_key' => 0, // 消息队列的KEY,默认会使用ftok(__FILE__, 1) + // 'mode' => 2, // 通信模式,默认为2,表示争抢模式 + // 'capacity' => 8192, // 单个消息长度,长度受限于操作系统内核参数的限制,默认为8192,最大不超过65536 + //], + //'restart_interval' => 5, // 进程异常退出后需等待多少秒再重启,默认5秒 + ], + ], + ``` + +3. 注意:callback()方法不能退出,如果退出,Manager进程将会重新创建进程。 + +4. 示例:向自定义进程中写数据。 + + ```php + // config/laravels.php + 'processes' => [ + 'test' => [ + 'class' => \App\Processes\TestProcess::class, + 'redirect' => false, + 'pipe' => 1, + ], + ], + ``` + + ```php + // app/Processes/TestProcess.php + public static function callback(Server $swoole, Process $process) + { + while ($data = $process->read()) { + \Log::info('TestProcess: read data', [$data]); + $process->write('TestProcess: ' . $data); + } + } + ``` + + ```php + // app/Http/Controllers/TestController.php + public function testProcessWrite() + { + /**@var \Swoole\Process[] $process */ + $customProcesses = \Hhxsv5\LaravelS\LaravelS::getCustomProcesses(); + $process = $customProcesses['test']; + $process->write('TestController: write data' . time()); + var_dump($process->read()); + } + ``` + +## 常用组件 + +### Apollo +> 启动`LaravelS`时会获取`Apollo`配置并写入到`.env`文件,同时会启动自定义进程`apollo`用于监听配置变更,当配置发生变更时自动`reload`。 + +1. 启用Apollo组件:启动参数加上`--enable-apollo`以及Apollo的配置参数。 + + ```bash + php bin/laravels start --enable-apollo --apollo-server=http://127.0.0.1:8080 --apollo-app-id=LARAVEL-S-TEST + ``` + +2. 配置热更新(可选的)。 + + ```php + // 修改文件 config/laravels.php + 'processes' => Hhxsv5\LaravelS\Components\Apollo\Process::getDefinition(), + ``` + + ```php + // 当存在其他自定义进程配置时 + 'processes' => [ + 'test' => [ + 'class' => \App\Processes\TestProcess::class, + 'redirect' => false, + 'pipe' => 1, + ], + // ... + ] + Hhxsv5\LaravelS\Components\Apollo\Process::getDefinition(), + ``` + +3. 可用的参数列表。 + +| 参数名 | 描述 | 默认值 | 示例 | +| -------- | -------- | -------- | -------- | +| apollo-server | Apollo服务器URL | - | --apollo-server=http://127.0.0.1:8080 | +| apollo-app-id | Apollo应用ID | - | --apollo-app-id=LARAVEL-S-TEST | +| apollo-namespaces | APP所属的命名空间,可指定多个 | application | --apollo-namespaces=application --apollo-namespaces=env | +| apollo-cluster | APP所属的集群 | default | --apollo-cluster=default | +| apollo-client-ip | 当前实例的IP,还可用于灰度发布 | 本机内网IP | --apollo-client-ip=10.2.1.83 | +| apollo-pull-timeout | 拉取配置时的超时时间(秒) | 5 | --apollo-pull-timeout=5 | +| apollo-backup-old-env | 更新配置文件`.env`时是否备份老的配置文件 | false | --apollo-backup-old-env | + +### Prometheus +> 支持Prometheus监控与告警,Grafana可视化查看监控指标。请参考[Docker Compose](https://github.com/hhxsv5/docker)完成Prometheus与Grafana的环境搭建。 + +1. 依赖[APCu >= 5.0.0](https://pecl.php.net/package/apcu)扩展,请先安装它 `pecl install apcu`。 + +2. 拷贝配置文件`prometheus.php`到你的工程`config`目录。视情况修改配置。 + ```bash + # 项目根目录下执行命令 + cp vendor/hhxsv5/laravel-s/config/prometheus.php config/ + ``` + 如果是`Lumen`工程,还需要在`bootstrap/app.php`中手动加载配置`$app->configure('prometheus');`。 + +3. 配置`全局`中间件:`Hhxsv5\LaravelS\Components\Prometheus\RequestMiddleware::class`。为了尽可能精确地统计请求耗时,`RequestMiddleware`必须作为`第一个`全局中间件,需要放在其他中间件的前面。 + +4. 注册 ServiceProvider:`Hhxsv5\LaravelS\Components\Prometheus\ServiceProvider::class`。 + +5. 在`config/laravels.php`中配置 CollectorProcess 进程,用于定时采集 Swoole Worker/Task/Timer 进程的指标。 + ```php + 'processes' => Hhxsv5\LaravelS\Components\Prometheus\CollectorProcess::getDefinition(), + ``` + +6. 创建路由,输出监控指标数据。 + ```php + use Hhxsv5\LaravelS\Components\Prometheus\Exporter; + + Route::get('/actuator/prometheus', function () { + $result = app(Exporter::class)->render(); + return response($result, 200, ['Content-Type' => Exporter::REDNER_MIME_TYPE]); + }); + ``` + +7. 完成Prometheus的配置,启动Prometheus。 + ```yml + global: + scrape_interval: 5s + scrape_timeout: 5s + evaluation_interval: 30s + scrape_configs: + - job_name: laravel-s-test + honor_timestamps: true + metrics_path: /actuator/prometheus + scheme: http + follow_redirects: true + static_configs: + - targets: + - 127.0.0.1:5200 # The ip and port of the monitored service + # Dynamically discovered using one of the supported service-discovery mechanisms + # https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config + # - job_name: laravels-eureka + # honor_timestamps: true + # scrape_interval: 5s + # metrics_path: /actuator/prometheus + # scheme: http + # follow_redirects: true + # eureka_sd_configs: + # - server: http://127.0.0.1:8080/eureka + # follow_redirects: true + # refresh_interval: 5s + ``` + +8. 启动Grafana,然后导入[panel json](https://github.com/hhxsv5/laravel-s/tree/PHP-8.x/grafana-dashboard.json)。 + +Grafana Dashboard + +## 其他特性 + +### 配置Swoole事件 + +支持的事件列表: + +| 事件 | 需实现的接口 | 发生时机 | +| -------- | -------- | -------- | +| ServerStart | Hhxsv5\LaravelS\Swoole\Events\ServerStartInterface | 发生在Master进程启动时,`此事件中不应处理复杂的业务逻辑,只能做一些初始化的简单工作` | +| ServerStop | Hhxsv5\LaravelS\Swoole\Events\ServerStopInterface | 发生在Server正常退出时,`此事件中不能使用异步或协程相关的API` | +| WorkerStart | Hhxsv5\LaravelS\Swoole\Events\WorkerStartInterface | 发生在Worker/Task进程启动完成后 | +| WorkerStop | Hhxsv5\LaravelS\Swoole\Events\WorkerStopInterface | 发生在Worker/Task进程正常退出后 | +| WorkerError | Hhxsv5\LaravelS\Swoole\Events\WorkerErrorInterface | 发生在Worker/Task进程发生异常或致命错误时 | + +1.创建事件处理类,实现相应的接口。 +```php +namespace App\Events; +use Hhxsv5\LaravelS\Swoole\Events\ServerStartInterface; +use Swoole\Atomic; +use Swoole\Http\Server; +class ServerStartEvent implements ServerStartInterface +{ + public function __construct() + { + } + public function handle(Server $server) + { + // 初始化一个全局计数器(跨进程的可用) + $server->atomicCount = new Atomic(2233); + + // 控制器中调用:app('swoole')->atomicCount->get(); + } +} +``` + +```php +namespace App\Events; +use Hhxsv5\LaravelS\Swoole\Events\WorkerStartInterface; +use Swoole\Http\Server; +class WorkerStartEvent implements WorkerStartInterface +{ + public function __construct() + { + } + public function handle(Server $server, $workerId) + { + // 初始化一个数据库连接池对象 + // DatabaseConnectionPool::init(); + } +} +``` + +2.配置。 +```php +// 修改文件 config/laravels.php +'event_handlers' => [ + 'ServerStart' => [\App\Events\ServerStartEvent::class], // 按数组顺序触发事件 + 'WorkerStart' => [\App\Events\WorkerStartEvent::class], +], +``` + +### Serverless + +#### 阿里云函数计算 +> [函数计算官方文档](https://help.aliyun.com/product/50980.html)。 + +1.修改`bootstrap/app.php`,设置storage目录。因为项目目录只读,`/tmp`目录才可读写。 + +```php +$app->useStoragePath(env('APP_STORAGE_PATH', '/tmp/storage')); +``` + +2.创建Shell脚本`laravels_bootstrap`,并赋予`可执行权限`。 + +```bash +#!/usr/bin/env bash +set +e + +# 创建storage相关目录 +mkdir -p /tmp/storage/app/public +mkdir -p /tmp/storage/framework/cache +mkdir -p /tmp/storage/framework/sessions +mkdir -p /tmp/storage/framework/testing +mkdir -p /tmp/storage/framework/views +mkdir -p /tmp/storage/logs + +# 设置环境变量APP_STORAGE_PATH,请确保与.env的APP_STORAGE_PATH一样 +export APP_STORAGE_PATH=/tmp/storage + +# Start LaravelS +php bin/laravels start +``` + +3.配置`template.xml`。 + +```xml +ROSTemplateFormatVersion: '2015-09-01' +Transform: 'Aliyun::Serverless-2018-04-03' +Resources: + laravel-s-demo: + Type: 'Aliyun::Serverless::Service' + Properties: + Description: 'LaravelS Demo for Serverless' + fc-laravel-s: + Type: 'Aliyun::Serverless::Function' + Properties: + Handler: laravels.handler + Runtime: custom + MemorySize: 512 + Timeout: 30 + CodeUri: ./ + InstanceConcurrency: 10 + EnvironmentVariables: + BOOTSTRAP_FILE: laravels_bootstrap + +``` + +## 注意事项 + +### 单例问题 + +- 传统FPM下,单例模式的对象的生命周期仅在每次请求中,请求开始=>实例化单例=>请求结束后=>单例对象资源回收。 + +- Swoole Server下,所有单例对象会常驻于内存,这个时候单例对象的生命周期与FPM不同,请求开始=>实例化单例=>请求结束=>单例对象依旧保留,需要开发者自己维护单例的状态。 + +- 常见的解决方案: + + 1. 写一个`XxxCleaner`清理器类来清理单例对象状态,此类需实现接口`Hhxsv5\LaravelS\Illuminate\Cleaners\CleanerInterface`,然后注册到`laravels.php`的`cleaners`中。 + + 2. 用一个`中间件`来`重置`单例对象的状态。 + + 3. 如果是以`ServiceProvider`注册的单例对象,可添加该`ServiceProvider`到`laravels.php`的`register_providers`中,这样每次请求会重新注册该`ServiceProvider`,重新实例化单例对象,[参考](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/Settings-CN.md#register_providers)。 + +### 清理器 +> [设置清理器](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/Settings-CN.md#cleaners)。 + +### 常见问题 +> [常见问题](https://github.com/hhxsv5/laravel-s/blob/PHP-8.x/KnownIssues-CN.md):一揽子的已知问题和解决方案。 + +### 调试方式 + +- 记录日志;如想要在控制台输出,可使用`stderr`,Log::channel('stderr')->debug('debug message')。 + +- [Laravel Dump Server](https://github.com/beyondcode/laravel-dump-server)(Laravel 5.7已默认集成)。 + +### 读取请求 +应通过`Illuminate\Http\Request`对象来读取请求信息,$_ENV是可读取的,$_SERVER是部分可读的,`不能使用`$_GET、$_POST、$_FILES、$_COOKIE、$_REQUEST、$_SESSION、$GLOBALS。 + +```php +public function form(\Illuminate\Http\Request $request) +{ + $name = $request->input('name'); + $all = $request->all(); + $sessionId = $request->cookie('sessionId'); + $photo = $request->file('photo'); + // 调用getContent()来获取原始的POST body,而不能用file_get_contents('php://input') + $rawContent = $request->getContent(); + //... +} +``` + +### 输出响应 +推荐通过返回`Illuminate\Http\Response`对象来响应请求,兼容echo、vardump()、print_r(),`不能使用`函数 dd()、exit()、die()、header()、setcookie()、http_response_code()。 + +```php +public function json() +{ + return response()->json(['time' => time()])->header('header1', 'value1')->withCookie('c1', 'v1'); +} +``` + +### 持久连接 +`单例的连接`将被常驻内存,建议开启`持久连接`,获得更好的性能。 +1. 数据库连接,连接断开后会自动重连 + +```php +// config/database.php +'connections' => [ + 'my_conn' => [ + 'driver' => 'mysql', + 'host' => env('DB_MY_CONN_HOST', 'localhost'), + 'port' => env('DB_MY_CONN_PORT', 3306), + 'database' => env('DB_MY_CONN_DATABASE', 'forge'), + 'username' => env('DB_MY_CONN_USERNAME', 'forge'), + 'password' => env('DB_MY_CONN_PASSWORD', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => false, + 'options' => [ + // 开启持久连接 + \PDO::ATTR_PERSISTENT => true, + ], + ], +], +``` + +2. Redis连接,连接断开后`不会立即`自动重连,会抛出一个关于连接断开的异常,下次会自动重连。需确保每次操作Redis前正确的`SELECT DB`。 + +```php +// config/database.php +'redis' => [ + 'client' => env('REDIS_CLIENT', 'phpredis'), // 推荐使用phpredis,以获得更好的性能 + 'default' => [ + 'host' => env('REDIS_HOST', 'localhost'), + 'password' => env('REDIS_PASSWORD', null), + 'port' => env('REDIS_PORT', 6379), + 'database' => 0, + 'persistent' => true, // 开启持久连接 + ], +], +``` + +### 关于内存泄露 + +- 避免使用全局变量,如一定要,请手动清理或重置。 + +- 无限追加元素到全局变量、静态变量、单例,将导致内存溢出。 + + ```php + class Test + { + public static $array = []; + public static $string = ''; + } + + // 某控制器 + public function test(Request $req) + { + // 内存溢出 + Test::$array[] = $req->input('param1'); + Test::$string .= $req->input('param2'); + } + ``` + +- 内存泄露的检测方法 + + 1. 修改`config/laravels.php`:`worker_num=1, max_request=1000000`,测试完成后记得改回去; + + 2. 增加路由`/debug-memory-leak`,不设置任何`路由中间件`,用于观察`Worker`进程的内存变化情况; + + ```php + Route::get('/debug-memory-leak', function () { + global $previous; + $current = memory_get_usage(); + $stats = [ + 'prev_mem' => $previous, + 'curr_mem' => $current, + 'diff_mem' => $current - $previous, + ]; + $previous = $current; + return $stats; + }); + ``` + + 3. 启动`LaravelS`,请求`/debug-memory-leak`,直到`diff_mem`小于或等于零;如果`diff_mem`一直大于零,说明`全局中间件`或`Laravel框架`可能存在内存泄露; + + 4. 完成`步骤3`后,`交替`请求业务路由与`/debug-memory-leak`(建议使用`ab`/`wrk`对业务路由进行大量的请求),刚开始出现的内存增涨是正常现象。业务路由经过大量请求后,如果`diff_mem`一直大于零,并且`curr_mem`持续增大,则大概率存在内存泄露;如果`curr_mem`始终在一定范围内变化,没有持续变大,则大概率不存在内存泄露。 + + 5. 如果始终没法解决,[max_request](https://wiki.swoole.com/#/server/setting?id=max_request)是最后兜底的方案。 + diff --git a/docs/README_LOG.md b/docs/README_LOG.md deleted file mode 100644 index 5cb1ac7..0000000 --- a/docs/README_LOG.md +++ /dev/null @@ -1,608 +0,0 @@ -# 系统操作日志模块文档 - -## 概述 - -系统操作日志模块用于记录后台管理系统的所有操作请求,包括用户操作、API 调用、错误信息等,方便管理员进行系统监控、审计和问题排查。 - -## 技术特性 - -- **自动记录**: 通过中间件自动记录所有请求,无需手动调用 -- **详细信息**: 记录用户信息、请求参数、响应结果、执行时间等 -- **敏感信息保护**: 自动过滤密码等敏感信息 -- **性能优化**: 不影响业务响应速度 -- **多维度查询**: 支持按用户、模块、操作、状态、时间等多维度筛选 -- **数据导出**: 支持导出日志数据为 Excel 文件 -- **批量操作**: 支持批量删除和定期清理 - -## 数据库表结构 - -### system_logs 表 - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | bigint | 主键 ID | -| user_id | bigint | 用户 ID | -| username | varchar(100) | 用户名 | -| module | varchar(50) | 模块名称 | -| action | varchar(100) | 操作名称 | -| method | varchar(10) | 请求方法 (GET/POST/PUT/DELETE) | -| url | text | 请求 URL | -| ip | varchar(45) | 客户端 IP 地址 | -| user_agent | text | 用户代理 | -| params | json | 请求参数 | -| result | text | 响应结果(仅错误时记录) | -| status_code | int | HTTP 状态码 | -| status | varchar(20) | 状态 (success/error) | -| error_message | text | 错误信息 | -| execution_time | int | 执行时间(毫秒) | -| created_at | timestamp | 创建时间 | -| updated_at | timestamp | 更新时间 | - -## 核心组件 - -### 1. 中间件 (Middleware) - -**LogRequestMiddleware** - -位置: `app/Http/Middleware/LogRequestMiddleware.php` - -功能: -- 自动拦截所有经过的请求 -- 记录请求和响应信息 -- 计算请求执行时间 -- 提取用户信息和操作详情 -- 过滤敏感参数 -- 处理异常情况 - -使用方式: -```php -// 在路由中应用 -Route::middleware(['log.request'])->group(function () { - // 需要记录日志的路由 -}); -``` - -### 2. 服务层 (Service) - -**LogService** - -位置: `app/Services/System/LogService.php` - -主要方法: -- `create(array $data)`: 创建日志记录 -- `getList(array $params)`: 获取日志列表(分页) -- `getListQuery(array $params)`: 获取日志查询构建器 -- `getById(int $id)`: 根据 ID 获取日志详情 -- `delete(int $id)`: 删除单条日志 -- `batchDelete(array $ids)`: 批量删除日志 -- `clearLogs(string $days)`: 清理指定天数前的日志 -- `getStatistics(array $params)`: 获取日志统计信息 - -### 3. 控制器 (Controller) - -**Log Controller** - -位置: `app/Http/Controllers/System/Admin/Log.php` - -接口列表: -- `GET /admin/logs`: 获取日志列表 -- `GET /admin/logs/{id}`: 获取日志详情 -- `GET /admin/logs/statistics`: 获取日志统计 -- `POST /admin/logs/export`: 导出日志 -- `DELETE /admin/logs/{id}`: 删除单条日志 -- `POST /admin/logs/batch-delete`: 批量删除日志 -- `POST /admin/logs/clear`: 清理历史日志 - -### 4. 请求验证 (Request Validation) - -**LogRequest** - -位置: `app/Http/Requests/LogRequest.php` - -验证规则: -- `user_id`: 用户 ID(可选) -- `username`: 用户名(模糊查询,可选) -- `module`: 模块名称(可选) -- `action`: 操作名称(可选) -- `status`: 状态(success/error,可选) -- `start_date`: 开始日期(可选) -- `end_date`: 结束日期(可选) -- `ip`: IP 地址(可选) -- `page`: 页码(默认 1) -- `page_size`: 每页数量(默认 20,最大 100) - -## API 接口文档 - -### 1. 获取日志列表 - -**接口**: `GET /admin/logs` - -**请求参数**: -```json -{ - "user_id": 1, - "username": "admin", - "module": "users", - "action": "创建 users", - "status": "success", - "start_date": "2024-01-01", - "end_date": "2024-12-31", - "ip": "192.168.1.1", - "page": 1, - "page_size": 20 -} -``` - -**响应示例**: -```json -{ - "code": 200, - "message": "success", - "data": { - "list": [ - { - "id": 1, - "user_id": 1, - "username": "admin", - "module": "users", - "action": "创建 users", - "method": "POST", - "url": "http://example.com/admin/users", - "ip": "192.168.1.1", - "user_agent": "Mozilla/5.0...", - "params": { - "name": "test", - "email": "test@example.com" - }, - "result": null, - "status_code": 200, - "status": "success", - "error_message": null, - "execution_time": 125, - "created_at": "2024-01-01 12:00:00", - "user": { - "id": 1, - "name": "管理员", - "username": "admin" - } - } - ], - "total": 100, - "page": 1, - "page_size": 20 - } -} -``` - -### 2. 获取日志详情 - -**接口**: `GET /admin/logs/{id}` - -**响应示例**: -```json -{ - "code": 200, - "message": "success", - "data": { - "id": 1, - "user_id": 1, - "username": "admin", - "module": "users", - "action": "创建 users", - "method": "POST", - "url": "http://example.com/admin/users", - "ip": "192.168.1.1", - "user_agent": "Mozilla/5.0...", - "params": { - "name": "test", - "email": "test@example.com" - }, - "result": null, - "status_code": 200, - "status": "success", - "error_message": null, - "execution_time": 125, - "created_at": "2024-01-01 12:00:00", - "user": { - "id": 1, - "name": "管理员", - "username": "admin", - "email": "admin@example.com", - "created_at": "2024-01-01 10:00:00" - } - } -} -``` - -### 3. 获取日志统计 - -**接口**: `GET /admin/logs/statistics` - -**请求参数**: -```json -{ - "start_date": "2024-01-01", - "end_date": "2024-12-31" -} -``` - -**响应示例**: -```json -{ - "code": 200, - "message": "success", - "data": { - "total": 1000, - "success": 950, - "error": 50 - } -} -``` - -### 4. 导出日志 - -**接口**: `POST /admin/logs/export` - -**请求参数**: 与获取日志列表相同的查询参数 - -**响应**: Excel 文件下载 - -文件名格式: `系统操作日志_YYYYMMDDHHmmss.xlsx` - -包含字段: -- ID -- 用户名 -- 模块 -- 操作 -- 请求方法 -- URL -- IP 地址 -- 状态码 -- 状态 -- 错误信息 -- 执行时间(ms) -- 创建时间 - -### 5. 删除单条日志 - -**接口**: `DELETE /admin/logs/{id}` - -**响应示例**: -```json -{ - "code": 200, - "message": "删除成功", - "data": null -} -``` - -### 6. 批量删除日志 - -**接口**: `POST /admin/logs/batch-delete` - -**请求参数**: -```json -{ - "ids": [1, 2, 3, 4, 5] -} -``` - -**响应示例**: -```json -{ - "code": 200, - "message": "批量删除成功", - "data": null -} -``` - -### 7. 清理历史日志 - -**接口**: `POST /admin/logs/clear` - -**请求参数**: -```json -{ - "days": 30 -} -``` - -**说明**: 清理指定天数前的所有日志记录,默认清理 30 天前的数据。 - -**响应示例**: -```json -{ - "code": 200, - "message": "清理成功", - "data": null -} -``` - -## 日志记录规则 - -### 1. 自动记录的请求 - -所有经过 `log.request` 中间件的请求都会被自动记录,包括: -- 用户管理操作 -- 角色管理操作 -- 权限管理操作 -- 部门管理操作 -- 系统配置操作 -- 其他所有后台管理操作 - -### 2. 不记录的请求 - -- 登录接口 (`POST /admin/auth/login`) -- 健康检查接口 (`GET /up`) -- 其他明确排除的路由 - -### 3. 敏感信息过滤 - -以下字段会被自动过滤,记录为 `******`: -- `password` -- `password_confirmation` -- `token` -- `secret` -- `key` - -### 4. 错误日志处理 - -- 成功请求 (HTTP 状态码 < 400): `status` = `success` -- 失败请求 (HTTP 状态码 >= 400): `status` = `error` -- 错误时记录响应内容和错误消息 -- 同时写入 Laravel 日志文件 (`storage/logs/laravel.log`) - -## 模块和操作名称解析 - -### 模块名称 - -从 URL 路径中解析,例如: -- `/admin/users` → 模块: `users` -- `/admin/roles` → 模块: `roles` -- `/admin/configs` → 模块: `configs` - -### 操作名称 - -根据 HTTP 方法和资源名称生成: -- `GET /admin/users` → 操作: `查询 users` -- `POST /admin/users` → 操作: `创建 users` -- `PUT /admin/users/1` → 操作: `更新 users` -- `DELETE /admin/users/1` → 操作: `删除 users` - -## 性能优化建议 - -### 1. 定期清理日志 - -建议使用 Laravel 任务调度器定期清理历史日志: - -```php -// app/Console/Kernel.php - -protected function schedule(Schedule $schedule) -{ - // 每天凌晨 2 点清理 90 天前的日志 - $schedule->call(function () { - app(LogService::class)->clearLogs(90); - })->dailyAt('02:00'); -} -``` - -### 2. 数据库索引 - -确保以下字段有索引: -- `user_id` -- `username` -- `module` -- `status` -- `created_at` - -### 3. 分页查询 - -列表查询必须使用分页,避免一次加载过多数据。 - -### 4. 异步记录 - -日志记录操作应放在请求处理后,不影响响应速度。 - -## 前端集成示例 - -### Vue3 + Ant Design Vue - -```vue - - - -``` - -## 注意事项 - -1. **权限控制**: 日志管理接口需要相应的权限才能访问 -2. **数据安全**: 敏感信息已自动过滤,但仍需注意日志数据的安全存储 -3. **性能影响**: 虽然日志记录不影响响应速度,但大量日志会增加数据库负载 -4. **定期备份**: 重要日志数据建议定期备份 -5. **日志分析**: 可结合 BI 工具对日志数据进行深度分析 - -## 常见问题 - -### Q1: 为什么某些请求没有被记录? - -A: 检查路由是否应用了 `log.request` 中间件,或者在中间件中是否被排除了。 - -### Q2: 日志数据过多怎么办? - -A: 使用 `clearLogs` 方法定期清理历史日志,或设置任务调度器自动清理。 - -### Q3: 如何自定义日志记录规则? - -A: 修改 `LogRequestMiddleware` 中的 `parseModule` 和 `parseAction` 方法。 - -### Q4: 日志记录会影响性能吗? - -A: 日志记录在请求处理后执行,不影响响应速度。但大量日志会增加数据库写入压力。 - -### Q5: 如何查看完整的请求参数? - -A: 在日志详情接口中,`params` 字段包含了完整的请求参数(敏感信息已过滤)。 - -## 更新日志 - -### v1.0.0 (2024-01-01) -- 初始版本 -- 实现基础日志记录功能 -- 支持多维度查询和筛选 -- 支持数据导出 -- 支持批量删除和清理 diff --git a/docs/README_SYSTEM.md b/docs/README_SYSTEM.md index 39344a7..7ead397 100644 --- a/docs/README_SYSTEM.md +++ b/docs/README_SYSTEM.md @@ -44,6 +44,8 @@ app/Http/Controllers/System/ - Laravel 11 - Redis 缓存 - Intervention Image (图像处理) +- Laravel-S / Swoole (WebSocket 通知) +- WebSocket (实时消息推送) ## 数据库表结构 @@ -84,15 +86,18 @@ app/Http/Controllers/System/ - `username`: 用户名 - `module`: 模块 - `action`: 操作 -- `method`: 请求方法 +- `method`: 请求方法 (GET/POST/PUT/DELETE) - `url`: 请求URL - `ip`: IP地址 - `user_agent`: 用户代理 -- `request_data`: 请求数据(JSON) -- `response_data`: 响应数据(JSON) -- `duration`: 执行时间(毫秒) -- `status_code`: 状态码 +- `params`: 请求参数(JSON) +- `result`: 响应结果(仅错误时记录) +- `status_code`: HTTP状态码 +- `status`: 状态 (success/error) +- `error_message`: 错误信息 +- `execution_time`: 执行时间(毫秒) - `created_at`: 创建时间 +- `updated_at`: 更新时间 ### system_tasks (任务表) - `id`: 主键 @@ -197,34 +202,220 @@ app/Http/Controllers/System/ ### 操作日志管理 +操作日志模块通过中间件自动记录所有后台管理 API 请求,实现全自动化的日志记录功能。 + +#### 中间件说明 + +**LogRequestMiddleware** + +位置: `app/Http/Middleware/LogRequestMiddleware.php` + +功能: +- 自动拦截所有经过的请求 +- 记录请求和响应信息 +- 计算请求执行时间 +- 提取用户信息和操作详情 +- 过滤敏感参数(password、token、secret、key) +- 获取客户端真实 IP(支持代理) +- 异常处理,记录失败不影响业务 + +使用方式: +```php +// 在路由中应用 +Route::middleware(['auth.check:admin', 'log.request'])->group(function () { + // 需要记录日志的路由 +}); +``` + +#### 日志记录规则 + +**自动记录的请求:** +所有经过 `log.request` 中间件的请求都会被自动记录,包括: +- 用户管理操作 +- 角色管理操作 +- 权限管理操作 +- 部门管理操作 +- 系统配置操作 +- 其他所有后台管理操作 + +**不记录的请求:** +- 登录接口 (`POST /admin/auth/login`) +- 健康检查接口 (`GET /up`) +- 其他明确排除的路由 + +**敏感信息过滤:** +以下字段会被自动过滤,记录为 `******`: +- `password` +- `password_confirmation` +- `token` +- `secret` +- `key` + +**错误日志处理:** +- 成功请求 (HTTP 状态码 < 400): `status` = `success` +- 失败请求 (HTTP 状态码 >= 400): `status` = `error` +- 错误时记录响应内容和错误消息 +- 同时写入 Laravel 日志文件 (`storage/logs/laravel.log`) + #### 获取日志列表 - **接口**: `GET /admin/logs` - **参数**: - - `page`, `page_size` - - `keyword`: 搜索关键词(用户名/模块/操作) - - `module`: 模块 - - `action`: 操作 - - `user_id`: 用户ID - - `start_date`: 开始日期 - - `end_date`: 结束日期 - - `order_by`, `order_direction` + ```json + { + "user_id": 1, + "username": "admin", + "module": "users", + "action": "创建 users", + "status": "success", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "ip": "192.168.1.1", + "page": 1, + "page_size": 20 + } + ``` +- **响应示例**: + ```json + { + "code": 200, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "user_id": 1, + "username": "admin", + "module": "users", + "action": "创建 users", + "method": "POST", + "url": "http://example.com/admin/users", + "ip": "192.168.1.1", + "user_agent": "Mozilla/5.0...", + "params": { + "name": "test", + "email": "test@example.com" + }, + "result": null, + "status_code": 200, + "status": "success", + "error_message": null, + "execution_time": 125, + "created_at": "2024-01-01 12:00:00" + } + ], + "total": 100, + "page": 1, + "page_size": 20 + } + } + ``` #### 获取日志详情 - **接口**: `GET /admin/logs/{id}` +- **响应示例**: + ```json + { + "code": 200, + "message": "success", + "data": { + "id": 1, + "user_id": 1, + "username": "admin", + "module": "users", + "action": "创建 users", + "method": "POST", + "url": "http://example.com/admin/users", + "ip": "192.168.1.1", + "user_agent": "Mozilla/5.0...", + "params": { + "name": "test", + "email": "test@example.com" + }, + "result": null, + "status_code": 200, + "status": "success", + "error_message": null, + "execution_time": 125, + "created_at": "2024-01-01 12:00:00", + "user": { + "id": 1, + "name": "管理员", + "username": "admin" + } + } + } + ``` -#### 删除日志 +#### 获取日志统计 +- **接口**: `GET /admin/logs/statistics` +- **参数**: + ```json + { + "start_date": "2024-01-01", + "end_date": "2024-12-31" + } + ``` +- **响应示例**: + ```json + { + "code": 200, + "message": "success", + "data": { + "total": 1000, + "success": 950, + "error": 50 + } + } + ``` + +#### 导出日志 +- **接口**: `POST /admin/logs/export` +- **参数**: 与获取日志列表相同的查询参数 +- **响应**: Excel 文件下载 +- **文件名格式**: `系统操作日志_YYYYMMDDHHmmss.xlsx` +- **包含字段**: + - ID + - 用户名 + - 模块 + - 操作 + - 请求方法 + - URL + - IP 地址 + - 状态码 + - 状态 + - 错误信息 + - 执行时间(ms) + - 创建时间 + +#### 删除单条日志 - **接口**: `DELETE /admin/logs/{id}` +- **响应示例**: + ```json + { + "code": 200, + "message": "删除成功", + "data": null + } + ``` #### 批量删除日志 - **接口**: `POST /admin/logs/batch-delete` - **参数**: ```json { - "ids": [1, 2, 3] + "ids": [1, 2, 3, 4, 5] + } + ``` +- **响应示例**: + ```json + { + "code": 200, + "message": "批量删除成功", + "data": null } ``` -#### 清理日志 +#### 清理历史日志 - **接口**: `POST /admin/logs/clear` - **参数**: ```json @@ -232,39 +423,129 @@ app/Http/Controllers/System/ "days": 30 } ``` -- **说明**: 删除指定天数之前的日志记录 - -#### 获取日志统计 -- **接口**: `GET /admin/logs/statistics` -- **参数**: - - `start_date`: 开始日期 - - `end_date`: 结束日期 -- **返回**: +- **说明**: 清理指定天数前的所有日志记录,默认清理 30 天前的数据 +- **响应示例**: ```json { "code": 200, - "message": "success", - "data": { - "total_count": 1000, - "module_stats": [ - { - "module": "user", - "count": 500 - } - ], - "user_stats": [ - { - "user_id": 1, - "username": "admin", - "count": 800 - } - ] - } + "message": "清理成功", + "data": null } ``` ### 数据字典管理 +数据字典模块提供了完整的字典管理功能,包括字典分类和字典项的 CRUD 操作。通过 WebSocket 实现了前后端缓存的实时同步更新。 + +#### 字典缓存更新机制 + +**概述** + +字典缓存更新机制通过 WebSocket 实现前后端字典缓存的实时同步,确保在字典分类和字典项的增删改等操作后,前端字典缓存能够自动更新。 + +**技术实现** + +1. **后端实现** + +在 `app/Services/System/DictionaryService.php` 中添加了 WebSocket 通知功能: + +**通知方法:** +- `notifyDictionaryUpdate` - 字典分类更新通知 + - 触发时机:创建、更新、删除、批量删除、批量更新状态 + - 消息类型:`dictionary_update` + +- `notifyDictionaryItemUpdate` - 字典项更新通知 + - 触发时机:创建、更新、删除、批量删除、批量更新状态 + - 消息类型:`dictionary_item_update` + +**WebSocket 消息格式:** + +字典分类更新消息: +```json +{ + "type": "dictionary_update", + "data": { + "action": "create|update|delete|batch_delete|batch_update_status", + "resource_type": "dictionary", + "data": { + // 字典分类数据 + }, + "timestamp": 1234567890 + } +} +``` + +字典项更新消息: +```json +{ + "type": "dictionary_item_update", + "data": { + "action": "create|update|delete|batch_delete|batch_update_status", + "resource_type": "dictionary_item", + "data": { + // 字典项数据 + }, + "timestamp": 1234567890 + } +} +``` + +2. **前端实现** + +创建了 `resources/admin/src/composables/useWebSocket.js` 来处理 WebSocket 连接和消息监听: + +**主要功能:** +- 初始化 WebSocket 连接(检查用户登录状态、验证用户信息完整性) +- 消息处理器:`handleDictionaryUpdate` 和 `handleDictionaryItemUpdate` +- 缓存刷新:接收到更新通知后,自动刷新字典缓存并显示成功提示 + +**App.vue 集成:** + +```javascript +onMounted(async () => { + // 初始化 WebSocket 连接 + if (userStore.isLoggedIn()) { + initWebSocket() + } +}) + +onUnmounted(() => { + // 关闭 WebSocket 连接 + closeWebSocket() +}) +``` + +**工作流程:** + +``` +用户操作(增删改字典) + ↓ +后端 Controller 调用 Service + ↓ +Service 执行数据库操作 + ↓ +Service 清理后端缓存(Redis) + ↓ +Service 发送 WebSocket 广播通知 + ↓ +WebSocket 推送消息到所有在线客户端 + ↓ +前端接收 WebSocket 消息 + ↓ +触发相应的消息处理器 + ↓ +刷新前端字典缓存 + ↓ +显示成功提示 +``` + +**注意事项:** +- WebSocket 仅在用户登录后建立连接 +- 连接失败会自动重试(最多 5 次) +- 页面卸载时会自动关闭连接 +- WebSocket 通知功能依赖于 Laravel-S (Swoole) 环境 +- 在普通 PHP 环境下运行时,WebSocket 通知会优雅降级(不发送通知,但不影响功能) + #### 获取字典列表 - **接口**: `GET /admin/dictionaries` - **参数**: @@ -658,8 +939,17 @@ app/Http/Controllers/System/ ### 数据字典缓存 - **缓存键**: `dictionary:all` 或 `dictionary:code:{code}` -- **过期时间**: 60分钟 -- **更新时机**: 字典数据增删改时自动清除 +- **过期时间**: 3600秒(1小时) +- **更新时机**: + - 字典数据增删改时自动清除后端缓存(Redis) + - 通过 WebSocket 通知前端自动刷新缓存 + +**字典缓存同步流程:** +1. 后端执行字典操作(增删改) +2. 清理 Redis 缓存 +3. 发送 WebSocket 广播通知 +4. 前端接收通知并自动刷新缓存 +5. 显示成功提示 ## 服务层说明 @@ -681,13 +971,24 @@ app/Http/Controllers/System/ **主要方法**: - `getList()`: 获取日志列表 +- `getById()`: 根据 ID 获取日志详情 - `getStatistics()`: 获取统计数据 +- `getListQuery()`: 获取日志查询构建器 - `clearLogs()`: 清理过期日志 +- `delete()`: 删除单条日志 +- `batchDelete()`: 批量删除日志 - `record()`: 记录日志(由中间件自动调用) +**特性**: +- 自动记录所有经过中间件的请求 +- 计算请求执行时间 +- 过滤敏感参数 +- 获取客户端真实 IP(支持代理) +- 异常处理,记录失败不影响业务 + ### DictionaryService -提供数据字典和字典项的管理功能。 +提供数据字典和字典项的管理功能,包括 WebSocket 通知机制。 **主要方法**: - `getList()`: 获取字典列表 @@ -696,6 +997,13 @@ app/Http/Controllers/System/ - `createItem()`: 创建字典项 - `update()`: 更新字典 - `updateItem()`: 更新字典项 +- `notifyDictionaryUpdate()`: 发送字典更新通知 +- `notifyDictionaryItemUpdate()`: 发送字典项更新通知 + +**缓存机制**: +- Redis 缓存字典数据(TTL: 3600秒) +- WebSocket 实时通知前端更新 +- 前端 Pinia + 本地存储持久化 ### TaskService @@ -744,49 +1052,459 @@ php artisan db:seed --class=SystemSeeder - 常用数据字典 - 全国省市区数据 +## 前端集成示例 + +### 日志管理页面 + +```vue + + + +``` + ## 注意事项 -1. **Swoole环境注意事项**: - - 文件上传时注意临时文件清理 - - 使用Redis缓存避免内存泄漏 - - 图片压缩使用协程安全的方式 +### 1. Swoole环境注意事项 +- 文件上传时注意临时文件清理 +- 使用Redis缓存避免内存泄漏 +- 图片压缩使用协程安全的方式 +- WebSocket 通知依赖于 Laravel-S 环境 -2. **安全注意事项**: - - 文件上传必须验证文件类型和大小 - - 敏感操作必须记录日志 - - 配置数据不要存储密码等敏感信息 +### 2. 安全注意事项 +- 文件上传必须验证文件类型和大小 +- 敏感操作必须记录日志 +- 配置数据不要存储密码等敏感信息 +- 日志敏感信息已自动过滤 -3. **性能优化**: - - 城市数据使用Redis缓存 - - 大量日志数据定期清理 - - 图片上传时进行压缩处理 +### 3. 性能优化 +- 城市数据使用Redis缓存 +- 大量日志数据定期清理 +- 图片上传时进行压缩处理 +- 日志记录在请求处理后执行,不影响响应速度 -4. **文件上传**: - - 限制文件上传大小 - - 验证文件MIME类型 - - 定期清理临时文件 +### 4. 文件上传 +- 限制文件上传大小 +- 验证文件MIME类型 +- 定期清理临时文件 + +### 5. 日志管理 +- 定期清理历史日志(建议使用任务调度器) +- 确保查询字段有索引 +- 使用分页查询避免加载过多数据 + +## 性能优化建议 + +### 1. 定期清理日志 + +建议使用 Laravel 任务调度器定期清理历史日志: + +```php +// app/Console/Kernel.php + +protected function schedule(Schedule $schedule) +{ + // 每天凌晨 2 点清理 90 天前的日志 + $schedule->call(function () { + app(LogService::class)->clearLogs(90); + })->dailyAt('02:00'); +} +``` + +### 2. 数据库索引 + +确保以下字段有索引: +- `system_logs`: `user_id`, `username`, `module`, `status`, `created_at` +- `system_dictionaries`: `code`, `status` +- `system_dictionary_items`: `dictionary_id`, `status` +- `system_configs`: `group`, `key`, `status` + +### 3. 分页查询 + +列表查询必须使用分页,避免一次加载过多数据。 + +### 4. 异步记录 + +日志记录操作应放在请求处理后,不影响响应速度。 + +### 5. 细粒度缓存更新 + +字典缓存当前实现为全量刷新,未来可以优化为增量更新: + +```javascript +// 只更新受影响的字典 +async function handleDictionaryUpdate(data) { + const { action, data: dictData } = data + + if (action === 'update' && dictData.code) { + // 只更新特定的字典 + await dictionaryStore.getDictionary(dictData.code, true) + } else { + // 全量刷新 + await dictionaryStore.refresh(true) + } +} +``` ## 扩展建议 -1. **日志告警**: 添加日志异常告警功能 -2. **配置加密**: 敏感配置数据加密存储 -3. **多语言**: 支持配置数据的多语言 -4. **任务监控**: 添加任务执行监控和通知 -5. **CDN集成**: 文件上传支持CDN分发 +### 1. 日志告警 +添加日志异常告警功能,当出现大量错误日志时自动通知管理员。 + +### 2. 配置加密 +敏感配置数据加密存储,提高安全性。 + +### 3. 多语言 +支持配置数据的多语言,便于国际化部署。 + +### 4. 任务监控 +添加任务执行监控和通知,实时掌握任务运行状态。 + +### 5. CDN集成 +文件上传支持CDN分发,提高访问速度。 + +### 6. WebSocket 权限控制 +可以只向有权限的用户发送通知: + +```php +// 后端只向有字典管理权限的用户发送 +$adminUserIds = User::whereHas('roles', function($query) { + $query->where('name', 'admin'); +})->pluck('id')->toArray(); + +$this->webSocketService->sendToUsers($adminUserIds, $message); +``` + +### 7. 消息队列 +对于高并发场景,可以使用消息队列异步发送 WebSocket 通知: + +```php +// 使用 Laravel 队列 +UpdateDictionaryCacheJob::dispatch($action, $data); +``` ## 常见问题 -### Q: 如何清除城市数据缓存? +### Q1: 如何清除城市数据缓存? A: 调用 `CityService::clearCache()` 方法或运行 `php artisan cache:forget city:tree`。 -### Q: 图片上传后如何压缩? +### Q2: 图片上传后如何压缩? A: 上传时设置 `compress=true` 和 `quality` 参数,系统会自动压缩。 -### Q: 如何配置定时任务? +### Q3: 如何配置定时任务? A: 在Admin后台创建任务,设置Cron表达式,系统会自动调度执行。 -### Q: 数据字典如何使用? +### Q4: 数据字典如何使用? A: 通过Public API获取字典数据,前端根据数据渲染下拉框等组件。 -### Q: 日志数据过多如何处理? +### Q5: 日志数据过多如何处理? A: 定期使用 `/admin/logs/clear` 接口清理过期日志,或在后台设置自动清理任务。 + +### Q6: 为什么某些请求没有被记录? +A: 检查路由是否应用了 `log.request` 中间件,或者在中间件中是否被排除了。 + +### Q7: 字典缓存未更新怎么办? +A: 检查以下几点: +- 确认 Laravel-S 服务是否启动:`php bin/laravels status` +- 检查浏览器控制台是否有 WebSocket 错误 +- 确认用户已登录且有 token +- 手动刷新页面验证 API 是否正常 + +### Q8: WebSocket 连接失败会影响功能吗? +A: 不会。WebSocket 连接失败不影响页面正常使用,只是不会收到自动更新通知。字典数据仍会正常更新到数据库和 Redis 缓存,只是前端不会收到实时通知,需要手动刷新页面。 + +## 测试建议 + +### 1. 功能测试 +1. 测试各种请求是否被正确记录 +2. 测试敏感信息是否被正确过滤 +3. 测试日志查询和筛选功能 +4. 测试日志导出功能 +5. 测试批量删除和清理功能 +6. 测试字典 WebSocket 通知是否正确 + +### 2. 性能测试 +1. 测试日志记录对响应时间的影响 +2. 测试大量日志数据的查询性能 +3. 测试并发写入的性能 +4. 测试 WebSocket 广播性能 + +### 3. 集成测试(字典 WebSocket) +1. 启动后端服务(Laravel-S) +2. 启动前端开发服务器 +3. 在浏览器中登录系统 +4. 打开开发者工具的 Network -> WS 标签查看 WebSocket 消息 +5. 执行字典增删改操作 +6. 验证: + - WebSocket 消息是否正确接收 + - 缓存是否自动刷新 + - 页面数据是否更新 + - 提示消息是否显示 + +### 4. 并发测试 +1. 打开多个浏览器窗口并登录 +2. 在一个窗口中进行字典操作 +3. 验证所有窗口的缓存是否同步更新 + +### 5. 边界测试 +1. 测试异常情况下的日志记录 +2. 测试超长参数的处理 +3. 测试特殊字符的处理 +4. 测试 WebSocket 断连重连机制 + +## 文件清单 + +### 核心文件 + +**控制器:** +``` +app/Http/Controllers/System/Admin/Config.php +app/Http/Controllers/System/Admin/Log.php +app/Http/Controllers/System/Admin/Dictionary.php +app/Http/Controllers/System/Admin/Task.php +app/Http/Controllers/System/Admin/City.php +app/Http/Controllers/System/Admin/Upload.php +app/Http/Controllers/System/WebSocket.php +``` + +**中间件:** +``` +app/Http/Middleware/LogRequestMiddleware.php +``` + +**请求验证:** +``` +app/Http/Requests/LogRequest.php +``` + +**服务层:** +``` +app/Services/System/ConfigService.php +app/Services/System/LogService.php +app/Services/System/DictionaryService.php +app/Services/System/TaskService.php +app/Services/System/CityService.php +app/Services/System/UploadService.php +app/Services/WebSocket/WebSocketService.php +``` + +**模型:** +``` +app/Models/System/Config.php +app/Models/System/Log.php +app/Models/System/Dictionary.php +app/Models/System/DictionaryItem.php +app/Models/System/Task.php +app/Models/System/City.php +``` + +**路由:** +``` +routes/admin.php (后台管理路由) +routes/api.php (公共 API 路由) +``` + +**前端:** +``` +resources/admin/src/composables/useWebSocket.js +resources/admin/src/App.vue (集成 WebSocket) +``` + +**文档:** +``` +docs/README_SYSTEM.md (本文档) +``` + +## 总结 + +System 基础模块提供了完整的系统管理功能,包括: + +### 核心功能 + +- ✅ 系统配置管理(多分组、多类型支持) +- ✅ 数据字典管理(分类 + 字典项) +- ✅ 操作日志管理(自动记录、多维度查询、导出) +- ✅ 任务管理(定时任务、手动执行、统计) +- ✅ 城市数据管理(三级联动、缓存优化) +- ✅ 文件上传管理(单文件、多文件、Base64、压缩) + +### 高级特性 + +- ✅ WebSocket 实时通知(字典缓存自动更新) +- ✅ Redis 缓存机制(性能优化) +- ✅ 自动化日志记录(中间件拦截) +- ✅ 敏感信息保护(自动过滤) +- ✅ 数据导出功能(Excel 导出) +- ✅ 批量操作支持 +- ✅ 完整的 API 文档 + +### 性能优化 + +- ✅ Redis 缓存(城市数据、系统配置、数据字典) +- ✅ 日志异步记录(不影响响应速度) +- ✅ 分页查询(避免加载过多数据) +- ✅ 图片压缩(减少存储空间) +- ✅ WebSocket 实时更新(减少不必要的请求) + +### 安全特性 + +- ✅ 敏感信息过滤 +- ✅ 文件类型验证 +- ✅ 请求日志记录 +- ✅ IP 地址记录 +- ✅ 异常处理机制 + +System 模块现已完全集成到项目中,提供了完整的系统管理功能和优秀的用户体验。 diff --git a/docs/README_WEBSOCKET_NOTIFICATION.md b/docs/README_WEBSOCKET_NOTIFICATION.md index 0f8ec0e..05c6b35 100644 --- a/docs/README_WEBSOCKET_NOTIFICATION.md +++ b/docs/README_WEBSOCKET_NOTIFICATION.md @@ -10,6 +10,7 @@ - [API接口](#api接口) - [配置说明](#配置说明) - [使用示例](#使用示例) +- [连接问题修复](#连接问题修复) - [故障排查与修复](#故障排查与修复) - [性能优化](#性能优化) - [安全考虑](#安全考虑) @@ -25,7 +26,7 @@ ### 核心特性 - ✅ 实时双向通信(WebSocket) -- ✅ 用户连接管理 +- ✅ 用户连接管理(单例模式,避免重复连接) - ✅ 点对点消息发送 - ✅ 群发消息/广播 - ✅ 频道订阅/取消订阅 @@ -36,6 +37,7 @@ - ✅ 通知持久化存储 - ✅ 已读/未读状态管理 - ✅ 批量操作支持 +- ✅ **连接时自动授权(无需单独发送 auth 事件)** --- @@ -46,9 +48,11 @@ | 功能 | 说明 | 状态 | |------|------|------| | 自动连接管理 | 登录后自动建立连接,退出时自动关闭 | ✅ | +| 单例连接模式 | 全局只有一个 WebSocket 实例,避免重复连接 | ✅ | +| 连接时授权 | 通过 URL 参数传递 token,握手时即完成认证 | ✅ | | 断线重连 | 连接断开自动重连(最多5次) | ✅ | | 心跳机制 | 客户端每30秒发送心跳保持连接 | ✅ | -| 用户认证 | 通过 token 验证用户身份 | ✅ | +| 用户认证 | JWT token 验证,验证用户 ID 匹配和过期时间 | ✅ | | 点对点消息 | 发送消息给指定用户 | ✅ | | 广播消息 | 向所有在线用户发送消息 | ✅ | | 频道订阅 | 支持频道订阅和取消订阅 | ✅ | @@ -82,6 +86,9 @@ │ ┌──────────────────────────────┐ │ │ │ WebSocketHandler │ │ │ │ (WebSocket 事件处理) │ │ +│ │ - onOpen: 连接时授权 │ │ +│ │ - onMessage: 消息路由 │ │ +│ │ - onClose: 清理连接 │ │ │ └──────────┬───────────────┘ │ │ │ │ │ ▼ │ @@ -104,7 +111,7 @@ │ │ │ │ ▼ │ │ ┌──────────────────────────────┐ │ -│ │ Swoole Server │ │ +│ │ Swoole Server │ │ │ │ (WebSocket 服务器) │ │ │ └──────────┬───────────────┘ │ │ │ │ @@ -125,8 +132,21 @@ ├─────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────┐ │ -│ │ Userbar Component │ │ -│ │ (通知入口 + 徽章显示) │ │ +│ │ App.vue (统一连接点) │ │ +│ │ - 初始化 WebSocket │ │ +│ │ - 监听用户状态 │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ useWebSocket Hook │ │ +│ │ (单例管理 + 消息路由) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ WebSocket Client │ │ +│ │ (单例 + 重连 + 心跳) │ │ │ └──────────┬───────────────┘ │ │ │ │ │ ▼ │ @@ -137,19 +157,8 @@ │ │ │ │ ▼ │ │ ┌──────────────────────────────┐ │ -│ │ System API │ │ -│ │ (接口封装) │ │ -│ └──────────┬───────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────┐ │ -│ │ useWebSocket Hook │ │ -│ │ (WebSocket 管理) │ │ -│ └──────────┬───────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────┐ │ -│ │ WebSocket Client │ │ +│ │ Userbar/Notification Page │ │ +│ │ (展示 + 交互) │ │ │ └──────────────────────────────┘ │ │ │ └─────────────────────────────────────────┘ @@ -190,7 +199,7 @@ WebSocket 处理器,实现 Swoole 的 `WebSocketHandlerInterface` 接口。 **主要方法:** -- `onOpen()`: 处理连接建立事件 +- `onOpen()`: 处理连接建立事件,**连接时通过 URL 参数授权** - `onMessage()`: 处理消息接收事件 - `onClose()`: 处理连接关闭事件 @@ -201,6 +210,118 @@ WebSocket 处理器,实现 Swoole 的 `WebSocketHandlerInterface` 接口。 - `broadcast`: 广播消息 - `subscribe/unsubscribe`: 频道订阅/取消订阅 +**连接时授权流程:** + +```php +public function onOpen(Server $server, Request $request): void +{ + // 从 URL 查询参数获取认证信息 + $userId = (int)($request->get['user_id'] ?? 0); + $token = $request->get['token'] ?? ''; + + // 用户认证 + if (!$userId || !$token) { + $server->push($request->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '认证失败:缺少 user_id 或 token', + 'code' => 401 + ] + ])); + $server->disconnect($request->fd); + return; + } + + // 验证 JWT token + try { + $payload = JWTAuth::setToken($token)->getPayload(); + + // 验证 token 中的用户 ID 是否匹配 + $tokenUserId = $payload['sub'] ?? null; + if ($tokenUserId != $userId) { + Log::warning('WebSocket 认证失败:用户 ID 不匹配', [ + 'fd' => $request->fd, + 'token_user_id' => $tokenUserId, + 'query_user_id' => $userId + ]); + + $server->push($request->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '认证失败:用户 ID 不匹配', + 'code' => 401 + ] + ])); + $server->disconnect($request->fd); + return; + } + + // 验证 token 是否过期 + if (isset($payload['exp']) && $payload['exp'] < time()) { + Log::warning('WebSocket 认证失败:token 已过期', [ + 'fd' => $request->fd, + 'user_id' => $userId, + 'exp' => $payload['exp'], + 'current_time' => time() + ]); + + $server->push($request->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '认证失败:token 已过期', + 'code' => 401 + ] + ])); + $server->disconnect($request->fd); + return; + } + + Log::info('WebSocket 认证成功', [ + 'fd' => $request->fd, + 'user_id' => $userId + ]); + } catch (\Exception $e) { + Log::warning('WebSocket 认证失败:无效的 token', [ + 'fd' => $request->fd, + 'user_id' => $userId, + 'error' => $e->getMessage() + ]); + + $server->push($request->fd, json_encode([ + 'type' => 'error', + 'data' => [ + 'message' => '认证失败:无效的 token', + 'code' => 401 + ] + ])); + $server->disconnect($request->fd); + return; + } + + // 认证成功,存储连接映射 + $wsTable->set('uid:' . $userId, [ + 'value' => $request->fd, + 'expiry' => time() + 3600 + ]); + + $wsTable->set('fd:' . $request->fd, [ + 'value' => $userId, + 'expiry' => time() + 3600 + ]); + + // 发送欢迎消息 + $server->push($request->fd, json_encode([ + 'type' => 'connected', + 'data' => [ + 'message' => '欢迎连接到 LaravelS WebSocket', + 'user_id' => $userId, + 'fd' => $request->fd, + 'timestamp' => time() + ] + ])); +} +``` + #### WebSocketService (`app/Services/WebSocket/WebSocketService.php`) WebSocket 服务类,提供便捷的 WebSocket 操作方法。 @@ -365,11 +486,68 @@ php artisan notifications:retry-unsent --limit=50 WebSocket 客户端封装类,提供自动连接、重连、消息处理等功能。 **主要功能:** +- 单例模式,确保全局只有一个实例 - 自动连接和重连 - 心跳机制 - 消息类型路由 - 事件监听 - 连接状态管理 +- **连接时通过 URL 参数授权** + +**连接 URL 构建:** +```javascript +export function createWebSocket(userId, token, options = {}) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = options.wsUrl || window.location.host + + // ✅ 认证信息通过 URL 参数传递 + const url = `${protocol}//${host}/ws?user_id=${userId}&token=${token}` + + return new WebSocketClient(url, options) +} +``` + +**单例管理:** +```javascript +let wsClient = null +let currentUserId = null +let currentToken = null +let pendingConnection = null + +export function getWebSocket(userId, token, options = {}) { + // 检查是否已有相同用户的连接 + if (wsClient) { + if (currentUserId === userId && currentToken === token) { + const state = wsClient.getConnectionState() + + // 如果已连接或正在连接,返回现有实例 + if (state === 'OPEN' || state === 'CONNECTING') { + return wsClient + } + + // 如果连接已关闭,清理 + if (state === 'CLOSED' || state === 'CLOSING') { + wsClient = null + } + } + + // 不同用户/Token,断开旧连接 + if (currentUserId !== userId || currentToken !== token) { + if (wsClient) { + wsClient.disconnect() + } + wsClient = null + } + } + + // 创建新连接 + currentUserId = userId + currentToken = token + wsClient = createWebSocket(userId, token, options) + + return wsClient +} +``` **使用示例:** ```javascript @@ -378,19 +556,19 @@ import { useUserStore } from '@/stores/modules/user' const userStore = useUserStore() -// 连接 WebSocket +// 连接 WebSocket(连接时自动授权) const ws = getWebSocket(userStore.userInfo.id, userStore.token, { onOpen: (event) => { - console.log('WebSocket 已连接') + console.log('WebSocket 已连接(已通过 URL 参数完成认证)') }, onMessage: (message) => { - console.log('收到消息:', message) + console.log('收到消息:', message) }, onError: (error) => { - console.error('WebSocket 错误:', error) + console.error('WebSocket 错误:', error) }, onClose: (event) => { - console.log('WebSocket 已关闭') + console.log('WebSocket 已关闭') } }) @@ -409,7 +587,68 @@ ws.on('notification', (data) => { ws.disconnect() ``` -### 2. 通知 Store +### 2. useWebSocket Hook + +#### useWebSocket (`resources/admin/src/composables/useWebSocket.js`) + +WebSocket 组合式函数,统一管理 WebSocket 连接和消息处理。 + +**主要功能:** +- 统一初始化 WebSocket(只在 App.vue 中调用) +- 防止重复初始化 +- 自动注册消息处理器 +- 监听用户状态变化 +- 连接时自动授权(无需发送 auth 事件) + +**关键实现:** +```javascript +let wsInstance = null +let isInitialized = false + +export function useWebSocket() { + const userStore = useUserStore() + + const initWebSocket = () => { + // 防止重复初始化 + if (isInitialized && wsInstance && wsInstance.isConnected) { + console.log('WebSocket 已连接,跳过重复初始化') + return + } + + isInitialized = true + + // 获取 WebSocket 实例(单例) + wsInstance = getWebSocket(userStore.userInfo.id, userStore.token, { + onOpen: handleOpen, + onMessage: handleMessage, + onError: handleError, + onClose: handleClose + }) + + // 注册消息处理器 + registerMessageHandlers() + + // 连接 + wsInstance.connect() + } + + // 连接打开(已通过 URL 参数完成认证) + function handleOpen(event) { + console.log('WebSocket 连接已建立(已通过 URL 参数完成认证)', event) + } + + return { + ws: wsInstance, + initWebSocket, + closeWebSocket, + reconnect, + isConnected, + send + } +} +``` + +### 3. 通知 Store #### Notification Store (`resources/admin/src/stores/modules/notification.js`) @@ -468,90 +707,6 @@ await notificationStore.retryUnsentNotifications(100) await notificationStore.fetchStatistics() ``` -### 3. 通知 API - -#### System API (`resources/admin/src/api/system.js`) - -通知相关的 API 接口封装。 - -```javascript -// API 方法 -export default { - // 通知列表 - notifications: { - get: async function(params) { - return await request.get('admin/system/notifications', { params }) - }, - - // 未读列表 - unread: { - get: async function(params) { - return await request.get('admin/system/notifications/unread', { params }) - } - }, - - // 未读数量 - unreadCount: { - get: async function() { - return await request.get('admin/system/notifications/unread-count') - } - }, - - // 详情 - show: async function(id) { - return await request.get(`admin/system/notifications/${id}`) - }, - - // 标记已读 - read: async function(id) { - return await request.post(`admin/system/notifications/${id}/read`) - }, - - // 批量标记已读 - batchRead: async function(data) { - return await request.post('admin/system/notifications/batch-read', data) - }, - - // 全部标记已读 - readAll: async function() { - return await request.post('admin/system/notifications/read-all') - }, - - // 删除 - delete: async function(id) { - return await request.delete(`admin/system/notifications/${id}`) - }, - - // 批量删除 - batchDelete: async function(data) { - return await request.post('admin/system/notifications/batch-delete', data) - }, - - // 清空已读 - clearRead: async function() { - return await request.post('admin/system/notifications/clear-read') - }, - - // 统计 - statistics: { - get: async function() { - return await request.get('admin/system/notifications/statistics') - } - }, - - // 发送通知 - send: async function(data) { - return await request.post('admin/system/notifications/send', data) - }, - - // 重试未发送 - retryUnsent: async function(params) { - return await request.post('admin/system/notifications/retry-unsent', null, { params }) - } - } -} -``` - ### 4. 用户栏通知组件 #### Userbar Component (`resources/admin/src/layouts/components/userbar.vue`) @@ -601,11 +756,11 @@ GET /admin/websocket/online-count **响应:** ```json { - "code": 200, - "message": "success", - "data": { - "online_count": 10 - } + "code": 200, + "message": "success", + "data": { + "online_count": 10 + } } ``` @@ -618,12 +773,12 @@ GET /admin/websocket/online-users **响应:** ```json { - "code": 200, - "message": "success", - "data": { - "user_ids": [1, 2, 3, 4, 5], - "count": 5 - } + "code": 200, + "message": "success", + "data": { + "user_ids": [1, 2, 3, 4, 5], + "count": 5 + } } ``` @@ -636,19 +791,19 @@ POST /admin/websocket/check-online **请求参数:** ```json { - "user_id": 1 + "user_id": 1 } ``` **响应:** ```json { - "code": 200, - "message": "success", - "data": { - "user_id": 1, - "is_online": true - } + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "is_online": true + } } ``` @@ -661,12 +816,12 @@ POST /admin/websocket/send-to-user **请求参数:** ```json { - "user_id": 1, - "type": "notification", - "data": { - "title": "新消息", - "message": "您有一条新消息" - } + "user_id": 1, + "type": "notification", + "data": { + "title": "新消息", + "message": "您有一条新消息" + } } ``` @@ -679,12 +834,12 @@ POST /admin/websocket/send-to-users **请求参数:** ```json { - "user_ids": [1, 2, 3], - "type": "notification", - "data": { - "title": "系统通知", - "message": "系统将在今晚进行维护" - } + "user_ids": [1, 2, 3], + "type": "notification", + "data": { + "title": "系统通知", + "message": "系统将在今晚进行维护" + } } ``` @@ -697,12 +852,12 @@ POST /admin/websocket/broadcast **请求参数:** ```json { - "type": "notification", - "data": { - "title": "公告", - "message": "欢迎使用新版本" - }, - "exclude_user_id": 1 // 可选:排除某个用户 + "type": "notification", + "data": { + "title": "公告", + "message": "欢迎使用新版本" + }, + "exclude_user_id": 1 // 可选:排除某个用户 } ``` @@ -715,12 +870,12 @@ POST /admin/websocket/send-to-channel **请求参数:** ```json { - "channel": "orders", - "type": "data_update", - "data": { - "order_id": 123, - "status": "paid" - } + "channel": "orders", + "type": "data_update", + "data": { + "order_id": 123, + "status": "paid" + } } ``` @@ -733,13 +888,13 @@ POST /admin/websocket/send-notification **请求参数:** ```json { - "title": "系统维护", - "message": "系统将于今晚 23:00-24:00 进行维护", - "type": "warning", - "extra_data": { - "start_time": "23:00", - "end_time": "24:00" - } + "title": "系统维护", + "message": "系统将于今晚 23:00-24:00 进行维护", + "type": "warning", + "extra_data": { + "start_time": "23:00", + "end_time": "24:00" + } } ``` @@ -752,10 +907,10 @@ POST /admin/websocket/send-notification-to-users **请求参数:** ```json { - "user_ids": [1, 2, 3], - "title": "订单更新", - "message": "您的订单已发货", - "type": "success" + "user_ids": [1, 2, 3], + "title": "订单更新", + "message": "您的订单已发货", + "type": "success" } ``` @@ -768,13 +923,13 @@ POST /admin/websocket/push-data-update **请求参数:** ```json { - "user_ids": [1, 2, 3], - "resource_type": "order", - "action": "update", - "data": { - "id": 123, - "status": "shipped" - } + "user_ids": [1, 2, 3], + "resource_type": "order", + "action": "update", + "data": { + "id": 123, + "status": "shipped" + } } ``` @@ -787,14 +942,14 @@ POST /admin/websocket/push-data-update-channel **请求参数:** ```json { - "channel": "orders", - "resource_type": "order", - "action": "create", - "data": { - "id": 124, - "customer": "张三", - "amount": 100.00 - } + "channel": "orders", + "resource_type": "order", + "action": "create", + "data": { + "id": 124, + "customer": "张三", + "amount": 100.00 + } } ``` @@ -807,7 +962,7 @@ POST /admin/websocket/disconnect-user **请求参数:** ```json { - "user_id": 1 + "user_id": 1 } ``` @@ -840,28 +995,28 @@ GET /admin/system/notifications "code": 200, "message": "success", "data": { - "list": [ - { - "id": 1, - "user_id": 1, - "title": "系统通知", - "content": "这是一个测试通知", - "type": "info", - "category": "system", - "data": {}, - "action_type": null, - "action_data": null, - "is_read": false, - "read_at": null, - "sent_via_websocket": true, - "sent_at": "2024-02-18 10:00:00", - "retry_count": 0, - "created_at": "2024-02-18 10:00:00" - } - ], - "total": 100, - "page": 1, - "page_size": 20 + "list": [ + { + "id": 1, + "user_id": 1, + "title": "系统通知", + "content": "这是一个测试通知", + "type": "info", + "category": "system", + "data": {}, + "action_type": null, + "action_data": null, + "is_read": false, + "read_at": null, + "sent_via_websocket": true, + "sent_at": "2024-02-18 10:00:00", + "retry_count": 0, + "created_at": "2024-02-18 10:00:00" + } + ], + "total": 100, + "page": 1, + "page_size": 20 } } ``` @@ -884,7 +1039,7 @@ GET /admin/system/notifications/unread-count "code": 200, "message": "success", "data": { - "count": 5 + "count": 5 } } ``` @@ -957,23 +1112,23 @@ GET /admin/system/notifications/statistics "code": 200, "message": "success", "data": { - "total": 100, - "unread": 5, - "read": 95, - "by_type": { - "info": 50, - "success": 20, - "warning": 15, - "error": 10, - "task": 5 - }, - "by_category": { - "system": 60, - "task": 20, - "message": 10, - "reminder": 8, - "announcement": 2 - } + "total": 100, + "unread": 5, + "read": 95, + "by_type": { + "info": 50, + "success": 20, + "warning": 15, + "error": 10, + "task": 5 + }, + "by_category": { + "system": 60, + "task": 20, + "message": 10, + "reminder": 8, + "announcement": 2 + } } } ``` @@ -993,12 +1148,12 @@ POST /admin/system/notifications/send "type": "warning", "category": "announcement", "data": { - "maintenance_start": "2024-02-18 22:00:00", - "maintenance_end": "2024-02-19 00:00:00" + "maintenance_start": "2024-02-18 22:00:00", + "maintenance_end": "2024-02-19 00:00:00" }, "action_type": "link", "action_data": { - "url": "/system/maintenance" + "url": "/system/maintenance" } } ``` @@ -1019,26 +1174,26 @@ POST /admin/system/notifications/retry-unsent?limit=100 ```php 'websocket' => [ - 'enable' => env('LARAVELS_WEBSOCKET', true), - 'handler' => \App\Services\WebSocket\WebSocketHandler::class, + 'enable' => env('LARAVELS_WEBSOCKET', true), + 'handler' => \App\Services\WebSocket\WebSocketHandler::class, ], 'swoole' => [ - 'enable_coroutine' => true, - 'worker_num' => 4, - 'max_request' => 5000, - 'max_request_grace' => 500, - 'dispatch_mode' => 2, // 重要:使用抢占模式确保连接状态一致性 + 'enable_coroutine' => true, + 'worker_num' => 4, + 'max_request' => 5000, + 'max_request_grace' => 500, + 'dispatch_mode' => 2, // 重要:使用抢占模式确保连接状态一致性 ], 'swoole_tables' => [ - 'wsTable' => [ - 'size' => 102400, - 'column' => [ - ['name' => 'value', 'type' => \Swoole\Table::TYPE_STRING, 'size' => 1024], - ['name' => 'expiry', 'type' => \Swoole\Table::TYPE_INT, 'size' => 4], - ], - ], + 'wsTable' => [ + 'size' => 102400, + 'column' => [ + ['name' => 'value', 'type' => \Swoole\Table::TYPE_STRING, 'size' => 1024], + ['name' => 'expiry', 'type' => \Swoole\Table::TYPE_INT, 'size' => 4], + ], + ], ], ``` @@ -1097,32 +1252,32 @@ const maxLocalNotifications = 100 // 本地最大存储数量 ```nginx server { - listen 80; - server_name yourdomain.com; - root /path/to/your/project/public; + listen 80; + server_name yourdomain.com; + root /path/to/your/project/public; - location / { - try_files $uri $uri/ /index.php?$query_string; - } + location / { + try_files $uri $uri/ /index.php?$query_string; + } - # WebSocket 代理配置 - location /ws { - proxy_pass http://127.0.0.1:5200; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_read_timeout 86400; - } + # WebSocket 代理配置 + location /ws { + proxy_pass http://127.0.0.1:5200; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } - location ~ \.php$ { - fastcgi_pass 127.0.0.1:9000; - fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - include fastcgi_params; - } + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } } ``` @@ -1154,25 +1309,25 @@ use App\Models\System\Notification; class YourService { - protected $notificationService; + protected $notificationService; - public function __construct(NotificationService $notificationService) - { - $this->notificationService = $notificationService; - } + public function __construct(NotificationService $notificationService) + { + $this->notificationService = $notificationService; + } - public function someMethod() - { - // 发送通知给单个用户 - $result = $this->notificationService->sendToUser( - $userId = 1, - $title = '欢迎加入', - $content = '欢迎加入我们的系统!', - $type = Notification::TYPE_SUCCESS, - $category = Notification::CATEGORY_MESSAGE, - $extraData = ['welcome' => true] - ); - } + public function someMethod() + { + // 发送通知给单个用户 + $result = $this->notificationService->sendToUser( + $userId = 1, + $title = '欢迎加入', + $content = '欢迎加入我们的系统!', + $type = Notification::TYPE_SUCCESS, + $category = Notification::CATEGORY_MESSAGE, + $extraData = ['welcome' => true] + ); + } } ``` @@ -1181,11 +1336,11 @@ class YourService ```php // 发送通知给多个用户 $result = $this->notificationService->sendToUsers( - $userIds = [1, 2, 3], - $title = '系统维护通知', - $content = '系统将于今晚进行维护', - $type = Notification::TYPE_WARNING, - $category = Notification::CATEGORY_ANNOUNCEMENT + $userIds = [1, 2, 3], + $title = '系统维护通知', + $content = '系统将于今晚进行维护', + $type = Notification::TYPE_WARNING, + $category = Notification::CATEGORY_ANNOUNCEMENT ); ``` @@ -1194,10 +1349,10 @@ $result = $this->notificationService->sendToUsers( ```php // 广播通知(所有用户) $result = $this->notificationService->broadcast( - $title = '新功能上线', - $content = '我们推出了新的功能,快来体验吧!', - $type = Notification::TYPE_INFO, - $category = Notification::CATEGORY_ANNOUNCEMENT + $title = '新功能上线', + $content = '我们推出了新的功能,快来体验吧!', + $type = Notification::TYPE_INFO, + $category = Notification::CATEGORY_ANNOUNCEMENT ); ``` @@ -1206,14 +1361,14 @@ $result = $this->notificationService->broadcast( ```php // 发送任务通知 $result = $this->notificationService->sendTaskNotification( - $userId = 1, - $title = '任务提醒', - $content = '您有一个任务即将到期', - $taskData = [ - 'task_id' => 123, - 'task_name' => '完成报告', - 'due_date' => '2024-02-20' - ] + $userId = 1, + $title = '任务提醒', + $content = '您有一个任务即将到期', + $taskData = [ + 'task_id' => 123, + 'task_name' => '完成报告', + 'due_date' => '2024-02-20' + ] ); ``` @@ -1226,13 +1381,13 @@ $wsService = app(WebSocketService::class); // 发送给单个用户 $wsService->sendToUser($userId, [ - 'type' => 'notification', - 'data' => [ - 'title' => '系统通知', - 'content' => '这是一条通知', - 'type' => 'info', - 'timestamp' => time() - ] + 'type' => 'notification', + 'data' => [ + 'title' => '系统通知', + 'content' => '这是一条通知', + 'type' => 'info', + 'timestamp' => time() + ] ]); // 发送给多个用户 @@ -1247,19 +1402,19 @@ $count = $wsService->broadcast($data, $excludeUserId); // 发送到频道 $count = $wsService->sendToChannel('orders', [ - 'type' => 'data_update', - 'data' => [ - 'order_id' => 123, - 'status' => 'paid' - ] + 'type' => 'data_update', + 'data' => [ + 'order_id' => 123, + 'status' => 'paid' + ] ]); // 推送数据更新 $sentTo = $wsService->pushDataUpdate( - $userIds, - 'dictionary', - 'update', - ['id' => 1, 'name' => 'test'] + $userIds, + 'dictionary', + 'update', + ['id' => 1, 'name' => 'test'] ); // 检查用户是否在线 @@ -1277,50 +1432,52 @@ $wsService->disconnectUser($userId); ### 前端使用示例 -#### 1. 基本连接 +#### 1. 基本连接(App.vue 中) -```javascript -import { getWebSocket, closeWebSocket } from '@/utils/websocket' +```vue + ``` #### 2. 监听特定消息类型 ```javascript -// 监听通知消息 +// 在 useWebSocket 中注册消息处理器 ws.on('notification', (data) => { message.success(data.title, data.message) }) -// 监听数据更新 ws.on('data_update', (data) => { console.log('数据更新:', data.resource_type, data.action) // 刷新数据 loadData() }) -// 监听聊天消息 ws.on('chat', (data) => { console.log('收到聊天消息:', data) }) @@ -1329,23 +1486,27 @@ ws.on('chat', (data) => { #### 3. 发送消息 ```javascript +import { useWebSocket } from '@/composables/useWebSocket' + +const { send } = useWebSocket() + // 发送心跳 -ws.send('heartbeat', { timestamp: Date.now() }) +send('heartbeat', { timestamp: Date.now() }) // 发送私聊消息 -ws.send('chat', { +send('chat', { to_user_id: 2, content: '你好,这是一条私聊消息' }) // 订阅频道 -ws.send('subscribe', { channel: 'orders' }) +send('subscribe', { channel: 'orders' }) // 取消订阅 -ws.send('unsubscribe', { channel: 'orders' }) +send('unsubscribe', { channel: 'orders' }) // 发送广播消息 -ws.send('broadcast', { +send('broadcast', { message: '这是一条广播消息' }) ``` @@ -1402,80 +1563,403 @@ console.log('统计信息:', stats) ```vue ``` --- +## 连接问题修复 + +### 问题描述 + +在开发过程中发现 WebSocket 连接存在重复创建的问题,导致: +1. 每个组件页面都创建新的 WebSocket 连接 +2. 用户登录后可能同时存在多个 WebSocket 连接 +3. 消息接收混乱,状态不一致 + +### 根本原因 + +经过代码分析,发现以下**三个地方**都创建了 WebSocket 连接: + +1. **App.vue(主要连接点)**:正确,应该在此处统一初始化 +2. **userbar.vue(重复连接)**:错误,不应该重复初始化 +3. **notifications/index.vue(重复连接)**:错误,不应该重复初始化 + +### 解决方案 + +#### 原则 + +1. **单一连接点**:只在 `App.vue` 中统一初始化和关闭 WebSocket 连接 +2. **全局状态管理**:通过 `useWebSocket` composable 管理单例 WebSocket 实例 +3. **事件驱动**:组件通过监听 store 中的事件来处理 WebSocket 消息 +4. **连接时授权**:通过 URL 参数传递认证信息,无需单独发送 auth 事件 + +#### 修复内容 + +##### 1. websocket.js(单例模式) + +使用单例模式确保全局只有一个 WebSocket 实例: + +```javascript +let wsClient = null +let currentUserId = null +let currentToken = null +let pendingConnection = null + +export function getWebSocket(userId, token, options = {}) { + // 检查是否已有相同用户的连接 + if (wsClient) { + if (currentUserId === userId && currentToken === token) { + const state = wsClient.getConnectionState() + + // 如果已连接或正在连接,返回现有实例 + if (state === 'OPEN' || state === 'CONNECTING') { + return wsClient + } + + // 如果连接已关闭,清理 + if (state === 'CLOSED' || state === 'CLOSING') { + wsClient = null + } + } + + // 不同用户/Token,断开旧连接 + if (currentUserId !== userId || currentToken !== token) { + if (wsClient) { + wsClient.disconnect() + } + wsClient = null + } + } + + // 创建新连接 + currentUserId = userId + currentToken = token + wsClient = createWebSocket(userId, token, options) + + return wsClient +} +``` + +##### 2. useWebSocket.js(统一初始化) + +添加防止重复初始化的机制: + +```javascript +let wsInstance = null +let isInitialized = false + +export function useWebSocket() { + const initWebSocket = () => { + // 防止重复初始化 + if (isInitialized && wsInstance && wsInstance.isConnected) { + console.log('WebSocket 已连接,跳过重复初始化') + return + } + + isInitialized = true + wsInstance = getWebSocket(userStore.userInfo.id, userStore.token, { + onOpen: handleOpen, + onMessage: handleMessage, + onError: handleError, + onClose: handleClose + }) + + // 注册消息处理器 + registerMessageHandlers() + + wsInstance.connect() + } + + return { + ws: wsInstance, + initWebSocket, + closeWebSocket, + reconnect, + isConnected, + send + } +} +``` + +##### 3. 移除重复连接 + +**userbar.vue:** +```vue + +``` + +**notifications/index.vue:** +```vue + +``` + +### WebSocket 授权机制优化 + +#### 优化前 + +```javascript +// 旧方式:前端在 handleOpen 中发送 auth 事件 +function handleOpen(event) { + console.log('WebSocket 连接已建立', event) + + // ❌ 需要单独发送 auth 事件 + if (ws.value) { + ws.value.send('auth', { + token: userStore.token, + user_id: userStore.userInfo.id + }) + } +} +``` + +#### 优化后 + +```javascript +// 新方式:连接时通过 URL 参数认证 +// URL: ws://host/ws?user_id=1&token=xxxxx +function handleOpen(event) { + console.log('WebSocket 连接已建立(已通过 URL 参数完成认证)', event) + // ✅ 不需要发送 auth 事件 +} +``` + +#### 优势 + +✅ **性能优化**:减少 1 次消息往返,连接建立后立即可用 +✅ **安全性增强**:握手阶段即进行认证,未认证的连接直接拒绝 +✅ **代码简化**:前端无需处理 auth 事件,后端逻辑更清晰 + +#### 认证流程 + +``` +前端发起连接 + │ + ├─ ws://host/ws?user_id=1&token=xxxxx + │ + ▼ +后端 onOpen + │ + ├─ 1. 提取 URL 参数(user_id, token) + │ + ├─ 2. 验证参数完整性 + │ └─ 缺失 → 返回错误,断开连接 + │ + ├─ 3. 验证 JWT token + │ ├─ 3.1 解析 token + │ ├─ 3.2 验证用户 ID 匹配 + │ │ └─ 不匹配 → 返回错误,断开连接 + │ └─ 3.3 验证 token 是否过期 + │ └─ 已过期 → 返回错误,断开连接 + │ + ├─ 4. 认证成功 + │ │ + │ ├─ 存储用户到 fd 映射 + │ ├─ 存储 fd 到用户映射 + │ └─ 发送 connected 消息 + │ + ▼ +连接建立完成,可以正常通信 +``` + +### 架构说明 + +#### WebSocket 连接流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ App.vue │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ useWebSocket() 初始化 │ │ +│ │ - 获取用户信息 │ │ +│ │ - 创建 WebSocket 连接(单例) │ │ +│ │ - 连接时通过 URL 参数授权 │ │ +│ │ - 注册消息处理器 │ │ +│ └──────────────────┬────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ WebSocket Client (单例) │ │ +│ │ - 管理连接状态 │ │ +│ │ - 处理消息路由 │ │ +│ │ - 心跳检测和重连 │ │ +│ └──────────────────┬────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Notification Store │ │ +│ │ - 接收 WebSocket 消息 │ │ +│ │ - 更新通知状态 │ │ +│ │ - 触发 Vue 响应式更新 │ │ +│ └──────────────────┬────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ 组件 (userbar, notifications等) │ │ +│ │ - 监听 store 状态变化 │ │ +│ │ - 响应消息更新 │ │ +│ └───────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 消息处理流程 + +``` +后端推送消息 + │ + ▼ +WebSocketClient.onMessage() + │ + ▼ +消息类型路由 (switch/case) + │ + ├─► notification + │ │ + │ ▼ + │ notificationStore.handleWebSocketMessage() + │ │ + │ ├─► 更新未读数量 + │ ├─► 添加到通知列表 + │ └─► 触发通知提示 + │ + ├─► data_update + │ │ + │ ▼ + │ 自定义处理函数 + │ + └─► heartbeat + │ + ▼ + 响应心跳确认 +``` + +### 最佳实践 + +#### 1. WebSocket 连接管理 + +✅ **推荐做法:** +- 只在应用根组件(App.vue)中初始化 WebSocket +- 使用单例模式确保全局只有一个实例 +- 通过状态管理(Pinia Store)分发消息 + +❌ **避免做法:** +- 在多个组件中创建 WebSocket 连接 +- 直接在子组件中调用 `initWebSocket()` +- 绕过 Store 直接处理 WebSocket 消息 + +#### 2. 组件中使用 WebSocket + +✅ **推荐做法:** +```javascript +// 只需要监听 store 中的状态变化 +import { useNotificationStore } from '@/stores/modules/notification' + +const notificationStore = useNotificationStore() + +// 监听未读数量变化 +watch(() => notificationStore.unreadCount, (newCount) => { + console.log('未读数量更新:', newCount) +}) + +// 监听通知列表变化 +watch(() => notificationStore.notifications, (list) => { + console.log('通知列表更新:', list) +}) +``` + +❌ **避免做法:** +```javascript +// 不要在组件中直接使用 WebSocket +import { useWebSocket } from '@/composables/useWebSocket' + +const { initWebSocket } = useWebSocket() +initWebSocket() // ❌ 重复连接! +``` + +#### 3. 消息处理器注册 + +✅ **推荐做法:** +```javascript +// 在 useWebSocket 中统一注册处理器 +const initWebSocket = () => { + wsInstance.on('notification', (data) => { + notificationStore.handleWebSocketMessage({ type: 'notification', data }) + }) + + wsInstance.on('data_update', (data) => { + console.log('收到数据更新:', data) + }) +} +``` + +❌ **避免做法:** +```javascript +// 不要在每个组件中单独注册 +ws.onMessage((msg) => { + if (msg.type === 'notification') { + // 处理通知 + } +}) +``` + +--- + ## 故障排查与修复 ### 常见问题与解决方案 @@ -1598,8 +2082,8 @@ php bin/laravels config **正确配置:** ```php 'swoole' => [ - 'dispatch_mode' => 2, // ✅ 正确:抢占模式 - 'worker_num' => 4, + 'dispatch_mode' => 2, // ✅ 正确:抢占模式 + 'worker_num' => 4, ], ``` @@ -1644,6 +2128,35 @@ tail -f storage/logs/swoole.log | grep "notification" // 确保不会多次调用 sendNotification ``` +#### 问题 8:WebSocket 认证失败 + +**可能原因:** +- Token 无效或已过期 +- 用户 ID 不匹配 +- 缺少认证参数 + +**排查步骤:** + +检查前端控制台日志: +```javascript +// 查看连接 URL 是否正确 +console.log('WebSocket URL:', ws.url) + +// 查看收到的错误消息 +// 检查错误类型和错误信息 +``` + +检查后端日志: +```bash +# 查看认证失败日志 +tail -f storage/logs/laravel.log | grep "WebSocket 认证失败" +``` + +**解决方法:** +- 确保 token 有效且未过期 +- 确保 URL 参数中的 user_id 与 token 中的 sub 匹配 +- 重新登录获取新的 token + ### Laravel-S wsTable 使用规范 #### 正确的 wsTable 访问方式 @@ -1655,18 +2168,18 @@ tail -f storage/logs/swoole.log | grep "notification" ```php class WebSocketHandler implements WebSocketHandlerInterface { - protected $wsTable; + protected $wsTable; - public function __construct() - { - $this->wsTable = app('swoole')->wsTable; - } + public function __construct() + { + $this->wsTable = app('swoole')->wsTable; + } - public function onOpen(Server $server, Request $request): void - { - // 直接使用 $this->wsTable - $this->wsTable->set('uid:' . $userId, [...]); - } + public function onOpen(Server $server, Request $request): void + { + // 直接使用 $this->wsTable + $this->wsTable->set('uid:' . $userId, [...]); + } } ``` @@ -1675,16 +2188,16 @@ class WebSocketHandler implements WebSocketHandlerInterface ```php class WebSocketService { - public function sendToUser(int $userId, array $data): bool - { - $server = $this->getServer(); - - // 每次都通过服务容器获取最新的 wsTable - $wsTable = app('swoole')->wsTable; - - $fdInfo = $wsTable->get('uid:' . $userId); - // ... - } + public function sendToUser(int $userId, array $data): bool + { + $server = $this->getServer(); + + // 每次都通过服务容器获取最新的 wsTable + $wsTable = app('swoole')->wsTable; + + $fdInfo = $wsTable->get('uid:' . $userId); + // ... + } } ``` @@ -1708,10 +2221,11 @@ wsTable 用于存储 WebSocket 连接映射关系: ```php 'ws' => [ - 'size' => 102400, // 表最大行数 - 'columns' => [ - 'value' => ['type' => \Swoole\Table::TYPE_STRING, 'size' => 256], // 值 - ] + 'size' => 102400, // 表最大行数 + 'columns' => [ + 'value' => ['type' => \Swoole\Table::TYPE_STRING, 'size' => 256], // 值 + 'expiry' => ['type' => \Swoole\Table::TYPE_INT, 'size' => 4], // 过期时间 + ] ] ``` @@ -1724,10 +2238,10 @@ wsTable 用于存储 WebSocket 连接映射关系: 当使用多个 Worker 进程时(worker_num > 1): -1. **进程隔离:** 每个 Worker 有独立的内存空间 -2. **状态同步:** 使用 Swoole Table 实现跨进程数据共享 -3. **连接一致性:** 同一用户的连接必须由同一 Worker 处理 -4. **消息路由:** dispatch_mode = 2 确保连接和消息在同一 Worker +1. **进程隔离**:每个 Worker 有独立的内存空间 +2. **状态同步**:使用 Swoole Table 实现跨进程数据共享 +3. **连接一致性**:同一用户的连接必须由同一 Worker 处理 +4. **消息路由**:dispatch_mode = 2 确保连接和消息在同一 Worker --- @@ -1740,12 +2254,12 @@ wsTable 用于存储 WebSocket 连接映射关系: ```php // 为常用查询字段添加索引 Schema::table('system_notifications', function (Blueprint $table) { - $table->index('user_id'); - $table->index('is_read'); - $table->index('type'); - $table->index('category'); - $table->index(['user_id', 'is_read']); - $table->index(['user_id', 'created_at']); + $table->index('user_id'); + $table->index('is_read'); + $table->index('type'); + $table->index('category'); + $table->index(['user_id', 'is_read']); + $table->index(['user_id', 'created_at']); }); ``` @@ -1755,15 +2269,15 @@ Schema::table('system_notifications', function (Blueprint $table) { // 使用批量插入减少查询次数 $notifications = []; foreach ($userIds as $userId) { - $notifications[] = [ - 'user_id' => $userId, - 'title' => $title, - 'content' => $content, - 'type' => $type, - 'category' => $category, - 'created_at' => now(), - 'updated_at' => now(), - ]; + $notifications[] = [ + 'user_id' => $userId, + 'title' => $title, + 'content' => $content, + 'type' => $type, + 'category' => $category, + 'created_at' => now(), + 'updated_at' => now(), + ]; } Notification::insert($notifications); @@ -1775,21 +2289,21 @@ Notification::insert($notifications); // 定期清理过期连接 public function cleanExpiredConnections(): void { - $server = $this->getServer(); - $wsTable = app('swoole')->wsTable; - $currentTime = time(); - - foreach ($wsTable as $key => $row) { - if (strpos($key, 'uid:') === 0) { - $fd = $row['value']; - - if (!$server->isEstablished($fd)) { - // 清理无效的连接 - $wsTable->del($key); - $wsTable->del('fd:' . $fd); - } - } - } + $server = $this->getServer(); + $wsTable = app('swoole')->wsTable; + $currentTime = time(); + + foreach ($wsTable as $key => $row) { + if (strpos($key, 'uid:') === 0) { + $fd = $row['value']; + + if (!$server->isEstablished($fd)) { + // 清理无效的连接 + $wsTable->del($key); + $wsTable->del('fd:' . $fd); + } + } + } } ``` @@ -1802,8 +2316,8 @@ use Illuminate\Support\Facades\Queue; // 异步发送通知 dispatch(function () use ($userIds, $message) { - $webSocketService = new WebSocketService(); - $webSocketService->sendNotificationToUsers($userIds, $title, $message); + $webSocketService = new WebSocketService(); + $webSocketService->sendNotificationToUsers($userIds, $title, $message); })->onQueue('websocket'); ``` @@ -1815,25 +2329,25 @@ use Illuminate\Support\Facades\Cache; public function getUnreadCount(int $userId): int { - $cacheKey = "unread_count:{$userId}"; - - return Cache::remember($cacheKey, 300, function() use ($userId) { - return Notification::where('user_id', $userId) - ->where('is_read', false) - ->count(); - }); + $cacheKey = "unread_count:{$userId}"; + + return Cache::remember($cacheKey, 300, function() use ($userId) { + return Notification::where('user_id', $userId) + ->where('is_read', false) + ->count(); + }); } // 通知标记为已读时清除缓存 public function markAsRead(int $notificationId): bool { - $notification = Notification::find($notificationId); - $notification->markAsRead(); - - // 清除缓存 - Cache::forget("unread_count:{$notification->user_id}"); - - return true; + $notification = Notification::find($notificationId); + $notification->markAsRead(); + + // 清除缓存 + Cache::forget("unread_count:{$notification->user_id}"); + + return true; } ``` @@ -1848,7 +2362,7 @@ const maxLocalNotifications = 100 // 添加消息时检查数量 function addMessage(message) { if (messages.value.length >= maxLocalNotifications) { - messages.value.pop() // 删除最旧的消息 + messages.value.pop() // 删除最旧的消息 } messages.value.unshift(message) } @@ -1863,8 +2377,8 @@ const pageSize = ref(20) async function loadNotifications() { const response = await api.notifications.get({ - page: currentPage.value, - page_size: pageSize.value + page: currentPage.value, + page_size: pageSize.value }) // ... } @@ -1877,14 +2391,14 @@ async function loadNotifications() { ```vue ``` @@ -1904,7 +2418,7 @@ import { throttle } from 'lodash-es' const handleScroll = throttle(() => { if (isNearBottom()) { - loadMore() + loadMore() } }, 500) ``` @@ -1920,24 +2434,24 @@ const handleScroll = throttle(() => { ```php public function onOpen(Server $server, Request $request): void { - $userId = $request->get['user_id'] ?? null; - $token = $request->get['token'] ?? null; - - // ✅ 验证 token - if (!$token || !$this->validateToken($userId, $token)) { - $server->push($request->fd, json_encode([ - 'type' => 'error', - 'data' => ['message' => 'Authentication failed'] - ])); - $server->disconnect($request->fd); - return; - } - - // 认证成功,存储连接 - $this->wsTable->set('uid:' . $userId, [ - 'value' => $request->fd, - 'expiry' => time() + 3600 - ]); + $userId = $request->get['user_id'] ?? null; + $token = $request->get['token'] ?? null; + + // ✅ 验证 token + if (!$token || !$this->validateToken($userId, $token)) { + $server->push($request->fd, json_encode([ + 'type' => 'error', + 'data' => ['message' => 'Authentication failed'] + ])); + $server->disconnect($request->fd); + return; + } + + // 认证成功,存储连接 + $this->wsTable->set('uid:' . $userId, [ + 'value' => $request->fd, + 'expiry' => time() + 3600 + ]); } ``` @@ -1946,19 +2460,19 @@ public function onOpen(Server $server, Request $request): void ```php public function onMessage(Server $server, Frame $frame): void { - $message = json_decode($frame->data, true); - - // ✅ 验证消息格式 - if (!$message || !isset($message['type'])) { - $server->push($frame->fd, json_encode([ - 'type' => 'error', - 'data' => ['message' => 'Invalid message format'] - ])); - return; - } - - // 处理消息 - // ... + $message = json_decode($frame->data, true); + + // ✅ 验证消息格式 + if (!$message || !isset($message['type'])) { + $server->push($frame->fd, json_encode([ + 'type' => 'error', + 'data' => ['message' => 'Invalid message format'] + ])); + return; + } + + // 处理消息 + // ... } ``` @@ -1970,26 +2484,26 @@ private $messageRateLimits = []; public function checkRateLimit(int $fd): bool { - $key = 'fd:' . $fd; - $now = time(); - - if (!isset($this->messageRateLimits[$key])) { - $this->messageRateLimits[$key] = []; - } - - // 清理旧记录 - $this->messageRateLimits[$key] = array_filter( - $this->messageRateLimits[$key], - fn($time) => $time > $now - 60 - ); - - // 检查频率(每分钟最多 60 条) - if (count($this->messageRateLimits[$key]) >= 60) { - return false; - } - - $this->messageRateLimits[$key][] = $now; - return true; + $key = 'fd:' . $fd; + $now = time(); + + if (!isset($this->messageRateLimits[$key])) { + $this->messageRateLimits[$key] = []; + } + + // 清理旧记录 + $this->messageRateLimits[$key] = array_filter( + $this->messageRateLimits[$key], + fn($time) => $time > $now - 60 + ); + + // 检查频率(每分钟最多 60 条) + if (count($this->messageRateLimits[$key]) >= 60) { + return false; + } + + $this->messageRateLimits[$key][] = $now; + return true; } ``` @@ -1999,19 +2513,19 @@ public function checkRateLimit(int $fd): bool // 确保用户只能查看和操作自己的通知 public function index(Request $request) { - $userId = $request->user()->id; - - $notifications = Notification::where('user_id', $userId) - ->where(function($query) use ($request) { - // 应用搜索和筛选条件 - if ($request->has('keyword')) { - $query->where('title', 'like', '%' . $request->keyword . '%'); - } - // ... - }) - ->paginate($request->page_size ?? 20); - - return response()->json($notifications); + $userId = $request->user()->id; + + $notifications = Notification::where('user_id', $userId) + ->where(function($query) use ($request) { + // 应用搜索和筛选条件 + if ($request->has('keyword')) { + $query->where('title', 'like', '%' . $request->keyword . '%'); + } + // ... + }) + ->paginate($request->page_size ?? 20); + + return response()->json($notifications); } ``` @@ -2043,14 +2557,14 @@ const ws = getWebSocket(userStore.userInfo.id, userStore.token) function handleMessage(message) { // 验证消息格式 if (!message || !message.type || !message.data) { - console.warn('Invalid message format:', message) - return + console.warn('Invalid message format:', message) + return } // 过滤敏感内容 if (containsSensitiveContent(message.data)) { - console.warn('Message contains sensitive content') - return + console.warn('Message contains sensitive content') + return } // 处理消息 @@ -2106,11 +2620,11 @@ use App\Jobs\SendNotificationJob; // 发送大量通知 dispatch(new SendNotificationJob( - $userIds, - $title, - $content, - $type, - $category + $userIds, + $title, + $content, + $type, + $category ))->onQueue('notifications'); ``` @@ -2120,12 +2634,12 @@ dispatch(new SendNotificationJob( // 前端错误处理 async function markAsRead(notificationId) { try { - await api.notifications.read(notificationId) - // 更新本地状态 + await api.notifications.read(notificationId) + // 更新本地状态 } catch (error) { - console.error('标记已读失败:', error) - // 显示错误提示 - message.error('标记已读失败,请重试') + console.error('标记已读失败:', error) + // 显示错误提示 + message.error('标记已读失败,请重试') } } ``` @@ -2138,19 +2652,19 @@ use Illuminate\Support\Facades\Log; public function sendToUser(int $userId, array $data): bool { - Log::info('Sending notification to user', [ - 'user_id' => $userId, - 'type' => $data['type'] - ]); - - // ... 发送逻辑 - - Log::info('Notification sent successfully', [ - 'user_id' => $userId, - 'notification_id' => $notification->id - ]); - - return true; + Log::info('Sending notification to user', [ + 'user_id' => $userId, + 'type' => $data['type'] + ]); + + // ... 发送逻辑 + + Log::info('Notification sent successfully', [ + 'user_id' => $userId, + 'notification_id' => $notification->id + ]); + + return true; } ``` @@ -2160,16 +2674,16 @@ public function sendToUser(int $userId, array $data): bool // 单元测试示例 public function test_send_notification() { - $result = $this->service->sendNotification([ - 'user_id' => 1, - 'title' => 'Test', - 'content' => 'Test content' - ]); - - $this->assertTrue($result['success']); - $this->assertDatabaseHas('system_notifications', [ - 'title' => 'Test' - ]); + $result = $this->service->sendNotification([ + 'user_id' => 1, + 'title' => 'Test', + 'content' => 'Test content' + ]); + + $this->assertTrue($result['success']); + $this->assertDatabaseHas('system_notifications', [ + 'title' => 'Test' + ]); } ``` @@ -2189,6 +2703,10 @@ public function test_send_notification() - ✅ 修复 dispatch_mode 配置问题 - ✅ 修复 wsTable 访问问题 - ✅ 添加定时任务重试功能 +- ✅ 修复 WebSocket 重复连接问题 +- ✅ 实现单例模式管理连接 +- ✅ 优化授权机制(连接时自动认证) +- ✅ 合并文档,提供完整的使用指南 --- @@ -2202,6 +2720,6 @@ public function test_send_notification() --- -**文档版本:** v2.0 +**文档版本:** v3.0 **更新日期:** 2024-02-18 **维护者:** Development Team diff --git a/resources/admin/src/App.vue b/resources/admin/src/App.vue index a926024..f8a257e 100644 --- a/resources/admin/src/App.vue +++ b/resources/admin/src/App.vue @@ -1,131 +1,141 @@ diff --git a/resources/admin/src/api/auth.js b/resources/admin/src/api/auth.js index e352454..9ff9b74 100644 --- a/resources/admin/src/api/auth.js +++ b/resources/admin/src/api/auth.js @@ -1,36 +1,36 @@ -import request from '@/utils/request' +import request from "@/utils/request"; export default { // 认证相关 login: { post: async function (params) { - return await request.post('auth/login', params) + return await request.post("auth/login", params); }, }, logout: { post: async function () { - return await request.post('auth/logout') + return await request.post("auth/logout"); }, }, me: { get: async function () { - return await request.get('auth/me') + return await request.get("auth/me"); }, }, changePassword: { post: async function (params) { - return await request.post('auth/change-password', params) + return await request.post("auth/change-password", params); }, }, // 文件上传 upload: { post: async function (file) { - const formData = new FormData() - formData.append('file', file) - return await request.post('system/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) + const formData = new FormData(); + formData.append("file", file); + return await request.post("system/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); }, }, @@ -38,64 +38,71 @@ export default { users: { list: { get: async function (params) { - return await request.get('auth/users', { params }) + return await request.get("auth/users", { params }); }, }, detail: { get: async function (id) { - return await request.get(`auth/users/${id}`) + return await request.get(`auth/users/${id}`); }, }, add: { post: async function (params) { - return await request.post('auth/users', params) + return await request.post("auth/users", params); }, }, edit: { put: async function (id, params) { - return await request.put(`auth/users/${id}`, params) + return await request.put(`auth/users/${id}`, params); }, }, delete: { delete: async function (id) { - return await request.delete(`auth/users/${id}`) + return await request.delete(`auth/users/${id}`); }, }, batchDelete: { post: async function (params) { - return await request.post('auth/users/batch-delete', params) + return await request.post("auth/users/batch-delete", params); }, }, batchStatus: { post: async function (params) { - return await request.post('auth/users/batch-status', params) + return await request.post("auth/users/batch-status", params); }, }, batchDepartment: { post: async function (params) { - return await request.post('auth/users/batch-department', params) + return await request.post( + "auth/users/batch-department", + params, + ); }, }, batchRoles: { post: async function (params) { - return await request.post('auth/users/batch-roles', params) + return await request.post("auth/users/batch-roles", params); }, }, export: { post: async function (params) { - return await request.post('auth/users/export', params, { responseType: 'blob' }) + return await request.post("auth/users/export", params, { + responseType: "blob", + }); }, }, import: { post: async function (formData) { - return await request.post('auth/users/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) + return await request.post("auth/users/import", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); }, }, downloadTemplate: { get: async function () { - return await request.get('auth/users/download-template', { responseType: 'blob' }) + return await request.get("auth/users/download-template", { + responseType: "blob", + }); }, }, }, @@ -104,27 +111,34 @@ export default { onlineUsers: { count: { get: async function () { - return await request.get('auth/online-users/count') + return await request.get("auth/online-users/count"); }, }, list: { get: async function (params) { - return await request.get('auth/online-users', { params }) + return await request.get("auth/online-users", { params }); }, }, sessions: { get: async function (userId) { - return await request.get(`auth/online-users/${userId}/sessions`) + return await request.get( + `auth/online-users/${userId}/sessions`, + ); }, }, offline: { post: async function (userId, params) { - return await request.post(`auth/online-users/${userId}/offline`, params) + return await request.post( + `auth/online-users/${userId}/offline`, + params, + ); }, }, offlineAll: { post: async function (userId) { - return await request.post(`auth/online-users/${userId}/offline-all`) + return await request.post( + `auth/online-users/${userId}/offline-all`, + ); }, }, }, @@ -133,77 +147,84 @@ export default { roles: { list: { get: async function (params) { - return await request.get('auth/roles', { params }) + return await request.get("auth/roles", { params }); }, }, all: { get: async function () { - return await request.get('auth/roles/all') + return await request.get("auth/roles/all"); }, }, detail: { get: async function (id) { - return await request.get(`auth/roles/${id}`) + return await request.get(`auth/roles/${id}`); }, }, add: { post: async function (params) { - return await request.post('auth/roles', params) + return await request.post("auth/roles", params); }, }, edit: { put: async function (id, params) { - return await request.put(`auth/roles/${id}`, params) + return await request.put(`auth/roles/${id}`, params); }, }, delete: { delete: async function (id) { - return await request.delete(`auth/roles/${id}`) + return await request.delete(`auth/roles/${id}`); }, }, batchDelete: { post: async function (params) { - return await request.post('auth/roles/batch-delete', params) + return await request.post("auth/roles/batch-delete", params); }, }, batchStatus: { post: async function (params) { - return await request.post('auth/roles/batch-status', params) + return await request.post("auth/roles/batch-status", params); }, }, permissions: { get: async function (id) { - return await request.get(`auth/roles/${id}/permissions`) + return await request.get(`auth/roles/${id}/permissions`); }, post: async function (id, params) { - return await request.post(`auth/roles/${id}/permissions`, params) + return await request.post( + `auth/roles/${id}/permissions`, + params, + ); }, }, copy: { post: async function (id, params) { - return await request.post(`auth/roles/${id}/copy`, params) + return await request.post(`auth/roles/${id}/copy`, params); }, }, batchCopy: { post: async function (params) { - return await request.post('auth/roles/batch-copy', params) + return await request.post("auth/roles/batch-copy", params); }, }, export: { post: async function (params) { - return await request.post('auth/roles/export', params, { responseType: 'blob' }) + return await request.post("auth/roles/export", params, { + responseType: "blob", + }); }, }, import: { post: async function (formData) { - return await request.post('auth/roles/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) + return await request.post("auth/roles/import", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); }, }, downloadTemplate: { get: async function () { - return await request.get('auth/roles/download-template', { responseType: 'blob' }) + return await request.get("auth/roles/download-template", { + responseType: "blob", + }); }, }, }, @@ -212,131 +233,151 @@ export default { permissions: { list: { get: async function (params) { - return await request.get('auth/permissions', { params }) + return await request.get("auth/permissions", { params }); }, }, tree: { get: async function () { - return await request.get('auth/permissions/tree') + return await request.get("auth/permissions/tree"); }, }, menu: { get: async function () { - return await request.get('auth/permissions/menu') + return await request.get("auth/permissions/menu"); }, }, detail: { get: async function (id) { - return await request.get(`auth/permissions/${id}`) + return await request.get(`auth/permissions/${id}`); }, }, add: { post: async function (params) { - return await request.post('auth/permissions', params) + return await request.post("auth/permissions", params); }, }, edit: { put: async function (id, params) { - return await request.put(`auth/permissions/${id}`, params) + return await request.put(`auth/permissions/${id}`, params); }, }, delete: { delete: async function (id) { - return await request.delete(`auth/permissions/${id}`) + return await request.delete(`auth/permissions/${id}`); }, }, batchDelete: { post: async function (params) { - return await request.post('auth/permissions/batch-delete', params) + return await request.post( + "auth/permissions/batch-delete", + params, + ); }, }, batchStatus: { post: async function (params) { - return await request.post('auth/permissions/batch-status', params) + return await request.post( + "auth/permissions/batch-status", + params, + ); }, }, export: { post: async function (params) { - return await request.post('auth/permissions/export', params, { responseType: 'blob' }) + return await request.post("auth/permissions/export", params, { + responseType: "blob", + }); }, }, import: { post: async function (formData) { - return await request.post('auth/permissions/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) + return await request.post("auth/permissions/import", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); }, }, downloadTemplate: { get: async function () { - return await request.get('auth/permissions/download-template', { responseType: 'blob' }) + return await request.get("auth/permissions/download-template", { + responseType: "blob", + }); }, }, }, - // 部门管理 - departments: { - list: { - get: async function (params) { - return await request.get('auth/departments', { params }) - }, + // 部门管理 + departments: { + list: { + get: async function (params) { + return await request.get("auth/departments", { params }); }, - tree: { - get: async function (params) { - return await request.get('auth/departments/tree', { params }) - }, + }, + tree: { + get: async function (params) { + return await request.get("auth/departments/tree", { params }); }, + }, all: { get: async function () { - return await request.get('auth/departments/all') + return await request.get("auth/departments/all"); }, }, detail: { get: async function (id) { - return await request.get(`auth/departments/${id}`) + return await request.get(`auth/departments/${id}`); }, }, add: { post: async function (params) { - return await request.post('auth/departments', params) + return await request.post("auth/departments", params); }, }, edit: { put: async function (id, params) { - return await request.put(`auth/departments/${id}`, params) + return await request.put(`auth/departments/${id}`, params); }, }, delete: { delete: async function (id) { - return await request.delete(`auth/departments/${id}`) + return await request.delete(`auth/departments/${id}`); }, }, batchDelete: { post: async function (params) { - return await request.post('auth/departments/batch-delete', params) + return await request.post( + "auth/departments/batch-delete", + params, + ); }, }, batchStatus: { post: async function (params) { - return await request.post('auth/departments/batch-status', params) + return await request.post( + "auth/departments/batch-status", + params, + ); }, }, export: { post: async function (params) { - return await request.post('auth/departments/export', params, { responseType: 'blob' }) + return await request.post("auth/departments/export", params, { + responseType: "blob", + }); }, }, import: { post: async function (formData) { - return await request.post('auth/departments/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) + return await request.post("auth/departments/import", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); }, }, downloadTemplate: { get: async function () { - return await request.get('auth/departments/download-template', { responseType: 'blob' }) + return await request.get("auth/departments/download-template", { + responseType: "blob", + }); }, }, }, -} +}; diff --git a/resources/admin/src/api/system.js b/resources/admin/src/api/system.js index c5971fb..1b71e65 100644 --- a/resources/admin/src/api/system.js +++ b/resources/admin/src/api/system.js @@ -1,188 +1,211 @@ -import request from '@/utils/request' +import request from "@/utils/request"; export default { // 系统配置管理 configs: { list: { get: async function (params) { - return await request.get('system/configs', { params }) + return await request.get("system/configs", { params }); }, }, groups: { get: async function () { - return await request.get('system/configs/groups') + return await request.get("system/configs/groups"); }, }, all: { get: async function (params) { - return await request.get('system/configs/all', { params }) + return await request.get("system/configs/all", { params }); }, }, detail: { get: async function (id) { - return await request.get(`system/configs/${id}`) + return await request.get(`system/configs/${id}`); }, }, add: { post: async function (params) { - return await request.post('system/configs', params) + return await request.post("system/configs", params); }, }, edit: { put: async function (id, params) { - return await request.put(`system/configs/${id}`, params) + return await request.put(`system/configs/${id}`, params); }, }, delete: { delete: async function (id) { - return await request.delete(`system/configs/${id}`) + return await request.delete(`system/configs/${id}`); }, }, batchDelete: { post: async function (params) { - return await request.post('system/configs/batch-delete', params) + return await request.post( + "system/configs/batch-delete", + params, + ); }, }, batchStatus: { post: async function (params) { - return await request.post('system/configs/batch-status', params) + return await request.post( + "system/configs/batch-status", + params, + ); }, }, }, // 操作日志管理 logs: { - list: { - get: async function (params) { - return await request.get('system/logs', { params }) - }, - }, - detail: { - get: async function (id) { - return await request.get(`system/logs/${id}`) - }, - }, - delete: { - delete: async function (id) { - return await request.delete(`system/logs/${id}`) - }, - }, - batchDelete: { - post: async function (params) { - return await request.post('system/logs/batch-delete', params) - }, - }, - clear: { - post: async function (params) { - return await request.post('system/logs/clear', params) - }, - }, - export: { - get: async function (params) { - return await request.get('system/logs/export', { - params, - responseType: 'blob' - }) - }, - }, - statistics: { - get: async function (params) { - return await request.get('system/logs/statistics', { params }) - }, + list: { + get: async function (params) { + return await request.get("system/logs", { params }); }, }, + detail: { + get: async function (id) { + return await request.get(`system/logs/${id}`); + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`system/logs/${id}`); + }, + }, + batchDelete: { + post: async function (params) { + return await request.post("system/logs/batch-delete", params); + }, + }, + clear: { + post: async function (params) { + return await request.post("system/logs/clear", params); + }, + }, + export: { + get: async function (params) { + return await request.get("system/logs/export", { + params, + responseType: "blob", + }); + }, + }, + statistics: { + get: async function (params) { + return await request.get("system/logs/statistics", { params }); + }, + }, + }, - // 数据字典管理 - dictionaries: { - list: { - get: async function (params) { - return await request.get('system/dictionaries', { params }) - }, + // 数据字典管理 + dictionaries: { + list: { + get: async function (params) { + return await request.get("system/dictionaries", { params }); }, + }, + all: { + get: async function () { + return await request.get("system/dictionaries/all"); + }, + }, + detail: { + get: async function (id) { + return await request.get(`system/dictionaries/${id}`); + }, + }, + add: { + post: async function (params) { + return await request.post("system/dictionaries", params); + }, + }, + edit: { + put: async function (id, params) { + return await request.put(`system/dictionaries/${id}`, params); + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`system/dictionaries/${id}`); + }, + }, + batchDelete: { + post: async function (params) { + return await request.post( + "system/dictionaries/batch-delete", + params, + ); + }, + }, + batchStatus: { + post: async function (params) { + return await request.post( + "system/dictionaries/batch-status", + params, + ); + }, + }, + items: { all: { - get: async function () { - return await request.get('system/dictionaries/all') - }, - }, - detail: { - get: async function (id) { - return await request.get(`system/dictionaries/${id}`) - }, - }, - add: { - post: async function (params) { - return await request.post('system/dictionaries', params) - }, - }, - edit: { - put: async function (id, params) { - return await request.put(`system/dictionaries/${id}`, params) - }, - }, - delete: { - delete: async function (id) { - return await request.delete(`system/dictionaries/${id}`) - }, - }, - batchDelete: { - post: async function (params) { - return await request.post('system/dictionaries/batch-delete', params) - }, - }, - batchStatus: { - post: async function (params) { - return await request.post('system/dictionaries/batch-status', params) - }, - }, - items: { - all: { - get: async function (code) { - return await request.get(`system/dictionaries/code`, { params: { code } }) - }, + get: async function (code) { + return await request.get(`system/dictionaries/code`, { + params: { code }, + }); }, }, }, + }, // 数据字典项管理 dictionaryItems: { list: { get: async function (params) { - return await request.get('system/dictionary-items', { params }) + return await request.get("system/dictionary-items", { params }); }, }, all: { get: async function () { - return await request.get('system/dictionary-items/all') + return await request.get("system/dictionary-items/all"); }, }, detail: { get: async function (id) { - return await request.get(`system/dictionary-items/${id}`) + return await request.get(`system/dictionary-items/${id}`); }, }, add: { post: async function (params) { - return await request.post('system/dictionary-items', params) + return await request.post("system/dictionary-items", params); }, }, edit: { put: async function (id, params) { - return await request.put(`system/dictionary-items/${id}`, params) + return await request.put( + `system/dictionary-items/${id}`, + params, + ); }, }, delete: { delete: async function (id) { - return await request.delete(`system/dictionary-items/${id}`) + return await request.delete(`system/dictionary-items/${id}`); }, }, batchDelete: { post: async function (params) { - return await request.post('system/dictionary-items/batch-delete', params) + return await request.post( + "system/dictionary-items/batch-delete", + params, + ); }, }, batchStatus: { post: async function (params) { - return await request.post('system/dictionary-items/batch-status', params) + return await request.post( + "system/dictionary-items/batch-status", + params, + ); }, }, }, @@ -191,52 +214,52 @@ export default { tasks: { list: { get: async function (params) { - return await request.get('system/tasks', { params }) + return await request.get("system/tasks", { params }); }, }, all: { get: async function () { - return await request.get('system/tasks/all') + return await request.get("system/tasks/all"); }, }, detail: { get: async function (id) { - return await request.get(`system/tasks/${id}`) + return await request.get(`system/tasks/${id}`); }, }, add: { post: async function (params) { - return await request.post('system/tasks', params) + return await request.post("system/tasks", params); }, }, edit: { put: async function (id, params) { - return await request.put(`system/tasks/${id}`, params) + return await request.put(`system/tasks/${id}`, params); }, }, delete: { delete: async function (id) { - return await request.delete(`system/tasks/${id}`) + return await request.delete(`system/tasks/${id}`); }, }, batchDelete: { post: async function (params) { - return await request.post('system/tasks/batch-delete', params) + return await request.post("system/tasks/batch-delete", params); }, }, batchStatus: { post: async function (params) { - return await request.post('system/tasks/batch-status', params) + return await request.post("system/tasks/batch-status", params); }, }, run: { post: async function (id) { - return await request.post(`system/tasks/${id}/run`) + return await request.post(`system/tasks/${id}/run`); }, }, statistics: { get: async function () { - return await request.get('system/tasks/statistics') + return await request.get("system/tasks/statistics"); }, }, }, @@ -245,62 +268,62 @@ export default { cities: { list: { get: async function (params) { - return await request.get('system/cities', { params }) + return await request.get("system/cities", { params }); }, }, tree: { get: async function () { - return await request.get('system/cities/tree') + return await request.get("system/cities/tree"); }, }, detail: { get: async function (id) { - return await request.get(`system/cities/${id}`) + return await request.get(`system/cities/${id}`); }, }, children: { get: async function (id) { - return await request.get(`system/cities/${id}/children`) + return await request.get(`system/cities/${id}/children`); }, }, provinces: { get: async function () { - return await request.get('system/cities/provinces') + return await request.get("system/cities/provinces"); }, }, cities: { get: async function (provinceId) { - return await request.get(`system/cities/${provinceId}/cities`) + return await request.get(`system/cities/${provinceId}/cities`); }, }, districts: { get: async function (cityId) { - return await request.get(`system/cities/${cityId}/districts`) + return await request.get(`system/cities/${cityId}/districts`); }, }, add: { post: async function (params) { - return await request.post('system/cities', params) + return await request.post("system/cities", params); }, }, edit: { put: async function (id, params) { - return await request.put(`system/cities/${id}`, params) + return await request.put(`system/cities/${id}`, params); }, }, delete: { delete: async function (id) { - return await request.delete(`system/cities/${id}`) + return await request.delete(`system/cities/${id}`); }, }, batchDelete: { post: async function (params) { - return await request.post('system/cities/batch-delete', params) + return await request.post("system/cities/batch-delete", params); }, }, batchStatus: { post: async function (params) { - return await request.post('system/cities/batch-status', params) + return await request.post("system/cities/batch-status", params); }, }, }, @@ -309,31 +332,31 @@ export default { upload: { single: { post: async function (formData) { - return await request.post('system/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) + return await request.post("system/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); }, }, multiple: { post: async function (formData) { - return await request.post('system/upload/multiple', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) + return await request.post("system/upload/multiple", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); }, }, base64: { post: async function (params) { - return await request.post('system/upload/base64', params) + return await request.post("system/upload/base64", params); }, }, delete: { post: async function (params) { - return await request.post('system/upload/delete', params) + return await request.post("system/upload/delete", params); }, }, batchDelete: { post: async function (params) { - return await request.post('system/upload/batch-delete', params) + return await request.post("system/upload/batch-delete", params); }, }, }, @@ -342,67 +365,78 @@ export default { notifications: { list: { get: async function (params) { - return await request.get('system/notifications', { params }) + return await request.get("system/notifications", { params }); }, }, unread: { get: async function (params) { - return await request.get('system/notifications/unread', { params }) + return await request.get("system/notifications/unread", { + params, + }); }, }, unreadCount: { get: async function () { - return await request.get('system/notifications/unread-count') + return await request.get("system/notifications/unread-count"); }, }, detail: { get: async function (id) { - return await request.get(`system/notifications/${id}`) + return await request.get(`system/notifications/${id}`); }, }, markAsRead: { post: async function (id) { - return await request.post(`system/notifications/${id}/read`) + return await request.post(`system/notifications/${id}/read`); }, }, batchMarkAsRead: { post: async function (params) { - return await request.post('system/notifications/batch-read', params) + return await request.post( + "system/notifications/batch-read", + params, + ); }, }, markAllAsRead: { post: async function () { - return await request.post('system/notifications/read-all') + return await request.post("system/notifications/read-all"); }, }, delete: { delete: async function (id) { - return await request.delete(`system/notifications/${id}`) + return await request.delete(`system/notifications/${id}`); }, }, batchDelete: { post: async function (params) { - return await request.post('system/notifications/batch-delete', params) + return await request.post( + "system/notifications/batch-delete", + params, + ); }, }, clearRead: { post: async function () { - return await request.post('system/notifications/clear-read') + return await request.post("system/notifications/clear-read"); }, }, statistics: { get: async function () { - return await request.get('system/notifications/statistics') + return await request.get("system/notifications/statistics"); }, }, send: { post: async function (params) { - return await request.post('system/notifications/send', params) + return await request.post("system/notifications/send", params); }, }, retryUnsent: { post: async function (params) { - return await request.post('system/notifications/retry-unsent', params) + return await request.post( + "system/notifications/retry-unsent", + params, + ); }, }, }, @@ -412,70 +446,78 @@ export default { configs: { all: { get: async function () { - return await request.get('system/configs') + return await request.get("system/configs"); }, }, group: { get: async function (params) { - return await request.get('system/configs/group', { params }) + return await request.get("system/configs/group", { + params, + }); }, }, key: { get: async function (params) { - return await request.get('system/configs/key', { params }) + return await request.get("system/configs/key", { params }); }, }, }, dictionaries: { all: { get: async function () { - return await request.get('system/dictionaries') + return await request.get("system/dictionaries"); }, }, code: { get: async function (params) { - return await request.get('system/dictionaries/code', { params }) + return await request.get("system/dictionaries/code", { + params, + }); }, }, detail: { get: async function (id) { - return await request.get(`system/dictionaries/${id}`) + return await request.get(`system/dictionaries/${id}`); }, }, }, cities: { tree: { get: async function () { - return await request.get('system/cities/tree') + return await request.get("system/cities/tree"); }, }, provinces: { get: async function () { - return await request.get('system/cities/provinces') + return await request.get("system/cities/provinces"); }, }, cities: { get: async function (provinceId) { - return await request.get(`system/cities/${provinceId}/cities`) + return await request.get( + `system/cities/${provinceId}/cities`, + ); }, }, districts: { get: async function (cityId) { - return await request.get(`system/cities/${cityId}/districts`) + return await request.get( + `system/cities/${cityId}/districts`, + ); }, }, detail: { get: async function (id) { - return await request.get(`system/cities/${id}`) + return await request.get(`system/cities/${id}`); }, }, }, upload: { post: async function (formData) { - return await request.post('system/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }) + return await request.post("system/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); }, }, }, -} +}; diff --git a/resources/admin/src/assets/style/app.scss b/resources/admin/src/assets/style/app.scss index 796fd91..6ac0bf4 100644 --- a/resources/admin/src/assets/style/app.scss +++ b/resources/admin/src/assets/style/app.scss @@ -5,7 +5,9 @@ } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/resources/admin/src/assets/style/auth.scss b/resources/admin/src/assets/style/auth.scss index f594d23..b48b220 100644 --- a/resources/admin/src/assets/style/auth.scss +++ b/resources/admin/src/assets/style/auth.scss @@ -34,32 +34,52 @@ display: flex; align-items: center; justify-content: center; - background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); + background: linear-gradient( + 135deg, + var(--bg-gradient-start) 0%, + var(--bg-gradient-end) 100% + ); position: relative; overflow: hidden; // Tech pattern background &::before { - content: ''; + content: ""; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-image: - radial-gradient(circle at 20% 50%, rgba(255, 107, 53, 0.03) 0%, transparent 50%), - radial-gradient(circle at 80% 20%, rgba(255, 179, 71, 0.05) 0%, transparent 40%), - radial-gradient(circle at 40% 80%, rgba(255, 127, 80, 0.04) 0%, transparent 40%); + radial-gradient( + circle at 20% 50%, + rgba(255, 107, 53, 0.03) 0%, + transparent 50% + ), + radial-gradient( + circle at 80% 20%, + rgba(255, 179, 71, 0.05) 0%, + transparent 40% + ), + radial-gradient( + circle at 40% 80%, + rgba(255, 127, 80, 0.04) 0%, + transparent 40% + ); pointer-events: none; } // Animated tech elements &::after { - content: ''; + content: ""; position: absolute; width: 600px; height: 600px; - background: radial-gradient(circle, rgba(255, 107, 53, 0.08) 0%, transparent 70%); + background: radial-gradient( + circle, + rgba(255, 107, 53, 0.08) 0%, + transparent 70% + ); border-radius: 50%; top: -200px; right: -200px; @@ -94,14 +114,18 @@ // Tech accent line &::before { - content: ''; + content: ""; position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 80px; height: 4px; - background: linear-gradient(90deg, var(--auth-primary), var(--auth-secondary)); + background: linear-gradient( + 90deg, + var(--auth-primary), + var(--auth-secondary) + ); border-radius: 0 0 4px 4px; } } @@ -115,7 +139,11 @@ font-weight: 700; color: var(--text-primary); margin-bottom: 8px; - background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary)); + background: linear-gradient( + 135deg, + var(--auth-primary-dark), + var(--auth-primary) + ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -192,12 +220,20 @@ transition: all 0.3s ease; &.ant-btn-primary { - background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark)); + background: linear-gradient( + 135deg, + var(--auth-primary), + var(--auth-primary-dark) + ); border: none; box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35); &:hover { - background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary)); + background: linear-gradient( + 135deg, + var(--auth-primary-light), + var(--auth-primary) + ); transform: translateY(-2px); box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45); } @@ -255,7 +291,7 @@ &::before, &::after { - content: ''; + content: ""; flex: 1; height: 1px; background: var(--border-color); diff --git a/resources/admin/src/boot.js b/resources/admin/src/boot.js index ee7ffbf..c8ef6dd 100644 --- a/resources/admin/src/boot.js +++ b/resources/admin/src/boot.js @@ -1,15 +1,14 @@ -import * as AIcons from '@ant-design/icons-vue' -import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import * as AIcons from "@ant-design/icons-vue"; +import * as ElementPlusIconsVue from "@element-plus/icons-vue"; export default { install(app) { - for (let icon in AIcons) { - app.component(`${icon}`, AIcons[icon]) + app.component(`${icon}`, AIcons[icon]); } for (const [key, component] of Object.entries(ElementPlusIconsVue)) { - app.component(`El${key}`, component) + app.component(`El${key}`, component); } - } -} + }, +}; diff --git a/resources/admin/src/components/scCron/index.vue b/resources/admin/src/components/scCron/index.vue index b57a761..571b34a 100644 --- a/resources/admin/src/components/scCron/index.vue +++ b/resources/admin/src/components/scCron/index.vue @@ -1,63 +1,81 @@ diff --git a/resources/admin/src/layouts/index.vue b/resources/admin/src/layouts/index.vue index a410a3e..0053609 100644 --- a/resources/admin/src/layouts/index.vue +++ b/resources/admin/src/layouts/index.vue @@ -5,12 +5,21 @@
- logo + logo