storing analytics data
This commit is contained in:
@@ -175,8 +175,8 @@ final class ArtworkSearchService
|
||||
// ── Discover section helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trending: most viewed artworks, weighted toward recent uploads.
|
||||
* Uses views:desc + recency via created_at:desc as tiebreaker.
|
||||
* Trending: sorted by pre-computed trending_score_24h (recalculated every 30 min).
|
||||
* Falls back to views:desc if the column is not yet populated.
|
||||
*/
|
||||
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
@@ -185,7 +185,7 @@ final class ArtworkSearchService
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['views:desc', 'created_at:desc'],
|
||||
'sort' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'views:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
@@ -239,6 +239,64 @@ final class ArtworkSearchService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Artworks matching any of the given tag slugs, sorted by trending score.
|
||||
* Used for personalized "Because you like {tags}" homepage section.
|
||||
*
|
||||
* @param string[] $tagSlugs
|
||||
*/
|
||||
public function discoverByTags(array $tagSlugs, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
if (empty($tagSlugs)) {
|
||||
return $this->popular($limit);
|
||||
}
|
||||
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||
array_slice($tagSlugs, 0, 5)
|
||||
));
|
||||
|
||||
$cacheKey = 'discover.by-tags.' . md5(implode(',', $tagSlugs));
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
|
||||
'sort' => ['trending_score_7d:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh artworks in given categories, sorted by created_at desc.
|
||||
* Used for personalized "Fresh in your favourite categories" section.
|
||||
*
|
||||
* @param string[] $categorySlugs
|
||||
*/
|
||||
public function discoverByCategories(array $categorySlugs, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
if (empty($categorySlugs)) {
|
||||
return $this->recent($limit);
|
||||
}
|
||||
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
array_slice($categorySlugs, 0, 3)
|
||||
));
|
||||
|
||||
$cacheKey = 'discover.by-cats.' . md5(implode(',', $categorySlugs));
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
||||
'sort' => ['created_at:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function parseSort(string $sort): array
|
||||
|
||||
@@ -23,26 +23,56 @@ class ArtworkStatsService
|
||||
/**
|
||||
* Increment views for an artwork.
|
||||
* Set $defer=true to push to Redis for async processing when available.
|
||||
* Both all-time (views) and windowed (views_24h, views_7d) are updated.
|
||||
*/
|
||||
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
$this->pushDelta($artworkId, 'views_24h', $by);
|
||||
$this->pushDelta($artworkId, 'views_7d', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['views' => $by]);
|
||||
$this->applyDelta($artworkId, ['views' => $by, 'views_24h' => $by, 'views_7d' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment downloads for an artwork.
|
||||
* Both all-time (downloads) and windowed (downloads_24h, downloads_7d) are updated.
|
||||
*/
|
||||
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
|
||||
{
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
$this->pushDelta($artworkId, 'downloads_24h', $by);
|
||||
$this->pushDelta($artworkId, 'downloads_7d', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['downloads' => $by]);
|
||||
$this->applyDelta($artworkId, ['downloads' => $by, 'downloads_24h' => $by, 'downloads_7d' => $by]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one row to artwork_view_events (the persistent event log).
|
||||
*
|
||||
* Called from ArtworkViewController after session dedup passes.
|
||||
* Guests (unauthenticated) are recorded with user_id = null.
|
||||
* Rows are pruned after 90 days by skinbase:prune-view-events.
|
||||
*/
|
||||
public function logViewEvent(int $artworkId, ?int $userId): void
|
||||
{
|
||||
try {
|
||||
DB::table('artwork_view_events')->insert([
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'viewed_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to write artwork_view_events row', [
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,17 +105,21 @@ class ArtworkStatsService
|
||||
DB::transaction(function () use ($artworkId, $deltas) {
|
||||
// Ensure a stats row exists — insert default zeros if missing.
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artworkId,
|
||||
'views' => 0,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
'artwork_id' => $artworkId,
|
||||
'views' => 0,
|
||||
'views_24h' => 0,
|
||||
'views_7d' => 0,
|
||||
'downloads' => 0,
|
||||
'downloads_24h' => 0,
|
||||
'downloads_7d' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
foreach ($deltas as $column => $value) {
|
||||
// Only allow known columns to avoid SQL injection.
|
||||
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
|
||||
if (! in_array($column, ['views', 'views_24h', 'views_7d', 'downloads', 'downloads_24h', 'downloads_7d', 'favorites', 'rating_count'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,18 @@ final class FollowService
|
||||
$this->incrementCounter($targetId, 'followers_count');
|
||||
});
|
||||
|
||||
// Record activity event outside the transaction to avoid deadlocks
|
||||
if ($inserted) {
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $actorId,
|
||||
type: \App\Models\ActivityEvent::TYPE_FOLLOW,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_USER,
|
||||
targetId: $targetId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\UserPreferenceService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -23,7 +25,11 @@ final class HomepageService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(private readonly ArtworkService $artworks) {}
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworks,
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly UserPreferenceService $prefs,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public aggregator
|
||||
@@ -44,6 +50,36 @@ final class HomepageService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Personalized homepage data for an authenticated user.
|
||||
*
|
||||
* Sections:
|
||||
* 1. from_following – artworks from creators you follow
|
||||
* 2. trending – same trending feed as guests
|
||||
* 3. by_tags – artworks matching user's top tags
|
||||
* 4. by_categories – fresh uploads in user's favourite categories
|
||||
* 5. tags / creators / news – shared with guest homepage
|
||||
*/
|
||||
public function allForUser(\App\Models\User $user): array
|
||||
{
|
||||
$prefs = $this->prefs->build($user);
|
||||
|
||||
return [
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||||
'trending' => $this->getTrending(),
|
||||
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
||||
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
|
||||
'tags' => $this->getPopularTags(),
|
||||
'creators' => $this->getCreatorSpotlight(),
|
||||
'news' => $this->getNews(),
|
||||
'preferences' => [
|
||||
'top_tags' => $prefs['top_tags'] ?? [],
|
||||
'top_categories' => $prefs['top_categories'] ?? [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Sections
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -72,54 +108,61 @@ final class HomepageService
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending: up to 12 artworks ordered by award score, views, downloads, recent activity.
|
||||
* Trending: up to 12 artworks sorted by pre-computed trending_score_7d.
|
||||
*
|
||||
* Award score = SUM(weight × medal_value) where gold=3, silver=2, bronze=1.
|
||||
* Uses correlated subqueries to avoid GROUP BY issues with MySQL strict mode.
|
||||
* Uses Meilisearch sorted by the pre-computed score (updated every 30 min).
|
||||
* Falls back to DB ORDER BY trending_score_7d if Meilisearch is unavailable.
|
||||
* Spec: no heavy joins in the hot path.
|
||||
*/
|
||||
public function getTrending(int $limit = 12): array
|
||||
{
|
||||
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
|
||||
$ids = DB::table('artworks')
|
||||
->select('id')
|
||||
->selectRaw(
|
||||
'(SELECT COALESCE(SUM(weight * CASE medal'
|
||||
. ' WHEN \'gold\' THEN 3'
|
||||
. ' WHEN \'silver\' THEN 2'
|
||||
. ' ELSE 1 END), 0)'
|
||||
. ' FROM artwork_awards WHERE artwork_awards.artwork_id = artworks.id) AS award_score'
|
||||
)
|
||||
->selectRaw('COALESCE((SELECT views FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_views')
|
||||
->selectRaw('COALESCE((SELECT downloads FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_downloads')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->orderByDesc('award_score')
|
||||
->orderByDesc('stat_views')
|
||||
->orderByDesc('stat_downloads')
|
||||
->orderByDesc('published_at')
|
||||
->limit($limit)
|
||||
->pluck('id');
|
||||
try {
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'],
|
||||
])
|
||||
->paginate($limit, 'page', 1);
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return [];
|
||||
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
return $this->getTrendingFromDb($limit);
|
||||
}
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->getTrendingFromDb($limit);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->whereIn('id', $ids)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $ids
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $this->serializeArtwork($indexed[$id]))
|
||||
->values()
|
||||
->all();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DB-only fallback for trending (Meilisearch unavailable).
|
||||
* Uses pre-computed trending_score_7d column — no correlated subqueries.
|
||||
*/
|
||||
private function getTrendingFromDb(int $limit): array
|
||||
{
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->orderByDesc('trending_score_7d')
|
||||
->orderByDesc('trending_score_24h')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh uploads: latest 12 approved public artworks.
|
||||
*/
|
||||
@@ -268,6 +311,84 @@ final class HomepageService
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Personalized sections (auth only)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Latest artworks from creators the user follows (max 12).
|
||||
*/
|
||||
public function getFollowingFeed(\App\Models\User $user, array $prefs): array
|
||||
{
|
||||
$followingIds = $prefs['followed_creators'] ?? [];
|
||||
|
||||
if (empty($followingIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Cache::remember(
|
||||
"homepage.following.{$user->id}",
|
||||
60, // short TTL – personal data
|
||||
function () use ($followingIds): array {
|
||||
$artworks = Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->whereIn('user_id', $followingIds)
|
||||
->orderByDesc('published_at')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artworks matching the user's top tags (max 12).
|
||||
* Powered by Meilisearch.
|
||||
*/
|
||||
public function getByTags(array $tagSlugs): array
|
||||
{
|
||||
if (empty($tagSlugs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->search->discoverByTags($tagSlugs, 12);
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh artworks in the user's favourite categories (max 12).
|
||||
* Powered by Meilisearch.
|
||||
*/
|
||||
public function getByCategories(array $categorySlugs): array
|
||||
{
|
||||
if (empty($categorySlugs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->search->discoverByCategories($categorySlugs, 12);
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
132
app/Services/TrendingService.php
Normal file
132
app/Services/TrendingService.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* TrendingService
|
||||
*
|
||||
* Calculates and persists deterministic trending scores for artworks.
|
||||
*
|
||||
* Formula (Phase 1):
|
||||
* score = (award_score * 5)
|
||||
* + (favorites_count * 3)
|
||||
* + (reactions_count * 2)
|
||||
* + (downloads_count * 1)
|
||||
* + (views * 2)
|
||||
* - (hours_since_published * 0.1)
|
||||
*
|
||||
* The score is stored in artworks.trending_score_24h (artworks ≤ 7 days old)
|
||||
* and artworks.trending_score_7d (artworks ≤ 30 days old).
|
||||
*
|
||||
* Both columns are updated every run; use `--period` to limit computation.
|
||||
*/
|
||||
final class TrendingService
|
||||
{
|
||||
/** Weight constants — tune via config('discovery.trending.*') if needed */
|
||||
private const W_AWARD = 5.0;
|
||||
private const W_FAVORITE = 3.0;
|
||||
private const W_REACTION = 2.0;
|
||||
private const W_DOWNLOAD = 1.0;
|
||||
private const W_VIEW = 2.0;
|
||||
private const DECAY_RATE = 0.1; // score loss per hour since publish
|
||||
|
||||
/**
|
||||
* Recalculate trending scores for artworks published within the look-back window.
|
||||
*
|
||||
* @param string $period '24h' targets trending_score_24h (7-day window)
|
||||
* '7d' targets trending_score_7d (30-day window)
|
||||
* @param int $chunkSize Number of IDs per DB UPDATE batch
|
||||
* @return int Number of artworks updated
|
||||
*/
|
||||
public function recalculate(string $period = '7d', int $chunkSize = 1000): int
|
||||
{
|
||||
[$column, $windowDays] = match ($period) {
|
||||
'24h' => ['trending_score_24h', 7],
|
||||
default => ['trending_score_7d', 30],
|
||||
};
|
||||
|
||||
// Use the windowed counters: views_24h/views_7d and downloads_24h/downloads_7d
|
||||
// instead of all-time totals so trending reflects recent activity.
|
||||
[$viewCol, $dlCol] = match ($period) {
|
||||
'24h' => ['views_24h', 'downloads_24h'],
|
||||
default => ['views_7d', 'downloads_7d'],
|
||||
};
|
||||
|
||||
$cutoff = now()->subDays($windowDays)->toDateTimeString();
|
||||
$updated = 0;
|
||||
|
||||
Artwork::query()
|
||||
->select('id')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '>=', $cutoff)
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function ($artworks) use ($column, &$updated): void {
|
||||
$ids = $artworks->pluck('id')->toArray();
|
||||
$inClause = implode(',', array_fill(0, count($ids), '?'));
|
||||
|
||||
// One bulk UPDATE per chunk – uses pre-computed windowed counters
|
||||
// for views and downloads (accurate rolling windows, reset nightly/weekly)
|
||||
// rather than all-time totals. All other signals use correlated subqueries.
|
||||
// Column name ($column) is controlled internally, not user-supplied.
|
||||
DB::update(
|
||||
"UPDATE artworks
|
||||
SET
|
||||
{$column} = GREATEST(
|
||||
COALESCE((SELECT score_total FROM artwork_award_stats WHERE artwork_award_stats.artwork_id = artworks.id), 0) * ?
|
||||
+ COALESCE((SELECT favorites FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
|
||||
+ COALESCE((SELECT COUNT(*) FROM artwork_reactions WHERE artwork_reactions.artwork_id = artworks.id), 0) * ?
|
||||
+ COALESCE((SELECT {$dlCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
|
||||
+ COALESCE((SELECT {$viewCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
|
||||
- (TIMESTAMPDIFF(HOUR, artworks.published_at, NOW()) * ?)
|
||||
, 0),
|
||||
last_trending_calculated_at = NOW()
|
||||
WHERE id IN ({$inClause})",
|
||||
array_merge(
|
||||
[self::W_AWARD, self::W_FAVORITE, self::W_REACTION, self::W_DOWNLOAD, self::W_VIEW, self::DECAY_RATE],
|
||||
$ids
|
||||
)
|
||||
);
|
||||
|
||||
$updated += count($ids);
|
||||
});
|
||||
|
||||
Log::info('TrendingService: recalculation complete', [
|
||||
'period' => $period,
|
||||
'column' => $column,
|
||||
'updated' => $updated,
|
||||
]);
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch Meilisearch re-index jobs for artworks in the trending window.
|
||||
* Called after recalculate() to keep the search index current.
|
||||
*/
|
||||
public function syncToSearchIndex(string $period = '7d', int $chunkSize = 500): void
|
||||
{
|
||||
$windowDays = $period === '24h' ? 7 : 30;
|
||||
$cutoff = now()->subDays($windowDays)->toDateTimeString();
|
||||
|
||||
Artwork::query()
|
||||
->select('id')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->where('published_at', '>=', $cutoff)
|
||||
->chunkById($chunkSize, function ($artworks): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
\App\Jobs\IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
93
app/Services/UserPreferenceService.php
Normal file
93
app/Services/UserPreferenceService.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* UserPreferenceService
|
||||
*
|
||||
* Builds a lightweight preference profile for a user based on:
|
||||
* - Tags on artworks they have favourited
|
||||
* - Categories of artwork they have favourited / downloaded
|
||||
* - Creators they follow
|
||||
*
|
||||
* Output shape:
|
||||
* [
|
||||
* 'top_tags' => ['space', 'nature', ...], // up to 5 slugs
|
||||
* 'top_categories' => ['wallpapers', ...], // up to 3 slugs
|
||||
* 'followed_creators' => [1, 5, 23, ...], // user IDs
|
||||
* ]
|
||||
*/
|
||||
final class UserPreferenceService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function build(User $user): array
|
||||
{
|
||||
return Cache::remember(
|
||||
"user.prefs.{$user->id}",
|
||||
self::CACHE_TTL,
|
||||
fn () => $this->compute($user)
|
||||
);
|
||||
}
|
||||
|
||||
private function compute(User $user): array
|
||||
{
|
||||
return [
|
||||
'top_tags' => $this->topTags($user),
|
||||
'top_categories' => $this->topCategories($user),
|
||||
'followed_creators' => $this->followedCreatorIds($user),
|
||||
];
|
||||
}
|
||||
|
||||
/** Top tag slugs derived from the user's favourited artworks */
|
||||
private function topTags(User $user, int $limit = 5): array
|
||||
{
|
||||
return DB::table('artwork_favourites as af')
|
||||
->join('artwork_tag as at', 'at.artwork_id', '=', 'af.artwork_id')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->where('t.is_active', true)
|
||||
->selectRaw('t.slug, COUNT(*) as cnt')
|
||||
->groupBy('t.id', 't.slug')
|
||||
->orderByDesc('cnt')
|
||||
->limit($limit)
|
||||
->pluck('slug')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/** Top category slugs derived from the user's favourited artworks */
|
||||
private function topCategories(User $user, int $limit = 3): array
|
||||
{
|
||||
return DB::table('artwork_favourites as af')
|
||||
->join('artwork_category as ac', 'ac.artwork_id', '=', 'af.artwork_id')
|
||||
->join('categories as c', 'c.id', '=', 'ac.category_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->whereNull('c.deleted_at')
|
||||
->selectRaw('c.slug, COUNT(*) as cnt')
|
||||
->groupBy('c.id', 'c.slug')
|
||||
->orderByDesc('cnt')
|
||||
->limit($limit)
|
||||
->pluck('slug')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/** IDs of creators the user follows, latest follows first */
|
||||
private function followedCreatorIds(User $user, int $limit = 100): array
|
||||
{
|
||||
return DB::table('user_followers')
|
||||
->where('follower_id', $user->id)
|
||||
->orderByDesc('created_at')
|
||||
->limit($limit)
|
||||
->pluck('user_id')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user