From 190f81c12cf3291b52514ee8fbda8f468f0d87ed Mon Sep 17 00:00:00 2001 From: xboard <147830212+cedar2025@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:45:31 +0800 Subject: [PATCH 01/14] fix: resolve vmess http-opts headers null issue in subscription generation --- app/Protocols/Clash.php | 6 ++++-- app/Protocols/ClashMeta.php | 6 ++++-- app/Protocols/SingBox.php | 8 ++++---- app/Protocols/Stash.php | 11 +++++++++-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/Protocols/Clash.php b/app/Protocols/Clash.php index f0914a2..844f0e2 100644 --- a/app/Protocols/Clash.php +++ b/app/Protocols/Clash.php @@ -203,10 +203,12 @@ class Clash extends AbstractProtocol case 'tcp': $array['network'] = data_get($protocol_settings, 'network_settings.header.type'); if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') { - $array['http-opts'] = [ + if ($httpOpts = array_filter([ 'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'), 'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/']) - ]; + ])) { + $array['http-opts'] = $httpOpts; + } } break; case 'ws': diff --git a/app/Protocols/ClashMeta.php b/app/Protocols/ClashMeta.php index 3fc1b10..daa46ce 100644 --- a/app/Protocols/ClashMeta.php +++ b/app/Protocols/ClashMeta.php @@ -261,10 +261,12 @@ class ClashMeta extends AbstractProtocol case 'tcp': $array['network'] = data_get($protocol_settings, 'network_settings.header.type', 'tcp'); if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') { - $array['http-opts'] = [ + if ($httpOpts = array_filter([ 'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'), 'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/']) - ]; + ])) { + $array['http-opts'] = $httpOpts; + } } break; case 'ws': diff --git a/app/Protocols/SingBox.php b/app/Protocols/SingBox.php index 5f42500..43a1f3e 100644 --- a/app/Protocols/SingBox.php +++ b/app/Protocols/SingBox.php @@ -209,13 +209,13 @@ class SingBox extends AbstractProtocol 'path' => Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])), 'host' => data_get($protocol_settings, 'network_settings.header.request.headers.Host', []) ] : null, - 'ws' => [ + 'ws' => array_filter([ 'type' => 'ws', 'path' => data_get($protocol_settings, 'network_settings.path'), 'headers' => ($host = data_get($protocol_settings, 'network_settings.headers.Host')) ? ['Host' => $host] : null, 'max_early_data' => 2048, 'early_data_header_name' => 'Sec-WebSocket-Protocol' - ], + ]), 'grpc' => [ 'type' => 'grpc', 'service_name' => data_get($protocol_settings, 'network_settings.serviceName') @@ -330,13 +330,13 @@ class SingBox extends AbstractProtocol 'type' => 'grpc', 'service_name' => data_get($protocol_settings, 'network_settings.serviceName') ], - 'ws' => [ + 'ws' => array_filter([ 'type' => 'ws', 'path' => data_get($protocol_settings, 'network_settings.path'), 'headers' => data_get($protocol_settings, 'network_settings.headers.Host') ? ['Host' => [data_get($protocol_settings, 'network_settings.headers.Host')]] : null, 'max_early_data' => 2048, 'early_data_header_name' => 'Sec-WebSocket-Protocol' - ], + ]), default => null }; $array['transport'] = $transport; diff --git a/app/Protocols/Stash.php b/app/Protocols/Stash.php index 0e5a373..86b63e9 100644 --- a/app/Protocols/Stash.php +++ b/app/Protocols/Stash.php @@ -314,7 +314,12 @@ class Stash extends AbstractProtocol case 'tcp': if ($headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp') != 'tcp') { $array['network'] = $headerType; - $array['http-opts']['path'] = data_get($protocol_settings, 'network_settings.header.request.path', ['/']); + if ($httpOpts = array_filter([ + 'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'), + 'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/']) + ])) { + $array['http-opts'] = $httpOpts; + } } break; case 'ws': @@ -356,7 +361,9 @@ class Stash extends AbstractProtocol case 'ws': $array['network'] = 'ws'; $array['ws-opts']['path'] = data_get($protocol_settings, 'network_settings.path'); - $array['ws-opts']['headers'] = data_get($protocol_settings, 'network_settings.headers.Host') ? ['Host' => data_get($protocol_settings, 'network_settings.headers.Host')] : null; + if ($host = data_get($protocol_settings, 'network_settings.headers.Host')) { + $array['ws-opts']['headers'] = ['Host' => $host]; + } break; } if ($serverName = data_get($protocol_settings, 'server_name')) { From 28f11e30af8cd702fdb4474e44ba823fb84c4b07 Mon Sep 17 00:00:00 2001 From: xboard <147830212+cedar2025@users.noreply.github.com> Date: Mon, 15 Sep 2025 09:21:20 +0800 Subject: [PATCH 02/14] fix(server): Correct node_info retrieval method --- .../Controllers/V1/Server/ShadowsocksTidalabController.php | 4 ++-- app/Http/Controllers/V1/Server/TrojanTidalabController.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/V1/Server/ShadowsocksTidalabController.php b/app/Http/Controllers/V1/Server/ShadowsocksTidalabController.php index 71f62e1..62e5af9 100644 --- a/app/Http/Controllers/V1/Server/ShadowsocksTidalabController.php +++ b/app/Http/Controllers/V1/Server/ShadowsocksTidalabController.php @@ -21,7 +21,7 @@ class ShadowsocksTidalabController extends Controller public function user(Request $request) { ini_set('memory_limit', -1); - $server = $request->input('node_info'); + $server = $request->attributes->get('node_info'); Cache::put(CacheKey::get('SERVER_SHADOWSOCKS_LAST_CHECK_AT', $server->id), time(), 3600); $users = ServerService::getAvailableUsers($server); $result = []; @@ -45,7 +45,7 @@ class ShadowsocksTidalabController extends Controller // 后端提交数据 public function submit(Request $request) { - $server = $request->input('node_info'); + $server = $request->attributes->get('node_info'); $data = json_decode(request()->getContent(), true); Cache::put(CacheKey::get('SERVER_SHADOWSOCKS_ONLINE_USER', $server->id), count($data), 3600); Cache::put(CacheKey::get('SERVER_SHADOWSOCKS_LAST_PUSH_AT', $server->id), time(), 3600); diff --git a/app/Http/Controllers/V1/Server/TrojanTidalabController.php b/app/Http/Controllers/V1/Server/TrojanTidalabController.php index 64ef193..ceff48f 100644 --- a/app/Http/Controllers/V1/Server/TrojanTidalabController.php +++ b/app/Http/Controllers/V1/Server/TrojanTidalabController.php @@ -23,7 +23,7 @@ class TrojanTidalabController extends Controller public function user(Request $request) { ini_set('memory_limit', -1); - $server = $request->input('node_info'); + $server = $request->attributes->get('node_info'); if ($server->type !== 'trojan') { return $this->fail([400, '节点不存在']); } @@ -50,7 +50,7 @@ class TrojanTidalabController extends Controller // 后端提交数据 public function submit(Request $request) { - $server = $request->input('node_info'); + $server = $request->attributes->get('node_info'); if ($server->type !== 'trojan') { return $this->fail([400, '节点不存在']); } @@ -73,7 +73,7 @@ class TrojanTidalabController extends Controller // 后端获取配置 public function config(Request $request) { - $server = $request->input('node_info'); + $server = $request->attributes->get('node_info'); if ($server->type !== 'trojan') { return $this->fail([400, '节点不存在']); } From cf99d4372ebcd69a1d3cb815c911021745d6c6f2 Mon Sep 17 00:00:00 2001 From: xboard <147830212+cedar2025@users.noreply.github.com> Date: Mon, 15 Sep 2025 09:56:36 +0800 Subject: [PATCH 03/14] refactor(Jobs): Optimize traffic statistics jobs with upsert --- app/Jobs/StatServerJob.php | 89 +++++++++++++++++++++++++++----------- app/Jobs/StatUserJob.php | 88 ++++++++++++++++++++++++++----------- 2 files changed, 126 insertions(+), 51 deletions(-) diff --git a/app/Jobs/StatServerJob.php b/app/Jobs/StatServerJob.php index ba567b0..9c822cc 100644 --- a/app/Jobs/StatServerJob.php +++ b/app/Jobs/StatServerJob.php @@ -45,16 +45,12 @@ class StatServerJob implements ShouldQueue $this->recordType = $recordType; } - /** - * Execute the job. - */ public function handle(): void { $recordAt = $this->recordType === 'm' ? strtotime(date('Y-m-01')) : strtotime(date('Y-m-d')); - // Aggregate traffic data $u = $d = 0; foreach ($this->data as $traffic) { $u += $traffic[0]; @@ -62,31 +58,72 @@ class StatServerJob implements ShouldQueue } try { - DB::transaction(function () use ($u, $d, $recordAt) { - $affected = StatServer::where([ - 'record_at' => $recordAt, - 'server_id' => $this->server['id'], - 'server_type' => $this->protocol, - 'record_type' => $this->recordType, - ])->update([ - 'u' => DB::raw('u + ' . $u), - 'd' => DB::raw('d + ' . $d), - ]); - - if (!$affected) { - StatServer::create([ - 'record_at' => $recordAt, - 'server_id' => $this->server['id'], - 'server_type' => $this->protocol, - 'record_type' => $this->recordType, - 'u' => $u, - 'd' => $d, - ]); - } - }, 3); + $this->processServerStat($u, $d, $recordAt); } catch (\Exception $e) { Log::error('StatServerJob failed for server ' . $this->server['id'] . ': ' . $e->getMessage()); throw $e; } } + + protected function processServerStat(int $u, int $d, int $recordAt): void + { + if (config('database.default') === 'sqlite') { + $this->processServerStatForSqlite($u, $d, $recordAt); + } else { + $this->processServerStatForOtherDatabases($u, $d, $recordAt); + } + } + + protected function processServerStatForSqlite(int $u, int $d, int $recordAt): void + { + DB::transaction(function () use ($u, $d, $recordAt) { + $existingRecord = StatServer::where([ + 'record_at' => $recordAt, + 'server_id' => $this->server['id'], + 'server_type' => $this->protocol, + 'record_type' => $this->recordType, + ])->first(); + + if ($existingRecord) { + $existingRecord->update([ + 'u' => $existingRecord->u + $u, + 'd' => $existingRecord->d + $d, + 'updated_at' => time(), + ]); + } else { + StatServer::create([ + 'record_at' => $recordAt, + 'server_id' => $this->server['id'], + 'server_type' => $this->protocol, + 'record_type' => $this->recordType, + 'u' => $u, + 'd' => $d, + 'created_at' => time(), + 'updated_at' => time(), + ]); + } + }, 3); + } + + protected function processServerStatForOtherDatabases(int $u, int $d, int $recordAt): void + { + StatServer::upsert( + [ + 'record_at' => $recordAt, + 'server_id' => $this->server['id'], + 'server_type' => $this->protocol, + 'record_type' => $this->recordType, + 'u' => $u, + 'd' => $d, + 'created_at' => time(), + 'updated_at' => time(), + ], + ['server_id', 'server_type', 'record_at', 'record_type'], + [ + 'u' => DB::raw("u + VALUES(u)"), + 'd' => DB::raw("d + VALUES(d)"), + 'updated_at' => time(), + ] + ); + } } diff --git a/app/Jobs/StatUserJob.php b/app/Jobs/StatUserJob.php index c8927f2..db00882 100644 --- a/app/Jobs/StatUserJob.php +++ b/app/Jobs/StatUserJob.php @@ -45,9 +45,6 @@ class StatUserJob implements ShouldQueue $this->recordType = $recordType; } - /** - * Execute the job. - */ public function handle(): void { $recordAt = $this->recordType === 'm' @@ -56,32 +53,73 @@ class StatUserJob implements ShouldQueue foreach ($this->data as $uid => $v) { try { - DB::transaction(function () use ($uid, $v, $recordAt) { - $affected = StatUser::where([ - 'user_id' => $uid, - 'server_rate' => $this->server['rate'], - 'record_at' => $recordAt, - 'record_type' => $this->recordType, - ])->update([ - 'u' => DB::raw('u + ' . ($v[0] * $this->server['rate'])), - 'd' => DB::raw('d + ' . ($v[1] * $this->server['rate'])), - ]); - - if (!$affected) { - StatUser::create([ - 'user_id' => $uid, - 'server_rate' => $this->server['rate'], - 'record_at' => $recordAt, - 'record_type' => $this->recordType, - 'u' => ($v[0] * $this->server['rate']), - 'd' => ($v[1] * $this->server['rate']), - ]); - } - }, 3); + $this->processUserStat($uid, $v, $recordAt); } catch (\Exception $e) { Log::error('StatUserJob failed for user ' . $uid . ': ' . $e->getMessage()); throw $e; } } } + + protected function processUserStat(int $uid, array $v, int $recordAt): void + { + if (config('database.default') === 'sqlite') { + $this->processUserStatForSqlite($uid, $v, $recordAt); + } else { + $this->processUserStatForOtherDatabases($uid, $v, $recordAt); + } + } + + protected function processUserStatForSqlite(int $uid, array $v, int $recordAt): void + { + DB::transaction(function () use ($uid, $v, $recordAt) { + $existingRecord = StatUser::where([ + 'user_id' => $uid, + 'server_rate' => $this->server['rate'], + 'record_at' => $recordAt, + 'record_type' => $this->recordType, + ])->first(); + + if ($existingRecord) { + $existingRecord->update([ + 'u' => $existingRecord->u + ($v[0] * $this->server['rate']), + 'd' => $existingRecord->d + ($v[1] * $this->server['rate']), + 'updated_at' => time(), + ]); + } else { + StatUser::create([ + 'user_id' => $uid, + 'server_rate' => $this->server['rate'], + 'record_at' => $recordAt, + 'record_type' => $this->recordType, + 'u' => ($v[0] * $this->server['rate']), + 'd' => ($v[1] * $this->server['rate']), + 'created_at' => time(), + 'updated_at' => time(), + ]); + } + }, 3); + } + + protected function processUserStatForOtherDatabases(int $uid, array $v, int $recordAt): void + { + StatUser::upsert( + [ + 'user_id' => $uid, + 'server_rate' => $this->server['rate'], + 'record_at' => $recordAt, + 'record_type' => $this->recordType, + 'u' => ($v[0] * $this->server['rate']), + 'd' => ($v[1] * $this->server['rate']), + 'created_at' => time(), + 'updated_at' => time(), + ], + ['user_id', 'server_rate', 'record_at', 'record_type'], + [ + 'u' => DB::raw("u + VALUES(u)"), + 'd' => DB::raw("d + VALUES(d)"), + 'updated_at' => time(), + ] + ); + } } \ No newline at end of file From ebea3e8b8c4fccacc3e5d9bb9b359b7a691caf20 Mon Sep 17 00:00:00 2001 From: xboard <147830212+cedar2025@users.noreply.github.com> Date: Mon, 15 Sep 2025 16:15:39 +0800 Subject: [PATCH 04/14] feat(middleware): Add transaction state guard for Octane --- .../Middleware/EnsureTransactionState.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 app/Http/Middleware/EnsureTransactionState.php diff --git a/app/Http/Middleware/EnsureTransactionState.php b/app/Http/Middleware/EnsureTransactionState.php new file mode 100644 index 0000000..595dc50 --- /dev/null +++ b/app/Http/Middleware/EnsureTransactionState.php @@ -0,0 +1,29 @@ + 0) { + DB::rollBack(); + } + } + } +} From 895f94084998ec478261d9f6fbec9624d1e7d519 Mon Sep 17 00:00:00 2001 From: xboard <147830212+cedar2025@users.noreply.github.com> Date: Mon, 15 Sep 2025 20:32:22 +0800 Subject: [PATCH 05/14] refactor(online-status): consolidate updates and add cleanup command --- .../Commands/CleanupExpiredOnlineStatus.php | 52 +++++++++ app/Console/Kernel.php | 5 +- .../V1/Server/UniProxyController.php | 3 +- app/Jobs/SyncUserOnlineStatusJob.php | 69 ----------- app/Jobs/UpdateAliveDataJob.php | 108 ++++++++++++++++++ 5 files changed, 163 insertions(+), 74 deletions(-) create mode 100644 app/Console/Commands/CleanupExpiredOnlineStatus.php delete mode 100644 app/Jobs/SyncUserOnlineStatusJob.php create mode 100644 app/Jobs/UpdateAliveDataJob.php diff --git a/app/Console/Commands/CleanupExpiredOnlineStatus.php b/app/Console/Commands/CleanupExpiredOnlineStatus.php new file mode 100644 index 0000000..e51afc9 --- /dev/null +++ b/app/Console/Commands/CleanupExpiredOnlineStatus.php @@ -0,0 +1,52 @@ +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/Kernel.php b/app/Console/Kernel.php index c9168b9..d1071c5 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -46,10 +46,7 @@ class Kernel extends ConsoleKernel // if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) { // $schedule->command('backup:database', ['true'])->daily()->onOneServer(); // } - // 每分钟清理过期的在线状态 - $schedule->call(function () { - app(UserOnlineService::class)->cleanExpiredOnlineStatus(); - })->everyMinute()->name('cleanup:expired-online-status')->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 b90e8cf..01a45d2 100644 --- a/app/Http/Controllers/V1/Server/UniProxyController.php +++ b/app/Http/Controllers/V1/Server/UniProxyController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\V1\Server; use App\Http\Controllers\Controller; +use App\Jobs\UpdateAliveDataJob; use App\Services\ServerService; use App\Services\UserService; use App\Utils\CacheKey; @@ -216,7 +217,7 @@ class UniProxyController extends Controller 'error' => 'Invalid online data' ], 400); } - $this->userOnlineService->updateAliveData($data, $node->type, $node->id); + UpdateAliveDataJob::dispatch($data, $node->type, $node->id); return response()->json(['data' => true]); } diff --git a/app/Jobs/SyncUserOnlineStatusJob.php b/app/Jobs/SyncUserOnlineStatusJob.php deleted file mode 100644 index 32d463b..0000000 --- a/app/Jobs/SyncUserOnlineStatusJob.php +++ /dev/null @@ -1,69 +0,0 @@ -updates)) { - return; - } - collect($this->updates) - ->chunk(1000) - ->each(function (Collection $chunk) { - $userIds = $chunk->pluck('id')->all(); - User::query() - ->whereIn('id', $userIds) - ->each(function (User $user) use ($chunk) { - $update = $chunk->firstWhere('id', $user->id); - if ($update) { - $user->update([ - 'online_count' => $update['count'], - 'last_online_at' => now(), - ]); - } - }); - }); - } - - /** - * 任务失败的处理 - */ - public function failed(\Throwable $exception): void - { - \Log::error('Failed to sync user online status', [ - 'error' => $exception->getMessage(), - 'updates_count' => count($this->updates) - ]); - } -} \ No newline at end of file diff --git a/app/Jobs/UpdateAliveDataJob.php b/app/Jobs/UpdateAliveDataJob.php new file mode 100644 index 0000000..b152800 --- /dev/null +++ b/app/Jobs/UpdateAliveDataJob.php @@ -0,0 +1,108 @@ +onQueue('online_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('UpdateAliveDataJob failed', [ + 'error' => $e->getMessage(), + ]); + $this->fail($e); + } + } + + +} From 471ec94ae3bff70ea91f588c77a3d904720c909f Mon Sep 17 00:00:00 2001 From: xboard <147830212+cedar2025@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:44:44 +0800 Subject: [PATCH 06/14] fix --- app/Services/UserOnlineService.php | 69 +----------------------------- 1 file changed, 2 insertions(+), 67 deletions(-) diff --git a/app/Services/UserOnlineService.php b/app/Services/UserOnlineService.php index 8ddc556..42ec854 100644 --- a/app/Services/UserOnlineService.php +++ b/app/Services/UserOnlineService.php @@ -3,12 +3,8 @@ namespace App\Services; -use App\Models\User; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; -use App\Jobs\SyncUserOnlineStatusJob; class UserOnlineService { @@ -16,8 +12,6 @@ class UserOnlineService * 缓存相关常量 */ private const CACHE_PREFIX = 'ALIVE_IP_USER_'; - private const CACHE_TTL = 120; - private const NODE_DATA_EXPIRY = 100; /** * 获取所有限制设备用户的在线数量 @@ -77,47 +71,6 @@ class UserOnlineService ]; } - /** - * 更新用户在线数据 - */ - public function updateAliveData(array $data, string $nodeType, int $nodeId): void - { - $updateAt = now()->timestamp; - $nodeKey = $nodeType . $nodeId; - $userUpdates = []; - - foreach ($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 = $this->calculateDeviceCount($ipsArray); - $ipsArray['alive_ip'] = $count; - cache()->put($cacheKey, $ipsArray, now()->addSeconds(self::CACHE_TTL)); - - $userUpdates[] = [ - 'id' => $uid, - 'count' => $count, - ]; - } - - // 使用队列异步更新数据库 - if (!empty($userUpdates)) { - dispatch(new SyncUserOnlineStatusJob($userUpdates)) - ->onQueue('online_sync') - ->afterCommit(); - } - } /** * 批量获取用户在线设备数 @@ -144,29 +97,13 @@ class UserOnlineService } /** - * 清理过期的在线记录 + * 计算在线设备数量 */ - public function cleanExpiredOnlineStatus(): void - { - dispatch(function () { - User::query() - ->where('last_online_at', '<', now()->subMinutes(5)) - ->update(['online_count' => 0]); - })->onQueue('online_sync'); - } - - /** - * Calculate the number of devices based on IPs array and device limit mode. - * - * @param array $ipsArray Array containing IP data - * @return int Number of devices - */ - private function calculateDeviceCount(array $ipsArray): int + public static function calculateDeviceCount(array $ipsArray): int { $mode = (int) admin_setting('device_limit_mode', 0); return match ($mode) { - // Loose mode: Count unique IPs (ignoring suffixes after '_') 1 => collect($ipsArray) ->filter(fn(mixed $data): bool => is_array($data) && isset($data['aliveips'])) ->flatMap( @@ -177,11 +114,9 @@ class UserOnlineService ) ->unique() ->count(), - // Strict mode: Sum total number of alive IPs 0 => collect($ipsArray) ->filter(fn(mixed $data): bool => is_array($data) && isset($data['aliveips'])) ->sum(fn(array $data): int => count($data['aliveips'])), - // Handle invalid modes default => throw new \InvalidArgumentException("Invalid device limit mode: $mode"), }; } From 34a9ed0fce851c026c7b13364897344d9c28cfad Mon Sep 17 00:00:00 2001 From: xboard <147830212+cedar2025@users.noreply.github.com> Date: Wed, 17 Sep 2025 00:02:59 +0800 Subject: [PATCH 07/14] feat(plugin): add user.subscribe.response hook --- app/Http/Controllers/V1/User/UserController.php | 2 ++ app/Models/User.php | 8 -------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/V1/User/UserController.php b/app/Http/Controllers/V1/User/UserController.php index 6e5b5be..3b39360 100755 --- a/app/Http/Controllers/V1/User/UserController.php +++ b/app/Http/Controllers/V1/User/UserController.php @@ -12,6 +12,7 @@ use App\Models\Ticket; use App\Models\User; use App\Services\Auth\LoginService; use App\Services\AuthService; +use App\Services\Plugin\HookManager; use App\Services\UserService; use App\Utils\CacheKey; use App\Utils\Helper; @@ -156,6 +157,7 @@ class UserController extends Controller $user['subscribe_url'] = Helper::getSubscribeUrl($user['token']); $userService = new UserService(); $user['reset_day'] = $userService->getResetDay($user); + $user = HookManager::filter('user.subscribe.response', $user); return $this->success($user); } diff --git a/app/Models/User.php b/app/Models/User.php index e023554..b5b7fba 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -137,14 +137,6 @@ class User extends Authenticatable return $this->hasMany(TrafficResetLog::class, 'user_id', 'id'); } - /** - * 获取订阅链接属性 - */ - public function getSubscribeUrlAttribute(): string - { - return Helper::getSubscribeUrl($this->token); - } - /** * 检查用户是否处于活跃状态 */ From 221784367ec548c089699f1554670b91f286eae4 Mon Sep 17 00:00:00 2001 From: xboard <147830212+cedar2025@users.noreply.github.com> Date: Sat, 20 Sep 2025 13:36:10 +0800 Subject: [PATCH 08/14] feat(knowledge): add KnowledgeResource with plugin hooks - Add KnowledgeResource with user.knowledge.resource hook - Unify processKnowledgeContent for both single and list items - Remove isListItem parameter for cleaner architecture --- .../V1/User/KnowledgeController.php | 150 ++++++++++++++---- app/Http/Resources/KnowledgeResource.php | 28 ++++ 2 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 app/Http/Resources/KnowledgeResource.php diff --git a/app/Http/Controllers/V1/User/KnowledgeController.php b/app/Http/Controllers/V1/User/KnowledgeController.php index 00f28b3..e3646c9 100644 --- a/app/Http/Controllers/V1/User/KnowledgeController.php +++ b/app/Http/Controllers/V1/User/KnowledgeController.php @@ -4,50 +4,58 @@ namespace App\Http\Controllers\V1\User; use App\Exceptions\ApiException; use App\Http\Controllers\Controller; +use App\Http\Resources\KnowledgeResource; use App\Models\Knowledge; use App\Models\User; +use App\Services\Plugin\HookManager; use App\Services\UserService; use App\Utils\Helper; use Illuminate\Http\Request; class KnowledgeController extends Controller { + private UserService $userService; + + public function __construct(UserService $userService) + { + $this->userService = $userService; + } + public function fetch(Request $request) { - if ($request->input('id')) { - $knowledge = Knowledge::where('id', $request->input('id')) - ->where('show', 1) - ->first(); - - if (!$knowledge) { - return $this->fail([500, __('Article does not exist')]); - } - - $knowledge = $knowledge->toArray(); - $user = User::find($request->user()->id); - $userService = new UserService(); - if (!$userService->isAvailable($user)) { - $this->formatAccessData($knowledge['body']); - } - $subscribeUrl = Helper::getSubscribeUrl($user['token']); - $knowledge['body'] = str_replace('{{siteName}}', admin_setting('app_name', 'XBoard'), $knowledge['body']); - $knowledge['body'] = str_replace('{{subscribeUrl}}', $subscribeUrl, $knowledge['body']); - $knowledge['body'] = str_replace('{{urlEncodeSubscribeUrl}}', urlencode($subscribeUrl), $knowledge['body']); - $knowledge['body'] = str_replace( - '{{safeBase64SubscribeUrl}}', - str_replace( - array('+', '/', '='), - array('-', '_', ''), - base64_encode($subscribeUrl) - ), - $knowledge['body'] - ); - return $this->success($knowledge); + $request->validate([ + 'id' => 'nullable|sometimes|integer|min:1', + 'language' => 'nullable|sometimes|string|max:10', + 'keyword' => 'nullable|sometimes|string|max:255', + ]); + + return $request->input('id') + ? $this->fetchSingle($request) + : $this->fetchList($request); + } + + private function fetchSingle(Request $request) + { + $knowledge = $this->buildKnowledgeQuery() + ->where('id', $request->input('id')) + ->first(); + + if (!$knowledge) { + return $this->fail([500, __('Article does not exist')]); } - $builder = Knowledge::select(['id', 'category', 'title', 'updated_at']) + + $knowledge = $knowledge->toArray(); + $knowledge = $this->processKnowledgeContent($knowledge, $request->user()); + + return $this->success(KnowledgeResource::make($knowledge)); + } + + private function fetchList(Request $request) + { + $builder = $this->buildKnowledgeQuery(['id', 'category', 'title', 'updated_at', 'body']) ->where('language', $request->input('language')) - ->where('show', 1) ->orderBy('sort', 'ASC'); + $keyword = $request->input('keyword'); if ($keyword) { $builder = $builder->where(function ($query) use ($keyword) { @@ -57,14 +65,86 @@ class KnowledgeController extends Controller } $knowledges = $builder->get() + ->map(function ($knowledge) use ($request) { + $knowledge = $knowledge->toArray(); + $knowledge = $this->processKnowledgeContent($knowledge, $request->user()); + return KnowledgeResource::make($knowledge); + }) ->groupBy('category'); + return $this->success($knowledges); } - private function formatAccessData(&$body) + private function buildKnowledgeQuery(array $select = ['*']) { - $pattern = '/(.*?)/s'; - $replacement = '