Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,150 @@
<?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);
}
});
}
}