From f289f688987bc95434d548b84d83e49a4aca1281 Mon Sep 17 00:00:00 2001 From: xboard 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 1fd4f923adee425ba91614f0b0bfcfcbc5249e7f Mon Sep 17 00:00:00 2001 From: xboard 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 2ac126dd42e6400fe8f5f0c644348b05a4eb4497 Mon Sep 17 00:00:00 2001 From: xboard 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 cd8a8ecf589842598434d71e651b1347f9e66261 Mon Sep 17 00:00:00 2001 From: xboard 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 843c5af4c2eaccbdb3bd7e0f519bed821c4654b0 Mon Sep 17 00:00:00 2001 From: xboard 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 58a374bde9d690b0d75cea070c1e84ef625c77ef Mon Sep 17 00:00:00 2001 From: xboard 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 8ae3de511b0d97eca993c920932d676a8ac6dab1 Mon Sep 17 00:00:00 2001 From: xboard 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 61a44483d41686070943309c4d2addc60e28c655 Mon Sep 17 00:00:00 2001 From: xboard 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 = '
' . __('You must have a valid subscription to view content in this area') . '
'; - $body = preg_replace($pattern, $replacement, $body); + return Knowledge::select($select)->where('show', 1); + } + + private function processKnowledgeContent(array $knowledge, User $user): array + { + if (!isset($knowledge['body'])) { + return $knowledge; + } + + if (!$this->userService->isAvailable($user)) { + $this->formatAccessData($knowledge['body']); + } + $subscribeUrl = Helper::getSubscribeUrl($user['token']); + $knowledge['body'] = $this->replacePlaceholders($knowledge['body'], $subscribeUrl); + + return $knowledge; + } + + private function formatAccessData(&$body): void + { + $rules = [ + [ + 'type' => 'regex', + 'pattern' => '/(.*?)/s', + 'replacement' => '
' . __('You must have a valid subscription to view content in this area') . '
' + ] + ]; + + $this->applyReplacementRules($body, $rules); + } + + private function replacePlaceholders(string $body, string $subscribeUrl): string + { + $rules = [ + [ + 'type' => 'string', + 'search' => '{{siteName}}', + 'replacement' => admin_setting('app_name', 'XBoard') + ], + [ + 'type' => 'string', + 'search' => '{{subscribeUrl}}', + 'replacement' => $subscribeUrl + ], + [ + 'type' => 'string', + 'search' => '{{urlEncodeSubscribeUrl}}', + 'replacement' => urlencode($subscribeUrl) + ], + [ + 'type' => 'string', + 'search' => '{{safeBase64SubscribeUrl}}', + 'replacement' => str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($subscribeUrl)) + ] + ]; + + $this->applyReplacementRules($body, $rules); + return $body; + } + + private function applyReplacementRules(string &$body, array $rules): void + { + foreach ($rules as $rule) { + if ($rule['type'] === 'regex') { + $body = preg_replace($rule['pattern'], $rule['replacement'], $body); + } else { + $body = str_replace($rule['search'], $rule['replacement'], $body); + } + } } } diff --git a/app/Http/Resources/KnowledgeResource.php b/app/Http/Resources/KnowledgeResource.php new file mode 100644 index 0000000..cda796c --- /dev/null +++ b/app/Http/Resources/KnowledgeResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + $data = [ + 'id' => $this['id'], + 'category' => $this['category'], + 'title' => $this['title'], + 'body' => $this->when(isset($this['body']), $this['body']), + 'updated_at' => $this['updated_at'], + ]; + + return HookManager::filter('user.knowledge.resource', $data, $request, $this); + } +} From 2808f407376cee9b1dae0dc856d250c92f365c35 Mon Sep 17 00:00:00 2001 From: xboard Date: Mon, 22 Sep 2025 18:47:25 +0800 Subject: [PATCH 09/14] Remove Smogate plugin --- plugins/.gitignore | 1 - plugins/Smogate/Plugin.php | 128 ------------------------------------ plugins/Smogate/config.json | 8 --- 3 files changed, 137 deletions(-) delete mode 100644 plugins/Smogate/Plugin.php delete mode 100644 plugins/Smogate/config.json diff --git a/plugins/.gitignore b/plugins/.gitignore index ab7bf93..34eb213 100644 --- a/plugins/.gitignore +++ b/plugins/.gitignore @@ -5,5 +5,4 @@ !Coinbase !Epay !Mgate -!Smogate !Telegram \ No newline at end of file diff --git a/plugins/Smogate/Plugin.php b/plugins/Smogate/Plugin.php deleted file mode 100644 index e5c71de..0000000 --- a/plugins/Smogate/Plugin.php +++ /dev/null @@ -1,128 +0,0 @@ -filter('available_payment_methods', function($methods) { - if ($this->getConfig('enabled', true)) { - $methods['Smogate'] = [ - 'name' => $this->getConfig('display_name', 'Smogate'), - 'icon' => $this->getConfig('icon', '🔥'), - 'plugin_code' => $this->getPluginCode(), - 'type' => 'plugin' - ]; - } - return $methods; - }); - } - - public function form(): array - { - return [ - 'smogate_app_id' => [ - 'label' => 'APP ID', - 'type' => 'string', - 'required' => true, - 'description' => 'Smogate -> 接入文档和密钥 -> 查看APPID和密钥' - ], - 'smogate_app_secret' => [ - 'label' => 'APP Secret', - 'type' => 'string', - 'required' => true, - 'description' => 'Smogate -> 接入文档和密钥 -> 查看APPID和密钥' - ], - 'smogate_source_currency' => [ - 'label' => '源货币', - 'type' => 'string', - 'description' => '默认CNY,源货币类型' - ], - 'smogate_method' => [ - 'label' => '支付方式', - 'type' => 'string', - 'required' => true, - 'description' => 'Smogate支付方式标识' - ] - ]; - } - - public function pay($order): array - { - $params = [ - 'out_trade_no' => $order['trade_no'], - 'total_amount' => $order['total_amount'], - 'notify_url' => $order['notify_url'], - 'method' => $this->getConfig('smogate_method') - ]; - - if ($this->getConfig('smogate_source_currency')) { - $params['source_currency'] = strtolower($this->getConfig('smogate_source_currency')); - } - - $params['app_id'] = $this->getConfig('smogate_app_id'); - ksort($params); - $str = http_build_query($params) . $this->getConfig('smogate_app_secret'); - $params['sign'] = md5($str); - - $curl = new Curl(); - $curl->setUserAgent("Smogate {$this->getConfig('smogate_app_id')}"); - $curl->setOpt(CURLOPT_SSL_VERIFYPEER, 0); - $curl->post("https://{$this->getConfig('smogate_app_id')}.vless.org/v1/gateway/pay", http_build_query($params)); - $result = $curl->response; - - if (!$result) { - abort(500, '网络异常'); - } - - if ($curl->error) { - if (isset($result->errors)) { - $errors = (array)$result->errors; - abort(500, $errors[array_keys($errors)[0]][0]); - } - if (isset($result->message)) { - abort(500, $result->message); - } - abort(500, '未知错误'); - } - - $curl->close(); - - if (!isset($result->data)) { - abort(500, '请求失败'); - } - - return [ - 'type' => $this->isMobile() ? 1 : 0, - 'data' => $result->data - ]; - } - - public function notify($params): array|bool - { - $sign = $params['sign']; - unset($params['sign']); - ksort($params); - reset($params); - $str = http_build_query($params) . $this->getConfig('smogate_app_secret'); - - if ($sign !== md5($str)) { - return false; - } - - return [ - 'trade_no' => $params['out_trade_no'], - 'callback_no' => $params['trade_no'] - ]; - } - - private function isMobile(): bool - { - return strpos(strtolower($_SERVER['HTTP_USER_AGENT']), 'mobile') !== false; - } -} \ No newline at end of file diff --git a/plugins/Smogate/config.json b/plugins/Smogate/config.json deleted file mode 100644 index 84e12b0..0000000 --- a/plugins/Smogate/config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Smogate", - "code": "smogate", - "type": "payment", - "version": "1.0.0", - "description": "Smogate payment plugin", - "author": "XBoard Team" -} \ No newline at end of file From 92c448e2e10a4c9dadc58d21927cff65036a1599 Mon Sep 17 00:00:00 2001 From: xboard Date: Mon, 22 Sep 2025 22:52:11 +0800 Subject: [PATCH 10/14] fix: remove smogate --- app/Models/Plugin.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 84c7dd1..0425558 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -36,7 +36,6 @@ class Plugin extends Model 'coinbase', // Coinbase 'coin_payments', // CoinPayments 'mgate', // MGate - 'smogate', // Smogate 'telegram', // Telegram ]; From bf1234a9c292f5f0cb4e7afc1d4a3787b53ad279 Mon Sep 17 00:00:00 2001 From: xboard Date: Tue, 23 Sep 2025 14:59:22 +0800 Subject: [PATCH 11/14] fix(plugin): remove stale plugin records when files missing; adjust logging --- app/Services/Plugin/PluginManager.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Services/Plugin/PluginManager.php b/app/Services/Plugin/PluginManager.php index 3efe640..5c3b351 100644 --- a/app/Services/Plugin/PluginManager.php +++ b/app/Services/Plugin/PluginManager.php @@ -53,7 +53,8 @@ class PluginManager if (!class_exists($pluginClass)) { $pluginFile = $this->getPluginPath($pluginCode) . '/Plugin.php'; if (!File::exists($pluginFile)) { - Log::error("Plugin class file not found: {$pluginFile}"); + Log::warning("Plugin class file not found: {$pluginFile}"); + Plugin::query()->where('code', $pluginCode)->delete(); return null; } require_once $pluginFile; From ccd65f26d4aedfcb3f449f6cbfcb43011c9f2beb Mon Sep 17 00:00:00 2001 From: xboard Date: Tue, 23 Sep 2025 18:57:44 +0800 Subject: [PATCH 12/14] fix(jobs): resolve PostgreSQL issue in StatServerJob and StatUserJob --- app/Jobs/StatServerJob.php | 34 +++++++++++++++++++++++++++++++++- app/Jobs/StatUserJob.php | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/app/Jobs/StatServerJob.php b/app/Jobs/StatServerJob.php index 9c822cc..e8460a6 100644 --- a/app/Jobs/StatServerJob.php +++ b/app/Jobs/StatServerJob.php @@ -67,8 +67,11 @@ class StatServerJob implements ShouldQueue protected function processServerStat(int $u, int $d, int $recordAt): void { - if (config('database.default') === 'sqlite') { + $driver = config('database.default'); + if ($driver === 'sqlite') { $this->processServerStatForSqlite($u, $d, $recordAt); + } elseif ($driver === 'pgsql') { + $this->processServerStatForPostgres($u, $d, $recordAt); } else { $this->processServerStatForOtherDatabases($u, $d, $recordAt); } @@ -126,4 +129,33 @@ class StatServerJob implements ShouldQueue ] ); } + + /** + * PostgreSQL upsert with arithmetic increments using ON CONFLICT ... DO UPDATE + */ + protected function processServerStatForPostgres(int $u, int $d, int $recordAt): void + { + $table = (new StatServer())->getTable(); + $now = time(); + + // Use parameter binding to avoid SQL injection and keep maintainability + $sql = "INSERT INTO {$table} (record_at, server_id, server_type, record_type, u, d, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (server_id, server_type, record_at) + DO UPDATE SET + u = {$table}.u + EXCLUDED.u, + d = {$table}.d + EXCLUDED.d, + updated_at = EXCLUDED.updated_at"; + + DB::statement($sql, [ + $recordAt, + $this->server['id'], + $this->protocol, + $this->recordType, + $u, + $d, + $now, + $now, + ]); + } } diff --git a/app/Jobs/StatUserJob.php b/app/Jobs/StatUserJob.php index db00882..4e3b81c 100644 --- a/app/Jobs/StatUserJob.php +++ b/app/Jobs/StatUserJob.php @@ -63,8 +63,11 @@ class StatUserJob implements ShouldQueue protected function processUserStat(int $uid, array $v, int $recordAt): void { - if (config('database.default') === 'sqlite') { + $driver = config('database.default'); + if ($driver === 'sqlite') { $this->processUserStatForSqlite($uid, $v, $recordAt); + } elseif ($driver === 'pgsql') { + $this->processUserStatForPostgres($uid, $v, $recordAt); } else { $this->processUserStatForOtherDatabases($uid, $v, $recordAt); } @@ -122,4 +125,34 @@ class StatUserJob implements ShouldQueue ] ); } + + /** + * PostgreSQL upsert with arithmetic increments using ON CONFLICT ... DO UPDATE + */ + protected function processUserStatForPostgres(int $uid, array $v, int $recordAt): void + { + $table = (new StatUser())->getTable(); + $now = time(); + $u = ($v[0] * $this->server['rate']); + $d = ($v[1] * $this->server['rate']); + + $sql = "INSERT INTO {$table} (user_id, server_rate, record_at, record_type, u, d, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, server_rate, record_at) + DO UPDATE SET + u = {$table}.u + EXCLUDED.u, + d = {$table}.d + EXCLUDED.d, + updated_at = EXCLUDED.updated_at"; + + DB::statement($sql, [ + $uid, + $this->server['rate'], + $recordAt, + $this->recordType, + $u, + $d, + $now, + $now, + ]); + } } \ No newline at end of file From c5ac76823df254eb519bc9a900e78ba7c9100d56 Mon Sep 17 00:00:00 2001 From: xboard Date: Wed, 24 Sep 2025 22:19:04 +0800 Subject: [PATCH 13/14] allow free plans --- app/Http/Requests/Admin/PlanSave.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Requests/Admin/PlanSave.php b/app/Http/Requests/Admin/PlanSave.php index 13c4a58..c35e4a7 100755 --- a/app/Http/Requests/Admin/PlanSave.php +++ b/app/Http/Requests/Admin/PlanSave.php @@ -82,10 +82,10 @@ class PlanSave extends FormRequest "prices.{$period}", "价格必须是数字格式" ); - } elseif ($numericPrice <= 0) { + } elseif ($numericPrice < 0) { $validator->errors()->add( "prices.{$period}", - "价格必须大于 0(如不需要此周期请留空或设为 null)" + "价格必须大于等于 0(如不需要此周期请留空)" ); } } From f83bdfc9ad5a9e1936e984e33dc99f076e150c43 Mon Sep 17 00:00:00 2001 From: xboard Date: Fri, 26 Sep 2025 19:04:17 +0800 Subject: [PATCH 14/14] fix: avoid getCurrentCommit on cache hit --- app/Services/UpdateService.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/Services/UpdateService.php b/app/Services/UpdateService.php index 2406d3c..de063cd 100644 --- a/app/Services/UpdateService.php +++ b/app/Services/UpdateService.php @@ -24,8 +24,10 @@ class UpdateService */ public function getCurrentVersion(): string { - $date = Cache::get(self::CACHE_VERSION_DATE, date('Ymd')); - $hash = Cache::get(self::CACHE_VERSION, $this->getCurrentCommit()); + $date = Cache::get(self::CACHE_VERSION_DATE) ?? date('Ymd'); + $hash = Cache::rememberForever(self::CACHE_VERSION, function () { + return $this->getCurrentCommit(); + }); return $date . '-' . $hash; } @@ -49,8 +51,9 @@ class UpdateService // Fallback Cache::forever(self::CACHE_VERSION_DATE, date('Ymd')); - Cache::forever(self::CACHE_VERSION, $this->getCurrentCommit()); - Log::info('Version cache updated (fallback): ' . date('Ymd') . '-' . $this->getCurrentCommit()); + $fallbackHash = $this->getCurrentCommit(); + Cache::forever(self::CACHE_VERSION, $fallbackHash); + Log::info('Version cache updated (fallback): ' . date('Ymd') . '-' . $fallbackHash); } public function checkForUpdates(): array