Files
SkinbaseNova/app/Services/UserStatsService.php
2026-02-26 21:12:32 +01:00

291 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}