Proje dosyaları eklendi

This commit is contained in:
2026-08-29 15:53:53 +03:00
commit d3313400ca
440 changed files with 24827 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpOperationState;
use Throwable;
final class ActivationManager
{
public function state(): ActivationState
{
try {
$configured = ActivationState::tryFrom((string) config('serp.operation_mode'));
if (! $configured) {
return ActivationState::Disabled;
}$db = SerpOperationState::query()->find(1);
if ($db?->emergency_stopped_at) {
return ActivationState::EmergencyStopped;
}
return $configured;
} catch (Throwable) {
return ActivationState::Disabled;
}
}
public function allowsNormal(): bool
{
return config('serp.enabled') === true && config('serp.provider') === 'dataforseo' && $this->state() === ActivationState::Enabled;
}
public function allowsCanary(): bool
{
return config('serp.enabled') === true && config('serp.provider') === 'dataforseo' && $this->state() === ActivationState::Canary;
}
public function allowsPaid(bool $canary = false): bool
{
return $canary ? $this->allowsCanary() : $this->allowsNormal();
}
public function assertPaid(bool $canary = false): void
{
if (! $this->allowsPaid($canary)) {
throw new OperationBlockedException($this->state() === ActivationState::EmergencyStopped ? 'emergency_stopped' : 'activation_blocked');
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Serp\Operations;
enum ActivationState: string
{
case Disabled = 'disabled';
case ValidationOnly = 'validation_only';
case Canary = 'canary';
case Enabled = 'enabled';
case EmergencyStopped = 'emergency_stopped';
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpBudgetEntry;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class BudgetManager
{
public function __construct(private KillSwitch $kill, private OperationAuditLogger $audit, private RampUpPolicy $ramp) {}
public function reserve(string $key, string $mode, ?int $checkId = null): SerpBudgetEntry
{
return Cache::lock('serp-budget-global', 10)->block(5, function () use ($key, $mode, $checkId) {
$result = DB::transaction(function () use ($key, $mode, $checkId) {
if ($e = SerpBudgetEntry::where('reservation_key', $key)->lockForUpdate()->first()) {
return $e;
}
$this->ramp->assertAllowsNewPaid();
$estimate = Money::micros((string) config('serp.estimated_costs.'.$mode));
$day = SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfDay())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
$month = SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfMonth())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
if ($day + $estimate > Money::micros((string) config('serp.daily_budget_usd')) || $month + $estimate > Money::micros((string) config('serp.monthly_budget_usd'))) {
return false;
}
return SerpBudgetEntry::create(['reservation_key' => $key, 'serp_check_id' => $checkId, 'mode' => $mode, 'reserved_micros' => $estimate, 'currency' => 'USD', 'reserved_at' => now('UTC')]);
});
if ($result === false) {
$this->kill->activate('budget_exceeded');
$this->audit->record('budget_blocked', ['cost_micros' => Money::micros((string) config('serp.estimated_costs.'.$mode))]);
throw new OperationBlockedException('budget_blocked');
}
$this->audit->record('budget_reserved', ['mode' => $mode, 'cost_micros' => $result->reserved_micros]);
return $result;
});
}
public function reconcile(string $key, string|int|float|null $actual, ?string $taskId = null): void
{
DB::transaction(function () use ($key, $actual, $taskId) {
$e = SerpBudgetEntry::where('reservation_key', $key)->lockForUpdate()->first();
if (! $e || $e->reconciled_at) {
return;
}$e->forceFill(['provider_task_id' => $taskId, 'actual_micros' => Money::micros($actual), 'reconciled_at' => now('UTC')])->save();
$this->audit->record('budget_reconciled', ['cost_micros' => $e->actual_micros]);
});
}
public function usage(): array
{
$daily = (int) SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfDay())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
$monthly = (int) SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfMonth())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
return ['daily_micros' => $daily, 'monthly_micros' => $monthly];
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Serp\Operations;
use App\Serp\Exceptions\ProviderAuthenticationException;
use App\Serp\Exceptions\ProviderConfigurationException;
use App\Serp\Exceptions\SerpProviderException;
use Illuminate\Support\Facades\Cache;
final class CircuitBreaker
{
public function __construct(private KillSwitch $kill, private OperationAuditLogger $audit) {}
public function assertClosed(): void
{
try {
if (Cache::get('serp:circuit:open')) {
throw new OperationBlockedException('circuit_open');
}
} catch (OperationBlockedException $e) {
throw $e;
} catch (\Throwable) {
throw new OperationBlockedException('circuit_cache_unavailable');
}
}
public function success(): void
{
Cache::forget('serp:circuit:failures');
Cache::forget('serp:circuit:open');
}
public function failure(SerpProviderException $e): void
{
if ($e instanceof ProviderAuthenticationException || $e instanceof ProviderConfigurationException) {
Cache::put('serp:circuit:open', true, now()->addMinutes((int) config('serp.circuit_cooldown_minutes')));
$this->kill->activate('provider_auth_configuration');
$this->audit->record('circuit_opened', ['error_category' => $e->safeCode()]);
return;
}$n = (int) Cache::increment('serp:circuit:failures');
Cache::put('serp:circuit:failures', $n, now()->addMinutes((int) config('serp.circuit_window_minutes')));
if ($n >= (int) config('serp.circuit_failure_threshold')) {
Cache::put('serp:circuit:open', true, now()->addMinutes((int) config('serp.circuit_cooldown_minutes')));
$this->audit->record('circuit_opened', ['error_category' => $e->safeCode()]);
}
}
public function open(): bool
{
return (bool) Cache::get('serp:circuit:open');
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpOperationState;
use App\Serp\Data\ProviderHealth;
use App\Serp\Exceptions\UnexpectedProviderResponseException;
use App\Serp\Providers\DataForSeo\DataForSeoClient;
use App\Serp\Providers\DataForSeo\DataForSeoErrorClassifier;
use Illuminate\Support\Facades\Cache;
final class DataForSeoHealthService
{
public function __construct(private DataForSeoClient $client, private DataForSeoErrorClassifier $errors, private KillSwitch $kill, private OperationAuditLogger $audit) {}
public function validateConfig(): array
{
$configured = (string) config('serp.dataforseo.login') !== '' && (string) config('serp.dataforseo.password') !== '';
$base = rtrim((string) config('serp.dataforseo.base_url'), '/');
$parts = parse_url($base);
if (! is_array($parts) || ($parts['scheme'] ?? null) !== 'https' || strtolower($parts['host'] ?? '') !== 'api.dataforseo.com') {
throw new \InvalidArgumentException('Invalid provider endpoint.');
}if ($configured) {
$this->client->validateConfiguration();
}
foreach (['daily_budget_usd', 'monthly_budget_usd', 'low_balance_usd', 'critical_balance_usd'] as $key) {
if (Money::micros((string) config('serp.'.$key)) <= 0) {
throw new \InvalidArgumentException('Invalid '.$key);
}
}
return ['provider' => 'dataforseo', 'host' => 'api.dataforseo.com', 'tls' => true, 'credentials_configured' => $configured, 'operation_mode' => app(ActivationManager::class)->state()->value];
}
public function remote(bool $fresh = false): ProviderHealth
{
return Cache::remember('serp:dataforseo:health', $fresh ? 0 : (int) config('serp.health_ttl_seconds'), function () {
$data = $this->client->userData();
$status = $data['status_code'] ?? null;
if (! is_int($status)) {
throw new UnexpectedProviderResponseException('Health status missing.');
}if ($status !== 20000) {
throw $this->errors->api($status);
}$task = $data['tasks'][0] ?? null;
if (! is_array($task) || ($task['status_code'] ?? null) !== 20000) {
throw new UnexpectedProviderResponseException('Health task malformed.');
}$balance = $task['result'][0]['money']['balance'] ?? null;
if (! is_int($balance) && ! is_float($balance) && ! is_string($balance)) {
throw new UnexpectedProviderResponseException('Balance missing.');
}$health = new ProviderHealth('dataforseo', Money::micros($balance), 20000, now('UTC'));
SerpOperationState::query()->updateOrCreate(['id' => 1], ['last_health_at' => $health->checkedAt, 'last_balance_micros' => $health->balanceMicros, 'last_provider_status_code' => 20000, 'last_provider_success_at' => $health->checkedAt, 'last_error_category' => null]);
if ($health->balanceMicros < Money::micros((string) config('serp.critical_balance_usd'))) {
$this->kill->activate('critical_low_balance');
} elseif ($health->balanceMicros < Money::micros((string) config('serp.low_balance_usd'))) {
$this->audit->record('low_balance', ['cost_micros' => $health->balanceMicros]);
}
return $health;
});
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpOperationState;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
final class KillSwitch
{
public function __construct(private OperationAuditLogger $audit) {}
public function active(): bool
{
return SerpOperationState::query()->find(1)?->emergency_stopped_at !== null;
}
public function activate(string $reason, ?User $actor = null): void
{
DB::transaction(function () use ($reason, $actor) {
$s = SerpOperationState::query()->lockForUpdate()->find(1) ?: new SerpOperationState(['id' => 1]);
if (! $s->emergency_stopped_at) {
$s->forceFill(['emergency_reason_code' => $reason, 'emergency_stopped_at' => now(), 'emergency_actor_id' => $actor?->id])->save();
$this->audit->record('kill_switch_activated', ['reason' => $reason], $actor);
}
});
}
public function clear(User $actor, string $reason): void
{
if (! $actor->hasRole('super_admin') || ! $actor->hasPermission('serp_operations.emergency_stop')) {
throw new AuthorizationException;
}DB::transaction(function () use ($actor, $reason) {
$s = SerpOperationState::query()->lockForUpdate()->find(1);
if ($s?->emergency_stopped_at) {
$s->forceFill(['emergency_reason_code' => null, 'emergency_stopped_at' => null, 'emergency_actor_id' => null])->save();
$this->audit->record('kill_switch_cleared', ['reason' => $reason], $actor);
}
});
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Serp\Operations;
use InvalidArgumentException;
final class Money
{
public static function micros(string|int|float|null $value): int
{
if ($value === null) {
return 0;
}$s = is_float($value) ? number_format($value, 6, '.', '') : (string) $value;
if (! preg_match('/^-?\d+(?:\.\d{1,6})?$/', $s)) {
throw new InvalidArgumentException('Invalid money value.');
}$negative = str_starts_with($s, '-');
$s = ltrim($s, '-');
[$whole,$fraction] = array_pad(explode('.', $s, 2), 2, '');
$result = ((int) $whole * 1000000) + (int) str_pad($fraction, 6, '0');
return $negative ? -$result : $result;
}
public static function decimal(int $micros): string
{
$negative = $micros < 0;
$micros = abs($micros);
return ($negative ? '-' : '').intdiv($micros, 1000000).'.'.str_pad((string) ($micros % 1000000), 6, '0', STR_PAD_LEFT);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpOperationAudit;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class OperationAuditLogger
{
public function record(string $action, array $context = [], ?User $actor = null, ?string $correlationId = null): void
{
$safe = array_intersect_key($context, array_flip(['check_ulid', 'provider', 'mode', 'task_reference', 'attempt', 'duration_ms', 'result_count', 'error_category', 'cost_micros', 'reason']));
$id = $correlationId ?: Str::uuid()->toString();
SerpOperationAudit::create(['actor_user_id' => $actor?->id, 'action' => $action, 'correlation_id' => $id, 'context' => $safe]);
Log::info('serp_operation.'.$action, ['correlation_id' => $id] + $safe);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Serp\Operations;
use App\Serp\Exceptions\PermanentProviderException;
class OperationBlockedException extends PermanentProviderException
{
public function __construct(private readonly string $reason)
{
parent::__construct($reason);
}
public function safeCode(): string
{
return $this->reason;
}
public function safeMessage(): string
{
return 'SERP kontrol hizmeti şu anda kullanılamıyor.';
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpBudgetEntry;
use App\Models\SerpOperationState;
final class RampUpPolicy
{
public function __construct(private ActivationManager $activation) {}
public function assertAllowsNewPaid(): void
{
if ($this->activation->state() !== ActivationState::Enabled) {
return;
}$state = SerpOperationState::query()->lockForUpdate()->find(1);
if (! $state) {
throw new OperationBlockedException('ramp_state_missing');
}
if (! $state->ramp_started_at) {
$state->forceFill(['ramp_started_at' => now('UTC')])->save();
}$age = $state->ramp_started_at->diffInMinutes(now('UTC'));
$limit = $age < 60 ? (int) config('serp.ramp.first_hour_limit') : ($age < 360 ? (int) config('serp.ramp.six_hour_limit') : (int) config('serp.ramp.first_day_limit'));
$count = SerpBudgetEntry::where('reserved_at', '>=', $state->ramp_started_at)->count();
if ($count >= $limit) {
throw new OperationBlockedException('ramp_up_limit');
}
}
public function status(): array
{
$state = SerpOperationState::find(1);
if (! $state?->ramp_started_at) {
return ['started_at' => null, 'age_minutes' => null, 'used' => 0, 'limit' => (int) config('serp.ramp.first_hour_limit')];
}$age = $state->ramp_started_at->diffInMinutes(now('UTC'));
$limit = $age < 60 ? (int) config('serp.ramp.first_hour_limit') : ($age < 360 ? (int) config('serp.ramp.six_hour_limit') : (int) config('serp.ramp.first_day_limit'));
return ['started_at' => $state->ramp_started_at->toAtomString(), 'age_minutes' => (int) $age, 'used' => SerpBudgetEntry::where('reserved_at', '>=', $state->ramp_started_at)->count(), 'limit' => $limit];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Serp\Operations;
use App\Models\SerpCheck;
use App\Models\SerpOperationState;
use Illuminate\Support\Facades\DB;
final class SerpOperationsMetrics
{
public function __construct(private ActivationManager $activation, private BudgetManager $budget, private CircuitBreaker $circuit, private RampUpPolicy $ramp) {}
public function summary(): array
{
$since = now('UTC')->subDay();
$counts = SerpCheck::where('created_at', '>=', $since)->select('status', 'execution_mode', DB::raw('count(*) aggregate'))->groupBy('status', 'execution_mode')->get();
$total = $counts->sum('aggregate');
$completed = $counts->where('status', 'completed')->sum('aggregate');
$state = SerpOperationState::find(1);
return ['activation_mode' => $this->activation->state()->value, 'kill_switch' => (bool) $state?->emergency_stopped_at, 'kill_reason' => $state?->emergency_reason_code, 'circuit_open' => $this->circuit->open(), 'last_health_at' => $state?->last_health_at, 'last_balance_micros' => $state?->last_balance_micros, 'last_error_category' => $state?->last_error_category, 'checks_24h' => $total, 'completed_24h' => $completed, 'failed_24h' => $counts->where('status', 'failed')->sum('aggregate'), 'success_percent' => $total ? round($completed * 100 / $total, 1) : 0, 'average_duration_ms' => (int) SerpCheck::where('created_at', '>=', $since)->whereNotNull('duration_ms')->avg('duration_ms'), 'average_polls' => (float) SerpCheck::where('created_at', '>=', $since)->avg('provider_poll_attempts'), 'stuck' => SerpCheck::whereIn('status', ['pending', 'running'])->where('updated_at', '<', now()->subMinutes((int) config('serp.stuck_after_minutes')))->count(), 'waiting_provider' => SerpCheck::where('status', 'running')->whereNotNull('provider_reference')->count(), 'budget' => $this->budget->usage(), 'ramp_up' => $this->ramp->status(), 'queue_backlog' => DB::table('jobs')->where('queue', config('serp.queue'))->count(), 'failed_jobs' => DB::table('failed_jobs')->count()];
}
}