Initial commit: Animexe Laravel platform
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'username', 'email', 'password', 'avatar',
|
||||
'social_provider', 'social_id',
|
||||
'bio', 'banner_image', 'website', 'twitter', 'instagram', 'discord',
|
||||
'profile_color', 'show_watchlist', 'show_activity',
|
||||
'role', 'membership', 'premium_expires_at',
|
||||
'is_banned', 'ban_reason', 'banned_at',
|
||||
'gif_avatar', 'comment_bg', 'comment_glow', 'username_color', 'username_effect',
|
||||
'profile_frame', 'profile_badge', 'admin_badge', 'profile_bg', 'profile_theme',
|
||||
'profile_music_url', 'comment_signature', 'entry_effect', 'animated_banner',
|
||||
];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'premium_expires_at' => 'datetime',
|
||||
'banned_at' => 'datetime',
|
||||
'is_banned' => 'boolean',
|
||||
'show_watchlist' => 'boolean',
|
||||
'show_activity' => 'boolean',
|
||||
'animated_banner' => 'boolean',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* /u/{username} gibi URL'lerde username veya ID ile çözümleme.
|
||||
* custom_profile_url perki olan kullanıcılar /u/kullanici-adi şeklinde erişilebilir.
|
||||
*/
|
||||
public function resolveRouteBinding($value, $field = null): ?self
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return static::find($value);
|
||||
}
|
||||
return static::where('username', $value)
|
||||
->whereNotNull('username')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
|
||||
public function isModerator(): bool
|
||||
{
|
||||
return in_array($this->role, ['admin', 'moderator']);
|
||||
}
|
||||
|
||||
public function moderatorPermissions()
|
||||
{
|
||||
return $this->hasMany(ModeratorPermission::class);
|
||||
}
|
||||
|
||||
public function activityLogs()
|
||||
{
|
||||
return $this->hasMany(UserActivityLog::class);
|
||||
}
|
||||
|
||||
/** Returns cached permission set for this user. Admins have all permissions. */
|
||||
public function can_mod(string $permission): bool
|
||||
{
|
||||
if ($this->isAdmin()) return true;
|
||||
if ($this->role !== 'moderator') return false;
|
||||
|
||||
$key = "mod_perms_{$this->id}";
|
||||
$perms = cache()->remember($key, 300, fn() =>
|
||||
ModeratorPermission::where('user_id', $this->id)->pluck('permission')->all()
|
||||
);
|
||||
return in_array($permission, $perms);
|
||||
}
|
||||
|
||||
/** Flush cached permissions (call after saving changes). */
|
||||
public function flushPermCache(): void
|
||||
{
|
||||
cache()->forget("mod_perms_{$this->id}");
|
||||
}
|
||||
|
||||
/** isPremium() sonucunu istek başına önbellekle — sayfa başına onlarca kez çağrılıyor */
|
||||
private ?bool $_isPremiumCache = null;
|
||||
|
||||
public function isPremium(): bool
|
||||
{
|
||||
if ($this->_isPremiumCache !== null) {
|
||||
return $this->_isPremiumCache;
|
||||
}
|
||||
|
||||
// 1. yol: membership alanı 'premium' ve süresi dolmamış
|
||||
$viaMembership = $this->membership === 'premium'
|
||||
&& ($this->premium_expires_at === null || $this->premium_expires_at->isFuture());
|
||||
if ($viaMembership) {
|
||||
return $this->_isPremiumCache = true;
|
||||
}
|
||||
|
||||
// 2. yol: aktif abonelik var ama membership alanı senkronize değil
|
||||
// (2 farklı premium yolu — biri güncellenmezse kullanıcı yine premium sayılır)
|
||||
try {
|
||||
$viaSubscription = $this->subscriptions()
|
||||
->where('status', 'active')
|
||||
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||||
->exists();
|
||||
} catch (\Throwable) {
|
||||
$viaSubscription = false;
|
||||
}
|
||||
|
||||
return $this->_isPremiumCache = $viaSubscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının aktif planında belirli bir perk var mı?
|
||||
* Ücretsiz kullanıcılarda her zaman false döner.
|
||||
*/
|
||||
public function hasPerk(string $key): bool
|
||||
{
|
||||
if (!$this->isPremium()) return false;
|
||||
|
||||
// Ücretsiz mod: tüm perkler herkese açık
|
||||
if (self::freeModeActive()) return true;
|
||||
|
||||
$sub = $this->subscriptions()
|
||||
->where('status', 'active')
|
||||
->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||||
->latest()
|
||||
->with('plan')
|
||||
->first();
|
||||
|
||||
if (!$sub?->plan) return false;
|
||||
|
||||
$perks = $sub->plan->perks ?? [];
|
||||
return !empty($perks[$key]);
|
||||
}
|
||||
|
||||
/** premium_free_mode ayarını 5dk cache'leyerek okur */
|
||||
public static function freeModeActive(): bool
|
||||
{
|
||||
return cache()->remember('premium_free_mode', 300, fn() =>
|
||||
\App\Models\Setting::get('premium_free_mode', '0')
|
||||
) === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının aktif avatar URL'si: GIF avatar varsa ve hasPerk('gif_avatar') ise döner.
|
||||
*/
|
||||
public function effectiveAvatar(): ?string
|
||||
{
|
||||
if ($this->gif_avatar && $this->hasPerk('gif_avatar')) {
|
||||
return $this->gif_avatar;
|
||||
}
|
||||
return $this->avatar;
|
||||
}
|
||||
|
||||
public function subscriptions()
|
||||
{
|
||||
return $this->hasMany(Subscription::class);
|
||||
}
|
||||
|
||||
public function comments()
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
// ── Social ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function followers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'user_follows', 'following_id', 'follower_id')
|
||||
->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function following()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'user_follows', 'follower_id', 'following_id')
|
||||
->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function isFollowing(int $userId): bool
|
||||
{
|
||||
return \App\Models\UserFollow::where('follower_id', $this->id)
|
||||
->where('following_id', $userId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function conversations()
|
||||
{
|
||||
return $this->belongsToMany(Conversation::class, 'conversation_participants')
|
||||
->withPivot('last_read_at');
|
||||
}
|
||||
|
||||
public function totalUnreadMessages(): int
|
||||
{
|
||||
return $this->conversations()
|
||||
->with(['messages' => fn($q) => $q->where('user_id', '!=', $this->id)])
|
||||
->get()
|
||||
->sum(fn($c) => $c->unreadCountFor($this->id));
|
||||
}
|
||||
|
||||
// Anime zevk uyum skoru (0-100)
|
||||
public function compatibilityWith(User $other): int
|
||||
{
|
||||
$myIds = \App\Models\Watchlist::where('user_id', $this->id)->pluck('anime_id');
|
||||
$theirIds = \App\Models\Watchlist::where('user_id', $other->id)->pluck('anime_id');
|
||||
|
||||
if ($myIds->isEmpty() || $theirIds->isEmpty()) return 0;
|
||||
|
||||
$mySet = $myIds->unique()->values();
|
||||
$theirSet = $theirIds->unique()->values();
|
||||
$intersection = $mySet->intersect($theirSet)->count();
|
||||
$union = $mySet->merge($theirSet)->unique()->count();
|
||||
|
||||
$jaccard = $union > 0 ? $intersection / $union : 0;
|
||||
|
||||
// Rating similarity bonus
|
||||
$myRatings = \DB::table('anime_ratings')->where('user_id', $this->id)->pluck('rating', 'anime_id');
|
||||
$theirRatings = \DB::table('anime_ratings')->where('user_id', $other->id)->pluck('rating', 'anime_id');
|
||||
$commonAnimes = $myRatings->keys()->intersect($theirRatings->keys());
|
||||
|
||||
$ratingScore = 0;
|
||||
if ($commonAnimes->count() > 0) {
|
||||
$diffs = $commonAnimes->map(fn($id) => abs($myRatings[$id] - $theirRatings[$id]) / 10);
|
||||
$ratingScore = 1 - $diffs->avg();
|
||||
}
|
||||
|
||||
$score = $jaccard * 0.6 + $ratingScore * 0.4;
|
||||
return (int) round(min($score * 100, 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* İzleme saatine göre rank bilgisi döner.
|
||||
* watch_rank perki yoksa null döner.
|
||||
*/
|
||||
public function watchRank(): ?array
|
||||
{
|
||||
if (!$this->hasPerk('watch_rank')) return null;
|
||||
|
||||
$totalSeconds = \App\Models\ContinueWatching::where('user_id', $this->id)
|
||||
->sum('seconds_watched');
|
||||
$hours = $totalSeconds / 3600;
|
||||
|
||||
return match(true) {
|
||||
$hours >= 500 => ['label' => 'Efsane', 'color' => '#ff2d7d', 'icon' => 'bi-trophy-fill'],
|
||||
$hours >= 250 => ['label' => 'Usta', 'color' => '#ffd700', 'icon' => 'bi-star-fill'],
|
||||
$hours >= 100 => ['label' => 'Bağımlı', 'color' => '#b84dff', 'icon' => 'bi-heart-fill'],
|
||||
$hours >= 50 => ['label' => 'Hayran', 'color' => '#00f5ff', 'icon' => 'bi-eye-fill'],
|
||||
$hours >= 20 => ['label' => 'İzleyici', 'color' => '#00d4a4', 'icon' => 'bi-play-circle-fill'],
|
||||
default => ['label' => 'Acemi', 'color' => '#9ca3af', 'icon' => 'bi-controller'],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user