mirror of
https://github.com/lkddi/Xboard.git
synced 2026-04-03 10:30:51 +08:00
refactor: restructure device limit system
This commit is contained in:
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CleanupExpiredOnlineStatus extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'cleanup:expired-online-status';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Reset online_count to 0 for users stale for 5+ minutes';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
$affected = 0;
|
||||
User::query()
|
||||
->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
153
app/Http/Controllers/V2/Client/AppController.php
Normal file
153
app/Http/Controllers/V2/Client/AppController.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V2\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ServerService;
|
||||
use App\Services\UserService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class AppController extends Controller
|
||||
{
|
||||
public function getConfig(Request $request)
|
||||
{
|
||||
$config = [
|
||||
'app_info' => [
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\UserOnlineService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UserAliveSyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const CACHE_PREFIX = 'ALIVE_IP_USER_';
|
||||
private const CACHE_TTL = 120;
|
||||
private const NODE_DATA_EXPIRY = 100;
|
||||
|
||||
public function __construct(
|
||||
private readonly array $data,
|
||||
private readonly string $nodeType,
|
||||
private readonly int $nodeId
|
||||
) {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
148
app/Services/DeviceStateService.php
Normal file
148
app/Services/DeviceStateService.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class DeviceStateService
|
||||
{
|
||||
private const PREFIX = 'user_devices:';
|
||||
private const TTL = 300; // device state ttl
|
||||
private const DB_THROTTLE = 10; // update db throttle
|
||||
|
||||
/**
|
||||
* 批量设置设备
|
||||
* 用于 HTTP /alive 和 WebSocket report.devices
|
||||
*/
|
||||
public function setDevices(int $userId, int $nodeId, array $ips): void
|
||||
{
|
||||
$key = self::PREFIX . $userId;
|
||||
$timestamp = time();
|
||||
|
||||
$this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserOnlineService
|
||||
{
|
||||
/**
|
||||
* 缓存相关常量
|
||||
*/
|
||||
private const CACHE_PREFIX = 'ALIVE_IP_USER_';
|
||||
|
||||
/**
|
||||
* 获取所有限制设备用户的在线数量
|
||||
*/
|
||||
public function getAliveList(Collection $deviceLimitUsers): array
|
||||
{
|
||||
if ($deviceLimitUsers->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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
Submodule public/assets/admin updated: 878489d9a5...89c0577191
Reference in New Issue
Block a user