Proje dosyaları eklendi
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Services;use App\Models\{CompetitorAudit,ProjectCompetitor,User};use Illuminate\Support\Str;
|
||||
final class CompetitorAuditLogger{public function record(string$action,ProjectCompetitor$c,User$actor,array$before=[]):void{$safe=fn($v)=>array_intersect_key($v,array_flip(['name','domain','normalized_domain','status','archived_at']));CompetitorAudit::create(['actor_user_id'=>$actor->id,'project_id'=>$c->project_id,'competitor_id'=>$c->id,'action'=>$action,'correlation_id'=>(string)Str::uuid(),'before'=>$safe($before)?:null,'after'=>$safe($c->fresh()->attributesToArray())]);}}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Services;use App\Models\{Project,ProjectCompetitor,SerpCheck};use Illuminate\Support\Collection;
|
||||
final class CompetitorReportService{public function __construct(private ProjectDomainMatcher$matcher,private VisibilityReportService$visibility){}public function compare(Project$p,Collection$competitors,\DateTimeInterface$from,\DateTimeInterface$to):array{$ids=$p->keywords()->active()->pluck('id');$checks=SerpCheck::query()->whereIn('keyword_id',$ids)->where('status','completed')->whereBetween('checked_at',[$from,$to])->with(['keyword','results'=>fn($q)=>$q->where('result_type','organic')->orderBy('position')])->orderBy('checked_at')->get()->groupBy(fn($c)=>$c->checked_at->toDateString())->flatMap(fn($day)=>$day->groupBy('keyword_id')->map(fn($x)=>$x->last()))->groupBy('keyword_id')->map(fn($x)=>$x->sortByDesc('checked_at')->first())->values();$domains=collect([$p->domain])->merge($competitors->pluck('normalized_domain'));$scores=[];foreach($domains as$d){$ranks=$checks->map(fn($c)=>$this->rank($c,$d));$scores[$d]=round($ranks->sum(fn($r)=>(float)$this->visibility->weight($r))/max(1,$checks->count())*100,2);}$selected=$competitors->first();$rows=$selected?$checks->map(function($c)use($p,$selected){$own=$this->rank($c,$p->domain);$rival=$this->rank($c,$selected->normalized_domain);$leader=match(true){$own===null&&$rival===null=>'neither',$own===null=>'competitor',$rival===null=>'you',$own<$rival=>'you',$rival<$own=>'competitor',default=>'tied'};return['keyword'=>$c->keyword,'own'=>$own,'rival'=>$rival,'leader'=>$leader,'gap'=>$own&&$rival?abs($own-$rival):null,'checked_at'=>$c->checked_at];})->values():collect();return['checks'=>$checks,'scores'=>$scores,'rows'=>$rows,'lead'=>$rows->where('leader','you')->count(),'trail'=>$rows->where('leader','competitor')->count(),'shared'=>$rows->filter(fn($x)=>$x['own']&&$x['rival'])->count(),'unranked'=>$rows->whereNull('own')->count(),'opportunities'=>$rows->filter(fn($x)=>$x['leader']==='competitor'&&$x['rival']!==null&&$x['rival']<=10)->take(10),'strengths'=>$rows->where('leader','you')->take(10)];}private function rank(SerpCheck$c,string$d):?int{$r=$c->results->first(fn($r)=>$r->position>0&&$this->matcher->matches($d,$r->url));return$r?->position;}}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EarnApiAccessLog;
|
||||
use App\Models\EarnApiToken;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EarnApiAccessLogger
|
||||
{
|
||||
public function record(Request $request, string $event, int $status, ?string $failureCode = null, ?EarnApiToken $token = null, ?string $deviceHash = null, ?string $devicePreview = null): void
|
||||
{
|
||||
$token ??= $request->attributes->get('earn_api_token');
|
||||
$userAgent = preg_replace('/[\x00-\x1F\x7F]+/u', ' ', (string) $request->userAgent());
|
||||
EarnApiAccessLog::query()->create([
|
||||
'earn_member_id' => $token?->tokenable_id, 'personal_access_token_id' => $token?->id,
|
||||
'token_preview' => $token?->token_hint, 'event' => $event,
|
||||
'device_id_hash' => $deviceHash, 'device_id_preview' => $devicePreview,
|
||||
'ip_address' => $request->ip(), 'user_agent' => mb_substr((string) $userAgent, 0, 255),
|
||||
'http_method' => $request->method(), 'route_name' => $request->route()?->getName(),
|
||||
'response_status' => $status, 'failure_code' => $failureCode,
|
||||
'occurred_at' => now(), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EarnAudit;
|
||||
use App\Models\EarnMember;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EarnAuditLogger
|
||||
{
|
||||
public function record(string $event, ?EarnMember $member, Request $request, array $metadata = []): void
|
||||
{
|
||||
EarnAudit::query()->create([
|
||||
'earn_member_id' => $member?->id,
|
||||
'event' => $event,
|
||||
'ip_address' => $request->ip(),
|
||||
'metadata' => $metadata ?: null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class EarnDeviceIdentity
|
||||
{
|
||||
public function normalize(string $deviceId): string
|
||||
{
|
||||
return trim($deviceId);
|
||||
}
|
||||
|
||||
public function valid(string $deviceId): bool
|
||||
{
|
||||
$length = strlen($deviceId);
|
||||
|
||||
return $length >= 4 && $length <= 255 && preg_match('/^[\x20-\x7E]+$/D', $deviceId) === 1;
|
||||
}
|
||||
|
||||
public function hash(string $deviceId): string
|
||||
{
|
||||
$key = (string) config('earn.device.hash_key');
|
||||
if (strlen($key) < 32) {
|
||||
throw new RuntimeException('Earn device hashing is not configured.');
|
||||
}
|
||||
|
||||
return hash_hmac('sha256', $this->normalize($deviceId), $key);
|
||||
}
|
||||
|
||||
public function preview(string $deviceId): string
|
||||
{
|
||||
$normalized = $this->normalize($deviceId);
|
||||
|
||||
if (strlen($normalized) <= 10) {
|
||||
return substr($normalized, 0, 2).'••••'.substr($normalized, -2);
|
||||
}
|
||||
|
||||
return substr($normalized, 0, 6).'…'.substr($normalized, -5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EarnApiToken;
|
||||
use App\Models\EarnTaskAssignment;
|
||||
use App\Models\EarnTaskResult;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EarnTaskResultService
|
||||
{
|
||||
/**
|
||||
* Sonuç işleme olaylarını mevcut güvenli Earn access logger ile kaydeder.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function __construct(private readonly EarnApiAccessLogger $logs) {}
|
||||
|
||||
/**
|
||||
* Doğrulanmış token ve cihazdan gelen sonuçları sunucudaki atamaya göre idempotent kaydeder.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $items Doğrulanmış görev sonucu nesneleri.
|
||||
* @return array{received: int, accepted: int, rejected: int, results: array<int, array<string, mixed>>}
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function store(Request $request, array $items): array
|
||||
{
|
||||
/**
|
||||
* @var EarnApiToken $token Middleware tarafından doğrulanmış API anahtarı kaydı.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
$token = $request->attributes->get('earn_api_token');
|
||||
$deviceHash = (string) $request->attributes->get('earn_device_hash');
|
||||
$results = [];
|
||||
$accepted = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
// Atama doğrulaması ile ilk sonuç kaydını aynı kilitli işlemde tutar.
|
||||
// Yazar: Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
$outcome = DB::transaction(function () use ($item, $token, $deviceHash): array {
|
||||
$assignment = EarnTaskAssignment::query()->where('task_id', $item['task_id'])->lockForUpdate()->first();
|
||||
if (! $assignment instanceof EarnTaskAssignment
|
||||
|| $assignment->personal_access_token_id !== $token->id
|
||||
|| ! hash_equals($assignment->device_id_hash, $deviceHash)) {
|
||||
return ['accepted' => false, 'code' => 'TASK_NOT_ASSIGNED'];
|
||||
}
|
||||
if ($assignment->user_id !== $item['CID']) {
|
||||
return ['accepted' => false, 'code' => 'CID_MISMATCH'];
|
||||
}
|
||||
|
||||
$existing = EarnTaskResult::query()->where('earn_task_assignment_id', $assignment->id)->first();
|
||||
if ($existing instanceof EarnTaskResult) {
|
||||
return ['accepted' => true, 'code' => 'ALREADY_RECORDED', 'duplicate' => true];
|
||||
}
|
||||
|
||||
EarnTaskResult::query()->create([
|
||||
'earn_task_assignment_id' => $assignment->id, 'user_id' => $assignment->user_id,
|
||||
'earn_member_id' => $token->tokenable_id, 'personal_access_token_id' => $token->id,
|
||||
'device_id_hash' => $deviceHash, 'completed' => $item['completed'],
|
||||
'response_code' => $item['responseCode'], 'error_message' => $item['error'],
|
||||
'submitted_at' => now(),
|
||||
]);
|
||||
|
||||
return ['accepted' => true, 'code' => null];
|
||||
}, 3);
|
||||
|
||||
$results[] = ['task_id' => $item['task_id'], 'accepted' => $outcome['accepted'], 'code' => $outcome['code']];
|
||||
if ($outcome['accepted']) {
|
||||
$accepted++;
|
||||
}
|
||||
$event = match ($outcome['code']) {
|
||||
'ALREADY_RECORDED' => 'task_result_duplicate',
|
||||
'CID_MISMATCH' => 'task_result_cid_mismatch',
|
||||
'TASK_NOT_ASSIGNED' => 'task_result_not_assigned',
|
||||
default => 'task_result_accepted',
|
||||
};
|
||||
$this->logs->record($request, $event, $outcome['accepted'] ? 200 : 422, $outcome['code']);
|
||||
if (! $outcome['accepted']) {
|
||||
$this->logs->record($request, 'task_result_rejected', 422, $outcome['code']);
|
||||
}
|
||||
}
|
||||
|
||||
return ['received' => count($items), 'accepted' => $accepted,
|
||||
'rejected' => count($items) - $accepted, 'results' => $results];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Services;use App\Models\{Keyword,KeywordAudit,User};use Illuminate\Support\Str;
|
||||
final class KeywordAuditLogger{private const SAFE=['phrase','search_engine','country_code','language_code','device','status','archived_at'];public function record(string $action,Keyword $keyword,User $actor,array $before=[]):void{KeywordAudit::create(['actor_user_id'=>$actor->id,'keyword_id'=>$keyword->id,'project_id'=>$keyword->project_id,'action'=>$action,'correlation_id'=>(string)Str::uuid(),'before'=>array_intersect_key($before,array_flip(self::SAFE))?:null,'after'=>array_intersect_key($keyword->fresh()->attributesToArray(),array_flip(self::SAFE))]);}}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
use InvalidArgumentException;
|
||||
final class KeywordPhraseNormalizer {
|
||||
public function normalize(string $input):array {
|
||||
if(preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u',$input)) throw new InvalidArgumentException('Anahtar kelime kontrol karakteri içeremez.');
|
||||
$phrase=preg_replace('/\s+/u',' ',trim($input))??'';
|
||||
if($phrase==='') throw new InvalidArgumentException('Anahtar kelime zorunludur.');
|
||||
if(mb_strlen($phrase,'UTF-8')>190) throw new InvalidArgumentException('Anahtar kelime 190 karakterden uzun olamaz.');
|
||||
$normalized=mb_strtolower(str_replace(['I','İ'],['ı','i'],$phrase),'UTF-8');
|
||||
return ['phrase'=>$phrase,'normalized_phrase'=>$normalized];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Services;use Illuminate\Support\Collection;
|
||||
final class LosersReportService{public const CRITICAL_VISIBILITY_LOSS=20.0;public function __construct(private VisibilityReportService $visibility,private WinnersReportService $comparisons){}public function rows(Collection$ids,\DateTimeInterface$from,\DateTimeInterface$to):Collection{return$this->comparisons->rows($ids,$from,$to)->map(function($row){$p=$row['previous'];$c=$row['current'];$out=!$row['first_measurement']&&$p!==null&&$c===null;$lost=$p!==null&&$c!==null&&$c>$p?$c-$p:null;return array_merge($row,['dropped_out'=>$out,'lost'=>$lost,'visibility_loss'=>max(0,round(((float)$this->visibility->weight($p)-(float)$this->visibility->weight($c))*100,2)),'category'=>$this->category($p,$c,$out,$row['first_measurement'])]);});}public function losers(Collection$r):Collection{return$r->filter(fn($x)=>$x['lost']!==null||$x['dropped_out'])->values();}public function category(?int$p,?int$c,bool$out=false,bool$first=false):string{if($first)return'first_measurement';if($out)return'dropped_out';if($p===null||$c===null||$c<=$p)return'not_loser';return match(true){$p<=3&&$c>3=>'dropped_top3',$p<=10&&$c>10=>'dropped_top10',$p<=20&&$c>20=>'dropped_top20',$p<=100&&$c>100=>'dropped_top100',default=>'declined'};}public function summary(Collection$r):array{$l=$this->losers($r);return['losers'=>$l->count(),'lost'=>$l->sum('lost'),'top3'=>$l->where('category','dropped_top3')->count(),'top10'=>$l->where('category','dropped_top10')->count(),'top100'=>$l->where('dropped_out',true)->count(),'visibility_loss'=>$l->isEmpty()?null:round($l->sum('visibility_loss')/max(1,$r->where('first_measurement',false)->count()),2)];}public function distribution(Collection$r):array{$l=$this->losers($r);return['1_3'=>$l->filter(fn($x)=>$x['lost']>=1&&$x['lost']<=3)->count(),'4_10'=>$l->filter(fn($x)=>$x['lost']>=4&&$x['lost']<=10)->count(),'11_20'=>$l->filter(fn($x)=>$x['lost']>=11&&$x['lost']<=20)->count(),'over20'=>$l->filter(fn($x)=>$x['lost']>20)->count(),'dropped_out'=>$l->where('dropped_out',true)->count()];}public function critical(Collection$r):Collection{return$this->losers($r)->filter(fn($x)=>in_array($x['category'],['dropped_out','dropped_top3','dropped_top10'],true)||$x['visibility_loss']>=self::CRITICAL_VISIBILITY_LOSS)->take(10)->values();}}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
use App\Enums\CheckFrequency;use Carbon\CarbonImmutable;
|
||||
final class NextCheckAtCalculator {public function calculate(CheckFrequency|string $frequency,?CarbonImmutable $from=null):CarbonImmutable{$f=$frequency instanceof CheckFrequency?$frequency:CheckFrequency::from($frequency);$from=$from?:CarbonImmutable::now('UTC');return $f===CheckFrequency::Daily?$from->addDay():$from->addWeek();}}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Services;use App\Models\{ProfileAudit,User};use Illuminate\Support\Str;
|
||||
final class ProfileAuditLogger{public function record(string$action,User$actor,array$before=[],array$after=[]):void{ProfileAudit::create(['actor_user_id'=>$actor->id,'action'=>$action,'correlation_id'=>(string)Str::uuid(),'before'=>$before?:null,'after'=>$after?:null,'result'=>'success']);}public function mask(string$email):string{[$local,$domain]=array_pad(explode('@',$email,2),2,'');return mb_substr($local,0,1).'***@'.$domain;}}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
final class ProjectDomainMatcher {
|
||||
public function matches(string $projectDomain,string $url):bool{$host=parse_url($url,PHP_URL_HOST);if(!is_string($host)||$host==='')return false;$normalize=function(string $v):string{$v=strtolower(rtrim($v,'.'));return str_starts_with($v,'www.')?substr($v,4):$v;};$project=$normalize((string)(parse_url(str_contains($projectDomain,'://')?$projectDomain:'https://'.$projectDomain,PHP_URL_HOST)?:$projectDomain));$host=$normalize($host);return $host===$project||str_ends_with($host,'.'.$project);}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\NormalizedProjectUrl;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class ProjectUrlNormalizer
|
||||
{
|
||||
public function normalize(string $input): NormalizedProjectUrl
|
||||
{
|
||||
$input = trim($input);
|
||||
if ($input === '') throw new InvalidArgumentException('Web sitesi adresi zorunludur.');
|
||||
if (! preg_match('/^[a-z][a-z0-9+.-]*:/i', $input)) $input = 'https://'.$input;
|
||||
$parts = parse_url($input);
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
if (! in_array($scheme, ['http', 'https'], true)) throw new InvalidArgumentException('Yalnızca http ve https adresleri kabul edilir.');
|
||||
if (isset($parts['user']) || isset($parts['pass'])) throw new InvalidArgumentException('Kullanıcı bilgisi içeren adresler kabul edilmez.');
|
||||
$host = strtolower(rtrim((string) ($parts['host'] ?? ''), '.'));
|
||||
if ($host === '') throw new InvalidArgumentException('Geçerli bir alan adı girin.');
|
||||
if (preg_match('/[^\x00-\x7f]/', $host)) {
|
||||
if (! function_exists('idn_to_ascii')) throw new InvalidArgumentException('Uluslararası alan adları bu sunucuda desteklenmiyor.');
|
||||
$host = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: '';
|
||||
}
|
||||
if (str_starts_with($host, 'www.')) $host = substr($host, 4);
|
||||
if ($host === 'localhost' || str_ends_with($host, '.localhost') || filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
throw new InvalidArgumentException('Yerel veya IP adresleri kabul edilmez.');
|
||||
}
|
||||
if (strlen($host) > 253 || ! str_contains($host, '.')) throw new InvalidArgumentException('Geçerli bir alan adı girin.');
|
||||
foreach (explode('.', $host) as $label) {
|
||||
if ($label === '' || strlen($label) > 63 || ! preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/', $label)) {
|
||||
throw new InvalidArgumentException('Geçerli bir alan adı girin.');
|
||||
}
|
||||
}
|
||||
$port = $parts['port'] ?? null;
|
||||
if ($port !== null && ($port < 1 || $port > 65535)) throw new InvalidArgumentException('Geçerli bir port girin.');
|
||||
$includePort = $port !== null && !(($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443));
|
||||
return new NormalizedProjectUrl($host, $scheme.'://'.$host.($includePort ? ':'.$port : ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
use App\Enums\SerpCheckStatus;use App\Models\{Keyword,SerpCheck};use Illuminate\Support\Collection;
|
||||
final class RankingHistoryService{
|
||||
public function checksFor(Collection $keywordIds,\DateTimeInterface $from,\DateTimeInterface $to):Collection{return SerpCheck::query()->whereIn('keyword_id',$keywordIds)->where('status',SerpCheckStatus::Completed)->whereBetween('checked_at',[$from,$to])->with(['keyword.project','results'=>fn($q)=>$q->where('result_type','organic')->where('is_project_domain',true)->orderBy('position')])->orderBy('checked_at')->get()->unique(fn($c)=>$c->keyword_id.'|'.$c->checked_at?->format('Y-m-d H:i:s'))->values();}
|
||||
public function rank(SerpCheck $check):?int{return $check->results->min('position');}
|
||||
public function rows(Collection $checks):Collection{return $checks->groupBy('keyword_id')->flatMap(function(Collection $items){$previous=null;return $items->sortBy('checked_at')->map(function($check)use(&$previous){$rank=$this->rank($check);$row=['check'=>$check,'keyword'=>$check->keyword,'project'=>$check->keyword->project,'rank'=>$rank,'previous'=>$previous,'change'=>$rank!==null&&$previous!==null?$previous-$rank:null,'movement'=>$rank===null?'unranked':($previous===null?'new':($rank<$previous?'winner':($rank>$previous?'loser':'unchanged')))];if($rank!==null)$previous=$rank;return $row;});})->sortByDesc(fn($r)=>$r['check']->checked_at)->values();}
|
||||
public function latest(Collection $rows):Collection{return $rows->groupBy(fn($r)=>$r['keyword']->id)->map(fn($items)=>$items->sortByDesc(fn($r)=>$r['check']->checked_at)->first())->values();}
|
||||
public function summary(Collection $rows,int $tracked):array{$latest=$this->latest($rows);$ranked=$latest->whereNotNull('rank');return ['tracked'=>$tracked,'average'=>$ranked->isEmpty()?null:round($ranked->avg('rank'),1),'top3'=>$ranked->where('rank','<=',3)->count(),'top10'=>$ranked->where('rank','<=',10)->count(),'winners'=>$latest->where('movement','winner')->count(),'losers'=>$latest->where('movement','loser')->count()];}
|
||||
public function distribution(Collection $rows):array{$latest=$this->latest($rows);return ['top3'=>$latest->whereBetween('rank',[1,3])->count(),'4_10'=>$latest->whereBetween('rank',[4,10])->count(),'11_20'=>$latest->whereBetween('rank',[11,20])->count(),'21_50'=>$latest->whereBetween('rank',[21,50])->count(),'51_100'=>$latest->whereBetween('rank',[51,100])->count(),'unranked'=>$latest->whereNull('rank')->count()];}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\RoleName;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\Role;
|
||||
use App\Models\RoleAssignmentAudit;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class RoleAssignmentService
|
||||
{
|
||||
/**
|
||||
* Kullanıcı bilgileriyle çoklu rol pivotunu atomik olarak günceller ve sonucu audit kaydına yazar.
|
||||
*
|
||||
* @param User $actor Değişikliği yapan kullanıcı.
|
||||
* @param User $subject Bilgileri ve rolleri değiştirilecek kullanıcı.
|
||||
* @param array<string, mixed> $attributes Güncellenecek güvenli kullanıcı alanları.
|
||||
* @param array<int, int|string> $roleIds Seçili rollerin benzersizleştirilecek kimlikleri.
|
||||
* @return User Güncel kullanıcı ve rol ilişkileri.
|
||||
*
|
||||
* @throws Throwable Yetkilendirme, doğrulama veya kalıcılık işlemi başarısız olduğunda.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
public function update(User $actor, User $subject, array $attributes, array $roleIds): User
|
||||
{
|
||||
$correlationId = (string) Str::uuid();
|
||||
$previousRoles = $subject->roles()->pluck('name')->sort()->values()->all();
|
||||
$requestedRoles = $this->roleNamesForIds($roleIds);
|
||||
|
||||
try {
|
||||
if (! $actor->can('update', $subject) || ! $actor->hasPermission('roles.assign')) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
return Cache::lock('super-admin-governance', 10)->block(5, fn () => Cache::lock('user-role-update:'.$subject->id, 10)->block(5, fn () => DB::transaction(function () use ($actor, $subject, $attributes, $roleIds, $correlationId) {
|
||||
$locked = User::query()->lockForUpdate()->findOrFail($subject->id);
|
||||
$roles = Role::query()->whereIn('id', array_values(array_unique($roleIds)))->get();
|
||||
if ($roles->count() !== count(array_unique($roleIds))) {
|
||||
throw ValidationException::withMessages(['data.roles' => __('roles.invalid')]);
|
||||
}
|
||||
if (($attributes['status'] ?? $locked->status->value) === UserStatus::Active->value && $roles->isEmpty()) {
|
||||
throw ValidationException::withMessages(['data.roles' => __('roles.required')]);
|
||||
}
|
||||
|
||||
$before = $locked->roles()->pluck('name')->sort()->values()->all();
|
||||
$after = $roles->pluck('name')->sort()->values()->all();
|
||||
$touchesSuperAdmin = in_array(RoleName::SuperAdmin->value, array_merge($before, $after), true);
|
||||
if ($touchesSuperAdmin && ! $actor->hasRole(RoleName::SuperAdmin)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
if ($actor->is($locked) && in_array(RoleName::SuperAdmin->value, $before, true) && ! in_array(RoleName::SuperAdmin->value, $after, true)) {
|
||||
throw ValidationException::withMessages(['data.roles' => __('roles.self_remove')]);
|
||||
}
|
||||
$wouldDisableSuper = in_array(RoleName::SuperAdmin->value, $before, true) && (($attributes['status'] ?? $locked->status->value) !== UserStatus::Active->value || ! in_array(RoleName::SuperAdmin->value, $after, true));
|
||||
if ($wouldDisableSuper && $this->activeSuperAdminCount($locked->id) === 0) {
|
||||
throw ValidationException::withMessages(['data.roles' => __('roles.last_super_admin')]);
|
||||
}
|
||||
if ($actor->is($locked) && ($attributes['status'] ?? $locked->status->value) !== UserStatus::Active->value) {
|
||||
throw ValidationException::withMessages(['data.status' => __('roles.self_suspend')]);
|
||||
}
|
||||
|
||||
$locked->fill($attributes)->save();
|
||||
$locked->roles()->sync($roles->modelKeys());
|
||||
RoleAssignmentAudit::create([
|
||||
'actor_user_id' => $actor->id, 'subject_user_id' => $locked->id, 'correlation_id' => $correlationId,
|
||||
'previous_roles' => $before, 'new_roles' => $after, 'added_roles' => array_values(array_diff($after, $before)),
|
||||
'removed_roles' => array_values(array_diff($before, $after)), 'result' => 'success',
|
||||
]);
|
||||
|
||||
return $locked->refresh();
|
||||
}, 3)));
|
||||
} catch (Throwable $exception) {
|
||||
RoleAssignmentAudit::create([
|
||||
'actor_user_id' => $actor->id, 'subject_user_id' => $subject->id, 'correlation_id' => $correlationId,
|
||||
'previous_roles' => $previousRoles, 'new_roles' => $requestedRoles,
|
||||
'added_roles' => array_values(array_diff($requestedRoles, $previousRoles)),
|
||||
'removed_roles' => array_values(array_diff($previousRoles, $requestedRoles)), 'result' => 'failure',
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* İstemciden gelen rol kimliklerini mevcut rol kayıtlarındaki sıralı adlara dönüştürür.
|
||||
*
|
||||
* @param array<int, int|string> $roleIds Çözümlenecek rol kimlikleri.
|
||||
* @return array<int, string> Veritabanında bulunan benzersiz rol adları.
|
||||
*
|
||||
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
|
||||
*/
|
||||
private function roleNamesForIds(array $roleIds): array
|
||||
{
|
||||
return Role::query()->whereIn('id', array_values(array_unique($roleIds)))
|
||||
->pluck('name')->sort()->values()->all();
|
||||
}
|
||||
|
||||
public function replaceRole(User $actor, User $subject, RoleName|string $role): void
|
||||
{
|
||||
$name = $role instanceof RoleName ? $role->value : $role;
|
||||
$this->update($actor, $subject, [], [Role::where('name', $name)->sole()->id]);
|
||||
}
|
||||
|
||||
public function delete(User $actor, User $subject): void
|
||||
{
|
||||
if ($actor->is($subject)) {
|
||||
throw ValidationException::withMessages(['user' => __('roles.self_delete')]);
|
||||
}
|
||||
|
||||
if (! $actor->can('delete', $subject)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
Cache::lock('super-admin-governance', 10)->block(5, fn () => DB::transaction(function () use ($actor, $subject) {
|
||||
$locked = User::query()->lockForUpdate()->findOrFail($subject->id);
|
||||
if (! $actor->can('delete', $locked)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
if ($locked->hasRole(RoleName::SuperAdmin) && $this->activeSuperAdminCount($locked->id) === 0) {
|
||||
throw ValidationException::withMessages(['user' => __('roles.last_super_admin_delete')]);
|
||||
}
|
||||
DB::table('sessions')->where('user_id', $locked->id)->delete();
|
||||
$locked->tokens()->delete();
|
||||
$locked->forceFill(['remember_token' => Str::random(60)])->saveQuietly();
|
||||
$locked->delete();
|
||||
}, 3));
|
||||
}
|
||||
|
||||
private function activeSuperAdminCount(int $exceptId): int
|
||||
{
|
||||
return User::query()->where('id', '!=', $exceptId)->where('status', UserStatus::Active->value)
|
||||
->whereHas('roles', fn ($q) => $q->where('name', RoleName::SuperAdmin->value))->lockForUpdate()->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\KeywordStatus;
|
||||
use App\Enums\ProjectStatus;
|
||||
use App\Enums\SerpCheckStatus;
|
||||
use App\Jobs\RunSerpCheck;
|
||||
use App\Jobs\SubmitSerpCheck;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\SerpCheck;
|
||||
use App\Models\User;
|
||||
use App\Serp\Data\SerpQuery;
|
||||
use App\Serp\Operations\ActivationManager;
|
||||
use App\Serp\SerpProviderRegistry;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class SerpCheckCreator
|
||||
{
|
||||
public function __construct(private SerpProviderRegistry $providers, private ActivationManager $activation) {}
|
||||
|
||||
public function create(User $user, Keyword $keyword, bool $dispatch = true, string $origin = 'manual'): SerpCheck
|
||||
{
|
||||
$keyword->refresh()->loadMissing('project');
|
||||
if ($keyword->project->user_id !== $user->id || $keyword->status !== KeywordStatus::Active || $keyword->archived_at !== null || $keyword->project->status !== ProjectStatus::Active || $keyword->project->archived_at !== null) {
|
||||
throw ValidationException::withMessages(['serp' => 'Bu anahtar kelime için kontrol başlatılamaz.']);
|
||||
}if (! config('serp.enabled')) {
|
||||
throw ValidationException::withMessages(['serp' => 'SERP kontrol hizmeti henüz yapılandırılmamış.']);
|
||||
}$provider = $this->providers->resolve();
|
||||
if ($provider->key() === 'dataforseo' && ! $this->activation->allowsNormal()) {
|
||||
throw ValidationException::withMessages(['serp' => 'SERP kontrol hizmeti şu anda kullanılamıyor.']);
|
||||
}$query = new SerpQuery($keyword->phrase, $keyword->search_engine->value, $keyword->country_code, $keyword->language_code, $keyword->device->value, 10);
|
||||
if (! $provider->supports($query)) {
|
||||
throw ValidationException::withMessages(['serp' => 'Seçili hedef yapılandırılmış sağlayıcı tarafından desteklenmiyor.']);
|
||||
}if (! in_array($origin, ['manual', 'scheduled'], true)) {
|
||||
throw ValidationException::withMessages(['serp' => 'Geçersiz kontrol kaynağı.']);
|
||||
}$mode = $provider->key() === 'dataforseo' ? ($origin === 'manual' ? (string) config('serp.manual_mode') : (string) config('serp.automatic_mode')) : 'live';
|
||||
if (! in_array($mode, ['live', 'standard'], true)) {
|
||||
throw ValidationException::withMessages(['serp' => 'Geçersiz yürütme modu.']);
|
||||
}
|
||||
|
||||
return Cache::lock('serp-create:'.$keyword->id, 10)->block(3, function () use ($user, $keyword, $dispatch, $origin, $mode) {
|
||||
if ($keyword->serpChecks()->whereIn('status', ['pending', 'running'])->exists()) {
|
||||
throw ValidationException::withMessages(['serp' => 'Bu anahtar kelime için devam eden bir kontrol var.']);
|
||||
}$cool = (int) config('serp.keyword_cooldown_minutes');
|
||||
if ($keyword->serpChecks()->where('created_at', '>', now()->subMinutes($cool))->exists()) {
|
||||
throw ValidationException::withMessages(['serp' => 'Yeni kontrol için bekleme süresi henüz dolmadı.']);
|
||||
}$used = SerpCheck::whereHas('keyword.project', fn ($q) => $q->where('user_id', $user->id))->where('created_at', '>=', now()->startOfDay())->count();
|
||||
if ($used >= (int) config('serp.daily_user_quota')) {
|
||||
throw ValidationException::withMessages(['serp' => 'Günlük SERP kontrol kotanıza ulaştınız.']);
|
||||
}$check = DB::transaction(fn () => $keyword->serpChecks()->create(['phrase' => $keyword->phrase, 'status' => SerpCheckStatus::Pending, 'active_slot' => 'active', 'search_engine' => $keyword->search_engine, 'country_code' => $keyword->country_code, 'language_code' => $keyword->language_code, 'device' => $keyword->device, 'provider' => config('serp.provider'), 'execution_mode' => $mode, 'origin' => $origin, 'result_count' => 0]));
|
||||
if ($dispatch) {
|
||||
$job = $mode === 'standard' ? new SubmitSerpCheck($check->ulid) : new RunSerpCheck($check->ulid);
|
||||
dispatch($job->onQueue(config('serp.queue')))->afterCommit();
|
||||
}
|
||||
|
||||
return $check;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SerpCheckStatus;
|
||||
use App\Models\SerpCheck;
|
||||
use App\Serp\Data\SerpResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SerpResultPersister
|
||||
{
|
||||
public function __construct(private ProjectDomainMatcher $matcher) {}
|
||||
|
||||
public function complete(SerpCheck $check, SerpResponse $response, array $metadata = []): void
|
||||
{
|
||||
DB::transaction(function () use ($check, $response, $metadata) {
|
||||
$locked = SerpCheck::with('keyword.project')->whereKey($check->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->status !== SerpCheckStatus::Running) {
|
||||
return;
|
||||
}$locked->results()->delete();
|
||||
foreach ($response->results as $r) {
|
||||
$locked->results()->create(['position' => $r->position, 'result_type' => $r->resultType, 'title' => $r->title, 'url' => $r->url, 'display_url' => $r->displayUrl, 'snippet' => $r->snippet, 'is_project_domain' => $this->matcher->matches($locked->keyword->project->domain, $r->url)]);
|
||||
}$locked->complete($metadata + ['provider' => $response->providerKey, 'provider_reference' => $response->referenceId ?: $locked->provider_reference, 'result_count' => count($response->results)]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
use App\Models\SerpCheck;use Illuminate\Support\Collection;
|
||||
final class VisibilityReportService{
|
||||
public const FORMULA_VERSION='rank_weighted_v1';
|
||||
public function weight(?int $rank):string{return match(true){$rank===1=>'1.0000',$rank===2=>'0.8500',$rank===3=>'0.7500',$rank>=4&&$rank<=5=>'0.6000',$rank>=6&&$rank<=10=>'0.4000',$rank>=11&&$rank<=20=>'0.2000',$rank>=21&&$rank<=50=>'0.0800',$rank>=51&&$rank<=100=>'0.0200',default=>'0.0000'};}
|
||||
public function measurements(Collection $keywordIds,\DateTimeInterface $from,\DateTimeInterface $to):Collection{return SerpCheck::query()->whereIn('keyword_id',$keywordIds)->where('status','completed')->whereBetween('checked_at',[$from,$to])->with(['keyword.project','results'=>fn($q)=>$q->where('result_type','organic')->where('is_project_domain',true)->orderBy('position')])->orderBy('checked_at')->get()->unique(fn($c)=>$c->keyword_id.'|'.$c->checked_at?->format('Y-m-d H:i:s'))->groupBy(fn($c)=>$c->checked_at->toDateString())->map(fn($day)=>$day->groupBy('keyword_id')->map(fn($items)=>$items->last())->values())->flatten(1)->values();}
|
||||
public function rank(SerpCheck $check):?int{$rank=$check->results->min('position');return $rank&&$rank>0?$rank:null;}
|
||||
public function latest(Collection $checks):Collection{return $checks->groupBy('keyword_id')->map(fn($items)=>$items->sortByDesc('checked_at')->first())->values();}
|
||||
public function score(Collection $checks):?float{if($checks->isEmpty())return null;$sum=$checks->sum(fn($c)=>(float)$this->weight($this->rank($c)));return round(($sum/$checks->count())*100,2);}
|
||||
public function trend(Collection $checks):Collection{return $checks->groupBy(fn($c)=>$c->checked_at->toDateString())->map(fn($day,$date)=>['date'=>$date,'score'=>$this->score($day),'measured'=>$day->count()])->values();}
|
||||
public function distribution(Collection $latest,int $total):array{$ranks=$latest->map(fn($c)=>$this->rank($c));return ['top3'=>$ranks->filter(fn($r)=>$r>=1&&$r<=3)->count(),'4_10'=>$ranks->filter(fn($r)=>$r>=4&&$r<=10)->count(),'11_20'=>$ranks->filter(fn($r)=>$r>=11&&$r<=20)->count(),'21_50'=>$ranks->filter(fn($r)=>$r>=21&&$r<=50)->count(),'51_100'=>$ranks->filter(fn($r)=>$r>=51&&$r<=100)->count(),'unranked'=>$ranks->filter(fn($r)=>$r===null)->count(),'not_measured'=>max(0,$total-$latest->count())];}
|
||||
public function contributions(Collection $current,Collection $previous):Collection{$prior=$previous->keyBy('keyword_id');return $current->map(function($check)use($prior){$old=$prior->get($check->keyword_id);$delta=round(((float)$this->weight($this->rank($check))-(float)$this->weight($old?$this->rank($old):null))*100,2);return ['keyword'=>$check->keyword,'project'=>$check->keyword->project,'previous'=>$old?$this->rank($old):null,'current'=>$this->rank($check),'delta'=>$delta];});}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace App\Services;use App\Models\SerpCheck;use Illuminate\Support\Collection;
|
||||
final class WinnersReportService{public function __construct(private VisibilityReportService $visibility){}public function rows(Collection $keywordIds,\DateTimeInterface $from,\DateTimeInterface $to):Collection{$period=$this->visibility->measurements($keywordIds,$from,$to)->groupBy('keyword_id');$before=SerpCheck::query()->whereIn('keyword_id',$keywordIds)->where('status','completed')->where('checked_at','<',$from)->with(['keyword.project','results'=>fn($q)=>$q->where('result_type','organic')->where('is_project_domain',true)->orderBy('position')])->latest('checked_at')->get()->unique('keyword_id')->keyBy('keyword_id');return $keywordIds->map(function($id)use($period,$before){$items=$period->get($id,collect())->sortBy('checked_at')->values();if($items->isEmpty())return null;$currentCheck=$items->last();$previousCheck=$items->count()>1?$items->first():$before->get($id);$current=$this->visibility->rank($currentCheck);$previous=$previousCheck?$this->visibility->rank($previousCheck):null;$first=$previousCheck===null;$entry=$previousCheck!==null&&$previous===null&&$current!==null;$gained=$previous!==null&&$current!==null&&$current<$previous?$previous-$current:null;return ['keyword'=>$currentCheck->keyword,'project'=>$currentCheck->keyword->project,'previous'=>$previous,'current'=>$current,'gained'=>$gained,'contribution'=>round(((float)$this->visibility->weight($current)-(float)$this->visibility->weight($previous))*100,2),'new_entry'=>$entry,'first_measurement'=>$first,'category'=>$this->category($previous,$current,$entry,$first),'checked_at'=>$currentCheck->checked_at];})->filter()->values();}public function category(?int$p,?int$c,bool$entry=false,bool$first=false):string{if($first)return'first_measurement';if($entry)return'new_entry';if($c===null||$p===null||$c>=$p)return'not_winner';return match(true){$c<=3&&$p>3=>'entered_top3',$c<=10&&$p>10=>'entered_top10',$c<=20&&$p>20=>'entered_top20',$c<=100&&$p>100=>'entered_top100',default=>'improved'};}public function winners(Collection$r):Collection{return$r->filter(fn($x)=>$x['gained']!==null||$x['new_entry'])->values();}public function summary(Collection$r):array{$w=$this->winners($r);$comparable=$r->where('first_measurement',false);return ['winners'=>$w->whereNotNull('gained')->count(),'gained'=>$w->sum('gained'),'top3'=>$w->where('category','entered_top3')->count(),'top10'=>$w->where('category','entered_top10')->count(),'new_entries'=>$w->where('new_entry',true)->count(),'contribution'=>$comparable->isEmpty()?null:round($w->sum('contribution')/$comparable->count(),2)];}public function distribution(Collection$r):array{$w=$this->winners($r);return ['1_3'=>$w->filter(fn($x)=>$x['gained']>=1&&$x['gained']<=3)->count(),'4_10'=>$w->filter(fn($x)=>$x['gained']>=4&&$x['gained']<=10)->count(),'11_20'=>$w->filter(fn($x)=>$x['gained']>=11&&$x['gained']<=20)->count(),'over20'=>$w->filter(fn($x)=>$x['gained']>20)->count(),'new_entry'=>$w->where('new_entry',true)->count()];}}
|
||||
Reference in New Issue
Block a user