merge: sync upstream/master preserving local changes

This commit is contained in:
yinjianm
2026-04-23 22:27:18 +08:00
94 changed files with 14065 additions and 650 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ class MailLinkService
'subject' => __('Login to :name', [
'name' => admin_setting('app_name', 'Notification Service')
]),
'template_name' => 'login',
'template_name' => 'mailLogin',
'template_value' => [
'name' => admin_setting('app_name', 'Notification Service'),
'link' => $link,
+20
View File
@@ -32,6 +32,9 @@ class DeviceStateService
$this->removeNodeDevices($nodeId, $userId);
// Normalize: strip port suffix and deduplicate
$ips = array_values(array_unique(array_map([self::class, 'normalizeIP'], $ips)));
if (!empty($ips)) {
$fields = [];
foreach ($ips as $ip) {
@@ -98,6 +101,7 @@ class DeviceStateService
Redis::hdel($key, $field);
}
}
$this->notifyUpdate($userId);
}
return array_keys($oldDevices);
@@ -166,6 +170,22 @@ class DeviceStateService
return $result;
}
/**
* Strip port from IP address: "1.2.3.4:12345" → "1.2.3.4", "[::1]:443" → "::1"
*/
private static function normalizeIP(string $ip): string
{
// [IPv6]:port
if (preg_match('/^\[(.+)\]:\d+$/', $ip, $m)) {
return $m[1];
}
// IPv4:port
if (preg_match('/^(\d+\.\d+\.\d+\.\d+):\d+$/', $ip, $m)) {
return $m[1];
}
return $ip;
}
/**
* notify update (throttle control)
*/
+1
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Jobs\SendEmailJob;
use App\Models\MailLog;
use App\Models\MailTemplate;
use App\Models\User;
use App\Utils\CacheKey;
use Illuminate\Support\Facades\Cache;
+60 -13
View File
@@ -14,6 +14,7 @@ use Illuminate\Support\Str;
class PluginManager
{
protected string $pluginPath;
protected string $corePluginPath;
protected array $loadedPlugins = [];
protected bool $pluginsInitialized = false;
protected array $configTypesCache = [];
@@ -21,6 +22,7 @@ class PluginManager
public function __construct()
{
$this->pluginPath = base_path('plugins');
$this->corePluginPath = base_path('plugins-core');
}
/**
@@ -31,14 +33,42 @@ class PluginManager
return 'Plugin\\' . Str::studly($pluginCode);
}
/**
* 获取插件的基础路径
*/
public function resolvePluginPath(string $pluginCode): ?string
{
$dirName = Str::studly($pluginCode);
$corePath = $this->corePluginPath . '/' . $dirName;
if (File::isDirectory($corePath)) {
return $corePath;
}
$userPath = $this->pluginPath . '/' . $dirName;
if (File::isDirectory($userPath)) {
return $userPath;
}
return null;
}
public function getPluginPath(string $pluginCode): string
{
return $this->resolvePluginPath($pluginCode)
?? $this->pluginPath . '/' . Str::studly($pluginCode);
}
public function getUserPluginPath(string $pluginCode): string
{
return $this->pluginPath . '/' . Str::studly($pluginCode);
}
public function isCorePlugin(string $pluginCode): bool
{
$dirName = Str::studly($pluginCode);
return File::isDirectory($this->corePluginPath . '/' . $dirName);
}
public function getPluginPaths(): array
{
return [$this->corePluginPath, $this->pluginPath];
}
/**
* 加载插件类
*/
@@ -399,17 +429,19 @@ class PluginManager
*/
public function delete(string $pluginCode): bool
{
// 先卸载插件
if (Plugin::where('code', $pluginCode)->exists()) {
$this->uninstall($pluginCode);
}
$pluginPath = $this->getPluginPath($pluginCode);
if ($this->isCorePlugin($pluginCode)) {
throw new \Exception('核心插件不允许删除');
}
$pluginPath = $this->getUserPluginPath($pluginCode);
if (!File::exists($pluginPath)) {
throw new \Exception('插件不存在');
}
// 删除插件目录
File::deleteDirectory($pluginPath);
return true;
@@ -527,7 +559,7 @@ class PluginManager
throw new \Exception('插件配置文件格式错误');
}
$targetPath = $this->pluginPath . '/' . Str::studly($config['code']);
$targetPath = $this->getUserPluginPath($config['code']);
if (File::exists($targetPath)) {
$installedConfigPath = $targetPath . '/config.json';
if (!File::exists($installedConfigPath)) {
@@ -678,12 +710,27 @@ class PluginManager
*/
public static function installDefaultPlugins(): void
{
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
if (!Plugin::where('code', $pluginCode)->exists()) {
$pluginManager = app(self::class);
$pluginManager->install($pluginCode);
$pluginManager->enable($pluginCode);
Log::info("Installed and enabled default plugin: {$pluginCode}");
$pluginManager = app(self::class);
$coreDir = base_path('plugins-core');
if (!File::isDirectory($coreDir)) {
return;
}
foreach (File::directories($coreDir) as $directory) {
$configFile = $directory . '/config.json';
if (!File::exists($configFile)) {
continue;
}
$config = json_decode(File::get($configFile), true);
$code = $config['code'] ?? null;
if (!$code) {
continue;
}
if (!Plugin::where('code', $code)->exists()) {
$pluginManager->install($code);
$pluginManager->enable($code);
Log::info("Installed and enabled core plugin: {$code}");
}
}
}
+2 -5
View File
@@ -284,15 +284,12 @@ class ServerService
'trojan' => [
...$baseConfig,
'host' => $host,
'server_name' => data_get($protocolSettings, 'tls_settings.server_name') ?? $protocolSettings['server_name'],
'server_name' => data_get($protocolSettings, 'tls_settings.server_name'),
'multiplex' => data_get($protocolSettings, 'multiplex'),
'tls' => (int) $protocolSettings['tls'],
'tls_settings' => match ((int) $protocolSettings['tls']) {
2 => $protocolSettings['reality_settings'],
default => array_merge($protocolSettings['tls_settings'] ?? [], [
'server_name' => data_get($protocolSettings, 'tls_settings.server_name') ?? $protocolSettings['server_name'],
'allow_insecure' => data_get($protocolSettings, 'tls_settings.allow_insecure', $protocolSettings['allow_insecure']),
]),
default => $protocolSettings['tls_settings'],
},
],
'vless' => [
+13 -29
View File
@@ -22,11 +22,11 @@ class TicketService
'ticket_id' => $ticket->id,
'message' => $message
]);
if ($userId !== $ticket->user_id) {
$ticket->reply_status = Ticket::STATUS_OPENING;
} else {
$ticket->reply_status = Ticket::STATUS_CLOSED;
}
$isAdmin = $userId !== $ticket->user_id;
$ticket->reply_status = $isAdmin
? Ticket::REPLY_STATUS_REPLIED
: Ticket::REPLY_STATUS_WAITING;
$ticket->last_reply_user_id = $userId;
if (!$ticketMessage || !$ticket->save()) {
throw new \Exception();
}
@@ -40,33 +40,15 @@ class TicketService
public function replyByAdmin($ticketId, $message, $userId): void
{
$ticket = Ticket::where('id', $ticketId)
->first();
$ticket = Ticket::where('id', $ticketId)->first();
if (!$ticket) {
throw new ApiException('工单不存在');
}
$ticket->status = Ticket::STATUS_OPENING;
try {
DB::beginTransaction();
$ticketMessage = TicketMessage::create([
'user_id' => $userId,
'ticket_id' => $ticket->id,
'message' => $message
]);
if ($userId !== $ticket->user_id) {
$ticket->reply_status = Ticket::STATUS_OPENING;
} else {
$ticket->reply_status = Ticket::STATUS_CLOSED;
}
if (!$ticketMessage || !$ticket->save()) {
throw new ApiException('工单回复失败');
}
DB::commit();
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
} catch (\Exception $e) {
DB::rollBack();
throw $e;
$ticketMessage = $this->reply($ticket, $message, $userId);
if (!$ticketMessage) {
throw new ApiException('工单回复失败');
}
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
$this->sendEmailNotify($ticket, $ticketMessage);
}
@@ -81,7 +63,9 @@ class TicketService
$ticket = Ticket::create([
'user_id' => $userId,
'subject' => $subject,
'level' => $level
'level' => $level,
'reply_status' => Ticket::REPLY_STATUS_WAITING,
'last_reply_user_id' => $userId,
]);
if (!$ticket) {
throw new ApiException('工单创建失败');