feat: add multiple new features and enhancements

This commit is contained in:
xboard 2025-01-10 21:57:26 +08:00
parent 819feef80c
commit d8a9a747f8
30 changed files with 884 additions and 354 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -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' => '设备数量格式不正确'
];
}
}

View File

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

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

View File

@ -60,7 +60,8 @@ class Plan extends Model
'prices',
'reset_traffic_method',
'capacity_limit',
'sell'
'sell',
'device_limit'
];
protected $casts = [

View File

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

View File

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

View File

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

View File

@ -72,7 +72,8 @@ class ServerService
->select([
'id',
'uuid',
'speed_limit'
'speed_limit',
'device_limit'
])
->get();
}

View File

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

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

View File

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

View File

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

View File

@ -169,7 +169,6 @@ return [
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,

View File

@ -179,6 +179,7 @@ return [
'send_email',
'send_email_mass',
'send_telegram',
'online_sync'
],
'balance' => 'auto',
'minProcesses' => 1,

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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
View File

@ -0,0 +1,2 @@
*
!.gitignore