Save workspace changes
This commit is contained in:
474
app/Console/Commands/AuditOrphanedArtworksCommand.php
Normal file
474
app/Console/Commands/AuditOrphanedArtworksCommand.php
Normal file
@@ -0,0 +1,474 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user