fix(api): 修复节点流量限额共享统计与父子显隐联动

统一节点流量统计与限额展示口径,节点详情新增昨日流量,
并让今日、昨日和本月使用清晰的半开时间窗口聚合

同 machine_id 或同 host 的节点现在共享当前账期已用流量,
管理端优先使用后端 traffic_limit_snapshot 展示月额度状态,
mi-node 下发的 current_used 也改为共享账期统计

新增 parent_auto_hidden 标记与父节点显隐联动服务,父节点
因自动上线或流量限额变为不可展示时会隐藏当前显示的子节点,
恢复时只恢复这批自动隐藏的子节点,避免覆盖手动操作
This commit is contained in:
yinjianm
2026-04-29 02:24:57 +08:00
parent 922e86070d
commit e847252e12
27 changed files with 2078 additions and 47 deletions
+19
View File
@@ -53,6 +53,7 @@ class ServerAutoOnlineService
$wasShown = (bool) $server->show;
if ($wasShown === $shouldShow && !$shouldClearGfwAutoHidden) {
$this->syncChildrenForFinalState($server, $shouldShow, $result);
$result['unchanged']++;
return;
}
@@ -71,6 +72,24 @@ class ServerAutoOnlineService
if ($wasShown !== $shouldShow) {
$shouldShow ? $result['shown']++ : $result['hidden']++;
}
$this->syncChildrenForFinalState($server, $shouldShow, $result);
}
private function syncChildrenForFinalState(Server $server, bool $shouldShow, array &$result): void
{
$childResult = app(ServerParentVisibilityService::class)
->syncChildrenForParent($server, $shouldShow);
$hidden = (int) ($childResult['hidden'] ?? 0);
$restored = (int) ($childResult['restored'] ?? 0);
$childUpdates = $hidden + $restored;
if ($childUpdates <= 0) {
return;
}
$result['updated'] += $childUpdates;
$result['hidden'] += $hidden;
$result['shown'] += $restored;
}
private function emptyResult(int $total = 0): array
@@ -0,0 +1,113 @@
<?php
namespace App\Services;
use App\Models\Server;
use Illuminate\Database\Eloquent\Builder;
class ServerParentVisibilityService
{
public function syncChildrenForParent(Server $parent, bool $parentVisible): array
{
return $parentVisible
? $this->restoreChildrenForParent($parent)
: $this->hideChildrenForParent($parent);
}
public function hideChildrenForParent(Server $parent): array
{
if (!$this->isParentNode($parent)) {
return $this->emptyResult();
}
$result = $this->emptyResult();
$now = time();
$children = $this->childrenQuery($parent)
->where('show', true)
->get();
foreach ($children as $child) {
if (!$child instanceof Server) {
continue;
}
$child->forceFill([
'show' => false,
'parent_auto_hidden' => true,
'parent_auto_action_at' => $now,
])->save();
$result['hidden']++;
}
return $result;
}
public function restoreChildrenForParent(Server $parent): array
{
if (!$this->isParentNode($parent)) {
return $this->emptyResult();
}
$result = $this->emptyResult();
$now = time();
$children = $this->childrenQuery($parent)
->where('parent_auto_hidden', true)
->get();
foreach ($children as $child) {
if (!$child instanceof Server) {
continue;
}
if ($this->hasBlockingAutoHide($child)) {
$result['unchanged']++;
continue;
}
$child->forceFill([
'show' => true,
'parent_auto_hidden' => false,
'parent_auto_action_at' => $now,
])->save();
$result['restored']++;
}
return $result;
}
public function clearParentAutoHidden(Server $server): void
{
if (!(bool) $server->parent_auto_hidden && $server->parent_auto_action_at === null) {
return;
}
$server->parent_auto_hidden = false;
$server->parent_auto_action_at = null;
}
private function isParentNode(Server $server): bool
{
return (int) ($server->parent_id ?? 0) <= 0 && (int) ($server->id ?? 0) > 0;
}
private function childrenQuery(Server $parent): Builder
{
return Server::query()->where('parent_id', (int) $parent->id);
}
private function hasBlockingAutoHide(Server $server): bool
{
return (bool) $server->gfw_auto_hidden;
}
private function emptyResult(): array
{
return [
'hidden' => 0,
'restored' => 0,
'unchanged' => 0,
];
}
}
+2 -1
View File
@@ -247,7 +247,8 @@ class ServerService
];
if (isset($metrics['traffic_limit']) && is_array($metrics['traffic_limit'])) {
app(ServerTrafficLimitService::class)->applyRuntimeMetrics($node, $metrics['traffic_limit']);
$metricsData['traffic_limit'] = app(ServerTrafficLimitService::class)
->applyRuntimeMetrics($node, $metrics['traffic_limit']);
}
Cache::put(
+430 -20
View File
@@ -3,7 +3,11 @@
namespace App\Services;
use App\Models\Server;
use App\Models\StatServer;
use App\Utils\CacheKey;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ServerTrafficLimitService
@@ -13,22 +17,95 @@ class ServerTrafficLimitService
*/
public function buildNodeConfig(Server $server): array
{
$enabled = $this->isEnabled($server);
$nextResetAt = $enabled
? ($server->traffic_limit_next_reset_at ?: $this->calculateNextResetAt($server)?->timestamp)
: null;
$snapshot = $this->buildTrafficLimitSnapshot($server);
$enabled = (bool) $snapshot['enabled'];
return [
'enabled' => $enabled,
'limit' => $enabled ? (int) $server->transfer_enable : 0,
'limit' => (int) $snapshot['limit'],
'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),
'current_used' => (int) $snapshot['used'],
'last_reset_at' => (int) $snapshot['last_reset_at'],
'next_reset_at' => (int) $snapshot['next_reset_at'],
'suspended_at' => (int) $snapshot['suspended_at'],
'status' => (string) $snapshot['status'],
];
}
/**
* Build traffic limit snapshots for an already loaded server list.
*/
public function buildSnapshotsForServers(Collection $servers, ?int $referenceTimestamp = null): array
{
$serversByScope = $servers->groupBy(fn (Server $server) => $this->trafficLimitScopeKey($server));
$snapshots = [];
foreach ($servers as $server) {
$scopeServers = $serversByScope->get($this->trafficLimitScopeKey($server), collect([$server]));
$snapshots[(int) $server->id] = $this->buildTrafficLimitSnapshot(
$server,
$scopeServers,
$referenceTimestamp
);
}
return $snapshots;
}
/**
* Build a panel-side snapshot using the shared traffic limit scope.
*/
public function buildTrafficLimitSnapshot(
Server $server,
?Collection $scopeServers = null,
?int $referenceTimestamp = null
): array {
$enabled = $this->isEnabled($server);
$scopeServers = $this->resolveTrafficLimitScopeServers($server, $scopeServers);
$limit = $enabled ? (int) $server->transfer_enable : 0;
$trafficLimit = $server->getKey() ? $this->cachedTrafficLimitMetrics($server) : null;
$used = $enabled ? $this->currentUsed($server, $trafficLimit, $scopeServers, $referenceTimestamp) : 0;
$reportedSuspension = $this->scopeReportedSuspension($server, $trafficLimit, $scopeServers, $limit);
$suspended = $enabled && $limit > 0 && (
$used >= $limit
|| $reportedSuspension['suspended']
);
$reference = $this->referenceTime($server, $referenceTimestamp);
$cycleStartAt = $enabled ? $this->calculateCurrentCycleStartAt($server, $reference) : null;
$cycleStartTimestamp = $cycleStartAt ? $cycleStartAt->timestamp : 0;
$nextResetAt = $enabled
? ($server->traffic_limit_next_reset_at ?: $this->calculateNextResetAt($server, $reference)?->timestamp)
: 0;
$lastResetAt = max(
(int) ($server->traffic_limit_last_reset_at ?? 0),
$cycleStartTimestamp
);
return [
'enabled' => $enabled,
'limit' => $limit,
'used' => $used,
'percent' => $limit > 0 ? min(100, (int) round(($used / $limit) * 100)) : 0,
'suspended' => $suspended,
'last_reset_at' => $enabled ? $lastResetAt : 0,
'cycle_start_at' => $enabled ? $cycleStartTimestamp : 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,
'suspended_at' => $suspended
? (int) ($reportedSuspension['suspended_at'] ?: ($server->traffic_limit_suspended_at ?? time()))
: 0,
'status' => $suspended
? Server::TRAFFIC_LIMIT_STATUS_SUSPENDED
: Server::TRAFFIC_LIMIT_STATUS_NORMAL,
'scope_key' => $this->trafficLimitScopeKey($server),
'scope_node_ids' => $scopeServers
->pluck('id')
->filter(fn ($id) => (int) $id > 0)
->map(fn ($id) => (int) $id)
->unique()
->values()
->all(),
];
}
@@ -39,6 +116,9 @@ class ServerTrafficLimitService
{
$values = $this->scheduleValues($server);
$server->forceFill($values)->saveQuietly();
$freshServer = $server->refresh();
$this->syncCachedRuntimeMetrics($freshServer);
$this->syncParentChildrenFromTrafficLimit($freshServer);
if ($notifyNode) {
NodeSyncService::notifyConfigUpdated((int) $server->id);
@@ -61,6 +141,9 @@ class ServerTrafficLimitService
: null,
'traffic_limit_suspended_at' => null,
])->saveQuietly();
$freshServer = $server->refresh();
$this->syncCachedRuntimeMetrics($freshServer);
$this->syncParentChildrenFromTrafficLimit($freshServer);
if ($notifyNode) {
NodeSyncService::notifyFullSync((int) $server->id);
@@ -147,29 +230,69 @@ class ServerTrafficLimitService
}
/**
* Apply limiter metrics from mi-node to panel-side runtime fields.
* Calculate the reset boundary that starts the current billing cycle.
*/
public function applyRuntimeMetrics(Server $server, array $trafficLimit): void
public function calculateCurrentCycleStartAt(Server $server, ?Carbon $from = null): ?Carbon
{
if (empty($trafficLimit)) {
return;
if (!$this->isEnabled($server)) {
return null;
}
$suspended = (bool) ($trafficLimit['suspended'] ?? false);
$timezone = $this->normalizeTimezone($server->traffic_limit_timezone);
$from = ($from ?: Carbon::now($timezone))->copy()->timezone($timezone);
[$hour, $minute] = $this->parseResetTime($server->traffic_limit_reset_time);
$currentMonthTarget = $this->targetForMonth(
$from->year,
$from->month,
$this->normalizeResetDay($server->traffic_limit_reset_day),
$hour,
$minute,
$timezone
);
if ($currentMonthTarget->timestamp <= $from->timestamp) {
return $currentMonthTarget;
}
$previousMonth = $from->copy()->startOfMonth()->subMonthNoOverflow();
return $this->targetForMonth(
$previousMonth->year,
$previousMonth->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): array
{
if (empty($trafficLimit)) {
return [];
}
$snapshot = $this->runtimeSnapshot($server, $trafficLimit);
$suspended = (bool) $snapshot['suspended'];
$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,
'traffic_limit_last_reset_at' => $this->nullableTimestamp($snapshot['last_reset_at']),
'traffic_limit_next_reset_at' => $this->nullableTimestamp($snapshot['next_reset_at']),
'traffic_limit_suspended_at' => $this->nullableTimestamp($snapshot['suspended_at']),
]);
if ($server->isDirty()) {
$server->saveQuietly();
}
$this->syncParentChildrenFromTrafficLimit($server->refresh());
return $snapshot;
}
private function scheduleValues(Server $server): array
@@ -183,13 +306,21 @@ class ServerTrafficLimitService
];
}
$currentUsed = $this->currentUsed($server);
$suspended = $this->isSuspendedByUsage($server, $currentUsed);
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_status' => $suspended
? Server::TRAFFIC_LIMIT_STATUS_SUSPENDED
: Server::TRAFFIC_LIMIT_STATUS_NORMAL,
'traffic_limit_next_reset_at' => $this->calculateNextResetAt($server)?->timestamp,
'traffic_limit_suspended_at' => $suspended
? ($server->traffic_limit_suspended_at ?: time())
: null,
];
}
@@ -198,6 +329,285 @@ class ServerTrafficLimitService
return (bool) $server->traffic_limit_enabled && (int) $server->transfer_enable > 0;
}
private function isSuspendedByUsage(Server $server, ?int $used = null): bool
{
return $this->isEnabled($server)
&& ($used ?? $this->currentUsed($server)) >= (int) $server->transfer_enable;
}
private function syncParentChildrenFromTrafficLimit(Server $server): void
{
if ((int) ($server->parent_id ?? 0) > 0 || (int) ($server->id ?? 0) <= 0) {
return;
}
$suspended = $server->traffic_limit_status === Server::TRAFFIC_LIMIT_STATUS_SUSPENDED
|| $this->isSuspendedByUsage($server);
app(ServerParentVisibilityService::class)->syncChildrenForParent(
$server,
!$suspended && (bool) $server->show
);
}
private function currentUsed(
Server $server,
?array $trafficLimit = null,
?Collection $scopeServers = null,
?int $referenceTimestamp = null
): int
{
$scopeServers = $this->resolveTrafficLimitScopeServers($server, $scopeServers);
$cycleUsed = $this->currentCycleUsed($server, $scopeServers, $referenceTimestamp);
$panelUsed = $this->panelUsed($scopeServers);
$reportedUsed = $this->scopeReportedUsed($server, $trafficLimit, $scopeServers);
return max($cycleUsed ?? $panelUsed, $reportedUsed);
}
private function runtimeSnapshot(Server $server, array $trafficLimit): array
{
$enabled = $this->isEnabled($server);
$limit = $enabled ? (int) $server->transfer_enable : 0;
$used = $this->currentUsed($server, $trafficLimit);
$reportedLimit = (int) ($trafficLimit['limit'] ?? 0);
$snapshotCurrent = $this->isRuntimeSnapshotCurrent($server, $trafficLimit);
$reportedSuspended = $snapshotCurrent && (bool) ($trafficLimit['suspended'] ?? false);
$sameLimitSnapshot = $reportedLimit === 0 || $reportedLimit === $limit;
$metricNextResetAt = (int) ($trafficLimit['next_reset_at'] ?? 0);
$serverNextResetAt = (int) ($server->traffic_limit_next_reset_at ?? 0);
$suspended = $enabled && (
$used >= $limit
|| ($reportedSuspended && $sameLimitSnapshot)
);
return [
'enabled' => $enabled,
'limit' => $limit,
'used' => $used,
'suspended' => $suspended,
'last_reset_at' => max(
(int) ($server->traffic_limit_last_reset_at ?? 0),
(int) ($trafficLimit['last_reset_at'] ?? 0)
),
'next_reset_at' => $snapshotCurrent && $sameLimitSnapshot && $metricNextResetAt > 0
? $metricNextResetAt
: $serverNextResetAt,
'suspended_at' => $suspended
? (int) ($trafficLimit['suspended_at'] ?? $server->traffic_limit_suspended_at ?? time())
: 0,
'status' => $suspended
? Server::TRAFFIC_LIMIT_STATUS_SUSPENDED
: Server::TRAFFIC_LIMIT_STATUS_NORMAL,
];
}
private function syncCachedRuntimeMetrics(Server $server): void
{
$metrics = Cache::get($this->metricsCacheKey($server));
if (!is_array($metrics)) {
return;
}
$trafficLimit = $this->runtimeSnapshot(
$server,
is_array($metrics['traffic_limit'] ?? null) ? $metrics['traffic_limit'] : []
);
$metrics['traffic_limit'] = $trafficLimit;
Cache::put(
$this->metricsCacheKey($server),
$metrics,
max(300, (int) admin_setting('server_push_interval', 60) * 3)
);
}
private function cachedTrafficLimitMetrics(Server $server): ?array
{
$metrics = Cache::get($this->metricsCacheKey($server));
return is_array($metrics) && is_array($metrics['traffic_limit'] ?? null)
? $metrics['traffic_limit']
: null;
}
private function scopeReportedUsed(Server $server, ?array $trafficLimit, Collection $scopeServers): int
{
$used = 0;
foreach ($scopeServers as $scopeServer) {
$scopeTrafficLimit = $this->scopeTrafficLimitMetrics($server, $trafficLimit, $scopeServer);
if (!is_array($scopeTrafficLimit) || !$this->isRuntimeSnapshotCurrent($scopeServer, $scopeTrafficLimit)) {
continue;
}
$used = max($used, max(0, (int) ($scopeTrafficLimit['used'] ?? 0)));
}
return $used;
}
private function scopeReportedSuspension(
Server $server,
?array $trafficLimit,
Collection $scopeServers,
int $limit
): array {
foreach ($scopeServers as $scopeServer) {
$scopeTrafficLimit = $this->scopeTrafficLimitMetrics($server, $trafficLimit, $scopeServer);
if (!is_array($scopeTrafficLimit) || !$this->isRuntimeSnapshotCurrent($scopeServer, $scopeTrafficLimit)) {
continue;
}
$reportedLimit = (int) ($scopeTrafficLimit['limit'] ?? 0);
$sameLimitSnapshot = $reportedLimit === 0 || $reportedLimit === $limit;
if ($sameLimitSnapshot && (bool) ($scopeTrafficLimit['suspended'] ?? false)) {
return [
'suspended' => true,
'suspended_at' => (int) ($scopeTrafficLimit['suspended_at'] ?? 0),
];
}
}
return [
'suspended' => false,
'suspended_at' => 0,
];
}
private function scopeTrafficLimitMetrics(
Server $server,
?array $trafficLimit,
Server $scopeServer
): ?array {
if (!$scopeServer->getKey()) {
return null;
}
if ((int) $scopeServer->getKey() === (int) $server->getKey()) {
return $trafficLimit ?? $this->cachedTrafficLimitMetrics($scopeServer);
}
return $this->cachedTrafficLimitMetrics($scopeServer);
}
private function isRuntimeSnapshotCurrent(Server $server, array $trafficLimit): bool
{
$serverLastResetAt = (int) ($server->traffic_limit_last_reset_at ?? 0);
$snapshotLastResetAt = (int) ($trafficLimit['last_reset_at'] ?? 0);
return $serverLastResetAt <= 0
|| ($snapshotLastResetAt > 0 && $snapshotLastResetAt >= $serverLastResetAt);
}
private function metricsCacheKey(Server $server): string
{
$serverId = $server->parent_id ?: $server->id;
return CacheKey::get('SERVER_' . strtoupper((string) $server->type) . '_METRICS', $serverId);
}
private function resolveTrafficLimitScopeServers(Server $server, ?Collection $scopeServers = null): Collection
{
if ($scopeServers instanceof Collection && $scopeServers->isNotEmpty()) {
return $scopeServers->values();
}
if (!$server->exists || !$server->getKey()) {
return collect([$server]);
}
$machineId = (int) ($server->machine_id ?? 0);
if ($machineId > 0) {
return Server::query()
->where('machine_id', $machineId)
->get();
}
$host = $this->normalizeHost($server->host);
if ($host === '') {
return collect([$server]);
}
return Server::query()
->whereRaw('LOWER(TRIM(host)) = ?', [$host])
->get();
}
private function currentCycleUsed(
Server $server,
Collection $scopeServers,
?int $referenceTimestamp = null
): ?int {
$serverIds = $scopeServers
->pluck('id')
->filter(fn ($id) => (int) $id > 0)
->map(fn ($id) => (int) $id)
->unique()
->values();
if ($serverIds->isEmpty()) {
return null;
}
$reference = $this->referenceTime($server, $referenceTimestamp);
$cycleStartAt = $this->calculateCurrentCycleStartAt($server, $reference);
if (!$cycleStartAt) {
return 0;
}
// v2_stat_server is stored at day granularity, so include the reset day.
$startAt = $cycleStartAt->copy()->startOfDay()->timestamp;
$endAt = $reference->copy()->addDay()->startOfDay()->timestamp;
$row = StatServer::query()
->selectRaw('COUNT(*) as records, COALESCE(SUM(u), 0) as upload, COALESCE(SUM(d), 0) as download')
->whereIn('server_id', $serverIds->all())
->where('record_type', 'd')
->where('record_at', '>=', $startAt)
->where('record_at', '<', $endAt)
->first();
if ((int) ($row->records ?? 0) <= 0) {
return null;
}
return max(0, (int) ($row->upload ?? 0) + (int) ($row->download ?? 0));
}
private function panelUsed(Collection $scopeServers): int
{
return max(0, (int) $scopeServers->sum(
fn (Server $server) => max(0, (int) $server->u + (int) $server->d)
));
}
private function trafficLimitScopeKey(Server $server): string
{
$machineId = (int) ($server->machine_id ?? 0);
if ($machineId > 0) {
return 'machine:' . $machineId;
}
$host = $this->normalizeHost($server->host);
if ($host !== '') {
return 'host:' . $host;
}
return 'server:' . (int) ($server->id ?? 0);
}
private function normalizeHost(?string $host): string
{
return strtolower(trim((string) $host));
}
private function referenceTime(Server $server, ?int $referenceTimestamp = null): Carbon
{
$timezone = $this->normalizeTimezone($server->traffic_limit_timezone);
return $referenceTimestamp !== null
? Carbon::createFromTimestamp($referenceTimestamp, $timezone)
: Carbon::now($timezone);
}
private function normalizeResetDay($day): int
{
$normalized = (int) ($day ?: 1);