feat: optimize settings management and admin functionality

- Add system log cleanup functionality with batch processing
- Optimize v2_settings table performance by unifying value storage
- Add comprehensive client support list for one-click subscription
- Fix QR code subscription links for specific node types
- Fix route addition issues in admin management panel
- Enhance admin system controller with log management APIs
This commit is contained in:
xboard
2025-06-21 12:11:27 +08:00
parent 895a870dfc
commit 272dbd2107
29 changed files with 1759 additions and 1392 deletions
-4
View File
@@ -3,13 +3,9 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Log; use App\Models\Log;
use App\Models\Plan;
use App\Models\StatServer; use App\Models\StatServer;
use App\Models\StatUser; use App\Models\StatUser;
use App\Utils\Helper;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class ResetLog extends Command class ResetLog extends Command
{ {
+20 -3
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Support\Setting; use App\Support\Setting;
use Illuminate\Support\Facades\App;
if (! function_exists('admin_setting')) { if (! function_exists('admin_setting')) {
/** /**
@@ -11,15 +12,31 @@ if (! function_exists('admin_setting')) {
*/ */
function admin_setting($key = null, $default = null) function admin_setting($key = null, $default = null)
{ {
$setting = Setting::getInstance();
if ($key === null) { if ($key === null) {
return App::make(Setting::class)->toArray(); return $setting->toArray();
} }
if (is_array($key)) { if (is_array($key)) {
App::make(Setting::class)->save($key); $setting->save($key);
return ''; return '';
} }
$default = config('v2board.'. $key) ?? $default; $default = config('v2board.'. $key) ?? $default;
return App::make(Setting::class)->get($key) ?? $default ; return $setting->get($key) ?? $default;
}
}
if (! function_exists('admin_settings_batch')) {
/**
* 批量获取配置参数,性能优化版本
*
* @param array $keys 配置键名数组
* @return array 返回键值对数组
*/
function admin_settings_batch(array $keys): array
{
return Setting::getInstance()->getBatch($keys);
} }
} }
@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ConfigSave; use App\Http\Requests\Admin\ConfigSave;
use App\Protocols\Clash; use App\Protocols\Clash;
use App\Protocols\ClashMeta; use App\Protocols\ClashMeta;
use App\Protocols\Loon;
use App\Protocols\SingBox; use App\Protocols\SingBox;
use App\Protocols\Stash; use App\Protocols\Stash;
use App\Protocols\Surfboard; use App\Protocols\Surfboard;
@@ -17,7 +16,6 @@ use App\Services\ThemeService;
use App\Utils\Dict; use App\Utils\Dict;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class ConfigController extends Controller class ConfigController extends Controller
{ {
@@ -84,22 +82,29 @@ class ConfigController extends Controller
return $this->success(true); return $this->success(true);
} }
/**
* 获取自定义规则文件路径,如果不存在则返回默认文件路径
*
* @param string $customFile 自定义规则文件路径
* @param string $defaultFile 默认文件名
* @return string 文件名
*/
private function getRuleFile(string $customFile, string $defaultFile): string
{
return File::exists(base_path($customFile)) ? $customFile : $defaultFile;
}
public function fetch(Request $request) public function fetch(Request $request)
{ {
$key = $request->input('key'); $key = $request->input('key');
$data = [
// 构建配置数据映射
$configMappings = $this->getConfigMappings();
// 如果请求特定分组,直接返回
if ($key && isset($configMappings[$key])) {
return $this->success([$key => $configMappings[$key]]);
}
return $this->success($configMappings);
}
/**
* 获取配置映射数据
*
* @return array 配置映射数组
*/
private function getConfigMappings(): array
{
return [
'invite' => [ 'invite' => [
'invite_force' => (bool) admin_setting('invite_force', 0), 'invite_force' => (bool) admin_setting('invite_force', 0),
'invite_commission' => admin_setting('invite_commission', 10), 'invite_commission' => admin_setting('invite_commission', 10),
@@ -141,7 +146,6 @@ class ConfigController extends Controller
'default_remind_expire' => (bool) admin_setting('default_remind_expire', 1), 'default_remind_expire' => (bool) admin_setting('default_remind_expire', 1),
'default_remind_traffic' => (bool) admin_setting('default_remind_traffic', 1), 'default_remind_traffic' => (bool) admin_setting('default_remind_traffic', 1),
'subscribe_path' => admin_setting('subscribe_path', 's'), 'subscribe_path' => admin_setting('subscribe_path', 's'),
], ],
'frontend' => [ 'frontend' => [
'frontend_theme' => admin_setting('frontend_theme', 'Xboard'), 'frontend_theme' => admin_setting('frontend_theme', 'Xboard'),
@@ -197,70 +201,23 @@ class ConfigController extends Controller
'password_limit_expire' => admin_setting('password_limit_expire', 60) 'password_limit_expire' => admin_setting('password_limit_expire', 60)
], ],
'subscribe_template' => [ 'subscribe_template' => [
'subscribe_template_singbox' => (function () { 'subscribe_template_singbox' => $this->formatTemplateContent(
$content = $this->getTemplateContent( admin_setting('subscribe_template_singbox', $this->getDefaultTemplate('singbox')),
$this->getRuleFile(SingBox::CUSTOM_TEMPLATE_FILE, SingBox::DEFAULT_TEMPLATE_FILE)); 'json'
return json_encode(json_decode($content), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
})(),
'subscribe_template_clash' => (string) $this->getTemplateContent(
$this->getRuleFile(Clash::CUSTOM_TEMPLATE_FILE, Clash::DEFAULT_TEMPLATE_FILE)
), ),
'subscribe_template_clashmeta' => (string) $this->getTemplateContent( 'subscribe_template_clash' => admin_setting('subscribe_template_clash', $this->getDefaultTemplate('clash')),
$this->getRuleFile( 'subscribe_template_clashmeta' => admin_setting('subscribe_template_clashmeta', $this->getDefaultTemplate('clashmeta')),
ClashMeta::CUSTOM_TEMPLATE_FILE, 'subscribe_template_stash' => admin_setting('subscribe_template_stash', $this->getDefaultTemplate('stash')),
$this->getRuleFile(ClashMeta::CUSTOM_CLASH_TEMPLATE_FILE, ClashMeta::DEFAULT_TEMPLATE_FILE) 'subscribe_template_surge' => admin_setting('subscribe_template_surge', $this->getDefaultTemplate('surge')),
) 'subscribe_template_surfboard' => admin_setting('subscribe_template_surfboard', $this->getDefaultTemplate('surfboard'))
),
'subscribe_template_stash' => (string) $this->getTemplateContent(
$this->getRuleFile(
Stash::CUSTOM_TEMPLATE_FILE,
$this->getRuleFile(Stash::CUSTOM_CLASH_TEMPLATE_FILE, Stash::DEFAULT_TEMPLATE_FILE)
)
),
'subscribe_template_surge' => (string) $this->getTemplateContent(
$this->getRuleFile(Surge::CUSTOM_TEMPLATE_FILE, Surge::DEFAULT_TEMPLATE_FILE)
),
'subscribe_template_surfboard' => (string) $this->getTemplateContent(
$this->getRuleFile(Surfboard::CUSTOM_TEMPLATE_FILE, Surfboard::DEFAULT_TEMPLATE_FILE)
)
] ]
]; ];
if ($key && isset($data[$key])) {
return $this->success([
$key => $data[$key]
]);
}
;
// TODO: default should be in Dict
return $this->success($data);
} }
public function save(ConfigSave $request) public function save(ConfigSave $request)
{ {
$data = $request->validated(); $data = $request->validated();
// 处理特殊的模板设置字段,将其保存为文件
$templateFields = [
'subscribe_template_clash' => Clash::CUSTOM_TEMPLATE_FILE,
'subscribe_template_clashmeta' => ClashMeta::CUSTOM_TEMPLATE_FILE,
'subscribe_template_stash' => Stash::CUSTOM_TEMPLATE_FILE,
'subscribe_template_surge' => Surge::CUSTOM_TEMPLATE_FILE,
'subscribe_template_singbox' => SingBox::CUSTOM_TEMPLATE_FILE,
'subscribe_template_surfboard' => Surfboard::CUSTOM_TEMPLATE_FILE,
];
foreach ($templateFields as $field => $filename) {
if (isset($data[$field])) {
$content = $data[$field];
// 对于JSON格式的内容,确保格式化正确
if ($field === 'subscribe_template_singbox' && is_array($content)) {
$content = json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$this->saveTemplateContent($filename, $content);
unset($data[$field]); // 从数据库保存列表中移除
}
}
foreach ($data as $k => $v) { foreach ($data as $k => $v) {
if ($k == 'frontend_theme') { if ($k == 'frontend_theme') {
$themeService = app(ThemeService::class); $themeService = app(ThemeService::class);
@@ -268,29 +225,86 @@ class ConfigController extends Controller
} }
admin_setting([$k => $v]); admin_setting([$k => $v]);
} }
// \Artisan::call('horizon:terminate'); //重启队列使配置生效
return $this->success(true); return $this->success(true);
} }
/** /**
* 保存规则模板内容到文件 * 格式化模板内容
* *
* @param string $filepath 文件名 * @param mixed $content 模板内容
* @param string $content 文件内容 * @param string $format 输出格式 (json|string)
* @return bool 是否保存成功 * @return string 格式化后的内容
*/ */
private function saveTemplateContent(string $filepath, string $content): bool private function formatTemplateContent(mixed $content, string $format = 'string'): string
{ {
$path = base_path($filepath); return match ($format) {
try { 'json' => match (true) {
File::put($path, $content); is_array($content) => json_encode(
return true; value: $content,
} catch (\Exception $e) { flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
Log::error('保存规则模板失败', [ ),
'filepath' => $path,
'error' => $e->getMessage() is_string($content) && str($content)->isJson() => rescue(
]); callback: fn() => json_encode(
return false; value: json_decode($content, associative: true, flags: JSON_THROW_ON_ERROR),
flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
),
rescue: $content,
report: false
),
default => str($content)->toString()
},
default => str($content)->toString()
};
}
/**
* 获取默认模板内容
*
* @param string $type 模板类型
* @return string 默认模板内容
*/
private function getDefaultTemplate(string $type): string
{
$fileMap = [
'singbox' => [SingBox::CUSTOM_TEMPLATE_FILE, SingBox::DEFAULT_TEMPLATE_FILE],
'clash' => [Clash::CUSTOM_TEMPLATE_FILE, Clash::DEFAULT_TEMPLATE_FILE],
'clashmeta' => [
ClashMeta::CUSTOM_TEMPLATE_FILE,
ClashMeta::CUSTOM_CLASH_TEMPLATE_FILE,
ClashMeta::DEFAULT_TEMPLATE_FILE
],
'stash' => [
Stash::CUSTOM_TEMPLATE_FILE,
Stash::CUSTOM_CLASH_TEMPLATE_FILE,
Stash::DEFAULT_TEMPLATE_FILE
],
'surge' => [Surge::CUSTOM_TEMPLATE_FILE, Surge::DEFAULT_TEMPLATE_FILE],
'surfboard' => [Surfboard::CUSTOM_TEMPLATE_FILE, Surfboard::DEFAULT_TEMPLATE_FILE],
];
if (!isset($fileMap[$type])) {
return '';
} }
// 按优先级查找可用的模板文件
foreach ($fileMap[$type] as $file) {
$content = $this->getTemplateContent($file);
if (!empty($content)) {
// 对于 SingBox,需要格式化 JSON
if ($type === 'singbox') {
$decoded = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE) {
return json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}
return $content;
}
}
return '';
} }
} }
@@ -130,7 +130,7 @@ class SystemController extends Controller
$pageSize = $request->input('page_size') >= 10 ? $request->input('page_size') : 10; $pageSize = $request->input('page_size') >= 10 ? $request->input('page_size') : 10;
$level = $request->input('level'); $level = $request->input('level');
$keyword = $request->input('keyword'); $keyword = $request->input('keyword');
$builder = LogModel::orderBy('created_at', 'DESC') $builder = LogModel::orderBy('created_at', 'DESC')
->when($level, function ($query) use ($level) { ->when($level, function ($query) use ($level) {
return $query->where('level', strtoupper($level)); return $query->where('level', strtoupper($level));
@@ -138,16 +138,16 @@ class SystemController extends Controller
->when($keyword, function ($query) use ($keyword) { ->when($keyword, function ($query) use ($keyword) {
return $query->where(function ($q) use ($keyword) { return $query->where(function ($q) use ($keyword) {
$q->where('data', 'like', '%' . $keyword . '%') $q->where('data', 'like', '%' . $keyword . '%')
->orWhere('context', 'like', '%' . $keyword . '%') ->orWhere('context', 'like', '%' . $keyword . '%')
->orWhere('title', 'like', '%' . $keyword . '%') ->orWhere('title', 'like', '%' . $keyword . '%')
->orWhere('uri', 'like', '%' . $keyword . '%'); ->orWhere('uri', 'like', '%' . $keyword . '%');
}); });
}); });
$total = $builder->count(); $total = $builder->count();
$res = $builder->forPage($current, $pageSize) $res = $builder->forPage($current, $pageSize)
->get(); ->get();
return response([ return response([
'data' => $res, 'data' => $res,
'total' => $total 'total' => $total
@@ -174,4 +174,126 @@ class SystemController extends Controller
'page_size' => $pageSize, 'page_size' => $pageSize,
]); ]);
} }
/**
* 清除系统日志
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function clearSystemLog(Request $request)
{
$request->validate([
'days' => 'integer|min:1|max:365',
'level' => 'string|in:info,warning,error,all',
'limit' => 'integer|min:100|max:10000'
], [
'days.required' => '请指定要清除多少天前的日志',
'days.integer' => '天数必须为整数',
'days.min' => '天数不能少于1天',
'days.max' => '天数不能超过365天',
'level.in' => '日志级别只能是:info、warning、error、all',
'limit.min' => '单次清除数量不能少于100条',
'limit.max' => '单次清除数量不能超过10000条'
]);
$days = $request->input('days', 30); // 默认清除30天前的日志
$level = $request->input('level', 'all'); // 默认清除所有级别
$limit = $request->input('limit', 1000); // 默认单次清除1000条
try {
$cutoffDate = now()->subDays($days);
// 构建查询条件
$query = LogModel::where('created_at', '<', $cutoffDate->timestamp);
if ($level !== 'all') {
$query->where('level', strtoupper($level));
}
// 获取要删除的记录数量
$totalCount = $query->count();
if ($totalCount === 0) {
return $this->success([
'message' => '没有找到符合条件的日志记录',
'deleted_count' => 0,
'total_count' => $totalCount
]);
}
// 分批删除,避免单次删除过多数据
$deletedCount = 0;
$batchSize = min($limit, 1000); // 每批最多1000条
while ($deletedCount < $limit && $deletedCount < $totalCount) {
$remainingLimit = min($batchSize, $limit - $deletedCount);
$batchQuery = LogModel::where('created_at', '<', $cutoffDate->timestamp);
if ($level !== 'all') {
$batchQuery->where('level', strtoupper($level));
}
$idsToDelete = $batchQuery->limit($remainingLimit)->pluck('id');
if ($idsToDelete->isEmpty()) {
break;
}
$batchDeleted = LogModel::whereIn('id', $idsToDelete)->delete();
$deletedCount += $batchDeleted;
// 避免长时间占用数据库连接
if ($deletedCount < $limit && $deletedCount < $totalCount) {
usleep(100000); // 暂停0.1秒
}
}
return $this->success([
'message' => '日志清除完成',
'deleted_count' => $deletedCount,
'total_count' => $totalCount,
'remaining_count' => max(0, $totalCount - $deletedCount)
]);
} catch (\Exception $e) {
return $this->error('清除日志失败:' . $e->getMessage());
}
}
/**
* 获取日志清除统计信息
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getLogClearStats(Request $request)
{
$days = $request->input('days', 30);
$level = $request->input('level', 'all');
try {
$cutoffDate = now()->subDays($days);
$query = LogModel::where('created_at', '<', $cutoffDate->timestamp);
if ($level !== 'all') {
$query->where('level', strtoupper($level));
}
$stats = [
'days' => $days,
'level' => $level,
'cutoff_date' => $cutoffDate->format(format: 'Y-m-d H:i:s'),
'total_logs' => LogModel::count(),
'logs_to_clear' => $query->count(),
'oldest_log' => LogModel::orderBy('created_at', 'asc')->first(),
'newest_log' => LogModel::orderBy('created_at', 'desc')->first(),
];
return $this->success($stats);
} catch (\Exception $e) {
return $this->error('获取统计信息失败:' . $e->getMessage());
}
}
} }
+2
View File
@@ -192,6 +192,8 @@ class AdminRoute
$router->get('/getQueueMasters', '\\Laravel\\Horizon\\Http\\Controllers\\MasterSupervisorController@index'); $router->get('/getQueueMasters', '\\Laravel\\Horizon\\Http\\Controllers\\MasterSupervisorController@index');
$router->get('/getSystemLog', [SystemController::class, 'getSystemLog']); $router->get('/getSystemLog', [SystemController::class, 'getSystemLog']);
$router->get('/getHorizonFailedJobs', [SystemController::class, 'getHorizonFailedJobs']); $router->get('/getHorizonFailedJobs', [SystemController::class, 'getHorizonFailedJobs']);
$router->post('/clearSystemLog', [SystemController::class, 'clearSystemLog']);
$router->get('/getLogClearStats', [SystemController::class, 'getLogClearStats']);
}); });
// Update // Update
+42 -11
View File
@@ -9,29 +9,60 @@ class Setting extends Model
protected $table = 'v2_settings'; protected $table = 'v2_settings';
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
'key' => 'string', 'name' => 'string',
'value' => 'string', 'value' => 'string',
]; ];
public function getValueAttribute($value) /**
* 获取实际内容值
*/
public function getContentValue()
{ {
if ($value === null) { $rawValue = $this->attributes['value'] ?? null;
if ($rawValue === null) {
return null; return null;
} }
if (is_array($value)) { // 如果已经是数组,直接返回
return $value; if (is_array($rawValue)) {
return $rawValue;
} }
if (is_numeric($value) && !preg_match('/[^\d.]/', $value)) { // 如果是数字字符串,返回原值
return $value; if (is_numeric($rawValue) && !preg_match('/[^\d.]/', $rawValue)) {
return $rawValue;
} }
$decodedValue = json_decode($value, true); // 尝试解析 JSON
if (json_last_error() === JSON_ERROR_NONE) { if (is_string($rawValue)) {
return $decodedValue; $decodedValue = json_decode($rawValue, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decodedValue;
}
} }
return $value; return $rawValue;
}
/**
* 兼容性:保持原有的 value 访问器
*/
public function getValueAttribute($value)
{
return $this->getContentValue();
}
/**
* 创建或更新设置项
*/
public static function createOrUpdate(string $name, $value): self
{
$processedValue = is_array($value) ? json_encode($value) : $value;
return self::updateOrCreate(
['name' => $name],
['value' => $processedValue]
);
} }
} }
+3 -2
View File
@@ -18,9 +18,10 @@ class Clash extends AbstractProtocol
$user = $this->user; $user = $this->user;
$appName = admin_setting('app_name', 'XBoard'); $appName = admin_setting('app_name', 'XBoard');
$template = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) // 优先从数据库配置中获取模板
$template = admin_setting('subscribe_template_clash', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE)) ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)); : File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
+2 -2
View File
@@ -65,13 +65,13 @@ class ClashMeta extends AbstractProtocol
$user = $this->user; $user = $this->user;
$appName = admin_setting('app_name', 'XBoard'); $appName = admin_setting('app_name', 'XBoard');
$template = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $template = admin_setting('subscribe_template_clashmeta', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE)) ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: ( : (
File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE)) File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE)) ? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)) : File::get(base_path(self::DEFAULT_TEMPLATE_FILE))
); ));
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
+3 -3
View File
@@ -72,11 +72,11 @@ class SingBox extends AbstractProtocol
protected function loadConfig() protected function loadConfig()
{ {
$jsonData = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $jsonData = admin_setting('subscribe_template_singbox', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE)) ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)); : File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
return json_decode($jsonData, true); return is_array($jsonData) ? $jsonData : json_decode($jsonData, true);
} }
protected function buildOutbounds() protected function buildOutbounds()
+2 -2
View File
@@ -67,13 +67,13 @@ class Stash extends AbstractProtocol
$user = $this->user; $user = $this->user;
$appName = admin_setting('app_name', 'XBoard'); $appName = admin_setting('app_name', 'XBoard');
$template = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $template = admin_setting('subscribe_template_stash', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE)) ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: ( : (
File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE)) File::exists(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE)) ? File::get(base_path(self::CUSTOM_CLASH_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)) : File::get(base_path(self::DEFAULT_TEMPLATE_FILE))
); ));
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
+2 -2
View File
@@ -52,9 +52,9 @@ class Surfboard extends AbstractProtocol
} }
} }
$config = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $config = admin_setting('subscribe_template_surfboard', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE)) ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)); : File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
// Subscription link // Subscription link
$subsURL = Helper::getSubscribeUrl($user['token']); $subsURL = Helper::getSubscribeUrl($user['token']);
$subsDomain = request()->header('Host'); $subsDomain = request()->header('Host');
+9 -3
View File
@@ -60,9 +60,9 @@ class Surge extends AbstractProtocol
} }
$config = File::exists(base_path(self::CUSTOM_TEMPLATE_FILE)) $config = admin_setting('subscribe_template_surge', File::exists(base_path(self::CUSTOM_TEMPLATE_FILE))
? File::get(base_path(self::CUSTOM_TEMPLATE_FILE)) ? File::get(base_path(self::CUSTOM_TEMPLATE_FILE))
: File::get(base_path(self::DEFAULT_TEMPLATE_FILE)); : File::get(base_path(self::DEFAULT_TEMPLATE_FILE)));
// Subscription link // Subscription link
$subsDomain = request()->header('Host'); $subsDomain = request()->header('Host');
@@ -83,6 +83,7 @@ class Surge extends AbstractProtocol
$config = str_replace('$subscribe_info', $subscribeInfo, $config); $config = str_replace('$subscribe_info', $subscribeInfo, $config);
return response($config, 200) return response($config, 200)
->header('content-type', 'application/octet-stream')
->header('content-disposition', "attachment;filename*=UTF-8''" . rawurlencode($appName) . ".conf"); ->header('content-disposition', "attachment;filename*=UTF-8''" . rawurlencode($appName) . ".conf");
} }
@@ -202,11 +203,16 @@ class Surge extends AbstractProtocol
"{$server['host']}", "{$server['host']}",
"{$server['port']}", "{$server['port']}",
"password={$password}", "password={$password}",
"download-bandwidth={$protocol_settings['bandwidth']['up']}",
$protocol_settings['tls']['server_name'] ? "sni={$protocol_settings['tls']['server_name']}" : "", $protocol_settings['tls']['server_name'] ? "sni={$protocol_settings['tls']['server_name']}" : "",
// 'tfo=true', // 'tfo=true',
'udp-relay=true' 'udp-relay=true'
]; ];
if (data_get($protocol_settings, 'bandwidth.up')) {
$config[] = "upload-bandwidth={$protocol_settings['bandwidth']['up']}";
}
if (data_get($protocol_settings, 'bandwidth.down')) {
$config[] = "download-bandwidth={$protocol_settings['bandwidth']['down']}";
}
if (data_get($protocol_settings, 'tls.allow_insecure')) { if (data_get($protocol_settings, 'tls.allow_insecure')) {
$config[] = !!data_get($protocol_settings, 'tls.allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false'; $config[] = !!data_get($protocol_settings, 'tls.allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false';
} }
+98 -18
View File
@@ -5,17 +5,32 @@ namespace App\Support;
use App\Models\Setting as SettingModel; use App\Models\Setting as SettingModel;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Fluent;
class Setting class Setting
{ {
const CACHE_KEY = 'admin_settings'; const CACHE_KEY = 'admin_settings';
private $cache; private $cache;
public function __construct() private static $instance = null;
private static $inMemoryCache = null;
private static $cacheLoaded = false;
private function __construct()
{ {
$this->cache = Cache::store('redis'); $this->cache = Cache::store('redis');
} }
/**
* 获取单例实例
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/** /**
* 获取配置. * 获取配置.
* *
@@ -26,7 +41,28 @@ class Setting
public function get($key, $default = null) public function get($key, $default = null)
{ {
$key = strtolower($key); $key = strtolower($key);
return Arr::get($this->fromDatabase(), $key, $default); return Arr::get($this->getInMemoryCache(), $key, $default);
}
/**
* 获取内存缓存数据
*/
private function getInMemoryCache(): array
{
if (!self::$cacheLoaded) {
self::$inMemoryCache = $this->fromDatabase();
self::$cacheLoaded = true;
}
return self::$inMemoryCache ?? [];
}
/**
* 清除内存缓存
*/
public static function clearInMemoryCache(): void
{
self::$inMemoryCache = null;
self::$cacheLoaded = false;
} }
/** /**
@@ -38,16 +74,16 @@ class Setting
*/ */
public function set(string $key, $value = null): bool public function set(string $key, $value = null): bool
{ {
if (is_array($value)) {
$value = json_encode($value);
}
$key = strtolower($key); $key = strtolower($key);
SettingModel::updateOrCreate(['name' => $key], ['value' => $value]); SettingModel::createOrUpdate($key, $value);
$this->cache->forget(self::CACHE_KEY); $this->cache->forget(self::CACHE_KEY);
// 清除内存缓存,下次访问时重新加载
self::clearInMemoryCache();
return true; return true;
} }
/** /**
* 保存配置到数据库. * 保存配置到数据库.
* *
@@ -57,8 +93,13 @@ class Setting
public function save(array $settings): bool public function save(array $settings): bool
{ {
foreach ($settings as $key => $value) { foreach ($settings as $key => $value) {
$this->set($key, $value); $key = strtolower($key);
SettingModel::createOrUpdate($key, $value);
} }
// 批量更新后清除缓存
$this->cache->forget(self::CACHE_KEY);
self::clearInMemoryCache();
return true; return true;
} }
@@ -73,6 +114,7 @@ class Setting
{ {
SettingModel::where('name', $key)->delete(); SettingModel::where('name', $key)->delete();
$this->cache->forget(self::CACHE_KEY); $this->cache->forget(self::CACHE_KEY);
self::clearInMemoryCache();
return true; return true;
} }
@@ -83,9 +125,25 @@ class Setting
public function fromDatabase(): array public function fromDatabase(): array
{ {
try { try {
return $this->cache->rememberForever(self::CACHE_KEY, function (): array { // 统一从 value 字段获取所有配置
return array_change_key_case(SettingModel::pluck('value', 'name')->toArray(), CASE_LOWER); $settings = $this->cache->rememberForever(self::CACHE_KEY, function (): array {
return array_change_key_case(
SettingModel::pluck('value', 'name')->toArray(),
CASE_LOWER
);
}); });
// 处理JSON格式的值
foreach ($settings as $key => $value) {
if (is_string($value) && $value !== null) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$settings[$key] = $decoded;
}
}
}
return $settings;
} catch (\Throwable $th) { } catch (\Throwable $th) {
return []; return [];
} }
@@ -98,7 +156,7 @@ class Setting
*/ */
public function toArray(): array public function toArray(): array
{ {
return $this->fromDatabase(); return $this->getInMemoryCache();
} }
/** /**
@@ -110,12 +168,34 @@ class Setting
*/ */
public function update(string $key, $value): bool public function update(string $key, $value): bool
{ {
if (is_array($value)) { return $this->set($key, $value);
$value = json_encode($value); }
/**
* 批量获取配置项,优化多个配置项获取的性能
*
* @param array $keys 配置键名数组,格式:['key1', 'key2' => 'default_value', ...]
* @return array 返回键值对数组
*/
public function getBatch(array $keys): array
{
$cache = $this->getInMemoryCache();
$result = [];
foreach ($keys as $index => $item) {
if (is_numeric(value: $index)) {
// 格式:['key1', 'key2']
$key = strtolower($item);
$default = config('v2board.'. $item);
$result[$item] = Arr::get($cache, $key, $default);
} else {
// 格式:['key1' => 'default_value']
$key = strtolower($index);
$default = config('v2board.'. $index) ?? $item;
$result[$index] = Arr::get($cache, $key, $default);
}
} }
$key = strtolower($key);
SettingModel::updateOrCreate(['name' => $key], ['value' => $value]); return $result;
$this->cache->forget(self::CACHE_KEY);
return true;
} }
} }
+24 -7
View File
@@ -7,6 +7,8 @@ use Illuminate\Support\Arr;
class Helper class Helper
{ {
private static $subscribeUrlCache = null;
public static function uuidToBase64($uuid, $length) public static function uuidToBase64($uuid, $length)
{ {
return base64_encode(substr($uuid, 0, $length)); return base64_encode(substr($uuid, 0, $length));
@@ -122,13 +124,29 @@ class Helper
public static function getSubscribeUrl(string $token, $subscribeUrl = null) public static function getSubscribeUrl(string $token, $subscribeUrl = null)
{ {
$path = route('client.subscribe', ['token' => $token], false); $path = route('client.subscribe', ['token' => $token], false);
if (!$subscribeUrl) {
$subscribeUrls = explode(',', (string)admin_setting('subscribe_url', '')); // 如果已提供订阅URL,直接处理并返回
$subscribeUrl = Arr::random($subscribeUrls); if ($subscribeUrl) {
$subscribeUrl = self::replaceByPattern($subscribeUrl); $finalUrl = rtrim($subscribeUrl, '/') . $path;
return HookManager::filter('subscribe.url', $finalUrl);
} }
$finalUrl = $subscribeUrl ? rtrim($subscribeUrl, '/') . $path : url($path); // 使用静态缓存避免重复查询配置
if (self::$subscribeUrlCache === null) {
$urlString = (string)admin_setting('subscribe_url', '');
self::$subscribeUrlCache = $urlString ? explode(',', $urlString) : [];
}
// 如果没有配置订阅URL,使用默认URL
if (empty(self::$subscribeUrlCache)) {
return HookManager::filter('subscribe.url', url($path));
}
// 高效随机选择URL并处理
$randomIndex = array_rand(self::$subscribeUrlCache);
$selectedUrl = self::replaceByPattern(self::$subscribeUrlCache[$randomIndex]);
$finalUrl = rtrim($selectedUrl, '/') . $path;
return HookManager::filter('subscribe.url', $finalUrl); return HookManager::filter('subscribe.url', $finalUrl);
} }
@@ -184,5 +202,4 @@ class Helper
$revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')'); $revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')');
return strtr(rawurlencode($str), $revert); return strtr(rawurlencode($str), $revert);
} }
} }
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class OptimizeV2SettingsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('v2_settings', function (Blueprint $table) {
// 将 value 字段改为 MEDIUMTEXT,支持最大16MB内容
$table->mediumText('value')->nullable()->change();
// 添加优化索引
$table->index('name', 'idx_setting_name');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('v2_settings', function (Blueprint $table) {
$table->string('value')->nullable()->change();
$table->dropIndex('idx_setting_name');
});
}
}
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link rel="icon" type="image/png" href="/images/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadcn Admin</title>
<meta
name="description"
content="Admin Dashboard UI built with Shadcn and Vite."
/>
<script>
window.settings = {
base_url: 'http://127.0.0.1:8000',
title: 'Xboard',
version: '1.0.0',
logo: 'https://xboard.io/i6mages/logo.png',
secure_path: '/6a416b7a',
}
</script>
<script src="./locales/en-US.js"></script>
<script src="./locales/zh-CN.js"></script>
<script src="./locales/ko-KR.js"></script>
<script type="module" crossorigin src="./assets/index.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index.css">
<link rel="stylesheet" crossorigin href="./assets/vendor.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1 -1
View File
@@ -1079,7 +1079,7 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"uri": "URI", "uri": "URI",
"requestData": "Request Data", "requestData": "Request Data",
"exception": "Exception", "exception": "Exception",
"totalLogs": "Total logs: {{count}}", "totalLogs": "Total logs",
"tabs": { "tabs": {
"all": "All", "all": "All",
"info": "Info", "info": "Info",
+1 -1
View File
@@ -1095,7 +1095,7 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"uri": "URI", "uri": "URI",
"requestData": "요청 데이터", "requestData": "요청 데이터",
"exception": "예외", "exception": "예외",
"totalLogs": "총 로그 수: {{count}}", "totalLogs": "총 로그 수",
"tabs": { "tabs": {
"all": "전체", "all": "전체",
"info": "정보", "info": "정보",
+1 -1
View File
@@ -1077,7 +1077,7 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"uri": "URI", "uri": "URI",
"requestData": "请求数据", "requestData": "请求数据",
"exception": "异常信息", "exception": "异常信息",
"totalLogs": "总日志数{{count}}", "totalLogs": "总日志数",
"tabs": { "tabs": {
"all": "全部", "all": "全部",
"info": "信息", "info": "信息",
+1170 -1194
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+31 -33
View File
@@ -1,35 +1,33 @@
{ {
"name": "Xboard", "name": "Xboard",
"description": "Xboard", "description": "Xboard",
"version": "1.0.0", "version": "1.0.0",
"images": [ "images": "",
"https://raw.githubusercontent.com/cedar2025/Xboard/master/docs/images/user.png" "configs": [
], {
"configs": [ "label": "主题色",
{ "placeholder": "请选择主题颜色",
"label": "主题色", "field_name": "theme_color",
"placeholder": "请选择主题颜色", "field_type": "select",
"field_name": "theme_color", "select_options": {
"field_type": "select", "default": "默认(绿色)",
"select_options": { "blue": "蓝色",
"default": "默认(绿色)", "black": "黑色",
"blue": "蓝色", "darkblue": "蓝色"
"black": "黑色", },
"darkblue": "暗蓝色" "default_value": "default"
}, },
"default_value": "default" {
}, "label": "背景",
{ "placeholder": "请输入背景图片URL",
"label": "背景", "field_name": "background_url",
"placeholder": "请输入背景图片URL", "field_type": "input"
"field_name": "background_url", },
"field_type": "input" {
}, "label": "自定义页脚HTML",
{ "placeholder": "可以实现客服JS代码的加入等",
"label": "自定义页脚HTML", "field_name": "custom_html",
"placeholder": "可以实现客服JS代码的加入等", "field_type": "textarea"
"field_name": "custom_html", }
"field_type": "textarea" ]
}
]
} }
+19
View File
@@ -0,0 +1,19 @@
// API地址
window.routerBase = 'http://127.0.0.1:8000/'
window.settings = {
// 站点名称
title: 'Xboard',
// 站点描述
description: 'Xboard',
assets_path: '/assets',
// 主题色
theme: {
color: 'default', //可选default、blue、black、、darkblue
},
// 版本号
version: '0.1.1-dev',
// 登陆背景
background_url: '',
// 站点LOGO
logo: '',
}
+18
View File
@@ -0,0 +1,18 @@
window.routerBase = 'http://127.0.0.1:8000/'
window.settings = {
// 站点名称
title: 'Xboard',
// 主题色
theme: {
color: 'anyway', //可选default、blue、black、、darkblue
},
// 站点描述
description: 'Xboard',
assets_path: '/assets',
// 版本号
version: '0.1.1-dev',
// 登陆背景
background_url: '',
// 站点LOGO
logo: '',
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no"><title>Xboard</title><script type="module" crossorigin src="/assets/umi.js"></script></head><body><script src="./env.js"></script><div id="app"></div></body></html>