mivita/app/Console/Commands/DeletePartnerShopSubdomains.php
Kevin 84238c7694 Subdomain-Cleanup-Commands
Neue Artisan-Commands DeleteAllKasSubdomains und DeletePartnerShopSubdomains
zum Aufraeumen von KAS-/Partnershop-Subdomains, jeweils mit Feature-Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:37:45 +00:00

241 lines
7.9 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Http\Controllers\Api\KasController;
use App\Models\UserShop;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class DeletePartnerShopSubdomains extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'subdomains:delete-partnershops
{--domain=mivita.care : Hauptdomain der Partnershop-Subdomains}
{--start=1 : UserShop-ID, ab der Shops berücksichtigt werden}
{--limit= : Maximale Anzahl zu löschender Subdomains}
{--active-only : Nur aktive/nicht gelöschte UserShops berücksichtigen}
{--include-path-matches : Zusätzlich KAS-Subdomains mit dem Partnershop-Pfad löschen}
{--path=/mein.mivita.care/public/ : Zielpfad der alten Partnershop-Subdomains}
{--force : Subdomains wirklich löschen; ohne diese Option läuft nur ein Dry-Run}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Löscht alte KAS-Subdomains der Partnershops nach Umstellung auf Wildcard-Konfiguration';
/**
* @var array<int, string>
*/
private array $protectedPrefixes = ['www.', 'api.', 'checkout.', 'preview.'];
public function __construct(private KasController $kas)
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$domain = trim((string) $this->option('domain'), '.');
$startId = max(1, (int) $this->option('start'));
$limit = $this->option('limit') !== null ? (int) $this->option('limit') : null;
$isForce = (bool) $this->option('force');
if ($domain === '') {
$this->error('Die Option --domain darf nicht leer sein.');
return self::FAILURE;
}
if ($limit !== null && $limit < 1) {
$this->error('Die Option --limit muss größer als 0 sein.');
return self::FAILURE;
}
try {
$subdomains = $this->getKasSubdomains();
$shopHosts = $this->getPartnerShopHosts($domain, $startId);
$candidates = $this->getDeletionCandidates($subdomains, $shopHosts, $domain);
if ($limit !== null) {
$candidates = $candidates->take($limit)->values();
}
$this->printPreview($candidates, $isForce);
if ($candidates->isEmpty()) {
$this->info('Keine löschbaren Partnershop-Subdomains gefunden.');
return self::SUCCESS;
}
if (! $isForce) {
$this->warn('Dry-Run: Es wurde nichts gelöscht. Mit --force werden die gefundenen Subdomains gelöscht.');
return self::SUCCESS;
}
return $this->deleteSubdomains($candidates);
} catch (Exception $e) {
$this->error('Fehler beim Löschen der Partnershop-Subdomains: '.$e->getMessage());
Log::error('KAS Partnershop-Subdomain-Löschung fehlgeschlagen', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return self::FAILURE;
}
}
private function getKasSubdomains(): Collection
{
$this->info('Rufe Subdomains von KAS ab...');
$subdomains = $this->kas->action('get_subdomains');
if (! is_iterable($subdomains)) {
throw new Exception('KAS hat keine auswertbare Subdomain-Liste zurückgegeben: '.json_encode($subdomains));
}
return collect($subdomains);
}
private function getPartnerShopHosts(string $domain, int $startId): Collection
{
$query = UserShop::query()
->where('id', '>=', $startId)
->orderBy('id');
if ($this->option('active-only')) {
$query->where('active', true);
} else {
$query->withTrashed();
}
return $query
->pluck('slug')
->filter()
->map(fn (string $slug): string => strtolower($slug.'.'.$domain))
->unique()
->values();
}
private function getDeletionCandidates(Collection $subdomains, Collection $shopHosts, string $domain): Collection
{
$shopHostLookup = array_flip($shopHosts->all());
$includePathMatches = (bool) $this->option('include-path-matches');
$partnerShopPath = (string) $this->option('path');
return $subdomains
->filter(function (array $subdomain) use ($domain, $includePathMatches, $partnerShopPath, $shopHostLookup): bool {
$name = strtolower((string) ($subdomain['subdomain_name'] ?? ''));
if ($name === '' || ! $this->belongsToDomain($name, $domain) || $this->isProtectedSubdomain($name)) {
return false;
}
if (isset($shopHostLookup[$name])) {
return true;
}
return $includePathMatches
&& trim((string) ($subdomain['subdomain_path'] ?? '')) === $partnerShopPath;
})
->map(fn (array $subdomain): array => [
'subdomain_name' => (string) $subdomain['subdomain_name'],
'subdomain_path' => (string) ($subdomain['subdomain_path'] ?? ''),
'php_version' => (string) ($subdomain['php_version'] ?? ''),
])
->unique('subdomain_name')
->sortBy('subdomain_name')
->values();
}
private function belongsToDomain(string $subdomainName, string $domain): bool
{
return str_ends_with($subdomainName, '.'.strtolower($domain));
}
private function isProtectedSubdomain(string $subdomainName): bool
{
foreach ($this->protectedPrefixes as $prefix) {
if (str_starts_with($subdomainName, $prefix)) {
return true;
}
}
return false;
}
private function printPreview(Collection $candidates, bool $isForce): void
{
$this->newLine();
$this->info('Modus: '.($isForce ? 'Löschen' : 'Dry-Run'));
$this->info('Gefundene Lösch-Kandidaten: '.$candidates->count());
if ($candidates->isEmpty()) {
return;
}
$this->table(
['Subdomain', 'Pfad', 'PHP'],
$candidates->take(25)->map(fn (array $candidate): array => [
$candidate['subdomain_name'],
$candidate['subdomain_path'] ?: '-',
$candidate['php_version'] ?: '-',
])->all()
);
if ($candidates->count() > 25) {
$this->line('... weitere '.($candidates->count() - 25).' Subdomains ausgeblendet.');
}
}
private function deleteSubdomains(Collection $candidates): int
{
$deleted = 0;
$failed = 0;
foreach ($candidates as $candidate) {
$subdomainName = $candidate['subdomain_name'];
$result = $this->kas->action('delete_subdomain', [
'subdomain_name' => $subdomainName,
]);
if ($this->isSuccessfulDeleteResult($result, $subdomainName)) {
$deleted++;
$this->info("Gelöscht: {$subdomainName}");
continue;
}
$failed++;
$this->error("Fehler beim Löschen von {$subdomainName}: ".json_encode($result));
}
$this->newLine();
$this->info("Gelöscht: {$deleted}");
$this->info("Fehler: {$failed}");
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
private function isSuccessfulDeleteResult(mixed $result, string $subdomainName): bool
{
return $result === true
|| $result === 'TRUE'
|| $result === $subdomainName;
}
}