Commit workspace changes

This commit is contained in:
2026-04-05 19:42:33 +02:00
parent 148a3bbe43
commit 08ad757bcb
312 changed files with 35149 additions and 399 deletions

View File

@@ -6,6 +6,7 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkMetricSnapshotHourly;
use App\Models\Group;
use App\Models\Leaderboard;
use App\Models\Story;
use App\Models\StoryLike;
@@ -22,6 +23,11 @@ class LeaderboardService
private const CREATOR_STORE_LIMIT = 10000;
private const ENTITY_STORE_LIMIT = 500;
public function __construct(
private readonly GroupReputationService $groupReputation,
) {
}
public function calculateCreatorLeaderboard(string $period): int
{
$normalizedPeriod = $this->normalizePeriod($period);
@@ -52,6 +58,16 @@ class LeaderboardService
return $this->persistRows(Leaderboard::TYPE_STORY, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
}
public function calculateGroupLeaderboard(string $period): int
{
$normalizedPeriod = $this->normalizePeriod($period);
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
? $this->allTimeGroupRows()
: $this->windowedGroupRows($this->periodStart($normalizedPeriod));
return $this->persistRows(Leaderboard::TYPE_GROUP, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
}
public function refreshAll(): array
{
$results = [];
@@ -59,12 +75,14 @@ class LeaderboardService
foreach ([
Leaderboard::TYPE_CREATOR,
Leaderboard::TYPE_ARTWORK,
Leaderboard::TYPE_GROUP,
Leaderboard::TYPE_STORY,
] as $type) {
foreach ($this->periods() as $period) {
$results[$type][$period] = match ($type) {
Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period),
Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period),
Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period),
Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period),
};
}
@@ -83,14 +101,12 @@ class LeaderboardService
$this->cacheKey($normalizedType, $normalizedPeriod, $limit),
self::CACHE_TTL_SECONDS,
function () use ($normalizedType, $normalizedPeriod, $limit): array {
$items = Leaderboard::query()
->where('type', $normalizedType)
->where('period', $normalizedPeriod)
->orderByDesc('score')
->orderBy('entity_id')
->limit($limit)
->get(['entity_id', 'score'])
->values();
$items = $this->leaderboardRows($normalizedType, $normalizedPeriod, $limit);
if ($items->isEmpty()) {
$this->generateLeaderboard($normalizedType, $normalizedPeriod);
$items = $this->leaderboardRows($normalizedType, $normalizedPeriod, $limit);
}
if ($items->isEmpty()) {
return [
@@ -103,6 +119,7 @@ class LeaderboardService
$entities = match ($normalizedType) {
Leaderboard::TYPE_CREATOR => $this->creatorEntities($items->pluck('entity_id')->all()),
Leaderboard::TYPE_ARTWORK => $this->artworkEntities($items->pluck('entity_id')->all()),
Leaderboard::TYPE_GROUP => $this->groupEntities($items->pluck('entity_id')->all()),
Leaderboard::TYPE_STORY => $this->storyEntities($items->pluck('entity_id')->all()),
};
@@ -186,11 +203,34 @@ class LeaderboardService
return match (strtolower(trim($type))) {
'creator', 'creators' => Leaderboard::TYPE_CREATOR,
'artwork', 'artworks' => Leaderboard::TYPE_ARTWORK,
'group', 'groups' => Leaderboard::TYPE_GROUP,
'story', 'stories' => Leaderboard::TYPE_STORY,
default => Leaderboard::TYPE_CREATOR,
};
}
private function leaderboardRows(string $type, string $period, int $limit): Collection
{
return Leaderboard::query()
->where('type', $type)
->where('period', $period)
->orderByDesc('score')
->orderBy('entity_id')
->limit($limit)
->get(['entity_id', 'score'])
->values();
}
private function generateLeaderboard(string $type, string $period): void
{
match ($type) {
Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period),
Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period),
Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period),
Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period),
};
}
private function periodStart(string $period): CarbonImmutable
{
$now = CarbonImmutable::now();
@@ -465,6 +505,194 @@ class LeaderboardService
->values();
}
private function allTimeGroupRows(): Collection
{
$members = DB::table('group_members')
->select('group_id', DB::raw('COUNT(*) as members_count'))
->where('status', Group::STATUS_ACTIVE)
->groupBy('group_id');
$releases = DB::table('group_releases')
->select('group_id', DB::raw('COUNT(*) as releases_count'))
->where('visibility', 'public')
->where('status', 'released')
->groupBy('group_id');
$projects = DB::table('group_projects')
->select('group_id', DB::raw('COUNT(*) as projects_count'))
->where('visibility', 'public')
->whereIn('status', ['active', 'review', 'released'])
->groupBy('group_id');
$challenges = DB::table('group_challenges')
->select('group_id', DB::raw('COUNT(*) as challenges_count'))
->where('visibility', 'public')
->whereIn('status', ['published', 'active'])
->groupBy('group_id');
$events = DB::table('group_events')
->select('group_id', DB::raw('COUNT(*) as events_count'))
->where('visibility', 'public')
->where('status', 'published')
->groupBy('group_id');
$activity = DB::table('group_activity_items')
->select('group_id', DB::raw('COUNT(*) as activity_count'))
->where('visibility', 'public')
->groupBy('group_id');
return Group::query()
->from('groups')
->leftJoinSub($members, 'members', 'members.group_id', '=', 'groups.id')
->leftJoinSub($releases, 'releases', 'releases.group_id', '=', 'groups.id')
->leftJoinSub($projects, 'projects', 'projects.group_id', '=', 'groups.id')
->leftJoinSub($challenges, 'challenges', 'challenges.group_id', '=', 'groups.id')
->leftJoinSub($events, 'events', 'events.group_id', '=', 'groups.id')
->leftJoinSub($activity, 'activity', 'activity.group_id', '=', 'groups.id')
->public()
->select([
'groups.id',
'groups.followers_count',
'groups.artworks_count',
'groups.collections_count',
'groups.is_verified',
DB::raw('COALESCE(members.members_count, 0) as members_count'),
DB::raw('COALESCE(releases.releases_count, 0) as releases_count'),
DB::raw('COALESCE(projects.projects_count, 0) as projects_count'),
DB::raw('COALESCE(challenges.challenges_count, 0) as challenges_count'),
DB::raw('COALESCE(events.events_count, 0) as events_count'),
DB::raw('COALESCE(activity.activity_count, 0) as activity_count'),
])
->get()
->map(function ($row): array {
$score = ((int) $row->followers_count * 8)
+ ((int) $row->artworks_count * 10)
+ ((int) $row->collections_count * 6)
+ ((int) $row->members_count * 20)
+ ((int) $row->releases_count * 30)
+ ((int) $row->projects_count * 24)
+ ((int) $row->challenges_count * 18)
+ ((int) $row->events_count * 14)
+ ((int) $row->activity_count * 4)
+ ((bool) $row->is_verified ? 120 : 0);
return [
'entity_id' => (int) $row->id,
'score' => $score,
];
})
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function windowedGroupRows(CarbonImmutable $start): Collection
{
$follows = DB::table('group_follows')
->select('group_id', DB::raw('COUNT(*) as follows_count'))
->where('created_at', '>=', $start)
->groupBy('group_id');
$artworks = DB::table('artworks')
->select('group_id', DB::raw('COUNT(*) as artworks_count'))
->whereNotNull('group_id')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->where('published_at', '>=', $start)
->groupBy('group_id');
$releases = DB::table('group_releases')
->select('group_id', DB::raw('COUNT(*) as releases_count'))
->where('visibility', 'public')
->where('status', 'released')
->where('released_at', '>=', $start)
->groupBy('group_id');
$projects = DB::table('group_projects')
->select('group_id', DB::raw('COUNT(*) as projects_count'))
->where('visibility', 'public')
->whereIn('status', ['active', 'review', 'released'])
->where('updated_at', '>=', $start)
->groupBy('group_id');
$challenges = DB::table('group_challenges')
->select('group_id', DB::raw('COUNT(*) as challenges_count'))
->where('visibility', 'public')
->whereIn('status', ['published', 'active'])
->where(function ($query) use ($start): void {
$query->where('updated_at', '>=', $start)
->orWhere('start_at', '>=', $start)
->orWhere('created_at', '>=', $start);
})
->groupBy('group_id');
$events = DB::table('group_events')
->select('group_id', DB::raw('COUNT(*) as events_count'))
->where('visibility', 'public')
->where('status', 'published')
->where(function ($query) use ($start): void {
$query->where('published_at', '>=', $start)
->orWhere('start_at', '>=', $start)
->orWhere('updated_at', '>=', $start);
})
->groupBy('group_id');
$activity = DB::table('group_activity_items')
->select('group_id', DB::raw('COUNT(*) as activity_count'))
->where('visibility', 'public')
->where('occurred_at', '>=', $start)
->groupBy('group_id');
$members = DB::table('group_members')
->select('group_id', DB::raw('COUNT(*) as members_count'))
->where('status', Group::STATUS_ACTIVE)
->groupBy('group_id');
return Group::query()
->from('groups')
->leftJoinSub($follows, 'follows', 'follows.group_id', '=', 'groups.id')
->leftJoinSub($artworks, 'artworks', 'artworks.group_id', '=', 'groups.id')
->leftJoinSub($releases, 'releases', 'releases.group_id', '=', 'groups.id')
->leftJoinSub($projects, 'projects', 'projects.group_id', '=', 'groups.id')
->leftJoinSub($challenges, 'challenges', 'challenges.group_id', '=', 'groups.id')
->leftJoinSub($events, 'events', 'events.group_id', '=', 'groups.id')
->leftJoinSub($activity, 'activity', 'activity.group_id', '=', 'groups.id')
->leftJoinSub($members, 'members', 'members.group_id', '=', 'groups.id')
->public()
->select([
'groups.id',
'groups.is_verified',
DB::raw('COALESCE(follows.follows_count, 0) as follows_count'),
DB::raw('COALESCE(artworks.artworks_count, 0) as artworks_count'),
DB::raw('COALESCE(releases.releases_count, 0) as releases_count'),
DB::raw('COALESCE(projects.projects_count, 0) as projects_count'),
DB::raw('COALESCE(challenges.challenges_count, 0) as challenges_count'),
DB::raw('COALESCE(events.events_count, 0) as events_count'),
DB::raw('COALESCE(activity.activity_count, 0) as activity_count'),
DB::raw('COALESCE(members.members_count, 0) as members_count'),
])
->get()
->map(function ($row): array {
$score = ((int) $row->follows_count * 18)
+ ((int) $row->artworks_count * 16)
+ ((int) $row->releases_count * 34)
+ ((int) $row->projects_count * 22)
+ ((int) $row->challenges_count * 20)
+ ((int) $row->events_count * 16)
+ ((int) $row->activity_count * 6)
+ ((int) $row->members_count * 8)
+ ((bool) $row->is_verified ? 45 : 0);
return [
'entity_id' => (int) $row->id,
'score' => $score,
];
})
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function artworkSnapshotDeltas(CarbonImmutable $start): \Illuminate\Database\Query\Builder
{
return ArtworkMetricSnapshotHourly::query()
@@ -472,15 +700,26 @@ class LeaderboardService
->where('snapshots.bucket_hour', '>=', $start)
->select([
'snapshots.artwork_id',
DB::raw('GREATEST(MAX(snapshots.views_count) - MIN(snapshots.views_count), 0) as views_delta'),
DB::raw('GREATEST(MAX(snapshots.downloads_count) - MIN(snapshots.downloads_count), 0) as downloads_delta'),
DB::raw('GREATEST(MAX(snapshots.favourites_count) - MIN(snapshots.favourites_count), 0) as favourites_delta'),
DB::raw('GREATEST(MAX(snapshots.comments_count) - MIN(snapshots.comments_count), 0) as comments_delta'),
DB::raw($this->nonNegativeSnapshotDelta('views_count', 'views_delta')),
DB::raw($this->nonNegativeSnapshotDelta('downloads_count', 'downloads_delta')),
DB::raw($this->nonNegativeSnapshotDelta('favourites_count', 'favourites_delta')),
DB::raw($this->nonNegativeSnapshotDelta('comments_count', 'comments_delta')),
])
->groupBy('snapshots.artwork_id')
->toBase();
}
private function nonNegativeSnapshotDelta(string $column, string $alias): string
{
$delta = sprintf('MAX(snapshots.%1$s) - MIN(snapshots.%1$s)', $column);
if (DB::connection()->getDriverName() === 'sqlite') {
return sprintf('CASE WHEN %1$s > 0 THEN %1$s ELSE 0 END as %2$s', $delta, $alias);
}
return sprintf('GREATEST(%s, 0) as %s', $delta, $alias);
}
private function creatorEntities(array $ids): array
{
return User::query()
@@ -550,4 +789,34 @@ class LeaderboardService
])
->all();
}
private function groupEntities(array $ids): array
{
return Group::query()
->with(['owner.profile', 'recruitmentProfile', 'badges', 'members'])
->whereIn('id', $ids)
->public()
->get()
->mapWithKeys(function (Group $group): array {
return [
(int) $group->id => [
'id' => (int) $group->id,
'type' => Leaderboard::TYPE_GROUP,
'name' => (string) $group->name,
'headline' => (string) ($group->headline ?? ''),
'url' => $group->publicUrl(),
'avatar' => $group->avatarUrl(),
'image' => $group->bannerUrl() ?: $group->avatarUrl(),
'followers_count' => (int) ($group->followers_count ?? 0),
'artworks_count' => (int) ($group->artworks_count ?? 0),
'collections_count' => (int) ($group->collections_count ?? 0),
'members_count' => (int) $group->members->where('status', Group::STATUS_ACTIVE)->count(),
'is_recruiting' => (bool) ($group->recruitmentProfile?->is_recruiting ?? false),
'trust_signals' => $this->groupReputation->trustSignals($group),
'badges' => $this->groupReputation->groupBadges($group, 3),
],
];
})
->all();
}
}