feat(api): 新增节点月流量限额强制下线
新增节点级月流量限额配置、重置调度和运行状态持久化 下发 traffic_limit 给 mi-node,并在超额后停止内核、到期后恢复 管理端支持编辑限额参数并展示额度进度、状态和下次重置 手动与定时重置会同步清理限额状态并通知节点刷新配置
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ServerTrafficLimitService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SyncServerTrafficLimits extends Command
|
||||
{
|
||||
protected $signature = 'sync:server-traffic-limits';
|
||||
|
||||
protected $description = '重置到期的节点流量限额状态';
|
||||
|
||||
public function handle(ServerTrafficLimitService $service): int
|
||||
{
|
||||
try {
|
||||
$result = $service->resetDueServers();
|
||||
$this->info("处理 {$result['processed']} 个节点,重置 {$result['reset']} 个节点");
|
||||
|
||||
if (!empty($result['errors'])) {
|
||||
$this->warn('部分节点重置失败,详情请查看日志');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('节点流量限额同步失败', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
$this->error("节点流量限额同步失败: {$e->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('cleanup:online-status')->everyFiveMinutes()->onOneServer();
|
||||
$schedule->command('sync:server-auto-online')->everyFiveMinutes()->onOneServer()->withoutOverlapping(5);
|
||||
$schedule->command('sync:server-gfw-checks')->everyThirtyMinutes()->onOneServer()->withoutOverlapping(30);
|
||||
$schedule->command('sync:server-traffic-limits')->everyMinute()->onOneServer()->withoutOverlapping(10);
|
||||
// backup Timing
|
||||
// if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) {
|
||||
// $schedule->command('backup:database', ['true'])->daily()->onOneServer();
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\StatServer;
|
||||
use App\Services\ServerAutoOnlineService;
|
||||
use App\Services\ServerGfwCheckService;
|
||||
use App\Services\ServerService;
|
||||
use App\Services\ServerTrafficLimitService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -134,6 +135,7 @@ class ManageController extends Controller
|
||||
$params['gfw_auto_action_at'] = null;
|
||||
}
|
||||
$server->update($params);
|
||||
app(ServerTrafficLimitService::class)->refreshSchedule($server->refresh());
|
||||
$this->syncAutoOnlineIfEnabled($server);
|
||||
return $this->success(true);
|
||||
} catch (\Exception $e) {
|
||||
@@ -144,6 +146,7 @@ class ManageController extends Controller
|
||||
|
||||
try {
|
||||
$server = Server::create($params);
|
||||
app(ServerTrafficLimitService::class)->refreshSchedule($server->refresh());
|
||||
$this->syncAutoOnlineIfEnabled($server);
|
||||
return $this->success(true);
|
||||
} catch (\Exception $e) {
|
||||
@@ -262,10 +265,8 @@ class ManageController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$server->u = 0;
|
||||
$server->d = 0;
|
||||
$server->save();
|
||||
|
||||
app(ServerTrafficLimitService::class)->resetServer($server);
|
||||
|
||||
Log::info("Server {$server->id} ({$server->name}) traffic reset by admin");
|
||||
return $this->success(true);
|
||||
} catch (\Exception $e) {
|
||||
@@ -292,10 +293,10 @@ class ManageController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
Server::whereIn('id', $ids)->update([
|
||||
'u' => 0,
|
||||
'd' => 0,
|
||||
]);
|
||||
$service = app(ServerTrafficLimitService::class);
|
||||
Server::whereIn('id', $ids)
|
||||
->get()
|
||||
->each(fn (Server $server) => $service->resetServer($server));
|
||||
|
||||
Log::info("Servers " . implode(',', $ids) . " traffic reset by admin");
|
||||
return $this->success(true);
|
||||
|
||||
@@ -141,6 +141,10 @@ class ServerSave extends FormRequest
|
||||
'rate_time_ranges.*.rate' => 'required_with:rate_time_ranges|numeric|min:0',
|
||||
'protocol_settings' => 'array',
|
||||
'transfer_enable' => 'nullable|integer|min:0',
|
||||
'traffic_limit_enabled' => 'nullable|boolean',
|
||||
'traffic_limit_reset_day' => 'nullable|integer|min:1|max:31',
|
||||
'traffic_limit_reset_time' => 'nullable|string|date_format:H:i',
|
||||
'traffic_limit_timezone' => 'nullable|string|max:64',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -304,6 +308,11 @@ class ServerSave extends FormRequest
|
||||
'protocol_settings.*.in' => ':attribute 的值不合法',
|
||||
'transfer_enable.integer' => '流量上限必须是整数',
|
||||
'transfer_enable.min' => '流量上限不能小于0',
|
||||
'traffic_limit_reset_day.integer' => '重置日期必须是整数',
|
||||
'traffic_limit_reset_day.min' => '重置日期不能小于1',
|
||||
'traffic_limit_reset_day.max' => '重置日期不能大于31',
|
||||
'traffic_limit_reset_time.date_format' => '重置时间格式必须为HH:mm',
|
||||
'traffic_limit_timezone.max' => '重置时区长度不能超过64个字符',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
* @property boolean $gfw_check_enabled 是否自动检测墙状态并同步显示
|
||||
* @property boolean $gfw_auto_hidden 是否由墙状态自动隐藏
|
||||
* @property int|null $gfw_auto_action_at 最近墙状态自动显隐时间
|
||||
* @property boolean $traffic_limit_enabled 是否启用节点流量限额强制下线
|
||||
* @property int|null $traffic_limit_reset_day 节点流量每月重置日
|
||||
* @property string|null $traffic_limit_reset_time 节点流量重置时间
|
||||
* @property string|null $traffic_limit_timezone 节点流量重置时区
|
||||
* @property string|null $traffic_limit_status 节点流量限额运行状态
|
||||
* @property int|null $traffic_limit_last_reset_at 节点流量最近重置时间
|
||||
* @property int|null $traffic_limit_next_reset_at 节点流量下次重置时间
|
||||
* @property int|null $traffic_limit_suspended_at 节点流量限额下线时间
|
||||
* @property string|null $allow_insecure 是否允许不安全
|
||||
* @property string|null $network 网络类型
|
||||
* @property int|null $parent_id 父节点ID
|
||||
@@ -78,6 +86,8 @@ class Server extends Model
|
||||
public const STATUS_OFFLINE = 0;
|
||||
public const STATUS_ONLINE_NO_PUSH = 1;
|
||||
public const STATUS_ONLINE = 2;
|
||||
public const TRAFFIC_LIMIT_STATUS_NORMAL = 'normal';
|
||||
public const TRAFFIC_LIMIT_STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
public const CHECK_INTERVAL = 300; // 5 minutes in seconds
|
||||
|
||||
@@ -133,6 +143,11 @@ class Server extends Model
|
||||
'gfw_check_enabled' => 'boolean',
|
||||
'gfw_auto_hidden' => 'boolean',
|
||||
'gfw_auto_action_at' => 'integer',
|
||||
'traffic_limit_enabled' => 'boolean',
|
||||
'traffic_limit_reset_day' => 'integer',
|
||||
'traffic_limit_last_reset_at' => 'integer',
|
||||
'traffic_limit_next_reset_at' => 'integer',
|
||||
'traffic_limit_suspended_at' => 'integer',
|
||||
'enabled' => 'boolean',
|
||||
'created_at' => 'timestamp',
|
||||
'updated_at' => 'timestamp',
|
||||
|
||||
@@ -26,6 +26,13 @@ class ServerObserver
|
||||
'custom_outbounds',
|
||||
'custom_routes',
|
||||
'cert_config',
|
||||
'transfer_enable',
|
||||
'traffic_limit_enabled',
|
||||
'traffic_limit_reset_day',
|
||||
'traffic_limit_reset_time',
|
||||
'traffic_limit_timezone',
|
||||
'traffic_limit_last_reset_at',
|
||||
'traffic_limit_next_reset_at',
|
||||
])) {
|
||||
NodeSyncService::notifyConfigUpdated($server->id);
|
||||
}
|
||||
|
||||
@@ -241,10 +241,15 @@ class ServerService
|
||||
'api' => $metrics['api'] ?? [],
|
||||
'ws' => $metrics['ws'] ?? [],
|
||||
'limits' => $metrics['limits'] ?? [],
|
||||
'traffic_limit' => $metrics['traffic_limit'] ?? null,
|
||||
'updated_at' => now()->timestamp,
|
||||
'kernel_status' => (bool) ($metrics['kernel_status'] ?? false),
|
||||
];
|
||||
|
||||
if (isset($metrics['traffic_limit']) && is_array($metrics['traffic_limit'])) {
|
||||
app(ServerTrafficLimitService::class)->applyRuntimeMetrics($node, $metrics['traffic_limit']);
|
||||
}
|
||||
|
||||
Cache::put(
|
||||
CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId),
|
||||
$metricsData,
|
||||
@@ -397,6 +402,8 @@ class ServerService
|
||||
}
|
||||
}
|
||||
|
||||
$response['traffic_limit'] = app(ServerTrafficLimitService::class)->buildNodeConfig($node);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ServerTrafficLimitService
|
||||
{
|
||||
/**
|
||||
* Build the traffic limit block sent to mi-node.
|
||||
*/
|
||||
public function buildNodeConfig(Server $server): array
|
||||
{
|
||||
$enabled = $this->isEnabled($server);
|
||||
$nextResetAt = $enabled
|
||||
? ($server->traffic_limit_next_reset_at ?: $this->calculateNextResetAt($server)?->timestamp)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'limit' => $enabled ? (int) $server->transfer_enable : 0,
|
||||
'reset_day' => $enabled ? $this->normalizeResetDay($server->traffic_limit_reset_day) : 0,
|
||||
'reset_time' => $enabled ? $this->normalizeResetTime($server->traffic_limit_reset_time) : null,
|
||||
'timezone' => $enabled ? $this->normalizeTimezone($server->traffic_limit_timezone) : null,
|
||||
'current_used' => max(0, (int) $server->u + (int) $server->d),
|
||||
'last_reset_at' => (int) ($server->traffic_limit_last_reset_at ?? 0),
|
||||
'next_reset_at' => (int) ($nextResetAt ?? 0),
|
||||
'suspended_at' => (int) ($server->traffic_limit_suspended_at ?? 0),
|
||||
'status' => $server->traffic_limit_status ?: Server::TRAFFIC_LIMIT_STATUS_NORMAL,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh persisted schedule fields after admin edits node limit settings.
|
||||
*/
|
||||
public function refreshSchedule(Server $server, bool $notifyNode = true): void
|
||||
{
|
||||
$values = $this->scheduleValues($server);
|
||||
$server->forceFill($values)->saveQuietly();
|
||||
|
||||
if ($notifyNode) {
|
||||
NodeSyncService::notifyConfigUpdated((int) $server->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset panel-side node traffic and notify mi-node to clear local limiter state.
|
||||
*/
|
||||
public function resetServer(Server $server, bool $notifyNode = true): void
|
||||
{
|
||||
$now = time();
|
||||
$server->forceFill([
|
||||
'u' => 0,
|
||||
'd' => 0,
|
||||
'traffic_limit_status' => Server::TRAFFIC_LIMIT_STATUS_NORMAL,
|
||||
'traffic_limit_last_reset_at' => $now,
|
||||
'traffic_limit_next_reset_at' => $this->isEnabled($server)
|
||||
? $this->calculateNextResetAt($server, Carbon::createFromTimestamp($now + 1, $this->normalizeTimezone($server->traffic_limit_timezone)))?->timestamp
|
||||
: null,
|
||||
'traffic_limit_suspended_at' => null,
|
||||
])->saveQuietly();
|
||||
|
||||
if ($notifyNode) {
|
||||
NodeSyncService::notifyFullSync((int) $server->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all nodes whose configured reset time has arrived.
|
||||
*/
|
||||
public function resetDueServers(): array
|
||||
{
|
||||
$now = time();
|
||||
$processed = 0;
|
||||
$reset = 0;
|
||||
$errors = [];
|
||||
|
||||
Server::query()
|
||||
->where('traffic_limit_enabled', true)
|
||||
->where('transfer_enable', '>', 0)
|
||||
->whereNotNull('traffic_limit_next_reset_at')
|
||||
->where('traffic_limit_next_reset_at', '<=', $now)
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($servers) use (&$processed, &$reset, &$errors) {
|
||||
foreach ($servers as $server) {
|
||||
$processed++;
|
||||
try {
|
||||
$this->resetServer($server);
|
||||
$reset++;
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = [
|
||||
'server_id' => $server->id,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
Log::error('节点流量限额重置失败', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
'processed' => $processed,
|
||||
'reset' => $reset,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next monthly reset time from a reference time.
|
||||
*/
|
||||
public function calculateNextResetAt(Server $server, ?Carbon $from = null): ?Carbon
|
||||
{
|
||||
if (!$this->isEnabled($server)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timezone = $this->normalizeTimezone($server->traffic_limit_timezone);
|
||||
$from = ($from ?: Carbon::now($timezone))->copy()->timezone($timezone);
|
||||
[$hour, $minute] = $this->parseResetTime($server->traffic_limit_reset_time);
|
||||
|
||||
$target = $this->targetForMonth(
|
||||
$from->year,
|
||||
$from->month,
|
||||
$this->normalizeResetDay($server->traffic_limit_reset_day),
|
||||
$hour,
|
||||
$minute,
|
||||
$timezone
|
||||
);
|
||||
|
||||
if ($target->timestamp > $from->timestamp) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
$nextMonth = $from->copy()->startOfMonth()->addMonthNoOverflow();
|
||||
return $this->targetForMonth(
|
||||
$nextMonth->year,
|
||||
$nextMonth->month,
|
||||
$this->normalizeResetDay($server->traffic_limit_reset_day),
|
||||
$hour,
|
||||
$minute,
|
||||
$timezone
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply limiter metrics from mi-node to panel-side runtime fields.
|
||||
*/
|
||||
public function applyRuntimeMetrics(Server $server, array $trafficLimit): void
|
||||
{
|
||||
if (empty($trafficLimit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$suspended = (bool) ($trafficLimit['suspended'] ?? false);
|
||||
$server->forceFill([
|
||||
'traffic_limit_status' => $suspended
|
||||
? Server::TRAFFIC_LIMIT_STATUS_SUSPENDED
|
||||
: Server::TRAFFIC_LIMIT_STATUS_NORMAL,
|
||||
'traffic_limit_last_reset_at' => $this->nullableTimestamp($trafficLimit['last_reset_at'] ?? null),
|
||||
'traffic_limit_next_reset_at' => $this->nullableTimestamp($trafficLimit['next_reset_at'] ?? null),
|
||||
'traffic_limit_suspended_at' => $suspended
|
||||
? $this->nullableTimestamp($trafficLimit['suspended_at'] ?? null)
|
||||
: null,
|
||||
]);
|
||||
|
||||
if ($server->isDirty()) {
|
||||
$server->saveQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
private function scheduleValues(Server $server): array
|
||||
{
|
||||
if (!$this->isEnabled($server)) {
|
||||
return [
|
||||
'traffic_limit_enabled' => false,
|
||||
'traffic_limit_status' => null,
|
||||
'traffic_limit_next_reset_at' => null,
|
||||
'traffic_limit_suspended_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'traffic_limit_enabled' => true,
|
||||
'traffic_limit_reset_day' => $this->normalizeResetDay($server->traffic_limit_reset_day),
|
||||
'traffic_limit_reset_time' => $this->normalizeResetTime($server->traffic_limit_reset_time),
|
||||
'traffic_limit_timezone' => $this->normalizeTimezone($server->traffic_limit_timezone),
|
||||
'traffic_limit_status' => $server->traffic_limit_status ?: Server::TRAFFIC_LIMIT_STATUS_NORMAL,
|
||||
'traffic_limit_next_reset_at' => $this->calculateNextResetAt($server)?->timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
private function isEnabled(Server $server): bool
|
||||
{
|
||||
return (bool) $server->traffic_limit_enabled && (int) $server->transfer_enable > 0;
|
||||
}
|
||||
|
||||
private function normalizeResetDay($day): int
|
||||
{
|
||||
$normalized = (int) ($day ?: 1);
|
||||
return max(1, min(31, $normalized));
|
||||
}
|
||||
|
||||
private function normalizeResetTime(?string $time): string
|
||||
{
|
||||
return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) $time)
|
||||
? (string) $time
|
||||
: '00:00';
|
||||
}
|
||||
|
||||
private function normalizeTimezone(?string $timezone): string
|
||||
{
|
||||
$timezone = trim((string) $timezone);
|
||||
if ($timezone === '') {
|
||||
return config('app.timezone', 'UTC');
|
||||
}
|
||||
|
||||
return in_array($timezone, timezone_identifiers_list(), true)
|
||||
? $timezone
|
||||
: config('app.timezone', 'UTC');
|
||||
}
|
||||
|
||||
private function parseResetTime(?string $time): array
|
||||
{
|
||||
[$hour, $minute] = explode(':', $this->normalizeResetTime($time));
|
||||
return [(int) $hour, (int) $minute];
|
||||
}
|
||||
|
||||
private function targetForMonth(int $year, int $month, int $day, int $hour, int $minute, string $timezone): Carbon
|
||||
{
|
||||
$firstDay = Carbon::create($year, $month, 1, $hour, $minute, 0, $timezone);
|
||||
$targetDay = min($day, $firstDay->copy()->endOfMonth()->day);
|
||||
|
||||
return Carbon::create($year, $month, $targetDay, $hour, $minute, 0, $timezone);
|
||||
}
|
||||
|
||||
private function nullableTimestamp($value): ?int
|
||||
{
|
||||
$timestamp = (int) ($value ?? 0);
|
||||
return $timestamp > 0 ? $timestamp : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user