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
@@ -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;
}
}
+47
View File
@@ -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)));
}
}