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 @@
+
+
+---
+
+## 持续更新
+- *请`Watch`此仓库,以获得最新的更新。*
+- **QQ交流群**:
+ - `698480528` [](//shang.qq.com/wpa/qunwpa?idkey=f949191c8f413a3ecc5fbce661e57d379740ba92172bd50b02d23a5ab36cc7d6)
+ - `62075835` [](//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)。
+
+
+
+## 其他特性
+
+### 配置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
-
-
-
-
-
-
-
-
-
-
-
-
- 成功
- 失败
-
-
-
- 查询
- 重置
- 导出
-
-
-
-
-
-
-
- {{ record.status === 'success' ? '成功' : '失败' }}
-
-
-
- 查看
- 删除
-
-
-
-
-
-
-```
-
-## 注意事项
-
-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
+
+
+
+
+
+
+
+
+
+
+
+
+ 成功
+ 失败
+
+
+
+ 查询
+ 重置
+ 导出
+
+
+
+
+
+
+
+ {{ record.status === 'success' ? '成功' : '失败' }}
+
+
+
+ 查看
+ 删除
+
+
+
+
+
+
+```
+
## 注意事项
-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
-
断开连接
-
发送消息
-
连接状态: {{ connectionStatus }}
+
发送消息
+
连接状态: {{ connectionStatus }}
```
---
+## 连接问题修复
+
+### 问题描述
+
+在开发过程中发现 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 @@
-
+
快捷设置:
- 每天零点
- 每周零点
- 每月1号零点
- 每6小时
- 每小时
- 每30分钟
+ 每天零点
+ 每周零点
+ 每月1号零点
+ 每6小时
+ 每小时
+ 每30分钟
-
- 格式: 秒 分 时 日 月 周
-
+
格式: 秒 分 时 日 月 周
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 @@