151 lines
6.2 KiB
PHP
151 lines
6.2 KiB
PHP
<?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 = (favorites_velocity * wf)
|
||
* + (comments_velocity * wc)
|
||
* + (shares_velocity * ws)
|
||
* + (downloads_velocity * wd)
|
||
* + (views_velocity * wv)
|
||
* - (hours_since_published * 0.1)
|
||
*
|
||
* The score is stored in artworks.trending_score_1h / 24h / 7d.
|
||
*
|
||
* 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 '1h' targets trending_score_1h (3-day window)
|
||
* '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) {
|
||
'1h' => ['trending_score_1h', 3],
|
||
'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) {
|
||
'1h' => ['views_1h', 'downloads_1h'],
|
||
'24h' => ['views_24h', 'downloads_24h'],
|
||
default => ['views_7d', 'downloads_7d'],
|
||
};
|
||
|
||
[$favCol, $commentCol, $shareCol] = match ($period) {
|
||
'1h' => ['favourites_1h', 'comments_1h', 'shares_1h'],
|
||
'24h' => ['favourites_24h', 'comments_24h', 'shares_24h'],
|
||
default => ['favorites', 'comments_count', 'shares_count'],
|
||
};
|
||
|
||
$weights = (array) config('discovery.v2.trending.velocity_weights', []);
|
||
$wView = (float) ($weights['views'] ?? self::W_VIEW);
|
||
$wFavorite = (float) ($weights['favorites'] ?? self::W_FAVORITE);
|
||
$wComment = (float) ($weights['comments'] ?? self::W_REACTION);
|
||
$wShare = (float) ($weights['shares'] ?? self::W_DOWNLOAD);
|
||
|
||
$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, $viewCol, $dlCol, $favCol, $commentCol, $shareCol, $wFavorite, $wComment, $wShare, $wView, &$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 {$favCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
|
||
+ COALESCE((SELECT {$commentCol} FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) * ?
|
||
+ COALESCE((SELECT {$shareCol} FROM artwork_stats WHERE artwork_stats.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(
|
||
[$wFavorite, $wComment, $wShare, self::W_DOWNLOAD, $wView, 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 = match ($period) {
|
||
'1h' => 3,
|
||
'24h' => 7,
|
||
default => 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);
|
||
}
|
||
});
|
||
}
|
||
}
|