Files
SkinbaseNova/app/Console/Commands/AuditOrphanedArtworksCommand.php
2026-04-18 17:02:56 +02:00

475 lines
21 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class AuditOrphanedArtworksCommand extends Command
{
protected $signature = 'skinbase:audit-orphaned-artworks
{--output= : Path to write CSV report (optional)}
{--sql= : Path to write SQL import script for recoverable users (optional)}
{--check-usernames : Compare usernames between legacy DB and new users table for the same IDs}
{--hide-missing : Suppress MISS lines in --check-usernames output}
{--username-output= : Path to write username-diff CSV (optional, used with --check-usernames)}
{--username-fix-sql= : Path to write SQL UPDATE script to align new usernames to legacy (optional, used with --check-usernames)}
{--chunk=500 : Chunk size for processing}';
protected $description = 'Find artworks whose user_id does not exist in the users table (also checks legacy DB)';
public function handle(): int
{
$this->info('Scanning for artworks with missing users…');
$chunkSize = (int) $this->option('chunk');
$outputPath = $this->option('output');
$sqlPath = $this->option('sql');
// ── Step 1: find user_ids in artworks missing from the NEW users table ──
$missingFromNew = DB::table('artworks')
->select('user_id')
->distinct()
->whereNotIn('user_id', function ($sub) {
$sub->select('id')->from('users');
})
->pluck('user_id')
->sort()
->values();
if ($missingFromNew->isEmpty()) {
$this->info('✓ No orphaned artworks found — all user_ids exist in users.');
return self::SUCCESS;
}
$this->warn("Found {$missingFromNew->count()} user_id(s) in artworks missing from the new users table.");
// ── Step 2: cross-check against legacy DB ──
$legacyAvailable = false;
$legacyRows = collect();
try {
DB::connection('legacy')->getPdo();
$legacyAvailable = true;
} catch (\Throwable) {
$this->warn('Legacy DB connection is not available — skipping legacy cross-check.');
}
if ($legacyAvailable) {
$this->info('Cross-checking against legacy DB…');
// Legacy users table uses `user_id` as PK (not `id`).
$legacyRows = DB::connection('legacy')
->table('users')
->whereIn('user_id', $missingFromNew->all())
->get()
->keyBy('user_id');
$this->line("{$legacyRows->count()} of those exist in the legacy DB (recoverable).");
$this->line(' • ' . $missingFromNew->diff($legacyRows->keys())->count() . ' do NOT exist in legacy DB either (truly orphaned).');
}
$this->newLine();
// ── Step 3: collect artwork rows ──
$rows = [];
DB::table('artworks')
->whereIn('user_id', $missingFromNew->all())
->orderBy('user_id')
->orderBy('id')
->chunk($chunkSize, function ($artworks) use ($legacyRows, &$rows) {
foreach ($artworks as $artwork) {
$inLegacy = $legacyRows->has($artwork->user_id);
$rows[] = [
'user_id' => $artwork->user_id,
'artwork_id' => $artwork->id,
'artwork_slug' => $artwork->slug ?? '',
'artwork_title' => $artwork->title ?? '',
'status' => $artwork->status ?? '',
'created_at' => $artwork->created_at ?? '',
'in_legacy_db' => $inLegacy ? 'yes' : 'no',
];
}
});
// ── Step 4: console table ──
$tableRows = collect($rows)->map(fn($r) => [
$r['user_id'],
$r['artwork_id'],
$r['artwork_slug'],
mb_strimwidth((string) $r['artwork_title'], 0, 48, '…'),
$r['status'],
$r['created_at'],
$r['in_legacy_db'],
])->all();
$this->table(
['user_id', 'artwork_id', 'slug', 'title', 'status', 'created_at', 'in_legacy_db'],
$tableRows,
);
$this->newLine();
// ── Step 5: per-user summary ──
$countPerUser = collect($rows)->groupBy('user_id');
$this->info('Summary per missing user:');
foreach ($countPerUser as $userId => $group) {
$inLegacy = $group->first()['in_legacy_db'];
$this->line(sprintf(
' user_id %-8s %3d artwork(s) legacy_db: %s',
$userId,
$group->count(),
$inLegacy,
));
}
$this->newLine();
$this->warn('Total orphaned artworks: ' . count($rows));
// ── Step 6: optional CSV export ──
if ($outputPath) {
$fp = fopen($outputPath, 'w');
if ($fp === false) {
$this->error("Could not open output file: {$outputPath}");
return self::FAILURE;
}
fputcsv($fp, ['user_id', 'artwork_id', 'artwork_slug', 'artwork_title', 'status', 'created_at', 'in_legacy_db']);
foreach ($rows as $row) {
fputcsv($fp, array_values($row));
}
fclose($fp);
$this->info("Report written to: {$outputPath}");
}
// ── Step 7: optional SQL import script for recoverable users ──
if ($sqlPath) {
if (! $legacyAvailable) {
$this->error('Cannot generate SQL: legacy DB connection is not available.');
return self::FAILURE;
}
$result = $this->generateImportSql($sqlPath, $legacyRows, $missingFromNew);
if ($result !== self::SUCCESS) {
return $result;
}
}
// ── Step 8: optional username diff between legacy and new DB ──
if ($this->option('check-usernames')) {
$this->checkUsernameDiff((int) $this->option('chunk'), $this->option('username-output'), $this->option('username-fix-sql'));
}
return self::SUCCESS;
}
/**
* Generate a self-contained SQL script that re-inserts recoverable users
* (plus their user_profiles and user_statistics rows) into the new DB.
*
* @param \Illuminate\Support\Collection $legacyRows keyed by user_id
* @param \Illuminate\Support\Collection $missingFromNew
*/
protected function generateImportSql(string $path, $legacyRows, $missingFromNew): int
{
// Also pull statistics from legacy for each recoverable user.
$legacyStats = DB::connection('legacy')
->table('users_statistics')
->whereIn('user_id', $legacyRows->keys()->all())
->get()
->keyBy('user_id');
$now = now()->format('Y-m-d H:i:s');
$lines = [];
$lines[] = '-- ============================================================';
$lines[] = '-- Skinbase orphaned-artwork user recovery script';
$lines[] = '-- Generated: ' . $now;
$lines[] = '-- Source: legacy DB → new users / user_profiles / user_statistics';
$lines[] = '-- REVIEW CAREFULLY before running on production.';
$lines[] = '-- ============================================================';
$lines[] = '';
$lines[] = 'SET NAMES utf8mb4;';
$lines[] = 'USE `' . config('database.connections.mysql.database') . '`;';
$lines[] = 'START TRANSACTION;';
$lines[] = '';
$recovered = 0;
$skipped = 0;
foreach ($legacyRows as $userId => $row) {
$userId = (int) $userId;
$stat = $legacyStats->get($userId);
// --- resolve username (mirrors ImportLegacyUsers: lowercase, alnum+dash+underscore, max 20) ---
$rawUsername = trim((string) ($row->uname ?? ''));
$username = $rawUsername !== '' ? $rawUsername : ('user' . $userId);
$username = strtolower(preg_replace('/[^A-Za-z0-9_\-]/', '', $username));
$username = substr($username !== '' ? $username : ('user' . $userId), 0, 20);
// --- resolve email ---
// Use a guaranteed-unique synthetic email for the INSERT so it never conflicts
// with an existing account (email has a unique constraint). The real legacy email
// is stored in a comment — patch it manually after verifying no conflict.
$rawEmail = strtolower(trim((string) ($row->email ?? '')));
$safeEmail = $userId . '@legacy.skinbase.org'; // collision-free, always unique
// --- dates ---
$createdAt = $this->sqlDate($row->joinDate ?? null, $now);
$lastVisitAt = $this->sqlDate($row->LastVisit ?? null, null);
// --- stats ---
$uploads = $this->safeInt($stat->uploads ?? 0);
$downloads = $this->safeInt($stat->downloads ?? 0);
$pageviews = $this->safeInt($stat->pageviews ?? 0);
$awards = $this->safeInt($stat->awards ?? 0);
// --- profile fields ---
$about = $this->sqlString($row->about_me ?? $row->description ?? null);
$country = $this->sqlString($row->country ?? null);
$countryCode = $this->sqlString($row->country_code ? substr($row->country_code, 0, 2) : null);
$language = $this->sqlString($row->lang ?? null);
$website = $this->sqlString($row->web ?? null);
$gender = $this->sqlString($this->normalizeLegacyGender($row->gender ?? null));
$birthdate = $this->sqlDate($row->birth ?? null, null);
$avatarLegacy = $this->sqlString($row->picture ?? null);
$coverImage = $this->sqlString($row->cover_art ?? null);
$lines[] = "-- user_id={$userId} username={$username} real_email={$rawEmail} (password placeholder; user must reset)";
$lines[] = "-- To restore real email after import: UPDATE \`users\` SET \`email\`=" . $this->sqlString($rawEmail) . " WHERE \`id\`={$userId} AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM \`users\` WHERE \`email\`=" . $this->sqlString($rawEmail) . " AND \`id\`!={$userId}) _c);";
$lines[] = "SAVEPOINT sp_{$userId};";
// users: synthetic email guarantees no unique-constraint conflict on INSERT
$name = $this->sqlString($row->real_name ?: $username);
$usernameQ = $this->sqlString($username);
$emailQ = $this->sqlString($safeEmail);
$passwordQ = $this->sqlString('$2y$12$' . Str::random(53));
$isActive = ($row->active ?? 1) ? '1' : '0';
$lastVisitQ = $lastVisitAt ? "'$lastVisitAt'" : 'NULL';
$lines[] = "INSERT IGNORE INTO `users`"
. " (`id`, `username`, `username_changed_at`, `name`, `email`, `password`, `role`,"
. " `is_active`, `needs_password_reset`, `legacy_password_algo`, `last_visit_at`, `created_at`, `updated_at`)"
. " VALUES ({$userId}, {$usernameQ}, '{$now}', {$name}, {$emailQ}, {$passwordQ},"
. " 'user', {$isActive}, 1, NULL, {$lastVisitQ}, '{$createdAt}', '{$now}');";
$lines[] = "UPDATE `users` SET `username`={$usernameQ}, `username_changed_at`='{$now}',"
. " `name`={$name}, `updated_at`='{$now}' WHERE `id` = {$userId};";
// user_profiles: INSERT IGNORE (FK-safe — silently skipped if parent missing) + UPDATE
$lines[] = "INSERT IGNORE INTO `user_profiles`"
. " (`user_id`, `about`, `avatar_legacy`, `cover_image`,"
. " `country`, `country_code`, `language`, `website`, `gender`, `birthdate`, `updated_at`)"
. " VALUES ({$userId}, {$about}, {$avatarLegacy}, {$coverImage},"
. " {$country}, {$countryCode}, {$language}, {$website}, {$gender},"
. " " . ($birthdate ? "'$birthdate'" : 'NULL') . ", '{$now}');";
$lines[] = "UPDATE `user_profiles` SET"
. " `about`={$about}, `avatar_legacy`={$avatarLegacy}, `cover_image`={$coverImage},"
. " `country`={$country}, `country_code`={$countryCode}, `language`={$language},"
. " `website`={$website}, `updated_at`='{$now}' WHERE `user_id` = {$userId};";
// user_statistics: same INSERT IGNORE + UPDATE pattern
$lines[] = "INSERT IGNORE INTO `user_statistics`"
. " (`user_id`, `uploads_count`, `downloads_received_count`,"
. " `artwork_views_received_count`, `awards_received_count`, `updated_at`)"
. " VALUES ({$userId}, {$uploads}, {$downloads}, {$pageviews}, {$awards}, '{$now}');";
$lines[] = "UPDATE `user_statistics` SET"
. " `uploads_count`={$uploads}, `downloads_received_count`={$downloads},"
. " `artwork_views_received_count`={$pageviews}, `awards_received_count`={$awards},"
. " `updated_at`='{$now}' WHERE `user_id` = {$userId};";
$lines[] = '';
$recovered++;
}
// List truly-orphaned user_ids (not in legacy DB either) as comments.
$trulyOrphaned = $missingFromNew->diff($legacyRows->keys());
if ($trulyOrphaned->isNotEmpty()) {
$lines[] = '-- ============================================================';
$lines[] = '-- The following user_ids were NOT found in legacy DB.';
$lines[] = '-- Their artworks are truly orphaned and cannot be auto-recovered.';
$lines[] = '-- ============================================================';
foreach ($trulyOrphaned as $uid) {
$lines[] = "-- user_id={$uid}";
$skipped++;
}
$lines[] = '';
}
$lines[] = 'COMMIT;';
$lines[] = '';
$lines[] = "-- Recovered: {$recovered} | Truly orphaned (no SQL generated): {$skipped}";
$sql = implode("\n", $lines) . "\n";
if (file_put_contents($path, $sql) === false) {
$this->error("Could not write SQL file: {$path}");
return self::FAILURE;
}
$this->info("SQL import script written to: {$path} ({$recovered} users)");
if ($skipped > 0) {
$this->warn("{$skipped} user_id(s) not found in legacy DB — listed as comments at the bottom of the SQL file.");
}
return self::SUCCESS;
}
// ── Helpers ──────────────────────────────────────────────────────────────
protected function checkUsernameDiff(int $chunkSize, ?string $outputPath, ?string $fixSqlPath): void
{
try {
DB::connection('legacy')->getPdo();
} catch (\Throwable) {
$this->error('--check-usernames: legacy DB connection is not available.');
return;
}
$this->info('Comparing usernames between legacy and new DB…');
$this->newLine();
$diffs = [];
$matches = 0;
$missing = 0;
// Stream legacy users in chunks; for each one look up the new users table by same ID.
DB::connection('legacy')
->table('users')
->orderBy('user_id')
->chunk($chunkSize, function ($legacyUsers) use (&$diffs, &$matches, &$missing) {
$ids = $legacyUsers->pluck('user_id')->all();
$newUsers = DB::table('users')
->whereIn('id', $ids)
->pluck('username', 'id'); // keyed by id
foreach ($legacyUsers as $lu) {
$id = (int) $lu->user_id;
if (! $newUsers->has($id)) {
$legacyUsername = strtolower(preg_replace('/[^A-Za-z0-9_\-]/', '', trim((string) ($lu->uname ?? ''))));
$legacyUsername = substr($legacyUsername !== '' ? $legacyUsername : ('user' . $id), 0, 20);
if (! $this->option('hide-missing')) {
$this->line(sprintf(
' <fg=gray>MISS</> id=%-8d legacy=<fg=yellow>%s</>',
$id, $legacyUsername,
));
}
$missing++;
continue;
}
$legacyUsername = strtolower(preg_replace('/[^A-Za-z0-9_\-]/', '', trim((string) ($lu->uname ?? ''))));
$legacyUsername = substr($legacyUsername !== '' ? $legacyUsername : ('user' . $id), 0, 20);
$newUsername = (string) $newUsers->get($id);
if ($legacyUsername !== $newUsername) {
$this->line(sprintf(
' <fg=red>FAIL</> id=%-8d legacy=<fg=yellow>%-20s</> new=<fg=cyan>%s</>',
$id, $legacyUsername, $newUsername,
));
$diffs[] = [
'user_id' => $id,
'legacy_username' => $legacyUsername,
'new_username' => $newUsername,
'legacy_email' => strtolower(trim((string) ($lu->email ?? ''))),
];
} else {
$matches++;
}
}
});
$this->newLine();
if (empty($diffs) && $missing === 0) {
$this->info("✓ All {$matches} checked username(s) match.");
return;
}
$this->warn(count($diffs) . ' FAIL / ' . $missing . ' MISSING / ' . $matches . ' OK');
if ($outputPath) {
$fp = fopen($outputPath, 'w');
if ($fp === false) {
$this->error("Could not open output file: {$outputPath}");
return;
}
fputcsv($fp, ['user_id', 'legacy_username', 'new_username', 'legacy_email']);
foreach ($diffs as $d) {
fputcsv($fp, array_values($d));
}
fclose($fp);
$this->info("Username diff written to: {$outputPath}");
}
if ($fixSqlPath && ! empty($diffs)) {
$lines = [];
$lines[] = "-- Username fix: update new DB usernames to match normalized legacy usernames";
$lines[] = "-- Generated: " . now()->toDateTimeString();
$lines[] = "-- " . count($diffs) . " row(s) affected";
$lines[] = "SET NAMES utf8mb4;";
$lines[] = "USE `projekti_2026_skinbase`;";
$lines[] = "START TRANSACTION;";
$lines[] = '';
foreach ($diffs as $d) {
$newUsername = addslashes($d['new_username']);
$legacyUsername = addslashes($d['legacy_username']);
$lines[] = "-- id={$d['user_id']} old='{$newUsername}' -> new='{$legacyUsername}'";
$lines[] = "UPDATE `users` SET `username` = '{$legacyUsername}', `updated_at` = NOW() WHERE `id` = {$d['user_id']} AND `username` = '{$newUsername}';";
}
$lines[] = '';
$lines[] = 'COMMIT;';
$written = file_put_contents($fixSqlPath, implode("\n", $lines) . "\n");
if ($written === false) {
$this->error("Could not write SQL fix file: {$fixSqlPath}");
} else {
$this->info("Username fix SQL written to: {$fixSqlPath}");
}
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
private function sqlString(?string $value): string
{
if ($value === null || trim($value) === '') {
return 'NULL';
}
// Escape single-quotes and backslashes for SQL.
$escaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $value);
return "'{$escaped}'";
}
private function sqlDate($value, ?string $fallback): ?string
{
if (! $value) {
return $fallback;
}
try {
return \Carbon\Carbon::parse($value)->format('Y-m-d H:i:s');
} catch (\Throwable) {
return $fallback;
}
}
private function safeInt($value): int
{
$n = (int) $value;
return $n < 0 ? 0 : $n;
}
private function normalizeLegacyGender(?string $value): ?string
{
return match (strtolower(trim((string) $value))) {
'm', 'male' => 'M',
'f', 'female' => 'F',
default => null,
};
}
}