diff --git a/app/Console/Commands/RetryUnsentNotifications.php b/app/Console/Commands/RetryUnsentNotifications.php new file mode 100644 index 0000000..d5187dd --- /dev/null +++ b/app/Console/Commands/RetryUnsentNotifications.php @@ -0,0 +1,74 @@ +notificationService = $notificationService; + } + + /** + * Execute the console command. + */ + public function handle(): int + { + $limit = (int) $this->option('limit'); + + $this->info('开始重试未发送的通知...'); + $this->info("最大处理数量: {$limit}"); + + try { + $sentCount = $this->notificationService->retryUnsentNotifications($limit); + + if ($sentCount > 0) { + $this->info("成功发送 {$sentCount} 条通知"); + } else { + $this->info('没有需要重试的通知'); + } + + Log::info('重试未发送通知完成', [ + 'sent_count' => $sentCount, + 'limit' => $limit + ]); + + return self::SUCCESS; + } catch (\Exception $e) { + $this->error('重试未发送通知失败: ' . $e->getMessage()); + Log::error('重试未发送通知失败', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return self::FAILURE; + } + } +} diff --git a/app/Http/Controllers/System/Admin/Notification.php b/app/Http/Controllers/System/Admin/Notification.php new file mode 100644 index 0000000..fabcfc3 --- /dev/null +++ b/app/Http/Controllers/System/Admin/Notification.php @@ -0,0 +1,361 @@ +notificationService = $notificationService; + } + + /** + * 获取通知列表 + * + * @param Request $request + * @return JsonResponse + */ + public function index(Request $request): JsonResponse + { + $params = $request->all(); + + // 如果没有指定user_id,使用当前登录用户 + if (empty($params['user_id'])) { + $params['user_id'] = auth('admin')->id(); + } + + $result = $this->notificationService->getList($params); + + return response()->json([ + 'code' => 200, + 'message' => 'success', + 'data' => $result + ]); + } + + /** + * 获取未读通知 + * + * @param Request $request + * @return JsonResponse + */ + public function unread(Request $request): JsonResponse + { + $userId = auth('admin')->id(); + $limit = $request->input('limit', 10); + + $notifications = $this->notificationService->getUnreadNotifications($userId, $limit); + + return response()->json([ + 'code' => 200, + 'message' => 'success', + 'data' => [ + 'list' => $notifications + ] + ]); + } + + /** + * 获取未读通知数量 + * + * @return JsonResponse + */ + public function unreadCount(): JsonResponse + { + $userId = auth('admin')->id(); + $count = $this->notificationService->getUnreadCount($userId); + + return response()->json([ + 'code' => 200, + 'message' => 'success', + 'data' => [ + 'count' => $count + ] + ]); + } + + /** + * 获取通知详情 + * + * @param int $id + * @return JsonResponse + */ + public function show(int $id): JsonResponse + { + $notification = $this->notificationService->getById($id); + + if (!$notification) { + return response()->json([ + 'code' => 404, + 'message' => 'Notification not found', + 'data' => null + ], 404); + } + + // 检查权限 + if ($notification->user_id !== auth('admin')->id()) { + return response()->json([ + 'code' => 403, + 'message' => 'Access denied', + 'data' => null + ], 403); + } + + return response()->json([ + 'code' => 200, + 'message' => 'success', + 'data' => $notification + ]); + } + + /** + * 标记通知为已读 + * + * @param Request $request + * @param int $id + * @return JsonResponse + */ + public function markAsRead(Request $request, int $id): JsonResponse + { + $userId = auth('admin')->id(); + $result = $this->notificationService->markAsRead($id, $userId); + + if (!$result) { + return response()->json([ + 'code' => 404, + 'message' => 'Notification not found or access denied', + 'data' => null + ], 404); + } + + return response()->json([ + 'code' => 200, + 'message' => 'Notification marked as read', + 'data' => null + ]); + } + + /** + * 批量标记通知为已读 + * + * @param Request $request + * @return JsonResponse + */ + public function batchMarkAsRead(Request $request): JsonResponse + { + $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'integer' + ]); + + $userId = auth('admin')->id(); + $ids = $request->input('ids'); + + $count = $this->notificationService->batchMarkAsRead($ids, $userId); + + return response()->json([ + 'code' => 200, + 'message' => 'Notifications marked as read', + 'data' => [ + 'count' => $count + ] + ]); + } + + /** + * 标记所有通知为已读 + * + * @return JsonResponse + */ + public function markAllAsRead(): JsonResponse + { + $userId = auth('admin')->id(); + $count = $this->notificationService->markAllAsRead($userId); + + return response()->json([ + 'code' => 200, + 'message' => 'All notifications marked as read', + 'data' => [ + 'count' => $count + ] + ]); + } + + /** + * 删除通知 + * + * @param int $id + * @return JsonResponse + */ + public function destroy(int $id): JsonResponse + { + $userId = auth('admin')->id(); + $result = $this->notificationService->delete($id, $userId); + + if (!$result) { + return response()->json([ + 'code' => 404, + 'message' => 'Notification not found or access denied', + 'data' => null + ], 404); + } + + return response()->json([ + 'code' => 200, + 'message' => 'Notification deleted', + 'data' => null + ]); + } + + /** + * 批量删除通知 + * + * @param Request $request + * @return JsonResponse + */ + public function batchDelete(Request $request): JsonResponse + { + $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'integer' + ]); + + $userId = auth('admin')->id(); + $ids = $request->input('ids'); + + $count = $this->notificationService->batchDelete($ids, $userId); + + return response()->json([ + 'code' => 200, + 'message' => 'Notifications deleted', + 'data' => [ + 'count' => $count + ] + ]); + } + + /** + * 清空已读通知 + * + * @return JsonResponse + */ + public function clearRead(): JsonResponse + { + $userId = auth('admin')->id(); + $count = $this->notificationService->clearReadNotifications($userId); + + return response()->json([ + 'code' => 200, + 'message' => 'Read notifications cleared', + 'data' => [ + 'count' => $count + ] + ]); + } + + /** + * 获取通知统计 + * + * @return JsonResponse + */ + public function statistics(): JsonResponse + { + $userId = auth('admin')->id(); + $stats = $this->notificationService->getStatistics($userId); + + return response()->json([ + 'code' => 200, + 'message' => 'success', + 'data' => $stats + ]); + } + + /** + * 发送通知(管理员功能) + * + * @param Request $request + * @return JsonResponse + */ + public function send(Request $request): JsonResponse + { + $request->validate([ + 'user_ids' => 'nullable|array', + 'user_ids.*' => 'integer', + 'title' => 'required|string|max:200', + 'content' => 'required|string', + 'type' => 'required|string|in:info,success,warning,error,task,system', + 'category' => 'nullable|string|in:system,task,message,reminder,announcement', + 'data' => 'nullable|array', + 'action_type' => 'nullable|string|in:link,modal,none', + 'action_data' => 'nullable|array', + ]); + + $userIds = $request->input('user_ids'); + $title = $request->input('title'); + $content = $request->input('content'); + $type = $request->input('type', 'info'); + $category = $request->input('category', 'system'); + $extraData = $request->input('data', []); + + // 如果没有指定user_ids,则发送给所有用户 + if (empty($userIds)) { + $result = $this->notificationService->broadcast( + $title, + $content, + $type, + $category, + $extraData + ); + } else { + $result = $this->notificationService->sendToUsers( + $userIds, + $title, + $content, + $type, + $category, + $extraData + ); + } + + return response()->json([ + 'code' => 200, + 'message' => 'Notification sent successfully', + 'data' => [ + 'count' => count($result) + ] + ]); + } + + /** + * 重试发送未发送的通知(管理员功能) + * + * @param Request $request + * @return JsonResponse + */ + public function retryUnsent(Request $request): JsonResponse + { + $limit = $request->input('limit', 100); + $count = $this->notificationService->retryUnsentNotifications($limit); + + return response()->json([ + 'code' => 200, + 'message' => 'Unsent notifications retried', + 'data' => [ + 'count' => $count + ] + ]); + } +} diff --git a/app/Models/System/Notification.php b/app/Models/System/Notification.php new file mode 100644 index 0000000..2c7ec1c --- /dev/null +++ b/app/Models/System/Notification.php @@ -0,0 +1,154 @@ + 'array', + 'is_read' => 'boolean', + 'sent_via_websocket' => 'boolean', + 'read_at' => 'datetime', + 'sent_at' => 'datetime', + 'retry_count' => 'integer', + ]; + + /** + * 通知类型常量 + */ + const TYPE_INFO = 'info'; + const TYPE_SUCCESS = 'success'; + const TYPE_WARNING = 'warning'; + const TYPE_ERROR = 'error'; + const TYPE_TASK = 'task'; + const TYPE_SYSTEM = 'system'; + + /** + * 通知分类常量 + */ + const CATEGORY_SYSTEM = 'system'; + const CATEGORY_TASK = 'task'; + const CATEGORY_MESSAGE = 'message'; + const CATEGORY_REMINDER = 'reminder'; + const CATEGORY_ANNOUNCEMENT = 'announcement'; + + /** + * 操作类型常量 + */ + const ACTION_LINK = 'link'; + const ACTION_MODAL = 'modal'; + const ACTION_NONE = 'none'; + + /** + * 关联用户 + */ + public function user(): BelongsTo + { + return $this->belongsTo(\App\Models\Auth\User::class, 'user_id'); + } + + /** + * 标记为已读 + */ + public function markAsRead(): bool + { + $this->is_read = true; + $this->read_at = now(); + return $this->save(); + } + + /** + * 标记为未读 + */ + public function markAsUnread(): bool + { + $this->is_read = false; + $this->read_at = null; + return $this->save(); + } + + /** + * 标记已通过WebSocket发送 + */ + public function markAsSent(): bool + { + $this->sent_via_websocket = true; + $this->sent_at = now(); + return $this->save(); + } + + /** + * 增加重试次数 + */ + public function incrementRetry(): bool + { + $this->increment('retry_count'); + return true; + } + + /** + * 获取通知类型选项 + */ + public static function getTypeOptions(): array + { + return [ + self::TYPE_INFO => '信息', + self::TYPE_SUCCESS => '成功', + self::TYPE_WARNING => '警告', + self::TYPE_ERROR => '错误', + self::TYPE_TASK => '任务', + self::TYPE_SYSTEM => '系统', + ]; + } + + /** + * 获取通知分类选项 + */ + public static function getCategoryOptions(): array + { + return [ + self::CATEGORY_SYSTEM => '系统通知', + self::CATEGORY_TASK => '任务通知', + self::CATEGORY_MESSAGE => '消息通知', + self::CATEGORY_REMINDER => '提醒通知', + self::CATEGORY_ANNOUNCEMENT => '公告通知', + ]; + } + + /** + * 获取操作类型选项 + */ + public static function getActionTypeOptions(): array + { + return [ + self::ACTION_LINK => '跳转链接', + self::ACTION_MODAL => '弹窗显示', + self::ACTION_NONE => '无操作', + ]; + } +} diff --git a/app/Services/System/NotificationService.php b/app/Services/System/NotificationService.php new file mode 100644 index 0000000..7b60d12 --- /dev/null +++ b/app/Services/System/NotificationService.php @@ -0,0 +1,548 @@ +webSocketService = app(WebSocketService::class); + } + + /** + * 获取通知列表 + * + * @param array $params + * @return array + */ + public function getList(array $params): array + { + $query = Notification::query(); + + // 过滤用户ID + if (!empty($params['user_id'])) { + $query->where('user_id', $params['user_id']); + } + + // 关键字搜索 + if (!empty($params['keyword'])) { + $query->where(function ($q) use ($params) { + $q->where('title', 'like', '%' . $params['keyword'] . '%') + ->orWhere('content', 'like', '%' . $params['keyword'] . '%'); + }); + } + + // 过滤阅读状态 + if (isset($params['is_read']) && $params['is_read'] !== '') { + $query->where('is_read', $params['is_read']); + } + + // 过滤通知类型 + if (!empty($params['type'])) { + $query->where('type', $params['type']); + } + + // 过滤通知分类 + if (!empty($params['category'])) { + $query->where('category', $params['category']); + } + + // 日期范围 + if (!empty($params['start_date'])) { + $query->where('created_at', '>=', $params['start_date']); + } + if (!empty($params['end_date'])) { + $query->where('created_at', '<=', $params['end_date']); + } + + $query->orderBy('created_at', 'desc'); + + $pageSize = $params['page_size'] ?? 20; + $list = $query->paginate($pageSize); + + return [ + 'list' => $list->items(), + 'total' => $list->total(), + 'page' => $list->currentPage(), + 'page_size' => $list->perPage(), + ]; + } + + /** + * 获取未读通知列表 + * + * @param int $userId + * @param int $limit + * @return array + */ + public function getUnreadNotifications(int $userId, int $limit = 10): array + { + return Notification::where('user_id', $userId) + ->where('is_read', false) + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get() + ->toArray(); + } + + /** + * 获取未读通知数量 + * + * @param int $userId + * @return int + */ + public function getUnreadCount(int $userId): int + { + return Notification::where('user_id', $userId) + ->where('is_read', false) + ->count(); + } + + /** + * 根据ID获取通知 + * + * @param int $id + * @return Notification|null + */ + public function getById(int $id): ?Notification + { + return Notification::find($id); + } + + /** + * 创建通知 + * + * @param array $data + * @return Notification + */ + public function create(array $data): Notification + { + $notification = Notification::create($data); + + // 如果用户在线,立即通过WebSocket发送 + if ($this->webSocketService->isUserOnline($data['user_id'])) { + $this->sendViaWebSocket($notification); + } + + return $notification; + } + + /** + * 批量创建通知 + * + * @param array $notificationsData + * @return array + */ + public function batchCreate(array $notificationsData): array + { + $notifications = []; + + DB::beginTransaction(); + try { + foreach ($notificationsData as $data) { + $notification = Notification::create($data); + $notifications[] = $notification; + + // 如果用户在线,立即通过WebSocket发送 + if ($this->webSocketService->isUserOnline($data['user_id'])) { + $this->sendViaWebSocket($notification); + } + } + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + Log::error('批量创建通知失败', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + throw $e; + } + + return $notifications; + } + + /** + * 发送系统通知给单个用户 + * + * @param int $userId + * @param string $title + * @param string $content + * @param string $type + * @param string $category + * @param array $extraData + * @return Notification + */ + public function sendToUser( + int $userId, + string $title, + string $content, + string $type = Notification::TYPE_INFO, + string $category = Notification::CATEGORY_SYSTEM, + array $extraData = [] + ): Notification { + $data = [ + 'user_id' => $userId, + 'title' => $title, + 'content' => $content, + 'type' => $type, + 'category' => $category, + 'data' => $extraData, + 'is_read' => false, + ]; + + return $this->create($data); + } + + /** + * 发送系统通知给多个用户 + * + * @param array $userIds + * @param string $title + * @param string $content + * @param string $type + * @param string $category + * @param array $extraData + * @return array + */ + public function sendToUsers( + array $userIds, + string $title, + string $content, + string $type = Notification::TYPE_INFO, + string $category = Notification::CATEGORY_SYSTEM, + array $extraData = [] + ): array { + $notificationsData = []; + + foreach ($userIds as $userId) { + $notificationsData[] = [ + 'user_id' => $userId, + 'title' => $title, + 'content' => $content, + 'type' => $type, + 'category' => $category, + 'data' => $extraData, + 'is_read' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + return $this->batchCreate($notificationsData); + } + + /** + * 发送系统广播通知(所有用户) + * + * @param string $title + * @param string $content + * @param string $type + * @param string $category + * @param array $extraData + * @return array + */ + public function broadcast( + string $title, + string $content, + string $type = Notification::TYPE_INFO, + string $category = Notification::CATEGORY_ANNOUNCEMENT, + array $extraData = [] + ): array { + // 获取所有用户ID + $userIds = \App\Models\Auth\User::where('status', 1)->pluck('id')->toArray(); + + return $this->sendToUsers($userIds, $title, $content, $type, $category, $extraData); + } + + /** + * 通过WebSocket发送通知 + * + * @param Notification $notification + * @return bool + */ + protected function sendViaWebSocket(Notification $notification): bool + { + $data = [ + 'type' => 'notification', + 'data' => [ + 'id' => $notification->id, + 'title' => $notification->title, + 'content' => $notification->content, + 'type' => $notification->type, + 'category' => $notification->category, + 'data' => $notification->data, + 'action_type' => $notification->action_type, + 'action_data' => $notification->action_data, + 'timestamp' => $notification->created_at->timestamp, + ] + ]; + + $result = $this->webSocketService->sendToUser($notification->user_id, $data); + + if ($result) { + $notification->markAsSent(); + } else { + $notification->incrementRetry(); + } + + return $result; + } + + /** + * 重试发送未发送的通知 + * + * @param int $limit + * @return int + */ + public function retryUnsentNotifications(int $limit = 100): int + { + $notifications = Notification::where('sent_via_websocket', false) + ->where('retry_count', '<', 3) + ->where('created_at', '>', now()->subHours(24)) + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + + $sentCount = 0; + + foreach ($notifications as $notification) { + if ($this->webSocketService->isUserOnline($notification->user_id)) { + if ($this->sendViaWebSocket($notification)) { + $sentCount++; + } + } + } + + return $sentCount; + } + + /** + * 标记通知为已读 + * + * @param int $id + * @param int $userId + * @return bool + */ + public function markAsRead(int $id, int $userId): bool + { + $notification = Notification::where('id', $id) + ->where('user_id', $userId) + ->first(); + + if (!$notification) { + return false; + } + + return $notification->markAsRead(); + } + + /** + * 批量标记通知为已读 + * + * @param array $ids + * @param int $userId + * @return int + */ + public function batchMarkAsRead(array $ids, int $userId): int + { + return Notification::where('user_id', $userId) + ->whereIn('id', $ids) + ->update([ + 'is_read' => true, + 'read_at' => now(), + ]); + } + + /** + * 标记所有通知为已读 + * + * @param int $userId + * @return int + */ + public function markAllAsRead(int $userId): int + { + return Notification::where('user_id', $userId) + ->where('is_read', false) + ->update([ + 'is_read' => true, + 'read_at' => now(), + ]); + } + + /** + * 删除通知 + * + * @param int $id + * @param int $userId + * @return bool + */ + public function delete(int $id, int $userId): bool + { + $notification = Notification::where('id', $id) + ->where('user_id', $userId) + ->first(); + + if (!$notification) { + return false; + } + + return $notification->delete(); + } + + /** + * 批量删除通知 + * + * @param array $ids + * @param int $userId + * @return int + */ + public function batchDelete(array $ids, int $userId): int + { + return Notification::where('user_id', $userId) + ->whereIn('id', $ids) + ->delete(); + } + + /** + * 清空已读通知 + * + * @param int $userId + * @return int + */ + public function clearReadNotifications(int $userId): int + { + return Notification::where('user_id', $userId) + ->where('is_read', true) + ->delete(); + } + + /** + * 获取通知统计 + * + * @param int $userId + * @return array + */ + public function getStatistics(int $userId): array + { + $total = Notification::where('user_id', $userId)->count(); + $unread = Notification::where('user_id', $userId)->where('is_read', false)->count(); + $read = Notification::where('user_id', $userId)->where('is_read', true)->count(); + + // 按类型统计 + $byType = Notification::where('user_id', $userId) + ->selectRaw('type, COUNT(*) as count') + ->groupBy('type') + ->pluck('count', 'type') + ->toArray(); + + // 按分类统计 + $byCategory = Notification::where('user_id', $userId) + ->selectRaw('category, COUNT(*) as count') + ->groupBy('category') + ->pluck('count', 'category') + ->toArray(); + + return [ + 'total' => $total, + 'unread' => $unread, + 'read' => $read, + 'by_type' => $byType, + 'by_category' => $byCategory, + ]; + } + + /** + * 发送任务通知 + * + * @param int $userId + * @param string $title + * @param string $content + * @param array $taskData + * @return Notification + */ + public function sendTaskNotification(int $userId, string $title, string $content, array $taskData = []): Notification + { + return $this->sendToUser( + $userId, + $title, + $content, + Notification::TYPE_TASK, + Notification::CATEGORY_TASK, + array_merge(['task' => $taskData], $taskData) + ); + } + + /** + * 发送系统维护通知 + * + * @param string $title + * @param string $content + * @param array $maintenanceData + * @return array + */ + public function sendMaintenanceNotification(string $title, string $content, array $maintenanceData = []): array + { + return $this->broadcast( + $title, + $content, + Notification::TYPE_WARNING, + Notification::CATEGORY_ANNOUNCEMENT, + array_merge(['maintenance' => $maintenanceData], $maintenanceData) + ); + } + + /** + * 发送新消息通知 + * + * @param int $userId + * @param string $title + * @param string $content + * @param array $messageData + * @return Notification + */ + public function sendNewMessageNotification(int $userId, string $title, string $content, array $messageData = []): Notification + { + return $this->sendToUser( + $userId, + $title, + $content, + Notification::TYPE_INFO, + Notification::CATEGORY_MESSAGE, + array_merge(['message' => $messageData], $messageData) + ); + } + + /** + * 发送提醒通知 + * + * @param int $userId + * @param string $title + * @param string $content + * @param array $reminderData + * @return Notification + */ + public function sendReminderNotification(int $userId, string $title, string $content, array $reminderData = []): Notification + { + return $this->sendToUser( + $userId, + $title, + $content, + Notification::TYPE_WARNING, + Notification::CATEGORY_REMINDER, + array_merge(['reminder' => $reminderData], $reminderData) + ); + } +} diff --git a/app/Services/System/TaskService.php b/app/Services/System/TaskService.php index 828a378..9839c18 100644 --- a/app/Services/System/TaskService.php +++ b/app/Services/System/TaskService.php @@ -3,10 +3,25 @@ namespace App\Services\System; use App\Models\System\Task; +use App\Services\System\NotificationService; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Facades\Log; class TaskService { + /** + * @var NotificationService + */ + protected $notificationService; + + /** + * TaskService constructor + */ + public function __construct(NotificationService $notificationService) + { + $this->notificationService = $notificationService; + } + public function getList(array $params): array { $query = Task::query(); @@ -144,6 +159,9 @@ class TaskService 'last_output' => substr($output, 0, 10000), ]); + // 发送任务执行结果通知 + $this->sendTaskNotification($task, $status, $errorMessage, $executionTime); + return [ 'status' => $status, 'output' => $output, @@ -164,4 +182,110 @@ class TaskService 'inactive' => $inactive, ]; } + + /** + * 发送任务执行结果通知 + * + * @param Task $task + * @param string $status + * @param string|null $errorMessage + * @param int $executionTime + * @return void + */ + protected function sendTaskNotification(Task $task, string $status, ?string $errorMessage, int $executionTime): void + { + try { + // 只对失败的任务或重要的成功任务发送通知 + // 这里可以根据实际需求调整通知策略 + + if ($status === 'error') { + // 任务失败通知 + $title = '任务执行失败: ' . $task->name; + $content = sprintf( + "任务 %s 执行失败,错误信息:%s\n执行时间:%d 毫秒", + $task->name, + $errorMessage ?: '未知错误', + $executionTime + ); + + // 获取管理员用户ID列表 + $adminUserIds = $this->getAdminUserIds(); + + if (!empty($adminUserIds)) { + $this->notificationService->sendToUsers( + $adminUserIds, + $title, + $content, + \App\Models\System\Notification::TYPE_ERROR, + \App\Models\System\Notification::CATEGORY_TASK, + [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'command' => $task->command, + 'error_message' => $errorMessage, + 'execution_time' => $executionTime, + 'last_run_at' => $task->last_run_at?->toDateTimeString() + ] + ); + } + } elseif ($executionTime > 60000) { + // 执行时间超过1分钟的成功任务,发送通知 + $title = '任务执行完成: ' . $task->name; + $content = sprintf( + "任务 %s 执行成功,耗时:%.2f 秒", + $task->name, + $executionTime / 1000 + ); + + $adminUserIds = $this->getAdminUserIds(); + + if (!empty($adminUserIds)) { + $this->notificationService->sendToUsers( + $adminUserIds, + $title, + $content, + \App\Models\System\Notification::TYPE_SUCCESS, + \App\Models\System\Notification::CATEGORY_TASK, + [ + 'task_id' => $task->id, + 'task_name' => $task->name, + 'execution_time' => $executionTime, + 'last_run_at' => $task->last_run_at?->toDateTimeString() + ] + ); + } + } + } catch (\Exception $e) { + Log::error('发送任务通知失败', [ + 'task_id' => $task->id, + 'error' => $e->getMessage() + ]); + } + } + + /** + * 获取管理员用户ID列表 + * + * @return array + */ + protected function getAdminUserIds(): array + { + try { + // 获取拥有管理员权限的用户 + // 这里可以根据实际业务逻辑调整,例如获取特定角色的用户 + $adminUserIds = \App\Models\Auth\User::where('status', 1) + ->whereHas('roles', function ($query) { + $query->where('name', 'admin'); + }) + ->pluck('id') + ->toArray(); + + return $adminUserIds; + } catch (\Exception $e) { + Log::error('获取管理员用户列表失败', [ + 'error' => $e->getMessage() + ]); + return []; + } + } } diff --git a/app/Services/WebSocket/WebSocketHandler.php b/app/Services/WebSocket/WebSocketHandler.php index 5aa6551..ff58836 100644 --- a/app/Services/WebSocket/WebSocketHandler.php +++ b/app/Services/WebSocket/WebSocketHandler.php @@ -21,6 +21,16 @@ class WebSocketHandler implements WebSocketHandlerInterface */ protected $userOnlineService; + /** + * Get wsTable instance + * + * @return \Swoole\Table + */ + protected function getWsTable(): \Swoole\Table + { + return app('swoole')->wsTable; + } + /** * WebSocketHandler constructor */ @@ -54,12 +64,12 @@ class WebSocketHandler implements WebSocketHandlerInterface if ($userId && $token) { // Store user connection mapping - $server->wsTable->set('uid:' . $userId, [ + $this->getWsTable()->set('uid:' . $userId, [ 'value' => $fd, 'expiry' => time() + 3600, // 1 hour expiry ]); - $server->wsTable->set('fd:' . $fd, [ + $this->getWsTable()->set('fd:' . $fd, [ 'value' => $userId, 'expiry' => time() + 3600 ]); @@ -231,7 +241,7 @@ class WebSocketHandler implements WebSocketHandlerInterface $token = $data['token'] ?? null; // Get the user ID from wsTable (set during connection) - $storedUserId = $server->wsTable->get('fd:' . $fd)['value'] ?? null; + $storedUserId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null; if ($storedUserId && $storedUserId == $userId) { // Authentication confirmed, send success response @@ -291,13 +301,13 @@ class WebSocketHandler implements WebSocketHandlerInterface } // Get target user's connection - $targetFd = $server->wsTable->get('uid:' . $toUserId); + $targetFd = $this->getWsTable()->get('uid:' . $toUserId); if ($targetFd && $targetFd['value']) { $server->push((int)$targetFd['value'], json_encode([ 'type' => 'chat', 'data' => [ - 'from_user_id' => $server->wsTable->get('fd:' . $fd)['value'] ?? null, + 'from_user_id' => $this->getWsTable()->get('fd:' . $fd)['value'] ?? null, 'content' => $content, 'timestamp' => time() ] @@ -334,7 +344,7 @@ class WebSocketHandler implements WebSocketHandlerInterface protected function handleBroadcast(Server $server, int $fd, array $data): void { $message = $data['message'] ?? ''; - $userId = $server->wsTable->get('fd:' . $fd)['value'] ?? null; + $userId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null; // TODO: Check if user has admin permission to broadcast // For now, allow any authenticated user @@ -400,7 +410,7 @@ class WebSocketHandler implements WebSocketHandlerInterface } // Store subscription in wsTable - $server->wsTable->set('channel:' . $channel . ':fd:' . $fd, [ + $this->getWsTable()->set('channel:' . $channel . ':fd:' . $fd, [ 'value' => 1, 'expiry' => time() + 7200 // 2 hours ]); @@ -443,7 +453,7 @@ class WebSocketHandler implements WebSocketHandlerInterface } // Remove subscription from wsTable - $server->wsTable->del('channel:' . $channel . ':fd:' . $fd); + $this->getWsTable()->del('channel:' . $channel . ':fd:' . $fd); $server->push($fd, json_encode([ 'type' => 'unsubscribed', @@ -476,12 +486,12 @@ class WebSocketHandler implements WebSocketHandlerInterface ]); // Get user ID from wsTable - $userId = $server->wsTable->get('fd:' . $fd)['value'] ?? null; + $userId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null; if ($userId) { // Remove user connection mapping - $server->wsTable->del('uid:' . $userId); - $server->wsTable->del('fd:' . $fd); + $this->getWsTable()->del('uid:' . $userId); + $this->getWsTable()->del('fd:' . $fd); // Update user online status $this->userOnlineService->updateUserOnlineStatus($userId, $fd, false); diff --git a/app/Services/WebSocket/WebSocketService.php b/app/Services/WebSocket/WebSocketService.php index a39622a..0e05ec1 100644 --- a/app/Services/WebSocket/WebSocketService.php +++ b/app/Services/WebSocket/WebSocketService.php @@ -50,12 +50,14 @@ class WebSocketService { $server = $this->getServer(); - if (!$server || !isset($server->wsTable)) { + if (!$server) { Log::warning('WebSocket server not available', ['user_id' => $userId]); return false; } - $fdInfo = $server->wsTable->get('uid:' . $userId); + $wsTable = app('swoole')->wsTable; + + $fdInfo = $wsTable->get('uid:' . $userId); if (!$fdInfo || !$fdInfo['value']) { Log::info('User not connected to WebSocket', ['user_id' => $userId]); @@ -67,8 +69,8 @@ class WebSocketService if (!$server->isEstablished($fd)) { Log::info('WebSocket connection not established', ['user_id' => $userId, 'fd' => $fd]); // Clean up stale connection - $server->wsTable->del('uid:' . $userId); - $server->wsTable->del('fd:' . $fd); + $wsTable->del('uid:' . $userId); + $wsTable->del('fd:' . $fd); return false; } @@ -119,6 +121,7 @@ class WebSocketService return 0; } + $wsTable = app('swoole')->wsTable; $message = json_encode($data); $count = 0; @@ -129,7 +132,7 @@ class WebSocketService // Check if we should exclude this user if ($excludeUserId) { - $fdInfo = $server->wsTable->get('fd:' . $fd); + $fdInfo = $wsTable->get('fd:' . $fd); if ($fdInfo && $fdInfo['value'] == $excludeUserId) { continue; } @@ -159,11 +162,12 @@ class WebSocketService { $server = $this->getServer(); - if (!$server || !isset($server->wsTable)) { + if (!$server) { Log::warning('WebSocket server not available for channel broadcast', ['channel' => $channel]); return 0; } + $wsTable = app('swoole')->wsTable; $count = 0; $message = json_encode($data); @@ -173,7 +177,7 @@ class WebSocketService continue; } - $subscription = $server->wsTable->get('channel:' . $channel . ':fd:' . $fd); + $subscription = $wsTable->get('channel:' . $channel . ':fd:' . $fd); if ($subscription) { $server->push($fd, $message); @@ -224,11 +228,13 @@ class WebSocketService { $server = $this->getServer(); - if (!$server || !isset($server->wsTable)) { + if (!$server) { return false; } - $fdInfo = $server->wsTable->get('uid:' . $userId); + $wsTable = app('swoole')->wsTable; + + $fdInfo = $wsTable->get('uid:' . $userId); if (!$fdInfo || !$fdInfo['value']) { return false; @@ -249,11 +255,13 @@ class WebSocketService { $server = $this->getServer(); - if (!$server || !isset($server->wsTable)) { + if (!$server) { return false; } - $fdInfo = $server->wsTable->get('uid:' . $userId); + $wsTable = app('swoole')->wsTable; + + $fdInfo = $wsTable->get('uid:' . $userId); if (!$fdInfo || !$fdInfo['value']) { return false; @@ -274,8 +282,8 @@ class WebSocketService $server->disconnect($fd); // Clean up - $server->wsTable->del('uid:' . $userId); - $server->wsTable->del('fd:' . $fd); + $wsTable->del('uid:' . $userId); + $wsTable->del('fd:' . $fd); Log::info('User disconnected from WebSocket by server', [ 'user_id' => $userId, @@ -297,10 +305,12 @@ class WebSocketService { $server = $this->getServer(); - if (!$server || !isset($server->wsTable)) { + if (!$server) { return []; } + $wsTable = app('swoole')->wsTable; + $userIds = []; foreach ($server->connections as $fd) { @@ -308,7 +318,7 @@ class WebSocketService continue; } - $fdInfo = $server->wsTable->get('fd:' . $fd); + $fdInfo = $wsTable->get('fd:' . $fd); if ($fdInfo && $fdInfo['value']) { $userIds[] = (int)$fdInfo['value']; diff --git a/config/laravels.php b/config/laravels.php index 7cf199b..a42a82b 100644 --- a/config/laravels.php +++ b/config/laravels.php @@ -292,7 +292,7 @@ return [ 'swoole' => [ 'daemonize' => env('LARAVELS_DAEMONIZE', false), - 'dispatch_mode' => env('LARAVELS_DISPATCH_MODE', 3), + 'dispatch_mode' => env('LARAVELS_DISPATCH_MODE', 2), 'worker_num' => env('LARAVELS_WORKER_NUM', 30), //'task_worker_num' => env('LARAVELS_TASK_WORKER_NUM', 10), 'task_ipc_mode' => 1, diff --git a/database/migrations/2024_02_18_000001_create_system_notifications_table.php b/database/migrations/2024_02_18_000001_create_system_notifications_table.php new file mode 100644 index 0000000..37cd48a --- /dev/null +++ b/database/migrations/2024_02_18_000001_create_system_notifications_table.php @@ -0,0 +1,43 @@ +comment('系统通知表'); + $table->id(); + $table->unsignedBigInteger('user_id')->comment('用户ID'); + $table->string('title')->comment('通知标题'); + $table->text('content')->comment('通知内容'); + $table->string('type')->default('info')->comment('通知类型:info, success, warning, error, task, system'); + $table->string('category')->nullable()->comment('通知分类:system, task, message, reminder, announcement'); + $table->json('data')->nullable()->comment('附加数据(JSON格式)'); + $table->string('action_type')->nullable()->comment('操作类型:link, modal, none'); + $table->text('action_data')->nullable()->comment('操作数据'); + $table->boolean('is_read')->default(false)->comment('是否已读'); + $table->timestamp('read_at')->nullable()->comment('阅读时间'); + $table->boolean('sent_via_websocket')->default(false)->comment('是否已通过WebSocket发送'); + $table->timestamp('sent_at')->nullable()->comment('发送时间'); + $table->integer('retry_count')->default(0)->comment('重试次数'); + $table->timestamps(); + $table->softDeletes(); + + $table->index('user_id'); + $table->index('is_read'); + $table->index('type'); + $table->index('category'); + $table->index('created_at'); + }); + } + + public function down() + { + Schema::dropIfExists('system_notifications'); + } +}; diff --git a/docs/README_WEBSOCKET.md b/docs/README_WEBSOCKET.md deleted file mode 100644 index 57f00aa..0000000 --- a/docs/README_WEBSOCKET.md +++ /dev/null @@ -1,684 +0,0 @@ -# WebSocket 功能文档 - -## 概述 - -本项目基于 Laravel-S 和 Swoole 实现了完整的 WebSocket 功能,支持实时通信、消息推送、广播等功能。 - -## 功能特性 - -- ✅ 实时双向通信 -- ✅ 用户连接管理 -- ✅ 点对点消息发送 -- ✅ 群发消息/广播 -- ✅ 频道订阅/取消订阅 -- ✅ 心跳机制 -- ✅ 自动重连 -- ✅ 在线状态管理 -- ✅ 系统通知推送 -- ✅ 数据更新推送 - -## 架构设计 - -### 后端组件 - -#### 1. WebSocketHandler (`app/Services/WebSocket/WebSocketHandler.php`) - -WebSocket 处理器,实现了 Swoole 的 `WebSocketHandlerInterface` 接口。 - -**主要方法:** -- `onOpen()`: 处理连接建立事件 -- `onMessage()`: 处理消息接收事件 -- `onClose()`: 处理连接关闭事件 - -**支持的消息类型:** -- `ping/pong`: 心跳检测 -- `heartbeat`: 心跳确认 -- `chat`: 私聊消息 -- `broadcast`: 广播消息 -- `subscribe/unsubscribe`: 频道订阅/取消订阅 - -#### 2. WebSocketService (`app/Services/WebSocket/WebSocketService.php`) - -WebSocket 服务类,提供便捷的 WebSocket 操作方法。 - -**主要方法:** -- `sendToUser($userId, $data)`: 发送消息给指定用户 -- `sendToUsers($userIds, $data)`: 发送消息给多个用户 -- `broadcast($data, $excludeUserId)`: 广播消息给所有用户 -- `sendToChannel($channel, $data)`: 发送消息给指定频道 -- `getOnlineUserCount()`: 获取在线用户数 -- `isUserOnline($userId)`: 检查用户是否在线 -- `sendSystemNotification()`: 发送系统通知 -- `pushDataUpdate()`: 推送数据更新 - -#### 3. WebSocketController (`app/Http/Controllers/System/WebSocket.php`) - -WebSocket API 控制器,提供 HTTP 接口用于管理 WebSocket 连接。 - -### 前端组件 - -#### WebSocketClient (`resources/admin/src/utils/websocket.js`) - -WebSocket 客户端封装类。 - -**功能:** -- 自动连接和重连 -- 心跳机制 -- 消息类型路由 -- 事件监听 -- 连接状态管理 - -## 配置说明 - -### Laravel-S 配置 (`config/laravels.php`) - -```php -'websocket' => [ - 'enable' => env('LARAVELS_WEBSOCKET', true), - 'handler' => \App\Services\WebSocket\WebSocketHandler::class, -], - -'swoole_tables' => [ - 'wsTable' => [ - 'size' => 102400, - 'column' => [ - ['name' => 'value', 'type' => \Swoole\Table::TYPE_STRING, 'size' => 1024], - ['name' => 'expiry', 'type' => \Swoole\Table::TYPE_INT, 'size' => 4], - ], - ], -], -``` - -### 环境变量 - -在 `.env` 文件中添加: - -```env -LARAVELS_WEBSOCKET=true -``` - -## API 接口 - -### 1. 获取在线用户数 - -``` -GET /admin/websocket/online-count -``` - -**响应:** -```json -{ - "code": 200, - "message": "success", - "data": { - "online_count": 10 - } -} -``` - -### 2. 获取在线用户列表 - -``` -GET /admin/websocket/online-users -``` - -**响应:** -```json -{ - "code": 200, - "message": "success", - "data": { - "user_ids": [1, 2, 3, 4, 5], - "count": 5 - } -} -``` - -### 3. 检查用户在线状态 - -``` -POST /admin/websocket/check-online -``` - -**请求参数:** -```json -{ - "user_id": 1 -} -``` - -**响应:** -```json -{ - "code": 200, - "message": "success", - "data": { - "user_id": 1, - "is_online": true - } -} -``` - -### 4. 发送消息给指定用户 - -``` -POST /admin/websocket/send-to-user -``` - -**请求参数:** -```json -{ - "user_id": 1, - "type": "notification", - "data": { - "title": "新消息", - "message": "您有一条新消息" - } -} -``` - -### 5. 发送消息给多个用户 - -``` -POST /admin/websocket/send-to-users -``` - -**请求参数:** -```json -{ - "user_ids": [1, 2, 3], - "type": "notification", - "data": { - "title": "系统通知", - "message": "系统将在今晚进行维护" - } -} -``` - -### 6. 广播消息 - -``` -POST /admin/websocket/broadcast -``` - -**请求参数:** -```json -{ - "type": "notification", - "data": { - "title": "公告", - "message": "欢迎使用新版本" - }, - "exclude_user_id": 1 // 可选:排除某个用户 -} -``` - -### 7. 发送消息到频道 - -``` -POST /admin/websocket/send-to-channel -``` - -**请求参数:** -```json -{ - "channel": "orders", - "type": "data_update", - "data": { - "order_id": 123, - "status": "paid" - } -} -``` - -### 8. 发送系统通知 - -``` -POST /admin/websocket/send-notification -``` - -**请求参数:** -```json -{ - "title": "系统维护", - "message": "系统将于今晚 23:00-24:00 进行维护", - "type": "warning", - "extra_data": { - "start_time": "23:00", - "end_time": "24:00" - } -} -``` - -### 9. 发送通知给指定用户 - -``` -POST /admin/websocket/send-notification-to-users -``` - -**请求参数:** -```json -{ - "user_ids": [1, 2, 3], - "title": "订单更新", - "message": "您的订单已发货", - "type": "success" -} -``` - -### 10. 推送数据更新 - -``` -POST /admin/websocket/push-data-update -``` - -**请求参数:** -```json -{ - "user_ids": [1, 2, 3], - "resource_type": "order", - "action": "update", - "data": { - "id": 123, - "status": "shipped" - } -} -``` - -### 11. 推送数据更新到频道 - -``` -POST /admin/websocket/push-data-update-channel -``` - -**请求参数:** -```json -{ - "channel": "orders", - "resource_type": "order", - "action": "create", - "data": { - "id": 124, - "customer": "张三", - "amount": 100.00 - } -} -``` - -### 12. 断开用户连接 - -``` -POST /admin/websocket/disconnect-user -``` - -**请求参数:** -```json -{ - "user_id": 1 -} -``` - -## 前端使用示例 - -### 1. 基本连接 - -```javascript -import { getWebSocket, closeWebSocket } from '@/utils/websocket' -import { useUserStore } from '@/stores/modules/user' - -const userStore = useUserStore() - -// 连接 WebSocket -const ws = getWebSocket(userStore.userInfo.id, userStore.token, { - onOpen: (event) => { - console.log('WebSocket 已连接') - }, - onMessage: (message) => { - console.log('收到消息:', message) - }, - onError: (error) => { - console.error('WebSocket 错误:', error) - }, - onClose: (event) => { - console.log('WebSocket 已关闭') - } -}) - -// 连接 -ws.connect() -``` - -### 2. 监听特定消息类型 - -```javascript -// 监听通知消息 -ws.on('notification', (data) => { - message.success(data.title, data.message) -}) - -// 监听数据更新 -ws.on('data_update', (data) => { - console.log('数据更新:', data.resource_type, data.action) - // 刷新数据 - loadData() -}) -``` - -### 3. 发送消息 - -```javascript -// 发送心跳 -ws.send('heartbeat', { timestamp: Date.now() }) - -// 发送私聊消息 -ws.send('chat', { - to_user_id: 2, - content: '你好,这是一条私聊消息' -}) - -// 订阅频道 -ws.send('subscribe', { channel: 'orders' }) - -// 取消订阅 -ws.send('unsubscribe', { channel: 'orders' }) -``` - -### 4. 发送广播消息 - -```javascript -ws.send('broadcast', { - message: '这是一条广播消息' -}) -``` - -### 5. 断开连接 - -```javascript -// 断开连接 -ws.disconnect() - -// 或使用全局方法 -closeWebSocket() -``` - -### 6. 在 Vue 组件中使用 - -```vue - - - -``` - -## 消息格式 - -### 服务端发送的消息格式 - -```json -{ - "type": "notification", - "data": { - "title": "标题", - "message": "内容", - "type": "info", - "timestamp": 1641234567 - } -} -``` - -### 客户端发送的消息格式 - -```json -{ - "type": "chat", - "data": { - "to_user_id": 2, - "content": "消息内容" - } -} -``` - -## 启动和停止 - -### 启动 Laravel-S 服务 - -```bash -php bin/laravels start -``` - -### 停止 Laravel-S 服务 - -```bash -php bin/laravels stop -``` - -### 重启 Laravel-S 服务 - -```bash -php bin/laravels restart -``` - -### 重载 Laravel-S 服务(平滑重启) - -```bash -php bin/laravels reload -``` - -### 查看服务状态 - -```bash -php bin/laravels status -``` - -## WebSocket 连接地址 - -### 开发环境 - -``` -ws://localhost:5200/ws?user_id={user_id}&token={token} -``` - -### 生产环境 - -``` -wss://yourdomain.com/ws?user_id={user_id}&token={token} -``` - -## Nginx 配置示例 - -```nginx -server { - listen 80; - server_name yourdomain.com; - root /path/to/your/project/public; - - 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; - } - - 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; - } -} -``` - -## 使用场景 - -### 1. 实时通知 - -```php -// 发送系统通知 -$webSocketService->sendSystemNotification( - '系统维护', - '系统将于今晚进行维护', - 'warning' -); -``` - -### 2. 订单状态更新 - -```php -// 推送订单状态更新给相关人员 -$webSocketService->pushDataUpdate( - [$order->user_id], - 'order', - 'update', - [ - 'id' => $order->id, - 'status' => $order->status, - 'updated_at' => $order->updated_at - ] -); -``` - -### 3. 实时聊天 - -```javascript -// 发送私聊消息 -ws.send('chat', { - to_user_id: 2, - content: '你好' -}) -``` - -### 4. 数据监控 - -```php -// 推送系统监控数据到特定频道 -$webSocketService->sendToChannel('system_monitor', 'monitor', [ - 'cpu_usage' => 75, - 'memory_usage' => 80, - 'disk_usage' => 60 -]); -``` - -## 注意事项 - -1. **连接认证**: WebSocket 连接时需要提供 `user_id` 和 `token` 参数 -2. **心跳机制**: 客户端默认每 30 秒发送一次心跳 -3. **自动重连**: 连接断开后会自动尝试重连,最多重试 5 次 -4. **并发限制**: Swoole Table 最多支持 102,400 个连接 -5. **内存管理**: 注意内存泄漏问题,定期重启服务 -6. **安全性**: 生产环境建议使用 WSS (WebSocket Secure) -7. **日志监控**: 查看日志文件 `storage/logs/swoole-YYYY-MM.log` - -## 故障排查 - -### 1. 无法连接 WebSocket - -- 检查 Laravel-S 服务是否启动 -- 检查端口 5200 是否被占用 -- 检查防火墙设置 -- 查看日志文件 - -### 2. 连接频繁断开 - -- 检查网络稳定性 -- 调整心跳间隔 -- 检查服务器资源使用情况 - -### 3. 消息发送失败 - -- 检查用户是否在线 -- 检查消息格式是否正确 -- 查看错误日志 - -## 参考资料 - -- [Laravel-S 文档](https://github.com/hhxsv5/laravel-s) -- [Swoole 文档](https://www.swoole.com/) -- [WebSocket API](https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket) - -## 更新日志 - -### 2024-02-08 - -- ✅ 初始版本发布 -- ✅ 实现基础 WebSocket 功能 -- ✅ 实现消息推送功能 -- ✅ 实现频道订阅功能 -- ✅ 实现前端客户端封装 -- ✅ 实现管理 API 接口 diff --git a/docs/README_WEBSOCKET_NOTIFICATION.md b/docs/README_WEBSOCKET_NOTIFICATION.md new file mode 100644 index 0000000..0f8ec0e --- /dev/null +++ b/docs/README_WEBSOCKET_NOTIFICATION.md @@ -0,0 +1,2207 @@ +# WebSocket 与通知系统完整文档 + +## 目录 + +- [概述](#概述) +- [功能特性](#功能特性) +- [技术架构](#技术架构) +- [后端实现](#后端实现) +- [前端实现](#前端实现) +- [API接口](#api接口) +- [配置说明](#配置说明) +- [使用示例](#使用示例) +- [故障排查与修复](#故障排查与修复) +- [性能优化](#性能优化) +- [安全考虑](#安全考虑) +- [最佳实践](#最佳实践) +- [更新日志](#更新日志) + +--- + +## 概述 + +本项目基于 Laravel-S 和 Swoole 实现了完整的 WebSocket 与通知系统,支持实时双向通信、消息推送、通知管理等功能。系统采用 WebSocket 实现实时推送,同时将通知持久化到数据库,确保离线用户也能收到通知。 + +### 核心特性 + +- ✅ 实时双向通信(WebSocket) +- ✅ 用户连接管理 +- ✅ 点对点消息发送 +- ✅ 群发消息/广播 +- ✅ 频道订阅/取消订阅 +- ✅ 心跳机制与自动重连 +- ✅ 在线状态管理 +- ✅ 系统通知推送 +- ✅ 数据更新推送 +- ✅ 通知持久化存储 +- ✅ 已读/未读状态管理 +- ✅ 批量操作支持 + +--- + +## 功能特性 + +### WebSocket 功能 + +| 功能 | 说明 | 状态 | +|------|------|------| +| 自动连接管理 | 登录后自动建立连接,退出时自动关闭 | ✅ | +| 断线重连 | 连接断开自动重连(最多5次) | ✅ | +| 心跳机制 | 客户端每30秒发送心跳保持连接 | ✅ | +| 用户认证 | 通过 token 验证用户身份 | ✅ | +| 点对点消息 | 发送消息给指定用户 | ✅ | +| 广播消息 | 向所有在线用户发送消息 | ✅ | +| 频道订阅 | 支持频道订阅和取消订阅 | ✅ | +| 在线状态 | 实时获取用户在线状态 | ✅ | + +### 通知功能 + +| 功能 | 说明 | 状态 | +|------|------|------| +| 通知发送 | 支持单个/批量/广播发送 | ✅ | +| 通知类型 | info/success/warning/error/task/system | ✅ | +| 通知分类 | system/task/message/reminder/announcement | ✅ | +| 实时推送 | 在线用户通过 WebSocket 实时推送 | ✅ | +| 持久化存储 | 所有通知保存到数据库 | ✅ | +| 已读管理 | 支持标记已读(单个/批量/全部) | ✅ | +| 通知删除 | 支持删除(单个/批量/清空) | ✅ | +| 重试机制 | 发送失败自动重试(最多3次) | ✅ | +| 统计分析 | 提供通知统计数据 | ✅ | + +--- + +## 技术架构 + +### 后端架构 + +``` +┌─────────────────────────────────────────┐ +│ Laravel + Laravel-S │ +├─────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ WebSocketHandler │ │ +│ │ (WebSocket 事件处理) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ WebSocketService │ │ +│ │ (WebSocket 管理) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ NotificationService │ │ +│ │ (通知服务) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ Notification Model │ │ +│ │ (通知模型) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ Swoole Server │ │ +│ │ (WebSocket 服务器) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ MySQL Database │ │ +│ │ (数据持久化) │ │ +│ └──────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────┘ +``` + +### 前端架构 + +``` +┌─────────────────────────────────────────┐ +│ Vue 3 + Vite │ +├─────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ Userbar Component │ │ +│ │ (通知入口 + 徽章显示) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ Notification Store │ │ +│ │ (Pinia 状态管理) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ System API │ │ +│ │ (接口封装) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ useWebSocket Hook │ │ +│ │ (WebSocket 管理) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ WebSocket Client │ │ +│ └──────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────┘ +``` + +--- + +## 后端实现 + +### 1. 数据模型 + +#### system_notifications 表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | bigint | 主键ID | +| user_id | bigint | 接收通知的用户ID | +| title | varchar(255) | 通知标题 | +| content | text | 通知内容 | +| type | varchar(50) | 通知类型 | +| category | varchar(50) | 通知分类 | +| data | json | 附加数据 | +| action_type | varchar(50) | 操作类型 | +| action_data | text | 操作数据 | +| is_read | boolean | 是否已读 | +| read_at | timestamp | 阅读时间 | +| sent_via_websocket | boolean | 是否已通过WebSocket发送 | +| sent_at | timestamp | 发送时间 | +| retry_count | int | 重试次数 | +| created_at | timestamp | 创建时间 | +| updated_at | timestamp | 更新时间 | +| deleted_at | timestamp | 删除时间(软删除) | + +### 2. 核心服务类 + +#### WebSocketHandler (`app/Services/WebSocket/WebSocketHandler.php`) + +WebSocket 处理器,实现 Swoole 的 `WebSocketHandlerInterface` 接口。 + +**主要方法:** +- `onOpen()`: 处理连接建立事件 +- `onMessage()`: 处理消息接收事件 +- `onClose()`: 处理连接关闭事件 + +**支持的消息类型:** +- `ping/pong`: 心跳检测 +- `heartbeat`: 心跳确认 +- `chat`: 私聊消息 +- `broadcast`: 广播消息 +- `subscribe/unsubscribe`: 频道订阅/取消订阅 + +#### WebSocketService (`app/Services/WebSocket/WebSocketService.php`) + +WebSocket 服务类,提供便捷的 WebSocket 操作方法。 + +**主要方法:** +```php +// 发送消息给指定用户 +sendToUser(int $userId, array $data): bool + +// 发送消息给多个用户 +sendToUsers(array $userIds, array $data): array + +// 广播消息给所有用户 +broadcast(array $data, ?int $excludeUserId = null): int + +// 发送消息到频道 +sendToChannel(string $channel, array $data): int + +// 获取在线用户数 +getOnlineUserCount(): int + +// 检查用户是否在线 +isUserOnline(int $userId): bool + +// 获取在线用户ID列表 +getOnlineUserIds(): array + +// 断开用户连接 +disconnectUser(int $userId): bool + +// 发送系统通知 +sendSystemNotification(string $title, string $content, string $type): int + +// 发送通知给指定用户 +sendNotificationToUsers(array $userIds, string $title, string $content, string $type): int + +// 推送数据更新 +pushDataUpdate(array $userIds, string $resourceType, string $action, array $data): array + +// 推送数据更新到频道 +pushDataUpdateToChannel(string $channel, string $resourceType, string $action, array $data): int +``` + +#### NotificationService (`app/Services/System/NotificationService.php`) + +通知服务类,负责通知的创建、发送和管理。 + +**主要方法:** +```php +// 发送通知给单个用户 +sendToUser(int $userId, string $title, string $content, string $type, string $category, ?array $extraData = null): array + +// 发送通知给多个用户 +sendToUsers(array $userIds, string $title, string $content, string $type, string $category, ?array $extraData = null): array + +// 广播通知给所有用户 +broadcast(string $title, string $content, string $type, string $category, ?array $extraData = null): array + +// 发送任务通知 +sendTaskNotification(int $userId, string $title, string $content, array $taskData): array + +// 发送消息通知 +sendNewMessageNotification(int $userId, string $title, string $content, array $messageData): array + +// 发送提醒通知 +sendReminderNotification(int $userId, string $title, string $content, array $reminderData): array + +// 标记通知为已读 +markAsRead(int $notificationId): bool + +// 批量标记为已读 +markMultipleAsRead(array $notificationIds): int + +// 标记所有通知为已读 +markAllAsRead(int $userId): int + +// 删除通知 +deleteNotification(int $notificationId): bool + +// 批量删除通知 +deleteMultipleNotifications(array $notificationIds): int + +// 清空已读通知 +clearReadNotifications(int $userId): int + +// 重试未发送的通知 +retryUnsentNotifications(int $limit = 100): int + +// 获取通知统计 +getStatistics(int $userId): array +``` + +### 3. 控制器 + +#### NotificationController (`app/Http/Controllers/System/Admin/Notification.php`) + +后台管理通知控制器,提供完整的 CRUD 操作。 + +**主要方法:** +- `index()`: 获取通知列表 +- `show()`: 获取通知详情 +- `read()`: 标记通知为已读 +- `batchRead()`: 批量标记为已读 +- `readAll()`: 标记所有通知为已读 +- `destroy()`: 删除通知 +- `batchDelete()`: 批量删除通知 +- `clearRead()`: 清空已读通知 +- `unread()`: 获取未读通知列表 +- `unreadCount()`: 获取未读通知数量 +- `statistics()`: 获取通知统计 +- `send()`: 发送通知(管理员) +- `retryUnsent()`: 重试未发送的通知(管理员) + +#### WebSocketController (`app/Http/Controllers/System/WebSocket.php`) + +WebSocket API 控制器,提供 HTTP 接口用于管理 WebSocket 连接。 + +**主要方法:** +- `getOnlineCount()`: 获取在线用户数 +- `getOnlineUsers()`: 获取在线用户列表 +- `checkOnline()`: 检查用户在线状态 +- `sendToUser()`: 发送消息给指定用户 +- `sendToUsers()`: 发送消息给多个用户 +- `broadcast()`: 广播消息 +- `sendToChannel()`: 发送消息到频道 +- `sendNotification()`: 发送系统通知 +- `sendNotificationToUsers()`: 发送通知给指定用户 +- `pushDataUpdate()`: 推送数据更新 +- `pushDataUpdateChannel()`: 推送数据更新到频道 +- `disconnectUser()`: 断开用户连接 + +### 4. 定时任务 + +#### RetryUnsentNotifications (`app/Console/Commands/RetryUnsentNotifications.php`) + +自动重试发送失败的通知。 + +**使用方法:** +```bash +# 重试最多100条未发送的通知 +php artisan notifications:retry-unsent + +# 重试最多50条未发送的通知 +php artisan notifications:retry-unsent --limit=50 +``` + +**定时任务配置:** +```php +// config/crontab.php +// 每5分钟重试一次未发送的通知 +*/5 * * * * php /path/to/artisan notifications:retry-unsent --limit=50 +``` + +--- + +## 前端实现 + +### 1. WebSocket 客户端 + +#### WebSocketClient (`resources/admin/src/utils/websocket.js`) + +WebSocket 客户端封装类,提供自动连接、重连、消息处理等功能。 + +**主要功能:** +- 自动连接和重连 +- 心跳机制 +- 消息类型路由 +- 事件监听 +- 连接状态管理 + +**使用示例:** +```javascript +import { getWebSocket, closeWebSocket } from '@/utils/websocket' +import { useUserStore } from '@/stores/modules/user' + +const userStore = useUserStore() + +// 连接 WebSocket +const ws = getWebSocket(userStore.userInfo.id, userStore.token, { + onOpen: (event) => { + console.log('WebSocket 已连接') + }, + onMessage: (message) => { + console.log('收到消息:', message) + }, + onError: (error) => { + console.error('WebSocket 错误:', error) + }, + onClose: (event) => { + console.log('WebSocket 已关闭') + } +}) + +// 连接 +ws.connect() + +// 发送消息 +ws.send('heartbeat', { timestamp: Date.now() }) + +// 监听特定消息类型 +ws.on('notification', (data) => { + message.success(data.title, data.message) +}) + +// 断开连接 +ws.disconnect() +``` + +### 2. 通知 Store + +#### Notification Store (`resources/admin/src/stores/modules/notification.js`) + +通知状态管理,基于 Pinia 实现。 + +**主要方法:** +```javascript +// 获取未读数量 +await notificationStore.fetchUnreadCount() + +// 获取未读通知列表 +await notificationStore.fetchUnreadNotifications({ + page: 1, + page_size: 10 +}) + +// 获取通知列表 +await notificationStore.fetchNotifications({ + page: 1, + page_size: 20, + type: 'notification', + category: 'system' +}) + +// 标记为已读 +await notificationStore.markAsRead(notificationId) + +// 批量标记为已读 +await notificationStore.markMultipleAsRead([1, 2, 3]) + +// 标记所有为已读 +await notificationStore.markAllAsRead() + +// 删除通知 +await notificationStore.deleteNotification(notificationId) + +// 批量删除 +await notificationStore.deleteMultipleNotifications([1, 2, 3]) + +// 清空已读通知 +await notificationStore.clearReadNotifications() + +// 发送通知(管理员) +await notificationStore.sendNotification({ + recipient_id: userId, + title: '通知标题', + content: '通知内容', + type: 'info', + category: 'system' +}) + +// 重试未发送的通知(管理员) +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`) + +顶部用户栏中的通知组件,提供: + +- 通知铃铛图标 +- 未读数量徽章 +- 通知下拉列表 +- 快捷操作(全部已读、清空) +- 通知分类筛选 +- 点击标记已读 + +**功能特性:** +- 未读消息数量实时更新 +- 支持按类型筛选(全部/通知/任务/警告) +- 悬浮显示删除按钮 +- 分页浏览 +- 本地缓存(localStorage) + +### 5. 通知列表页面 + +#### Notification List Page (`resources/admin/src/pages/system/notifications/index.vue`) + +通知管理页面,提供完整的通知管理功能。 + +**功能特性:** +- 通知列表展示 +- 搜索和筛选 +- 批量操作 +- 通知详情查看 +- 已读/未读状态切换 +- 删除和清空 + +--- + +## API接口 + +### WebSocket 接口 + +#### 1. 获取在线用户数 + +``` +GET /admin/websocket/online-count +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "online_count": 10 + } +} +``` + +#### 2. 获取在线用户列表 + +``` +GET /admin/websocket/online-users +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "user_ids": [1, 2, 3, 4, 5], + "count": 5 + } +} +``` + +#### 3. 检查用户在线状态 + +``` +POST /admin/websocket/check-online +``` + +**请求参数:** +```json +{ + "user_id": 1 +} +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "user_id": 1, + "is_online": true + } +} +``` + +#### 4. 发送消息给指定用户 + +``` +POST /admin/websocket/send-to-user +``` + +**请求参数:** +```json +{ + "user_id": 1, + "type": "notification", + "data": { + "title": "新消息", + "message": "您有一条新消息" + } +} +``` + +#### 5. 发送消息给多个用户 + +``` +POST /admin/websocket/send-to-users +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], + "type": "notification", + "data": { + "title": "系统通知", + "message": "系统将在今晚进行维护" + } +} +``` + +#### 6. 广播消息 + +``` +POST /admin/websocket/broadcast +``` + +**请求参数:** +```json +{ + "type": "notification", + "data": { + "title": "公告", + "message": "欢迎使用新版本" + }, + "exclude_user_id": 1 // 可选:排除某个用户 +} +``` + +#### 7. 发送消息到频道 + +``` +POST /admin/websocket/send-to-channel +``` + +**请求参数:** +```json +{ + "channel": "orders", + "type": "data_update", + "data": { + "order_id": 123, + "status": "paid" + } +} +``` + +#### 8. 发送系统通知 + +``` +POST /admin/websocket/send-notification +``` + +**请求参数:** +```json +{ + "title": "系统维护", + "message": "系统将于今晚 23:00-24:00 进行维护", + "type": "warning", + "extra_data": { + "start_time": "23:00", + "end_time": "24:00" + } +} +``` + +#### 9. 发送通知给指定用户 + +``` +POST /admin/websocket/send-notification-to-users +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], + "title": "订单更新", + "message": "您的订单已发货", + "type": "success" +} +``` + +#### 10. 推送数据更新 + +``` +POST /admin/websocket/push-data-update +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], + "resource_type": "order", + "action": "update", + "data": { + "id": 123, + "status": "shipped" + } +} +``` + +#### 11. 推送数据更新到频道 + +``` +POST /admin/websocket/push-data-update-channel +``` + +**请求参数:** +```json +{ + "channel": "orders", + "resource_type": "order", + "action": "create", + "data": { + "id": 124, + "customer": "张三", + "amount": 100.00 + } +} +``` + +#### 12. 断开用户连接 + +``` +POST /admin/websocket/disconnect-user +``` + +**请求参数:** +```json +{ + "user_id": 1 +} +``` + +### 通知接口 + +#### 1. 获取通知列表 + +``` +GET /admin/system/notifications +``` + +**请求参数:** +```json +{ + "user_id": 1, // 用户ID(可选,默认为当前用户) + "keyword": "通知", // 关键字搜索 + "is_read": false, // 阅读状态(true/false) + "type": "info", // 通知类型 + "category": "system", // 通知分类 + "start_date": "2024-01-01", // 开始日期 + "end_date": "2024-12-31", // 结束日期 + "page": 1, // 页码 + "page_size": 20 // 每页数量 +} +``` + +**响应:** +```json +{ + "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 + } +} +``` + +#### 2. 获取未读通知 + +``` +GET /admin/system/notifications/unread?limit=10 +``` + +#### 3. 获取未读通知数量 + +``` +GET /admin/system/notifications/unread-count +``` + +**响应:** +```json +{ + "code": 200, + "message": "success", + "data": { + "count": 5 + } +} +``` + +#### 4. 获取通知详情 + +``` +GET /admin/system/notifications/{id} +``` + +#### 5. 标记通知为已读 + +``` +POST /admin/system/notifications/{id}/read +``` + +#### 6. 批量标记为已读 + +``` +POST /admin/system/notifications/batch-read +``` + +**请求参数:** +```json +{ + "ids": [1, 2, 3, 4, 5] +} +``` + +#### 7. 标记所有通知为已读 + +``` +POST /admin/system/notifications/read-all +``` + +#### 8. 删除通知 + +``` +DELETE /admin/system/notifications/{id} +``` + +#### 9. 批量删除通知 + +``` +POST /admin/system/notifications/batch-delete +``` + +**请求参数:** +```json +{ + "ids": [1, 2, 3] +} +``` + +#### 10. 清空已读通知 + +``` +POST /admin/system/notifications/clear-read +``` + +#### 11. 获取通知统计 + +``` +GET /admin/system/notifications/statistics +``` + +**响应:** +```json +{ + "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 + } + } +} +``` + +#### 12. 发送通知(管理员功能) + +``` +POST /admin/system/notifications/send +``` + +**请求参数:** +```json +{ + "user_ids": [1, 2, 3], // 用户ID数组,为空则发送给所有用户 + "title": "系统维护通知", + "content": "系统将于今晚22:00进行维护,预计维护时间2小时。", + "type": "warning", + "category": "announcement", + "data": { + "maintenance_start": "2024-02-18 22:00:00", + "maintenance_end": "2024-02-19 00:00:00" + }, + "action_type": "link", + "action_data": { + "url": "/system/maintenance" + } +} +``` + +#### 13. 重试发送未发送的通知(管理员功能) + +``` +POST /admin/system/notifications/retry-unsent?limit=100 +``` + +--- + +## 配置说明 + +### 后端配置 + +#### Laravel-S 配置 (`config/laravels.php`) + +```php +'websocket' => [ + '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, // 重要:使用抢占模式确保连接状态一致性 +], + +'swoole_tables' => [ + 'wsTable' => [ + 'size' => 102400, + 'column' => [ + ['name' => 'value', 'type' => \Swoole\Table::TYPE_STRING, 'size' => 1024], + ['name' => 'expiry', 'type' => \Swoole\Table::TYPE_INT, 'size' => 4], + ], + ], +], +``` + +#### 环境变量 + +在 `.env` 文件中添加: + +```env +LARAVELS_WEBSOCKET=true +``` + +**重要配置说明:** + +**dispatch_mode 模式详解** + +| 模式 | 值 | 说明 | 适用场景 | +|------|-----|------|----------| +| 轮询模式 | 1 | 按顺序依次分配请求到 Worker | 需要平均分配负载 | +| 抢占模式 | 2 | 固定 Worker 处理特定连接 | **推荐:保持连接状态一致性** | +| 抢占模式 | 3 | 随机分配请求到 Worker | ❌ 会导致状态不一致 | + +**dispatch_mode = 3 会导致的问题:** +- 请求被随机分配到不同的 Worker 进程 +- 同一用户的连接和消息发送可能被分配到不同 Worker +- wsTable 中的用户连接数据和消息发送操作无法正确匹配 +- 导致消息发送失败、通知无法接收 + +**dispatch_mode = 2 的优势:** +- 确保同一用户的请求始终由同一个 Worker 处理 +- 连接状态保持一致 +- 消息发送可靠 + +### 前端配置 + +#### WebSocket 配置 + +在 `resources/admin/src/utils/websocket.js` 中: + +```javascript +const WS_URL = 'ws://localhost:5200' // WebSocket 服务器地址 +const RECONNECT_INTERVAL = 5000 // 重连间隔(毫秒) +const MAX_RECONNECT_ATTEMPTS = 5 // 最大重连次数 +const HEARTBEAT_INTERVAL = 30000 // 心跳间隔(毫秒) +``` + +#### 通知配置 + +在 `resources/admin/src/stores/modules/notification.js` 中: + +```javascript +const pageSize = 20 // 每页数量 +const maxLocalNotifications = 100 // 本地最大存储数量 +``` + +### Nginx 配置示例 + +```nginx +server { + listen 80; + server_name yourdomain.com; + root /path/to/your/project/public; + + 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; + } + + 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; + } +} +``` + +### 定时任务配置 + +建议配置以下定时任务: + +```php +// config/crontab.php + +// 每5分钟重试一次未发送的通知 +*/5 * * * * php /path/to/artisan notifications:retry-unsent --limit=50 + +// 每天凌晨清理30天前的已读通知 +0 0 * * * php /path/to/artisan notifications:cleanup +``` + +--- + +## 使用示例 + +### 后端使用示例 + +#### 1. 发送通知给单个用户 + +```php +use App\Services\System\NotificationService; +use App\Models\System\Notification; + +class YourService +{ + protected $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] + ); + } +} +``` + +#### 2. 发送通知给多个用户 + +```php +// 发送通知给多个用户 +$result = $this->notificationService->sendToUsers( + $userIds = [1, 2, 3], + $title = '系统维护通知', + $content = '系统将于今晚进行维护', + $type = Notification::TYPE_WARNING, + $category = Notification::CATEGORY_ANNOUNCEMENT +); +``` + +#### 3. 广播通知 + +```php +// 广播通知(所有用户) +$result = $this->notificationService->broadcast( + $title = '新功能上线', + $content = '我们推出了新的功能,快来体验吧!', + $type = Notification::TYPE_INFO, + $category = Notification::CATEGORY_ANNOUNCEMENT +); +``` + +#### 4. 发送任务通知 + +```php +// 发送任务通知 +$result = $this->notificationService->sendTaskNotification( + $userId = 1, + $title = '任务提醒', + $content = '您有一个任务即将到期', + $taskData = [ + 'task_id' => 123, + 'task_name' => '完成报告', + 'due_date' => '2024-02-20' + ] +); +``` + +#### 5. 使用 WebSocket Service + +```php +use App\Services\WebSocket\WebSocketService; + +$wsService = app(WebSocketService::class); + +// 发送给单个用户 +$wsService->sendToUser($userId, [ + 'type' => 'notification', + 'data' => [ + 'title' => '系统通知', + 'content' => '这是一条通知', + 'type' => 'info', + 'timestamp' => time() + ] +]); + +// 发送给多个用户 +$userIds = [1, 2, 3]; +$sentTo = $wsService->sendToUsers($userIds, $data); + +// 广播给所有用户 +$count = $wsService->broadcast($data); + +// 广播给所有用户(排除指定用户) +$count = $wsService->broadcast($data, $excludeUserId); + +// 发送到频道 +$count = $wsService->sendToChannel('orders', [ + 'type' => 'data_update', + 'data' => [ + 'order_id' => 123, + 'status' => 'paid' + ] +]); + +// 推送数据更新 +$sentTo = $wsService->pushDataUpdate( + $userIds, + 'dictionary', + 'update', + ['id' => 1, 'name' => 'test'] +); + +// 检查用户是否在线 +$isOnline = $wsService->isUserOnline($userId); + +// 获取在线用户数量 +$count = $wsService->getOnlineUserCount(); + +// 获取在线用户ID列表 +$userIds = $wsService->getOnlineUserIds(); + +// 断开用户连接 +$wsService->disconnectUser($userId); +``` + +### 前端使用示例 + +#### 1. 基本连接 + +```javascript +import { getWebSocket, closeWebSocket } from '@/utils/websocket' +import { useUserStore } from '@/stores/modules/user' + +const userStore = useUserStore() + +// 连接 WebSocket +const ws = getWebSocket(userStore.userInfo.id, userStore.token, { + onOpen: (event) => { + console.log('WebSocket 已连接') + }, + onMessage: (message) => { + console.log('收到消息:', message) + }, + onError: (error) => { + console.error('WebSocket 错误:', error) + }, + onClose: (event) => { + console.log('WebSocket 已关闭') + } +}) + +// 连接 +ws.connect() +``` + +#### 2. 监听特定消息类型 + +```javascript +// 监听通知消息 +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) +}) +``` + +#### 3. 发送消息 + +```javascript +// 发送心跳 +ws.send('heartbeat', { timestamp: Date.now() }) + +// 发送私聊消息 +ws.send('chat', { + to_user_id: 2, + content: '你好,这是一条私聊消息' +}) + +// 订阅频道 +ws.send('subscribe', { channel: 'orders' }) + +// 取消订阅 +ws.send('unsubscribe', { channel: 'orders' }) + +// 发送广播消息 +ws.send('broadcast', { + message: '这是一条广播消息' +}) +``` + +#### 4. 使用通知 Store + +```javascript +import { useNotificationStore } from '@/stores/modules/notification' + +const notificationStore = useNotificationStore() + +// 获取未读数量 +await notificationStore.fetchUnreadCount() + +// 获取未读通知列表 +const unreadList = await notificationStore.fetchUnreadNotifications({ + page: 1, + page_size: 10 +}) + +// 获取通知列表 +const list = await notificationStore.fetchNotifications({ + page: 1, + page_size: 20, + type: 'notification', + category: 'system' +}) + +// 标记为已读 +await notificationStore.markAsRead(notificationId) + +// 批量标记为已读 +await notificationStore.markMultipleAsRead([1, 2, 3]) + +// 标记所有为已读 +await notificationStore.markAllAsRead() + +// 删除通知 +await notificationStore.deleteNotification(notificationId) + +// 批量删除 +await notificationStore.deleteMultipleNotifications([1, 2, 3]) + +// 清空已读通知 +await notificationStore.clearReadNotifications() + +// 获取统计信息 +const stats = await notificationStore.fetchStatistics() +console.log('统计信息:', stats) +``` + +#### 5. 在 Vue 组件中使用 + +```vue + + + +``` + +--- + +## 故障排查与修复 + +### 常见问题与解决方案 + +#### 问题 1:WebSocket 连接失败 + +**可能原因:** +- WebSocket 服务未启动 +- 端口被占用 +- 防火墙阻止连接 +- URL 配置错误 + +**排查步骤:** +```bash +# 1. 检查 Laravel-S 是否运行 +php bin/laravels status + +# 2. 启动 Laravel-S +php bin/laravels start + +# 3. 检查端口是否被占用 +netstat -ano | findstr :5200 + +# 4. 检查防火墙设置 +# Windows: 控制面板 -> 系统和安全 -> Windows 防火墙 -> 允许应用通过防火墙 +# Linux: sudo ufw allow 5200 +``` + +#### 问题 2:登录后 WebSocket 未连接 + +**可能原因:** +- 用户信息未加载完成 +- token 无效 + +**解决方法:** +```javascript +// 检查控制台日志 +// 确认 userStore.isUserInfoComplete() 返回 true +// 查看 getWebSocket 调用参数 + +// 手动测试连接 +import { useUserStore } from '@/stores/modules/user' + +const userStore = useUserStore() + +console.log('User Info:', userStore.userInfo) +console.log('Token:', userStore.token) +console.log('Is Complete:', userStore.isUserInfoComplete()) +``` + +#### 问题 3:消息未收到 + +**可能原因:** +- 消息处理器未注册 +- 消息类型不匹配 +- 网络问题 +- dispatch_mode 配置错误 + +**排查步骤:** +```bash +# 1. 检查 dispatch_mode 配置 +cat config/laravels.php | grep dispatch_mode +# 应该是 dispatch_mode = 2 + +# 2. 完全重启 Laravel-S +php bin/laravels stop && php bin/laravels start + +# 3. 检查消息处理器 +# 查看 useWebSocket.js 中的消息处理器注册 + +# 4. 查看网络面板 WebSocket 帧 +# 浏览器开发者工具 -> Network -> WS + +# 5. 查看日志 +tail -f storage/logs/swoole.log +tail -f storage/logs/laravel.log +``` + +#### 问题 4:消息发送失败 + +**可能原因:** +- wsTable 访问方式错误 +- 用户不在线 +- dispatch_mode 配置错误 + +**错误信息:** +``` +TypeError: Access to undefined property Swoole\WebSocket\Server::$wsTable +``` + +**正确修复方法:** + +确保通过 `app('swoole')->wsTable` 而不是 `$server->wsTable` 访问。 + +```php +// ❌ 错误:$server 对象没有 wsTable 属性 +$server->wsTable->set('uid:' . $userId, [...]); +$fdInfo = $server->wsTable->get('fd:' . $fd); + +// ✅ 正确:通过服务容器获取 wsTable +$wsTable = app('swoole')->wsTable; +$wsTable->set('uid:' . $userId, [...]); +$fdInfo = $wsTable->get('fd:' . $fd); +``` + +#### 问题 5:dispatch_mode 配置不生效 + +**检查方法:** +```bash +# 1. 确认配置文件 +cat config/laravels.php | grep dispatch_mode + +# 2. 完全重启 Laravel-S +php bin/laravels stop && php bin/laravels start + +# 3. 检查运行时配置 +php bin/laravels config +``` + +**正确配置:** +```php +'swoole' => [ + 'dispatch_mode' => 2, // ✅ 正确:抢占模式 + 'worker_num' => 4, +], +``` + +#### 问题 6:通知未收到 + +**检查项:** +- 用户是否在线 +- WebSocket 连接是否正常 +- 数据库中是否有通知记录 +- `sent_via_websocket` 字段是否为 true + +**排查步骤:** +```bash +# 1. 检查用户在线状态 +php bin/laravels +# 在控制台中执行: +$wsService = app(App\Services\WebSocket\WebSocketService::class); +$wsService->isUserOnline(1); + +# 2. 检查通知记录 +php artisan tinker +>>> $notifications = App\Models\System\Notification::where('user_id', 1)->get(); +>>> $notifications->each(fn($n) => echo "ID: {$n->id}, Sent via WS: {$n->sent_via_websocket}\n"); + +# 3. 检查日志 +tail -f storage/logs/swoole.log | grep "notification" +``` + +#### 问题 7:通知重复发送 + +**检查项:** +- 是否有多个任务在重试 +- `retry_count` 是否超过限制 +- 是否有重复的创建逻辑 + +**解决方法:** +```php +// 检查重试逻辑 +// 通知最多重试3次,超过后不再重试 + +// 检查是否有重复的发送逻辑 +// 确保不会多次调用 sendNotification +``` + +### Laravel-S wsTable 使用规范 + +#### 正确的 wsTable 访问方式 + +根据 Laravel-S 文档和源码,正确访问 wsTable 的方式有两种: + +**方式 1:在 WebSocketHandler 中通过构造函数获取(推荐)** + +```php +class WebSocketHandler implements WebSocketHandlerInterface +{ + protected $wsTable; + + public function __construct() + { + $this->wsTable = app('swoole')->wsTable; + } + + public function onOpen(Server $server, Request $request): void + { + // 直接使用 $this->wsTable + $this->wsTable->set('uid:' . $userId, [...]); + } +} +``` + +**方式 2:在普通 Service 中通过服务容器获取** + +```php +class WebSocketService +{ + public function sendToUser(int $userId, array $data): bool + { + $server = $this->getServer(); + + // 每次都通过服务容器获取最新的 wsTable + $wsTable = app('swoole')->wsTable; + + $fdInfo = $wsTable->get('uid:' . $userId); + // ... + } +} +``` + +#### 错误的访问方式(禁止使用) + +```php +// ❌ 错误:$server 对象没有 wsTable 属性 +$server->wsTable->set('uid:' . $userId, [...]); +$fdInfo = $server->wsTable->get('fd:' . $fd); +``` + +**原因:** +- `$server` 是 Swoole\WebSocket\Server 对象 +- 该对象没有 `wsTable` 属性 +- wsTable 是通过 Laravel-S 扩展动态添加到 Swoole Server 的 +- Laravel-S 通过服务容器管理 wsTable,应该通过 `app('swoole')->wsTable` 访问 + +### Swoole Table 数据结构 + +wsTable 用于存储 WebSocket 连接映射关系: + +```php +'ws' => [ + 'size' => 102400, // 表最大行数 + 'columns' => [ + 'value' => ['type' => \Swoole\Table::TYPE_STRING, 'size' => 256], // 值 + ] +] +``` + +**存储的键值对:** +- `uid:{userId}` → `['value' => {fd}, 'expiry' => timestamp]` - 用户 ID 到文件描述符的映射 +- `fd:{fd}` → `['value' => {userId}, 'expiry' => timestamp]` - 文件描述符到用户 ID 的映射 +- `channel:{channel}:fd:{fd}` → `['value' => 1, 'expiry' => timestamp]` - 频道订阅关系 + +### 多 Worker 进程注意事项 + +当使用多个 Worker 进程时(worker_num > 1): + +1. **进程隔离:** 每个 Worker 有独立的内存空间 +2. **状态同步:** 使用 Swoole Table 实现跨进程数据共享 +3. **连接一致性:** 同一用户的连接必须由同一 Worker 处理 +4. **消息路由:** dispatch_mode = 2 确保连接和消息在同一 Worker + +--- + +## 性能优化 + +### 后端优化 + +#### 1. 数据库优化 + +```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']); +}); +``` + +#### 2. 批量操作 + +```php +// 使用批量插入减少查询次数 +$notifications = []; +foreach ($userIds as $userId) { + $notifications[] = [ + 'user_id' => $userId, + 'title' => $title, + 'content' => $content, + 'type' => $type, + 'category' => $category, + 'created_at' => now(), + 'updated_at' => now(), + ]; +} + +Notification::insert($notifications); +``` + +#### 3. 连接池管理 + +```php +// 定期清理过期连接 +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); + } + } + } +} +``` + +#### 4. 消息队列 + +对于大量消息发送场景,建议使用队列异步处理: + +```php +use Illuminate\Support\Facades\Queue; + +// 异步发送通知 +dispatch(function () use ($userIds, $message) { + $webSocketService = new WebSocketService(); + $webSocketService->sendNotificationToUsers($userIds, $title, $message); +})->onQueue('websocket'); +``` + +#### 5. 缓存优化 + +```php +// 使用 Redis 缓存未读数量 +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(); + }); +} + +// 通知标记为已读时清除缓存 +public function markAsRead(int $notificationId): bool +{ + $notification = Notification::find($notificationId); + $notification->markAsRead(); + + // 清除缓存 + Cache::forget("unread_count:{$notification->user_id}"); + + return true; +} +``` + +### 前端优化 + +#### 1. 消息限制 + +```javascript +// 限制本地存储数量 +const maxLocalNotifications = 100 + +// 添加消息时检查数量 +function addMessage(message) { + if (messages.value.length >= maxLocalNotifications) { + messages.value.pop() // 删除最旧的消息 + } + messages.value.unshift(message) +} +``` + +#### 2. 分页加载 + +```javascript +// 消息列表使用分页,避免一次性加载过多数据 +const currentPage = ref(1) +const pageSize = ref(20) + +async function loadNotifications() { + const response = await api.notifications.get({ + page: currentPage.value, + page_size: pageSize.value + }) + // ... +} +``` + +#### 3. 虚拟滚动 + +对于大量消息列表,使用虚拟滚动提升性能: + +```vue + +``` + +#### 4. 防抖和节流 + +```javascript +// 搜索输入防抖 +import { debounce } from 'lodash-es' + +const handleSearch = debounce((keyword) => { + fetchNotifications({ keyword }) +}, 300) + +// 滚动加载节流 +import { throttle } from 'lodash-es' + +const handleScroll = throttle(() => { + if (isNearBottom()) { + loadMore() + } +}, 500) +``` + +--- + +## 安全考虑 + +### 后端安全 + +#### 1. 连接认证 + +```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 + ]); +} +``` + +#### 2. 消息验证 + +```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; + } + + // 处理消息 + // ... +} +``` + +#### 3. 速率限制 + +```php +// 防止消息滥用 +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; +} +``` + +#### 4. 权限控制 + +```php +// 确保用户只能查看和操作自己的通知 +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); +} +``` + +#### 5. 数据安全 + +- 敏感信息不要放在通知内容中 +- 使用参数验证和过滤 +- SQL 注入防护(使用 Eloquent ORM) +- XSS 防护(使用 `htmlspecialchars` 或类似函数) + +### 前端安全 + +#### 1. Token 管理 + +```javascript +// 不要在前端硬编码 token +// 从 store 中动态获取 +import { useUserStore } from '@/stores/modules/user' + +const userStore = useUserStore() + +const ws = getWebSocket(userStore.userInfo.id, userStore.token) +``` + +#### 2. 消息过滤 + +```javascript +// 处理接收到的消息时,进行过滤和验证 +function handleMessage(message) { + // 验证消息格式 + if (!message || !message.type || !message.data) { + console.warn('Invalid message format:', message) + return + } + + // 过滤敏感内容 + if (containsSensitiveContent(message.data)) { + console.warn('Message contains sensitive content') + return + } + + // 处理消息 + // ... +} +``` + +#### 3. 连接限制 + +```javascript +// 限制每个用户的连接数量 +const MAX_CONNECTIONS_PER_USER = 3 + +function canConnect(userId) { + const existingConnections = getConnectionsByUser(userId) + return existingConnections.length < MAX_CONNECTIONS_PER_USER +} +``` + +--- + +## 最佳实践 + +### 1. 通知类型选择 + +- **info**: 一般信息,如欢迎消息、功能更新 +- **success**: 成功操作,如创建成功、导入成功 +- **warning**: 警告信息,如即将过期、维护通知 +- **error**: 错误信息,如执行失败、验证错误 +- **task**: 任务相关,如任务提醒、执行结果 +- **system**: 系统级,如系统维护、重要公告 + +### 2. 通知分类选择 + +- **system**: 系统管理、配置变更 +- **task**: 定时任务、后台任务 +- **message**: 用户消息、聊天消息 +- **reminder**: 日程提醒、待办事项 +- **announcement**: 公告、通知 + +### 3. 避免通知轰炸 + +- 合理设置通知频率 +- 对相似通知进行合并 +- 提供通知偏好设置 +- 允许用户关闭特定类型的通知 + +### 4. 异步处理 + +```php +// 大量通知建议使用队列异步处理 +use App\Jobs\SendNotificationJob; + +// 发送大量通知 +dispatch(new SendNotificationJob( + $userIds, + $title, + $content, + $type, + $category +))->onQueue('notifications'); +``` + +### 5. 错误处理 + +```javascript +// 前端错误处理 +async function markAsRead(notificationId) { + try { + await api.notifications.read(notificationId) + // 更新本地状态 + } catch (error) { + console.error('标记已读失败:', error) + // 显示错误提示 + message.error('标记已读失败,请重试') + } +} +``` + +### 6. 日志记录 + +```php +// 后端日志记录 +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; +} +``` + +### 7. 测试 + +```php +// 单元测试示例 +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' + ]); +} +``` + +--- + +## 更新日志 + +### 2024-02-18 + +- ✅ 初始版本发布 +- ✅ 实现基础 WebSocket 功能 +- ✅ 实现通知系统功能 +- ✅ 实现消息推送功能 +- ✅ 实现频道订阅功能 +- ✅ 实现前端客户端封装 +- ✅ 实现管理 API 接口 +- ✅ 修复 dispatch_mode 配置问题 +- ✅ 修复 wsTable 访问问题 +- ✅ 添加定时任务重试功能 + +--- + +## 参考资料 + +- [Laravel-S 文档](https://github.com/hhxsv5/laravel-s) +- [Swoole 文档](https://www.swoole.com/) +- [WebSocket API](https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket) +- [JWT-Auth 文档](https://github.com/tymondesigns/jwt-auth) +- [Laravel Modules 文档](https://nwidart.com/laravel-modules/) + +--- + +**文档版本:** v2.0 +**更新日期:** 2024-02-18 +**维护者:** Development Team diff --git a/docs/WEBSOCKET_FEATURE.md b/docs/WEBSOCKET_FEATURE.md deleted file mode 100644 index fb93e31..0000000 --- a/docs/WEBSOCKET_FEATURE.md +++ /dev/null @@ -1,444 +0,0 @@ -# WebSocket 功能文档 - -## 概述 - -本系统使用 WebSocket 实现实时通信功能,支持消息推送、数据更新通知等实时功能。 - -## 技术栈 - -- **前端**: 原生 WebSocket API + Vue 3 Composable -- **后端**: Laravel-S (Swoole) WebSocket Server -- **协议**: WS/WSS - -## 功能特性 - -### 1. 自动连接管理 - -- 登录后自动建立 WebSocket 连接 -- 用户信息加载完成后自动重试连接 -- 支持断线自动重连(最多 5 次) -- 退出登录时自动关闭连接 - -### 2. 消息推送 - -- 系统通知推送 -- 数据更新通知 -- 字典数据更新通知 -- 任务提醒 - -### 3. 心跳机制 - -- 客户端每 30 秒发送心跳 -- 保持连接活跃状态 -- 检测连接状态 - -### 4. 消息管理 - -- 消息持久化存储(localStorage) -- 未读消息计数 -- 消息分类筛选 -- 消息分页显示 -- 标记已读/删除消息 - -## 前端使用 - -### 1. 在组件中使用 WebSocket - -```javascript -import { useWebSocket } from '@/composables/useWebSocket' - -const { initWebSocket, closeWebSocket, isConnected, send } = useWebSocket() - -// 初始化连接 -onMounted(() => { - initWebSocket() -}) - -// 检查连接状态 -if (isConnected()) { - console.log('WebSocket 已连接') -} - -// 发送消息 -send('message_type', { data: 'your data' }) - -// 关闭连接 -onUnmounted(() => { - closeWebSocket() -}) -``` - -### 2. 在 App.vue 中自动初始化 - -WebSocket 已在 App.vue 中自动集成,无需手动调用: - -- 监听用户信息变化,自动初始化连接 -- 组件卸载时自动关闭连接 -- 消息数据自动恢复 - -### 3. 使用消息 Store - -```javascript -import { useMessageStore } from '@/stores/modules/message' - -const messageStore = useMessageStore() - -// 添加消息 -messageStore.addMessage({ - type: 'notification', - title: '系统通知', - content: '这是一条通知' -}) - -// 标记已读 -messageStore.markAsRead(messageId) - -// 标记所有已读 -messageStore.markAllAsRead() - -// 删除消息 -messageStore.removeMessage(messageId) - -// 清空所有消息 -messageStore.clearAll() - -// 获取消息列表(分页) -const { list, total, page } = messageStore.getMessages({ - page: 1, - pageSize: 10, - type: 'notification' // 可选:按类型过滤 -}) - -// 格式化消息时间 -const timeStr = messageStore.formatMessageTime(timestamp) -``` - -## 消息类型 - -### 支持的消息类型 - -| 类型 | 说明 | 枚举值 | -|------|------|--------| -| 系统通知 | 系统级别的通知消息 | `notification` | -| 任务提醒 | 任务相关提醒 | `task` | -| 警告消息 | 警告类消息 | `warning` | -| 错误消息 | 错误类消息 | `error` | -| 成功消息 | 成功类消息 | `success` | -| 信息消息 | 一般信息 | `info` | - -### 消息优先级 - -| 优先级 | 说明 | 枚举值 | -|--------|------|--------| -| 低 | 低优先级消息 | `low` | -| 中 | 中等优先级消息 | `medium` | -| 高 | 高优先级消息 | `high` | -| 紧急 | 紧急消息 | `urgent` | - -## WebSocket 消息格式 - -### 客户端发送格式 - -```json -{ - "type": "message_type", - "data": { - "key": "value" - } -} -``` - -### 服务端推送格式 - -```json -{ - "type": "notification", - "data": { - "title": "消息标题", - "content": "消息内容", - "type": "success", - "timestamp": 1234567890 - } -} -``` - -### 数据更新消息格式 - -```json -{ - "type": "data_update", - "data": { - "resource_type": "dictionary", - "action": "update", - "data": {}, - "timestamp": 1234567890 - } -} -``` - -## 后端使用 - -### 1. 发送消息给特定用户 - -```php -use App\Services\WebSocket\WebSocketService; - -$wsService = app(WebSocketService::class); - -// 发送给单个用户 -$wsService->sendToUser($userId, [ - 'type' => 'notification', - 'data' => [ - 'title' => '系统通知', - 'content' => '这是一条通知', - 'type' => 'info', - 'timestamp' => time() - ] -]); - -// 发送给多个用户 -$userIds = [1, 2, 3]; -$sentTo = $wsService->sendToUsers($userIds, $data); -``` - -### 2. 广播消息 - -```php -// 广播给所有在线用户 -$count = $wsService->broadcast([ - 'type' => 'notification', - 'data' => [ - 'title' => '系统维护通知', - 'content' => '系统将在 10 分钟后进行维护', - 'type' => 'warning', - 'timestamp' => time() - ] -]); - -// 广播给所有在线用户(排除指定用户) -$count = $wsService->broadcast($data, $excludeUserId); -``` - -### 3. 发送系统通知 - -```php -// 发送系统通知 -$count = $wsService->sendSystemNotification( - '新版本发布', - '系统已更新到 v2.0', - 'success' -); - -// 发送通知给特定用户 -$count = $wsService->sendNotificationToUsers( - [1, 2, 3], - '任务分配', - '您有新的待处理任务', - 'task' -); -``` - -### 4. 发送数据更新通知 - -```php -// 发送数据更新给特定用户 -$userIds = [1, 2, 3]; -$sentTo = $wsService->pushDataUpdate( - $userIds, - 'dictionary', - 'update', - ['id' => 1, 'name' => 'test'] -); - -// 发送数据更新到频道 -$count = $wsService->pushDataUpdateToChannel( - 'system_admin', - 'user', - 'create', - ['id' => 10, 'username' => 'newuser'] -); -``` - -### 5. 检查用户在线状态 - -```php -// 检查用户是否在线 -$isOnline = $wsService->isUserOnline($userId); - -// 获取在线用户数量 -$count = $wsService->getOnlineUserCount(); - -// 获取所有在线用户 ID -$userIds = $wsService->getOnlineUserIds(); - -// 强制断开用户连接 -$wsService->disconnectUser($userId); -``` - -## 顶部消息组件 - -### 功能说明 - -顶部消息组件位于 `layouts/components/userbar.vue`,提供以下功能: - -1. **消息通知铃铛** - - 显示未读消息数量 - - 点击打开消息列表 - -2. **消息列表** - - 按类型筛选消息(全部/通知/任务/警告) - - 显示消息标题、内容、时间 - - 点击消息标记为已读 - - 悬浮显示删除按钮 - - 分页浏览 - -3. **操作按钮** - - 全部标为已读 - - 清空全部消息 - -### 消息样式 - -- **未读消息**: 蓝色背景 + 左侧蓝条 + 红点标记 -- **已读消息**: 普通样式 -- **删除按钮**: 悬浮时显示 - -## 配置说明 - -### WebSocket URL 配置 - -在 `resources/admin/src/config/index.js` 中配置: - -```javascript -export default { - // WebSocket URL(如果不配置则使用当前域名) - WS_URL: 'ws://localhost:8080', - - // 其他配置... -} -``` - -### 后端 WebSocket 配置 - -在 `config/laravels.php` 中配置: - -```php -'swoole' => [ - 'enable_coroutine' => true, - 'worker_num' => 4, - 'max_request' => 5000, - 'max_request_grace' => 500, - // ... 其他配置 -] -``` - -## 故障排查 - -### 1. WebSocket 连接失败 - -**可能原因**: -- WebSocket 服务未启动 -- 端口被占用 -- 防火墙阻止连接 -- URL 配置错误 - -**解决方法**: -```bash -# 检查 Laravel-S 是否运行 -php bin/laravels status - -# 启动 Laravel-S -php bin/laravels start - -# 检查端口是否被占用 -netstat -ano | findstr :8080 -``` - -### 2. 登录后 WebSocket 未连接 - -**可能原因**: -- 用户信息未加载完成 -- token 无效 - -**解决方法**: -- 检查控制台日志 -- 确认 `userStore.isUserInfoComplete()` 返回 true -- 查看 `getWebSocket` 调用参数 - -### 3. 消息未收到 - -**可能原因**: -- 消息处理器未注册 -- 消息类型不匹配 -- 网络问题 - -**解决方法**: -- 检查 `useWebSocket.js` 中的消息处理器注册 -- 确认消息类型格式正确 -- 查看网络面板 WebSocket 帧 - -## 开发建议 - -### 1. 测试 WebSocket 功能 - -```javascript -// 在浏览器控制台测试 -import { useWebSocket } from '@/composables/useWebSocket' -import { useMessageStore } from '@/stores/modules/message' - -const { send } = useWebSocket() -const messageStore = useMessageStore() - -// 手动添加测试消息 -messageStore.addMessage({ - type: 'notification', - title: '测试消息', - content: '这是一条测试消息', - timestamp: Date.now() -}) - -// 发送测试消息到服务器 -send('test', { message: 'hello' }) -``` - -### 2. 添加新的消息类型 - -1. 在 `message.js` store 中添加类型枚举 -2. 在 `userbar.vue` 中添加对应的 Tab -3. 在 `i18n` 中添加翻译 -4. 在 `useWebSocket.js` 中添加处理逻辑 - -### 3. 自定义消息处理 - -```javascript -// 在 useWebSocket.js 中注册自定义处理器 -ws.value.on('custom_event', handleCustomEvent) - -function handleCustomEvent(data) { - console.log('收到自定义消息:', data) - // 处理逻辑 -} -``` - -## 性能优化 - -1. **消息限制**: 最多存储 100 条消息,超出后自动删除旧消息 -2. **分页加载**: 消息列表使用分页,避免一次性加载过多数据 -3. **心跳机制**: 保持连接活跃,减少不必要的重连 -4. **延迟加载**: 用户信息加载完成后才初始化连接 - -## 安全考虑 - -1. **Token 验证**: WebSocket 连接时发送 token 进行验证 -2. **用户隔离**: 每个用户只能接收自己的消息 -3. **消息过滤**: 根据权限过滤敏感消息 -4. **连接限制**: 限制单个用户的连接数量 - -## 更新日志 - -### v1.0.0 (2024-01-18) -- 初始版本 -- 实现基础 WebSocket 连接功能 -- 实现消息推送和接收 -- 实现消息管理 Store -- 实现顶部消息组件 -- 支持中英文国际化 diff --git a/resources/admin/src/api/system.js b/resources/admin/src/api/system.js index ec99c69..c5971fb 100644 --- a/resources/admin/src/api/system.js +++ b/resources/admin/src/api/system.js @@ -338,6 +338,75 @@ export default { }, }, + // 通知管理 + notifications: { + list: { + get: async function (params) { + return await request.get('system/notifications', { params }) + }, + }, + unread: { + get: async function (params) { + return await request.get('system/notifications/unread', { params }) + }, + }, + unreadCount: { + get: async function () { + return await request.get('system/notifications/unread-count') + }, + }, + detail: { + get: async function (id) { + return await request.get(`system/notifications/${id}`) + }, + }, + markAsRead: { + post: async function (id) { + return await request.post(`system/notifications/${id}/read`) + }, + }, + batchMarkAsRead: { + post: async function (params) { + return await request.post('system/notifications/batch-read', params) + }, + }, + markAllAsRead: { + post: async function () { + return await request.post('system/notifications/read-all') + }, + }, + delete: { + delete: async function (id) { + return await request.delete(`system/notifications/${id}`) + }, + }, + batchDelete: { + post: async function (params) { + return await request.post('system/notifications/batch-delete', params) + }, + }, + clearRead: { + post: async function () { + return await request.post('system/notifications/clear-read') + }, + }, + statistics: { + get: async function () { + return await request.get('system/notifications/statistics') + }, + }, + send: { + post: async function (params) { + return await request.post('system/notifications/send', params) + }, + }, + retryUnsent: { + post: async function (params) { + return await request.post('system/notifications/retry-unsent', params) + }, + }, + }, + // 公共接口 (无需认证) public: { configs: { diff --git a/resources/admin/src/composables/useWebSocket.js b/resources/admin/src/composables/useWebSocket.js index caaa465..8151a5c 100644 --- a/resources/admin/src/composables/useWebSocket.js +++ b/resources/admin/src/composables/useWebSocket.js @@ -3,6 +3,7 @@ import { getWebSocket } from '@/utils/websocket' import { useUserStore } from '@/stores/modules/user' import { useMessageStore } from '@/stores/modules/message' import { useDictionaryStore } from '@/stores/modules/dictionary' +import { useNotificationStore } from '@/stores/modules/notification' import { message, notification } from 'ant-design-vue' import config from '@/config' @@ -16,6 +17,7 @@ export function useWebSocket() { const userStore = useUserStore() const messageStore = useMessageStore() const dictionaryStore = useDictionaryStore() + const notificationStore = useNotificationStore() const reconnectTimer = ref(null) /** @@ -167,9 +169,27 @@ export function useWebSocket() { function handleNotification(data) { console.log('收到系统通知:', data) - const { title, message: content, type, timestamp } = data + const { title, message: content, type, timestamp, ...rest } = data - // 添加到消息 store + // 构建通知数据格式 + const notificationData = { + id: rest.id || Date.now(), + title: title || '系统通知', + content: content || '', + type: type || 'info', + category: rest.category || 'system', + is_read: false, + created_at: rest.created_at || new Date().toISOString(), + ...rest + } + + // 更新通知 store + notificationStore.handleWebSocketMessage({ + type: 'notification', + data: notificationData + }) + + // 添加到消息 store(保持兼容性) messageStore.addMessage({ type: type || 'notification', title: title || '系统通知', diff --git a/resources/admin/src/layouts/components/userbar.vue b/resources/admin/src/layouts/components/userbar.vue index 4a379fc..3ad7c3b 100644 --- a/resources/admin/src/layouts/components/userbar.vue +++ b/resources/admin/src/layouts/components/userbar.vue @@ -8,7 +8,7 @@ - + @@ -51,9 +51,9 @@
{{ msg.title }}
{{ msg.content }}
-
{{ messageStore.formatMessageTime(msg.timestamp) }}
+
{{ notificationStore.formatNotificationTime(msg.created_at) }}
- + { - const result = messageStore.getMessages({ - page: messagesPage.value, - pageSize: messagesPageSize.value, - type: currentMessageType.value === 'all' ? null : currentMessageType.value - }) - return result.list -}) - -// 消息总数(用于分页) -const messagesTotal = computed(() => { - return messageStore.getMessages({ - page: messagesPage.value, - pageSize: messagesPageSize.value, - type: currentMessageType.value === 'all' ? null : currentMessageType.value - }).total -}) +const notificationsList = ref([]) // 未读消息数量 -const messageCount = computed(() => messageStore.unreadCount) +const messageCount = computed(() => notificationStore.unreadCount) + +// 消息总数(用于分页) +const messagesTotal = computed(() => notificationStore.total) + +// 从 store 获取消息数据 +const messages = computed(() => notificationsList.value) // 任务数据 const tasks = ref([ @@ -232,8 +221,58 @@ const handleFullscreenChange = () => { isFullscreen.value = !!document.fullscreenElement } +// 加载未读通知 +const loadNotifications = async () => { + try { + await notificationStore.fetchUnreadCount() + await loadUnreadNotifications() + } catch (error) { + console.error('加载通知失败:', error) + } +} + +// 加载未读通知列表 +const loadUnreadNotifications = async () => { + try { + const res = await notificationStore.fetchUnreadNotifications({ + page: messagesPage.value, + page_size: messagesPageSize.value, + type: currentMessageType.value === 'all' ? null : currentMessageType.value + }) + notificationsList.value = res.list || [] + } catch (error) { + console.error('加载未读通知列表失败:', error) + } +} + +// 加载所有通知 +const loadAllNotifications = async () => { + try { + await notificationStore.fetchNotifications({ + page: messagesPage.value, + page_size: messagesPageSize.value, + type: currentMessageType.value === 'all' ? null : currentMessageType.value + }) + notificationsList.value = notificationStore.notifications + } catch (error) { + console.error('加载通知列表失败:', error) + } +} + +// WebSocket 消息处理 +const handleWebSocketMessage = (data) => { + notificationStore.handleWebSocketMessage(data) +} + onMounted(() => { document.addEventListener('fullscreenchange', handleFullscreenChange) + + // 加载通知数据 + loadNotifications() + + // 连接 WebSocket + const { initWebSocket } = useWebSocket() + initWebSocket() }) onUnmounted(() => { @@ -246,46 +285,113 @@ const showSearch = () => { } // 清除消息 -const clearMessages = () => { +const clearMessages = async () => { Modal.confirm({ title: t('common.confirmClear'), content: t('common.confirmClearMessages'), okText: t('common.confirm'), cancelText: t('common.cancel'), - onOk: () => { - messageStore.clearAll() - message.success(t('common.cleared')) + onOk: async () => { + try { + await notificationStore.clearReadNotifications() + message.success(t('common.cleared')) + notificationsList.value = [] + } catch (error) { + console.error('清空消息失败:', error) + } }, }) } // 标记消息为已读 -const handleMessageRead = (msg) => { - if (!msg.read) { - messageStore.markAsRead(msg.id) +const handleMessageRead = async (msg) => { + if (!msg.is_read) { + try { + await notificationStore.markAsRead(msg.id) + // 更新本地状态 + const notification = notificationsList.value.find(n => n.id === msg.id) + if (notification) { + notification.is_read = true + notification.read_at = new Date().toISOString() + } + } catch (error) { + console.error('标记已读失败:', error) + } } } // 标记所有消息为已读 -const markAllAsRead = () => { - messageStore.markAllAsRead() - message.success(t('common.markedAsRead')) +const markAllAsRead = async () => { + Modal.confirm({ + title: '确认全部已读', + content: '确定要将所有消息标记为已读吗?', + okText: '确定', + cancelText: '取消', + onOk: async () => { + try { + await notificationStore.markAllAsRead() + message.success(t('common.markedAsRead')) + // 更新本地状态 + notificationsList.value.forEach(n => { + n.is_read = true + n.read_at = n.read_at || new Date().toISOString() + }) + } catch (error) { + console.error('标记全部已读失败:', error) + } + } + }) } // 删除消息 -const handleDeleteMessage = (msgId) => { - messageStore.removeMessage(msgId) +const handleDeleteMessage = async (msgId) => { + Modal.confirm({ + title: '确认删除', + content: '确定要删除这条消息吗?', + okText: '确定', + cancelText: '取消', + onOk: async () => { + try { + await notificationStore.deleteNotification(msgId) + message.success('删除成功') + // 更新本地状态 + const index = notificationsList.value.findIndex(n => n.id === msgId) + if (index !== -1) { + notificationsList.value.splice(index, 1) + } + } catch (error) { + console.error('删除消息失败:', error) + } + } + }) } // 切换消息类型 -const changeMessageType = (type) => { +const changeMessageType = async (type) => { currentMessageType.value = type messagesPage.value = 1 + if (type === 'all') { + await loadAllNotifications() + } else { + await loadUnreadNotifications() + } } // 分页变化 -const handleMessagePageChange = (page) => { +const handleMessagePageChange = async (page) => { messagesPage.value = page + if (currentMessageType.value === 'all') { + await loadAllNotifications() + } else { + await loadUnreadNotifications() + } +} + +// 下拉框打开时加载数据 +const handleMessageDropdownOpen = async (open) => { + if (open) { + await loadNotifications() + } } // 显示任务抽屉 diff --git a/resources/admin/src/pages/system/notifications/index.vue b/resources/admin/src/pages/system/notifications/index.vue new file mode 100644 index 0000000..7b842f7 --- /dev/null +++ b/resources/admin/src/pages/system/notifications/index.vue @@ -0,0 +1,643 @@ + + + + + diff --git a/resources/admin/src/stores/modules/notification.js b/resources/admin/src/stores/modules/notification.js new file mode 100644 index 0000000..bf687dd --- /dev/null +++ b/resources/admin/src/stores/modules/notification.js @@ -0,0 +1,431 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' +import { customStorage } from '../persist' +import systemApi from '@/api/system' +import { message } from 'ant-design-vue' + +/** + * 通知类型枚举 + */ +export const NotificationType = { + INFO: 'info', // 信息 + SUCCESS: 'success', // 成功 + WARNING: 'warning', // 警告 + ERROR: 'error', // 错误 + TASK: 'task', // 任务 + SYSTEM: 'system' // 系统 +} + +/** + * 通知分类枚举 + */ +export const NotificationCategory = { + SYSTEM: 'system', // 系统通知 + TASK: 'task', // 任务通知 + MESSAGE: 'message', // 消息通知 + REMINDER: 'reminder', // 提醒通知 + ANNOUNCEMENT: 'announcement' // 公告通知 +} + +/** + * 通知操作类型枚举 + */ +export const NotificationActionType = { + NONE: 'none', // 无操作 + LINK: 'link', // 跳转链接 + MODAL: 'modal' // 打开弹窗 +} + +export const useNotificationStore = defineStore( + 'notification', + () => { + // 通知列表 + const notifications = ref([]) + + // 未读数量 + const unreadCount = ref(0) + + // 加载状态 + const loading = ref(false) + + // 当前页码 + const currentPage = ref(1) + + // 每页数量 + const pageSize = ref(20) + + // 总数量 + const total = ref(0) + + // 获取未读数量(计算属性) + const unreadCountComputed = computed(() => unreadCount.value) + + // 获取已读数量 + const readCount = computed(() => total.value - unreadCount.value) + + // 获取通知列表 + async function fetchNotifications(params = {}) { + try { + loading.value = true + const res = await systemApi.notifications.list.get({ + page: params.page || currentPage.value, + page_size: params.page_size || pageSize.value, + ...params + }) + + notifications.value = res.data.list || [] + total.value = res.data.total || 0 + currentPage.value = res.data.page || 1 + pageSize.value = res.data.page_size || 20 + + return res.data + } catch (error) { + message.error(error.message || '获取通知列表失败') + throw error + } finally { + loading.value = false + } + } + + // 获取未读通知列表 + async function fetchUnreadNotifications(params = {}) { + try { + loading.value = true + const res = await systemApi.notifications.unread.get({ + page: params.page || 1, + page_size: params.page_size || 10, + ...params + }) + + return res.data + } catch (error) { + message.error(error.message || '获取未读通知失败') + throw error + } finally { + loading.value = false + } + } + + // 获取未读数量 + async function fetchUnreadCount() { + try { + const res = await systemApi.notifications.unreadCount.get() + unreadCount.value = res.data.count || 0 + return unreadCount.value + } catch (error) { + console.error('获取未读数量失败:', error) + return 0 + } + } + + // 标记为已读 + async function markAsRead(id) { + try { + await systemApi.notifications.markAsRead.post(id) + + // 更新本地状态 + const notification = notifications.value.find(n => n.id === id) + if (notification && !notification.is_read) { + notification.is_read = true + notification.read_at = new Date().toISOString() + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + + return true + } catch (error) { + message.error(error.message || '标记已读失败') + throw error + } + } + + // 批量标记为已读 + async function batchMarkAsRead(ids) { + try { + const res = await systemApi.notifications.batchMarkAsRead.post({ ids }) + + // 更新本地状态 + ids.forEach(id => { + const notification = notifications.value.find(n => n.id === id) + if (notification && !notification.is_read) { + notification.is_read = true + notification.read_at = new Date().toISOString() + } + }) + + // 更新未读数量 + unreadCount.value = Math.max(0, unreadCount.value - (res.data.count || ids.length)) + + return res.data + } catch (error) { + message.error(error.message || '批量标记已读失败') + throw error + } + } + + // 标记全部为已读 + async function markAllAsRead() { + try { + await systemApi.notifications.markAllAsRead.post() + + // 更新本地状态 + notifications.value.forEach(n => { + n.is_read = true + n.read_at = n.read_at || new Date().toISOString() + }) + unreadCount.value = 0 + + return true + } catch (error) { + message.error(error.message || '标记全部已读失败') + throw error + } + } + + // 删除通知 + async function deleteNotification(id) { + try { + await systemApi.notifications.delete.delete(id) + + // 更新本地状态 + const index = notifications.value.findIndex(n => n.id === id) + if (index !== -1) { + const notification = notifications.value[index] + if (!notification.is_read) { + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + notifications.value.splice(index, 1) + total.value = Math.max(0, total.value - 1) + } + + return true + } catch (error) { + message.error(error.message || '删除通知失败') + throw error + } + } + + // 批量删除通知 + async function batchDeleteNotification(ids) { + try { + const res = await systemApi.notifications.batchDelete.post({ ids }) + + // 更新本地状态 + const deletedCount = 0 + ids.forEach(id => { + const index = notifications.value.findIndex(n => n.id === id) + if (index !== -1) { + const notification = notifications.value[index] + if (!notification.is_read) { + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + notifications.value.splice(index, 1) + } + }) + total.value = Math.max(0, total.value - ids.length) + + return res.data + } catch (error) { + message.error(error.message || '批量删除通知失败') + throw error + } + } + + // 清空已读通知 + async function clearReadNotifications() { + try { + await systemApi.notifications.clearRead.post() + + // 更新本地状态 + notifications.value = notifications.value.filter(n => !n.is_read) + total.value = notifications.value.length + + return true + } catch (error) { + message.error(error.message || '清空已读通知失败') + throw error + } + } + + // 获取通知详情 + async function getNotificationDetail(id) { + try { + const res = await systemApi.notifications.detail.get(id) + return res.data + } catch (error) { + message.error(error.message || '获取通知详情失败') + throw error + } + } + + // 发送通知(管理员功能) + async function sendNotification(params) { + try { + const res = await systemApi.notifications.send.post(params) + message.success('发送通知成功') + return res.data + } catch (error) { + message.error(error.message || '发送通知失败') + throw error + } + } + + // 重试发送失败的通知(管理员功能) + async function retryUnsentNotifications(params = {}) { + try { + const res = await systemApi.notifications.retryUnsent.post(params) + message.success('重试发送成功') + return res.data + } catch (error) { + message.error(error.message || '重试发送失败') + throw error + } + } + + // 获取通知统计信息 + async function fetchStatistics() { + try { + const res = await systemApi.notifications.statistics.get() + return res.data + } catch (error) { + message.error(error.message || '获取统计信息失败') + throw error + } + } + + // 处理 WebSocket 消息 + function handleWebSocketMessage(data) { + if (data.type === 'notification') { + const notification = data.data + + // 添加到通知列表顶部 + notifications.value.unshift(notification) + total.value++ + + // 如果是未读,增加未读数量 + if (!notification.is_read) { + unreadCount.value++ + } + + // 限制本地存储的通知数量 + if (notifications.value.length > 100) { + notifications.value = notifications.value.slice(0, 100) + } + + // 显示通知提示 + message.info(`新通知: ${notification.title}`) + } + } + + // 格式化通知时间 + function formatNotificationTime(timestamp) { + if (!timestamp) return '-' + + const now = Date.now() + const diff = now - new Date(timestamp).getTime() + + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + + if (diff < minute) { + return '刚刚' + } else if (diff < hour) { + return `${Math.floor(diff / minute)}分钟前` + } else if (diff < day) { + return `${Math.floor(diff / hour)}小时前` + } else if (diff < 7 * day) { + return `${Math.floor(diff / day)}天前` + } else { + const date = new Date(timestamp) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + } + } + + // 获取通知类型文本 + function getNotificationTypeText(type) { + const texts = { + info: '信息', + success: '成功', + warning: '警告', + error: '错误', + task: '任务', + system: '系统' + } + return texts[type] || type + } + + // 获取通知类型颜色 + function getNotificationTypeColor(type) { + const colors = { + info: 'blue', + success: 'green', + warning: 'orange', + error: 'red', + task: 'purple', + system: 'cyan' + } + return colors[type] || 'default' + } + + // 获取通知分类文本 + function getNotificationCategoryText(category) { + const texts = { + system: '系统通知', + task: '任务通知', + message: '消息通知', + reminder: '提醒通知', + announcement: '公告通知' + } + return texts[category] || category + } + + // 重置状态 + function reset() { + notifications.value = [] + unreadCount.value = 0 + currentPage.value = 1 + total.value = 0 + loading.value = false + } + + return { + notifications, + unreadCount, + loading, + currentPage, + pageSize, + total, + unreadCountComputed, + readCount, + fetchNotifications, + fetchUnreadNotifications, + fetchUnreadCount, + markAsRead, + batchMarkAsRead, + markAllAsRead, + deleteNotification, + batchDeleteNotification, + clearReadNotifications, + getNotificationDetail, + sendNotification, + retryUnsentNotifications, + fetchStatistics, + handleWebSocketMessage, + formatNotificationTime, + getNotificationTypeText, + getNotificationTypeColor, + getNotificationCategoryText, + reset, + NotificationType, + NotificationCategory, + NotificationActionType + } + }, + { + persist: { + key: 'notification-store', + storage: customStorage, + pick: [] // 不自动持久化,通知数据从服务器获取 + } + } +) diff --git a/routes/admin.php b/routes/admin.php index 06aa0d4..965db12 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -173,6 +173,23 @@ Route::middleware(['auth.check:admin', 'log.request'])->group(function () { Route::post('/delete', [\App\Http\Controllers\System\Admin\Upload::class, 'delete']); Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Upload::class, 'batchDelete']); }); + + // 通知管理 + Route::prefix('notifications')->group(function () { + Route::get('/', [\App\Http\Controllers\System\Admin\Notification::class, 'index']); + Route::get('/unread', [\App\Http\Controllers\System\Admin\Notification::class, 'unread']); + Route::get('/unread-count', [\App\Http\Controllers\System\Admin\Notification::class, 'unreadCount']); + Route::get('/{id}', [\App\Http\Controllers\System\Admin\Notification::class, 'show']); + Route::post('/{id}/read', [\App\Http\Controllers\System\Admin\Notification::class, 'markAsRead']); + Route::post('/batch-read', [\App\Http\Controllers\System\Admin\Notification::class, 'batchMarkAsRead']); + Route::post('/read-all', [\App\Http\Controllers\System\Admin\Notification::class, 'markAllAsRead']); + Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Notification::class, 'destroy']); + Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Notification::class, 'batchDelete']); + Route::post('/clear-read', [\App\Http\Controllers\System\Admin\Notification::class, 'clearRead']); + Route::get('/statistics', [\App\Http\Controllers\System\Admin\Notification::class, 'statistics']); + Route::post('/send', [\App\Http\Controllers\System\Admin\Notification::class, 'send']); + Route::post('/retry-unsent', [\App\Http\Controllers\System\Admin\Notification::class, 'retryUnsent']); + }); }); // WebSocket 管理