refactor: restructure device limit system

This commit is contained in:
xboard
2026-03-25 17:50:16 +08:00
parent 73a37a07dd
commit 420521d90a
10 changed files with 429 additions and 299 deletions

View File

@@ -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;
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Server; use App\Models\Server;
use App\Services\DeviceStateService;
use App\Services\NodeSyncService; use App\Services\NodeSyncService;
use App\Services\NodeRegistry; use App\Services\NodeRegistry;
use App\Services\ServerService; 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) { $worker->onConnect = function (TcpConnection $conn) {
@@ -126,6 +144,10 @@ class NodeWebSocketServer extends Command
NodeRegistry::add($nodeId, $conn); NodeRegistry::add($nodeId, $conn);
Cache::put("node_ws_alive:{$nodeId}", true, 86400); Cache::put("node_ws_alive:{$nodeId}", true, 86400);
// 清理该节点的旧设备数据(节点重连后需重新上报全量)
$deviceStateService = app(DeviceStateService::class);
$deviceStateService->clearNodeDevices($nodeId);
Log::debug("[WS] Node#{$nodeId} connected", [ Log::debug("[WS] Node#{$nodeId} connected", [
'remote' => $conn->getRemoteIp(), 'remote' => $conn->getRemoteIp(),
'total' => NodeRegistry::count(), 'total' => NodeRegistry::count(),
@@ -162,8 +184,17 @@ class NodeWebSocketServer extends Command
$this->handleNodeStatus($nodeId, $msg['data']); $this->handleNodeStatus($nodeId, $msg['data']);
} }
break; 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: default:
// Future: handle other node-initiated messages if needed
break; break;
} }
}; };
@@ -173,6 +204,9 @@ class NodeWebSocketServer extends Command
$nodeId = $conn->nodeId; $nodeId = $conn->nodeId;
NodeRegistry::remove($nodeId); NodeRegistry::remove($nodeId);
Cache::forget("node_ws_alive:{$nodeId}"); Cache::forget("node_ws_alive:{$nodeId}");
app(DeviceStateService::class)->clearNodeDevices($nodeId);
Log::debug("[WS] Node#{$nodeId} disconnected", [ Log::debug("[WS] Node#{$nodeId} disconnected", [
'total' => NodeRegistry::count(), 'total' => NodeRegistry::count(),
]); ]);
@@ -194,13 +228,85 @@ class NodeWebSocketServer extends Command
// Update last check-in cache // Update last check-in cache
Cache::put(\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_LAST_CHECK_AT', $nodeId), time(), 3600); Cache::put(\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_LAST_CHECK_AT', $nodeId), time(), 3600);
// Update metrics cache via Service // Update metrics cache via Service
ServerService::updateMetrics($node, $data); ServerService::updateMetrics($node, $data);
Log::debug("[WS] Node#{$nodeId} status updated via WebSocket"); 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. * 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. * 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); $sent = NodeRegistry::send((int) $nodeId, $event, $data);
if ($sent) { if ($sent) {
Log::debug("[WS] Pushed {$event} to node#{$nodeId}"); Log::debug("[WS] Pushed {$event} to node#{$nodeId}, data: " . json_encode($data));
} }
}); });

View File

@@ -7,7 +7,6 @@ use App\Utils\CacheKey;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use App\Services\UserOnlineService;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
{ {
@@ -35,7 +34,7 @@ class Kernel extends ConsoleKernel
$schedule->command('check:order')->everyMinute()->onOneServer()->withoutOverlapping(5); $schedule->command('check:order')->everyMinute()->onOneServer()->withoutOverlapping(5);
$schedule->command('check:commission')->everyMinute()->onOneServer()->withoutOverlapping(5); $schedule->command('check:commission')->everyMinute()->onOneServer()->withoutOverlapping(5);
$schedule->command('check:ticket')->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 // reset
$schedule->command('reset:traffic')->everyMinute()->onOneServer()->withoutOverlapping(10); $schedule->command('reset:traffic')->everyMinute()->onOneServer()->withoutOverlapping(10);
$schedule->command('reset:log')->daily()->onOneServer(); $schedule->command('reset:log')->daily()->onOneServer();
@@ -47,8 +46,6 @@ class Kernel extends ConsoleKernel
// if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) { // if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) {
// $schedule->command('backup:database', ['true'])->daily()->onOneServer(); // $schedule->command('backup:database', ['true'])->daily()->onOneServer();
// } // }
$schedule->command('cleanup:expired-online-status')->everyMinute()->onOneServer()->withoutOverlapping(4);
app(PluginManager::class)->registerPluginSchedules($schedule); app(PluginManager::class)->registerPluginSchedules($schedule);
} }

View File

@@ -3,7 +3,7 @@
namespace App\Http\Controllers\V1\Server; namespace App\Http\Controllers\V1\Server;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\UserAliveSyncJob; use App\Services\DeviceStateService;
use App\Services\NodeSyncService; use App\Services\NodeSyncService;
use App\Services\ServerService; use App\Services\ServerService;
use App\Services\UserService; use App\Services\UserService;
@@ -11,13 +11,12 @@ use App\Utils\CacheKey;
use App\Utils\Helper; use App\Utils\Helper;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use App\Services\UserOnlineService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
class UniProxyController extends Controller class UniProxyController extends Controller
{ {
public function __construct( 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}\""); return response($response)->header('ETag', "\"{$eTag}\"");
} }
// 获取在线用户数据wyx2685 // 获取在线用户数据
public function alivelist(Request $request): JsonResponse public function alivelist(Request $request): JsonResponse
{ {
$node = $this->getNodeInfo($request); $node = $this->getNodeInfo($request);
$deviceLimitUsers = ServerService::getAvailableUsers($node) $deviceLimitUsers = ServerService::getAvailableUsers($node)
->where('device_limit', '>', 0); ->where('device_limit', '>', 0);
$alive = $this->userOnlineService->getAliveList($deviceLimitUsers);
$alive = $this->deviceStateService->getAliveList(collect($deviceLimitUsers));
return response()->json(['alive' => (object) $alive]); return response()->json(['alive' => (object) $alive]);
} }
@@ -123,7 +124,11 @@ class UniProxyController extends Controller
'error' => 'Invalid online data' 'error' => 'Invalid online data'
], 400); ], 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]); return response()->json(['data' => true]);
} }

View 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);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\V2\Server;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\UserAliveSyncJob; use App\Jobs\UserAliveSyncJob;
use App\Services\DeviceStateService;
use App\Services\ServerService; use App\Services\ServerService;
use App\Services\UserService; use App\Services\UserService;
use App\Utils\CacheKey; use App\Utils\CacheKey;
@@ -83,7 +84,10 @@ class ServerController extends Controller
// handle alive data // handle alive data
$alive = $request->input('alive'); $alive = $request->input('alive');
if (is_array($alive) && !empty($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 // handle active connections
@@ -127,7 +131,7 @@ class ServerController extends Controller
// handle node metrics (Metrics) // handle node metrics (Metrics)
$metrics = $request->input('metrics'); $metrics = $request->input('metrics');
if (is_array($metrics) && !empty($metrics)) { if (is_array($metrics) && !empty($metrics)) {
ServerService::updateMetrics($node, $metrics); ServerService::updateMetrics($node, $metrics);
} }
return response()->json(['data' => true]); return response()->json(['data' => true]);

View File

@@ -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);
}
}
}

View 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(),
]);
}
}
}

View File

@@ -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"),
};
}
}