291 lines
10 KiB
PHP
291 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Jobs\IndexUserJob;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* UserStatsService – single source of truth for user_statistics counters.
|
||
*
|
||
* All counter updates MUST go through this service.
|
||
* No direct increments in controllers or jobs.
|
||
*
|
||
* Design:
|
||
* - Atomic SQL increments (no read-modify-write races).
|
||
* - Negative counters are prevented at the SQL level (WHERE col > 0).
|
||
* - ensureRow() upserts the row before any counter touch.
|
||
* - recomputeUser() rebuilds all columns from authoritative tables.
|
||
*/
|
||
final class UserStatsService
|
||
{
|
||
// ─── Row management ──────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Guarantee a user_statistics row exists for the given user.
|
||
* Safe to call before every increment.
|
||
*/
|
||
public function ensureRow(int $userId): void
|
||
{
|
||
DB::table('user_statistics')->insertOrIgnore([
|
||
'user_id' => $userId,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
// ─── Increment helpers ────────────────────────────────────────────────────
|
||
|
||
public function incrementUploads(int $userId, int $by = 1): void
|
||
{
|
||
$this->ensureRow($userId);
|
||
$this->inc($userId, 'uploads_count', $by);
|
||
$this->touchActive($userId);
|
||
$this->reindex($userId);
|
||
}
|
||
|
||
public function decrementUploads(int $userId, int $by = 1): void
|
||
{
|
||
$this->dec($userId, 'uploads_count', $by);
|
||
$this->reindex($userId);
|
||
}
|
||
|
||
public function incrementDownloadsReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->ensureRow($creatorUserId);
|
||
$this->inc($creatorUserId, 'downloads_received_count', $by);
|
||
$this->reindex($creatorUserId);
|
||
}
|
||
|
||
public function incrementArtworkViewsReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->ensureRow($creatorUserId);
|
||
$this->inc($creatorUserId, 'artwork_views_received_count', $by);
|
||
// Views are high-frequency – do NOT reindex on every view.
|
||
}
|
||
|
||
public function incrementAwardsReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->ensureRow($creatorUserId);
|
||
$this->inc($creatorUserId, 'awards_received_count', $by);
|
||
$this->reindex($creatorUserId);
|
||
}
|
||
|
||
public function decrementAwardsReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->dec($creatorUserId, 'awards_received_count', $by);
|
||
$this->reindex($creatorUserId);
|
||
}
|
||
|
||
public function incrementFavoritesReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->ensureRow($creatorUserId);
|
||
$this->inc($creatorUserId, 'favorites_received_count', $by);
|
||
$this->reindex($creatorUserId);
|
||
}
|
||
|
||
public function decrementFavoritesReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->dec($creatorUserId, 'favorites_received_count', $by);
|
||
$this->reindex($creatorUserId);
|
||
}
|
||
|
||
public function incrementCommentsReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->ensureRow($creatorUserId);
|
||
$this->inc($creatorUserId, 'comments_received_count', $by);
|
||
$this->reindex($creatorUserId);
|
||
}
|
||
|
||
public function decrementCommentsReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->dec($creatorUserId, 'comments_received_count', $by);
|
||
$this->reindex($creatorUserId);
|
||
}
|
||
|
||
public function incrementReactionsReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->ensureRow($creatorUserId);
|
||
$this->inc($creatorUserId, 'reactions_received_count', $by);
|
||
$this->reindex($creatorUserId);
|
||
}
|
||
|
||
public function decrementReactionsReceived(int $creatorUserId, int $by = 1): void
|
||
{
|
||
$this->dec($creatorUserId, 'reactions_received_count', $by);
|
||
$this->reindex($creatorUserId);
|
||
}
|
||
|
||
public function incrementProfileViews(int $userId, int $by = 1): void
|
||
{
|
||
$this->ensureRow($userId);
|
||
$this->inc($userId, 'profile_views_count', $by);
|
||
}
|
||
|
||
// ─── Timestamp helpers ────────────────────────────────────────────────────
|
||
|
||
public function setLastUploadAt(int $userId, ?Carbon $timestamp = null): void
|
||
{
|
||
$this->ensureRow($userId);
|
||
DB::table('user_statistics')
|
||
->where('user_id', $userId)
|
||
->update([
|
||
'last_upload_at' => ($timestamp ?? now())->toDateTimeString(),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
public function setLastActiveAt(int $userId, ?Carbon $timestamp = null): void
|
||
{
|
||
$this->ensureRow($userId);
|
||
DB::table('user_statistics')
|
||
->where('user_id', $userId)
|
||
->update([
|
||
'last_active_at' => ($timestamp ?? now())->toDateTimeString(),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
// ─── Recompute ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Recompute all counters for a single user from authoritative tables.
|
||
* Returns the computed values (array) without writing when $dryRun=true.
|
||
*
|
||
* @return array<string, int|string|null>
|
||
*/
|
||
public function recomputeUser(int $userId, bool $dryRun = false): array
|
||
{
|
||
$computed = [
|
||
'uploads_count' => (int) DB::table('artworks')
|
||
->where('user_id', $userId)
|
||
->whereNull('deleted_at')
|
||
->count(),
|
||
|
||
'downloads_received_count' => (int) DB::table('artwork_downloads as d')
|
||
->join('artworks as a', 'a.id', '=', 'd.artwork_id')
|
||
->where('a.user_id', $userId)
|
||
->whereNull('a.deleted_at')
|
||
->count(),
|
||
|
||
'artwork_views_received_count' => (int) DB::table('artwork_stats as s')
|
||
->join('artworks as a', 'a.id', '=', 's.artwork_id')
|
||
->where('a.user_id', $userId)
|
||
->whereNull('a.deleted_at')
|
||
->sum('s.views'),
|
||
|
||
'awards_received_count' => (int) DB::table('artwork_awards as aw')
|
||
->join('artworks as a', 'a.id', '=', 'aw.artwork_id')
|
||
->where('a.user_id', $userId)
|
||
->whereNull('a.deleted_at')
|
||
->count(),
|
||
|
||
'favorites_received_count' => (int) DB::table('artwork_favourites as f')
|
||
->join('artworks as a', 'a.id', '=', 'f.artwork_id')
|
||
->where('a.user_id', $userId)
|
||
->whereNull('a.deleted_at')
|
||
->count(),
|
||
|
||
'comments_received_count' => (int) DB::table('artwork_comments as c')
|
||
->join('artworks as a', 'a.id', '=', 'c.artwork_id')
|
||
->where('a.user_id', $userId)
|
||
->whereNull('a.deleted_at')
|
||
->whereNull('c.deleted_at')
|
||
->count(),
|
||
|
||
'reactions_received_count' => (int) DB::table('artwork_reactions as r')
|
||
->join('artworks as a', 'a.id', '=', 'r.artwork_id')
|
||
->where('a.user_id', $userId)
|
||
->whereNull('a.deleted_at')
|
||
->count(),
|
||
|
||
'followers_count' => (int) DB::table('user_followers')
|
||
->where('user_id', $userId)
|
||
->count(),
|
||
|
||
'following_count' => (int) DB::table('user_followers')
|
||
->where('follower_id', $userId)
|
||
->count(),
|
||
|
||
'last_upload_at' => DB::table('artworks')
|
||
->where('user_id', $userId)
|
||
->whereNull('deleted_at')
|
||
->max('created_at'),
|
||
];
|
||
|
||
if (! $dryRun) {
|
||
$this->ensureRow($userId);
|
||
|
||
DB::table('user_statistics')
|
||
->where('user_id', $userId)
|
||
->update(array_merge($computed, ['updated_at' => now()]));
|
||
|
||
$this->reindex($userId);
|
||
}
|
||
|
||
return $computed;
|
||
}
|
||
|
||
/**
|
||
* Recompute stats for all users in chunks.
|
||
*
|
||
* @param int $chunk Users per chunk.
|
||
*/
|
||
public function recomputeAll(int $chunk = 1000): void
|
||
{
|
||
DB::table('users')
|
||
->whereNull('deleted_at')
|
||
->orderBy('id')
|
||
->chunk($chunk, function ($users) {
|
||
foreach ($users as $user) {
|
||
$this->recomputeUser($user->id);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Private helpers ──────────────────────────────────────────────────────
|
||
|
||
private function inc(int $userId, string $column, int $by = 1): void
|
||
{
|
||
DB::table('user_statistics')
|
||
->where('user_id', $userId)
|
||
->update([
|
||
$column => DB::raw("MAX(0, COALESCE({$column}, 0) + {$by})"),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
private function dec(int $userId, string $column, int $by = 1): void
|
||
{
|
||
DB::table('user_statistics')
|
||
->where('user_id', $userId)
|
||
->where($column, '>', 0)
|
||
->update([
|
||
$column => DB::raw("MAX(0, COALESCE({$column}, 0) - {$by})"),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
private function touchActive(int $userId): void
|
||
{
|
||
DB::table('user_statistics')
|
||
->where('user_id', $userId)
|
||
->update([
|
||
'last_active_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Queue a Meilisearch reindex for the user.
|
||
* Uses IndexUserJob to avoid blocking the request.
|
||
*/
|
||
private function reindex(int $userId): void
|
||
{
|
||
IndexUserJob::dispatch($userId);
|
||
}
|
||
}
|