mirror of
https://github.com/cedar2025/Xboard.git
synced 2025-01-22 10:38: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) {
|
||||
$this->warn("配置 ${k} 在数据库已经存在, 忽略");
|
||||
$this->warn("配置 {$k} 在数据库已经存在, 忽略");
|
||||
continue;
|
||||
}
|
||||
Setting::create([
|
||||
'name' => $k,
|
||||
'value' => is_array($v)? json_encode($v) : $v,
|
||||
]);
|
||||
$this->info("配置 ${k} 迁移成功");
|
||||
$this->info("配置 {$k} 迁移成功");
|
||||
}
|
||||
\Artisan::call('config:cache');
|
||||
|
||||
|
@ -6,6 +6,7 @@ use App\Utils\CacheKey;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\UserOnlineService;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
@ -44,6 +45,10 @@ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\V1\Server;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
@ -9,10 +11,16 @@ use App\Utils\CacheKey;
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use App\Services\UserOnlineService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UniProxyController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserOnlineService $userOnlineService
|
||||
) {
|
||||
}
|
||||
|
||||
// 后端获取用户
|
||||
public function user(Request $request)
|
||||
{
|
||||
@ -142,9 +150,27 @@ class UniProxyController extends Controller
|
||||
return response($response)->header('ETag', "\"{$eTag}\"");
|
||||
}
|
||||
|
||||
// 后端提交在线数据
|
||||
public function alive(Request $request)
|
||||
// 获取在线用户数据(wyx2685
|
||||
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_pull_interval' => admin_setting('server_pull_interval', 60),
|
||||
'server_push_interval' => admin_setting('server_push_interval', 60),
|
||||
'device_limit_mode' => (int) admin_setting('device_limit_mode', 0),
|
||||
],
|
||||
'email' => [
|
||||
'email_template' => admin_setting('email_template', 'default'),
|
||||
@ -178,7 +179,8 @@ class ConfigController extends Controller
|
||||
$data = $request->validated();
|
||||
foreach ($data as $k => $v) {
|
||||
if ($k == 'frontend_theme') {
|
||||
ThemeService::switchTheme($v);
|
||||
$themeService = new ThemeService();
|
||||
$themeService->switch($v);
|
||||
}
|
||||
admin_setting([$k => $v]);
|
||||
}
|
||||
|
@ -49,7 +49,8 @@ class PlanController extends Controller
|
||||
User::where('plan_id', $plan->id)->update([
|
||||
'group_id' => $params['group_id'],
|
||||
'transfer_enable' => $params['transfer_enable'] * 1073741824,
|
||||
'speed_limit' => $params['speed_limit']
|
||||
'speed_limit' => $params['speed_limit'],
|
||||
'device_limit' => $params['device_limit'],
|
||||
]);
|
||||
}
|
||||
$plan->update($params);
|
||||
|
@ -213,6 +213,26 @@ class StatController extends Controller
|
||||
$currentMonthStart = strtotime(date('Y-m-01'));
|
||||
$lastMonthStart = strtotime('-1 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
|
||||
$currentMonthIncome = Order::where('created_at', '>=', $currentMonthStart)
|
||||
@ -266,9 +286,13 @@ class StatController extends Controller
|
||||
$lastMonthIncomeGrowth = $twoMonthsAgoIncome > 0 ? round(($lastMonthIncome - $twoMonthsAgoIncome) / $twoMonthsAgoIncome * 100, 1) : 0;
|
||||
$commissionGrowth = $twoMonthsAgoCommission > 0 ? round(($lastMonthCommissionPayout - $twoMonthsAgoCommission) / $twoMonthsAgoCommission * 100, 1) : 0;
|
||||
$userGrowth = $lastMonthNewUsers > 0 ? round(($currentMonthNewUsers - $lastMonthNewUsers) / $lastMonthNewUsers * 100, 1) : 0;
|
||||
$dayIncomeGrowth = $yesterdayIncome > 0 ? round(($todayIncome - $yesterdayIncome) / $yesterdayIncome * 100, 1) : 0;
|
||||
|
||||
return [
|
||||
'data' => [
|
||||
'todayIncome' => $todayIncome,
|
||||
'onlineUsers' => $onlineUsers,
|
||||
'dayIncomeGrowth' => $dayIncomeGrowth,
|
||||
'currentMonthIncome' => $currentMonthIncome,
|
||||
'lastMonthIncome' => $lastMonthIncome,
|
||||
'lastMonthCommissionPayout' => $lastMonthCommissionPayout,
|
||||
|
@ -7,8 +7,6 @@ use App\Models\Log as LogModel;
|
||||
use App\Utils\CacheKey;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Horizon\Contracts\JobRepository;
|
||||
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
|
||||
use Laravel\Horizon\Contracts\MetricsRepository;
|
||||
|
@ -65,8 +65,8 @@ class ThemeController extends Controller
|
||||
|
||||
// 检查文件名安全性
|
||||
$originalName = $file->getClientOriginalName();
|
||||
if (!preg_match('/^[a-zA-Z0-9\-\_]+\.zip$/', $originalName)) {
|
||||
throw new ApiException('主题包文件名只能包含字母、数字、下划线和中划线');
|
||||
if (!preg_match('/^[a-zA-Z0-9\-\_\.]+\.zip$/', $originalName)) {
|
||||
throw new ApiException('主题包文件名只能包含字母、数字、下划线、中划线和点');
|
||||
}
|
||||
|
||||
$this->themeService->upload($file);
|
||||
@ -117,7 +117,7 @@ class ThemeController extends Controller
|
||||
$payload = $request->validate([
|
||||
'name' => 'required'
|
||||
]);
|
||||
$this->themeService->switchTheme($payload['name']);
|
||||
$this->themeService->switch($payload['name']);
|
||||
return $this->success(true);
|
||||
}
|
||||
|
||||
|
@ -2,9 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\V2\Admin;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UserFetch;
|
||||
use App\Http\Requests\Admin\UserGenerate;
|
||||
use App\Http\Requests\Admin\UserSendMail;
|
||||
use App\Http\Requests\Admin\UserUpdate;
|
||||
@ -75,17 +73,50 @@ class UserController extends Controller
|
||||
*/
|
||||
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}%");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($field === 'group_ids') {
|
||||
$query->whereIn('group_id', $value);
|
||||
return;
|
||||
[$operator, $filterValue] = explode(':', $value, 2);
|
||||
|
||||
// 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_pull_interval' => 'integer',
|
||||
'server_push_interval' => 'integer',
|
||||
'device_limit_mode' => 'integer',
|
||||
// frontend
|
||||
'frontend_theme' => '',
|
||||
'frontend_theme_sidebar' => 'nullable|in:dark,light',
|
||||
|
@ -30,7 +30,8 @@ class UserUpdate extends FormRequest
|
||||
'commission_type' => 'integer',
|
||||
'commission_balance' => 'integer',
|
||||
'remarks' => 'nullable',
|
||||
'speed_limit' => 'nullable|integer'
|
||||
'speed_limit' => 'nullable|integer',
|
||||
'device_limit' => 'nullable|integer'
|
||||
];
|
||||
}
|
||||
|
||||
@ -60,7 +61,8 @@ class UserUpdate extends FormRequest
|
||||
'balance.integer' => '余额格式不正确',
|
||||
'commission_balance.integer' => '佣金格式不正确',
|
||||
'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->post('push', [UniProxyController::class, 'push']);
|
||||
$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',
|
||||
'reset_traffic_method',
|
||||
'capacity_limit',
|
||||
'sell'
|
||||
'sell',
|
||||
'device_limit'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
@ -142,6 +142,20 @@ class General implements ProtocolInterface
|
||||
case 'grpc':
|
||||
$config['serviceName'] = data_get($protocol_settings, 'network_settings.serviceName');
|
||||
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;
|
||||
|
@ -68,7 +68,7 @@ class SingBox implements ProtocolInterface
|
||||
$proxies[] = $vlessConfig;
|
||||
}
|
||||
if ($item['type'] === 'hysteria') {
|
||||
$hysteriaConfig = $this->buildHysteria($this->user['uuid'], $item, $this->user);
|
||||
$hysteriaConfig = $this->buildHysteria($this->user['uuid'], $item);
|
||||
$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,
|
||||
'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
|
||||
};
|
||||
|
||||
|
@ -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([
|
||||
'id',
|
||||
'uuid',
|
||||
'speed_limit'
|
||||
'speed_limit',
|
||||
'device_limit'
|
||||
])
|
||||
->get();
|
||||
}
|
||||
|
@ -4,29 +4,99 @@ namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Exception;
|
||||
use ZipArchive;
|
||||
|
||||
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 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
|
||||
{
|
||||
$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))
|
||||
->mapWithKeys(function ($dir) {
|
||||
->mapWithKeys(function ($dir) use ($canDelete) {
|
||||
$name = basename($dir);
|
||||
// 检查必要文件是否存在
|
||||
if (
|
||||
!File::exists($dir . '/' . self::CONFIG_FILE) ||
|
||||
!File::exists($dir . '/dashboard.blade.php')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
$config = $this->readConfigFile($name);
|
||||
$config['can_delete'] = !in_array($name, self::CANNOT_DELETE_THEMES) && $name != admin_setting('current_theme');
|
||||
return $config ? [$name => $config] : [];
|
||||
if (!$config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$config['can_delete'] = $canDelete && $name !== admin_setting('current_theme');
|
||||
$config['is_system'] = !$canDelete;
|
||||
return [$name => $config];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
@ -40,56 +110,70 @@ class ThemeService
|
||||
|
||||
try {
|
||||
if ($zip->open($file->path()) !== true) {
|
||||
throw new Exception('Invalid theme package');
|
||||
throw new Exception('无效的主题包');
|
||||
}
|
||||
|
||||
// 验证主题包结构
|
||||
$hasConfig = false;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
if (basename($zip->getNameIndex($i)) === self::CONFIG_FILE) {
|
||||
$hasConfig = true;
|
||||
break;
|
||||
}
|
||||
// 查找配置文件
|
||||
$configEntry = collect(range(0, $zip->numFiles - 1))
|
||||
->map(fn($i) => $zip->getNameIndex($i))
|
||||
->first(fn($name) => basename($name) === self::CONFIG_FILE);
|
||||
|
||||
if (!$configEntry) {
|
||||
throw new Exception('主题配置文件不存在');
|
||||
}
|
||||
|
||||
if (!$hasConfig) {
|
||||
throw new Exception('Theme configuration file not found');
|
||||
}
|
||||
|
||||
// 解压并移动到主题目录
|
||||
// 解压并读取配置
|
||||
$zip->extractTo($tmpPath);
|
||||
$zip->close();
|
||||
|
||||
$themeName = basename($tmpPath);
|
||||
$targetPath = base_path(self::THEME_DIR . $themeName);
|
||||
$sourcePath = $tmpPath . '/' . rtrim(dirname($configEntry), '.');
|
||||
$configFile = $sourcePath . '/' . self::CONFIG_FILE;
|
||||
|
||||
if (File::exists($targetPath)) {
|
||||
throw new Exception('Theme already exists');
|
||||
if (!File::exists($configFile)) {
|
||||
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;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Theme upload failed', ['error' => $e->getMessage()]);
|
||||
throw $e;
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
if (File::exists($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 {
|
||||
$this->validateTheme($theme);
|
||||
// 验证主题是否存在
|
||||
$themePath = $this->getThemePath($theme);
|
||||
if (!$themePath) {
|
||||
throw new Exception('主题不存在');
|
||||
}
|
||||
|
||||
// 验证视图文件是否存在
|
||||
if (!File::exists($this->getThemeViewPath($theme))) {
|
||||
throw new Exception('主题视图文件不存在');
|
||||
}
|
||||
|
||||
// 复制主题文件到public目录
|
||||
$sourcePath = base_path(self::THEME_DIR . $theme);
|
||||
$targetPath = public_path(self::THEME_DIR . $theme);
|
||||
|
||||
if (!File::copyDirectory($sourcePath, $targetPath)) {
|
||||
throw new Exception('Failed to copy theme files');
|
||||
$targetPath = public_path('theme/' . $theme);
|
||||
if (!File::copyDirectory($themePath, $targetPath)) {
|
||||
throw new Exception('复制主题文件失败');
|
||||
}
|
||||
|
||||
// 清理旧主题文件
|
||||
if ($currentTheme) {
|
||||
$oldPath = public_path(self::THEME_DIR . $currentTheme);
|
||||
File::exists($oldPath) && File::deleteDirectory($oldPath);
|
||||
$oldPath = public_path('theme/' . $currentTheme);
|
||||
if (File::exists($oldPath)) {
|
||||
File::deleteDirectory($oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
admin_setting(['current_theme' => $theme]);
|
||||
@ -131,20 +224,31 @@ class ThemeService
|
||||
*/
|
||||
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 {
|
||||
$themePath = base_path(self::THEME_DIR . $theme);
|
||||
$publicPath = public_path(self::THEME_DIR . $theme);
|
||||
|
||||
if (!File::exists($themePath)) {
|
||||
throw new Exception('Theme not found');
|
||||
// 检查是否为系统主题
|
||||
if (in_array($theme, self::SYSTEM_THEMES)) {
|
||||
throw new Exception('系统主题不能删除');
|
||||
}
|
||||
|
||||
// 检查是否为当前使用的主题
|
||||
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::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]);
|
||||
@ -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
|
||||
{
|
||||
try {
|
||||
$this->validateTheme($theme);
|
||||
// 验证主题是否存在
|
||||
if (!$this->getThemePath($theme)) {
|
||||
throw new Exception('主题不存在');
|
||||
}
|
||||
|
||||
$schema = $this->readConfigFile($theme);
|
||||
if (!$schema) {
|
||||
throw new Exception('主题配置文件无效');
|
||||
}
|
||||
|
||||
// 只保留有效的配置字段
|
||||
$validFields = collect($schema['configs'] ?? [])->pluck('field_name')->toArray();
|
||||
@ -201,18 +338,13 @@ class ThemeService
|
||||
*/
|
||||
private function readConfigFile(string $theme): ?array
|
||||
{
|
||||
$file = base_path(self::THEME_DIR . $theme . '/' . self::CONFIG_FILE);
|
||||
return File::exists($file) ? json_decode(File::get($file), true) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证主题
|
||||
*/
|
||||
private function validateTheme(string $theme): void
|
||||
{
|
||||
if (!$this->readConfigFile($theme)) {
|
||||
throw new Exception("Invalid theme: {$theme}");
|
||||
$themePath = $this->getThemePath($theme);
|
||||
if (!$themePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
$config = $this->readConfigFile($theme);
|
||||
if (!$config)
|
||||
if (!$config) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaults = collect($config['configs'] ?? [])
|
||||
->mapWithKeys(fn($col) => [$col['field_name'] => $col['default_value'] ?? ''])
|
||||
->toArray();
|
||||
|
||||
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)
|
||||
{
|
||||
$key = strtolower($key);
|
||||
return Arr::get($this->fromDatabase(), $key, $default);
|
||||
}
|
||||
|
||||
@ -39,6 +40,7 @@ class Setting
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value);
|
||||
}
|
||||
$key = strtolower($key);
|
||||
SettingModel::updateOrCreate(['name' => $key], ['value' => $value]);
|
||||
$this->cache->forget(self::CACHE_KEY);
|
||||
return true;
|
||||
@ -81,11 +83,10 @@ class Setting
|
||||
{
|
||||
try {
|
||||
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) {
|
||||
return [];
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -166,4 +166,10 @@ class Helper
|
||||
$fingerprints = ['chrome', 'firefox', 'safari', 'ios', 'edge', 'qq'];
|
||||
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...
|
||||
*/
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
// App\Providers\BroadcastServiceProvider::class,
|
||||
App\Providers\EventServiceProvider::class,
|
||||
|
@ -179,6 +179,7 @@ return [
|
||||
'send_email',
|
||||
'send_email_mass',
|
||||
'send_telegram',
|
||||
'online_sync'
|
||||
],
|
||||
'balance' => 'auto',
|
||||
'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 Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -21,22 +22,42 @@ Route::get('/', function (Request $request) {
|
||||
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');
|
||||
$themeService = new ThemeService();
|
||||
|
||||
if (!admin_setting("theme_{$theme}")) {
|
||||
ThemeService::switchTheme($theme);
|
||||
try {
|
||||
// 检查主题是否存在,不存在则尝试切换到默认主题
|
||||
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:: 兼容
|
||||
|
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