mirror of
https://github.com/cedar2025/Xboard.git
synced 2025-01-22 18:48:14 -05:00
feat: add multiple new features and enhancements
This commit is contained in:
parent
819feef80c
commit
d8a9a747f8
@ -167,14 +167,14 @@ class MigrateFromV2b extends Command
|
|||||||
|
|
||||||
// 如果记录不存在,则插入
|
// 如果记录不存在,则插入
|
||||||
if ($existingSetting) {
|
if ($existingSetting) {
|
||||||
$this->warn("配置 ${k} 在数据库已经存在, 忽略");
|
$this->warn("配置 {$k} 在数据库已经存在, 忽略");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Setting::create([
|
Setting::create([
|
||||||
'name' => $k,
|
'name' => $k,
|
||||||
'value' => is_array($v)? json_encode($v) : $v,
|
'value' => is_array($v)? json_encode($v) : $v,
|
||||||
]);
|
]);
|
||||||
$this->info("配置 ${k} 迁移成功");
|
$this->info("配置 {$k} 迁移成功");
|
||||||
}
|
}
|
||||||
\Artisan::call('config:cache');
|
\Artisan::call('config:cache');
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ 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
|
||||||
{
|
{
|
||||||
@ -44,6 +45,10 @@ 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->call(function () {
|
||||||
|
app(UserOnlineService::class)->cleanExpiredOnlineStatus();
|
||||||
|
})->everyMinute();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers\V1\Server;
|
namespace App\Http\Controllers\V1\Server;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
@ -9,10 +11,16 @@ 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 Illuminate\Support\Facades\Validator;
|
use App\Services\UserOnlineService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class UniProxyController extends Controller
|
class UniProxyController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserOnlineService $userOnlineService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
// 后端获取用户
|
// 后端获取用户
|
||||||
public function user(Request $request)
|
public function user(Request $request)
|
||||||
{
|
{
|
||||||
@ -142,9 +150,27 @@ class UniProxyController extends Controller
|
|||||||
return response($response)->header('ETag', "\"{$eTag}\"");
|
return response($response)->header('ETag', "\"{$eTag}\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后端提交在线数据
|
// 获取在线用户数据(wyx2685
|
||||||
public function alive(Request $request)
|
public function alivelist(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
return $this->success(true);
|
$node = $request->input('node_info');
|
||||||
|
$deviceLimitUsers = ServerService::getAvailableUsers($node->group_ids)
|
||||||
|
->where('device_limit', '>', 0);
|
||||||
|
$alive = $this->userOnlineService->getAliveList($deviceLimitUsers);
|
||||||
|
return response()->json(['alive' => (object) $alive]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端提交在线数据
|
||||||
|
public function alive(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$node = $request->input('node_info');
|
||||||
|
$data = json_decode(request()->getContent(), true);
|
||||||
|
if ($data === null) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Invalid online data'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
$this->userOnlineService->updateAliveData($data, $node->type, $node->id);
|
||||||
|
return response()->json(['data' => true]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -122,6 +122,7 @@ class ConfigController extends Controller
|
|||||||
'server_token' => admin_setting('server_token'),
|
'server_token' => admin_setting('server_token'),
|
||||||
'server_pull_interval' => admin_setting('server_pull_interval', 60),
|
'server_pull_interval' => admin_setting('server_pull_interval', 60),
|
||||||
'server_push_interval' => admin_setting('server_push_interval', 60),
|
'server_push_interval' => admin_setting('server_push_interval', 60),
|
||||||
|
'device_limit_mode' => (int) admin_setting('device_limit_mode', 0),
|
||||||
],
|
],
|
||||||
'email' => [
|
'email' => [
|
||||||
'email_template' => admin_setting('email_template', 'default'),
|
'email_template' => admin_setting('email_template', 'default'),
|
||||||
@ -178,7 +179,8 @@ class ConfigController extends Controller
|
|||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
foreach ($data as $k => $v) {
|
foreach ($data as $k => $v) {
|
||||||
if ($k == 'frontend_theme') {
|
if ($k == 'frontend_theme') {
|
||||||
ThemeService::switchTheme($v);
|
$themeService = new ThemeService();
|
||||||
|
$themeService->switch($v);
|
||||||
}
|
}
|
||||||
admin_setting([$k => $v]);
|
admin_setting([$k => $v]);
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,8 @@ class PlanController extends Controller
|
|||||||
User::where('plan_id', $plan->id)->update([
|
User::where('plan_id', $plan->id)->update([
|
||||||
'group_id' => $params['group_id'],
|
'group_id' => $params['group_id'],
|
||||||
'transfer_enable' => $params['transfer_enable'] * 1073741824,
|
'transfer_enable' => $params['transfer_enable'] * 1073741824,
|
||||||
'speed_limit' => $params['speed_limit']
|
'speed_limit' => $params['speed_limit'],
|
||||||
|
'device_limit' => $params['device_limit'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$plan->update($params);
|
$plan->update($params);
|
||||||
|
@ -213,6 +213,26 @@ class StatController extends Controller
|
|||||||
$currentMonthStart = strtotime(date('Y-m-01'));
|
$currentMonthStart = strtotime(date('Y-m-01'));
|
||||||
$lastMonthStart = strtotime('-1 month', $currentMonthStart);
|
$lastMonthStart = strtotime('-1 month', $currentMonthStart);
|
||||||
$twoMonthsAgoStart = strtotime('-2 month', $currentMonthStart);
|
$twoMonthsAgoStart = strtotime('-2 month', $currentMonthStart);
|
||||||
|
|
||||||
|
// Today's start timestamp
|
||||||
|
$todayStart = strtotime('today');
|
||||||
|
$yesterdayStart = strtotime('-1 day', $todayStart);
|
||||||
|
|
||||||
|
// Today's income
|
||||||
|
$todayIncome = Order::where('created_at', '>=', $todayStart)
|
||||||
|
->where('created_at', '<', time())
|
||||||
|
->whereNotIn('status', [0, 2])
|
||||||
|
->sum('total_amount');
|
||||||
|
|
||||||
|
// Yesterday's income for day growth calculation
|
||||||
|
$yesterdayIncome = Order::where('created_at', '>=', $yesterdayStart)
|
||||||
|
->where('created_at', '<', $todayStart)
|
||||||
|
->whereNotIn('status', [0, 2])
|
||||||
|
->sum('total_amount');
|
||||||
|
|
||||||
|
// Online users (active in last 10 minutes)
|
||||||
|
$onlineUsers = User::where('t', '>=', time() - 600)
|
||||||
|
->count();
|
||||||
|
|
||||||
// Current month income
|
// Current month income
|
||||||
$currentMonthIncome = Order::where('created_at', '>=', $currentMonthStart)
|
$currentMonthIncome = Order::where('created_at', '>=', $currentMonthStart)
|
||||||
@ -266,9 +286,13 @@ class StatController extends Controller
|
|||||||
$lastMonthIncomeGrowth = $twoMonthsAgoIncome > 0 ? round(($lastMonthIncome - $twoMonthsAgoIncome) / $twoMonthsAgoIncome * 100, 1) : 0;
|
$lastMonthIncomeGrowth = $twoMonthsAgoIncome > 0 ? round(($lastMonthIncome - $twoMonthsAgoIncome) / $twoMonthsAgoIncome * 100, 1) : 0;
|
||||||
$commissionGrowth = $twoMonthsAgoCommission > 0 ? round(($lastMonthCommissionPayout - $twoMonthsAgoCommission) / $twoMonthsAgoCommission * 100, 1) : 0;
|
$commissionGrowth = $twoMonthsAgoCommission > 0 ? round(($lastMonthCommissionPayout - $twoMonthsAgoCommission) / $twoMonthsAgoCommission * 100, 1) : 0;
|
||||||
$userGrowth = $lastMonthNewUsers > 0 ? round(($currentMonthNewUsers - $lastMonthNewUsers) / $lastMonthNewUsers * 100, 1) : 0;
|
$userGrowth = $lastMonthNewUsers > 0 ? round(($currentMonthNewUsers - $lastMonthNewUsers) / $lastMonthNewUsers * 100, 1) : 0;
|
||||||
|
$dayIncomeGrowth = $yesterdayIncome > 0 ? round(($todayIncome - $yesterdayIncome) / $yesterdayIncome * 100, 1) : 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'data' => [
|
'data' => [
|
||||||
|
'todayIncome' => $todayIncome,
|
||||||
|
'onlineUsers' => $onlineUsers,
|
||||||
|
'dayIncomeGrowth' => $dayIncomeGrowth,
|
||||||
'currentMonthIncome' => $currentMonthIncome,
|
'currentMonthIncome' => $currentMonthIncome,
|
||||||
'lastMonthIncome' => $lastMonthIncome,
|
'lastMonthIncome' => $lastMonthIncome,
|
||||||
'lastMonthCommissionPayout' => $lastMonthCommissionPayout,
|
'lastMonthCommissionPayout' => $lastMonthCommissionPayout,
|
||||||
|
@ -7,8 +7,6 @@ use App\Models\Log as LogModel;
|
|||||||
use App\Utils\CacheKey;
|
use App\Utils\CacheKey;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Laravel\Horizon\Contracts\JobRepository;
|
use Laravel\Horizon\Contracts\JobRepository;
|
||||||
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
|
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
|
||||||
use Laravel\Horizon\Contracts\MetricsRepository;
|
use Laravel\Horizon\Contracts\MetricsRepository;
|
||||||
|
@ -65,8 +65,8 @@ class ThemeController extends Controller
|
|||||||
|
|
||||||
// 检查文件名安全性
|
// 检查文件名安全性
|
||||||
$originalName = $file->getClientOriginalName();
|
$originalName = $file->getClientOriginalName();
|
||||||
if (!preg_match('/^[a-zA-Z0-9\-\_]+\.zip$/', $originalName)) {
|
if (!preg_match('/^[a-zA-Z0-9\-\_\.]+\.zip$/', $originalName)) {
|
||||||
throw new ApiException('主题包文件名只能包含字母、数字、下划线和中划线');
|
throw new ApiException('主题包文件名只能包含字母、数字、下划线、中划线和点');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->themeService->upload($file);
|
$this->themeService->upload($file);
|
||||||
@ -117,7 +117,7 @@ class ThemeController extends Controller
|
|||||||
$payload = $request->validate([
|
$payload = $request->validate([
|
||||||
'name' => 'required'
|
'name' => 'required'
|
||||||
]);
|
]);
|
||||||
$this->themeService->switchTheme($payload['name']);
|
$this->themeService->switch($payload['name']);
|
||||||
return $this->success(true);
|
return $this->success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\V2\Admin;
|
namespace App\Http\Controllers\V2\Admin;
|
||||||
|
|
||||||
use App\Exceptions\ApiException;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\UserFetch;
|
|
||||||
use App\Http\Requests\Admin\UserGenerate;
|
use App\Http\Requests\Admin\UserGenerate;
|
||||||
use App\Http\Requests\Admin\UserSendMail;
|
use App\Http\Requests\Admin\UserSendMail;
|
||||||
use App\Http\Requests\Admin\UserUpdate;
|
use App\Http\Requests\Admin\UserUpdate;
|
||||||
@ -75,17 +73,50 @@ class UserController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function buildFilterQuery(Builder $query, string $field, mixed $value): void
|
private function buildFilterQuery(Builder $query, string $field, mixed $value): void
|
||||||
{
|
{
|
||||||
if (!is_array($value)) {
|
// Handle array values for 'in' operations
|
||||||
|
if (is_array($value)) {
|
||||||
|
$query->whereIn($field === 'group_ids' ? 'group_id' : $field, $value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle operator-based filtering
|
||||||
|
if (!is_string($value) || !str_contains($value, ':')) {
|
||||||
$query->where($field, 'like', "%{$value}%");
|
$query->where($field, 'like', "%{$value}%");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($field === 'group_ids') {
|
[$operator, $filterValue] = explode(':', $value, 2);
|
||||||
$query->whereIn('group_id', $value);
|
|
||||||
return;
|
// Convert numeric strings to appropriate type
|
||||||
|
if (is_numeric($filterValue)) {
|
||||||
|
$filterValue = strpos($filterValue, '.') !== false
|
||||||
|
? (float) $filterValue
|
||||||
|
: (int) $filterValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->whereIn($field, $value);
|
// Handle computed fields
|
||||||
|
$queryField = match ($field) {
|
||||||
|
'total_used' => DB::raw('(u + d)'),
|
||||||
|
default => $field
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply operator
|
||||||
|
$query->where($queryField, match (strtolower($operator)) {
|
||||||
|
'eq' => '=',
|
||||||
|
'gt' => '>',
|
||||||
|
'gte' => '>=',
|
||||||
|
'lt' => '<',
|
||||||
|
'lte' => '<=',
|
||||||
|
'like' => 'like',
|
||||||
|
'notlike' => 'not like',
|
||||||
|
'null' => static fn($q) => $q->whereNull($queryField),
|
||||||
|
'notnull' => static fn($q) => $q->whereNotNull($queryField),
|
||||||
|
default => 'like'
|
||||||
|
}, match (strtolower($operator)) {
|
||||||
|
'like', 'notlike' => "%{$filterValue}%",
|
||||||
|
'null', 'notnull' => null,
|
||||||
|
default => $filterValue
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -50,6 +50,7 @@ class ConfigSave extends FormRequest
|
|||||||
'server_token' => 'nullable|min:16',
|
'server_token' => 'nullable|min:16',
|
||||||
'server_pull_interval' => 'integer',
|
'server_pull_interval' => 'integer',
|
||||||
'server_push_interval' => 'integer',
|
'server_push_interval' => 'integer',
|
||||||
|
'device_limit_mode' => 'integer',
|
||||||
// frontend
|
// frontend
|
||||||
'frontend_theme' => '',
|
'frontend_theme' => '',
|
||||||
'frontend_theme_sidebar' => 'nullable|in:dark,light',
|
'frontend_theme_sidebar' => 'nullable|in:dark,light',
|
||||||
|
@ -30,7 +30,8 @@ class UserUpdate extends FormRequest
|
|||||||
'commission_type' => 'integer',
|
'commission_type' => 'integer',
|
||||||
'commission_balance' => 'integer',
|
'commission_balance' => 'integer',
|
||||||
'remarks' => 'nullable',
|
'remarks' => 'nullable',
|
||||||
'speed_limit' => 'nullable|integer'
|
'speed_limit' => 'nullable|integer',
|
||||||
|
'device_limit' => 'nullable|integer'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +61,8 @@ class UserUpdate extends FormRequest
|
|||||||
'balance.integer' => '余额格式不正确',
|
'balance.integer' => '余额格式不正确',
|
||||||
'commission_balance.integer' => '佣金格式不正确',
|
'commission_balance.integer' => '佣金格式不正确',
|
||||||
'password.min' => '密码长度最小8位',
|
'password.min' => '密码长度最小8位',
|
||||||
'speed_limit.integer' => '限速格式不正确'
|
'speed_limit.integer' => '限速格式不正确',
|
||||||
|
'device_limit.integer' => '设备数量格式不正确'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ class ServerRoute
|
|||||||
$route->get('user', [UniProxyController::class, 'user']);
|
$route->get('user', [UniProxyController::class, 'user']);
|
||||||
$route->post('push', [UniProxyController::class, 'push']);
|
$route->post('push', [UniProxyController::class, 'push']);
|
||||||
$route->post('alive', [UniProxyController::class, 'alive']);
|
$route->post('alive', [UniProxyController::class, 'alive']);
|
||||||
|
$route->get('alivelist', [UniProxyController::class, 'alivelist']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
70
app/Jobs/SyncUserOnlineStatusJob.php
Normal file
70
app/Jobs/SyncUserOnlineStatusJob.php
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
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\Collection;
|
||||||
|
|
||||||
|
class SyncUserOnlineStatusJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务最大尝试次数
|
||||||
|
*/
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务可以运行的最大秒数
|
||||||
|
*/
|
||||||
|
public int $timeout = 30;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $updates
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行任务
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
if (empty($this->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)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
@ -60,7 +60,8 @@ class Plan extends Model
|
|||||||
'prices',
|
'prices',
|
||||||
'reset_traffic_method',
|
'reset_traffic_method',
|
||||||
'capacity_limit',
|
'capacity_limit',
|
||||||
'sell'
|
'sell',
|
||||||
|
'device_limit'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
@ -142,6 +142,20 @@ class General implements ProtocolInterface
|
|||||||
case 'grpc':
|
case 'grpc':
|
||||||
$config['serviceName'] = data_get($protocol_settings, 'network_settings.serviceName');
|
$config['serviceName'] = data_get($protocol_settings, 'network_settings.serviceName');
|
||||||
break;
|
break;
|
||||||
|
case 'kcp':
|
||||||
|
$config['path'] = data_get($protocol_settings, 'network_settings.seed');
|
||||||
|
$config['type'] = data_get($protocol_settings, 'network_settings.header.type', 'none');
|
||||||
|
break;
|
||||||
|
case 'httpupgrade':
|
||||||
|
$config['path'] = data_get($protocol_settings, 'network_settings.path');
|
||||||
|
$config['host'] = data_get($protocol_settings, 'network_settings.headers.Host');
|
||||||
|
break;
|
||||||
|
case 'xhttp':
|
||||||
|
$config['path'] = data_get($protocol_settings, 'network_settings.path');
|
||||||
|
$config['host'] = data_get($protocol_settings, 'network_settings.headers.Host');
|
||||||
|
$config['mode'] = data_get($protocol_settings, 'network_settings.mode', 'auto');
|
||||||
|
$config['extra'] = data_get($protocol_settings, 'network_settings.extra') ? Helper::encodeURIComponent(data_get($protocol_settings, 'network_settings.extra')) : null;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $uuid . '@' . $host . ':' . $port;
|
$user = $uuid . '@' . $host . ':' . $port;
|
||||||
|
@ -68,7 +68,7 @@ class SingBox implements ProtocolInterface
|
|||||||
$proxies[] = $vlessConfig;
|
$proxies[] = $vlessConfig;
|
||||||
}
|
}
|
||||||
if ($item['type'] === 'hysteria') {
|
if ($item['type'] === 'hysteria') {
|
||||||
$hysteriaConfig = $this->buildHysteria($this->user['uuid'], $item, $this->user);
|
$hysteriaConfig = $this->buildHysteria($this->user['uuid'], $item);
|
||||||
$proxies[] = $hysteriaConfig;
|
$proxies[] = $hysteriaConfig;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -212,6 +212,12 @@ class SingBox implements ProtocolInterface
|
|||||||
'host' => data_get($protocol_settings, 'network_settings.host') ? [data_get($protocol_settings, 'network_settings.host')] : null,
|
'host' => data_get($protocol_settings, 'network_settings.host') ? [data_get($protocol_settings, 'network_settings.host')] : null,
|
||||||
'path' => data_get($protocol_settings, 'network_settings.path')
|
'path' => data_get($protocol_settings, 'network_settings.path')
|
||||||
],
|
],
|
||||||
|
'httpupgrade' => [
|
||||||
|
'type' => 'httpupgrade',
|
||||||
|
'path' => data_get($protocol_settings, 'network_settings.path'),
|
||||||
|
'host' => data_get($protocol_settings, 'network_settings.headers.Host'),
|
||||||
|
'headers' => data_get($protocol_settings, 'network_settings.headers')
|
||||||
|
],
|
||||||
default => null
|
default => null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function register()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function boot()
|
|
||||||
{
|
|
||||||
$this->app['view']->addNamespace('theme', public_path() . '/theme');
|
|
||||||
}
|
|
||||||
}
|
|
@ -72,7 +72,8 @@ class ServerService
|
|||||||
->select([
|
->select([
|
||||||
'id',
|
'id',
|
||||||
'uuid',
|
'uuid',
|
||||||
'speed_limit'
|
'speed_limit',
|
||||||
|
'device_limit'
|
||||||
])
|
])
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
@ -4,29 +4,99 @@ namespace App\Services;
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Exception;
|
use Exception;
|
||||||
use ZipArchive;
|
use ZipArchive;
|
||||||
|
|
||||||
class ThemeService
|
class ThemeService
|
||||||
{
|
{
|
||||||
private const THEME_DIR = 'theme/';
|
private const SYSTEM_THEME_DIR = 'theme/';
|
||||||
|
private const USER_THEME_DIR = '/storage/theme/';
|
||||||
private const CONFIG_FILE = 'config.json';
|
private const CONFIG_FILE = 'config.json';
|
||||||
private const SETTING_PREFIX = 'theme_';
|
private const SETTING_PREFIX = 'theme_';
|
||||||
private const CANNOT_DELETE_THEMES = ['Xboard', 'v2board'];
|
private const SYSTEM_THEMES = ['Xboard', 'v2board'];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->registerThemeViewPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册主题视图路径
|
||||||
|
*/
|
||||||
|
private function registerThemeViewPaths(): void
|
||||||
|
{
|
||||||
|
// 系统主题路径
|
||||||
|
$systemPath = base_path(self::SYSTEM_THEME_DIR);
|
||||||
|
if (File::exists($systemPath)) {
|
||||||
|
View::addNamespace('theme', $systemPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户主题路径
|
||||||
|
$userPath = base_path(self::USER_THEME_DIR);
|
||||||
|
if (File::exists($userPath)) {
|
||||||
|
View::prependNamespace('theme', $userPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主题视图路径
|
||||||
|
*/
|
||||||
|
public function getThemeViewPath(string $theme): ?string
|
||||||
|
{
|
||||||
|
$themePath = $this->getThemePath($theme);
|
||||||
|
if (!$themePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $themePath . '/dashboard.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有可用主题列表
|
* 获取所有可用主题列表
|
||||||
*/
|
*/
|
||||||
public function getList(): array
|
public function getList(): array
|
||||||
{
|
{
|
||||||
$path = base_path(self::THEME_DIR);
|
$themes = [];
|
||||||
|
|
||||||
|
// 获取系统主题
|
||||||
|
$systemPath = base_path(self::SYSTEM_THEME_DIR);
|
||||||
|
if (File::exists($systemPath)) {
|
||||||
|
$themes = $this->getThemesFromPath($systemPath, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户主题
|
||||||
|
$userPath = base_path(self::USER_THEME_DIR);
|
||||||
|
if (File::exists($userPath)) {
|
||||||
|
$themes = array_merge($themes, $this->getThemesFromPath($userPath, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $themes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定路径获取主题列表
|
||||||
|
*/
|
||||||
|
private function getThemesFromPath(string $path, bool $canDelete): array
|
||||||
|
{
|
||||||
return collect(File::directories($path))
|
return collect(File::directories($path))
|
||||||
->mapWithKeys(function ($dir) {
|
->mapWithKeys(function ($dir) use ($canDelete) {
|
||||||
$name = basename($dir);
|
$name = basename($dir);
|
||||||
|
// 检查必要文件是否存在
|
||||||
|
if (
|
||||||
|
!File::exists($dir . '/' . self::CONFIG_FILE) ||
|
||||||
|
!File::exists($dir . '/dashboard.blade.php')
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
$config = $this->readConfigFile($name);
|
$config = $this->readConfigFile($name);
|
||||||
$config['can_delete'] = !in_array($name, self::CANNOT_DELETE_THEMES) && $name != admin_setting('current_theme');
|
if (!$config) {
|
||||||
return $config ? [$name => $config] : [];
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$config['can_delete'] = $canDelete && $name !== admin_setting('current_theme');
|
||||||
|
$config['is_system'] = !$canDelete;
|
||||||
|
return [$name => $config];
|
||||||
})->toArray();
|
})->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,56 +110,70 @@ class ThemeService
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if ($zip->open($file->path()) !== true) {
|
if ($zip->open($file->path()) !== true) {
|
||||||
throw new Exception('Invalid theme package');
|
throw new Exception('无效的主题包');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证主题包结构
|
// 查找配置文件
|
||||||
$hasConfig = false;
|
$configEntry = collect(range(0, $zip->numFiles - 1))
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
->map(fn($i) => $zip->getNameIndex($i))
|
||||||
if (basename($zip->getNameIndex($i)) === self::CONFIG_FILE) {
|
->first(fn($name) => basename($name) === self::CONFIG_FILE);
|
||||||
$hasConfig = true;
|
|
||||||
break;
|
if (!$configEntry) {
|
||||||
}
|
throw new Exception('主题配置文件不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$hasConfig) {
|
// 解压并读取配置
|
||||||
throw new Exception('Theme configuration file not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解压并移动到主题目录
|
|
||||||
$zip->extractTo($tmpPath);
|
$zip->extractTo($tmpPath);
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
$themeName = basename($tmpPath);
|
$sourcePath = $tmpPath . '/' . rtrim(dirname($configEntry), '.');
|
||||||
$targetPath = base_path(self::THEME_DIR . $themeName);
|
$configFile = $sourcePath . '/' . self::CONFIG_FILE;
|
||||||
|
|
||||||
if (File::exists($targetPath)) {
|
if (!File::exists($configFile)) {
|
||||||
throw new Exception('Theme already exists');
|
throw new Exception('主题配置文件不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
File::moveDirectory($tmpPath, $targetPath);
|
$config = json_decode(File::get($configFile), true);
|
||||||
|
if (empty($config['name'])) {
|
||||||
|
throw new Exception('主题名称未配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为系统主题
|
||||||
|
if (in_array($config['name'], self::SYSTEM_THEMES)) {
|
||||||
|
throw new Exception('不能上传与系统主题同名的主题');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查必要文件
|
||||||
|
if (!File::exists($sourcePath . '/dashboard.blade.php')) {
|
||||||
|
throw new Exception('缺少必要的主题文件:dashboard.blade.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目标目录存在
|
||||||
|
$userThemePath = base_path(self::USER_THEME_DIR);
|
||||||
|
if (!File::exists($userThemePath)) {
|
||||||
|
File::makeDirectory($userThemePath, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPath = $userThemePath . $config['name'];
|
||||||
|
if (File::exists($targetPath)) {
|
||||||
|
throw new Exception('主题已存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
File::moveDirectory($sourcePath, $targetPath);
|
||||||
|
$this->initConfig($config['name']);
|
||||||
|
|
||||||
// 初始化主题配置
|
|
||||||
$this->initConfig($themeName);
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('Theme upload failed', ['error' => $e->getMessage()]);
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
// 清理临时文件
|
||||||
if (File::exists($tmpPath)) {
|
if (File::exists($tmpPath)) {
|
||||||
File::deleteDirectory($tmpPath);
|
File::deleteDirectory($tmpPath);
|
||||||
}
|
}
|
||||||
throw $e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换主题
|
|
||||||
*/
|
|
||||||
public static function switchTheme(string $theme): bool
|
|
||||||
{
|
|
||||||
return (new self())->switch($theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换主题
|
* 切换主题
|
||||||
*/
|
*/
|
||||||
@ -101,20 +185,29 @@ class ThemeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->validateTheme($theme);
|
// 验证主题是否存在
|
||||||
|
$themePath = $this->getThemePath($theme);
|
||||||
|
if (!$themePath) {
|
||||||
|
throw new Exception('主题不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证视图文件是否存在
|
||||||
|
if (!File::exists($this->getThemeViewPath($theme))) {
|
||||||
|
throw new Exception('主题视图文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
// 复制主题文件到public目录
|
// 复制主题文件到public目录
|
||||||
$sourcePath = base_path(self::THEME_DIR . $theme);
|
$targetPath = public_path('theme/' . $theme);
|
||||||
$targetPath = public_path(self::THEME_DIR . $theme);
|
if (!File::copyDirectory($themePath, $targetPath)) {
|
||||||
|
throw new Exception('复制主题文件失败');
|
||||||
if (!File::copyDirectory($sourcePath, $targetPath)) {
|
|
||||||
throw new Exception('Failed to copy theme files');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理旧主题文件
|
// 清理旧主题文件
|
||||||
if ($currentTheme) {
|
if ($currentTheme) {
|
||||||
$oldPath = public_path(self::THEME_DIR . $currentTheme);
|
$oldPath = public_path('theme/' . $currentTheme);
|
||||||
File::exists($oldPath) && File::deleteDirectory($oldPath);
|
if (File::exists($oldPath)) {
|
||||||
|
File::deleteDirectory($oldPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
admin_setting(['current_theme' => $theme]);
|
admin_setting(['current_theme' => $theme]);
|
||||||
@ -131,20 +224,31 @@ class ThemeService
|
|||||||
*/
|
*/
|
||||||
public function delete(string $theme): bool
|
public function delete(string $theme): bool
|
||||||
{
|
{
|
||||||
if ($theme === admin_setting('current_theme') || in_array($theme, self::CANNOT_DELETE_THEMES)) {
|
|
||||||
throw new Exception('Cannot delete active theme');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$themePath = base_path(self::THEME_DIR . $theme);
|
// 检查是否为系统主题
|
||||||
$publicPath = public_path(self::THEME_DIR . $theme);
|
if (in_array($theme, self::SYSTEM_THEMES)) {
|
||||||
|
throw new Exception('系统主题不能删除');
|
||||||
if (!File::exists($themePath)) {
|
|
||||||
throw new Exception('Theme not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为当前使用的主题
|
||||||
|
if ($theme === admin_setting('current_theme')) {
|
||||||
|
throw new Exception('当前使用的主题不能删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取主题路径
|
||||||
|
$themePath = base_path(self::USER_THEME_DIR . $theme);
|
||||||
|
if (!File::exists($themePath)) {
|
||||||
|
throw new Exception('主题不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除主题文件
|
||||||
File::deleteDirectory($themePath);
|
File::deleteDirectory($themePath);
|
||||||
File::exists($publicPath) && File::deleteDirectory($publicPath);
|
|
||||||
|
// 删除public目录下的主题文件
|
||||||
|
$publicPath = public_path('theme/' . $theme);
|
||||||
|
if (File::exists($publicPath)) {
|
||||||
|
File::deleteDirectory($publicPath);
|
||||||
|
}
|
||||||
|
|
||||||
// 清理主题配置
|
// 清理主题配置
|
||||||
admin_setting([self::SETTING_PREFIX . $theme => null]);
|
admin_setting([self::SETTING_PREFIX . $theme => null]);
|
||||||
@ -156,6 +260,32 @@ class ThemeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查主题是否存在
|
||||||
|
*/
|
||||||
|
public function exists(string $theme): bool
|
||||||
|
{
|
||||||
|
return $this->getThemePath($theme) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主题路径
|
||||||
|
*/
|
||||||
|
private function getThemePath(string $theme): ?string
|
||||||
|
{
|
||||||
|
$systemPath = base_path(self::SYSTEM_THEME_DIR . $theme);
|
||||||
|
if (File::exists($systemPath)) {
|
||||||
|
return $systemPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userPath = base_path(self::USER_THEME_DIR . $theme);
|
||||||
|
if (File::exists($userPath)) {
|
||||||
|
return $userPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取主题配置
|
* 获取主题配置
|
||||||
*/
|
*/
|
||||||
@ -175,8 +305,15 @@ class ThemeService
|
|||||||
public function updateConfig(string $theme, array $config): bool
|
public function updateConfig(string $theme, array $config): bool
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->validateTheme($theme);
|
// 验证主题是否存在
|
||||||
|
if (!$this->getThemePath($theme)) {
|
||||||
|
throw new Exception('主题不存在');
|
||||||
|
}
|
||||||
|
|
||||||
$schema = $this->readConfigFile($theme);
|
$schema = $this->readConfigFile($theme);
|
||||||
|
if (!$schema) {
|
||||||
|
throw new Exception('主题配置文件无效');
|
||||||
|
}
|
||||||
|
|
||||||
// 只保留有效的配置字段
|
// 只保留有效的配置字段
|
||||||
$validFields = collect($schema['configs'] ?? [])->pluck('field_name')->toArray();
|
$validFields = collect($schema['configs'] ?? [])->pluck('field_name')->toArray();
|
||||||
@ -201,18 +338,13 @@ class ThemeService
|
|||||||
*/
|
*/
|
||||||
private function readConfigFile(string $theme): ?array
|
private function readConfigFile(string $theme): ?array
|
||||||
{
|
{
|
||||||
$file = base_path(self::THEME_DIR . $theme . '/' . self::CONFIG_FILE);
|
$themePath = $this->getThemePath($theme);
|
||||||
return File::exists($file) ? json_decode(File::get($file), true) : null;
|
if (!$themePath) {
|
||||||
}
|
return null;
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证主题
|
|
||||||
*/
|
|
||||||
private function validateTheme(string $theme): void
|
|
||||||
{
|
|
||||||
if (!$this->readConfigFile($theme)) {
|
|
||||||
throw new Exception("Invalid theme: {$theme}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$file = $themePath . '/' . self::CONFIG_FILE;
|
||||||
|
return File::exists($file) ? json_decode(File::get($file), true) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -221,13 +353,13 @@ class ThemeService
|
|||||||
private function initConfig(string $theme): void
|
private function initConfig(string $theme): void
|
||||||
{
|
{
|
||||||
$config = $this->readConfigFile($theme);
|
$config = $this->readConfigFile($theme);
|
||||||
if (!$config)
|
if (!$config) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$defaults = collect($config['configs'] ?? [])
|
$defaults = collect($config['configs'] ?? [])
|
||||||
->mapWithKeys(fn($col) => [$col['field_name'] => $col['default_value'] ?? ''])
|
->mapWithKeys(fn($col) => [$col['field_name'] => $col['default_value'] ?? ''])
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
admin_setting([self::SETTING_PREFIX . $theme => $defaults]);
|
admin_setting([self::SETTING_PREFIX . $theme => $defaults]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
182
app/Services/UserOnlineService.php
Normal file
182
app/Services/UserOnlineService.php
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 缓存相关常量
|
||||||
|
*/
|
||||||
|
private const CACHE_PREFIX = 'ALIVE_IP_USER_';
|
||||||
|
private const CACHE_TTL = 120;
|
||||||
|
private const NODE_DATA_EXPIRY = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有限制设备用户的在线数量
|
||||||
|
*/
|
||||||
|
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 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取用户在线设备数
|
||||||
|
*/
|
||||||
|
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 function cleanExpiredOnlineStatus(): void
|
||||||
|
{
|
||||||
|
dispatch(function () {
|
||||||
|
User::query()
|
||||||
|
->where('last_online_at', '<', now()->subMinutes(5))
|
||||||
|
->update(['online_count' => 0]);
|
||||||
|
})->onQueue('online_sync');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算设备数量
|
||||||
|
*/
|
||||||
|
private function calculateDeviceCount(array $ipsArray): int
|
||||||
|
{
|
||||||
|
// 设备限制模式
|
||||||
|
return match ((int) admin_setting('device_limit_mode', 0)) {
|
||||||
|
// 宽松模式
|
||||||
|
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']))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,7 @@ class Setting
|
|||||||
*/
|
*/
|
||||||
public function get($key, $default = null)
|
public function get($key, $default = null)
|
||||||
{
|
{
|
||||||
|
$key = strtolower($key);
|
||||||
return Arr::get($this->fromDatabase(), $key, $default);
|
return Arr::get($this->fromDatabase(), $key, $default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ class Setting
|
|||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
$value = json_encode($value);
|
$value = json_encode($value);
|
||||||
}
|
}
|
||||||
|
$key = strtolower($key);
|
||||||
SettingModel::updateOrCreate(['name' => $key], ['value' => $value]);
|
SettingModel::updateOrCreate(['name' => $key], ['value' => $value]);
|
||||||
$this->cache->forget(self::CACHE_KEY);
|
$this->cache->forget(self::CACHE_KEY);
|
||||||
return true;
|
return true;
|
||||||
@ -81,11 +83,10 @@ class Setting
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return $this->cache->rememberForever(self::CACHE_KEY, function (): array {
|
return $this->cache->rememberForever(self::CACHE_KEY, function (): array {
|
||||||
return SettingModel::pluck('value', 'name')->toArray();
|
return array_change_key_case(SettingModel::pluck('value', 'name')->toArray(), CASE_LOWER);
|
||||||
});
|
});
|
||||||
} catch (\Throwable $th) {
|
} catch (\Throwable $th) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -166,4 +166,10 @@ class Helper
|
|||||||
$fingerprints = ['chrome', 'firefox', 'safari', 'ios', 'edge', 'qq'];
|
$fingerprints = ['chrome', 'firefox', 'safari', 'ios', 'edge', 'qq'];
|
||||||
return \Arr::random($fingerprints);
|
return \Arr::random($fingerprints);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function encodeURIComponent($str) {
|
||||||
|
$revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')');
|
||||||
|
return strtr(rawurlencode($str), $revert);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -169,7 +169,6 @@ return [
|
|||||||
/*
|
/*
|
||||||
* Application Service Providers...
|
* Application Service Providers...
|
||||||
*/
|
*/
|
||||||
App\Providers\AppServiceProvider::class,
|
|
||||||
App\Providers\AuthServiceProvider::class,
|
App\Providers\AuthServiceProvider::class,
|
||||||
// App\Providers\BroadcastServiceProvider::class,
|
// App\Providers\BroadcastServiceProvider::class,
|
||||||
App\Providers\EventServiceProvider::class,
|
App\Providers\EventServiceProvider::class,
|
||||||
|
@ -179,6 +179,7 @@ return [
|
|||||||
'send_email',
|
'send_email',
|
||||||
'send_email_mass',
|
'send_email_mass',
|
||||||
'send_telegram',
|
'send_telegram',
|
||||||
|
'online_sync'
|
||||||
],
|
],
|
||||||
'balance' => 'auto',
|
'balance' => 'auto',
|
||||||
'minProcesses' => 1,
|
'minProcesses' => 1,
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('v2_plan', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('device_limit')->nullable()->after('speed_limit');
|
||||||
|
});
|
||||||
|
Schema::table('v2_user', function (Blueprint $table) {
|
||||||
|
$table->integer('device_limit')->nullable()->after('expired_at');
|
||||||
|
$table->integer('online_count')->nullable()->after('device_limit');
|
||||||
|
$table->timestamp('last_online_at')->nullable()->after('online_count');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('v2_user', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('device_limit');
|
||||||
|
$table->dropColumn('online_count');
|
||||||
|
$table->dropColumn('last_online_at');
|
||||||
|
});
|
||||||
|
Schema::table('v2_plan', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('device_limit');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
2
public/assets/admin/assets/index.css
vendored
2
public/assets/admin/assets/index.css
vendored
File diff suppressed because one or more lines are too long
18
public/assets/admin/assets/index.js
vendored
18
public/assets/admin/assets/index.js
vendored
File diff suppressed because one or more lines are too long
416
public/assets/admin/assets/vendor.js
vendored
416
public/assets/admin/assets/vendor.js
vendored
File diff suppressed because one or more lines are too long
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Services\ThemeService;
|
use App\Services\ThemeService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@ -21,22 +22,42 @@ Route::get('/', function (Request $request) {
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$renderParams = [
|
|
||||||
'title' => admin_setting('app_name', 'Xboard'),
|
|
||||||
'theme' => admin_setting('frontend_theme', 'Xboard'),
|
|
||||||
'version' => config('app.version'),
|
|
||||||
'description' => admin_setting('app_description', 'Xboard is best'),
|
|
||||||
'logo' => admin_setting('logo')
|
|
||||||
];
|
|
||||||
|
|
||||||
$theme = admin_setting('frontend_theme', 'Xboard');
|
$theme = admin_setting('frontend_theme', 'Xboard');
|
||||||
|
$themeService = new ThemeService();
|
||||||
|
|
||||||
if (!admin_setting("theme_{$theme}")) {
|
try {
|
||||||
ThemeService::switchTheme($theme);
|
// 检查主题是否存在,不存在则尝试切换到默认主题
|
||||||
|
if (!$themeService->exists($theme)) {
|
||||||
|
if ($theme !== 'Xboard') {
|
||||||
|
Log::warning('Theme not found, switching to default theme', ['theme' => $theme]);
|
||||||
|
$theme = 'Xboard';
|
||||||
|
admin_setting(['frontend_theme' => $theme]);
|
||||||
|
}
|
||||||
|
$themeService->switch($theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查主题视图文件是否存在
|
||||||
|
if (!$themeService->getThemeViewPath($theme)) {
|
||||||
|
throw new Exception('主题视图文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$renderParams = [
|
||||||
|
'title' => admin_setting('app_name', 'Xboard'),
|
||||||
|
'theme' => $theme,
|
||||||
|
'version' => config('app.version'),
|
||||||
|
'description' => admin_setting('app_description', 'Xboard is best'),
|
||||||
|
'logo' => admin_setting('logo'),
|
||||||
|
'theme_config' => $themeService->getConfig($theme)
|
||||||
|
];
|
||||||
|
return view('theme::' . $theme . '.dashboard', $renderParams);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Theme rendering failed', [
|
||||||
|
'theme' => $theme,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
abort(500, '主题加载失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
$renderParams['theme_config'] = (new ThemeService())->getConfig($theme);
|
|
||||||
return view('theme::' . $theme . '.dashboard', $renderParams);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
//TODO:: 兼容
|
//TODO:: 兼容
|
||||||
|
2
storage/theme/.gitignore
vendored
Normal file
2
storage/theme/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
Loading…
Reference in New Issue
Block a user