Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz

This commit is contained in:
2026-03-22 09:13:39 +01:00
parent e8b5edf5d2
commit 2608be7420
80 changed files with 3991 additions and 723 deletions

View File

@@ -11,7 +11,7 @@ use Illuminate\Support\Str;
class ImportLegacyUsers extends Command
{
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--dry-run : Preview which users would be skipped/deleted without making changes}';
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--restore-temp-usernames : Restore legacy usernames for existing users still using tmpu12345-style placeholders} {--dry-run : Preview which users would be skipped/deleted without making changes}';
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
protected string $migrationLogPath;
@@ -20,7 +20,7 @@ class ImportLegacyUsers extends Command
public function handle(): int
{
$this->migrationLogPath = storage_path('logs/username_migration.log');
$this->migrationLogPath = (string) storage_path('logs/username_migration.log');
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
// Build the set of legacy user IDs that have any meaningful activity.
@@ -134,8 +134,14 @@ class ImportLegacyUsers extends Command
{
$legacyId = (int) $row->user_id;
// Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB).
$username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
// Use legacy username as-is by default. Placeholder tmp usernames can be
// restored explicitly with --restore-temp-usernames using safe uniqueness rules.
$existingUser = DB::table('users')
->select(['id', 'username'])
->where('id', $legacyId)
->first();
$username = $this->resolveImportUsername($row, $legacyId, $existingUser?->username ?? null);
$normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? ''));
if ($normalizedLegacy !== $username) {
@@ -173,7 +179,12 @@ class ImportLegacyUsers extends Command
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
$now = now();
$alreadyExists = DB::table('users')->where('id', $legacyId)->exists();
$existingUser = DB::table('users')
->select(['id', 'username'])
->where('id', $legacyId)
->first();
$alreadyExists = $existingUser !== null;
$previousUsername = (string) ($existingUser?->username ?? '');
// All fields synced from legacy on every run
$sharedFields = [
@@ -212,7 +223,7 @@ class ImportLegacyUsers extends Command
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
'language' => $row->lang ?: null,
'birthdate' => $row->birth ?: null,
'gender' => $row->gender ?: 'X',
'gender' => $this->normalizeLegacyGender($row->gender ?? null),
'website' => $row->web ?: null,
'updated_at' => $now,
]
@@ -232,7 +243,7 @@ class ImportLegacyUsers extends Command
);
if (Schema::hasTable('username_redirects')) {
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
$old = $this->usernameRedirectKey((string) ($row->uname ?? ''));
if ($old !== '' && $old !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
@@ -244,10 +255,50 @@ class ImportLegacyUsers extends Command
]
);
}
if ($this->shouldRestoreTemporaryUsername($previousUsername) && $previousUsername !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $this->usernameRedirectKey($previousUsername)],
[
'new_username' => $username,
'user_id' => $legacyId,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
});
}
protected function resolveImportUsername(object $row, int $legacyId, ?string $existingUsername = null): string
{
$legacyUsername = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
if (! $this->option('restore-temp-usernames')) {
return $legacyUsername;
}
if ($existingUsername === null || $existingUsername === '') {
return $legacyUsername;
}
if (! $this->shouldRestoreTemporaryUsername($existingUsername)) {
return $existingUsername;
}
return UsernamePolicy::uniqueCandidate((string) ($row->uname ?: ('user' . $legacyId)), $legacyId);
}
protected function shouldRestoreTemporaryUsername(?string $username): bool
{
if (! is_string($username) || trim($username) === '') {
return false;
}
return preg_match('/^tmpu\d+$/i', trim($username)) === 1;
}
/**
* Ensure statistic values are safe for unsigned DB columns.
*/
@@ -265,6 +316,24 @@ class ImportLegacyUsers extends Command
return UsernamePolicy::sanitizeLegacy($username);
}
protected function usernameRedirectKey(?string $username): string
{
$value = $this->sanitizeUsername((string) ($username ?? ''));
return $value === 'user' && trim((string) ($username ?? '')) === '' ? '' : $value;
}
protected function normalizeLegacyGender(mixed $value): ?string
{
$normalized = strtoupper(trim((string) ($value ?? '')));
return match ($normalized) {
'M', 'MALE', 'MAN', 'BOY' => 'M',
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
default => null,
};
}
protected function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use Illuminate\Console\Command;
final class IndexArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-index
{--start-id=0 : Start from this artwork id (inclusive)}
{--after-id=0 : Resume after this artwork id}
{--batch=100 : Batch size per iteration}
{--limit=0 : Maximum artworks to process in this run}
{--public-only : Index only public, approved, published artworks}
{--dry-run : Preview requests without sending them}';
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $client->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
$startId = max(0, (int) $this->option('start-id'));
$afterId = max(0, (int) $this->option('after-id'));
$batch = max(1, min((int) $this->option('batch'), 1000));
$limit = max(0, (int) $this->option('limit'));
$publicOnly = (bool) $this->option('public-only');
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
$processed = 0;
$indexed = 0;
$skipped = 0;
$failed = 0;
$lastId = $afterId;
if ($startId > 0 && $afterId > 0) {
$this->warn(sprintf(
'Both --start-id=%d and --after-id=%d were provided. Using --start-id and ignoring --after-id.',
$startId,
$afterId
));
}
$this->info(sprintf(
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s public_only=%s dry_run=%s',
$startId,
$afterId,
$nextId,
$batch,
$limit > 0 ? (string) $limit : 'all',
$publicOnly ? 'yes' : 'no',
$dryRun ? 'yes' : 'no'
));
while (true) {
$remaining = $limit > 0 ? max(0, $limit - $processed) : $batch;
if ($limit > 0 && $remaining === 0) {
break;
}
$take = $limit > 0 ? min($batch, $remaining) : $batch;
$query = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->where('id', '>=', $nextId)
->whereNotNull('hash')
->orderBy('id')
->limit($take);
if ($publicOnly) {
$query->public()->published();
}
$artworks = $query->get();
if ($artworks->isEmpty()) {
$this->line('No more artworks matched the current query window.');
break;
}
$this->line(sprintf(
'Fetched batch: count=%d first_id=%d last_id=%d',
$artworks->count(),
(int) $artworks->first()->id,
(int) $artworks->last()->id
));
foreach ($artworks as $artwork) {
$processed++;
$lastId = (int) $artwork->id;
$nextId = $lastId + 1;
$url = $imageUrl->fromArtwork($artwork);
if ($url === null) {
$skipped++;
$this->warn("Skipped artwork {$artwork->id}: no vision image URL could be generated.");
continue;
}
$metadata = $this->metadataForArtwork($artwork);
$this->line(sprintf(
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
(int) $artwork->id,
(string) ($artwork->hash ?? ''),
(string) ($artwork->thumb_ext ?? ''),
$url,
$this->json($metadata)
));
if ($dryRun) {
$indexed++;
$this->line(sprintf(
'[dry] artwork=%d indexed=%d/%d',
(int) $artwork->id,
$indexed,
$processed
));
continue;
}
try {
$client->upsertByUrl($url, (int) $artwork->id, $metadata);
$indexed++;
$this->info(sprintf(
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
(int) $artwork->id,
$processed,
$indexed,
$skipped,
$failed
));
} catch (\Throwable $e) {
$failed++;
$this->warn("Failed artwork {$artwork->id}: {$e->getMessage()}");
}
}
}
$this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @param array<string, string> $payload
*/
private function json(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($json) ? $json : '{}';
}
/**
* @return array{content_type: string, category: string, user_id: string}
*/
private function metadataForArtwork(Artwork $artwork): array
{
$category = $this->primaryCategory($artwork);
return [
'content_type' => (string) ($category?->contentType?->name ?? ''),
'category' => (string) ($category?->name ?? ''),
'user_id' => (string) ($artwork->user_id ?? ''),
];
}
private function primaryCategory(Artwork $artwork): ?Category
{
/** @var Category|null $category */
$category = $artwork->categories->sortBy('sort_order')->first();
return $category;
}
}

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Carbon\Carbon;
class RepairLegacyWallzUsersCommand extends Command
{
protected $signature = 'skinbase:repair-legacy-wallz-users
{--chunk=500 : Number of legacy wallz rows to scan per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=wallz : Legacy table to update}
{--artworks-table=artworks : Current DB artworks table name}
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
{--dry-run : Preview matches and inserts without writing changes}';
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$legacyConnection = (string) $this->option('legacy-connection');
$legacyTable = (string) $this->option('legacy-table');
$artworksTable = (string) $this->option('artworks-table');
$fixArtworks = (bool) $this->option('fix-artworks');
$dryRun = (bool) $this->option('dry-run');
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
if ($fixArtworks) {
$this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun);
}
$total = (int) DB::connection($legacyConnection)
->table($legacyTable)
->where('user_id', 0)
->count();
if ($total === 0) {
if (! $fixArtworks) {
$this->info('No legacy wallz rows with user_id = 0 were found.');
}
return self::SUCCESS;
}
$this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}.");
$processed = 0;
$updatedRows = 0;
$matchedUsers = 0;
$createdUsers = 0;
$skippedRows = 0;
$usernameMap = [];
DB::connection($legacyConnection)
->table($legacyTable)
->select(['id', 'uname'])
->where('user_id', 0)
->orderBy('id')
->chunkById($chunk, function ($rows) use (
&$processed,
&$updatedRows,
&$matchedUsers,
&$createdUsers,
&$skippedRows,
&$usernameMap,
$dryRun,
$legacyConnection,
$legacyTable
) {
foreach ($rows as $row) {
$processed++;
$rawUsername = trim((string) ($row->uname ?? ''));
if ($rawUsername === '') {
$skippedRows++;
$this->warn("Skipping wallz id={$row->id}: uname is empty.");
continue;
}
$lookupKey = UsernamePolicy::normalize($rawUsername);
if ($lookupKey === '') {
$skippedRows++;
$this->warn("Skipping wallz id={$row->id}: uname normalizes to empty.");
continue;
}
if (! array_key_exists($lookupKey, $usernameMap)) {
$existingUser = $this->findUserByUsername($lookupKey);
if ($existingUser !== null) {
$usernameMap[$lookupKey] = [
'user_id' => (int) $existingUser->id,
'created' => false,
];
} else {
$usernameMap[$lookupKey] = [
'user_id' => $dryRun
? 0
: $this->createUserForLegacyUsername($rawUsername, $legacyConnection),
'created' => true,
];
}
}
$resolved = $usernameMap[$lookupKey];
if ($resolved['created']) {
$createdUsers++;
$usernameMap[$lookupKey]['created'] = false;
$resolved['created'] = false;
$this->line($dryRun
? "[dry] Would create user for uname='{$rawUsername}'"
: "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'");
} else {
$matchedUsers++;
}
if ($dryRun) {
$targetUser = $usernameMap[$lookupKey]['user_id'] > 0
? (string) $usernameMap[$lookupKey]['user_id']
: '<new-user-id>';
$this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'");
$updatedRows++;
continue;
}
$affected = DB::connection($legacyConnection)
->table($legacyTable)
->where('id', $row->id)
->where('user_id', 0)
->update([
'user_id' => $usernameMap[$lookupKey]['user_id'],
]);
if ($affected > 0) {
$updatedRows += $affected;
}
}
}, 'id');
$this->info(sprintf(
'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d',
$processed,
$updatedRows,
$matchedUsers,
$createdUsers,
$skippedRows
));
return self::SUCCESS;
}
private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void
{
$this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0");
$total = (int) DB::table($artworksTable)->where('user_id', 0)->count();
$this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}.");
$processed = 0;
$updated = 0;
DB::table($artworksTable)
->select(['id'])
->where('user_id', 0)
->orderBy('id')
->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) {
foreach ($rows as $row) {
$processed++;
$legacyUser = DB::connection($legacyConnection)
->table($legacyTable)
->where('id', $row->id)
->value('user_id');
$legacyUser = (int) ($legacyUser ?? 0);
if ($legacyUser <= 0) {
continue;
}
if ($dryRun) {
$this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}");
$updated++;
continue;
}
$affected = DB::table($artworksTable)
->where('id', $row->id)
->where('user_id', 0)
->update(['user_id' => $legacyUser]);
if ($affected > 0) {
$updated += $affected;
}
}
}, 'id');
$this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated));
}
private function legacyTableExists(string $connection, string $table): bool
{
try {
return DB::connection($connection)->getSchemaBuilder()->hasTable($table);
} catch (\Throwable) {
return false;
}
}
private function findUserByUsername(string $normalizedUsername): ?object
{
return DB::table('users')
->select(['id', 'username'])
->whereRaw('LOWER(username) = ?', [$normalizedUsername])
->first();
}
private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int
{
$username = UsernamePolicy::uniqueCandidate($legacyUsername);
$emailLocal = $this->sanitizeEmailLocal($username);
$email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org');
$now = now();
// Attempt to copy legacy joinDate from the legacy `users` table when available.
$legacyJoin = null;
try {
$legacyJoin = DB::connection($legacyConnection)
->table('users')
->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)])
->value('joinDate');
} catch (\Throwable) {
$legacyJoin = null;
}
$createdAt = $now;
if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) {
try {
$createdAt = Carbon::parse($legacyJoin);
} catch (\Throwable) {
$createdAt = $now;
}
}
$userId = (int) DB::table('users')->insertGetId([
'username' => $username,
'username_changed_at' => $now,
'name' => $legacyUsername,
'email' => $email,
'password' => Hash::make(Str::random(64)),
'is_active' => true,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'created_at' => $createdAt,
'updated_at' => $now,
]);
return $userId;
}
private function uniqueEmailCandidate(string $email): string
{
$candidate = strtolower(trim($email));
$suffix = 1;
while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) {
$parts = explode('@', $email, 2);
$local = $parts[0] ?? 'user';
$domain = $parts[1] ?? 'users.skinbase.org';
$candidate = $local . '+' . $suffix . '@' . $domain;
$suffix++;
}
return $candidate;
}
private function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairTemporaryUsernamesCommand extends Command
{
protected $signature = 'skinbase:repair-temp-usernames
{--chunk=500 : Number of users to process per batch}
{--dry-run : Preview username changes without writing them}';
protected $description = 'Replace current users.username values like tmpu% using the users.name field';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$total = (int) DB::table('users')
->where('username', 'like', 'tmpu%')
->count();
if ($total === 0) {
$this->info('No users with temporary tmpu% usernames were found.');
return self::SUCCESS;
}
$this->info("Found {$total} users with temporary tmpu% usernames.");
$processed = 0;
$updated = 0;
$skipped = 0;
DB::table('users')
->select(['id', 'name', 'username'])
->where('username', 'like', 'tmpu%')
->chunkById($chunk, function ($rows) use (&$processed, &$updated, &$skipped, $dryRun) {
foreach ($rows as $row) {
$processed++;
$sourceName = trim((string) ($row->name ?? ''));
if ($sourceName === '') {
$skipped++;
$this->warn("Skipping user id={$row->id}: name is empty.");
continue;
}
$candidate = $this->resolveCandidate($sourceName, (int) $row->id);
if ($candidate === null || strcasecmp($candidate, (string) $row->username) === 0) {
$skipped++;
$this->warn("Skipping user id={$row->id}: unable to resolve a better username from name='{$sourceName}'.");
continue;
}
if ($dryRun) {
$this->line("[dry] Would update user id={$row->id} username '{$row->username}' => '{$candidate}'");
$updated++;
continue;
}
$affected = DB::table('users')
->where('id', (int) $row->id)
->where('username', 'like', 'tmpu%')
->update([
'username' => $candidate,
'username_changed_at' => now(),
'updated_at' => now(),
]);
if ($affected > 0) {
$updated += $affected;
$this->line("[update] user id={$row->id} username '{$row->username}' => '{$candidate}'");
}
}
}, 'id');
$this->info(sprintf('Finished. processed=%d updated=%d skipped=%d', $processed, $updated, $skipped));
return self::SUCCESS;
}
private function resolveCandidate(string $sourceName, int $userId): ?string
{
$base = UsernamePolicy::sanitizeLegacy($sourceName);
$min = UsernamePolicy::min();
$max = UsernamePolicy::max();
if ($base === '') {
return null;
}
if (preg_match('/^tmpu\d+$/i', $base) === 1) {
$base = 'user' . $userId;
}
if (strlen($base) < $min) {
$base = substr($base . $userId, 0, $max);
}
if ($base === '' || $base === 'user') {
$base = 'user' . $userId;
}
$candidate = substr($base, 0, $max);
$suffix = 1;
while ($this->usernameExists($candidate, $userId) || UsernamePolicy::isReserved($candidate)) {
$suffixValue = (string) $suffix;
$prefixLen = max(1, $max - strlen($suffixValue));
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
$suffix++;
}
return $candidate;
}
private function usernameExists(string $username, int $ignoreUserId): bool
{
return DB::table('users')
->whereRaw('LOWER(username) = ?', [strtolower($username)])
->where('id', '!=', $ignoreUserId)
->exists();
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use Illuminate\Console\Command;
final class SearchArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-search
{artwork_id : Source artwork id}
{--limit=5 : Number of similar artworks to return}';
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
{
if (! $client->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
$artworkId = max(1, (int) $this->argument('artwork_id'));
$limit = max(1, min((int) $this->option('limit'), 100));
$artwork = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->find($artworkId);
if (! $artwork) {
$this->error("Artwork {$artworkId} was not found.");
return self::FAILURE;
}
$url = $imageUrl->fromArtwork($artwork);
if ($url === null) {
$this->error("Artwork {$artworkId} does not have a usable CDN image URL.");
return self::FAILURE;
}
try {
$matches = $client->searchByUrl($url, $limit + 1);
} catch (\Throwable $e) {
$this->error('Vector search failed: ' . $e->getMessage());
return self::FAILURE;
}
$ids = collect($matches)
->map(fn (array $match): int => (int) $match['id'])
->filter(fn (int $id): bool => $id > 0 && $id !== $artworkId)
->unique()
->take($limit)
->values()
->all();
if ($ids === []) {
$this->warn('No similar artworks were returned by the vector gateway.');
return self::SUCCESS;
}
$artworks = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->whereIn('id', $ids)
->public()
->published()
->get()
->keyBy('id');
$rows = [];
foreach ($matches as $match) {
$matchId = (int) ($match['id'] ?? 0);
if ($matchId <= 0 || $matchId === $artworkId) {
continue;
}
/** @var Artwork|null $matchedArtwork */
$matchedArtwork = $artworks->get($matchId);
if (! $matchedArtwork) {
continue;
}
$category = $this->primaryCategory($matchedArtwork);
$rows[] = [
'id' => $matchId,
'score' => number_format((float) ($match['score'] ?? 0.0), 4, '.', ''),
'title' => (string) $matchedArtwork->title,
'content_type' => (string) ($category?->contentType?->name ?? ''),
'category' => (string) ($category?->name ?? ''),
];
if (count($rows) >= $limit) {
break;
}
}
if ($rows === []) {
$this->warn('The vector gateway returned matches, but none resolved to public published artworks.');
return self::SUCCESS;
}
$this->table(['ID', 'Score', 'Title', 'Content Type', 'Category'], $rows);
return self::SUCCESS;
}
private function primaryCategory(Artwork $artwork): ?Category
{
/** @var Category|null $category */
$category = $artwork->categories->sortBy('sort_order')->first();
return $category;
}
}

View File

@@ -7,6 +7,8 @@ use App\Console\Commands\ImportLegacyUsers;
use App\Console\Commands\ImportCategories;
use App\Console\Commands\MigrateFeaturedWorks;
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
use App\Console\Commands\IndexArtworkVectorsCommand;
use App\Console\Commands\SearchArtworkVectorsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
@@ -43,6 +45,8 @@ class Kernel extends ConsoleKernel
CleanupUploadsCommand::class,
PublishScheduledArtworksCommand::class,
BackfillArtworkEmbeddingsCommand::class,
IndexArtworkVectorsCommand::class,
SearchArtworkVectorsCommand::class,
AggregateSimilarArtworkAnalyticsCommand::class,
AggregateFeedAnalyticsCommand::class,
AggregateTagInteractionAnalyticsCommand::class,