Implement creator studio and upload updates
This commit is contained in:
122
app/Console/Commands/BuildSitemapsCommand.php
Normal file
122
app/Console/Commands/BuildSitemapsCommand.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Sitemaps\BuildSitemapReleaseJob;
|
||||
use App\Services\Sitemaps\SitemapBuildService;
|
||||
use App\Services\Sitemaps\SitemapPublishService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class BuildSitemapsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sitemaps:build
|
||||
{--only=* : Limit the build to one or more sitemap families}
|
||||
{--release= : Override the generated release id}
|
||||
{--shards : Show per-shard output in the command report}
|
||||
{--queue : Dispatch the release build to the queue}
|
||||
{--force : Accepted for backward compatibility; release builds are always fresh}
|
||||
{--clear : Accepted for backward compatibility; release builds are isolated}
|
||||
{--dry-run : Build a release artifact set without activating it}';
|
||||
|
||||
protected $description = 'Build a versioned sitemap release artifact set.';
|
||||
|
||||
public function handle(SitemapBuildService $build, SitemapPublishService $publish): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$families = $this->selectedFamilies($build);
|
||||
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== '' ? trim((string) $value) : null;
|
||||
|
||||
if ($families === []) {
|
||||
$this->error('No valid sitemap families were selected.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$showShards = (bool) $this->option('shards');
|
||||
|
||||
if ((bool) $this->option('queue')) {
|
||||
BuildSitemapReleaseJob::dispatch($families, $releaseId);
|
||||
$this->info('Queued sitemap release build' . ($releaseId !== null ? ' for [' . $releaseId . '].' : '.'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$manifest = $publish->buildRelease($families, $releaseId);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$totalUrls = 0;
|
||||
$totalDocuments = 0;
|
||||
|
||||
foreach ($families as $family) {
|
||||
$names = (array) data_get($manifest, 'families.' . $family . '.documents', []);
|
||||
$familyUrls = 0;
|
||||
|
||||
if (! $showShards) {
|
||||
$this->line('Building family [' . $family . '] with ' . count($names) . ' document(s).');
|
||||
}
|
||||
|
||||
foreach ($names as $name) {
|
||||
$documentType = str_ends_with((string) $name, '-index') ? 'index' : ((string) $family === (string) config('sitemaps.news.google_variant_name', 'news-google') ? 'google-news' : 'urlset');
|
||||
$familyUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
|
||||
$totalUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
|
||||
$totalDocuments++;
|
||||
|
||||
if ($showShards || ! str_contains((string) $name, '-000')) {
|
||||
$this->line(sprintf(
|
||||
' - %s [%s]',
|
||||
$name,
|
||||
$documentType,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf('Family [%s] complete: urls=%d documents=%d', $family, (int) data_get($manifest, 'families.' . $family . '.url_count', 0), count($names)));
|
||||
}
|
||||
$totalDocuments++;
|
||||
|
||||
$this->info(sprintf(
|
||||
'Sitemap release [%s] complete: families=%d documents=%d urls=%d status=%s duration=%.2fs',
|
||||
(string) $manifest['release_id'],
|
||||
(int) data_get($manifest, 'totals.families', 0),
|
||||
(int) data_get($manifest, 'totals.documents', 0),
|
||||
(int) data_get($manifest, 'totals.urls', 0),
|
||||
(string) ($manifest['status'] ?? 'built'),
|
||||
microtime(true) - $startedAt,
|
||||
));
|
||||
$this->line('Sitemap index complete');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function selectedFamilies(SitemapBuildService $build): array
|
||||
{
|
||||
$only = [];
|
||||
|
||||
foreach ((array) $this->option('only') as $value) {
|
||||
foreach (explode(',', (string) $value) as $family) {
|
||||
$normalized = trim($family);
|
||||
if ($normalized !== '') {
|
||||
$only[] = $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$enabled = $build->enabledFamilies();
|
||||
|
||||
if ($only === []) {
|
||||
return $enabled;
|
||||
}
|
||||
|
||||
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Images\ArtworkSquareThumbnailBackfillService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
final class GenerateMissingSquareThumbnailsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:generate-missing-sq-thumbs
|
||||
{--id= : Generate only for this artwork ID}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--force : Regenerate even if an sq variant row already exists}
|
||||
{--dry-run : Report what would be generated without writing files}';
|
||||
|
||||
protected $description = 'Generate missing smart square artwork thumbnails';
|
||||
|
||||
public function handle(ArtworkSquareThumbnailBackfillService $backfill): int
|
||||
{
|
||||
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$query = Artwork::query()
|
||||
->whereNotNull('hash')
|
||||
->where('hash', '!=', '')
|
||||
->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$query->whereKey($artworkId);
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$generated = 0;
|
||||
$planned = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
$query->chunkById(100, function ($artworks) use ($backfill, $force, $dryRun, $limit, &$processed, &$generated, &$planned, &$skipped, &$failed) {
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $backfill->ensureSquareThumbnail($artwork, $force, $dryRun);
|
||||
$status = (string) ($result['status'] ?? 'skipped');
|
||||
|
||||
if ($status === 'generated') {
|
||||
$generated++;
|
||||
} elseif ($status === 'dry_run') {
|
||||
$planned++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->getKey(), $e->getMessage()));
|
||||
}
|
||||
|
||||
$processed++;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Square thumbnail backfill complete. processed=%d generated=%d planned=%d skipped=%d failed=%d',
|
||||
$processed,
|
||||
$generated,
|
||||
$planned,
|
||||
$skipped,
|
||||
$failed,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -179,15 +179,8 @@ class ImportLegacyArtworks extends Command
|
||||
$art = null;
|
||||
|
||||
DB::connection()->transaction(function () use (&$art, $data, $legacyId, $legacyConn, $connectedTable) {
|
||||
// create artwork (guard against unique slug collisions)
|
||||
$baseSlug = $data['slug'];
|
||||
$attempt = 0;
|
||||
$slug = $baseSlug;
|
||||
while (Artwork::where('slug', $slug)->exists()) {
|
||||
$attempt++;
|
||||
$slug = $baseSlug . '-' . $attempt;
|
||||
}
|
||||
$data['slug'] = $slug;
|
||||
// Preserve the imported slug verbatim. Public artwork URLs include the artwork id.
|
||||
$data['slug'] = Str::limit((string) ($data['slug'] ?: 'artwork'), 160, '');
|
||||
|
||||
// Preserve legacy primary ID if available and safe to do so.
|
||||
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
|
||||
|
||||
@@ -6,13 +6,17 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Vision\VectorService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class IndexArtworkVectorsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:vectors-index
|
||||
{--order=updated-desc : Ordering mode: updated-desc or id-asc}
|
||||
{--start-id=0 : Start from this artwork id (inclusive)}
|
||||
{--after-id=0 : Resume after this artwork id}
|
||||
{--after-updated-at= : Resume updated-desc mode after this ISO-8601 timestamp}
|
||||
{--batch=100 : Batch size per iteration}
|
||||
{--limit=0 : Maximum artworks to process in this run}
|
||||
{--embedded-only : Re-upsert only artworks that already have local embeddings}
|
||||
@@ -29,8 +33,16 @@ final class IndexArtworkVectorsCommand extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$order = $this->normalizeOrder((string) $this->option('order'));
|
||||
$startId = max(0, (int) $this->option('start-id'));
|
||||
$afterId = max(0, (int) $this->option('after-id'));
|
||||
try {
|
||||
$afterUpdatedAt = $this->resolveAfterUpdatedAt($order, (string) $this->option('after-updated-at'));
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
$batch = max(1, min((int) $this->option('batch'), 1000));
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
$publicOnly = (bool) $this->option('public-only');
|
||||
@@ -42,6 +54,7 @@ final class IndexArtworkVectorsCommand extends Command
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$lastId = $afterId;
|
||||
$nextUpdatedAt = $afterUpdatedAt;
|
||||
|
||||
if ($startId > 0 && $afterId > 0) {
|
||||
$this->warn(sprintf(
|
||||
@@ -51,10 +64,16 @@ final class IndexArtworkVectorsCommand extends Command
|
||||
));
|
||||
}
|
||||
|
||||
if ($order === 'updated-desc' && ($startId > 0 || $afterId > 0) && $afterUpdatedAt === null) {
|
||||
$this->warn('The --start-id/--after-id options are legacy id-asc cursors. They are ignored unless --order=id-asc is used, or unless --after-updated-at is also provided for updated-desc mode.');
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s embedded_only=%s public_only=%s dry_run=%s',
|
||||
'Starting vector index: order=%s start_id=%d after_id=%d after_updated_at=%s next_id=%d batch=%d limit=%s embedded_only=%s public_only=%s dry_run=%s',
|
||||
$order,
|
||||
$startId,
|
||||
$afterId,
|
||||
$afterUpdatedAt?->toIso8601String() ?? 'none',
|
||||
$nextId,
|
||||
$batch,
|
||||
$limit > 0 ? (string) $limit : 'all',
|
||||
@@ -73,10 +92,27 @@ final class IndexArtworkVectorsCommand extends Command
|
||||
|
||||
$query = Artwork::query()
|
||||
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||
->where('id', '>=', $nextId)
|
||||
->whereNotNull('hash')
|
||||
->orderBy('id')
|
||||
->limit($take);
|
||||
->whereNotNull('hash');
|
||||
|
||||
if ($order === 'updated-desc') {
|
||||
$query->orderByDesc('updated_at')
|
||||
->orderByDesc('id')
|
||||
->limit($take);
|
||||
|
||||
if ($nextUpdatedAt !== null) {
|
||||
$query->where(function ($cursorQuery) use ($nextUpdatedAt, $afterId): void {
|
||||
$cursorQuery->where('updated_at', '<', $nextUpdatedAt)
|
||||
->orWhere(function ($sameTimestampQuery) use ($nextUpdatedAt, $afterId): void {
|
||||
$sameTimestampQuery->where('updated_at', '=', $nextUpdatedAt)
|
||||
->where('id', '<', $afterId);
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
$query->where('id', '>=', $nextId)
|
||||
->orderBy('id')
|
||||
->limit($take);
|
||||
}
|
||||
|
||||
if ($embeddedOnly) {
|
||||
$query->whereHas('embeddings');
|
||||
@@ -93,16 +129,25 @@ final class IndexArtworkVectorsCommand extends Command
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'Fetched batch: count=%d first_id=%d last_id=%d',
|
||||
'Fetched batch: count=%d first_id=%d last_id=%d first_updated_at=%s last_updated_at=%s',
|
||||
$artworks->count(),
|
||||
(int) $artworks->first()->id,
|
||||
(int) $artworks->last()->id
|
||||
(int) $artworks->last()->id,
|
||||
optional($artworks->first()->updated_at)->toIso8601String() ?? 'null',
|
||||
optional($artworks->last()->updated_at)->toIso8601String() ?? 'null'
|
||||
));
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
$processed++;
|
||||
$lastId = (int) $artwork->id;
|
||||
$nextId = $lastId + 1;
|
||||
if ($order === 'updated-desc') {
|
||||
$nextUpdatedAt = $artwork->updated_at !== null
|
||||
? CarbonImmutable::instance($artwork->updated_at)
|
||||
: null;
|
||||
$afterId = $lastId;
|
||||
} else {
|
||||
$nextId = $lastId + 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $vectors->payloadForArtwork($artwork);
|
||||
@@ -150,11 +195,49 @@ final class IndexArtworkVectorsCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}");
|
||||
$this->info(sprintf(
|
||||
'Vector index finished. processed=%d indexed=%d skipped=%d failed=%d last_id=%d next_id=%d next_updated_at=%s',
|
||||
$processed,
|
||||
$indexed,
|
||||
$skipped,
|
||||
$failed,
|
||||
$lastId,
|
||||
$nextId,
|
||||
$nextUpdatedAt?->toIso8601String() ?? 'none'
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalizeOrder(string $order): string
|
||||
{
|
||||
$normalized = strtolower(trim($order));
|
||||
|
||||
return match ($normalized) {
|
||||
'updated-desc', 'updated', 'latest', 'latest-updated' => 'updated-desc',
|
||||
'id-asc', 'id', 'legacy' => 'id-asc',
|
||||
default => 'updated-desc',
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveAfterUpdatedAt(string $order, string $afterUpdatedAt): ?CarbonImmutable
|
||||
{
|
||||
if ($order !== 'updated-desc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($afterUpdatedAt);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return CarbonImmutable::parse($value);
|
||||
} catch (\Throwable) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid --after-updated-at value [%s]. Use an ISO-8601 timestamp.', $afterUpdatedAt));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $payload
|
||||
*/
|
||||
|
||||
33
app/Console/Commands/ListSitemapReleasesCommand.php
Normal file
33
app/Console/Commands/ListSitemapReleasesCommand.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Sitemaps\SitemapReleaseManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class ListSitemapReleasesCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sitemaps:releases';
|
||||
|
||||
protected $description = 'List recent sitemap releases and the active release.';
|
||||
|
||||
public function handle(SitemapReleaseManager $releases): int
|
||||
{
|
||||
$active = $releases->activeReleaseId();
|
||||
|
||||
foreach ($releases->listReleases() as $release) {
|
||||
$this->line(sprintf(
|
||||
'%s status=%s families=%d published_at=%s%s',
|
||||
(string) ($release['release_id'] ?? 'unknown'),
|
||||
(string) ($release['status'] ?? 'unknown'),
|
||||
(int) data_get($release, 'totals.families', 0),
|
||||
(string) ($release['published_at'] ?? 'n/a'),
|
||||
(string) ($release['release_id'] ?? '') === $active ? ' [active]' : '',
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
120
app/Console/Commands/NormalizeArtworkSlugsCommand.php
Normal file
120
app/Console/Commands/NormalizeArtworkSlugsCommand.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class NormalizeArtworkSlugsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:normalize-slugs
|
||||
{--dry-run : Show the slug changes without writing them}
|
||||
{--chunk=500 : Number of artworks to process per chunk}
|
||||
{--only-mismatched : Only update rows whose current slug differs from the normalized title slug}';
|
||||
|
||||
protected $description = 'Normalize existing artwork slugs from artwork titles without enforcing uniqueness.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
$onlyMismatched = (bool) $this->option('only-mismatched');
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->ensureSlugIsNotUnique();
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
|
||||
DB::table('artworks')
|
||||
->select(['id', 'title', 'slug'])
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function ($artworks) use ($dryRun, $onlyMismatched, &$processed, &$updated): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
$processed++;
|
||||
|
||||
$normalizedSlug = Str::limit(Str::slug((string) ($artwork->title ?? '')) ?: 'artwork', 160, '');
|
||||
$currentSlug = (string) ($artwork->slug ?? '');
|
||||
|
||||
if ($onlyMismatched && $currentSlug === $normalizedSlug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($currentSlug === $normalizedSlug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf('#%d %s => %s', $artwork->id, $currentSlug !== '' ? $currentSlug : '[empty]', $normalizedSlug));
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('artworks')
|
||||
->where('id', $artwork->id)
|
||||
->update(['slug' => $normalizedSlug]);
|
||||
|
||||
$updated++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info(sprintf('Dry run complete. Checked %d artworks, %d would be updated.', $processed, $updated));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf('Normalization complete. Checked %d artworks, updated %d.', $processed, $updated));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function ensureSlugIsNotUnique(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
$indexes = collect(DB::select("SHOW INDEX FROM artworks WHERE Column_name = 'slug'"));
|
||||
|
||||
$indexes
|
||||
->filter(fn ($index) => (int) ($index->Non_unique ?? 1) === 0)
|
||||
->pluck('Key_name')
|
||||
->filter()
|
||||
->unique()
|
||||
->each(function ($indexName): void {
|
||||
$this->warn(sprintf('Dropping unique slug index %s before normalization.', $indexName));
|
||||
DB::statement(sprintf('ALTER TABLE artworks DROP INDEX `%s`', str_replace('`', '``', (string) $indexName)));
|
||||
});
|
||||
|
||||
$hasNonUniqueSlugIndex = $indexes->contains(fn ($index) => (string) ($index->Key_name ?? '') === 'artworks_slug_index' || (int) ($index->Non_unique ?? 0) === 1);
|
||||
|
||||
if (! $hasNonUniqueSlugIndex) {
|
||||
DB::statement('CREATE INDEX artworks_slug_index ON artworks (slug)');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
$indexes = collect(DB::select("PRAGMA index_list('artworks')"));
|
||||
|
||||
$indexes
|
||||
->filter(function ($index): bool {
|
||||
if ((int) ($index->unique ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$columns = collect(DB::select(sprintf("PRAGMA index_info('%s')", str_replace("'", "''", (string) $index->name))))
|
||||
->pluck('name')
|
||||
->map(fn ($name) => (string) $name);
|
||||
|
||||
return $columns->contains('slug');
|
||||
})
|
||||
->pluck('name')
|
||||
->each(fn ($indexName) => DB::statement(sprintf('DROP INDEX IF EXISTS "%s"', str_replace('"', '""', (string) $indexName))));
|
||||
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS artworks_slug_index ON artworks (slug)');
|
||||
}
|
||||
}
|
||||
}
|
||||
80
app/Console/Commands/PublishScheduledNovaCardsCommand.php
Normal file
80
app/Console/Commands/PublishScheduledNovaCardsCommand.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Services\NovaCards\NovaCardPublishService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PublishScheduledNovaCardsCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova-cards:publish-scheduled {--dry-run : List scheduled cards without publishing} {--limit=100 : Max cards per run}';
|
||||
|
||||
protected $description = 'Publish scheduled Nova Cards whose scheduled time has passed.';
|
||||
|
||||
public function handle(NovaCardPublishService $publishService): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
$now = now()->utc();
|
||||
|
||||
$candidates = NovaCard::query()
|
||||
->where('status', NovaCard::STATUS_SCHEDULED)
|
||||
->whereNotNull('scheduled_for')
|
||||
->where('scheduled_for', '<=', $now)
|
||||
->orderBy('scheduled_for')
|
||||
->limit($limit)
|
||||
->get(['id', 'title', 'scheduled_for']);
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->line('No scheduled Nova Cards due for publishing.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$published = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf('[dry-run] Would publish Nova Card #%d: "%s"', $candidate->id, $candidate->title));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($candidate, $publishService, &$published): void {
|
||||
$card = NovaCard::query()
|
||||
->lockForUpdate()
|
||||
->where('id', $candidate->id)
|
||||
->where('status', NovaCard::STATUS_SCHEDULED)
|
||||
->first();
|
||||
|
||||
if (! $card) {
|
||||
return;
|
||||
}
|
||||
|
||||
$publishService->publishNow($card);
|
||||
$published++;
|
||||
$this->line(sprintf('Published Nova Card #%d: "%s"', $candidate->id, $candidate->title));
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
$errors++;
|
||||
Log::error('PublishScheduledNovaCardsCommand failed', [
|
||||
'card_id' => $candidate->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
$this->error(sprintf('Failed to publish Nova Card #%d: %s', $candidate->id, $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info(sprintf('Done. Published: %d, Errors: %d.', $published, $errors));
|
||||
}
|
||||
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
48
app/Console/Commands/PublishSitemapsCommand.php
Normal file
48
app/Console/Commands/PublishSitemapsCommand.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Sitemaps\PublishSitemapReleaseJob;
|
||||
use App\Services\Sitemaps\SitemapPublishService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class PublishSitemapsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sitemaps:publish
|
||||
{--release= : Publish an existing built release}
|
||||
{--queue : Dispatch publish flow to the queue}
|
||||
{--sync : Run publish synchronously (default)}';
|
||||
|
||||
protected $description = 'Build, validate, and atomically publish a sitemap release.';
|
||||
|
||||
public function handle(SitemapPublishService $publish): int
|
||||
{
|
||||
$releaseId = $this->option('release');
|
||||
|
||||
if ((bool) $this->option('queue')) {
|
||||
PublishSitemapReleaseJob::dispatch(is_string($releaseId) && $releaseId !== '' ? $releaseId : null);
|
||||
$this->info('Queued sitemap publish flow' . (is_string($releaseId) && $releaseId !== '' ? ' for release [' . $releaseId . '].' : '.'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$manifest = $publish->publish(is_string($releaseId) && $releaseId !== '' ? $releaseId : null);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Published sitemap release [%s] with %d families and %d documents.',
|
||||
(string) $manifest['release_id'],
|
||||
(int) data_get($manifest, 'totals.families', 0),
|
||||
(int) data_get($manifest, 'totals.documents', 0),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
168
app/Console/Commands/RescanContentModerationCommand.php
Normal file
168
app/Console/Commands/RescanContentModerationCommand.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ModerationContentType;
|
||||
use App\Enums\ModerationStatus;
|
||||
use App\Models\ContentModerationFinding;
|
||||
use App\Services\Moderation\ContentModerationActionLogService;
|
||||
use App\Services\Moderation\ContentModerationProcessingService;
|
||||
use App\Services\Moderation\ContentModerationSourceService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RescanContentModerationCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:rescan-content-moderation
|
||||
{--only= : comments, descriptions, titles, bios, profile-links, collections, stories, cards, or a comma-separated list}
|
||||
{--status=pending : Filter findings by moderation status}
|
||||
{--limit= : Maximum number of findings to rescan}
|
||||
{--from-id= : Start rescanning at or after this finding ID}
|
||||
{--force : Rescan all matching findings, including already resolved findings}';
|
||||
|
||||
protected $description = 'Rescan existing moderation findings using the latest rules and scanner version.';
|
||||
|
||||
public function __construct(
|
||||
private readonly ContentModerationProcessingService $processing,
|
||||
private readonly ContentModerationSourceService $sources,
|
||||
private readonly ContentModerationActionLogService $actionLogs,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(0, (int) ($this->option('limit') ?? 0));
|
||||
$fromId = max(0, (int) ($this->option('from-id') ?? 0));
|
||||
$status = trim((string) ($this->option('status') ?? 'pending'));
|
||||
$force = (bool) $this->option('force');
|
||||
$types = $this->selectedTypes();
|
||||
|
||||
$counts = [
|
||||
'rescanned' => 0,
|
||||
'updated' => 0,
|
||||
'auto_hidden' => 0,
|
||||
'missing_source' => 0,
|
||||
];
|
||||
|
||||
$query = ContentModerationFinding::query()->orderBy('id');
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($fromId > 0) {
|
||||
$query->where('id', '>=', $fromId);
|
||||
}
|
||||
|
||||
if ($types !== []) {
|
||||
$query->whereIn('content_type', array_map(static fn (ModerationContentType $type): string => $type->value, $types));
|
||||
}
|
||||
|
||||
if (! $force) {
|
||||
$query->where('status', ModerationStatus::Pending->value);
|
||||
}
|
||||
|
||||
if ($limit > 0) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$query->chunkById(100, function ($findings) use (&$counts): bool {
|
||||
foreach ($findings as $finding) {
|
||||
$counts['rescanned']++;
|
||||
$rescanned = $this->processing->rescanFinding($finding, $this->sources);
|
||||
|
||||
if ($rescanned === null) {
|
||||
$counts['missing_source']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts['updated']++;
|
||||
if ($rescanned->is_auto_hidden) {
|
||||
$counts['auto_hidden']++;
|
||||
}
|
||||
|
||||
$this->actionLogs->log(
|
||||
$rescanned,
|
||||
'finding',
|
||||
$rescanned->id,
|
||||
'rescan',
|
||||
null,
|
||||
null,
|
||||
$rescanned->status->value,
|
||||
null,
|
||||
null,
|
||||
'Finding rescanned with the latest moderation rules.',
|
||||
['scanner_version' => $rescanned->scanner_version],
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, 'id');
|
||||
|
||||
$this->table(['Metric', 'Count'], [
|
||||
['Rescanned', $counts['rescanned']],
|
||||
['Updated', $counts['updated']],
|
||||
['Auto-hidden', $counts['auto_hidden']],
|
||||
['Missing source', $counts['missing_source']],
|
||||
]);
|
||||
|
||||
$this->info('Content moderation rescan complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ModerationContentType>
|
||||
*/
|
||||
private function selectedTypes(): array
|
||||
{
|
||||
$raw = trim((string) ($this->option('only') ?? ''));
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$selected = \collect(explode(',', $raw))
|
||||
->map(static fn (string $value): string => trim(strtolower($value)))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$types = [];
|
||||
|
||||
if ($selected->contains('comments')) {
|
||||
$types[] = ModerationContentType::ArtworkComment;
|
||||
}
|
||||
|
||||
if ($selected->contains('descriptions')) {
|
||||
$types[] = ModerationContentType::ArtworkDescription;
|
||||
}
|
||||
|
||||
if ($selected->contains('titles') || $selected->contains('artwork_titles')) {
|
||||
$types[] = ModerationContentType::ArtworkTitle;
|
||||
}
|
||||
|
||||
if ($selected->contains('bios') || $selected->contains('user_bios')) {
|
||||
$types[] = ModerationContentType::UserBio;
|
||||
}
|
||||
|
||||
if ($selected->contains('profile-links') || $selected->contains('profile_links')) {
|
||||
$types[] = ModerationContentType::UserProfileLink;
|
||||
}
|
||||
|
||||
if ($selected->contains('collections') || $selected->contains('collection_titles')) {
|
||||
$types[] = ModerationContentType::CollectionTitle;
|
||||
$types[] = ModerationContentType::CollectionDescription;
|
||||
}
|
||||
|
||||
if ($selected->contains('stories') || $selected->contains('story_titles')) {
|
||||
$types[] = ModerationContentType::StoryTitle;
|
||||
$types[] = ModerationContentType::StoryContent;
|
||||
}
|
||||
|
||||
if ($selected->contains('cards') || $selected->contains('card_titles')) {
|
||||
$types[] = ModerationContentType::CardTitle;
|
||||
$types[] = ModerationContentType::CardText;
|
||||
}
|
||||
|
||||
return $types;
|
||||
}
|
||||
}
|
||||
30
app/Console/Commands/RollbackSitemapReleaseCommand.php
Normal file
30
app/Console/Commands/RollbackSitemapReleaseCommand.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Sitemaps\SitemapPublishService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class RollbackSitemapReleaseCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sitemaps:rollback {release? : Release id to activate instead of the previous published release}';
|
||||
|
||||
protected $description = 'Rollback sitemap delivery to a previous published release.';
|
||||
|
||||
public function handle(SitemapPublishService $publish): int
|
||||
{
|
||||
try {
|
||||
$manifest = $publish->rollback(($release = $this->argument('release')) !== null ? (string) $release : null);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Rolled back to sitemap release [' . (string) $manifest['release_id'] . '].');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
366
app/Console/Commands/ScanContentModerationCommand.php
Normal file
366
app/Console/Commands/ScanContentModerationCommand.php
Normal file
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ModerationContentType;
|
||||
use App\Enums\ModerationStatus;
|
||||
use App\Services\Moderation\ContentModerationPersistenceService;
|
||||
use App\Services\Moderation\ContentModerationProcessingService;
|
||||
use App\Services\Moderation\ContentModerationService;
|
||||
use App\Services\Moderation\ContentModerationSourceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ScanContentModerationCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:scan-content-moderation
|
||||
{--only= : comments, descriptions, titles, bios, profile-links, collections, stories, cards, or a comma-separated list}
|
||||
{--limit= : Maximum number of rows to scan}
|
||||
{--from-id= : Start scanning at or after this source ID}
|
||||
{--status= : Reserved for compatibility with rescan tooling}
|
||||
{--force : Re-scan unchanged content}
|
||||
{--dry-run : Analyze content without persisting findings}';
|
||||
|
||||
protected $description = 'Scan artwork comments and descriptions for suspicious or spam-like content.';
|
||||
|
||||
public function __construct(
|
||||
private readonly ContentModerationService $moderation,
|
||||
private readonly ContentModerationPersistenceService $persistence,
|
||||
private readonly ContentModerationProcessingService $processing,
|
||||
private readonly ContentModerationSourceService $sources,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$targets = $this->targets();
|
||||
$limit = max(0, (int) ($this->option('limit') ?? 0));
|
||||
$remaining = $limit > 0 ? $limit : null;
|
||||
$counts = [
|
||||
'scanned' => 0,
|
||||
'flagged' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'clean' => 0,
|
||||
'auto_hidden' => 0,
|
||||
];
|
||||
|
||||
$this->announceScanStart($targets, $limit);
|
||||
|
||||
foreach ($targets as $target) {
|
||||
if ($remaining !== null && $remaining <= 0) {
|
||||
$this->comment('Scan limit reached. Stopping before the next content target.');
|
||||
break;
|
||||
}
|
||||
|
||||
$counts = $this->scanTarget($target, $counts, $remaining);
|
||||
}
|
||||
|
||||
$this->table(['Metric', 'Count'], [
|
||||
['Scanned', $counts['scanned']],
|
||||
['Flagged', $counts['flagged']],
|
||||
['Created', $counts['created']],
|
||||
['Updated', $counts['updated']],
|
||||
['Auto-hidden', $counts['auto_hidden']],
|
||||
['Clean', $counts['clean']],
|
||||
['Skipped', $counts['skipped']],
|
||||
]);
|
||||
|
||||
Log::info('Content moderation scan complete.', [
|
||||
'targets' => array_map(static fn (ModerationContentType $target): string => $target->value, $targets),
|
||||
'limit' => $limit > 0 ? $limit : null,
|
||||
'from_id' => max(0, (int) ($this->option('from-id') ?? 0)) ?: null,
|
||||
'force' => (bool) $this->option('force'),
|
||||
'dry_run' => (bool) $this->option('dry-run'),
|
||||
'counts' => $counts,
|
||||
]);
|
||||
|
||||
$this->info('Content moderation scan complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $counts
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function scanTarget(ModerationContentType $target, array $counts, ?int &$remaining): array
|
||||
{
|
||||
$before = $counts;
|
||||
$this->info('Scanning ' . $target->label() . ' entries...');
|
||||
|
||||
$query = match ($target) {
|
||||
ModerationContentType::ArtworkComment,
|
||||
ModerationContentType::ArtworkDescription,
|
||||
ModerationContentType::ArtworkTitle,
|
||||
ModerationContentType::UserBio,
|
||||
ModerationContentType::UserProfileLink,
|
||||
ModerationContentType::CollectionTitle,
|
||||
ModerationContentType::CollectionDescription,
|
||||
ModerationContentType::StoryTitle,
|
||||
ModerationContentType::StoryContent,
|
||||
ModerationContentType::CardTitle,
|
||||
ModerationContentType::CardText => $this->sources->queryForType($target),
|
||||
};
|
||||
|
||||
$fromId = max(0, (int) ($this->option('from-id') ?? 0));
|
||||
if ($fromId > 0) {
|
||||
$query->where('id', '>=', $fromId);
|
||||
}
|
||||
|
||||
$query->chunkById(200, function ($rows) use ($target, &$counts, &$remaining): bool {
|
||||
foreach ($rows as $row) {
|
||||
if ($remaining !== null && $remaining <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = $this->sources->buildContext($target, $row);
|
||||
$snapshot = (string) ($context['content_snapshot'] ?? '');
|
||||
$sourceId = (int) ($context['content_id'] ?? 0);
|
||||
|
||||
if ($snapshot === '') {
|
||||
$counts['skipped']++;
|
||||
$this->verboseLine($target, $sourceId, 'skipped empty snapshot');
|
||||
continue;
|
||||
}
|
||||
|
||||
$analysis = $this->moderation->analyze($snapshot, $context);
|
||||
$counts['scanned']++;
|
||||
|
||||
if (! $this->option('force') && ! $this->option('dry-run') && $this->persistence->hasCurrentFinding(
|
||||
(string) $context['content_type'],
|
||||
(int) $context['content_id'],
|
||||
$analysis->contentHash,
|
||||
$analysis->scannerVersion,
|
||||
)) {
|
||||
$counts['skipped']++;
|
||||
$this->verboseLine($target, $sourceId, 'skipped unchanged content');
|
||||
$remaining = $remaining !== null ? $remaining - 1 : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
if ($analysis->status === ModerationStatus::Pending) {
|
||||
$counts['flagged']++;
|
||||
$this->verboseAnalysis($target, $sourceId, $analysis, 'dry-run flagged');
|
||||
} else {
|
||||
$counts['clean']++;
|
||||
$this->verboseLine($target, $sourceId, 'dry-run clean');
|
||||
}
|
||||
|
||||
$remaining = $remaining !== null ? $remaining - 1 : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->processing->process($snapshot, $context, true);
|
||||
|
||||
if ($analysis->status !== ModerationStatus::Pending) {
|
||||
$counts['clean']++;
|
||||
if ($result['updated']) {
|
||||
$counts['updated']++;
|
||||
}
|
||||
$this->verboseLine($target, $sourceId, $result['updated'] ? 'clean, existing finding updated' : 'clean');
|
||||
$remaining = $remaining !== null ? $remaining - 1 : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts['flagged']++;
|
||||
|
||||
if ($result['created']) {
|
||||
$counts['created']++;
|
||||
} elseif ($result['updated']) {
|
||||
$counts['updated']++;
|
||||
}
|
||||
|
||||
if ($result['auto_hidden']) {
|
||||
$counts['auto_hidden']++;
|
||||
}
|
||||
|
||||
$outcome = $result['created']
|
||||
? 'flagged, finding created'
|
||||
: ($result['updated'] ? 'flagged, finding updated' : 'flagged');
|
||||
|
||||
if ($result['auto_hidden']) {
|
||||
$outcome .= ', auto-hidden';
|
||||
}
|
||||
|
||||
$this->verboseAnalysis($target, $sourceId, $analysis, $outcome);
|
||||
|
||||
$remaining = $remaining !== null ? $remaining - 1 : null;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, 'id');
|
||||
|
||||
$targetCounts = [
|
||||
'scanned' => $counts['scanned'] - $before['scanned'],
|
||||
'flagged' => $counts['flagged'] - $before['flagged'],
|
||||
'created' => $counts['created'] - $before['created'],
|
||||
'updated' => $counts['updated'] - $before['updated'],
|
||||
'auto_hidden' => $counts['auto_hidden'] - $before['auto_hidden'],
|
||||
'clean' => $counts['clean'] - $before['clean'],
|
||||
'skipped' => $counts['skipped'] - $before['skipped'],
|
||||
];
|
||||
|
||||
$this->line(sprintf(
|
||||
'Finished %s: scanned=%d, flagged=%d, created=%d, updated=%d, auto-hidden=%d, clean=%d, skipped=%d',
|
||||
$target->label(),
|
||||
$targetCounts['scanned'],
|
||||
$targetCounts['flagged'],
|
||||
$targetCounts['created'],
|
||||
$targetCounts['updated'],
|
||||
$targetCounts['auto_hidden'],
|
||||
$targetCounts['clean'],
|
||||
$targetCounts['skipped'],
|
||||
));
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, ModerationContentType> $targets
|
||||
*/
|
||||
private function announceScanStart(array $targets, int $limit): void
|
||||
{
|
||||
$this->info('Starting content moderation scan...');
|
||||
$this->line('Targets: ' . implode(', ', array_map(static fn (ModerationContentType $target): string => $target->label(), $targets)));
|
||||
$this->line('Mode: ' . ($this->option('dry-run') ? 'dry-run' : 'persist findings'));
|
||||
$this->line('Force re-scan: ' . ($this->option('force') ? 'yes' : 'no'));
|
||||
$this->line('From source ID: ' . (max(0, (int) ($this->option('from-id') ?? 0)) ?: 'start'));
|
||||
$this->line('Limit: ' . ($limit > 0 ? (string) $limit : 'none'));
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->comment('Verbose mode enabled. Use -vv for detailed reasons and matched domains.');
|
||||
}
|
||||
}
|
||||
|
||||
private function verboseLine(ModerationContentType $target, int $sourceId, string $message): void
|
||||
{
|
||||
if (! $this->output->isVerbose()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->line(sprintf('[%s #%d] %s', $target->value, $sourceId, $message));
|
||||
}
|
||||
|
||||
private function verboseAnalysis(ModerationContentType $target, int $sourceId, mixed $analysis, string $prefix): void
|
||||
{
|
||||
if (! $this->output->isVerbose()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'[%s #%d] %s; score=%d; severity=%s; policy=%s; queue=%s',
|
||||
$target->value,
|
||||
$sourceId,
|
||||
$prefix,
|
||||
$analysis->score,
|
||||
$analysis->severity->value,
|
||||
$analysis->policyName ?? 'default',
|
||||
$analysis->status->value,
|
||||
);
|
||||
|
||||
if ($analysis->priorityScore !== null) {
|
||||
$message .= '; priority=' . $analysis->priorityScore;
|
||||
}
|
||||
|
||||
if ($analysis->reviewBucket !== null) {
|
||||
$message .= '; bucket=' . $analysis->reviewBucket;
|
||||
}
|
||||
|
||||
if ($analysis->aiLabel !== null) {
|
||||
$message .= '; ai=' . $analysis->aiLabel;
|
||||
if ($analysis->aiConfidence !== null) {
|
||||
$message .= ' (' . $analysis->aiConfidence . '%)';
|
||||
}
|
||||
}
|
||||
|
||||
$this->line($message);
|
||||
|
||||
if ($this->output->isVeryVerbose()) {
|
||||
if ($analysis->matchedDomains !== []) {
|
||||
$this->line(' matched domains: ' . implode(', ', $analysis->matchedDomains));
|
||||
}
|
||||
|
||||
if ($analysis->matchedKeywords !== []) {
|
||||
$this->line(' matched keywords: ' . implode(', ', $analysis->matchedKeywords));
|
||||
}
|
||||
|
||||
if ($analysis->reasons !== []) {
|
||||
$this->line(' reasons: ' . implode(' | ', $analysis->reasons));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ModerationContentType>
|
||||
*/
|
||||
private function targets(): array
|
||||
{
|
||||
$raw = trim((string) ($this->option('only') ?? ''));
|
||||
if ($raw === '') {
|
||||
return [
|
||||
ModerationContentType::ArtworkComment,
|
||||
ModerationContentType::ArtworkDescription,
|
||||
];
|
||||
}
|
||||
|
||||
$selected = collect(explode(',', $raw))
|
||||
->map(static fn (string $value): string => trim(strtolower($value)))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$targets = [];
|
||||
|
||||
if ($selected->contains('comments')) {
|
||||
$targets[] = ModerationContentType::ArtworkComment;
|
||||
}
|
||||
|
||||
if ($selected->contains('descriptions')) {
|
||||
$targets[] = ModerationContentType::ArtworkDescription;
|
||||
}
|
||||
|
||||
if ($selected->contains('titles') || $selected->contains('artwork_titles')) {
|
||||
$targets[] = ModerationContentType::ArtworkTitle;
|
||||
}
|
||||
|
||||
if ($selected->contains('bios') || $selected->contains('user_bios')) {
|
||||
$targets[] = ModerationContentType::UserBio;
|
||||
}
|
||||
|
||||
if ($selected->contains('profile-links') || $selected->contains('profile_links')) {
|
||||
$targets[] = ModerationContentType::UserProfileLink;
|
||||
}
|
||||
|
||||
if ($selected->contains('collections') || $selected->contains('collection_titles')) {
|
||||
$targets[] = ModerationContentType::CollectionTitle;
|
||||
$targets[] = ModerationContentType::CollectionDescription;
|
||||
}
|
||||
|
||||
if ($selected->contains('stories') || $selected->contains('story_titles')) {
|
||||
$targets[] = ModerationContentType::StoryTitle;
|
||||
$targets[] = ModerationContentType::StoryContent;
|
||||
}
|
||||
|
||||
if ($selected->contains('cards') || $selected->contains('card_titles')) {
|
||||
$targets[] = ModerationContentType::CardTitle;
|
||||
$targets[] = ModerationContentType::CardText;
|
||||
}
|
||||
|
||||
return $targets === [] ? [
|
||||
ModerationContentType::ArtworkComment,
|
||||
ModerationContentType::ArtworkDescription,
|
||||
ModerationContentType::ArtworkTitle,
|
||||
ModerationContentType::UserBio,
|
||||
ModerationContentType::UserProfileLink,
|
||||
ModerationContentType::CollectionTitle,
|
||||
ModerationContentType::CollectionDescription,
|
||||
ModerationContentType::StoryTitle,
|
||||
ModerationContentType::StoryContent,
|
||||
ModerationContentType::CardTitle,
|
||||
ModerationContentType::CardText,
|
||||
] : $targets;
|
||||
}
|
||||
}
|
||||
116
app/Console/Commands/SyncArtworkCreatedAtCommand.php
Normal file
116
app/Console/Commands/SyncArtworkCreatedAtCommand.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\UserStatsService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SyncArtworkCreatedAtCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly UserStatsService $userStats)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected $signature = 'artworks:sync-created-at
|
||||
{--chunk=500 : Number of artworks to process per batch}
|
||||
{--only-null : Update only artworks whose created_at is null}
|
||||
{--dry-run : Preview changes without writing updates}';
|
||||
|
||||
protected $description = 'Copy artworks.published_at into artworks.created_at for published artworks.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$onlyNull = (bool) $this->option('only-null');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No changes will be written.');
|
||||
}
|
||||
|
||||
$query = DB::table('artworks')
|
||||
->select(['id', 'user_id', 'created_at', 'published_at'])
|
||||
->whereNotNull('published_at')
|
||||
->orderBy('id');
|
||||
|
||||
if ($onlyNull) {
|
||||
$query->whereNull('created_at');
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
$unchanged = 0;
|
||||
|
||||
$affectedUserIds = [];
|
||||
|
||||
$query->chunkById($chunk, function (Collection $rows) use (&$processed, &$updated, &$unchanged, &$affectedUserIds, $dryRun): void {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$publishedAt = $this->normalizeTimestamp($row->published_at ?? null);
|
||||
$createdAt = $this->normalizeTimestamp($row->created_at ?? null);
|
||||
|
||||
if ($publishedAt === null) {
|
||||
$unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($createdAt === $publishedAt) {
|
||||
$unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
'[dry] Would update artwork id=%d created_at %s => %s',
|
||||
(int) $row->id,
|
||||
$createdAt ?? '<null>',
|
||||
$publishedAt
|
||||
));
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('artworks')
|
||||
->where('id', (int) $row->id)
|
||||
->update([
|
||||
'created_at' => $publishedAt,
|
||||
'updated_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$affectedUserIds[(int) $row->user_id] = true;
|
||||
$updated++;
|
||||
$this->line(sprintf('[update] artwork id=%d created_at => %s', (int) $row->id, $publishedAt));
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
if (! $dryRun) {
|
||||
foreach (array_keys($affectedUserIds) as $userId) {
|
||||
$this->userStats->recomputeUser((int) $userId);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf('Finished. processed=%d updated=%d unchanged=%d', $processed, $updated, $unchanged));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalizeTimestamp(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse((string) $value)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
148
app/Console/Commands/ValidateSitemapsCommand.php
Normal file
148
app/Console/Commands/ValidateSitemapsCommand.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Sitemaps\SitemapBuildService;
|
||||
use App\Services\Sitemaps\SitemapReleaseManager;
|
||||
use App\Services\Sitemaps\SitemapReleaseValidator;
|
||||
use App\Services\Sitemaps\SitemapValidationService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class ValidateSitemapsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sitemaps:validate
|
||||
{--only=* : Limit validation to one or more sitemap families}
|
||||
{--release= : Validate a specific sitemap release}
|
||||
{--active : Validate the active published sitemap release when available}';
|
||||
|
||||
protected $description = 'Validate sitemap XML, shard integrity, and public URL safety.';
|
||||
|
||||
public function handle(SitemapValidationService $validation, SitemapBuildService $build, SitemapReleaseManager $releases, SitemapReleaseValidator $releaseValidator): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$families = $this->selectedFamilies($build);
|
||||
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== ''
|
||||
? trim((string) $value)
|
||||
: ((bool) $this->option('active') ? $releases->activeReleaseId() : $releases->activeReleaseId());
|
||||
|
||||
if (is_string($releaseId) && $releaseId !== '') {
|
||||
$report = $releaseValidator->validate($releaseId);
|
||||
|
||||
foreach ((array) ($report['families'] ?? []) as $familyReport) {
|
||||
$this->line(sprintf(
|
||||
'Family [%s]: documents=%d urls=%d shards=%d',
|
||||
(string) $familyReport['family'],
|
||||
(int) $familyReport['documents'],
|
||||
(int) $familyReport['url_count'],
|
||||
(int) $familyReport['shard_count'],
|
||||
));
|
||||
|
||||
foreach ((array) ($familyReport['warnings'] ?? []) as $warning) {
|
||||
$this->warn(' - ' . $warning);
|
||||
}
|
||||
|
||||
foreach ((array) ($familyReport['errors'] ?? []) as $error) {
|
||||
$this->error(' - ' . $error);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Sitemap release validation finished in %.2fs. release=%s families=%d documents=%d urls=%d shards=%d',
|
||||
microtime(true) - $startedAt,
|
||||
$releaseId,
|
||||
(int) data_get($report, 'totals.families', 0),
|
||||
(int) data_get($report, 'totals.documents', 0),
|
||||
(int) data_get($report, 'totals.urls', 0),
|
||||
(int) data_get($report, 'totals.shards', 0),
|
||||
));
|
||||
|
||||
if ((bool) ($report['ok'] ?? false)) {
|
||||
$this->info('Sitemap validation passed.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($families === []) {
|
||||
$this->error('No valid sitemap families were selected for validation.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$report = $validation->validate($families);
|
||||
|
||||
foreach ((array) ($report['index']['errors'] ?? []) as $error) {
|
||||
$this->error('Index: ' . $error);
|
||||
}
|
||||
|
||||
foreach ((array) ($report['families'] ?? []) as $familyReport) {
|
||||
$this->line(sprintf(
|
||||
'Family [%s]: documents=%d urls=%d shards=%d',
|
||||
(string) $familyReport['family'],
|
||||
(int) $familyReport['documents'],
|
||||
(int) $familyReport['url_count'],
|
||||
(int) $familyReport['shard_count'],
|
||||
));
|
||||
|
||||
foreach ((array) ($familyReport['warnings'] ?? []) as $warning) {
|
||||
$this->warn(' - ' . $warning);
|
||||
}
|
||||
|
||||
foreach ((array) ($familyReport['errors'] ?? []) as $error) {
|
||||
$this->error(' - ' . $error);
|
||||
}
|
||||
}
|
||||
|
||||
if ((array) ($report['duplicates'] ?? []) !== []) {
|
||||
foreach ((array) $report['duplicates'] as $duplicate) {
|
||||
$this->error('Duplicate URL detected: ' . $duplicate);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Sitemap validation finished in %.2fs. families=%d documents=%d urls=%d shards=%d',
|
||||
microtime(true) - $startedAt,
|
||||
(int) data_get($report, 'totals.families', 0),
|
||||
(int) data_get($report, 'totals.documents', 0),
|
||||
(int) data_get($report, 'totals.urls', 0),
|
||||
(int) data_get($report, 'totals.shards', 0),
|
||||
));
|
||||
|
||||
if ((bool) ($report['ok'] ?? false)) {
|
||||
$this->info('Sitemap validation passed.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function selectedFamilies(SitemapBuildService $build): array
|
||||
{
|
||||
$only = [];
|
||||
|
||||
foreach ((array) $this->option('only') as $value) {
|
||||
foreach (explode(',', (string) $value) as $family) {
|
||||
$normalized = trim($family);
|
||||
if ($normalized !== '') {
|
||||
$only[] = $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$enabled = $build->enabledFamilies();
|
||||
|
||||
if ($only === []) {
|
||||
return $enabled;
|
||||
}
|
||||
|
||||
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user