Proje dosyaları eklendi
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SerpOperationAudit;
|
||||
use App\Serp\Data\SerpQuery;
|
||||
use App\Serp\Exceptions\SerpProviderException;
|
||||
use App\Serp\Operations\ActivationManager;
|
||||
use App\Serp\Operations\BudgetManager;
|
||||
use App\Serp\Operations\CircuitBreaker;
|
||||
use App\Serp\Operations\OperationAuditLogger;
|
||||
use App\Serp\Operations\OperationBlockedException;
|
||||
use App\Serp\SerpProviderRegistry;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class DataForSeoCanaryCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:dataforseo:canary {--execute : Tek ücretli canary isteğini bilinçli olarak çalıştır} {--json}';
|
||||
|
||||
protected $description = 'DataForSEO canary kontrolünü varsayılan olarak dry-run yapar';
|
||||
|
||||
public function handle(ActivationManager $activation, BudgetManager $budget, CircuitBreaker $circuit, SerpProviderRegistry $providers, OperationAuditLogger $audit): int
|
||||
{
|
||||
$base = ['mode' => $activation->state()->value, 'execute' => (bool) $this->option('execute'), 'depth' => 10, 'provider' => 'dataforseo'];
|
||||
if (! $this->option('execute')) {
|
||||
$base['status'] = 'dry_run';
|
||||
$this->output($base);
|
||||
|
||||
return self::SUCCESS;
|
||||
}try {
|
||||
$activation->assertPaid(true);
|
||||
$circuit->assertClosed();
|
||||
$count = SerpOperationAudit::where('action', 'canary_executed')->where('created_at', '>=', now('UTC')->startOfDay())->count();
|
||||
if ($count >= (int) config('serp.canary.daily_limit')) {
|
||||
throw new OperationBlockedException('canary_limit');
|
||||
}$key = 'canary:'.now('UTC')->format('Y-m-d');
|
||||
$budget->reserve($key, 'live');
|
||||
$q = new SerpQuery((string) config('serp.canary.keyword'), 'google', (string) config('serp.canary.country'), (string) config('serp.canary.language'), (string) config('serp.canary.device'), 10);
|
||||
$started = microtime(true);
|
||||
$r = $providers->resolve('dataforseo')->search($q);
|
||||
$budget->reconcile($key, $r->cost, $r->referenceId);
|
||||
$circuit->success();
|
||||
$audit->record('canary_executed', ['provider' => 'dataforseo', 'mode' => 'live', 'duration_ms' => (int) ((microtime(true) - $started) * 1000), 'result_count' => count($r->results)]);
|
||||
$base += ['status' => 'completed', 'result_count' => count($r->results), 'duration_ms' => (int) ((microtime(true) - $started) * 1000)];
|
||||
$this->output($base);
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$base += ['status' => 'blocked_or_failed', 'category' => $e instanceof SerpProviderException ? $e->safeCode() : 'unexpected'];
|
||||
$this->output($base);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function output(array $data): void
|
||||
{
|
||||
$this->option('json') ? $this->line(json_encode($data)) : $this->info('Canary: '.$data['status']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Serp\Exceptions\SerpProviderException;
|
||||
use App\Serp\Operations\DataForSeoHealthService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class DataForSeoHealthCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:dataforseo:health {--remote : Açık izinle ücretsiz hesap/bakiye doğrulaması} {--json}';
|
||||
|
||||
protected $description = 'DataForSEO yapılandırmasını güvenli biçimde doğrular';
|
||||
|
||||
public function handle(DataForSeoHealthService $health): int
|
||||
{
|
||||
try {
|
||||
$data = $health->validateConfig();
|
||||
if ($this->option('remote')) {
|
||||
$r = $health->remote();
|
||||
$data += ['remote' => 'ok', 'balance_micros' => $r->balanceMicros, 'status_code' => $r->statusCode, 'checked_at' => $r->checkedAt->format(DATE_ATOM)];
|
||||
} else {
|
||||
$data += ['remote' => 'not_requested'];
|
||||
}$this->option('json') ? $this->line(json_encode($data, JSON_THROW_ON_ERROR)) : $this->info('DataForSEO health: OK (uzak çağrı: '.$data['remote'].')');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$safe = ['status' => 'failed', 'category' => $e instanceof SerpProviderException ? $e->safeCode() : 'configuration_invalid'];
|
||||
$this->option('json') ? $this->line(json_encode($safe)) : $this->error('DataForSEO health doğrulanamadı.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\KeywordStatus;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\Keyword;
|
||||
use App\Serp\Operations\ActivationManager;
|
||||
use App\Services\NextCheckAtCalculator;
|
||||
use App\Services\SerpCheckCreator;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class DispatchDueSerpChecks extends Command
|
||||
{
|
||||
protected $signature = 'serp:dispatch-due';
|
||||
|
||||
protected $description = 'Due SERP kontrollerini kuyruğa alır';
|
||||
|
||||
public function handle(SerpCheckCreator $creator, NextCheckAtCalculator $next, ActivationManager $activation): int
|
||||
{
|
||||
if (! config('serp.enabled') || ! config('serp.provider') || (config('serp.provider') === 'dataforseo' && ! $activation->allowsNormal())) {
|
||||
return self::SUCCESS;
|
||||
}Keyword::query()->with('project.user')->where('tracking_enabled', true)->where('status', KeywordStatus::Active)->where('next_check_at', '<=', now())->whereHas('project.user', fn ($q) => $q->where('status', UserStatus::Active))->whereDoesntHave('serpChecks', fn ($q) => $q->whereIn('status', ['pending', 'running']))->chunkById(100, function ($keywords) use ($creator, $next) {
|
||||
foreach ($keywords as $k) {
|
||||
try {
|
||||
$creator->create($k->project->user, $k, true, 'scheduled');
|
||||
$k->forceFill(['last_queued_at' => now(), 'next_check_at' => $next->calculate($k->check_frequency)])->save();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class MakeSuperAdmin extends Command
|
||||
{
|
||||
protected $signature = 'trafficpumper:make-super-admin';
|
||||
|
||||
protected $description = 'İlk TrafficPumper süper admin hesabını güvenli şekilde oluşturur';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$name = (string) $this->ask('Ad Soyad');
|
||||
$email = mb_strtolower((string) $this->ask('E-posta'));
|
||||
if (User::where('email', $email)->exists()) {
|
||||
$this->error('Bu e-posta zaten kullanımda. Mevcut hesap otomatik olarak yükseltilmedi.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$password = (string) $this->secret('Şifre');
|
||||
$confirmation = (string) $this->secret('Şifre tekrar');
|
||||
$validator = Validator::make(compact('name', 'email', 'password') + ['password_confirmation' => $confirmation], [
|
||||
'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->all() as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$user = User::create(['name' => $name, 'email' => $email, 'password' => Hash::make($password)]);
|
||||
$user->forceFill(['email_verified_at' => now()])->saveQuietly();
|
||||
$user->roles()->attach(Role::where('name', 'super_admin')->sole());
|
||||
$this->info('Süper admin hesabı oluşturuldu.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class MigrateSqliteToMariaDb extends Command
|
||||
{
|
||||
protected $signature = 'trafficpumper:migrate-sqlite-to-mariadb {--execute} {--chunk=500} {--include-operational} {--replace-migrated-seeds}';
|
||||
|
||||
protected $description = 'Inspect or copy legacy SQLite business data to an already migrated MariaDB database';
|
||||
|
||||
private const OPERATIONAL = ['cache', 'cache_locks', 'sessions', 'jobs', 'job_batches', 'failed_jobs'];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $alreadyPresent = [];
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $replaceSeeds = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
$source = DB::connection('sqlite_legacy');
|
||||
$target = DB::connection('mysql');
|
||||
$this->assertConnections($source, $target);
|
||||
$tables = $this->orderedTables($source);
|
||||
$this->assertTargetSchema($target, $tables);
|
||||
$this->reportPlan($source, $target, $tables);
|
||||
if (! $this->option('execute')) {
|
||||
$this->info('Dry-run completed. No rows were written. Use --execute to transfer.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$this->assertEmptyTarget($target, $tables);
|
||||
$this->copy($source, $target, $tables);
|
||||
$this->verify($source, $target, $tables);
|
||||
$this->info('Transfer and integrity verification completed successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function assertConnections(Connection $source, Connection $target): void
|
||||
{
|
||||
if ($source->getDriverName() !== 'sqlite') {
|
||||
throw new RuntimeException('sqlite_legacy must use SQLite.');
|
||||
}
|
||||
if (! in_array($target->getDriverName(), ['mysql', 'mariadb'], true)) {
|
||||
throw new RuntimeException('The mysql connection must point to MariaDB/MySQL.');
|
||||
}
|
||||
$path = (string) config('database.connections.sqlite_legacy.database');
|
||||
if (! is_file($path) || ! is_readable($path)) {
|
||||
throw new RuntimeException('The legacy SQLite database is missing or unreadable.');
|
||||
}
|
||||
$server = $target->selectOne('select version() version, @@character_set_database charset, @@collation_database collation');
|
||||
if ($server->charset !== 'utf8mb4' || $server->collation !== 'utf8mb4_unicode_ci') {
|
||||
throw new RuntimeException('Target database must use utf8mb4 and utf8mb4_unicode_ci.');
|
||||
}
|
||||
$this->info("Target server: {$server->version}; {$server->charset}/{$server->collation}");
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function orderedTables(Connection $source): array
|
||||
{
|
||||
$excluded = ['migrations', ...($this->option('include-operational') ? [] : self::OPERATIONAL)];
|
||||
$tables = collect(Schema::connection($source->getName())->getTables())
|
||||
->map(fn (array $table): string => (string) ($table['name'] ?? ''))
|
||||
->filter(fn (string $table): bool => $table !== '' && ! in_array($table, $excluded, true))->values()->all();
|
||||
$pending = array_fill_keys($tables, true);
|
||||
$ordered = [];
|
||||
while ($pending !== []) {
|
||||
$progress = false;
|
||||
foreach (array_keys($pending) as $table) {
|
||||
$dependencies = collect(Schema::connection($source->getName())->getForeignKeys($table))
|
||||
->map(fn (array $key): string => (string) ($key['foreign_table'] ?? ''))
|
||||
->filter(fn (string $dependency): bool => isset($pending[$dependency]) && $dependency !== $table);
|
||||
if ($dependencies->isNotEmpty()) {
|
||||
continue;
|
||||
}
|
||||
$ordered[] = $table;
|
||||
unset($pending[$table]);
|
||||
$progress = true;
|
||||
}
|
||||
if (! $progress) {
|
||||
throw new RuntimeException('Unresolved foreign-key dependencies: '.implode(', ', array_keys($pending)));
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function assertTargetSchema(Connection $target, array $tables): void
|
||||
{
|
||||
$missing = collect($tables)->reject(fn (string $table): bool => Schema::connection($target->getName())->hasTable($table));
|
||||
if ($missing->isNotEmpty()) {
|
||||
throw new RuntimeException('Run target migrations first. Missing tables: '.$missing->implode(', '));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function reportPlan(Connection $source, Connection $target, array $tables): void
|
||||
{
|
||||
$this->table(['Table', 'SQLite', 'MariaDB'], collect($tables)->map(fn (string $table): array => [$table, $source->table($table)->count(), $target->table($table)->count()])->all());
|
||||
if (! $this->option('include-operational')) {
|
||||
$this->warn('Operational tables are excluded; database sessions will require users to sign in again.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function assertEmptyTarget(Connection $target, array $tables): void
|
||||
{
|
||||
$source = DB::connection('sqlite_legacy');
|
||||
foreach ($tables as $table) {
|
||||
if (! $target->table($table)->exists()) {
|
||||
continue;
|
||||
}
|
||||
if ($source->table($table)->count() === $target->table($table)->count()
|
||||
&& hash_equals($this->tableChecksum($source, $table), $this->tableChecksum($target, $table))) {
|
||||
$this->alreadyPresent[$table] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
$primary = collect(Schema::connection($source->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false));
|
||||
$primaryColumns = (array) ($primary['columns'] ?? []);
|
||||
if ($this->option('replace-migrated-seeds') && $primaryColumns !== []
|
||||
&& $source->table($table)->count() === $target->table($table)->count()
|
||||
&& hash_equals($this->keyChecksum($source, $table, $primaryColumns), $this->keyChecksum($target, $table, $primaryColumns))) {
|
||||
$this->replaceSeeds[$table] = $primaryColumns;
|
||||
|
||||
continue;
|
||||
}
|
||||
throw new RuntimeException("Target table {$table} is non-empty and does not exactly match the source.");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function copy(Connection $source, Connection $target, array $tables): void
|
||||
{
|
||||
$chunk = max(1, min(5000, (int) $this->option('chunk')));
|
||||
foreach ($tables as $table) {
|
||||
if (isset($this->alreadyPresent[$table])) {
|
||||
$this->line("Kept identical migrated seed data in {$table}");
|
||||
|
||||
continue;
|
||||
}
|
||||
$columns = collect(Schema::connection($source->getName())->getColumns($table))->pluck('name')->all();
|
||||
$primary = collect(Schema::connection($source->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false));
|
||||
$order = ((array) ($primary['columns'] ?? []))[0] ?? $columns[0] ?? null;
|
||||
if ($order === null) {
|
||||
continue;
|
||||
}
|
||||
$target->transaction(function () use ($source, $target, $table, $order, $chunk): void {
|
||||
$source->table($table)->orderBy($order)->chunk($chunk, function ($rows) use ($target, $table): void {
|
||||
$data = $rows->map(fn (object $row): array => (array) $row)->all();
|
||||
if ($data !== []) {
|
||||
if (isset($this->replaceSeeds[$table])) {
|
||||
$target->table($table)->upsert($data, $this->replaceSeeds[$table]);
|
||||
} else {
|
||||
$target->table($table)->insert($data);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
$this->line("Copied {$table}");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $tables */
|
||||
private function verify(Connection $source, Connection $target, array $tables): void
|
||||
{
|
||||
foreach ($tables as $table) {
|
||||
if ($source->table($table)->count() !== $target->table($table)->count()) {
|
||||
throw new RuntimeException("Row-count mismatch for {$table}.");
|
||||
}
|
||||
$columns = collect(Schema::connection($source->getName())->getColumns($table))->pluck('name');
|
||||
foreach ($columns->filter(fn (string $column): bool => preg_match('/(^|_)(password|token|ulid|uuid)(_hash)?$/', $column) === 1) as $column) {
|
||||
if (! hash_equals($this->checksum($source, $table, $column), $this->checksum($target, $table, $column))) {
|
||||
throw new RuntimeException("Sensitive checksum mismatch for {$table}.{$column}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($tables as $table) {
|
||||
foreach (Schema::connection($target->getName())->getForeignKeys($table) as $key) {
|
||||
$local = (array) ($key['columns'] ?? []);
|
||||
$foreign = (array) ($key['foreign_columns'] ?? []);
|
||||
$parent = (string) ($key['foreign_table'] ?? '');
|
||||
if (count($local) !== 1 || count($foreign) !== 1 || $parent === '') {
|
||||
continue;
|
||||
}
|
||||
$orphans = $target->table("{$table} as child")
|
||||
->leftJoin("{$parent} as parent", "child.{$local[0]}", '=', "parent.{$foreign[0]}")
|
||||
->whereNotNull("child.{$local[0]}")->whereNull("parent.{$foreign[0]}")->count();
|
||||
if ($orphans > 0) {
|
||||
throw new RuntimeException("Foreign-key orphans found in {$table}.{$local[0]}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checksum(Connection $connection, string $table, string $column): string
|
||||
{
|
||||
$hash = hash_init('sha256');
|
||||
$connection->table($table)->orderBy($column)->select($column)->chunk(1000, function ($rows) use ($hash, $column): void {
|
||||
foreach ($rows as $row) {
|
||||
$value = (string) $row->{$column};
|
||||
hash_update($hash, strlen($value).':'.$value."\n");
|
||||
}
|
||||
});
|
||||
|
||||
return hash_final($hash);
|
||||
}
|
||||
|
||||
private function tableChecksum(Connection $connection, string $table): string
|
||||
{
|
||||
$columns = collect(Schema::connection($connection->getName())->getColumns($table))->pluck('name')->all();
|
||||
$primary = collect(Schema::connection($connection->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false));
|
||||
$order = (array) ($primary['columns'] ?? $columns);
|
||||
$hash = hash_init('sha256');
|
||||
$query = $connection->table($table)->select($columns);
|
||||
foreach ($order as $column) {
|
||||
$query->orderBy($column);
|
||||
}
|
||||
$query->chunk(1000, function ($rows) use ($hash, $columns): void {
|
||||
foreach ($rows as $row) {
|
||||
foreach ($columns as $column) {
|
||||
$value = $row->{$column};
|
||||
hash_update($hash, $value === null ? '-1:' : strlen((string) $value).':'.(string) $value);
|
||||
}
|
||||
hash_update($hash, "\n");
|
||||
}
|
||||
});
|
||||
|
||||
return hash_final($hash);
|
||||
}
|
||||
|
||||
/** @param list<string> $columns */
|
||||
private function keyChecksum(Connection $connection, string $table, array $columns): string
|
||||
{
|
||||
$hash = hash_init('sha256');
|
||||
$query = $connection->table($table)->select($columns);
|
||||
foreach ($columns as $column) {
|
||||
$query->orderBy($column);
|
||||
}
|
||||
foreach ($query->get() as $row) {
|
||||
foreach ($columns as $column) {
|
||||
hash_update($hash, strlen((string) $row->{$column}).':'.(string) $row->{$column});
|
||||
}
|
||||
hash_update($hash, "\n");
|
||||
}
|
||||
|
||||
return hash_final($hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\PollSerpCheck;
|
||||
use App\Models\SerpCheck;
|
||||
use App\Serp\Operations\OperationAuditLogger;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReconcileSerpChecksCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:reconcile {--execute : Düzeltmeleri uygula}';
|
||||
|
||||
protected $description = 'Takılı SERP kontrollerini güvenli biçimde uzlaştırır';
|
||||
|
||||
public function handle(OperationAuditLogger $audit): int
|
||||
{
|
||||
$count = 0;
|
||||
SerpCheck::query()->whereIn('status', ['pending', 'running'])->where('updated_at', '<', now()->subMinutes((int) config('serp.stuck_after_minutes')))->chunkById(100, function ($checks) use (&$count, $audit) {
|
||||
foreach ($checks as $c) {
|
||||
$count++;
|
||||
if (! $this->option('execute')) {
|
||||
continue;
|
||||
}if ($c->execution_mode === 'standard' && $c->provider_reference) {
|
||||
PollSerpCheck::dispatch($c->ulid)->onQueue(config('serp.queue'));
|
||||
$audit->record('reconciliation_dispatched', ['check_ulid' => $c->ulid, 'mode' => 'standard']);
|
||||
} elseif (! $c->provider_reference) {
|
||||
$c->fail('stuck_without_task', 'SERP kontrolü zamanında tamamlanamadı.');
|
||||
$audit->record('reconciliation_failed_stuck', ['check_ulid' => $c->ulid, 'error_category' => 'poll_exhausted']);
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->info(($this->option('execute') ? 'İşlenen' : 'Bulunan').' kayıt: '.$count);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Serp\Operations\SerpOperationsMetrics;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SerpOperationsSnapshotCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:operations:snapshot {--window=24h} {--json}';
|
||||
|
||||
protected $description = 'Scrub edilmiş SERP operasyon özetini gösterir';
|
||||
|
||||
public function handle(SerpOperationsMetrics $metrics): int
|
||||
{
|
||||
if ($this->option('window') !== '24h') {
|
||||
$this->error('Desteklenen pencere: 24h');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$data = $metrics->summary() + ['window' => '24h', 'generated_at' => now('UTC')->toAtomString()];
|
||||
$this->option('json') ? $this->line(json_encode($data, JSON_THROW_ON_ERROR)) : $this->table(['Alan', 'Değer'], collect($data)->except('budget')->map(fn ($v, $k) => [$k, is_scalar($v) || $v === null ? (string) $v : json_encode($v)])->values()->all());
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SerpCheck;
|
||||
use App\Models\SerpOperationState;
|
||||
use App\Serp\Operations\ActivationManager;
|
||||
use App\Serp\Operations\CircuitBreaker;
|
||||
use App\Serp\Operations\DataForSeoHealthService;
|
||||
use App\Serp\Operations\Money;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
class SerpProductionPreflightCommand extends Command
|
||||
{
|
||||
protected $signature = 'serp:production:preflight {--json}';
|
||||
|
||||
protected $description = 'Production SERP geçiş kapılarını dış HTTP olmadan doğrular';
|
||||
|
||||
public function handle(ActivationManager $activation, DataForSeoHealthService $health, CircuitBreaker $circuit): int
|
||||
{
|
||||
$gates = [];
|
||||
$gate = function (string $name, bool $pass, mixed $value = null, bool $critical = true) use (&$gates) {
|
||||
$gates[$name] = ['status' => $pass ? 'pass' : 'fail', 'critical' => $critical, 'value' => $value];
|
||||
};
|
||||
$configured = (string) config('serp.dataforseo.login') !== '' && (string) config('serp.dataforseo.password') !== '';
|
||||
$gate('environment', app()->environment('production'), app()->environment());
|
||||
$gate('debug', ! config('app.debug'), config('app.debug') ? 'enabled' : 'disabled');
|
||||
$gate('provider', config('serp.provider') === 'dataforseo', config('serp.provider'));
|
||||
$state = $activation->state();
|
||||
$consistent = (! config('serp.enabled') && in_array($state->value, ['disabled', 'validation_only', 'canary'], true)) || (config('serp.enabled') && in_array($state->value, ['canary', 'enabled', 'emergency_stopped'], true));
|
||||
$gate('activation_consistency', $consistent, ['enabled' => (bool) config('serp.enabled'), 'mode' => $state->value]);
|
||||
$gate('credentials', $configured, $configured ? 'configured' : 'not_configured');
|
||||
try {
|
||||
$local = $health->validateConfig();
|
||||
$gate('provider_security', true, ['host' => $local['host'], 'tls' => $local['tls']]);
|
||||
} catch (Throwable) {
|
||||
$gate('provider_security', false, 'invalid');
|
||||
}$pending = $this->pendingMigrations();
|
||||
$gate('migrations', $pending === 0, ['pending' => $pending]);
|
||||
try {
|
||||
$lock = Cache::lock('serp:preflight-lock', 5);
|
||||
$acquired = $lock->get();
|
||||
if ($acquired) {
|
||||
$lock->release();
|
||||
}$gate('cache_lock', $acquired, config('cache.default'));
|
||||
} catch (Throwable) {
|
||||
$gate('cache_lock', false, 'unavailable');
|
||||
}$gate('queue', in_array(config('queue.default'), ['redis', 'database'], true), ['connection' => config('queue.default'), 'serp_queue' => config('serp.queue')]);
|
||||
$console = file_get_contents(base_path('routes/console.php'));
|
||||
$gate('scheduler', str_contains($console, 'serp:dispatch-due') && str_contains($console, 'serp:reconcile'), ['defined' => true]);
|
||||
$stateRow = SerpOperationState::find(1);
|
||||
$gate('kill_switch', ! $stateRow?->emergency_stopped_at, $stateRow?->emergency_stopped_at ? 'active' : 'clear');
|
||||
$gate('circuit', ! $circuit->open(), $circuit->open() ? 'open' : 'closed');
|
||||
foreach (['daily_budget_usd', 'monthly_budget_usd', 'low_balance_usd', 'critical_balance_usd'] as $key) {
|
||||
try {
|
||||
$gate($key, Money::micros((string) config('serp.'.$key)) > 0, (string) config('serp.'.$key));
|
||||
} catch (Throwable) {
|
||||
$gate($key, false, 'invalid');
|
||||
}
|
||||
}$gate('depth', config('serp.dataforseo.result_depth') === 10, config('serp.dataforseo.result_depth'));
|
||||
$gate('provider_modes', config('serp.manual_mode') === 'live' && config('serp.automatic_mode') === 'standard', ['manual' => config('serp.manual_mode'), 'automatic' => config('serp.automatic_mode')]);
|
||||
$summary = ['status' => collect($gates)->contains(fn ($g) => $g['critical'] && $g['status'] === 'fail') ? 'failed' : 'passed', 'remote' => 'not_requested', 'gates' => $gates, 'operations' => ['last_health_at' => $stateRow?->last_health_at, 'last_provider_success_at' => $stateRow?->last_provider_success_at, 'stuck' => SerpCheck::whereIn('status', ['pending', 'running'])->where('updated_at', '<', now()->subMinutes((int) config('serp.stuck_after_minutes')))->count(), 'pending' => SerpCheck::where('status', 'pending')->count(), 'running' => SerpCheck::where('status', 'running')->count()]];
|
||||
$this->option('json') ? $this->line(json_encode($summary, JSON_THROW_ON_ERROR)) : $this->line('Preflight: '.$summary['status']);
|
||||
|
||||
return $summary['status'] === 'passed' ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
private function pendingMigrations(): int
|
||||
{
|
||||
if (! Schema::hasTable('migrations')) {
|
||||
return count(glob(database_path('migrations/*.php')));
|
||||
}$ran = DB::table('migrations')->pluck('migration')->all();
|
||||
|
||||
return count(array_filter(glob(database_path('migrations/*.php')), fn ($f) => ! in_array(pathinfo($f, PATHINFO_FILENAME), $ran, true)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user