Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View 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)));
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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
*/

View 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;
}
}

View 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)');
}
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}
}

View 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)));
}
}

View File

@@ -29,8 +29,16 @@ use App\Jobs\RecalculateRisingNovaCardsJob;
use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand;
use App\Console\Commands\NormalizeArtworkSlugsCommand;
use App\Console\Commands\PublishScheduledArtworksCommand;
use App\Console\Commands\PublishScheduledNovaCardsCommand;
use App\Console\Commands\BuildSitemapsCommand;
use App\Console\Commands\ListSitemapReleasesCommand;
use App\Console\Commands\PublishSitemapsCommand;
use App\Console\Commands\RollbackSitemapReleaseCommand;
use App\Console\Commands\SyncCollectionLifecycleCommand;
use App\Console\Commands\ValidateSitemapsCommand;
use App\Jobs\Sitemaps\CleanupSitemapReleasesJob;
class Kernel extends ConsoleKernel
{
@@ -48,8 +56,15 @@ class Kernel extends ConsoleKernel
\App\Console\Commands\AvatarsBulkUpdate::class,
\App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class,
BuildSitemapsCommand::class,
PublishSitemapsCommand::class,
ListSitemapReleasesCommand::class,
RollbackSitemapReleaseCommand::class,
NormalizeArtworkSlugsCommand::class,
PublishScheduledArtworksCommand::class,
PublishScheduledNovaCardsCommand::class,
SyncCollectionLifecycleCommand::class,
ValidateSitemapsCommand::class,
DispatchCollectionMaintenanceCommand::class,
BackfillArtworkEmbeddingsCommand::class,
BackfillArtworkVectorIndexCommand::class,
@@ -77,12 +92,34 @@ class Kernel extends ConsoleKernel
{
$schedule->command('uploads:cleanup')->dailyAt('03:00');
$schedule->command('skinbase:sitemaps:publish --sync')
->everySixHours()
->name('sitemaps-publish')
->withoutOverlapping()
->runInBackground();
$schedule->command('skinbase:sitemaps:validate')
->dailyAt('04:45')
->name('sitemaps-validate')
->withoutOverlapping()
->runInBackground();
$schedule->job(new CleanupSitemapReleasesJob)
->dailyAt('05:00')
->name('sitemaps-cleanup')
->withoutOverlapping()
->runInBackground();
// Publish artworks whose scheduled publish_at has passed
$schedule->command('artworks:publish-scheduled')
->everyMinute()
->name('publish-scheduled-artworks')
->withoutOverlapping(2) // prevent overlap up to 2 minutes
->runInBackground();
$schedule->command('nova-cards:publish-scheduled')
->everyMinute()
->name('publish-scheduled-nova-cards')
->withoutOverlapping(2)
->runInBackground();
$schedule->command('collections:sync-lifecycle')
->everyTenMinutes()
->name('sync-collection-lifecycle')