diff --git a/app/Console/Commands/CleanupExpiredOnlineStatus.php b/app/Console/Commands/CleanupExpiredOnlineStatus.php deleted file mode 100644 index e51afc9..0000000 --- a/app/Console/Commands/CleanupExpiredOnlineStatus.php +++ /dev/null @@ -1,52 +0,0 @@ -where('online_count', '>', 0) - ->where('last_online_at', '<', now()->subMinutes(5)) - ->chunkById(1000, function ($users) use (&$affected) { - if ($users->isEmpty()) { - return; - } - $count = User::whereIn('id', $users->pluck('id')) - ->update(['online_count' => 0]); - $affected += $count; - }, 'id'); - - $this->info("Expired online status cleaned. Affected: {$affected}"); - return self::SUCCESS; - } catch (\Throwable $e) { - Log::error('CleanupExpiredOnlineStatus failed', ['error' => $e->getMessage()]); - $this->error('Cleanup failed: ' . $e->getMessage()); - return self::FAILURE; - } - } -} diff --git a/app/Console/Commands/NodeWebSocketServer.php b/app/Console/Commands/NodeWebSocketServer.php index 4a63919..8c07d93 100644 --- a/app/Console/Commands/NodeWebSocketServer.php +++ b/app/Console/Commands/NodeWebSocketServer.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Server; +use App\Services\DeviceStateService; use App\Services\NodeSyncService; use App\Services\NodeRegistry; use App\Services\ServerService; @@ -69,6 +70,23 @@ class NodeWebSocketServer extends Command } } }); + + // 定时推送设备状态给节点(每10秒) + Timer::add(10, function () { + $pendingNodeIds = Redis::spop('device:push_pending_nodes', 100); + if (empty($pendingNodeIds)) { + return; + } + + $deviceStateService = app(DeviceStateService::class); + + foreach ($pendingNodeIds as $nodeId) { + $nodeId = (int) $nodeId; + if (NodeRegistry::get($nodeId) !== null) { + $this->pushDeviceStateToNode($nodeId, $deviceStateService); + } + } + }); }; $worker->onConnect = function (TcpConnection $conn) { @@ -126,6 +144,10 @@ class NodeWebSocketServer extends Command NodeRegistry::add($nodeId, $conn); Cache::put("node_ws_alive:{$nodeId}", true, 86400); + // 清理该节点的旧设备数据(节点重连后需重新上报全量) + $deviceStateService = app(DeviceStateService::class); + $deviceStateService->clearNodeDevices($nodeId); + Log::debug("[WS] Node#{$nodeId} connected", [ 'remote' => $conn->getRemoteIp(), 'total' => NodeRegistry::count(), @@ -162,8 +184,17 @@ class NodeWebSocketServer extends Command $this->handleNodeStatus($nodeId, $msg['data']); } break; + case 'report.devices': + if ($nodeId && isset($msg['data'])) { + $this->handleDeviceReport($nodeId, $msg['data']); + } + break; + case 'request.devices': + if ($nodeId) { + $this->handleDeviceRequest($conn, $nodeId); + } + break; default: - // Future: handle other node-initiated messages if needed break; } }; @@ -173,6 +204,9 @@ class NodeWebSocketServer extends Command $nodeId = $conn->nodeId; NodeRegistry::remove($nodeId); Cache::forget("node_ws_alive:{$nodeId}"); + + app(DeviceStateService::class)->clearNodeDevices($nodeId); + Log::debug("[WS] Node#{$nodeId} disconnected", [ 'total' => NodeRegistry::count(), ]); @@ -194,13 +228,85 @@ class NodeWebSocketServer extends Command // Update last check-in cache Cache::put(\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_LAST_CHECK_AT', $nodeId), time(), 3600); - + // Update metrics cache via Service ServerService::updateMetrics($node, $data); Log::debug("[WS] Node#{$nodeId} status updated via WebSocket"); } + /** + * Handle device report from node via WebSocket + * + * 节点发送全量设备列表,面板负责差异计算 + * 数据格式: {"event": "report.devices", "data": {userId: [ip1, ip2, ...], ...}} + * + * 示例: {"event": "report.devices", "data": {"123": ["1.1.1.1", "2.2.2.2"], "456": ["3.3.3.3"]}} + */ + private function handleDeviceReport(int $nodeId, array $data): void + { + $deviceStateService = app(DeviceStateService::class); + + // 清理该节点的旧数据 + $deviceStateService->clearNodeDevices($nodeId); + + // 全量写入新数据 + foreach ($data as $userId => $ips) { + if (is_numeric($userId) && is_array($ips)) { + $deviceStateService->setDevices((int) $userId, $nodeId, $ips); + } + } + + // 标记该节点待推送(由定时器批量处理) + Redis::sadd('device:push_pending_nodes', $nodeId); + + Log::debug("[WS] Node#{$nodeId} synced " . count($data) . " users"); + } + + /** + * 推送全量设备状态给指定节点 + */ + private function pushDeviceStateToNode(int $nodeId, DeviceStateService $service): void + { + $node = Server::find($nodeId); + if (!$node) return; + + // 获取该节点关联的所有用户 + $users = ServerService::getAvailableUsers($node); + $userIds = $users->pluck('id')->toArray(); + + // 获取这些用户的设备列表 + $devices = $service->getUsersDevices($userIds); + + NodeRegistry::send($nodeId, 'sync.devices', [ + 'users' => $devices + ]); + + Log::debug("[WS] Pushed device state to node#{$nodeId}: " . count($devices) . " users"); + } + + /** + * Handle device state request from node via WebSocket + */ + private function handleDeviceRequest(TcpConnection $conn, int $nodeId): void + { + $node = Server::find($nodeId); + if (!$node) return; + + $users = ServerService::getAvailableUsers($node); + $userIds = $users->pluck('id')->toArray(); + + $deviceStateService = app(DeviceStateService::class); + $devices = $deviceStateService->getUsersDevices($userIds); + + $conn->send(json_encode([ + 'event' => 'sync.devices', + 'data' => ['users' => $devices], + ])); + + Log::debug("[WS] Node#{$nodeId} requested devices, sent " . count($devices) . " users"); + } + /** * Subscribe to Redis pub/sub channel for receiving push commands from Laravel. * Laravel app publishes to "node:push" channel, Workerman picks it up and forwards to the right node. @@ -244,7 +350,7 @@ class NodeWebSocketServer extends Command $sent = NodeRegistry::send((int) $nodeId, $event, $data); if ($sent) { - Log::debug("[WS] Pushed {$event} to node#{$nodeId}"); + Log::debug("[WS] Pushed {$event} to node#{$nodeId}, data: " . json_encode($data)); } }); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 49b8ae6..77b1810 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -7,7 +7,6 @@ use App\Utils\CacheKey; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Illuminate\Support\Facades\Cache; -use App\Services\UserOnlineService; class Kernel extends ConsoleKernel { @@ -35,7 +34,7 @@ class Kernel extends ConsoleKernel $schedule->command('check:order')->everyMinute()->onOneServer()->withoutOverlapping(5); $schedule->command('check:commission')->everyMinute()->onOneServer()->withoutOverlapping(5); $schedule->command('check:ticket')->everyMinute()->onOneServer()->withoutOverlapping(5); - $schedule->command('check:traffic-exceeded')->everyMinute()->onOneServer()->withoutOverlapping(10); + $schedule->command('check:traffic-exceeded')->everyMinute()->onOneServer()->withoutOverlapping(10)->runInBackground(); // reset $schedule->command('reset:traffic')->everyMinute()->onOneServer()->withoutOverlapping(10); $schedule->command('reset:log')->daily()->onOneServer(); @@ -47,8 +46,6 @@ class Kernel extends ConsoleKernel // if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) { // $schedule->command('backup:database', ['true'])->daily()->onOneServer(); // } - $schedule->command('cleanup:expired-online-status')->everyMinute()->onOneServer()->withoutOverlapping(4); - app(PluginManager::class)->registerPluginSchedules($schedule); } diff --git a/app/Http/Controllers/V1/Server/UniProxyController.php b/app/Http/Controllers/V1/Server/UniProxyController.php index a557f36..5901c4b 100644 --- a/app/Http/Controllers/V1/Server/UniProxyController.php +++ b/app/Http/Controllers/V1/Server/UniProxyController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers\V1\Server; use App\Http\Controllers\Controller; -use App\Jobs\UserAliveSyncJob; +use App\Services\DeviceStateService; use App\Services\NodeSyncService; use App\Services\ServerService; use App\Services\UserService; @@ -11,13 +11,12 @@ use App\Utils\CacheKey; use App\Utils\Helper; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; -use App\Services\UserOnlineService; use Illuminate\Http\JsonResponse; class UniProxyController extends Controller { public function __construct( - private readonly UserOnlineService $userOnlineService + private readonly DeviceStateService $deviceStateService ) { } @@ -103,13 +102,15 @@ class UniProxyController extends Controller return response($response)->header('ETag', "\"{$eTag}\""); } - // 获取在线用户数据(wyx2685 + // 获取在线用户数据 public function alivelist(Request $request): JsonResponse { $node = $this->getNodeInfo($request); $deviceLimitUsers = ServerService::getAvailableUsers($node) ->where('device_limit', '>', 0); - $alive = $this->userOnlineService->getAliveList($deviceLimitUsers); + + $alive = $this->deviceStateService->getAliveList(collect($deviceLimitUsers)); + return response()->json(['alive' => (object) $alive]); } @@ -123,7 +124,11 @@ class UniProxyController extends Controller 'error' => 'Invalid online data' ], 400); } - UserAliveSyncJob::dispatch($data, $node->type, $node->id); + + foreach ($data as $uid => $ips) { + $this->deviceStateService->setDevices((int) $uid, $node->id, $ips); + } + return response()->json(['data' => true]); } diff --git a/app/Http/Controllers/V2/Client/AppController.php b/app/Http/Controllers/V2/Client/AppController.php new file mode 100644 index 0000000..85ec531 --- /dev/null +++ b/app/Http/Controllers/V2/Client/AppController.php @@ -0,0 +1,153 @@ + [ + 'app_name' => admin_setting('app_name', 'XB加速器'), // 应用名称 + 'app_description' => admin_setting('app_description', '专业的网络加速服务'), // 应用描述 + 'app_url' => admin_setting('app_url', 'https://app.example.com'), // 应用官网 URL + 'logo' => admin_setting('logo', 'https://example.com/logo.png'), // 应用 Logo URL + 'version' => admin_setting('app_version', '1.0.0'), // 应用版本号 + ], + 'features' => [ + 'enable_register' => (bool) admin_setting('app_enable_register', true), // 是否开启注册功能 + 'enable_invite_system' => (bool) admin_setting('app_enable_invite_system', true), // 是否开启邀请系统 + 'enable_telegram_bot' => (bool) admin_setting('telegram_bot_enable', false), // 是否开启 Telegram 机器人 + 'enable_ticket_system' => (bool) admin_setting('app_enable_ticket_system', true), // 是否开启工单系统 + 'ticket_must_wait_reply' => (bool) admin_setting('ticket_must_wait_reply', 0), // 工单是否需要等待管理员回复后才可继续发消息 + 'enable_commission_system' => (bool) admin_setting('app_enable_commission_system', true), // 是否开启佣金系统 + 'enable_traffic_log' => (bool) admin_setting('app_enable_traffic_log', true), // 是否开启流量日志 + 'enable_knowledge_base' => (bool) admin_setting('app_enable_knowledge_base', true), // 是否开启知识库 + 'enable_announcements' => (bool) admin_setting('app_enable_announcements', true), // 是否开启公告系统 + 'enable_auto_renewal' => (bool) admin_setting('app_enable_auto_renewal', false), // 是否开启自动续费 + 'enable_coupon_system' => (bool) admin_setting('app_enable_coupon_system', true), // 是否开启优惠券系统 + 'enable_speed_test' => (bool) admin_setting('app_enable_speed_test', true), // 是否开启测速功能 + 'enable_server_ping' => (bool) admin_setting('app_enable_server_ping', true), // 是否开启服务器延迟检测 + ], + 'ui_config' => [ + 'theme' => [ + 'primary_color' => admin_setting('app_primary_color', '#00C851'), // 主色调 (十六进制) + 'secondary_color' => admin_setting('app_secondary_color', '#007E33'), // 辅助色 (十六进制) + 'accent_color' => admin_setting('app_accent_color', '#FF6B35'), // 强调色 (十六进制) + 'background_color' => admin_setting('app_background_color', '#F5F5F5'), // 背景色 (十六进制) + 'text_color' => admin_setting('app_text_color', '#333333'), // 文字色 (十六进制) + ], + 'home_screen' => [ + 'show_speed_test' => (bool) admin_setting('app_show_speed_test', true), // 是否显示测速 + 'show_traffic_chart' => (bool) admin_setting('app_show_traffic_chart', true), // 是否显示流量图表 + 'show_server_ping' => (bool) admin_setting('app_show_server_ping', true), // 是否显示服务器延迟 + 'default_server_sort' => admin_setting('app_default_server_sort', 'ping'), // 默认服务器排序方式 + 'show_connection_status' => (bool) admin_setting('app_show_connection_status', true), // 是否显示连接状态 + ], + 'server_list' => [ + 'show_country_flags' => (bool) admin_setting('app_show_country_flags', true), // 是否显示国家旗帜 + 'show_ping_values' => (bool) admin_setting('app_show_ping_values', true), // 是否显示延迟值 + 'show_traffic_usage' => (bool) admin_setting('app_show_traffic_usage', true), // 是否显示流量使用 + 'group_by_country' => (bool) admin_setting('app_group_by_country', false), // 是否按国家分组 + 'show_server_status' => (bool) admin_setting('app_show_server_status', true), // 是否显示服务器状态 + ], + ], + 'business_rules' => [ + 'min_password_length' => (int) admin_setting('app_min_password_length', 8), // 最小密码长度 + 'max_login_attempts' => (int) admin_setting('app_max_login_attempts', 5), // 最大登录尝试次数 + 'session_timeout_minutes' => (int) admin_setting('app_session_timeout_minutes', 30), // 会话超时时间(分钟) + 'auto_disconnect_after_minutes' => (int) admin_setting('app_auto_disconnect_after_minutes', 60), // 自动断开连接时间(分钟) + 'max_concurrent_connections' => (int) admin_setting('app_max_concurrent_connections', 3), // 最大并发连接数 + 'traffic_warning_threshold' => (float) admin_setting('app_traffic_warning_threshold', 0.8), // 流量警告阈值(0-1) + 'subscription_reminder_days' => admin_setting('app_subscription_reminder_days', [7, 3, 1]), // 订阅到期提醒天数 + 'connection_timeout_seconds' => (int) admin_setting('app_connection_timeout_seconds', 10), // 连接超时时间(秒) + 'health_check_interval_seconds' => (int) admin_setting('app_health_check_interval_seconds', 30), // 健康检查间隔(秒) + ], + 'server_config' => [ + 'default_kernel' => admin_setting('app_default_kernel', 'clash'), // 默认内核 (clash/singbox) + 'auto_select_fastest' => (bool) admin_setting('app_auto_select_fastest', true), // 是否自动选择最快服务器 + 'fallback_servers' => admin_setting('app_fallback_servers', ['server1', 'server2']), // 备用服务器列表 + 'enable_auto_switch' => (bool) admin_setting('app_enable_auto_switch', true), // 是否开启自动切换 + 'switch_threshold_ms' => (int) admin_setting('app_switch_threshold_ms', 1000), // 切换阈值(毫秒) + ], + 'security_config' => [ + 'tos_url' => admin_setting('tos_url', 'https://example.com/tos'), // 服务条款 URL + 'privacy_policy_url' => admin_setting('app_privacy_policy_url', 'https://example.com/privacy'), // 隐私政策 URL + 'is_email_verify' => (int) admin_setting('email_verify', 1), // 是否开启邮箱验证 (0/1) + 'is_invite_force' => (int) admin_setting('invite_force', 0), // 是否强制邀请码 (0/1) + 'email_whitelist_suffix' => (int) admin_setting('email_whitelist_suffix', 0), // 邮箱白名单后缀 (0/1) + 'is_captcha' => (int) admin_setting('captcha_enable', 1), // 是否开启验证码 (0/1) + 'captcha_type' => admin_setting('captcha_type', 'recaptcha'), // 验证码类型 (recaptcha/turnstile) + 'recaptcha_site_key' => admin_setting('recaptcha_site_key', '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'), // reCAPTCHA 站点密钥 + 'recaptcha_v3_site_key' => admin_setting('recaptcha_v3_site_key', '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'), // reCAPTCHA v3 站点密钥 + 'recaptcha_v3_score_threshold' => (float) admin_setting('recaptcha_v3_score_threshold', 0.5), // reCAPTCHA v3 分数阈值 + 'turnstile_site_key' => admin_setting('turnstile_site_key', '0x4AAAAAAAABkMYinukE8nzUg'), // Turnstile 站点密钥 + ], + 'payment_config' => [ + 'currency' => admin_setting('currency', 'CNY'), // 货币类型 + 'currency_symbol' => admin_setting('currency_symbol', '¥'), // 货币符号 + 'withdraw_methods' => admin_setting('app_withdraw_methods', ['alipay', 'wechat', 'bank']), // 提现方式列表 + 'min_withdraw_amount' => (int) admin_setting('app_min_withdraw_amount', 100), // 最小提现金额(分) + 'withdraw_fee_rate' => (float) admin_setting('app_withdraw_fee_rate', 0.01), // 提现手续费率 + ], + 'notification_config' => [ + 'enable_push_notifications' => (bool) admin_setting('app_enable_push_notifications', true), // 是否开启推送通知 + 'enable_email_notifications' => (bool) admin_setting('app_enable_email_notifications', true), // 是否开启邮件通知 + 'enable_sms_notifications' => (bool) admin_setting('app_enable_sms_notifications', false), // 是否开启短信通知 + 'notification_schedule' => [ + 'traffic_warning' => (bool) admin_setting('app_notification_traffic_warning', true), // 流量警告通知 + 'subscription_expiry' => (bool) admin_setting('app_notification_subscription_expiry', true), // 订阅到期通知 + 'server_maintenance' => (bool) admin_setting('app_notification_server_maintenance', true), // 服务器维护通知 + 'promotional_offers' => (bool) admin_setting('app_notification_promotional_offers', false), // 促销优惠通知 + ], + ], + 'cache_config' => [ + 'config_cache_duration' => (int) admin_setting('app_config_cache_duration', 3600), // 配置缓存时长(秒) + 'server_list_cache_duration' => (int) admin_setting('app_server_list_cache_duration', 1800), // 服务器列表缓存时长(秒) + 'user_info_cache_duration' => (int) admin_setting('app_user_info_cache_duration', 900), // 用户信息缓存时长(秒) + ], + 'last_updated' => time(), // 最后更新时间戳 + ]; + $config['config_hash'] = md5(json_encode($config)); // 配置哈希值(用于校验) + + $config = $config ?? []; + return response()->json(['data' => $config]); + } + + public function getVersion(Request $request) + { + if ( + strpos($request->header('user-agent'), 'tidalab/4.0.0') !== false + || strpos($request->header('user-agent'), 'tunnelab/4.0.0') !== false + ) { + if (strpos($request->header('user-agent'), 'Win64') !== false) { + $data = [ + 'version' => admin_setting('windows_version'), + 'download_url' => admin_setting('windows_download_url') + ]; + } else { + $data = [ + 'version' => admin_setting('macos_version'), + 'download_url' => admin_setting('macos_download_url') + ]; + } + } else { + $data = [ + 'windows_version' => admin_setting('windows_version'), + 'windows_download_url' => admin_setting('windows_download_url'), + 'macos_version' => admin_setting('macos_version'), + 'macos_download_url' => admin_setting('macos_download_url'), + 'android_version' => admin_setting('android_version'), + 'android_download_url' => admin_setting('android_download_url') + ]; + } + return $this->success($data); + } +} diff --git a/app/Http/Controllers/V2/Server/ServerController.php b/app/Http/Controllers/V2/Server/ServerController.php index d5ea600..5770282 100644 --- a/app/Http/Controllers/V2/Server/ServerController.php +++ b/app/Http/Controllers/V2/Server/ServerController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\V2\Server; use App\Http\Controllers\Controller; use App\Jobs\UserAliveSyncJob; +use App\Services\DeviceStateService; use App\Services\ServerService; use App\Services\UserService; use App\Utils\CacheKey; @@ -83,7 +84,10 @@ class ServerController extends Controller // handle alive data $alive = $request->input('alive'); if (is_array($alive) && !empty($alive)) { - UserAliveSyncJob::dispatch($alive, $nodeType, $nodeId); + $deviceStateService = app(DeviceStateService::class); + foreach ($alive as $uid => $ips) { + $deviceStateService->setDevices((int) $uid, $nodeId, (array) $ips); + } } // handle active connections @@ -127,7 +131,7 @@ class ServerController extends Controller // handle node metrics (Metrics) $metrics = $request->input('metrics'); if (is_array($metrics) && !empty($metrics)) { - ServerService::updateMetrics($node, $metrics); + ServerService::updateMetrics($node, $metrics); } return response()->json(['data' => true]); diff --git a/app/Jobs/UserAliveSyncJob.php b/app/Jobs/UserAliveSyncJob.php deleted file mode 100644 index 0cff19d..0000000 --- a/app/Jobs/UserAliveSyncJob.php +++ /dev/null @@ -1,108 +0,0 @@ -onQueue('user_alive_sync'); - } - - public function handle(): void - { - try { - $updateAt = time(); - $nowTs = time(); - $now = now(); - $nodeKey = $this->nodeType . $this->nodeId; - $userUpdates = []; - - foreach ($this->data as $uid => $ips) { - $cacheKey = self::CACHE_PREFIX . $uid; - $ipsArray = Cache::get($cacheKey, []); - $ipsArray = [ - ...collect($ipsArray) - ->filter(fn(mixed $value): bool => is_array($value) && ($updateAt - ($value['lastupdateAt'] ?? 0) <= self::NODE_DATA_EXPIRY)), - $nodeKey => [ - 'aliveips' => $ips, - 'lastupdateAt' => $updateAt, - ], - ]; - - $count = UserOnlineService::calculateDeviceCount($ipsArray); - $ipsArray['alive_ip'] = $count; - Cache::put($cacheKey, $ipsArray, now()->addSeconds(self::CACHE_TTL)); - - $userUpdates[] = [ - 'id' => (int) $uid, - 'count' => (int) $count, - ]; - } - - if (!empty($userUpdates)) { - $allIds = collect($userUpdates) - ->pluck('id') - ->filter() - ->map(fn($v) => (int) $v) - ->unique() - ->values() - ->all(); - - if (!empty($allIds)) { - $existingIds = User::query() - ->whereIn('id', $allIds) - ->pluck('id') - ->map(fn($v) => (int) $v) - ->all(); - - if (!empty($existingIds)) { - collect($userUpdates) - ->filter(fn($row) => in_array((int) ($row['id'] ?? 0), $existingIds, true)) - ->chunk(1000) - ->each(function ($chunk) use ($now) { - collect($chunk)->each(function ($update) use ($now) { - $id = (int) ($update['id'] ?? 0); - $count = (int) ($update['count'] ?? 0); - if ($id > 0) { - User::query() - ->whereKey($id) - ->update([ - 'online_count' => $count, - 'last_online_at' => $now, - ]); - } - }); - }); - } - } - } - } catch (\Throwable $e) { - Log::error('UserAliveSyncJob failed', [ - 'error' => $e->getMessage(), - ]); - $this->fail($e); - } - } - - -} diff --git a/app/Services/DeviceStateService.php b/app/Services/DeviceStateService.php new file mode 100644 index 0000000..ad1d012 --- /dev/null +++ b/app/Services/DeviceStateService.php @@ -0,0 +1,148 @@ +clearNodeDevices($nodeId, $userId); + + if (!empty($ips)) { + $fields = []; + foreach ($ips as $ip) { + $fields["{$nodeId}:{$ip}"] = $timestamp; + } + Redis::hMset($key, $fields); + Redis::expire($key, self::TTL); + } + + $this->notifyUpdate($userId); + } + + /** + * clear node devices + * - only nodeId: clear all devices of the node + * - userId and nodeId: clear specific user's specific node device + */ + public function clearNodeDevices(int $nodeId, ?int $userId = null): void + { + if ($userId !== null) { + $key = self::PREFIX . $userId; + $prefix = "{$nodeId}:"; + foreach (Redis::hkeys($key) as $field) { + if (str_starts_with($field, $prefix)) { + Redis::hdel($key, $field); + } + } + return; + } + + $keys = Redis::keys(self::PREFIX . '*'); + $prefix = "{$nodeId}:"; + + foreach ($keys as $key) { + foreach (Redis::hkeys($key) as $field) { + if (str_starts_with($field, $prefix)) { + Redis::hdel($key, $field); + } + } + } + } + + /** + * get user device count (filter expired data) + */ + public function getDeviceCount(int $userId): int + { + $data = Redis::hgetall(self::PREFIX . $userId); + $now = time(); + $count = 0; + foreach ($data as $field => $timestamp) { + // if ($now - $timestamp <= self::TTL) { + $count++; + // } + } + return $count; + } + + /** + * get user device count (for alivelist interface) + */ + public function getAliveList(Collection $users): array + { + if ($users->isEmpty()) { + return []; + } + + $result = []; + foreach ($users as $user) { + $count = $this->getDeviceCount($user->id); + if ($count > 0) { + $result[$user->id] = $count; + } + } + + return $result; + } + + /** + * get devices of multiple users (for sync.devices, filter expired data) + */ + public function getUsersDevices(array $userIds): array + { + $result = []; + $now = time(); + + foreach ($userIds as $userId) { + $data = Redis::hgetall(self::PREFIX . $userId); + if (!empty($data)) { + $ips = []; + foreach ($data as $field => $timestamp) { + // if ($now - $timestamp <= self::TTL) { + $ips[] = substr($field, strpos($field, ':') + 1); + // } + } + if (!empty($ips)) { + $result[$userId] = array_unique($ips); + } + } + } + + return $result; + } + + /** + * notify update (throttle control) + */ + private function notifyUpdate(int $userId): void + { + $dbThrottleKey = "device:db_throttle:{$userId}"; + + if (Redis::setnx($dbThrottleKey, 1)) { + Redis::expire($dbThrottleKey, self::DB_THROTTLE); + + User::query() + ->whereKey($userId) + ->update([ + 'online_count' => $this->getDeviceCount($userId), + 'last_online_at' => now(), + ]); + } + } +} diff --git a/app/Services/UserOnlineService.php b/app/Services/UserOnlineService.php deleted file mode 100644 index 42ec854..0000000 --- a/app/Services/UserOnlineService.php +++ /dev/null @@ -1,123 +0,0 @@ -isEmpty()) { - return []; - } - - $cacheKeys = $deviceLimitUsers->pluck('id') - ->map(fn(int $id): string => self::CACHE_PREFIX . $id) - ->all(); - - return collect(cache()->many($cacheKeys)) - ->filter() - ->map(fn(array $data): ?int => $data['alive_ip'] ?? null) - ->filter() - ->mapWithKeys(fn(int $count, string $key): array => [ - (int) Str::after($key, self::CACHE_PREFIX) => $count - ]) - ->all(); - } - - /** - * 获取指定用户的在线设备信息 - */ - public static function getUserDevices(int $userId): array - { - $data = cache()->get(self::CACHE_PREFIX . $userId, []); - if (empty($data)) { - return ['total_count' => 0, 'devices' => []]; - } - - $devices = collect($data) - ->filter(fn(mixed $item): bool => is_array($item) && isset($item['aliveips'])) - ->flatMap(function (array $nodeData, string $nodeKey): array { - return collect($nodeData['aliveips']) - ->mapWithKeys(function (string $ipNodeId) use ($nodeData, $nodeKey): array { - $ip = Str::before($ipNodeId, '_'); - return [ - $ip => [ - 'ip' => $ip, - 'last_seen' => $nodeData['lastupdateAt'], - 'node_type' => Str::before($nodeKey, (string) $nodeData['lastupdateAt']) - ] - ]; - }) - ->all(); - }) - ->values() - ->all(); - - return [ - 'total_count' => $data['alive_ip'] ?? 0, - 'devices' => $devices - ]; - } - - - /** - * 批量获取用户在线设备数 - */ - public function getOnlineCounts(array $userIds): array - { - $cacheKeys = collect($userIds) - ->map(fn(int $id): string => self::CACHE_PREFIX . $id) - ->all(); - - return collect(cache()->many($cacheKeys)) - ->filter() - ->map(fn(array $data): int => $data['alive_ip'] ?? 0) - ->all(); - } - - /** - * 获取用户在线设备数 - */ - public function getOnlineCount(int $userId): int - { - $data = cache()->get(self::CACHE_PREFIX . $userId, []); - return $data['alive_ip'] ?? 0; - } - - /** - * 计算在线设备数量 - */ - public static function calculateDeviceCount(array $ipsArray): int - { - $mode = (int) admin_setting('device_limit_mode', 0); - - return match ($mode) { - 1 => collect($ipsArray) - ->filter(fn(mixed $data): bool => is_array($data) && isset($data['aliveips'])) - ->flatMap( - fn(array $data): array => collect($data['aliveips']) - ->map(fn(string $ipNodeId): string => Str::before($ipNodeId, '_')) - ->unique() - ->all() - ) - ->unique() - ->count(), - 0 => collect($ipsArray) - ->filter(fn(mixed $data): bool => is_array($data) && isset($data['aliveips'])) - ->sum(fn(array $data): int => count($data['aliveips'])), - default => throw new \InvalidArgumentException("Invalid device limit mode: $mode"), - }; - } -} \ No newline at end of file diff --git a/public/assets/admin b/public/assets/admin index 878489d..89c0577 160000 --- a/public/assets/admin +++ b/public/assets/admin @@ -1 +1 @@ -Subproject commit 878489d9a5466edcf1562426895e3b4cf1dce6c6 +Subproject commit 89c0577191adea858dbc1d48ae154aa6cc91bc1a