Save workspace changes
This commit is contained in:
256
app/Console/Commands/AiBiographyProvidersCommand.php
Normal file
256
app/Console/Commands/AiBiographyProvidersCommand.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
final class AiBiographyProvidersCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:providers
|
||||
{--provider= : Inspect only one provider (together|vision_gateway|vision|gemini|home)}
|
||||
{--limit=10 : Maximum number of models to display per provider}';
|
||||
|
||||
protected $description = 'Check AI biography provider health and list available models';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$provider = $this->resolveProviderOption();
|
||||
|
||||
if ($provider === false) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$providers = $provider !== null
|
||||
? [$provider]
|
||||
: ['together', 'vision_gateway', 'gemini', 'home'];
|
||||
|
||||
$inspections = array_map(fn (string $name): array => $this->inspectProvider($name, $limit), $providers);
|
||||
|
||||
$this->table(
|
||||
['Provider', 'Status', 'Configured model', 'Models endpoint'],
|
||||
array_map(fn (array $inspection): array => [
|
||||
$inspection['provider'],
|
||||
$inspection['status'],
|
||||
$inspection['configured_model'] ?: 'n/a',
|
||||
$inspection['models_endpoint'] ?: 'n/a',
|
||||
], $inspections)
|
||||
);
|
||||
|
||||
foreach ($inspections as $inspection) {
|
||||
$this->line('');
|
||||
$this->comment(strtoupper($inspection['provider']));
|
||||
$this->line(' Base URL : ' . ($inspection['base_url'] ?: 'n/a'));
|
||||
$this->line(' Configured : ' . ($inspection['configured'] ? 'yes' : 'no'));
|
||||
$this->line(' Status : ' . $inspection['status']);
|
||||
|
||||
if ($inspection['error'] !== null) {
|
||||
$this->line(' Error : ' . $inspection['error']);
|
||||
}
|
||||
|
||||
if ($inspection['models'] === []) {
|
||||
$this->line(' Models : none reported');
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(' Models');
|
||||
foreach ($inspection['models'] as $model) {
|
||||
$this->line(' - ' . $model);
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function inspectProvider(string $provider, int $limit): array
|
||||
{
|
||||
$baseUrl = $this->providerBaseUrl($provider);
|
||||
$configuredModel = $this->configuredModel($provider);
|
||||
$modelsEndpoint = $this->modelsEndpoint($provider, $baseUrl);
|
||||
|
||||
if (! $this->isConfigured($provider, $baseUrl, $configuredModel)) {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'status' => 'not_configured',
|
||||
'configured' => false,
|
||||
'configured_model' => $configuredModel,
|
||||
'base_url' => $baseUrl,
|
||||
'models_endpoint' => $modelsEndpoint,
|
||||
'models' => [],
|
||||
'error' => 'Required configuration is missing.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->buildRequest($provider)->get($modelsEndpoint);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'status' => 'offline',
|
||||
'configured' => true,
|
||||
'configured_model' => $configuredModel,
|
||||
'base_url' => $baseUrl,
|
||||
'models_endpoint' => $modelsEndpoint,
|
||||
'models' => [],
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'status' => 'http_' . $response->status(),
|
||||
'configured' => true,
|
||||
'configured_model' => $configuredModel,
|
||||
'base_url' => $baseUrl,
|
||||
'models_endpoint' => $modelsEndpoint,
|
||||
'models' => [],
|
||||
'error' => mb_substr(trim($response->body()), 0, 300),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'status' => 'online',
|
||||
'configured' => true,
|
||||
'configured_model' => $configuredModel,
|
||||
'base_url' => $baseUrl,
|
||||
'models_endpoint' => $modelsEndpoint,
|
||||
'models' => array_slice($this->extractModels($provider, $response), 0, $limit),
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveProviderOption(): string|false|null
|
||||
{
|
||||
$rawProvider = $this->option('provider');
|
||||
|
||||
if ($rawProvider === null || trim((string) $rawProvider) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower(trim((string) $rawProvider))) {
|
||||
'vision_gateway', 'vision', 'local' => 'vision_gateway',
|
||||
'gemini' => 'gemini',
|
||||
'home', 'lmstudio', 'lm_studio' => 'home',
|
||||
'together', 'together_ai' => 'together',
|
||||
default => $this->invalidProvider((string) $rawProvider),
|
||||
};
|
||||
}
|
||||
|
||||
private function invalidProvider(string $provider): false
|
||||
{
|
||||
$this->error("Invalid provider [{$provider}]. Supported values: together, vision_gateway, vision, gemini, home.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isConfigured(string $provider, string $baseUrl, string $configuredModel): bool
|
||||
{
|
||||
return match ($provider) {
|
||||
'vision_gateway' => $baseUrl !== '' && trim((string) config('vision.gateway.api_key', '')) !== '',
|
||||
'gemini' => $baseUrl !== '' && trim((string) config('ai_biography.gemini.api_key', '')) !== '' && $configuredModel !== '',
|
||||
'home' => $baseUrl !== '' && $configuredModel !== '',
|
||||
'together' => trim((string) config('ai_biography.together.api_key', '')) !== '' && $configuredModel !== '',
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function configuredModel(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'vision_gateway' => trim((string) config('ai_biography.llm_model', 'vision-gateway')),
|
||||
'gemini' => trim((string) config('ai_biography.gemini.model', 'gemini-flash-latest')),
|
||||
'home' => trim((string) config('ai_biography.home.model', 'qwen/qwen3.5-9b')),
|
||||
'together' => trim((string) config('ai_biography.together.model', 'google/gemma-3n-E4B-it')),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function providerBaseUrl(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'vision_gateway' => rtrim((string) config('vision.gateway.base_url', ''), '/'),
|
||||
'gemini' => rtrim((string) config('ai_biography.gemini.base_url', 'https://generativelanguage.googleapis.com'), '/'),
|
||||
'home' => rtrim((string) config('ai_biography.home.base_url', 'http://home.klevze.si:8200'), '/'),
|
||||
'together' => rtrim((string) config('ai_biography.together.base_url', 'https://api.together.xyz'), '/'),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function modelsEndpoint(string $provider, string $baseUrl): string
|
||||
{
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return match ($provider) {
|
||||
'gemini' => $baseUrl . '/v1beta/models',
|
||||
default => $baseUrl . '/v1/models',
|
||||
};
|
||||
}
|
||||
|
||||
private function buildRequest(string $provider): PendingRequest
|
||||
{
|
||||
$request = Http::acceptJson()->contentType('application/json');
|
||||
|
||||
return match ($provider) {
|
||||
'vision_gateway' => $request
|
||||
->withHeaders(['X-API-Key' => (string) config('vision.gateway.api_key', '')])
|
||||
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30))),
|
||||
'gemini' => $request
|
||||
->withHeaders(['X-goog-api-key' => (string) config('ai_biography.gemini.api_key', '')])
|
||||
->connectTimeout(max(1, (int) config('vision.gateway.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.llm_timeout_seconds', 30))),
|
||||
'together' => $request
|
||||
->withToken((string) config('ai_biography.together.api_key', ''))
|
||||
->connectTimeout(max(1, (int) config('ai_biography.together.connect_timeout_seconds', 5)))
|
||||
->timeout(max(5, (int) config('ai_biography.together.timeout_seconds', config('ai_biography.llm_timeout_seconds', 90)))),
|
||||
'home' => $this->buildHomeRequest($request),
|
||||
default => $request,
|
||||
};
|
||||
}
|
||||
|
||||
private function buildHomeRequest(PendingRequest $request): PendingRequest
|
||||
{
|
||||
$request = $request
|
||||
->connectTimeout(max(1, (int) config('ai_biography.home.connect_timeout_seconds', 3)))
|
||||
->timeout(max(5, (int) config('ai_biography.home.timeout_seconds', config('ai_biography.llm_timeout_seconds', 30))));
|
||||
|
||||
if (! (bool) config('ai_biography.home.verify_ssl', true)) {
|
||||
$request = $request->withoutVerifying();
|
||||
}
|
||||
|
||||
$apiKey = trim((string) config('ai_biography.home.api_key', ''));
|
||||
|
||||
return $apiKey !== '' ? $request->withToken($apiKey) : $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractModels(string $provider, Response $response): array
|
||||
{
|
||||
$json = $response->json();
|
||||
|
||||
if ($provider === 'gemini') {
|
||||
return collect((array) ($json['models'] ?? []))
|
||||
->map(fn ($model) => is_array($model) ? (string) ($model['name'] ?? $model['displayName'] ?? '') : '')
|
||||
->filter(fn (string $name): bool => $name !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return collect((array) ($json['data'] ?? []))
|
||||
->map(fn ($model) => is_array($model) ? (string) ($model['id'] ?? '') : '')
|
||||
->filter(fn (string $name): bool => $name !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
271
app/Console/Commands/AuditLegacyArtworkUserIdsCommand.php
Normal file
271
app/Console/Commands/AuditLegacyArtworkUserIdsCommand.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AuditLegacyArtworkUserIdsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:audit-legacy-user-ids
|
||||
{--chunk=1000 : Number of legacy artwork rows to process per batch}
|
||||
{--show=100 : Maximum number of discrepancies to print}
|
||||
{--artwork-id= : Only compare one artwork id}
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-table=wallz : Legacy artworks table name}
|
||||
{--new-table=artworks : Current artworks table name}
|
||||
{--json : Output the summary and discrepancies as JSON}';
|
||||
|
||||
protected $description = 'Compare legacy wallz.user_id values against artworks.user_id using shared artwork ids';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
$show = max(0, (int) $this->option('show'));
|
||||
$legacyConnection = (string) $this->option('legacy-connection');
|
||||
$legacyTable = (string) $this->option('legacy-table');
|
||||
$newTable = (string) $this->option('new-table');
|
||||
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
|
||||
$json = (bool) $this->option('json');
|
||||
|
||||
if ($artworkId !== null && $artworkId <= 0) {
|
||||
$this->error('The --artwork-id option must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->tableExists($legacyConnection, $legacyTable)) {
|
||||
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->tableExists(null, $newTable)) {
|
||||
$this->error("Current table {$newTable} does not exist.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->columnExists($legacyConnection, $legacyTable, 'user_id') || ! $this->columnExists($legacyConnection, $legacyTable, 'id')) {
|
||||
$this->error("Legacy table {$legacyConnection}.{$legacyTable} must contain id and user_id columns.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->columnExists(null, $newTable, 'user_id') || ! $this->columnExists(null, $newTable, 'id')) {
|
||||
$this->error("Current table {$newTable} must contain id and user_id columns.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$legacyCountQuery = DB::connection($legacyConnection)->table($legacyTable);
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$legacyCountQuery->where('id', $artworkId);
|
||||
}
|
||||
|
||||
$total = (int) $legacyCountQuery->count();
|
||||
|
||||
if ($total === 0) {
|
||||
$message = $artworkId === null
|
||||
? "No rows found in {$legacyConnection}.{$legacyTable}."
|
||||
: "Legacy artwork #{$artworkId} was not found in {$legacyConnection}.{$legacyTable}.";
|
||||
|
||||
$this->warn($message);
|
||||
|
||||
return $artworkId === null ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Comparing %d legacy %s.%s row(s) against %s in chunks of %d...',
|
||||
$total,
|
||||
$legacyConnection,
|
||||
$legacyTable,
|
||||
$newTable,
|
||||
$chunkSize,
|
||||
));
|
||||
|
||||
$summary = [
|
||||
'checked' => 0,
|
||||
'matched' => 0,
|
||||
'mismatched' => 0,
|
||||
'missing_in_new' => 0,
|
||||
];
|
||||
$discrepancies = [];
|
||||
|
||||
$legacyQuery = DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->select(['id', 'user_id'])
|
||||
->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$legacyQuery->where('id', $artworkId);
|
||||
}
|
||||
|
||||
$legacyQuery->chunkById($chunkSize, function ($rows) use (&$summary, &$discrepancies, $show, $newTable): void {
|
||||
$artworkIds = $rows->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
|
||||
|
||||
$newRows = DB::table($newTable)
|
||||
->select(['id', 'user_id', 'title'])
|
||||
->whereIn('id', $artworkIds)
|
||||
->get()
|
||||
->keyBy(static fn (object $row): int => (int) $row->id);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$summary['checked']++;
|
||||
|
||||
$legacyUserId = $this->normalizeNullableInt($row->user_id ?? null);
|
||||
$currentRow = $newRows->get((int) $row->id);
|
||||
|
||||
if ($currentRow === null) {
|
||||
$summary['missing_in_new']++;
|
||||
$this->rememberDiscrepancy(
|
||||
$discrepancies,
|
||||
$show,
|
||||
(int) $row->id,
|
||||
$legacyUserId,
|
||||
null,
|
||||
null,
|
||||
'missing_in_new',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$newUserId = $this->normalizeNullableInt($currentRow->user_id ?? null);
|
||||
|
||||
if ($legacyUserId === $newUserId) {
|
||||
$summary['matched']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['mismatched']++;
|
||||
$this->rememberDiscrepancy(
|
||||
$discrepancies,
|
||||
$show,
|
||||
(int) $row->id,
|
||||
$legacyUserId,
|
||||
$newUserId,
|
||||
(string) ($currentRow->title ?? ''),
|
||||
'user_id_mismatch',
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line(sprintf(
|
||||
' audited=%d matched=%d mismatched=%d missing_in_new=%d',
|
||||
$summary['checked'],
|
||||
$summary['matched'],
|
||||
$summary['mismatched'],
|
||||
$summary['missing_in_new'],
|
||||
));
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
if ($json) {
|
||||
$this->line(json_encode([
|
||||
'summary' => $summary,
|
||||
'discrepancies' => $discrepancies,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return ($summary['mismatched'] === 0 && $summary['missing_in_new'] === 0)
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Checked', 'Matched', 'Mismatched', 'Missing In New'],
|
||||
[[
|
||||
$summary['checked'],
|
||||
$summary['matched'],
|
||||
$summary['mismatched'],
|
||||
$summary['missing_in_new'],
|
||||
]],
|
||||
);
|
||||
|
||||
if ($discrepancies !== []) {
|
||||
$this->newLine();
|
||||
$this->warn(sprintf(
|
||||
'Showing %d discrepancy row(s)%s.',
|
||||
count($discrepancies),
|
||||
($summary['mismatched'] + $summary['missing_in_new']) > count($discrepancies)
|
||||
? sprintf(' out of %d total', $summary['mismatched'] + $summary['missing_in_new'])
|
||||
: '',
|
||||
));
|
||||
|
||||
$this->table(
|
||||
['Artwork ID', 'Legacy user_id', 'New user_id', 'Status', 'Title'],
|
||||
array_map(static fn (array $row): array => [
|
||||
$row['artwork_id'],
|
||||
$row['legacy_user_id'],
|
||||
$row['new_user_id'],
|
||||
$row['status'],
|
||||
$row['title'],
|
||||
], $discrepancies),
|
||||
);
|
||||
} else {
|
||||
$this->info('No user_id mismatches were found.');
|
||||
}
|
||||
|
||||
return ($summary['mismatched'] === 0 && $summary['missing_in_new'] === 0)
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
|
||||
private function tableExists(?string $connection, string $table): bool
|
||||
{
|
||||
try {
|
||||
return $connection === null
|
||||
? DB::getSchemaBuilder()->hasTable($table)
|
||||
: DB::connection($connection)->getSchemaBuilder()->hasTable($table);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function columnExists(?string $connection, string $table, string $column): bool
|
||||
{
|
||||
try {
|
||||
return $connection === null
|
||||
? DB::getSchemaBuilder()->hasColumn($table, $column)
|
||||
: DB::connection($connection)->getSchemaBuilder()->hasColumn($table, $column);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeNullableInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{artwork_id:int, legacy_user_id:string, new_user_id:string, title:string, status:string}> $discrepancies
|
||||
*/
|
||||
private function rememberDiscrepancy(
|
||||
array &$discrepancies,
|
||||
int $show,
|
||||
int $artworkId,
|
||||
?int $legacyUserId,
|
||||
?int $newUserId,
|
||||
?string $title,
|
||||
string $status,
|
||||
): void {
|
||||
if (count($discrepancies) >= $show) {
|
||||
return;
|
||||
}
|
||||
|
||||
$discrepancies[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'legacy_user_id' => $legacyUserId === null ? '[null]' : (string) $legacyUserId,
|
||||
'new_user_id' => $newUserId === null ? '[missing]' : (string) $newUserId,
|
||||
'title' => $title ?? '',
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
}
|
||||
416
app/Console/Commands/AuditMissingMigratedUsersCommand.php
Normal file
416
app/Console/Commands/AuditMissingMigratedUsersCommand.php
Normal file
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AuditMissingMigratedUsersCommand extends Command
|
||||
{
|
||||
protected $signature = 'users:audit-missing-migrated
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-users-table=users : Legacy users table name}
|
||||
{--new-connection= : New database connection name (defaults to the app default connection)}
|
||||
{--new-users-table=users : New users table name}
|
||||
{--sql-output= : Optional path for a transaction-wrapped SQL file with INSERTs for missing users}
|
||||
{--chunk=500 : Number of legacy users to process per chunk}';
|
||||
|
||||
protected $description = 'List legacy users flagged with should_migrate=1 that do not exist in the new users table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$legacyConnection = trim((string) $this->option('legacy-connection')) ?: 'legacy';
|
||||
$legacyUsersTable = trim((string) $this->option('legacy-users-table')) ?: 'users';
|
||||
$newConnection = trim((string) $this->option('new-connection')) ?: (string) config('database.default');
|
||||
$newUsersTable = trim((string) $this->option('new-users-table')) ?: 'users';
|
||||
$sqlOutputPath = trim((string) $this->option('sql-output')) ?: null;
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
|
||||
try {
|
||||
DB::connection($legacyConnection)->getPdo();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error("Cannot connect to legacy database connection [{$legacyConnection}]: " . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::connection($newConnection)->getPdo();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error("Cannot connect to new database connection [{$newConnection}]: " . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::connection($legacyConnection)->getSchemaBuilder()->hasTable($legacyUsersTable)) {
|
||||
$this->error("Legacy users table [{$legacyConnection}.{$legacyUsersTable}] does not exist.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::connection($newConnection)->getSchemaBuilder()->hasTable($newUsersTable)) {
|
||||
$this->error("New users table [{$newConnection}.{$newUsersTable}] does not exist.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'Scanning %s.%s for should_migrate=1 and checking %s.%s...',
|
||||
$legacyConnection,
|
||||
$legacyUsersTable,
|
||||
$newConnection,
|
||||
$newUsersTable,
|
||||
));
|
||||
|
||||
$legacySelectColumns = $this->legacySelectColumns($legacyConnection, $legacyUsersTable);
|
||||
$scanned = 0;
|
||||
$existing = 0;
|
||||
$missing = 0;
|
||||
$sqlInsertCount = 0;
|
||||
$sqlContext = $sqlOutputPath !== null
|
||||
? $this->startSqlExport($sqlOutputPath, $legacyConnection, $legacyUsersTable, $newUsersTable)
|
||||
: null;
|
||||
|
||||
DB::connection($legacyConnection)
|
||||
->table($legacyUsersTable)
|
||||
->select($legacySelectColumns)
|
||||
->where('should_migrate', 1)
|
||||
->orderBy('user_id')
|
||||
->chunkById($chunkSize, function ($rows) use (&$scanned, &$existing, &$missing, &$sqlInsertCount, &$sqlContext, $newConnection, $newUsersTable): void {
|
||||
$legacyIds = $rows->pluck('user_id')
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->filter(static fn (int $value): bool => $value > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$existingIds = DB::connection($newConnection)
|
||||
->table($newUsersTable)
|
||||
->whereIn('id', $legacyIds)
|
||||
->pluck('id')
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->flip();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$legacyId = (int) ($row->user_id ?? 0);
|
||||
if ($legacyId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scanned++;
|
||||
|
||||
if ($existingIds->has($legacyId)) {
|
||||
$existing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing++;
|
||||
|
||||
$username = trim((string) ($row->uname ?? ''));
|
||||
$email = trim((string) ($row->email ?? ''));
|
||||
|
||||
$this->line(sprintf(
|
||||
'[missing] id=%d uname=%s email=%s',
|
||||
$legacyId,
|
||||
$username !== '' ? '@' . $username : '(none)',
|
||||
$email !== '' ? '<' . $email . '>' : '(none)',
|
||||
));
|
||||
|
||||
if ($sqlContext !== null) {
|
||||
$statement = $this->buildMissingUserInsertStatement($row, $legacyId, $newConnection, $newUsersTable, $sqlContext);
|
||||
$this->appendSqlExport($sqlContext['path'], $statement . PHP_EOL);
|
||||
$sqlInsertCount++;
|
||||
}
|
||||
}
|
||||
}, 'user_id', 'user_id');
|
||||
|
||||
if ($sqlContext !== null) {
|
||||
$this->finishSqlExport($sqlContext['path'], $sqlInsertCount);
|
||||
$this->info(sprintf('SQL export written to %s with %d INSERT statement(s).', $sqlContext['path'], $sqlInsertCount));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Done. scanned=%d existing=%d missing=%d',
|
||||
$scanned,
|
||||
$existing,
|
||||
$missing,
|
||||
));
|
||||
|
||||
return $missing > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function legacySelectColumns(string $legacyConnection, string $legacyUsersTable): array
|
||||
{
|
||||
$available = [];
|
||||
foreach (DB::connection($legacyConnection)->getSchemaBuilder()->getColumnListing($legacyUsersTable) as $column) {
|
||||
$available[strtolower((string) $column)] = (string) $column;
|
||||
}
|
||||
|
||||
$select = [];
|
||||
foreach (['user_id', 'uname', 'email', 'real_name', 'joinDate', 'LastVisit', 'active'] as $wanted) {
|
||||
$key = strtolower($wanted);
|
||||
if (isset($available[$key])) {
|
||||
$select[] = $available[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $select;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path:string, generated_at:Carbon, reserved_usernames: array<string, true>, reserved_emails: array<string, true>}
|
||||
*/
|
||||
private function startSqlExport(string $sqlOutputPath, string $legacyConnection, string $legacyUsersTable, string $newUsersTable): array
|
||||
{
|
||||
$directory = dirname($sqlOutputPath);
|
||||
if ($directory !== '' && $directory !== '.' && ! is_dir($directory) && ! @mkdir($directory, 0777, true) && ! is_dir($directory)) {
|
||||
throw new \RuntimeException(sprintf('Could not create SQL output directory [%s].', $directory));
|
||||
}
|
||||
|
||||
$generatedAt = now();
|
||||
|
||||
$header = [
|
||||
'-- Generated by users:audit-missing-migrated',
|
||||
sprintf('-- Generated at: %s', $generatedAt->toIso8601String()),
|
||||
sprintf('-- Legacy source: %s.%s', $legacyConnection, $legacyUsersTable),
|
||||
sprintf('-- Target table: %s', $newUsersTable),
|
||||
'START TRANSACTION;',
|
||||
'',
|
||||
];
|
||||
|
||||
$this->appendSqlExport($sqlOutputPath, implode(PHP_EOL, $header));
|
||||
|
||||
return [
|
||||
'path' => $sqlOutputPath,
|
||||
'generated_at' => $generatedAt,
|
||||
'reserved_usernames' => [],
|
||||
'reserved_emails' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function finishSqlExport(string $sqlOutputPath, int $insertCount): void
|
||||
{
|
||||
$footer = [
|
||||
'',
|
||||
sprintf('-- Total INSERT statements: %d', $insertCount),
|
||||
'COMMIT;',
|
||||
'',
|
||||
];
|
||||
|
||||
$this->appendSqlExport($sqlOutputPath, implode(PHP_EOL, $footer));
|
||||
}
|
||||
|
||||
private function appendSqlExport(string $sqlOutputPath, string $content): void
|
||||
{
|
||||
$result = @file_put_contents($sqlOutputPath, $content, FILE_APPEND);
|
||||
|
||||
if ($result === false) {
|
||||
throw new \RuntimeException(sprintf('Could not write SQL output file [%s].', $sqlOutputPath));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{path:string, generated_at:Carbon, reserved_usernames: array<string, true>, reserved_emails: array<string, true>} $sqlContext
|
||||
*/
|
||||
private function buildMissingUserInsertStatement(object $legacyUser, int $legacyId, string $newConnection, string $newUsersTable, array &$sqlContext): string
|
||||
{
|
||||
$generatedAt = $sqlContext['generated_at'];
|
||||
$username = $this->resolveSqlImportUsername($legacyUser, $legacyId, $newConnection, $newUsersTable, $sqlContext['reserved_usernames']);
|
||||
$email = $this->resolveSqlImportEmail($legacyUser, $legacyId, $newConnection, $newUsersTable, $sqlContext['reserved_emails']);
|
||||
$name = trim((string) ($this->legacyField($legacyUser, 'real_name') ?: $username));
|
||||
$createdAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'joinDate')) ?? $generatedAt->copy();
|
||||
$lastVisitAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'LastVisit'));
|
||||
$isActive = (int) ($this->legacyField($legacyUser, 'active') ?? 1) === 1 ? 1 : 0;
|
||||
$passwordHash = Hash::make(Str::random(64));
|
||||
|
||||
$sqlContext['reserved_usernames'][strtolower($username)] = true;
|
||||
$sqlContext['reserved_emails'][strtolower($email)] = true;
|
||||
|
||||
$columns = [
|
||||
'id',
|
||||
'username',
|
||||
'username_changed_at',
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'is_active',
|
||||
'needs_password_reset',
|
||||
'role',
|
||||
'legacy_password_algo',
|
||||
'last_visit_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
$values = [
|
||||
$legacyId,
|
||||
$username,
|
||||
$generatedAt->format('Y-m-d H:i:s'),
|
||||
$name,
|
||||
$email,
|
||||
$passwordHash,
|
||||
$isActive,
|
||||
1,
|
||||
'user',
|
||||
null,
|
||||
$lastVisitAt?->format('Y-m-d H:i:s'),
|
||||
$createdAt->format('Y-m-d H:i:s'),
|
||||
$generatedAt->format('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
return sprintf(
|
||||
'INSERT INTO %s (%s) VALUES (%s);',
|
||||
$this->quoteIdentifier($newUsersTable),
|
||||
implode(', ', array_map(fn (string $column): string => $this->quoteIdentifier($column), $columns)),
|
||||
implode(', ', array_map(fn (mixed $value): string => $this->sqlLiteral($value), $values)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $reservedUsernames
|
||||
*/
|
||||
private function resolveSqlImportUsername(object $legacyUser, int $legacyId, string $newConnection, string $newUsersTable, array $reservedUsernames): string
|
||||
{
|
||||
$rawUsername = (string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId));
|
||||
$candidate = UsernamePolicy::sanitizeLegacy($rawUsername);
|
||||
|
||||
if (! $this->sqlUsernameExists($candidate, $legacyId, $newConnection, $newUsersTable, $reservedUsernames)) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$base = 'tmpu' . $legacyId;
|
||||
$candidate = $base;
|
||||
$suffix = 1;
|
||||
|
||||
while ($this->sqlUsernameExists($candidate, $legacyId, $newConnection, $newUsersTable, $reservedUsernames)) {
|
||||
$suffixStr = (string) $suffix;
|
||||
$candidate = substr($base, 0, max(1, UsernamePolicy::max() - strlen($suffixStr))) . $suffixStr;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $reservedUsernames
|
||||
*/
|
||||
private function sqlUsernameExists(string $username, int $legacyId, string $newConnection, string $newUsersTable, array $reservedUsernames): bool
|
||||
{
|
||||
$normalized = strtolower(trim($username));
|
||||
if ($normalized === '' || isset($reservedUsernames[$normalized])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return DB::connection($newConnection)
|
||||
->table($newUsersTable)
|
||||
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||
->where('id', '!=', $legacyId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $reservedEmails
|
||||
*/
|
||||
private function resolveSqlImportEmail(object $legacyUser, int $legacyId, string $newConnection, string $newUsersTable, array $reservedEmails): string
|
||||
{
|
||||
$rawEmail = strtolower(trim((string) ($this->legacyField($legacyUser, 'email') ?? '')));
|
||||
$seed = $rawEmail !== ''
|
||||
? $rawEmail
|
||||
: ($this->sanitizeEmailLocal((string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId))) . '@users.skinbase.org');
|
||||
|
||||
$candidate = $seed;
|
||||
$suffix = 1;
|
||||
|
||||
while ($this->sqlEmailExists($candidate, $legacyId, $newConnection, $newUsersTable, $reservedEmails)) {
|
||||
[$local, $domain] = array_pad(explode('@', $seed, 2), 2, 'users.skinbase.org');
|
||||
$candidate = $this->sanitizeEmailLocal($local) . '+' . $suffix . '@' . $domain;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $reservedEmails
|
||||
*/
|
||||
private function sqlEmailExists(string $email, int $legacyId, string $newConnection, string $newUsersTable, array $reservedEmails): bool
|
||||
{
|
||||
$normalized = strtolower(trim($email));
|
||||
if ($normalized === '' || isset($reservedEmails[$normalized])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return DB::connection($newConnection)
|
||||
->table($newUsersTable)
|
||||
->whereRaw('LOWER(email) = ?', [$normalized])
|
||||
->where('id', '!=', $legacyId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim(Str::ascii($value)));
|
||||
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
||||
|
||||
return trim($local, '.-') ?: 'user';
|
||||
}
|
||||
|
||||
private function legacyField(object $legacyUser, string $field): mixed
|
||||
{
|
||||
if (property_exists($legacyUser, $field)) {
|
||||
return $legacyUser->{$field};
|
||||
}
|
||||
|
||||
foreach ((array) $legacyUser as $key => $value) {
|
||||
if (strcasecmp((string) $key, $field) === 0) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parseLegacyDate(mixed $value): ?Carbon
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '' || str_starts_with($value, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
return implode('.', array_map(static fn (string $segment): string => '`' . str_replace('`', '``', $segment) . '`', explode('.', $identifier)));
|
||||
}
|
||||
|
||||
private function sqlLiteral(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return "'" . str_replace("'", "''", (string) $value) . "'";
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ class ConfigureMeilisearchIndex extends Command
|
||||
'has_missing_thumbnails',
|
||||
'category',
|
||||
'content_type',
|
||||
'published_as_type',
|
||||
'tags',
|
||||
'author_id',
|
||||
'orientation',
|
||||
@@ -69,6 +70,9 @@ class ConfigureMeilisearchIndex extends Command
|
||||
{
|
||||
$prefix = config('scout.prefix', '');
|
||||
$indexName = $prefix . (string) $this->option('index');
|
||||
$indexSettings = $this->configuredIndexSettings($indexName);
|
||||
$sortableAttributes = $this->configuredSortableAttributes($indexSettings);
|
||||
$filterableAttributes = $this->configuredFilterableAttributes($indexSettings);
|
||||
|
||||
/** @var MeilisearchClient $client */
|
||||
$client = app(MeilisearchClient::class);
|
||||
@@ -79,12 +83,12 @@ class ConfigureMeilisearchIndex extends Command
|
||||
|
||||
// ── Sortable attributes ───────────────────────────────────────────────
|
||||
$this->line(' → Updating sortableAttributes…');
|
||||
$task = $index->updateSortableAttributes(self::SORTABLE_ATTRIBUTES);
|
||||
$task = $index->updateSortableAttributes($sortableAttributes);
|
||||
$this->line(" Task uid: {$task['taskUid']}");
|
||||
|
||||
// ── Filterable attributes ─────────────────────────────────────────────
|
||||
$this->line(' → Updating filterableAttributes…');
|
||||
$task2 = $index->updateFilterableAttributes(self::FILTERABLE_ATTRIBUTES);
|
||||
$task2 = $index->updateFilterableAttributes($filterableAttributes);
|
||||
$this->line(" Task uid: {$task2['taskUid']}");
|
||||
|
||||
$this->info('Done. Meilisearch will process these tasks asynchronously.');
|
||||
@@ -92,4 +96,46 @@ class ConfigureMeilisearchIndex extends Command
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function configuredIndexSettings(string $indexName): array
|
||||
{
|
||||
$settings = config('scout.meilisearch.index-settings', []);
|
||||
|
||||
if (! is_array($settings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$configured = $settings[$indexName] ?? [];
|
||||
|
||||
return is_array($configured) ? $configured : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $indexSettings
|
||||
* @return list<string>
|
||||
*/
|
||||
private function configuredSortableAttributes(array $indexSettings): array
|
||||
{
|
||||
$configured = $indexSettings['sortableAttributes'] ?? null;
|
||||
|
||||
return is_array($configured) && $configured !== []
|
||||
? array_values($configured)
|
||||
: self::SORTABLE_ATTRIBUTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $indexSettings
|
||||
* @return list<string>
|
||||
*/
|
||||
private function configuredFilterableAttributes(array $indexSettings): array
|
||||
{
|
||||
$configured = $indexSettings['filterableAttributes'] ?? null;
|
||||
|
||||
return is_array($configured) && $configured !== []
|
||||
? array_values($configured)
|
||||
: self::FILTERABLE_ATTRIBUTES;
|
||||
}
|
||||
}
|
||||
|
||||
86
app/Console/Commands/ExportLegacyPasswordsCommand.php
Normal file
86
app/Console/Commands/ExportLegacyPasswordsCommand.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExportLegacyPasswordsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:export-legacy-passwords
|
||||
{--sql= : Path to write SQL update script (optional)}
|
||||
{--chunk=500 : Chunk size for processing}';
|
||||
|
||||
protected $description = 'Export legacy password hashes from legacy DB into a SQL file to update the new users table.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$sqlPath = $this->option('sql') ?? base_path('scripts/legacy-passwords-export.sql');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Legacy DB connection is not available: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$now = now()->format('Y-m-d H:i:s');
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '-- Legacy password export';
|
||||
$lines[] = '-- Generated: ' . $now;
|
||||
$lines[] = '-- Source: legacy DB (read-only)';
|
||||
$lines[] = '';
|
||||
$lines[] = 'SET NAMES utf8mb4;';
|
||||
$lines[] = 'USE `' . DB::getDatabaseName() . '`;';
|
||||
$lines[] = 'START TRANSACTION;';
|
||||
$lines[] = '';
|
||||
|
||||
$exported = 0;
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->select(['user_id', 'password2', 'password'])
|
||||
->orderBy('user_id')
|
||||
->chunk($chunk, function ($rows) use (&$lines, &$exported, $now) {
|
||||
foreach ($rows as $r) {
|
||||
$id = (int) ($r->user_id ?? 0);
|
||||
$hash = trim((string) ($r->password2 ?: $r->password ?: ''));
|
||||
if ($id === 0 || $hash === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$algo = 'unknown';
|
||||
if (preg_match('/^\$2[aby]\$/', $hash)) {
|
||||
$algo = 'bcrypt';
|
||||
} elseif (preg_match('/^\$argon2/', $hash)) {
|
||||
$algo = 'argon2';
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $hash);
|
||||
|
||||
$lines[] = "-- user_id={$id} legacy_algo={$algo}";
|
||||
$lines[] = "SAVEPOINT sp_{$id};";
|
||||
$lines[] = "UPDATE `users` SET `password` = '{$escaped}', `legacy_password_algo` = '{$algo}', `needs_password_reset` = 1, `updated_at` = '{$now}' WHERE `id` = {$id};";
|
||||
$lines[] = '';
|
||||
|
||||
$exported++;
|
||||
}
|
||||
});
|
||||
|
||||
$lines[] = 'COMMIT;';
|
||||
$lines[] = '';
|
||||
$lines[] = "-- Exported: {$exported} user(s)";
|
||||
|
||||
$sql = implode("\n", $lines) . "\n";
|
||||
|
||||
if (file_put_contents($sqlPath, $sql) === false) {
|
||||
$this->error('Could not write SQL file: ' . $sqlPath);
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Wrote ' . $exported . ' rows to: ' . $sqlPath);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
126
app/Console/Commands/FlagLegacyUsersForMigrationCommand.php
Normal file
126
app/Console/Commands/FlagLegacyUsersForMigrationCommand.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FlagLegacyUsersForMigrationCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:flag-legacy-users
|
||||
{--dry-run : Show what would be updated without writing to DB}
|
||||
{--chunk=500 : Chunk size for processing}';
|
||||
|
||||
protected $description = 'Set should_migrate=1 on legacy users that have any activity across 14 tables (artworks_comments, artworks_downloads, blog_articles, chat, favourites, featured_works, forum_posts, forum_topics, friends_list, news, news_comment, users_comments, users_opinions, wallz)';
|
||||
|
||||
/**
|
||||
* Activity tables to check — all confirmed to have a `user_id` column.
|
||||
*/
|
||||
private const ACTIVITY_TABLES = [
|
||||
'artworks_comments',
|
||||
'artworks_downloads',
|
||||
'blog_articles',
|
||||
'chat',
|
||||
'favourites',
|
||||
'featured_works',
|
||||
'forum_posts',
|
||||
'forum_topics',
|
||||
'friends_list',
|
||||
'news',
|
||||
'news_comment',
|
||||
'users_comments',
|
||||
'users_opinions',
|
||||
'wallz',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable) {
|
||||
$this->error('Legacy DB connection is not available.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$chunkSize = (int) $this->option('chunk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No changes will be written to the database.');
|
||||
}
|
||||
|
||||
$this->info('Scanning legacy users for activity…');
|
||||
$this->newLine();
|
||||
|
||||
$totalFlagged = 0;
|
||||
$totalSkipped = 0;
|
||||
$processed = 0;
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->orderBy('user_id')
|
||||
->chunk($chunkSize, function ($users) use ($dryRun, &$totalFlagged, &$totalSkipped, &$processed) {
|
||||
$ids = $users->pluck('user_id')->map(fn ($v) => (int) $v)->all();
|
||||
|
||||
// Collect all user_ids that have at least one row in any activity table.
|
||||
$activeIds = collect();
|
||||
|
||||
foreach (self::ACTIVITY_TABLES as $table) {
|
||||
$found = DB::connection('legacy')
|
||||
->table($table)
|
||||
->whereIn('user_id', $ids)
|
||||
->distinct()
|
||||
->pluck('user_id')
|
||||
->map(fn ($v) => (int) $v);
|
||||
|
||||
$activeIds = $activeIds->merge($found);
|
||||
}
|
||||
|
||||
$activeIds = $activeIds->unique()->values();
|
||||
|
||||
// Report per-user
|
||||
foreach ($users as $u) {
|
||||
$id = (int) $u->user_id;
|
||||
$isActive = $activeIds->contains($id);
|
||||
|
||||
if ($isActive) {
|
||||
$this->line(sprintf(
|
||||
' <fg=green>ACTIVE</> id=%-8d uname=<fg=yellow>%s</>',
|
||||
$id,
|
||||
$u->uname ?? '(no username)',
|
||||
));
|
||||
$totalFlagged++;
|
||||
} else {
|
||||
$totalSkipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk update in one query
|
||||
if (! $dryRun && $activeIds->isNotEmpty()) {
|
||||
DB::connection('legacy')
|
||||
->table('users')
|
||||
->whereIn('user_id', $activeIds->all())
|
||||
->update(['should_migrate' => 1]);
|
||||
}
|
||||
|
||||
$processed += count($ids);
|
||||
$this->line(sprintf(
|
||||
' … processed <fg=cyan>%d</> users so far (flagged: <fg=green>%d</>, skipped: %d)',
|
||||
$processed, $totalFlagged, $totalSkipped,
|
||||
));
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Done. %d user(s) flagged as should_migrate=1 / %d user(s) left at 0.',
|
||||
$totalFlagged,
|
||||
$totalSkipped,
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] Nothing was written to the database.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
429
app/Console/Commands/GenerateAiBiographyCommand.php
Normal file
429
app/Console/Commands/GenerateAiBiographyCommand.php
Normal file
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\GenerateAiBiographyJob;
|
||||
use App\Models\CreatorAiBiography;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyInputBuilder;
|
||||
use App\Services\AiBiography\AiBiographyPromptBuilder;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Generate AI biographies for one creator or refresh stale ones.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan ai-biography:generate {user_id}
|
||||
* php artisan ai-biography:generate
|
||||
* php artisan ai-biography:generate --stale
|
||||
* php artisan ai-biography:generate --stale --limit=50 --queue
|
||||
*/
|
||||
final class GenerateAiBiographyCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:generate
|
||||
{user_id? : The ID of a single creator to generate a biography for}
|
||||
{--stale : Refresh all biographies whose source hash has changed}
|
||||
{--all : Alias for generating only missing biographies ordered by latest public upload}
|
||||
{--provider= : Override the configured LLM provider for this run (vision_gateway|vision|gemini|home)}
|
||||
{--prompt : Output the initial prompt payload that would be sent for each processed creator}
|
||||
{--result : Output the generated biography text to the console after a successful inline run}
|
||||
{--skip-existing : Skip creators who already have an active AI biography}
|
||||
{--limit=100 : Maximum number of creators to process in batch mode}
|
||||
{--chunk=50 : Query chunk size for batch operations}
|
||||
{--force : Overwrite user-edited biographies}
|
||||
{--queue : Dispatch jobs to the queue instead of running inline}
|
||||
{--dry-run : List candidates without generating}';
|
||||
|
||||
protected $description = 'Generate missing AI biographies or refresh stale ones';
|
||||
|
||||
public function handle(
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
): int
|
||||
{
|
||||
if (! config('ai_biography.enabled', true)) {
|
||||
$this->warn('AI Biography is disabled (AI_BIOGRAPHY_ENABLED=false).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$provider = $this->resolveProviderOverride();
|
||||
|
||||
if ($provider === false) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (is_string($provider)) {
|
||||
config(['ai_biography.provider_override' => $provider]);
|
||||
config(['ai_biography.provider' => $provider]);
|
||||
$this->line("Using AI biography provider override: {$provider}");
|
||||
}
|
||||
|
||||
$userId = $this->argument('user_id');
|
||||
$stale = (bool) $this->option('stale');
|
||||
$all = (bool) $this->option('all');
|
||||
$prompt = (bool) $this->option('prompt');
|
||||
$result = (bool) $this->option('result');
|
||||
$skipExisting = (bool) $this->option('skip-existing');
|
||||
$force = (bool) $this->option('force');
|
||||
$queue = (bool) $this->option('queue');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
|
||||
if ($userId !== null) {
|
||||
return $this->handleSingle((int) $userId, $biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $provider ?: null, $prompt, $result, $skipExisting);
|
||||
}
|
||||
|
||||
if ($stale) {
|
||||
return $this->handleStale($biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $limit, $chunk, $provider ?: null, $prompt, $result, $skipExisting);
|
||||
}
|
||||
|
||||
if ($all) {
|
||||
$this->line('`--all` is now an alias for the default missing-only batch mode.');
|
||||
}
|
||||
|
||||
return $this->handleMissing($biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $limit, $provider ?: null, $prompt, $result, $skipExisting);
|
||||
}
|
||||
|
||||
private function resolveProviderOverride(): string|false|null
|
||||
{
|
||||
$rawProvider = $this->option('provider');
|
||||
|
||||
if ($rawProvider === null || trim((string) $rawProvider) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = strtolower(trim((string) $rawProvider));
|
||||
|
||||
return match ($provider) {
|
||||
'vision_gateway', 'vision', 'local' => 'vision_gateway',
|
||||
'gemini' => 'gemini',
|
||||
'home', 'lmstudio', 'lm_studio' => 'home',
|
||||
default => $this->invalidProvider($provider),
|
||||
};
|
||||
}
|
||||
|
||||
private function invalidProvider(string $provider): false
|
||||
{
|
||||
$this->error("Invalid provider [{$provider}]. Supported values: vision_gateway, vision, gemini, home.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function handleSingle(
|
||||
int $userId,
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
bool $force,
|
||||
bool $queue,
|
||||
bool $dryRun,
|
||||
?string $provider,
|
||||
bool $showPrompt,
|
||||
bool $showResult,
|
||||
bool $skipExisting,
|
||||
): int {
|
||||
$user = User::query()->where('id', $userId)->where('is_active', true)->whereNull('deleted_at')->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error("User #{$userId} not found or inactive.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line("Processing user #{$userId} ({$user->username})");
|
||||
|
||||
if ($skipExisting && $this->hasActiveBiography($userId)) {
|
||||
$this->info(' ↷ skipped_existing_active');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($showPrompt) {
|
||||
$this->outputPromptPreview($user, $inputBuilder, $promptBuilder);
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('[dry-run] Would generate biography.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
if ($showResult) {
|
||||
$this->warn('[--result] is only available for inline runs. The job was queued, so no biography text is available yet.');
|
||||
}
|
||||
|
||||
GenerateAiBiographyJob::dispatch($userId, $force, $provider)->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
$this->info("Queued biography generation for #{$userId}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$result = $biographies->regenerate($user, $force);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->info(" ✓ {$result['action']}");
|
||||
if ($showResult) {
|
||||
$this->outputGeneratedBiography($userId);
|
||||
}
|
||||
} else {
|
||||
$this->warn(" ✗ {$result['action']}: " . implode(', ', $result['errors']));
|
||||
}
|
||||
|
||||
return $result['success'] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
private function handleStale(
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
bool $force,
|
||||
bool $queue,
|
||||
bool $dryRun,
|
||||
int $limit,
|
||||
int $chunk,
|
||||
?string $provider,
|
||||
bool $showPrompt,
|
||||
bool $showResult,
|
||||
bool $skipExisting,
|
||||
): int {
|
||||
if ($skipExisting) {
|
||||
$this->warn('`--skip-existing` is ignored with `--stale` because stale refresh only applies to creators who already have an active AI biography.');
|
||||
}
|
||||
|
||||
$this->info("Scanning for stale AI biographies (limit={$limit})...");
|
||||
|
||||
$processed = 0;
|
||||
$queued = 0;
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
User::query()
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereExists(fn ($q) => $q->from('creator_ai_biographies')->whereColumn('creator_ai_biographies.user_id', 'users.id')->where('creator_ai_biographies.is_active', true))
|
||||
->chunkById($chunk, function ($users) use ($biographies, $inputBuilder, $promptBuilder, $force, $queue, $dryRun, $limit, $provider, $showPrompt, $showResult, &$processed, &$queued, &$generated, &$skipped): bool {
|
||||
foreach ($users as $user) {
|
||||
if ($processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $biographies->isStale($user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$this->line(" [{$user->id}] {$user->username} – stale");
|
||||
|
||||
if ($showPrompt) {
|
||||
$this->outputPromptPreview($user, $inputBuilder, $promptBuilder, ' ');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
if ($showResult) {
|
||||
$this->warn(" [--result] is only available for inline runs. User #{$user->id} was queued, so no biography text is available yet.");
|
||||
}
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, $force, $provider)->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
$queued++;
|
||||
} else {
|
||||
$result = $biographies->regenerate($user, $force);
|
||||
if ($result['success']) {
|
||||
$generated++;
|
||||
|
||||
if ($showResult) {
|
||||
$this->outputGeneratedBiography((int) $user->id, ' ');
|
||||
}
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info("Done. processed={$processed} queued={$queued} generated={$generated} skipped/dry={$skipped}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleMissing(
|
||||
AiBiographyService $biographies,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
bool $force,
|
||||
bool $queue,
|
||||
bool $dryRun,
|
||||
int $limit,
|
||||
?string $provider,
|
||||
bool $showPrompt,
|
||||
bool $showResult,
|
||||
bool $skipExisting,
|
||||
): int {
|
||||
$this->info("Generating missing AI biographies ordered by latest public upload (limit={$limit})...");
|
||||
|
||||
$processed = 0;
|
||||
$queued = 0;
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$latestUploads = DB::table('artworks')
|
||||
->selectRaw('user_id, MAX(published_at) as latest_uploaded_at')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('user_id');
|
||||
|
||||
$users = User::query()
|
||||
->leftJoinSub($latestUploads, 'latest_public_artwork', function ($join): void {
|
||||
$join->on('latest_public_artwork.user_id', '=', 'users.id');
|
||||
})
|
||||
->select('users.*')
|
||||
->where('users.is_active', true)
|
||||
->whereNull('users.deleted_at')
|
||||
->whereNotExists(fn ($q) => $q->from('creator_ai_biographies')->whereColumn('creator_ai_biographies.user_id', 'users.id')->where('creator_ai_biographies.is_active', true))
|
||||
->orderByDesc('latest_public_artwork.latest_uploaded_at')
|
||||
->orderByDesc('users.id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$processed++;
|
||||
$this->line(" [{$user->id}] {$user->username}");
|
||||
|
||||
if ($showPrompt) {
|
||||
$this->outputPromptPreview($user, $inputBuilder, $promptBuilder, ' ');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($queue) {
|
||||
if ($showResult) {
|
||||
$this->warn(" [--result] is only available for inline runs. User #{$user->id} was queued, so no biography text is available yet.");
|
||||
}
|
||||
|
||||
GenerateAiBiographyJob::dispatch((int) $user->id, $force, $provider)->onQueue((string) config('ai_biography.queue', 'default'));
|
||||
$queued++;
|
||||
} else {
|
||||
$result = $biographies->generate($user);
|
||||
if ($result['success']) {
|
||||
$generated++;
|
||||
|
||||
if ($showResult) {
|
||||
$this->outputGeneratedBiography((int) $user->id, ' ');
|
||||
}
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Done. processed={$processed} queued={$queued} generated={$generated} skipped/dry={$skipped}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function outputPromptPreview(
|
||||
User $user,
|
||||
AiBiographyInputBuilder $inputBuilder,
|
||||
AiBiographyPromptBuilder $promptBuilder,
|
||||
string $indent = ' ',
|
||||
): void {
|
||||
$input = $inputBuilder->build($user);
|
||||
$qualityTier = $inputBuilder->qualityTier($input);
|
||||
$meetsThreshold = $inputBuilder->meetsMinimumThreshold($input);
|
||||
|
||||
$this->line($indent . 'Prompt preview');
|
||||
$this->line($indent . ' Provider : ' . $this->resolvedProvider());
|
||||
$this->line($indent . ' Quality tier : ' . $qualityTier);
|
||||
$this->line($indent . ' Meets threshold: ' . ($meetsThreshold ? 'yes' : 'no'));
|
||||
|
||||
if (! $meetsThreshold) {
|
||||
$this->line($indent . ' No prompt will be sent because this profile is below the minimum generation threshold.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = $promptBuilder->build($input, strict: false, sparse: $qualityTier === 'sparse');
|
||||
$systemPrompt = (string) ($payload['messages'][0]['content'] ?? '');
|
||||
$userPrompt = (string) ($payload['messages'][1]['content'] ?? '');
|
||||
|
||||
$this->line($indent . ' Prompt version : ' . (string) ($payload['prompt_version'] ?? AiBiographyPromptBuilder::PROMPT_VERSION));
|
||||
$this->line($indent . ' Max tokens : ' . (string) ($payload['max_tokens'] ?? 'n/a'));
|
||||
$this->line($indent . ' Temperature : ' . (string) ($payload['temperature'] ?? 'n/a'));
|
||||
$this->line($indent . ' System prompt:');
|
||||
$this->writeIndentedBlock($systemPrompt, $indent . ' ');
|
||||
$this->line($indent . ' User prompt:');
|
||||
$this->writeIndentedBlock($userPrompt, $indent . ' ');
|
||||
}
|
||||
|
||||
private function writeIndentedBlock(string $text, string $indent): void
|
||||
{
|
||||
foreach (preg_split("/\r\n|\r|\n/", trim($text)) ?: [] as $line) {
|
||||
$this->line($indent . $line);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvedProvider(): string
|
||||
{
|
||||
$override = trim(strtolower((string) config('ai_biography.provider_override', '')));
|
||||
|
||||
if (in_array($override, ['together', 'vision_gateway', 'gemini', 'home'], true)) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
if (trim((string) config('ai_biography.together.api_key', '')) !== ''
|
||||
&& trim((string) config('ai_biography.together.model', '')) !== '') {
|
||||
return 'together';
|
||||
}
|
||||
|
||||
$provider = trim(strtolower((string) config('ai_biography.provider', 'together')));
|
||||
|
||||
return in_array($provider, ['together', 'vision_gateway', 'gemini', 'home'], true) ? $provider : 'together';
|
||||
}
|
||||
|
||||
private function outputGeneratedBiography(int $userId, string $indent = ' '): void
|
||||
{
|
||||
$record = CreatorAiBiography::query()
|
||||
->where('user_id', $userId)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$text = trim((string) ($record?->text ?? ''));
|
||||
|
||||
if ($text === '') {
|
||||
$this->line($indent . 'Generated biography text: n/a');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->line($indent . 'Generated biography text:');
|
||||
foreach (preg_split('/\r\n|\r|\n/', $text) ?: [] as $line) {
|
||||
$this->line($indent . ' ' . $line);
|
||||
}
|
||||
}
|
||||
|
||||
private function hasActiveBiography(int $userId): bool
|
||||
{
|
||||
return CreatorAiBiography::query()
|
||||
->where('user_id', $userId)
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
192
app/Console/Commands/GenerateArtworkAiSuggestionsCommand.php
Normal file
192
app/Console/Commands/GenerateArtworkAiSuggestionsCommand.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAiAssist;
|
||||
use App\Services\Studio\StudioAiAssistService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class GenerateArtworkAiSuggestionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:ai-suggest
|
||||
{artwork_id? : Generate suggestions for a single artwork}
|
||||
{--after-id=0 : Skip artworks with ID <= this value}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=50 : Database chunk size}
|
||||
{--provider= : Override tag suggestion provider (lm_studio|together)}
|
||||
{--force : Regenerate even when suggestions already exist}
|
||||
{--queue : Queue generation instead of running inline}
|
||||
{--skip-existing : Skip artworks that already have stored tag suggestions}';
|
||||
|
||||
protected $description = 'Generate and store studio AI suggestions for artworks, including 10-15 suggested tags from the md thumbnail';
|
||||
|
||||
public function handle(StudioAiAssistService $aiAssist): int
|
||||
{
|
||||
$artworkId = $this->argument('artwork_id');
|
||||
$afterId = max(0, (int) $this->option('after-id'));
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunk = max(1, min(200, (int) $this->option('chunk')));
|
||||
$provider = $this->normalizeProviderOption($this->option('provider'));
|
||||
$force = (bool) $this->option('force');
|
||||
$queue = (bool) $this->option('queue');
|
||||
$skipExisting = (bool) $this->option('skip-existing');
|
||||
|
||||
if ($provider === null && $this->option('provider') !== null) {
|
||||
$this->error('Invalid provider. Supported values: lm_studio, together.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query = Artwork::query()
|
||||
->with('artworkAiAssist')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('hash')
|
||||
->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->where('id', (int) $artworkId);
|
||||
} else {
|
||||
$query->where('id', '>', $afterId);
|
||||
}
|
||||
|
||||
$query->chunkById($chunk, function ($artworks) use (&$processed, &$generated, &$skipped, &$failed, $limit, $skipExisting, $force, $queue, $provider, $aiAssist) {
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
|
||||
$assist = $artwork->artworkAiAssist;
|
||||
$hasStoredTags = $assist instanceof ArtworkAiAssist
|
||||
&& is_array($assist->tag_suggestions_json)
|
||||
&& $assist->tag_suggestions_json !== [];
|
||||
|
||||
if ($skipExisting && $hasStoredTags && ! $force) {
|
||||
$this->line("[#{$artwork->id}] skip existing suggestions");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line("[#{$artwork->id}] {$artwork->title}");
|
||||
|
||||
try {
|
||||
if ($queue) {
|
||||
$result = $aiAssist->queueAnalysis($artwork, $force, 'tags', $provider);
|
||||
$this->line(" queued ({$result->status})");
|
||||
} else {
|
||||
$result = $aiAssist->analyze($artwork, $force, 'tags', $provider);
|
||||
$this->line(" {$result->status}");
|
||||
$this->renderInlineResult($result);
|
||||
}
|
||||
|
||||
if ($result->status === ArtworkAiAssist::STATUS_FAILED) {
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$generated++;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error(' failed: ' . $exception->getMessage());
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Done. processed={$processed} generated={$generated} skipped={$skipped} failed={$failed}");
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalizeProviderOption(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower(trim((string) $value))) {
|
||||
'lm_studio', 'lm-studio', 'local', 'home' => 'lm_studio',
|
||||
'together', 'together_ai' => 'together',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function renderInlineResult(ArtworkAiAssist $assist): void
|
||||
{
|
||||
if ($assist->status === ArtworkAiAssist::STATUS_FAILED) {
|
||||
if (is_string($assist->error_message) && $assist->error_message !== '') {
|
||||
$this->error(' error: ' . $assist->error_message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($assist->status !== ArtworkAiAssist::STATUS_READY) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = is_array($assist->raw_response_json) ? (array) ($assist->raw_response_json['request'] ?? []) : [];
|
||||
$tagGeneration = is_array($assist->raw_response_json) ? (array) ($assist->raw_response_json['tag_generation'] ?? []) : [];
|
||||
$categorySuggestions = is_array($assist->category_suggestions_json) ? $assist->category_suggestions_json : [];
|
||||
|
||||
$provider = $tagGeneration['provider'] ?? $request['provider'] ?? config('vision.tag_suggestions.provider');
|
||||
if (is_string($provider) && $provider !== '') {
|
||||
$this->line(' provider: ' . $provider);
|
||||
}
|
||||
|
||||
$titles = collect((array) $assist->title_suggestions_json)
|
||||
->pluck('text')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->take(3)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($titles !== []) {
|
||||
$this->line(' titles: ' . implode(' | ', $titles));
|
||||
}
|
||||
|
||||
$tags = collect((array) $assist->tag_suggestions_json)
|
||||
->pluck('tag')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->take(12)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($tags !== []) {
|
||||
$this->line(' tags: ' . implode(', ', $tags));
|
||||
}
|
||||
|
||||
$contentType = $categorySuggestions['content_type']['value'] ?? null;
|
||||
$category = $categorySuggestions['category']['value'] ?? null;
|
||||
|
||||
if (is_string($contentType) && $contentType !== '') {
|
||||
$line = ' content type: ' . $contentType;
|
||||
if (is_string($category) && $category !== '') {
|
||||
$line .= ' | category: ' . $category;
|
||||
}
|
||||
$this->line($line);
|
||||
} elseif (is_string($category) && $category !== '') {
|
||||
$this->line(' category: ' . $category);
|
||||
}
|
||||
|
||||
$description = collect((array) $assist->description_suggestions_json)
|
||||
->pluck('text')
|
||||
->first(fn (mixed $value): bool => is_string($value) && trim($value) !== '');
|
||||
|
||||
if (is_string($description) && $description !== '') {
|
||||
$this->line(' description: ' . Str::limit($description, 140, '...'));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
25
app/Console/Commands/HealthSchedulerTickCommand.php
Normal file
25
app/Console/Commands/HealthSchedulerTickCommand.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Throwable;
|
||||
|
||||
class HealthSchedulerTickCommand extends Command
|
||||
{
|
||||
protected $signature = 'health:tick';
|
||||
protected $description = 'Write a scheduler heartbeat timestamp to Redis for health:check --only=scheduler.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
Redis::setex('health:scheduler_last_tick', 300, time());
|
||||
} catch (Throwable $e) {
|
||||
// Non-fatal — never block the scheduler.
|
||||
$this->warn('health:tick could not write to Redis: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
97
app/Console/Commands/InspectAiBiographyCommand.php
Normal file
97
app/Console/Commands/InspectAiBiographyCommand.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Inspect the full AI biography record and input payload for a creator.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan ai-biography:inspect {user_id}
|
||||
*/
|
||||
final class InspectAiBiographyCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:inspect
|
||||
{user_id : The ID of the creator to inspect}';
|
||||
|
||||
protected $description = 'Inspect AI biography record and normalized input payload for a creator';
|
||||
|
||||
public function handle(AiBiographyService $biographies): int
|
||||
{
|
||||
$userId = (int) $this->argument('user_id');
|
||||
|
||||
$user = User::query()
|
||||
->where('id', $userId)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error("User #{$userId} not found or inactive.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$data = $biographies->adminInspect($user);
|
||||
|
||||
$this->line('');
|
||||
$this->info("=== AI Biography Inspect — User #{$userId} ({$user->username}) ===");
|
||||
$this->line('');
|
||||
|
||||
// ── Input quality ────────────────────────────────────────────────────
|
||||
$this->comment('── Input Quality ─────────────────────────');
|
||||
$this->line(' Quality tier : ' . ($data['quality_tier'] ?? 'N/A'));
|
||||
$this->line(' Meets threshold : ' . ($data['meets_threshold'] ? 'yes' : 'no'));
|
||||
$this->line(' Source hash live : ' . mb_substr((string) ($data['source_hash_live'] ?? ''), 0, 16) . '...');
|
||||
$this->line(' Is stale : ' . ($data['is_stale'] ? 'yes' : 'no'));
|
||||
$this->line('');
|
||||
|
||||
// ── Stored record ────────────────────────────────────────────────────
|
||||
$record = $data['record'];
|
||||
$this->comment('── Stored Biography Record ───────────────');
|
||||
if ($record === null) {
|
||||
$this->line(' No active biography stored.');
|
||||
} else {
|
||||
$this->line(' ID : ' . ($record['id'] ?? 'N/A'));
|
||||
$this->line(' Status : ' . ($record['status'] ?? 'N/A'));
|
||||
$this->line(' Prompt version : ' . ($record['prompt_version'] ?? 'N/A'));
|
||||
$this->line(' Input tier : ' . ($record['input_quality_tier'] ?? 'N/A'));
|
||||
$this->line(' Generation reason: ' . ($record['generation_reason'] ?? 'N/A'));
|
||||
$this->line(' Model : ' . ($record['model'] ?? 'N/A'));
|
||||
$this->line(' Is active : ' . ($record['is_active'] ? 'yes' : 'no'));
|
||||
$this->line(' Is hidden : ' . ($record['is_hidden'] ? 'yes' : 'no'));
|
||||
$this->line(' Is user-edited : ' . ($record['is_user_edited'] ? 'yes' : 'no'));
|
||||
$this->line(' Needs review : ' . ($record['needs_review'] ? 'YES' : 'no'));
|
||||
$this->line(' Source hash : ' . mb_substr((string) ($record['source_hash'] ?? ''), 0, 16) . '...');
|
||||
$this->line(' Generated at : ' . ($record['generated_at'] ?? 'N/A'));
|
||||
$this->line(' Last attempted : ' . ($record['last_attempted_at'] ?? 'N/A'));
|
||||
$this->line(' Last error code : ' . ($record['last_error_code'] ?? 'none'));
|
||||
$this->line(' Last error reason: ' . ($record['last_error_reason'] ?? 'none'));
|
||||
$this->line('');
|
||||
$this->comment('── Biography Text ────────────────────────');
|
||||
$this->line(' ' . wordwrap((string) ($record['text'] ?? '(empty)'), 100, "\n "));
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
// ── Input payload ─────────────────────────────────────────────────────
|
||||
if ($this->option('verbose') || $this->output->isVerbose()) {
|
||||
$this->comment('── Normalized Input Payload ──────────────');
|
||||
$payload = $data['input_payload'] ?? [];
|
||||
foreach ($payload as $key => $value) {
|
||||
$display = is_array($value) ? json_encode($value) : (string) $value;
|
||||
$this->line(sprintf(' %-26s: %s', $key, $display));
|
||||
}
|
||||
$this->line('');
|
||||
} else {
|
||||
$this->line(' Tip: run with -v to see the full normalized input payload.');
|
||||
$this->line('');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -35,6 +32,12 @@ class PublishScheduledArtworksCommand extends Command
|
||||
|
||||
protected $description = 'Publish scheduled artworks whose publish_at datetime has passed.';
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkPublicationService $publicationService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
@@ -42,13 +45,8 @@ class PublishScheduledArtworksCommand extends Command
|
||||
|
||||
$now = now()->utc();
|
||||
|
||||
$candidates = Artwork::query()
|
||||
->where('artwork_status', 'scheduled')
|
||||
->where('publish_at', '<=', $now)
|
||||
->where('is_approved', true)
|
||||
->orderBy('publish_at')
|
||||
->limit($limit)
|
||||
->get(['id', 'user_id', 'title', 'publish_at', 'artwork_status']);
|
||||
$result = $this->publicationService->publishDueScheduled($limit, $now);
|
||||
$candidates = $result['candidates'];
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->line('No scheduled artworks due for publishing.');
|
||||
@@ -67,50 +65,12 @@ class PublishScheduledArtworksCommand extends Command
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($candidate, $now, &$published) {
|
||||
// Re-fetch with lock to avoid double-publish in concurrent runs
|
||||
$artwork = Artwork::query()
|
||||
->lockForUpdate()
|
||||
->where('id', $candidate->id)
|
||||
->where('artwork_status', 'scheduled')
|
||||
->first();
|
||||
|
||||
if (! $artwork) {
|
||||
// Already published or status changed – skip
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork->is_public = $artwork->visibility !== Artwork::VISIBILITY_PRIVATE;
|
||||
$artwork->published_at = $now;
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->save();
|
||||
|
||||
// Trigger Meilisearch reindex via Scout (if searchable trait present)
|
||||
if (method_exists($artwork, 'searchable')) {
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// Record activity event
|
||||
try {
|
||||
ActivityEvent::record(
|
||||
actorId: (int) $artwork->user_id,
|
||||
type: ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logUpload((int) $artwork->user_id, (int) $artwork->id);
|
||||
} catch (\Throwable) {}
|
||||
$artwork = $this->publicationService->publishIfDue($candidate, $now);
|
||||
|
||||
if ($artwork->artwork_status === 'published') {
|
||||
$published++;
|
||||
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
|
||||
});
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errors++;
|
||||
Log::error("PublishScheduledArtworksCommand: failed to publish artwork #{$candidate->id}: {$e->getMessage()}");
|
||||
|
||||
96
app/Console/Commands/ReviewQueueAiBiographyCommand.php
Normal file
96
app/Console/Commands/ReviewQueueAiBiographyCommand.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CreatorAiBiography;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* List AI biographies that are candidates for admin review.
|
||||
*
|
||||
* Review queue candidates include:
|
||||
* – Biographies with needs_review = true (new AI draft available for user-edited bio)
|
||||
* – Biographies with status = 'needs_review'
|
||||
* – Biographies with recent generation failures (last_error_code set)
|
||||
* – Biographies with input_quality_tier = 'sparse' that still generated
|
||||
*
|
||||
* Usage:
|
||||
* php artisan ai-biography:review-queue
|
||||
* php artisan ai-biography:review-queue --tier=sparse
|
||||
* php artisan ai-biography:review-queue --failed
|
||||
* php artisan ai-biography:review-queue --limit=50
|
||||
*/
|
||||
final class ReviewQueueAiBiographyCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:review-queue
|
||||
{--tier= : Filter by input_quality_tier (rich|medium|sparse)}
|
||||
{--failed : Show only records with a last_error_code}
|
||||
{--needs-review : Show only records flagged needs_review=true}
|
||||
{--limit=100 : Maximum records to display}';
|
||||
|
||||
protected $description = 'List AI biography records that are candidates for review or manual inspection';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
$query = CreatorAiBiography::query()
|
||||
->with('user:id,username,email')
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($this->option('failed')) {
|
||||
$query->whereNotNull('last_error_code');
|
||||
} elseif ($this->option('needs-review')) {
|
||||
$query->where('needs_review', true);
|
||||
} else {
|
||||
// Default: union of all review-worthy states.
|
||||
$query->where(fn ($q) => $q
|
||||
->where('needs_review', true)
|
||||
->orWhere('status', CreatorAiBiography::STATUS_NEEDS_REVIEW)
|
||||
->orWhereNotNull('last_error_code')
|
||||
->orWhere('input_quality_tier', CreatorAiBiography::TIER_SPARSE)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->option('tier')) {
|
||||
$query->where('input_quality_tier', $this->option('tier'));
|
||||
}
|
||||
|
||||
$records = $query->get();
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
$this->info('No review-queue candidates found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info("=== AI Biography Review Queue ({$records->count()} records) ===");
|
||||
$this->line('');
|
||||
|
||||
$rows = $records->map(fn ($r) => [
|
||||
$r->id,
|
||||
$r->user?->username ?? "user#{$r->user_id}",
|
||||
$r->status,
|
||||
$r->input_quality_tier ?? '-',
|
||||
$r->is_user_edited ? 'edited' : '-',
|
||||
$r->needs_review ? 'YES' : '-',
|
||||
$r->last_error_code ?? '-',
|
||||
$r->updated_at?->diffForHumans() ?? '-',
|
||||
])->all();
|
||||
|
||||
$this->table(
|
||||
['ID', 'Username', 'Status', 'Tier', 'User-Edited', 'NeedsReview', 'LastError', 'Updated'],
|
||||
$rows,
|
||||
);
|
||||
|
||||
$this->line('');
|
||||
$this->line('Tip: run `php artisan ai-biography:inspect {user_id}` for full details on any record.');
|
||||
$this->line(' run `php artisan ai-biography:generate {user_id} --force` to regenerate.');
|
||||
$this->line('');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
138
app/Console/Commands/ValidateAiBiographyCommand.php
Normal file
138
app/Console/Commands/ValidateAiBiographyCommand.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CreatorAiBiography;
|
||||
use App\Models\User;
|
||||
use App\Services\AiBiography\AiBiographyValidator;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Validate stored AI biographies against the current validator rules.
|
||||
*
|
||||
* Useful after hardening the validator (e.g. v1.1 upgrade) to identify bios
|
||||
* generated under older, looser rules that no longer pass.
|
||||
*
|
||||
* Flagged biographies have needs_review=true set but are NOT hidden or deleted.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan ai-biography:validate
|
||||
* php artisan ai-biography:validate {user_id}
|
||||
* php artisan ai-biography:validate --dry-run
|
||||
*/
|
||||
final class ValidateAiBiographyCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-biography:validate
|
||||
{user_id? : Validate biography for a single creator}
|
||||
{--dry-run : Report failures without updating records}
|
||||
{--limit=500 : Maximum number of records to check}';
|
||||
|
||||
protected $description = 'Validate stored AI biographies against current validator rules';
|
||||
|
||||
public function handle(AiBiographyValidator $validator): int
|
||||
{
|
||||
$userId = $this->argument('user_id');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
if ($userId !== null) {
|
||||
return $this->handleSingle((int) $userId, $validator, $dryRun);
|
||||
}
|
||||
|
||||
return $this->handleBatch($validator, $dryRun, $limit);
|
||||
}
|
||||
|
||||
private function handleSingle(int $userId, AiBiographyValidator $validator, bool $dryRun): int
|
||||
{
|
||||
$user = User::query()->where('id', $userId)->whereNull('deleted_at')->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error("User #{$userId} not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$record = CreatorAiBiography::query()
|
||||
->where('user_id', $userId)
|
||||
->where('is_active', true)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($record === null) {
|
||||
$this->line("User #{$userId} ({$user->username}): no active biography.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($record->is_user_edited) {
|
||||
$this->line("User #{$userId} ({$user->username}): biography is user-edited — skipping.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$errors = $validator->validate(
|
||||
(string) $record->text,
|
||||
$record->input_quality_tier ?? 'rich',
|
||||
);
|
||||
|
||||
if ($errors === []) {
|
||||
$this->info("User #{$userId} ({$user->username}): ✓ valid");
|
||||
} else {
|
||||
$this->warn("User #{$userId} ({$user->username}): ✗ " . implode('; ', $errors));
|
||||
|
||||
if (! $dryRun) {
|
||||
$record->update(['needs_review' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleBatch(AiBiographyValidator $validator, bool $dryRun, int $limit): int
|
||||
{
|
||||
$this->info('Validating stored AI biographies against current rules...');
|
||||
|
||||
$checked = 0;
|
||||
$passed = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
|
||||
CreatorAiBiography::query()
|
||||
->where('is_active', true)
|
||||
->whereNotNull('text')
|
||||
->where('is_user_edited', false)
|
||||
->whereIn('status', [
|
||||
CreatorAiBiography::STATUS_GENERATED,
|
||||
CreatorAiBiography::STATUS_APPROVED,
|
||||
])
|
||||
->limit($limit)
|
||||
->chunkById(100, function ($records) use ($validator, $dryRun, &$checked, &$passed, &$failed, &$skipped): void {
|
||||
foreach ($records as $record) {
|
||||
$checked++;
|
||||
|
||||
$errors = $validator->validate(
|
||||
(string) $record->text,
|
||||
$record->input_quality_tier ?? 'rich',
|
||||
);
|
||||
|
||||
if ($errors === []) {
|
||||
$passed++;
|
||||
} else {
|
||||
$failed++;
|
||||
$this->warn(" [user:{$record->user_id}] id:{$record->id} — " . implode('; ', $errors));
|
||||
|
||||
if (! $dryRun) {
|
||||
$record->update(['needs_review' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$dryTag = $dryRun ? ' [dry-run — no records updated]' : '';
|
||||
$this->info("Done. checked={$checked} passed={$passed} failed/flagged={$failed}{$dryTag}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\GenerateArtworkAiSuggestionsCommand;
|
||||
use App\Console\Commands\SyncCountriesCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Console\Commands\DispatchCollectionMaintenanceCommand;
|
||||
@@ -79,13 +80,22 @@ class Kernel extends ConsoleKernel
|
||||
EvaluateFeedWeightsCommand::class,
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
GenerateArtworkAiSuggestionsCommand::class,
|
||||
SyncCountriesCommand::class,
|
||||
\App\Console\Commands\AuditMissingMigratedUsersCommand::class,
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
RecalculateTrendingCommand::class,
|
||||
RecalculateRankingsCommand::class,
|
||||
MetricsSnapshotHourlyCommand::class,
|
||||
RecalculateHeatCommand::class,
|
||||
\App\Console\Commands\RebuildCreatorErasCommand::class,
|
||||
\App\Console\Commands\AuditOrphanedArtworksCommand::class,
|
||||
\App\Console\Commands\FlagLegacyUsersForMigrationCommand::class,
|
||||
\App\Console\Commands\ExportLegacyPasswordsCommand::class,
|
||||
\App\Console\Commands\GenerateAiBiographyCommand::class,
|
||||
\App\Console\Commands\InspectAiBiographyCommand::class,
|
||||
\App\Console\Commands\ReviewQueueAiBiographyCommand::class,
|
||||
\App\Console\Commands\ValidateAiBiographyCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -149,7 +159,7 @@ class Kernel extends ConsoleKernel
|
||||
// Step 1: compute per-artwork scores every hour at :05
|
||||
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
|
||||
// Step 2: build ranked lists every hour at :15 (after scores are ready)
|
||||
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground();
|
||||
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->withoutOverlapping()->runInBackground();
|
||||
|
||||
// ── Ranking Engine V2 — runs every 30 min ──────────────────────────
|
||||
$schedule->command('nova:recalculate-rankings --sync-rank-scores')
|
||||
@@ -198,6 +208,14 @@ class Kernel extends ConsoleKernel
|
||||
->name('sync-countries')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ── Scheduler health heartbeat ──────────────────────────────────────
|
||||
// Stamps a Redis key each minute so `health:check --only=scheduler` can
|
||||
// verify cron is alive. The key expires after 5 minutes so a dead cron
|
||||
// will naturally cause the check to warn/fail.
|
||||
$schedule->command('health:tick')
|
||||
->everyMinute()
|
||||
->name('health-scheduler-tick');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user