optimizations
This commit is contained in:
103
app/Console/Commands/AggregateDiscoveryFeedbackCommand.php
Normal file
103
app/Console/Commands/AggregateDiscoveryFeedbackCommand.php
Normal 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')";
|
||||
}
|
||||
}
|
||||
29
app/Console/Commands/BackfillArtworkVectorIndexCommand.php
Normal file
29
app/Console/Commands/BackfillArtworkVectorIndexCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
571
app/Console/Commands/BackfillUserActivitiesCommand.php
Normal file
571
app/Console/Commands/BackfillUserActivitiesCommand.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}\"");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
342
app/Console/Commands/RepairLegacyUserJoinDatesCommand.php
Normal file
342
app/Console/Commands/RepairLegacyUserJoinDatesCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
46
app/Console/Commands/SyncCollectionLifecycleCommand.php
Normal file
46
app/Console/Commands/SyncCollectionLifecycleCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
149
app/Console/Commands/TestObjectStorageUploadCommand.php
Normal file
149
app/Console/Commands/TestObjectStorageUploadCommand.php
Normal 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(),
|
||||
'',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user