storing analytics data
This commit is contained in:
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user