update
This commit is contained in:
292
app/Services/XPService.php
Normal file
292
app/Services/XPService.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\Achievements\UserXpUpdated;
|
||||
use App\Models\User;
|
||||
use App\Models\UserXpLog;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class XPService
|
||||
{
|
||||
private const LEVEL_THRESHOLDS = [
|
||||
1 => 0,
|
||||
2 => 100,
|
||||
3 => 300,
|
||||
4 => 800,
|
||||
5 => 2000,
|
||||
6 => 5000,
|
||||
7 => 12000,
|
||||
];
|
||||
|
||||
private const RANKS = [
|
||||
1 => 'Newbie',
|
||||
2 => 'Explorer',
|
||||
3 => 'Contributor',
|
||||
4 => 'Creator',
|
||||
5 => 'Pro Creator',
|
||||
6 => 'Elite',
|
||||
7 => 'Legend',
|
||||
];
|
||||
|
||||
private const DAILY_CAPS = [
|
||||
'artwork_view_received' => 200,
|
||||
'comment_created' => 100,
|
||||
'story_published' => 200,
|
||||
'artwork_published' => 250,
|
||||
'follower_received' => 400,
|
||||
'artwork_like_received' => 500,
|
||||
];
|
||||
|
||||
public function addXP(
|
||||
User|int $user,
|
||||
int $amount,
|
||||
string $action,
|
||||
?int $referenceId = null,
|
||||
bool $dispatchEvent = true,
|
||||
): bool
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userId = $user instanceof User ? (int) $user->id : $user;
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$baseAction = $this->baseAction($action);
|
||||
$awardAmount = $this->applyDailyCap($userId, $amount, $baseAction);
|
||||
|
||||
if ($awardAmount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($userId, $awardAmount, $action, $referenceId): void {
|
||||
/** @var User $lockedUser */
|
||||
$lockedUser = User::query()->lockForUpdate()->findOrFail($userId);
|
||||
$nextXp = max(0, (int) $lockedUser->xp + $awardAmount);
|
||||
$level = $this->calculateLevel($nextXp);
|
||||
$rank = $this->getRank($level);
|
||||
|
||||
$lockedUser->forceFill([
|
||||
'xp' => $nextXp,
|
||||
'level' => $level,
|
||||
'rank' => $rank,
|
||||
])->save();
|
||||
|
||||
UserXpLog::query()->create([
|
||||
'user_id' => $userId,
|
||||
'action' => $action,
|
||||
'xp' => $awardAmount,
|
||||
'reference_id' => $referenceId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
$this->forgetSummaryCache($userId);
|
||||
|
||||
if ($dispatchEvent) {
|
||||
event(new UserXpUpdated($userId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function awardArtworkPublished(int $userId, int $artworkId): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 50, 'artwork_published', $artworkId);
|
||||
}
|
||||
|
||||
public function awardArtworkLikeReceived(int $userId, int $artworkId, int $actorId): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 5, 'artwork_like_received', $artworkId, $actorId);
|
||||
}
|
||||
|
||||
public function awardFollowerReceived(int $userId, int $followerId): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 20, 'follower_received', $followerId, $followerId);
|
||||
}
|
||||
|
||||
public function awardStoryPublished(int $userId, int $storyId): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 40, 'story_published', $storyId);
|
||||
}
|
||||
|
||||
public function awardCommentCreated(int $userId, int $referenceId, string $scope = 'generic'): bool
|
||||
{
|
||||
return $this->awardUnique($userId, 5, 'comment_created:' . $scope, $referenceId);
|
||||
}
|
||||
|
||||
public function awardArtworkViewReceived(int $userId, int $artworkId, ?int $viewerId = null, ?string $ipAddress = null): bool
|
||||
{
|
||||
$viewerKey = $viewerId !== null && $viewerId > 0
|
||||
? 'user:' . $viewerId
|
||||
: 'guest:' . sha1((string) ($ipAddress ?: 'guest'));
|
||||
|
||||
$expiresAt = now()->endOfDay();
|
||||
$qualifierKey = sprintf('xp:view:qualifier:%d:%d:%s:%s', $userId, $artworkId, $viewerKey, now()->format('Ymd'));
|
||||
if (! Cache::add($qualifierKey, true, $expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bucketKey = sprintf('xp:view:bucket:%d:%s', $userId, now()->format('Ymd'));
|
||||
Cache::add($bucketKey, 0, $expiresAt);
|
||||
$bucketCount = Cache::increment($bucketKey);
|
||||
|
||||
if ($bucketCount % 10 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->addXP($userId, 1, 'artwork_view_received', $artworkId);
|
||||
}
|
||||
|
||||
public function calculateLevel(int $xp): int
|
||||
{
|
||||
$resolvedLevel = 1;
|
||||
|
||||
foreach (self::LEVEL_THRESHOLDS as $level => $threshold) {
|
||||
if ($xp >= $threshold) {
|
||||
$resolvedLevel = $level;
|
||||
}
|
||||
}
|
||||
|
||||
return $resolvedLevel;
|
||||
}
|
||||
|
||||
public function getRank(int $level): string
|
||||
{
|
||||
return self::RANKS[$level] ?? Arr::last(self::RANKS);
|
||||
}
|
||||
|
||||
public function summary(User|int $user): array
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : $user;
|
||||
|
||||
return Cache::remember(
|
||||
$this->summaryCacheKey($userId),
|
||||
now()->addMinutes(10),
|
||||
function () use ($userId): array {
|
||||
$currentUser = User::query()->findOrFail($userId, ['id', 'xp', 'level', 'rank']);
|
||||
$currentLevel = max(1, (int) $currentUser->level);
|
||||
$currentXp = max(0, (int) $currentUser->xp);
|
||||
$currentThreshold = self::LEVEL_THRESHOLDS[$currentLevel] ?? 0;
|
||||
$nextLevel = min($currentLevel + 1, array_key_last(self::LEVEL_THRESHOLDS));
|
||||
$nextLevelXp = self::LEVEL_THRESHOLDS[$nextLevel] ?? $currentXp;
|
||||
$range = max(1, $nextLevelXp - $currentThreshold);
|
||||
$progressWithinLevel = min($range, max(0, $currentXp - $currentThreshold));
|
||||
$progressPercent = $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS)
|
||||
? 100
|
||||
: (int) round(($progressWithinLevel / $range) * 100);
|
||||
|
||||
return [
|
||||
'xp' => $currentXp,
|
||||
'level' => $currentLevel,
|
||||
'rank' => (string) ($currentUser->rank ?: $this->getRank($currentLevel)),
|
||||
'current_level_xp' => $currentThreshold,
|
||||
'next_level_xp' => $nextLevelXp,
|
||||
'progress_xp' => $progressWithinLevel,
|
||||
'progress_percent' => $progressPercent,
|
||||
'max_level' => $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function recalculateStoredProgress(User|int $user, bool $write = true): array
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : $user;
|
||||
|
||||
/** @var User $currentUser */
|
||||
$currentUser = User::query()->findOrFail($userId, ['id', 'xp', 'level', 'rank']);
|
||||
|
||||
$computedXp = (int) UserXpLog::query()
|
||||
->where('user_id', $userId)
|
||||
->sum('xp');
|
||||
|
||||
$computedLevel = $this->calculateLevel($computedXp);
|
||||
$computedRank = $this->getRank($computedLevel);
|
||||
$changed = (int) $currentUser->xp !== $computedXp
|
||||
|| (int) $currentUser->level !== $computedLevel
|
||||
|| (string) $currentUser->rank !== $computedRank;
|
||||
|
||||
if ($write && $changed) {
|
||||
$currentUser->forceFill([
|
||||
'xp' => $computedXp,
|
||||
'level' => $computedLevel,
|
||||
'rank' => $computedRank,
|
||||
])->save();
|
||||
|
||||
$this->forgetSummaryCache($userId);
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'changed' => $changed,
|
||||
'previous' => [
|
||||
'xp' => (int) $currentUser->xp,
|
||||
'level' => (int) $currentUser->level,
|
||||
'rank' => (string) $currentUser->rank,
|
||||
],
|
||||
'computed' => [
|
||||
'xp' => $computedXp,
|
||||
'level' => $computedLevel,
|
||||
'rank' => $computedRank,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function awardUnique(int $userId, int $amount, string $action, int $referenceId, ?int $actorId = null): bool
|
||||
{
|
||||
$actionKey = $actorId !== null ? $action . ':' . $actorId : $action;
|
||||
|
||||
$alreadyAwarded = UserXpLog::query()
|
||||
->where('user_id', $userId)
|
||||
->where('action', $actionKey)
|
||||
->where('reference_id', $referenceId)
|
||||
->exists();
|
||||
|
||||
if ($alreadyAwarded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->addXP($userId, $amount, $actionKey, $referenceId);
|
||||
}
|
||||
|
||||
private function applyDailyCap(int $userId, int $amount, string $baseAction): int
|
||||
{
|
||||
$cap = self::DAILY_CAPS[$baseAction] ?? null;
|
||||
if ($cap === null) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$dayStart = Carbon::now()->startOfDay();
|
||||
$awardedToday = (int) UserXpLog::query()
|
||||
->where('user_id', $userId)
|
||||
->where('action', 'like', $baseAction . '%')
|
||||
->where('created_at', '>=', $dayStart)
|
||||
->sum('xp');
|
||||
|
||||
return max(0, min($amount, $cap - $awardedToday));
|
||||
}
|
||||
|
||||
private function baseAction(string $action): string
|
||||
{
|
||||
return explode(':', $action, 2)[0];
|
||||
}
|
||||
|
||||
private function forgetSummaryCache(int $userId): void
|
||||
{
|
||||
Cache::forget($this->summaryCacheKey($userId));
|
||||
}
|
||||
|
||||
private function summaryCacheKey(int $userId): string
|
||||
{
|
||||
return 'xp:summary:' . $userId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user