diff --git a/admin-frontend/src/views/nodes/NodesView.vue b/admin-frontend/src/views/nodes/NodesView.vue
index 5aee5eb..40028e4 100644
--- a/admin-frontend/src/views/nodes/NodesView.vue
+++ b/admin-frontend/src/views/nodes/NodesView.vue
@@ -47,6 +47,7 @@ import {
getNodeGroupNames,
getNodeIdLabel,
getNodeStatusMeta,
+ getNodeTrafficLimitDetail,
getNodeTrafficDetails,
getNodeTypeLabel,
type NodeRelationFilter,
@@ -921,12 +922,36 @@ watch(
下行 {{ traffic.download }}
+
+
+ 月额度
+ {{ getNodeTrafficLimitDetail(row).used }} / {{ getNodeTrafficLimitDetail(row).limit }}
+
+
+
+
+
+ {{ getNodeTrafficLimitDetail(row).statusLabel }}
+ {{ getNodeTrafficLimitDetail(row).nextReset }}
+
+
{{ getNodeStatusMeta(row).label }}
+
+ {{ getNodeTrafficLimitDetail(row).statusLabel }}
+
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;
+ }
+ }
+}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index 200f082..3e3f64c 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -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();
diff --git a/app/Http/Controllers/V2/Admin/Server/ManageController.php b/app/Http/Controllers/V2/Admin/Server/ManageController.php
index a4cb39b..b4068f2 100644
--- a/app/Http/Controllers/V2/Admin/Server/ManageController.php
+++ b/app/Http/Controllers/V2/Admin/Server/ManageController.php
@@ -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);
diff --git a/app/Http/Requests/Admin/ServerSave.php b/app/Http/Requests/Admin/ServerSave.php
index 8fc806d..067ddad 100644
--- a/app/Http/Requests/Admin/ServerSave.php
+++ b/app/Http/Requests/Admin/ServerSave.php
@@ -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个字符',
];
}
}
diff --git a/app/Models/Server.php b/app/Models/Server.php
index c54d120..7d8139c 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -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',
diff --git a/app/Observers/ServerObserver.php b/app/Observers/ServerObserver.php
index f134ba5..14803da 100644
--- a/app/Observers/ServerObserver.php
+++ b/app/Observers/ServerObserver.php
@@ -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);
}
diff --git a/app/Services/ServerService.php b/app/Services/ServerService.php
index e388875..e5680d7 100644
--- a/app/Services/ServerService.php
+++ b/app/Services/ServerService.php
@@ -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;
}
diff --git a/app/Services/ServerTrafficLimitService.php b/app/Services/ServerTrafficLimitService.php
new file mode 100644
index 0000000..7ef8cb5
--- /dev/null
+++ b/app/Services/ServerTrafficLimitService.php
@@ -0,0 +1,245 @@
+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;
+ }
+}
diff --git a/database/migrations/2026_04_28_192200_add_traffic_limit_fields_to_v2_server_table.php b/database/migrations/2026_04_28_192200_add_traffic_limit_fields_to_v2_server_table.php
new file mode 100644
index 0000000..e0edb1a
--- /dev/null
+++ b/database/migrations/2026_04_28_192200_add_traffic_limit_fields_to_v2_server_table.php
@@ -0,0 +1,90 @@
+boolean('traffic_limit_enabled')
+ ->default(false)
+ ->after('transfer_enable')
+ ->comment('Enable node traffic limit enforcement');
+ }
+ if (!Schema::hasColumn('v2_server', 'traffic_limit_reset_day')) {
+ $table->unsignedTinyInteger('traffic_limit_reset_day')
+ ->nullable()
+ ->after('traffic_limit_enabled')
+ ->comment('Monthly reset day, 1-31');
+ }
+ if (!Schema::hasColumn('v2_server', 'traffic_limit_reset_time')) {
+ $table->string('traffic_limit_reset_time', 5)
+ ->nullable()
+ ->after('traffic_limit_reset_day')
+ ->comment('Monthly reset time in HH:mm');
+ }
+ if (!Schema::hasColumn('v2_server', 'traffic_limit_timezone')) {
+ $table->string('traffic_limit_timezone', 64)
+ ->nullable()
+ ->after('traffic_limit_reset_time')
+ ->comment('Timezone used for node traffic reset');
+ }
+ if (!Schema::hasColumn('v2_server', 'traffic_limit_status')) {
+ $table->string('traffic_limit_status', 32)
+ ->nullable()
+ ->after('traffic_limit_timezone')
+ ->comment('Runtime status reported by node traffic limiter');
+ }
+ if (!Schema::hasColumn('v2_server', 'traffic_limit_last_reset_at')) {
+ $table->unsignedBigInteger('traffic_limit_last_reset_at')
+ ->nullable()
+ ->after('traffic_limit_status')
+ ->comment('Last node traffic reset timestamp');
+ }
+ if (!Schema::hasColumn('v2_server', 'traffic_limit_next_reset_at')) {
+ $table->unsignedBigInteger('traffic_limit_next_reset_at')
+ ->nullable()
+ ->after('traffic_limit_last_reset_at')
+ ->comment('Next node traffic reset timestamp');
+ }
+ if (!Schema::hasColumn('v2_server', 'traffic_limit_suspended_at')) {
+ $table->unsignedBigInteger('traffic_limit_suspended_at')
+ ->nullable()
+ ->after('traffic_limit_next_reset_at')
+ ->comment('Timestamp when node was suspended by traffic limit');
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('v2_server', function (Blueprint $table) {
+ $columns = [
+ 'traffic_limit_enabled',
+ 'traffic_limit_reset_day',
+ 'traffic_limit_reset_time',
+ 'traffic_limit_timezone',
+ 'traffic_limit_status',
+ 'traffic_limit_last_reset_at',
+ 'traffic_limit_next_reset_at',
+ 'traffic_limit_suspended_at',
+ ];
+
+ foreach ($columns as $column) {
+ if (Schema::hasColumn('v2_server', $column)) {
+ $table->dropColumn($column);
+ }
+ }
+ });
+ }
+};
diff --git a/tests/Unit/ServerTrafficLimitServiceTest.php b/tests/Unit/ServerTrafficLimitServiceTest.php
new file mode 100644
index 0000000..fe342a8
--- /dev/null
+++ b/tests/Unit/ServerTrafficLimitServiceTest.php
@@ -0,0 +1,54 @@
+ true,
+ 'transfer_enable' => 100,
+ 'traffic_limit_reset_day' => 31,
+ 'traffic_limit_reset_time' => '03:30',
+ 'traffic_limit_timezone' => 'UTC',
+ ]);
+
+ $nextReset = app(ServerTrafficLimitService::class)->calculateNextResetAt(
+ $server,
+ Carbon::create(2026, 2, 1, 0, 0, 0, 'UTC')
+ );
+
+ $this->assertSame('2026-02-28 03:30:00', $nextReset?->format('Y-m-d H:i:s'));
+ }
+
+ public function test_build_node_config_uses_transfer_enable_and_panel_usage(): void
+ {
+ $server = new Server([
+ 'traffic_limit_enabled' => true,
+ 'transfer_enable' => 1024,
+ 'traffic_limit_reset_day' => 1,
+ 'traffic_limit_reset_time' => '04:00',
+ 'traffic_limit_timezone' => 'Asia/Shanghai',
+ 'traffic_limit_next_reset_at' => 1774977600,
+ 'u' => 400,
+ 'd' => 600,
+ ]);
+
+ $config = app(ServerTrafficLimitService::class)->buildNodeConfig($server);
+
+ $this->assertTrue($config['enabled']);
+ $this->assertSame(1024, $config['limit']);
+ $this->assertSame(1000, $config['current_used']);
+ $this->assertSame(1, $config['reset_day']);
+ $this->assertSame('04:00', $config['reset_time']);
+ $this->assertSame('Asia/Shanghai', $config['timezone']);
+ $this->assertSame(1774977600, $config['next_reset_at']);
+ $this->assertSame(Server::TRAFFIC_LIMIT_STATUS_NORMAL, $config['status']);
+ }
+}