475 lines
21 KiB
PHP
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,
|
|
};
|
|
}
|
|
}
|