optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class AggregateDiscoveryFeedbackCommand extends Command
{
protected $signature = 'analytics:aggregate-discovery-feedback {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate discovery feedback events into daily metrics by algorithm and surface';
public function handle(): int
{
if (! Schema::hasTable('user_discovery_events') || ! Schema::hasTable('discovery_feedback_daily_metrics')) {
$this->warn('Required discovery feedback tables are missing.');
return self::SUCCESS;
}
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$surfaceExpression = $this->surfaceExpression();
$rows = DB::table('user_discovery_events')
->selectRaw('algo_version')
->selectRaw($surfaceExpression . ' AS surface')
->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites")
->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads")
->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags")
->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags")
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks')
->whereDate('event_date', $date)
->groupBy('algo_version', DB::raw($surfaceExpression))
->get();
foreach ($rows as $row) {
$views = (int) ($row->views ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$favorites = (int) ($row->favorites ?? 0);
$downloads = (int) ($row->downloads ?? 0);
$feedbackActions = $favorites + $downloads;
$hiddenArtworks = (int) ($row->hidden_artworks ?? 0);
$dislikedTags = (int) ($row->disliked_tags ?? 0);
$undoHiddenArtworks = (int) ($row->undo_hidden_artworks ?? 0);
$undoDislikedTags = (int) ($row->undo_disliked_tags ?? 0);
$negativeFeedbackActions = $hiddenArtworks + $dislikedTags;
$undoActions = $undoHiddenArtworks + $undoDislikedTags;
DB::table('discovery_feedback_daily_metrics')->updateOrInsert(
[
'metric_date' => $date,
'algo_version' => (string) ($row->algo_version ?? ''),
'surface' => (string) ($row->surface ?? 'unknown'),
],
[
'views' => $views,
'clicks' => $clicks,
'favorites' => $favorites,
'downloads' => $downloads,
'hidden_artworks' => $hiddenArtworks,
'disliked_tags' => $dislikedTags,
'undo_hidden_artworks' => $undoHiddenArtworks,
'undo_disliked_tags' => $undoDislikedTags,
'feedback_actions' => $feedbackActions,
'negative_feedback_actions' => $negativeFeedbackActions,
'undo_actions' => $undoActions,
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_artworks' => (int) ($row->unique_artworks ?? 0),
'ctr' => $views > 0 ? $clicks / $views : 0.0,
'favorite_rate_per_click' => $clicks > 0 ? $favorites / $clicks : 0.0,
'download_rate_per_click' => $clicks > 0 ? $downloads / $clicks : 0.0,
'feedback_rate_per_click' => $clicks > 0 ? $feedbackActions / $clicks : 0.0,
'updated_at' => now(),
'created_at' => now(),
]
);
}
$this->info("Aggregated discovery feedback for {$date}.");
return self::SUCCESS;
}
private function surfaceExpression(): string
{
if (DB::connection()->getDriverName() === 'sqlite') {
return "COALESCE(NULLIF(JSON_EXTRACT(meta, '$.gallery_type'), ''), NULLIF(JSON_EXTRACT(meta, '$.surface'), ''), 'unknown')";
}
return "COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.gallery_type')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.surface')), ''), 'unknown')";
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\BackfillArtworkVectorIndexJob;
use Illuminate\Console\Command;
final class BackfillArtworkVectorIndexCommand extends Command
{
protected $signature = 'artworks:vectors-repair {--after-id=0 : Resume after this artwork id} {--batch=200 : Batch size for resumable fan-out} {--public-only : Repair only public, approved, published artworks} {--stale-hours=0 : Repair only artworks never indexed or older than this many hours}';
protected $description = 'Queue resumable vector gateway repair for artworks that already have local embeddings';
public function handle(): int
{
$afterId = max(0, (int) $this->option('after-id'));
$batch = max(1, min((int) $this->option('batch'), 1000));
$publicOnly = (bool) $this->option('public-only');
$staleHours = max(0, (int) $this->option('stale-hours'));
BackfillArtworkVectorIndexJob::dispatch($afterId, $batch, $publicOnly, $staleHours);
$this->info('Queued artwork vector repair (after_id=' . $afterId . ', batch=' . $batch . ', public_only=' . ($publicOnly ? 'yes' : 'no') . ', stale_hours=' . $staleHours . ').');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,571 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Models\UserActivity;
use App\Services\Activity\UserActivityService;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class BackfillUserActivitiesCommand extends Command
{
protected $signature = 'skinbase:backfill-user-activities
{--chunk=1000 : Number of source records to process per batch}
{--user-id= : Backfill only one actor user id}
{--types=all : Comma-separated groups: all, uploads, comments, likes, follows, achievements, forum}
{--dry-run : Preview inserts without writing changes}';
protected $description = 'Backfill historical profile activity into user_activities for existing users.';
public function __construct(private readonly UserActivityService $activities)
{
parent::__construct();
}
public function handle(): int
{
if (! Schema::hasTable('user_activities')) {
$this->error('The user_activities table does not exist. Run migrations first.');
return self::FAILURE;
}
$chunk = max(1, (int) $this->option('chunk'));
$userId = $this->option('user-id') !== null ? max(1, (int) $this->option('user-id')) : null;
$dryRun = (bool) $this->option('dry-run');
$groups = $this->parseGroups((string) $this->option('types'));
if ($groups === null) {
$this->error('Invalid --types value. Use one or more of: all, uploads, comments, likes, follows, achievements, forum.');
return self::FAILURE;
}
if ($userId !== null && ! User::query()->whereKey($userId)->exists()) {
$this->error("User id={$userId} was not found.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No activity rows will be inserted.');
}
$this->info('Backfilling historical profile activity.');
$summary = [];
foreach ($groups as $group) {
$groupSummary = match ($group) {
'uploads' => [
'uploads' => $this->backfillUploads($chunk, $userId, $dryRun),
],
'comments' => [
'comments' => $this->backfillArtworkComments($chunk, $userId, $dryRun),
],
'likes' => [
'likes' => $this->backfillArtworkLikes($chunk, $userId, $dryRun),
'favourites' => $this->backfillArtworkFavourites($chunk, $userId, $dryRun),
],
'follows' => [
'follows' => $this->backfillFollows($chunk, $userId, $dryRun),
],
'achievements' => [
'achievements' => $this->backfillAchievements($chunk, $userId, $dryRun),
],
'forum' => [
'forum_posts' => $this->backfillForumThreads($chunk, $userId, $dryRun),
'forum_replies' => $this->backfillForumReplies($chunk, $userId, $dryRun),
],
default => [],
};
$summary = [...$summary, ...$groupSummary];
}
foreach ($summary as $label => $stats) {
$this->line(sprintf(
'%s: processed=%d inserted=%d existing=%d skipped=%d',
$label,
(int) ($stats['processed'] ?? 0),
(int) ($stats['inserted'] ?? 0),
(int) ($stats['existing'] ?? 0),
(int) ($stats['skipped'] ?? 0),
));
}
$totalProcessed = array_sum(array_map(static fn (array $stats): int => (int) ($stats['processed'] ?? 0), $summary));
$totalInserted = array_sum(array_map(static fn (array $stats): int => (int) ($stats['inserted'] ?? 0), $summary));
$totalExisting = array_sum(array_map(static fn (array $stats): int => (int) ($stats['existing'] ?? 0), $summary));
$totalSkipped = array_sum(array_map(static fn (array $stats): int => (int) ($stats['skipped'] ?? 0), $summary));
$this->info(sprintf(
'Finished. processed=%d inserted=%d existing=%d skipped=%d',
$totalProcessed,
$totalInserted,
$totalExisting,
$totalSkipped,
));
return self::SUCCESS;
}
/**
* @return array<int, string>|null
*/
private function parseGroups(string $value): ?array
{
$items = collect(explode(',', strtolower(trim($value))))
->map(static fn (string $item): string => trim($item))
->filter()
->values();
if ($items->isEmpty() || $items->contains('all')) {
return ['uploads', 'comments', 'likes', 'follows', 'achievements', 'forum'];
}
$allowed = ['uploads', 'comments', 'likes', 'follows', 'achievements', 'forum'];
if ($items->contains(static fn (string $item): bool => ! in_array($item, $allowed, true))) {
return null;
}
return $items->unique()->values()->all();
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillUploads(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artworks')
->select(['id', 'user_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artworks.user_id'))
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->whereNull('deleted_at')
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'uploads',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_UPLOAD,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkComments(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_comments') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_comments')
->select(['id', 'user_id', 'parent_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_comments.user_id'))
->where('is_approved', true)
->whereNull('deleted_at')
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_comments.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'comments',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => $row->parent_id ? UserActivity::TYPE_REPLY : UserActivity::TYPE_COMMENT,
'entity_type' => UserActivity::ENTITY_ARTWORK_COMMENT,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkLikes(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_likes') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_likes')
->select(['id', 'user_id', 'artwork_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_likes.user_id'))
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_likes.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'likes',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_LIKE,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->artwork_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkFavourites(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_favourites') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_favourites')
->select(['id', 'user_id', 'artwork_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_favourites.user_id'))
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_favourites.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'favourites',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FAVOURITE,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->artwork_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillFollows(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('user_followers')) {
return $this->emptyStats();
}
$query = DB::table('user_followers')
->select(['id', 'follower_id', 'user_id', 'created_at'])
->where('follower_id', '>', 0)
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('user_followers.follower_id'))
->whereExists($this->existingUserSubquery('user_followers.user_id'))
->when($userId !== null, fn (Builder $builder) => $builder->where('follower_id', $userId));
return $this->backfillRows(
label: 'follows',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->follower_id,
'type' => UserActivity::TYPE_FOLLOW,
'entity_type' => UserActivity::ENTITY_USER,
'entity_id' => (int) $row->user_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillAchievements(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('user_achievements')) {
return $this->emptyStats();
}
$query = DB::table('user_achievements')
->select(['id', 'user_id', 'achievement_id', 'unlocked_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('user_achievements.user_id'))
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'achievements',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_ACHIEVEMENT,
'entity_type' => UserActivity::ENTITY_ACHIEVEMENT,
'entity_id' => (int) $row->achievement_id,
'meta' => null,
'created_at' => $row->unlocked_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillForumThreads(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('forum_threads')) {
return $this->emptyStats();
}
$query = DB::table('forum_threads')
->select(['id', 'user_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('forum_threads.user_id'))
->where('visibility', 'public')
->whereNull('deleted_at')
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'forum_posts',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FORUM_POST,
'entity_type' => UserActivity::ENTITY_FORUM_THREAD,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillForumReplies(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('forum_posts') || ! Schema::hasTable('forum_threads')) {
return $this->emptyStats();
}
$query = DB::table('forum_posts')
->select(['forum_posts.id', 'forum_posts.user_id', 'forum_posts.created_at'])
->join('forum_threads', 'forum_threads.id', '=', 'forum_posts.thread_id')
->where('forum_posts.user_id', '>', 0)
->whereExists($this->existingUserSubquery('forum_posts.user_id'))
->whereNull('forum_posts.deleted_at')
->where('forum_threads.visibility', 'public')
->whereNull('forum_threads.deleted_at')
->whereRaw('forum_posts.id <> (SELECT MIN(fp2.id) FROM forum_posts as fp2 WHERE fp2.thread_id = forum_posts.thread_id)')
->when(Schema::hasColumn('forum_posts', 'flagged'), fn (Builder $builder) => $builder->where('forum_posts.flagged', false))
->when($userId !== null, fn (Builder $builder) => $builder->where('forum_posts.user_id', $userId));
return $this->backfillRows(
label: 'forum_replies',
query: $query,
chunk: $chunk,
chunkColumn: 'forum_posts.id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FORUM_REPLY,
'entity_type' => UserActivity::ENTITY_FORUM_POST,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
chunkAlias: 'id',
);
}
/**
* @param callable(object): ?array{user_id:int,type:string,entity_type:string,entity_id:int,meta:?array,created_at:mixed} $mapper
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillRows(
string $label,
Builder $query,
int $chunk,
string $chunkColumn,
callable $mapper,
bool $dryRun,
?string $chunkAlias = null,
): array {
$stats = $this->emptyStats();
$query->chunkById($chunk, function (Collection $rows) use (&$stats, $mapper, $dryRun): void {
$stats['processed'] += $rows->count();
$entries = $rows
->map($mapper)
->filter(static fn (?array $entry): bool => $entry !== null && (int) ($entry['user_id'] ?? 0) > 0 && (int) ($entry['entity_id'] ?? 0) > 0 && ! empty($entry['created_at']))
->values();
if ($entries->isEmpty()) {
$stats['skipped'] += $rows->count();
return;
}
$existing = $this->existingKeysForEntries($entries);
$pending = [];
foreach ($entries as $entry) {
$key = $this->entryKey($entry['user_id'], $entry['type'], $entry['entity_type'], $entry['entity_id']);
if (isset($existing[$key])) {
$stats['existing']++;
continue;
}
$pending[] = [
'user_id' => (int) $entry['user_id'],
'type' => (string) $entry['type'],
'entity_type' => (string) $entry['entity_type'],
'entity_id' => (int) $entry['entity_id'],
'meta' => $entry['meta'] !== null
? json_encode($entry['meta'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
: null,
'created_at' => $entry['created_at'],
];
}
if ($pending === []) {
return;
}
if ($dryRun) {
$stats['inserted'] += count($pending);
return;
}
DB::table('user_activities')->insert($pending);
$stats['inserted'] += count($pending);
collect($pending)
->pluck('user_id')
->unique()
->each(fn (int $userId): bool => tap(true, fn () => $this->activities->invalidateUserFeed($userId)));
}, $chunkColumn, $chunkAlias);
$this->line(sprintf('%s backfill complete.', $label));
return $stats;
}
/**
* @param Collection<int, array{user_id:int,type:string,entity_type:string,entity_id:int,meta:?array,created_at:mixed}> $entries
* @return array<string, true>
*/
private function existingKeysForEntries(Collection $entries): array
{
$existing = [];
$entries
->groupBy(fn (array $entry): string => $entry['type'] . '|' . $entry['entity_type'])
->each(function (Collection $groupedEntries, string $groupKey) use (&$existing): void {
[$type, $entityType] = explode('|', $groupKey, 2);
$userIds = $groupedEntries->pluck('user_id')->unique()->values()->all();
$entityIds = $groupedEntries->pluck('entity_id')->unique()->values()->all();
DB::table('user_activities')
->select(['user_id', 'entity_id'])
->where('type', $type)
->where('entity_type', $entityType)
->whereIn('user_id', $userIds)
->whereIn('entity_id', $entityIds)
->get()
->each(function (object $row) use (&$existing, $type, $entityType): void {
$existing[$this->entryKey((int) $row->user_id, $type, $entityType, (int) $row->entity_id)] = true;
});
});
return $existing;
}
private function entryKey(int $userId, string $type, string $entityType, int $entityId): string
{
return $userId . ':' . $type . ':' . $entityType . ':' . $entityId;
}
private function existingUserSubquery(string $column): \Closure
{
return static function ($subquery) use ($column): void {
$subquery->selectRaw('1')
->from('users')
->whereColumn('users.id', $column);
};
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function emptyStats(): array
{
return [
'processed' => 0,
'inserted' => 0,
'existing' => 0,
'skipped' => 0,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\CollectionBackgroundJobService;
use Illuminate\Console\Command;
class DispatchCollectionMaintenanceCommand extends Command
{
protected $signature = 'collections:dispatch-maintenance
{--health : Dispatch health and eligibility refresh jobs}
{--recommendations : Dispatch recommendation refresh jobs}
{--duplicates : Dispatch duplicate scan jobs}';
protected $description = 'Dispatch queued collection maintenance jobs for health, recommendation, and duplicate workflows.';
public function handle(CollectionBackgroundJobService $jobs): int
{
$runHealth = (bool) $this->option('health');
$runRecommendations = (bool) $this->option('recommendations');
$runDuplicates = (bool) $this->option('duplicates');
if (! $runHealth && ! $runRecommendations && ! $runDuplicates) {
$runHealth = true;
$runRecommendations = true;
$runDuplicates = true;
}
$summary = $jobs->dispatchScheduledMaintenance($runHealth, $runRecommendations, $runDuplicates);
foreach ($summary as $key => $payload) {
$this->info(sprintf('%s: %d queued.', ucfirst((string) $key), (int) ($payload['count'] ?? 0)));
}
return self::SUCCESS;
}
}

View File

@@ -5,9 +5,7 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use App\Services\Vision\VectorService;
use Illuminate\Console\Command;
final class IndexArtworkVectorsCommand extends Command
@@ -17,15 +15,16 @@ final class IndexArtworkVectorsCommand extends Command
{--after-id=0 : Resume after this artwork id}
{--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}
{--public-only : Index only public, approved, published artworks}
{--dry-run : Preview requests without sending them}';
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
public function handle(VectorService $vectors): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $client->isConfigured()) {
if (! $dryRun && ! $vectors->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
@@ -36,6 +35,7 @@ final class IndexArtworkVectorsCommand extends Command
$limit = max(0, (int) $this->option('limit'));
$publicOnly = (bool) $this->option('public-only');
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
$embeddedOnly = (bool) $this->option('embedded-only');
$processed = 0;
$indexed = 0;
@@ -52,12 +52,13 @@ final class IndexArtworkVectorsCommand extends Command
}
$this->info(sprintf(
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s public_only=%s dry_run=%s',
'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',
$startId,
$afterId,
$nextId,
$batch,
$limit > 0 ? (string) $limit : 'all',
$embeddedOnly ? 'yes' : 'no',
$publicOnly ? 'yes' : 'no',
$dryRun ? 'yes' : 'no'
));
@@ -77,6 +78,10 @@ final class IndexArtworkVectorsCommand extends Command
->orderBy('id')
->limit($take);
if ($embeddedOnly) {
$query->whereHas('embeddings');
}
if ($publicOnly) {
$query->public()->published();
}
@@ -99,21 +104,21 @@ final class IndexArtworkVectorsCommand extends Command
$lastId = (int) $artwork->id;
$nextId = $lastId + 1;
$url = $imageUrl->fromArtwork($artwork);
if ($url === null) {
try {
$payload = $vectors->payloadForArtwork($artwork);
} catch (\Throwable $e) {
$skipped++;
$this->warn("Skipped artwork {$artwork->id}: no vision image URL could be generated.");
$this->warn("Skipped artwork {$artwork->id}: {$e->getMessage()}");
continue;
}
$metadata = $this->metadataForArtwork($artwork);
$this->line(sprintf(
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
(int) $artwork->id,
(string) ($artwork->hash ?? ''),
(string) ($artwork->thumb_ext ?? ''),
$url,
$this->json($metadata)
$payload['url'],
$this->json($payload['metadata'])
));
if ($dryRun) {
@@ -128,7 +133,7 @@ final class IndexArtworkVectorsCommand extends Command
}
try {
$client->upsertByUrl($url, (int) $artwork->id, $metadata);
$vectors->upsertArtwork($artwork);
$indexed++;
$this->info(sprintf(
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
@@ -159,26 +164,4 @@ final class IndexArtworkVectorsCommand extends Command
return is_string($json) ? $json : '{}';
}
/**
* @return array{content_type: string, category: string, user_id: string}
*/
private function metadataForArtwork(Artwork $artwork): array
{
$category = $this->primaryCategory($artwork);
return [
'content_type' => (string) ($category?->contentType?->name ?? ''),
'category' => (string) ($category?->name ?? ''),
'user_id' => (string) ($artwork->user_id ?? ''),
];
}
private function primaryCategory(Artwork $artwork): ?Category
{
/** @var Category|null $category */
$category = $artwork->categories->sortBy('sort_order')->first();
return $category;
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\ActivityEvent;
use App\Services\Activity\UserActivityService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -79,7 +80,7 @@ class PublishScheduledArtworksCommand extends Command
return;
}
$artwork->is_public = true;
$artwork->is_public = $artwork->visibility !== Artwork::VISIBILITY_PRIVATE;
$artwork->published_at = $now;
$artwork->artwork_status = 'published';
$artwork->save();
@@ -103,6 +104,10 @@ class PublishScheduledArtworksCommand extends Command
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logUpload((int) $artwork->user_id, (int) $artwork->id);
} catch (\Throwable) {}
$published++;
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
});

View File

@@ -8,12 +8,12 @@ use App\Services\TrendingService;
use Illuminate\Console\Command;
/**
* php artisan skinbase:recalculate-trending [--period=24h|7d] [--chunk=1000] [--skip-index]
* php artisan skinbase:recalculate-trending [--period=1h|24h|7d] [--chunk=1000] [--skip-index]
*/
class RecalculateTrendingCommand extends Command
{
protected $signature = 'skinbase:recalculate-trending
{--period=7d : Period to recalculate (24h or 7d). Use "all" to run both.}
{--period=7d : Period to recalculate (1h, 24h or 7d). Use "all" to run all three.}
{--chunk=1000 : DB chunk size}
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
@@ -30,11 +30,11 @@ class RecalculateTrendingCommand extends Command
$chunkSize = (int) $this->option('chunk');
$skipIndex = (bool) $this->option('skip-index');
$periods = $period === 'all' ? ['24h', '7d'] : [$period];
$periods = $period === 'all' ? ['1h', '24h', '7d'] : [$period];
foreach ($periods as $p) {
if (! in_array($p, ['24h', '7d'], true)) {
$this->error("Invalid period '{$p}'. Use 24h, 7d, or all.");
if (! in_array($p, ['1h', '24h', '7d'], true)) {
$this->error("Invalid period '{$p}'. Use 1h, 24h, 7d, or all.");
return self::FAILURE;
}

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class RepairLegacyUserJoinDatesCommand extends Command
{
/** @var array<string, bool> */
private array $legacyTableExistsCache = [];
protected $signature = 'skinbase:repair-user-join-dates
{--chunk=500 : Number of users to process per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=users : Legacy users table name}
{--only-null : Update only users whose current created_at is null}
{--dry-run : Preview join date updates without writing changes}';
protected $description = 'Backfill current users.created_at from legacy users.joinDate';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$legacyConnection = (string) $this->option('legacy-connection');
$legacyTable = (string) $this->option('legacy-table');
$onlyNull = (bool) $this->option('only-null');
$dryRun = (bool) $this->option('dry-run');
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$query = DB::table('users')->select(['id', 'created_at']);
if ($onlyNull) {
$query->whereNull('created_at');
}
$this->info('Scanning current users for legacy joinDate backfill.');
$processed = 0;
$matched = 0;
$updated = 0;
$unchanged = 0;
$skipped = 0;
$query
->chunkById($chunk, function (Collection $rows) use (
&$processed,
&$matched,
&$updated,
&$unchanged,
&$skipped,
$legacyConnection,
$legacyTable,
$dryRun
): void {
$legacyById = $this->loadLegacyUsersForChunk($rows, $legacyConnection, $legacyTable);
$activityById = $this->loadLegacyActivityDatesForChunk($rows, $legacyConnection);
foreach ($rows as $row) {
$processed++;
$legacyMatch = $legacyById[(int) $row->id] ?? null;
if ($legacyMatch === null) {
$skipped++;
continue;
}
$matched++;
$legacyJoinDate = $this->parseLegacyJoinDate($legacyMatch->joinDate ?? null);
$dateSource = 'joinDate';
if ($legacyJoinDate === null) {
$activityFallback = $activityById[(int) $row->id] ?? null;
$legacyJoinDate = $activityFallback['date'] ?? null;
$dateSource = $activityFallback['source'] ?? 'activity';
}
if ($legacyJoinDate === null) {
$skipped++;
continue;
}
$currentCreatedAt = $this->parseCurrentDate($row->created_at ?? null);
if ($currentCreatedAt !== null && $currentCreatedAt->equalTo($legacyJoinDate)) {
$unchanged++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry] Would update user id=%d created_at %s => %s (%s)',
(int) $row->id,
$currentCreatedAt?->toDateTimeString() ?? '<null>',
$legacyJoinDate->toDateTimeString(),
$dateSource
));
$updated++;
continue;
}
$affected = DB::table('users')
->where('id', (int) $row->id)
->update([
'created_at' => $legacyJoinDate->toDateTimeString(),
]);
if ($affected > 0) {
$updated += $affected;
$this->line(sprintf(
'[update] user id=%d created_at => %s (%s)',
(int) $row->id,
$legacyJoinDate->toDateTimeString(),
$dateSource
));
}
}
}, 'id');
$this->info(sprintf(
'Finished. processed=%d matched=%d updated=%d unchanged=%d skipped=%d',
$processed,
$matched,
$updated,
$unchanged,
$skipped
));
if ($processed === 0) {
$this->info('No users matched the requested scope.');
}
return self::SUCCESS;
}
private function legacyTableExists(string $connection, string $table): bool
{
$cacheKey = strtolower($connection . ':' . $table);
if (array_key_exists($cacheKey, $this->legacyTableExistsCache)) {
return $this->legacyTableExistsCache[$cacheKey];
}
try {
return $this->legacyTableExistsCache[$cacheKey] = DB::connection($connection)->getSchemaBuilder()->hasTable($table);
} catch (\Throwable) {
return $this->legacyTableExistsCache[$cacheKey] = false;
}
}
/**
* @return array<int, object>
*/
private function loadLegacyUsersForChunk(Collection $rows, string $legacyConnection, string $legacyTable): array
{
$legacyById = [];
$ids = $rows
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
if ($ids !== []) {
DB::connection($legacyConnection)
->table($legacyTable)
->select(['user_id', 'joinDate'])
->whereIn('user_id', $ids)
->get()
->each(function (object $legacyRow) use (&$legacyById): void {
$legacyById[(int) $legacyRow->user_id] = $legacyRow;
});
}
return $legacyById;
}
/**
* @return array<int, array{date: Carbon, source: string}>
*/
private function loadLegacyActivityDatesForChunk(Collection $rows, string $legacyConnection): array
{
$activityById = [];
$ids = $rows
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
if ($ids === []) {
return $activityById;
}
if ($this->legacyTableExists($legacyConnection, 'wallz')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('wallz')
->selectRaw('user_id, MIN(datum) as first_at')
->whereIn('user_id', $ids)
->whereRaw("datum IS NOT NULL AND datum <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first upload'
);
}
if ($this->legacyTableExists($legacyConnection, 'forum_topics')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('forum_topics')
->selectRaw('user_id, MIN(post_date) as first_at')
->whereIn('user_id', $ids)
->whereRaw("post_date <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first forum topic'
);
}
if ($this->legacyTableExists($legacyConnection, 'forum_posts')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('forum_posts')
->selectRaw('user_id, MIN(post_date) as first_at')
->whereIn('user_id', $ids)
->whereRaw("post_date <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first forum post'
);
}
if ($this->legacyTableExists($legacyConnection, 'artworks_comments')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('artworks_comments')
->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at")
->whereIn('user_id', $ids)
->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'")
->groupBy('user_id')
->get(),
'first artwork comment'
);
}
if ($this->legacyTableExists($legacyConnection, 'users_comments')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('users_comments')
->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at")
->whereIn('user_id', $ids)
->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'")
->groupBy('user_id')
->get(),
'first profile comment'
);
}
return $activityById;
}
/**
* @param array<int, array{date: Carbon, source: string}> $activityById
*/
private function registerChunkActivityDates(array &$activityById, iterable $rows, string $source): void
{
foreach ($rows as $row) {
$candidate = $this->parseLegacyJoinDate($row->first_at ?? null);
if ($candidate === null) {
continue;
}
$userId = (int) ($row->user_id ?? 0);
if ($userId <= 0) {
continue;
}
$existing = $activityById[$userId]['date'] ?? null;
if ($existing === null || $candidate->lt($existing)) {
$activityById[$userId] = [
'date' => $candidate,
'source' => $source,
];
}
}
}
private function parseLegacyJoinDate(mixed $value): ?Carbon
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
private function parseCurrentDate(mixed $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
$raw = trim((string) ($value ?? ''));
if ($raw === '') {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -6,8 +6,7 @@ namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use App\Services\Vision\VectorService;
use Illuminate\Console\Command;
final class SearchArtworkVectorsCommand extends Command
@@ -18,9 +17,9 @@ final class SearchArtworkVectorsCommand extends Command
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
public function handle(VectorService $vectors): int
{
if (! $client->isConfigured()) {
if (! $vectors->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
@@ -37,26 +36,14 @@ final class SearchArtworkVectorsCommand extends Command
return self::FAILURE;
}
$url = $imageUrl->fromArtwork($artwork);
if ($url === null) {
$this->error("Artwork {$artworkId} does not have a usable CDN image URL.");
return self::FAILURE;
}
try {
$matches = $client->searchByUrl($url, $limit + 1);
$matches = $vectors->similarToArtwork($artwork, $limit);
} catch (\Throwable $e) {
$this->error('Vector search failed: ' . $e->getMessage());
return self::FAILURE;
}
$ids = collect($matches)
->map(fn (array $match): int => (int) $match['id'])
->filter(fn (int $id): bool => $id > 0 && $id !== $artworkId)
->unique()
->take($limit)
->values()
->all();
$ids = collect($matches)->pluck('id')->map(fn (mixed $id): int => (int) $id)->filter()->values()->all();
if ($ids === []) {
$this->warn('No similar artworks were returned by the vector gateway.');
@@ -74,7 +61,7 @@ final class SearchArtworkVectorsCommand extends Command
$rows = [];
foreach ($matches as $match) {
$matchId = (int) ($match['id'] ?? 0);
if ($matchId <= 0 || $matchId === $artworkId) {
if ($matchId <= 0) {
continue;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Collection;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionLifecycleService;
use App\Services\CollectionSurfaceService;
use Illuminate\Console\Command;
class SyncCollectionLifecycleCommand extends Command
{
protected $signature = 'collections:sync-lifecycle';
protected $description = 'Expire pending collection invites, sync collection lifecycle states, and deactivate expired placements.';
public function handle(CollectionCollaborationService $collaborators, CollectionLifecycleService $lifecycle, CollectionSurfaceService $surfaces): int
{
$expiredInvites = $collaborators->expirePendingInvites();
$lifecycleResults = $lifecycle->syncScheduledCollections();
$expiredPlacements = $surfaces->syncPlacements();
$unfeaturedCollections = Collection::query()
->where('is_featured', true)
->whereNotNull('unpublished_at')
->where('unpublished_at', '<=', now())
->update([
'is_featured' => false,
'featured_at' => null,
'updated_at' => now(),
]);
$this->info(sprintf(
'Expired %d pending invites; published %d scheduled collections; expired %d collections; unfeatured %d unpublished collections; deactivated %d expired placements.',
$expiredInvites,
(int) ($lifecycleResults['scheduled'] ?? 0),
(int) ($lifecycleResults['expired'] ?? 0),
$unfeaturedCollections,
$expiredPlacements,
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class TestObjectStorageUploadCommand extends Command
{
protected $signature = 'storage:test-upload
{--disk=s3 : Filesystem disk to test}
{--file= : Optional absolute or relative path to an existing local file to upload}
{--path= : Optional remote object key to use}
{--keep : Keep the uploaded test object instead of deleting it afterwards}';
protected $description = 'Upload a probe file to the configured object storage disk and verify that it was stored.';
public function handle(): int
{
$diskName = (string) $this->option('disk');
$diskConfig = config("filesystems.disks.{$diskName}");
if (! is_array($diskConfig)) {
$this->error("Filesystem disk [{$diskName}] is not configured.");
return self::FAILURE;
}
$this->line('Testing object storage upload.');
$this->line('Disk: '.$diskName);
$this->line('Driver: '.(string) ($diskConfig['driver'] ?? 'unknown'));
$this->line('Bucket: '.(string) ($diskConfig['bucket'] ?? 'n/a'));
$this->line('Region: '.(string) ($diskConfig['region'] ?? 'n/a'));
$this->line('Endpoint: '.((string) ($diskConfig['endpoint'] ?? '') !== '' ? (string) $diskConfig['endpoint'] : '[not set]'));
$this->line('Path style: '.((bool) ($diskConfig['use_path_style_endpoint'] ?? false) ? 'true' : 'false'));
if ((string) ($diskConfig['endpoint'] ?? '') === '') {
$this->warn('No endpoint is configured for this S3 disk. Many S3-compatible providers, including Contabo object storage, require AWS_ENDPOINT to be set.');
}
$remotePath = $this->resolveRemotePath();
$keepObject = (bool) $this->option('keep');
$sourceFile = $this->option('file');
$filesystem = Storage::disk($diskName);
try {
if (is_string($sourceFile) && trim($sourceFile) !== '') {
$localPath = $this->resolveLocalPath($sourceFile);
if ($localPath === null) {
$this->error('The file passed to --file does not exist.');
return self::FAILURE;
}
$stream = fopen($localPath, 'rb');
if ($stream === false) {
$this->error('Unable to open the local file for reading.');
return self::FAILURE;
}
try {
$written = $filesystem->put($remotePath, $stream);
} finally {
fclose($stream);
}
$sourceLabel = $localPath;
} else {
$contents = $this->buildProbeContents($diskName);
$written = $filesystem->put($remotePath, $contents);
$sourceLabel = '[generated probe payload]';
}
if ($written !== true) {
$this->error('Upload did not complete successfully. The storage driver returned a failure status.');
return self::FAILURE;
}
$exists = $filesystem->exists($remotePath);
$size = $exists ? $filesystem->size($remotePath) : null;
$this->info('Upload succeeded.');
$this->line('Source: '.$sourceLabel);
$this->line('Object key: '.$remotePath);
$this->line('Exists after upload: '.($exists ? 'yes' : 'no'));
if ($size !== null) {
$this->line('Stored size: '.number_format((int) $size).' bytes');
}
if (! $keepObject && $exists) {
$filesystem->delete($remotePath);
$this->line('Cleanup: deleted uploaded test object');
} elseif ($keepObject) {
$this->warn('Cleanup skipped because --keep was used.');
}
return $exists ? self::SUCCESS : self::FAILURE;
} catch (Throwable $exception) {
$this->error('Object storage test failed.');
$this->line($exception->getMessage());
return self::FAILURE;
}
}
private function resolveRemotePath(): string
{
$provided = trim((string) $this->option('path'));
if ($provided !== '') {
return ltrim(str_replace('\\', '/', $provided), '/');
}
return 'tests/object-storage/'.now()->format('Ymd-His').'-'.Str::random(10).'.txt';
}
private function resolveLocalPath(string $path): ?string
{
$trimmed = trim($path);
if ($trimmed === '') {
return null;
}
if (is_file($trimmed)) {
return $trimmed;
}
$relative = base_path($trimmed);
return is_file($relative) ? $relative : null;
}
private function buildProbeContents(string $diskName): string
{
return implode("\n", [
'Skinbase object storage upload test',
'disk='.$diskName,
'timestamp='.now()->toIso8601String(),
'app_url='.(string) config('app.url'),
'random='.Str::uuid()->toString(),
'',
]);
}
}