Upload beautify
This commit is contained in:
55
app/Services/Artworks/ArtworkDraftService.php
Normal file
55
app/Services/Artworks/ArtworkDraftService.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Artworks;
|
||||
|
||||
use App\DTOs\Artworks\ArtworkDraftResult;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class ArtworkDraftService
|
||||
{
|
||||
public function createDraft(int $userId, string $title, ?string $description): ArtworkDraftResult
|
||||
{
|
||||
return DB::transaction(function () use ($userId, $title, $description) {
|
||||
$slug = $this->uniqueSlug($title);
|
||||
|
||||
$artwork = Artwork::create([
|
||||
'user_id' => $userId,
|
||||
'title' => $title,
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
'file_name' => 'pending',
|
||||
'file_path' => 'pending',
|
||||
'file_size' => 0,
|
||||
'mime_type' => 'application/octet-stream',
|
||||
'width' => 1,
|
||||
'height' => 1,
|
||||
'is_public' => false,
|
||||
'is_approved' => false,
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
return new ArtworkDraftResult((int) $artwork->id, 'draft');
|
||||
});
|
||||
}
|
||||
|
||||
private function uniqueSlug(string $title): string
|
||||
{
|
||||
$base = Str::slug($title);
|
||||
$base = $base !== '' ? $base : 'artwork';
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$suffix = Str::lower(Str::random(6));
|
||||
$slug = Str::limit($base . '-' . $suffix, 160, '');
|
||||
|
||||
if (! Artwork::where('slug', $slug)->exists()) {
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
|
||||
return Str::limit($base . '-' . Str::uuid()->toString(), 160, '');
|
||||
}
|
||||
}
|
||||
169
app/Services/AvatarService.php
Normal file
169
app/Services/AvatarService.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Intervention\Image\ImageManagerStatic as Image;
|
||||
use RuntimeException;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class AvatarService
|
||||
{
|
||||
protected $sizes = [
|
||||
'xs' => 32,
|
||||
'sm' => 64,
|
||||
'md' => 128,
|
||||
'lg' => 256,
|
||||
'xl' => 512,
|
||||
];
|
||||
|
||||
protected $quality = 85;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Guard: if Intervention Image is not installed, defer error until actual use
|
||||
if (class_exists(\Intervention\Image\ImageManagerStatic::class)) {
|
||||
try {
|
||||
Image::configure(['driver' => extension_loaded('gd') ? 'gd' : 'imagick']);
|
||||
$this->imageAvailable = true;
|
||||
} catch (\Throwable $e) {
|
||||
// If configuration fails, treat as unavailable and log for diagnostics
|
||||
logger()->warning('Intervention Image present but configuration failed: '.$e->getMessage());
|
||||
$this->imageAvailable = false;
|
||||
}
|
||||
} else {
|
||||
$this->imageAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an uploaded file for a user and store webp sizes.
|
||||
* Returns the computed sha1 hash.
|
||||
*
|
||||
* @param int $userId
|
||||
* @param UploadedFile $file
|
||||
* @return string sha1 hash
|
||||
*/
|
||||
public function storeFromUploadedFile(int $userId, UploadedFile $file): string
|
||||
{
|
||||
if (! $this->imageAvailable) {
|
||||
throw new RuntimeException('Intervention Image is not available. If you just installed the package, restart your PHP process (php artisan serve or PHP-FPM) and run `composer dump-autoload -o`.');
|
||||
}
|
||||
|
||||
// Load image and re-encode to webp after validating
|
||||
try {
|
||||
$img = Image::make($file->getRealPath());
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException('Failed to read uploaded image: '.$e->getMessage());
|
||||
}
|
||||
|
||||
// Ensure square center crop per spec
|
||||
$max = max($img->width(), $img->height());
|
||||
$img->fit($max, $max);
|
||||
|
||||
$basePath = "avatars/{$userId}";
|
||||
Storage::disk('public')->makeDirectory($basePath);
|
||||
|
||||
// Save original as webp
|
||||
$originalData = (string) $img->encode('webp', $this->quality);
|
||||
Storage::disk('public')->put($basePath . '/original.webp', $originalData);
|
||||
|
||||
// Generate sizes
|
||||
foreach ($this->sizes as $name => $size) {
|
||||
$resized = $img->resize($size, $size, function ($constraint) {
|
||||
$constraint->upsize();
|
||||
})->encode('webp', $this->quality);
|
||||
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
|
||||
}
|
||||
|
||||
$hash = sha1($originalData);
|
||||
$mime = 'image/webp';
|
||||
|
||||
// Persist metadata to user_profiles if exists, otherwise users table fallbacks
|
||||
if (SchemaHasTable('user_profiles')) {
|
||||
DB::table('user_profiles')->where('user_id', $userId)->update([
|
||||
'avatar_hash' => $hash,
|
||||
'avatar_updated_at' => Carbon::now(),
|
||||
'avatar_mime' => $mime,
|
||||
]);
|
||||
} else {
|
||||
DB::table('users')->where('id', $userId)->update([
|
||||
'avatar_hash' => $hash,
|
||||
'avatar_updated_at' => Carbon::now(),
|
||||
'avatar_mime' => $mime,
|
||||
]);
|
||||
}
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a legacy file path for a user (path-to-file).
|
||||
* Returns sha1 or null when missing.
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $path Absolute filesystem path
|
||||
* @return string|null
|
||||
*/
|
||||
public function storeFromLegacyFile(int $userId, string $path): ?string
|
||||
{
|
||||
if (!file_exists($path) || !is_readable($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$img = Image::make($path);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$max = max($img->width(), $img->height());
|
||||
$img->fit($max, $max);
|
||||
|
||||
$basePath = "avatars/{$userId}";
|
||||
Storage::disk('public')->makeDirectory($basePath);
|
||||
|
||||
$originalData = (string) $img->encode('webp', $this->quality);
|
||||
Storage::disk('public')->put($basePath . '/original.webp', $originalData);
|
||||
|
||||
foreach ($this->sizes as $name => $size) {
|
||||
$resized = $img->resize($size, $size, function ($constraint) {
|
||||
$constraint->upsize();
|
||||
})->encode('webp', $this->quality);
|
||||
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
|
||||
}
|
||||
|
||||
$hash = sha1($originalData);
|
||||
$mime = 'image/webp';
|
||||
|
||||
if (SchemaHasTable('user_profiles')) {
|
||||
DB::table('user_profiles')->where('user_id', $userId)->update([
|
||||
'avatar_hash' => $hash,
|
||||
'avatar_updated_at' => Carbon::now(),
|
||||
'avatar_mime' => $mime,
|
||||
]);
|
||||
} else {
|
||||
DB::table('users')->where('id', $userId)->update([
|
||||
'avatar_hash' => $hash,
|
||||
'avatar_updated_at' => Carbon::now(),
|
||||
'avatar_mime' => $mime,
|
||||
]);
|
||||
}
|
||||
|
||||
return $hash;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: check for table existence without importing Schema facade repeatedly
|
||||
*/
|
||||
function SchemaHasTable(string $name): bool
|
||||
{
|
||||
try {
|
||||
return \Illuminate\Support\Facades\Schema::hasTable($name);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
139
app/Services/Recommendations/FeedOfflineEvaluationService.php
Normal file
139
app/Services/Recommendations/FeedOfflineEvaluationService.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class FeedOfflineEvaluationService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function evaluateAlgo(string $algoVersion, string $from, string $to): array
|
||||
{
|
||||
$row = DB::table('feed_daily_metrics')
|
||||
->selectRaw('SUM(impressions) as impressions')
|
||||
->selectRaw('SUM(clicks) as clicks')
|
||||
->selectRaw('SUM(saves) as saves')
|
||||
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
|
||||
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
|
||||
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
|
||||
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
|
||||
->where('algo_version', $algoVersion)
|
||||
->whereBetween('metric_date', [$from, $to])
|
||||
->first();
|
||||
|
||||
$impressions = (int) ($row->impressions ?? 0);
|
||||
$clicks = (int) ($row->clicks ?? 0);
|
||||
$saves = (int) ($row->saves ?? 0);
|
||||
|
||||
$dwell05 = (int) ($row->dwell_0_5 ?? 0);
|
||||
$dwell530 = (int) ($row->dwell_5_30 ?? 0);
|
||||
$dwell30120 = (int) ($row->dwell_30_120 ?? 0);
|
||||
$dwell120Plus = (int) ($row->dwell_120_plus ?? 0);
|
||||
|
||||
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
|
||||
$longDwellShare = $clicks > 0 ? ($dwell30120 + $dwell120Plus) / $clicks : 0.0;
|
||||
$bounceRate = $clicks > 0 ? $dwell05 / $clicks : 0.0;
|
||||
|
||||
$objectiveWeights = (array) config('discovery.evaluation.objective_weights', []);
|
||||
$wCtr = (float) ($objectiveWeights['ctr'] ?? 0.45);
|
||||
$wSave = (float) ($objectiveWeights['save_rate'] ?? 0.35);
|
||||
$wLong = (float) ($objectiveWeights['long_dwell_share'] ?? 0.25);
|
||||
$wBouncePenalty = (float) ($objectiveWeights['bounce_rate_penalty'] ?? 0.15);
|
||||
|
||||
$saveRateInformational = (bool) config('discovery.evaluation.save_rate_informational', true);
|
||||
if ($saveRateInformational) {
|
||||
$wSave = 0.0;
|
||||
}
|
||||
|
||||
$normalizationSum = $wCtr + $wSave + $wLong + $wBouncePenalty;
|
||||
if ($normalizationSum > 0.0) {
|
||||
$wCtr /= $normalizationSum;
|
||||
$wSave /= $normalizationSum;
|
||||
$wLong /= $normalizationSum;
|
||||
$wBouncePenalty /= $normalizationSum;
|
||||
}
|
||||
|
||||
$objectiveScore = ($wCtr * $ctr)
|
||||
+ ($wSave * $saveRate)
|
||||
+ ($wLong * $longDwellShare)
|
||||
- ($wBouncePenalty * $bounceRate);
|
||||
|
||||
return [
|
||||
'algo_version' => $algoVersion,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'saves' => $saves,
|
||||
'ctr' => round($ctr, 6),
|
||||
'save_rate' => round($saveRate, 6),
|
||||
'long_dwell_share' => round($longDwellShare, 6),
|
||||
'bounce_rate' => round($bounceRate, 6),
|
||||
'dwell_buckets' => [
|
||||
'0_5' => $dwell05,
|
||||
'5_30' => $dwell530,
|
||||
'30_120' => $dwell30120,
|
||||
'120_plus' => $dwell120Plus,
|
||||
],
|
||||
'objective_score' => round($objectiveScore, 6),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function evaluateAll(string $from, string $to): array
|
||||
{
|
||||
$algoVersions = DB::table('feed_daily_metrics')
|
||||
->select('algo_version')
|
||||
->whereBetween('metric_date', [$from, $to])
|
||||
->distinct()
|
||||
->orderBy('algo_version')
|
||||
->pluck('algo_version')
|
||||
->map(static fn (mixed $v): string => (string) $v)
|
||||
->all();
|
||||
|
||||
$out = [];
|
||||
foreach ($algoVersions as $algoVersion) {
|
||||
$out[] = $this->evaluateAlgo($algoVersion, $from, $to);
|
||||
}
|
||||
|
||||
usort($out, static fn (array $a, array $b): int => $b['objective_score'] <=> $a['objective_score']);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function compareBaselineCandidate(string $baselineAlgoVersion, string $candidateAlgoVersion, string $from, string $to): array
|
||||
{
|
||||
$baseline = $this->evaluateAlgo($baselineAlgoVersion, $from, $to);
|
||||
$candidate = $this->evaluateAlgo($candidateAlgoVersion, $from, $to);
|
||||
|
||||
$deltaObjective = (float) $candidate['objective_score'] - (float) $baseline['objective_score'];
|
||||
$objectiveLiftPct = (float) $baseline['objective_score'] !== 0.0
|
||||
? ($deltaObjective / (float) $baseline['objective_score']) * 100.0
|
||||
: null;
|
||||
|
||||
return [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'baseline' => $baseline,
|
||||
'candidate' => $candidate,
|
||||
'delta' => [
|
||||
'objective_score' => round($deltaObjective, 6),
|
||||
'objective_lift_pct' => $objectiveLiftPct !== null ? round($objectiveLiftPct, 4) : null,
|
||||
'ctr' => round((float) $candidate['ctr'] - (float) $baseline['ctr'], 6),
|
||||
'save_rate' => round((float) $candidate['save_rate'] - (float) $baseline['save_rate'], 6),
|
||||
'long_dwell_share' => round((float) $candidate['long_dwell_share'] - (float) $baseline['long_dwell_share'], 6),
|
||||
'bounce_rate' => round((float) $candidate['bounce_rate'] - (float) $baseline['bounce_rate'], 6),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
567
app/Services/Recommendations/PersonalizedFeedService.php
Normal file
567
app/Services/Recommendations/PersonalizedFeedService.php
Normal file
@@ -0,0 +1,567 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use App\Jobs\RegenerateUserRecommendationCacheJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\UserInterestProfile;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class PersonalizedFeedService
|
||||
{
|
||||
public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
|
||||
{
|
||||
$safeLimit = max(1, min(50, $limit));
|
||||
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
|
||||
$weightSet = $this->resolveRankingWeights($resolvedAlgoVersion);
|
||||
$offset = $this->decodeCursorToOffset($cursor);
|
||||
|
||||
$cache = UserRecommendationCache::query()
|
||||
->where('user_id', $userId)
|
||||
->where('algo_version', $resolvedAlgoVersion)
|
||||
->first();
|
||||
|
||||
$cacheItems = $this->extractCacheItems($cache);
|
||||
$isFresh = $cache !== null && $cache->expires_at !== null && $cache->expires_at->isFuture();
|
||||
|
||||
$cacheStatus = 'hit';
|
||||
if ($cache === null) {
|
||||
$cacheStatus = 'miss';
|
||||
} elseif (! $isFresh) {
|
||||
$cacheStatus = 'stale';
|
||||
}
|
||||
|
||||
if ($cache === null || ! $isFresh) {
|
||||
RegenerateUserRecommendationCacheJob::dispatch($userId, $resolvedAlgoVersion)
|
||||
->onQueue((string) config('discovery.queue', 'default'));
|
||||
}
|
||||
|
||||
$items = $cacheItems;
|
||||
if ($items === []) {
|
||||
$items = $this->buildColdStartRecommendations($resolvedAlgoVersion, 240, 'fallback');
|
||||
$cacheStatus = $cacheStatus . '-fallback';
|
||||
}
|
||||
|
||||
return $this->buildFeedPageResponse(
|
||||
items: $items,
|
||||
offset: $offset,
|
||||
limit: $safeLimit,
|
||||
algoVersion: $resolvedAlgoVersion,
|
||||
weightVersion: (string) $weightSet['version'],
|
||||
cacheStatus: $cacheStatus,
|
||||
generatedAt: $cache?->generated_at?->toIso8601String()
|
||||
);
|
||||
}
|
||||
|
||||
public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
|
||||
{
|
||||
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
|
||||
$cacheVersion = (string) config('discovery.cache_version', 'cache-v1');
|
||||
$ttlMinutes = max(1, (int) config('discovery.cache_ttl_minutes', 60));
|
||||
|
||||
$items = $this->buildRecommendations($userId, $resolvedAlgoVersion, 240);
|
||||
$generatedAt = now();
|
||||
$expiresAt = now()->addMinutes($ttlMinutes);
|
||||
|
||||
UserRecommendationCache::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'algo_version' => $resolvedAlgoVersion,
|
||||
],
|
||||
[
|
||||
'cache_version' => $cacheVersion,
|
||||
'recommendations_json' => [
|
||||
'items' => $items,
|
||||
'algo_version' => $resolvedAlgoVersion,
|
||||
'weight_version' => (string) $this->resolveRankingWeights($resolvedAlgoVersion)['version'],
|
||||
'generated_at' => $generatedAt->toIso8601String(),
|
||||
],
|
||||
'generated_at' => $generatedAt,
|
||||
'expires_at' => $expiresAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
public function buildRecommendations(int $userId, string $algoVersion, int $maxItems = 240): array
|
||||
{
|
||||
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
|
||||
|
||||
$profile = UserInterestProfile::query()
|
||||
->where('user_id', $userId)
|
||||
->where('profile_version', $profileVersion)
|
||||
->where('algo_version', $algoVersion)
|
||||
->first();
|
||||
|
||||
$normalized = $profile !== null ? (array) ($profile->normalized_scores_json ?? []) : [];
|
||||
$personalized = $this->buildProfileBasedRecommendations($normalized, $maxItems, $algoVersion);
|
||||
|
||||
if ($personalized === []) {
|
||||
return $this->buildColdStartRecommendations($algoVersion, $maxItems, 'cold_start');
|
||||
}
|
||||
|
||||
$fallback = $this->buildColdStartRecommendations($algoVersion, $maxItems, 'fallback');
|
||||
|
||||
$combined = [];
|
||||
foreach (array_merge($personalized, $fallback) as $item) {
|
||||
$artworkId = (int) ($item['artwork_id'] ?? 0);
|
||||
if ($artworkId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($combined[$artworkId])) {
|
||||
$combined[$artworkId] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => (float) ($item['score'] ?? 0.0),
|
||||
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((float) $item['score'] > (float) $combined[$artworkId]['score']) {
|
||||
$combined[$artworkId]['score'] = (float) $item['score'];
|
||||
$combined[$artworkId]['source'] = (string) ($item['source'] ?? $combined[$artworkId]['source']);
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = array_values($combined);
|
||||
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||
|
||||
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $normalizedScores
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
private function buildProfileBasedRecommendations(array $normalizedScores, int $maxItems, string $algoVersion): array
|
||||
{
|
||||
$weightSet = $this->resolveRankingWeights($algoVersion);
|
||||
$w1 = (float) $weightSet['w1'];
|
||||
$w2 = (float) $weightSet['w2'];
|
||||
$w3 = (float) $weightSet['w3'];
|
||||
$w4 = (float) $weightSet['w4'];
|
||||
|
||||
$categoryAffinities = [];
|
||||
foreach ($normalizedScores as $key => $score) {
|
||||
if (! is_numeric($score)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with((string) $key, 'category:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryId = (int) str_replace('category:', '', (string) $key);
|
||||
if ($categoryId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryAffinities[$categoryId] = (float) $score;
|
||||
}
|
||||
|
||||
if ($categoryAffinities === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('artworks')
|
||||
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->whereIn('artwork_category.category_id', array_keys($categoryAffinities))
|
||||
->whereNull('artworks.deleted_at')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->where('artworks.published_at', '<=', now())
|
||||
->orderByDesc('artworks.published_at')
|
||||
->limit(max(200, $maxItems * 8))
|
||||
->get([
|
||||
'artworks.id',
|
||||
'artworks.published_at',
|
||||
'artwork_category.category_id',
|
||||
DB::raw('COALESCE(artwork_stats.views, 0) as views'),
|
||||
]);
|
||||
|
||||
$scored = [];
|
||||
foreach ($rows as $row) {
|
||||
$artworkId = (int) $row->id;
|
||||
$categoryId = (int) $row->category_id;
|
||||
$affinity = (float) ($categoryAffinities[$categoryId] ?? 0.0);
|
||||
if ($affinity <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$publishedAt = CarbonImmutable::parse((string) $row->published_at);
|
||||
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||
$recency = exp(-$ageDays / 30.0);
|
||||
$popularity = log(1 + max(0, (int) $row->views)) / 10.0;
|
||||
$novelty = max(0.0, 1.0 - min(1.0, $popularity));
|
||||
|
||||
// Phase 8B blend with versioned weights (manual tuning, no auto-tuning yet).
|
||||
$score = ($w1 * $affinity) + ($w2 * $recency) + ($w3 * $popularity) + ($w4 * $novelty);
|
||||
|
||||
if (! isset($scored[$artworkId]) || $score > $scored[$artworkId]['score']) {
|
||||
$scored[$artworkId] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => $score,
|
||||
'source' => 'personalized',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = array_values($scored);
|
||||
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||
|
||||
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
private function buildColdStartRecommendations(string $algoVersion, int $maxItems, string $sourceLabel = 'cold_start'): array
|
||||
{
|
||||
$popularIds = DB::table('artworks')
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->whereNull('artworks.deleted_at')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->where('artworks.published_at', '<=', now())
|
||||
->orderByDesc('artwork_stats.views')
|
||||
->orderByDesc('artwork_stats.downloads')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->limit(max(40, $maxItems))
|
||||
->pluck('artworks.id')
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
$seedIds = array_slice($popularIds, 0, 12);
|
||||
|
||||
$similarIds = [];
|
||||
if ($seedIds !== []) {
|
||||
$similarIds = DB::table('artwork_similarities')
|
||||
->where('algo_version', $algoVersion)
|
||||
->whereIn('artwork_id', $seedIds)
|
||||
->orderBy('rank')
|
||||
->orderByDesc('score')
|
||||
->limit(max(80, $maxItems * 2))
|
||||
->pluck('similar_artwork_id')
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach ($popularIds as $index => $artworkId) {
|
||||
$candidates[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => max(0.0, 1.0 - ($index * 0.003)),
|
||||
'source' => $sourceLabel,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($similarIds as $index => $artworkId) {
|
||||
$candidates[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => max(0.0, 0.75 - ($index * 0.002)),
|
||||
'source' => $sourceLabel,
|
||||
];
|
||||
}
|
||||
|
||||
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||
|
||||
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{artwork_id:int,score:float,source:string}> $candidates
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
private function applyDiversityGuard(array $candidates, string $algoVersion, int $maxItems): array
|
||||
{
|
||||
if ($candidates === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$uniqueCandidates = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
$artworkId = (int) ($candidate['artwork_id'] ?? 0);
|
||||
if ($artworkId <= 0 || isset($uniqueCandidates[$artworkId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uniqueCandidates[$artworkId] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => (float) ($candidate['score'] ?? 0.0),
|
||||
'source' => (string) ($candidate['source'] ?? 'mixed'),
|
||||
];
|
||||
}
|
||||
|
||||
$flattened = array_values($uniqueCandidates);
|
||||
$candidateIds = array_map(static fn (array $item): int => (int) $item['artwork_id'], $flattened);
|
||||
|
||||
$nearDuplicatePairs = DB::table('artwork_similarities')
|
||||
->where('algo_version', $algoVersion)
|
||||
->where('score', '>=', 0.97)
|
||||
->whereIn('artwork_id', $candidateIds)
|
||||
->whereIn('similar_artwork_id', $candidateIds)
|
||||
->get(['artwork_id', 'similar_artwork_id']);
|
||||
|
||||
$adjacency = [];
|
||||
foreach ($nearDuplicatePairs as $pair) {
|
||||
$left = (int) $pair->artwork_id;
|
||||
$right = (int) $pair->similar_artwork_id;
|
||||
|
||||
if ($left === $right) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$adjacency[$left][$right] = true;
|
||||
$adjacency[$right][$left] = true;
|
||||
}
|
||||
|
||||
$selected = [];
|
||||
$selectedSet = [];
|
||||
|
||||
foreach ($flattened as $candidate) {
|
||||
$id = (int) $candidate['artwork_id'];
|
||||
|
||||
$isNearDuplicate = false;
|
||||
foreach ($selectedSet as $selectedId => $value) {
|
||||
if (($adjacency[$id][$selectedId] ?? false) || ($adjacency[$selectedId][$id] ?? false)) {
|
||||
$isNearDuplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isNearDuplicate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$selected[] = [
|
||||
'artwork_id' => $id,
|
||||
'score' => round((float) $candidate['score'], 6),
|
||||
'source' => (string) $candidate['source'],
|
||||
];
|
||||
$selectedSet[$id] = true;
|
||||
|
||||
if (count($selected) >= $maxItems) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{artwork_id:int,score:float,source:string}> $items
|
||||
*/
|
||||
private function buildFeedPageResponse(
|
||||
array $items,
|
||||
int $offset,
|
||||
int $limit,
|
||||
string $algoVersion,
|
||||
string $weightVersion,
|
||||
string $cacheStatus,
|
||||
?string $generatedAt
|
||||
): array {
|
||||
$safeOffset = max(0, $offset);
|
||||
$pageItems = array_slice($items, $safeOffset, $limit);
|
||||
|
||||
$ids = array_values(array_unique(array_map(
|
||||
static fn (array $item): int => (int) ($item['artwork_id'] ?? 0),
|
||||
$pageItems
|
||||
)));
|
||||
|
||||
/** @var Collection<int, Artwork> $artworks */
|
||||
$artworks = Artwork::query()
|
||||
->with(['user:id,name'])
|
||||
->whereIn('id', $ids)
|
||||
->public()
|
||||
->published()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$responseItems = [];
|
||||
foreach ($pageItems as $item) {
|
||||
$artworkId = (int) ($item['artwork_id'] ?? 0);
|
||||
$artwork = $artworks->get($artworkId);
|
||||
if ($artwork === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$responseItems[] = [
|
||||
'id' => $artwork->id,
|
||||
'slug' => $artwork->slug,
|
||||
'title' => $artwork->title,
|
||||
'thumbnail_url' => $artwork->thumb_url,
|
||||
'author' => $artwork->user?->name,
|
||||
'score' => (float) ($item['score'] ?? 0.0),
|
||||
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||
];
|
||||
}
|
||||
|
||||
$nextOffset = $safeOffset + $limit;
|
||||
$hasNext = $nextOffset < count($items);
|
||||
|
||||
return [
|
||||
'data' => $responseItems,
|
||||
'meta' => [
|
||||
'algo_version' => $algoVersion,
|
||||
'weight_version' => $weightVersion,
|
||||
'cursor' => $this->encodeOffsetToCursor($safeOffset),
|
||||
'next_cursor' => $hasNext ? $this->encodeOffsetToCursor($nextOffset) : null,
|
||||
'limit' => $limit,
|
||||
'cache_status' => $cacheStatus,
|
||||
'generated_at' => $generatedAt,
|
||||
'total_candidates' => count($items),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string
|
||||
{
|
||||
if ($algoVersion !== null && $algoVersion !== '') {
|
||||
return $algoVersion;
|
||||
}
|
||||
|
||||
$forcedAlgoVersion = trim((string) config('discovery.rollout.force_algo_version', ''));
|
||||
if ($forcedAlgoVersion !== '') {
|
||||
return $forcedAlgoVersion;
|
||||
}
|
||||
|
||||
$defaultAlgoVersion = (string) config('discovery.algo_version', 'clip-cosine-v1');
|
||||
$rolloutEnabled = (bool) config('discovery.rollout.enabled', false);
|
||||
if (! $rolloutEnabled || $userId === null || $userId <= 0) {
|
||||
return $defaultAlgoVersion;
|
||||
}
|
||||
|
||||
$baselineAlgoVersion = (string) config('discovery.rollout.baseline_algo_version', $defaultAlgoVersion);
|
||||
$candidateAlgoVersion = (string) config('discovery.rollout.candidate_algo_version', $defaultAlgoVersion);
|
||||
if ($candidateAlgoVersion === '' || $candidateAlgoVersion === $baselineAlgoVersion) {
|
||||
return $baselineAlgoVersion;
|
||||
}
|
||||
|
||||
$activeGate = (string) config('discovery.rollout.active_gate', 'g10');
|
||||
$gates = (array) config('discovery.rollout.gates', []);
|
||||
$gate = (array) ($gates[$activeGate] ?? []);
|
||||
$rolloutPercentage = (int) ($gate['percentage'] ?? 0);
|
||||
$rolloutPercentage = max(0, min(100, $rolloutPercentage));
|
||||
|
||||
if ($rolloutPercentage <= 0) {
|
||||
return $baselineAlgoVersion;
|
||||
}
|
||||
|
||||
if ($rolloutPercentage >= 100) {
|
||||
return $candidateAlgoVersion;
|
||||
}
|
||||
|
||||
$bucket = abs((int) crc32((string) $userId)) % 100;
|
||||
|
||||
return $bucket < $rolloutPercentage
|
||||
? $candidateAlgoVersion
|
||||
: $baselineAlgoVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version:string,w1:float,w2:float,w3:float,w4:float}
|
||||
*/
|
||||
public function resolveRankingWeights(string $algoVersion): array
|
||||
{
|
||||
$defaults = (array) config('discovery.ranking.default_weights', []);
|
||||
$byAlgo = (array) config('discovery.ranking.algo_weight_sets', []);
|
||||
$override = (array) ($byAlgo[$algoVersion] ?? []);
|
||||
|
||||
$resolved = array_merge($defaults, $override);
|
||||
|
||||
$weights = [
|
||||
'version' => (string) ($resolved['version'] ?? 'rank-w-v1'),
|
||||
'w1' => max(0.0, (float) ($resolved['w1'] ?? 0.65)),
|
||||
'w2' => max(0.0, (float) ($resolved['w2'] ?? 0.20)),
|
||||
'w3' => max(0.0, (float) ($resolved['w3'] ?? 0.10)),
|
||||
'w4' => max(0.0, (float) ($resolved['w4'] ?? 0.05)),
|
||||
];
|
||||
|
||||
$sum = $weights['w1'] + $weights['w2'] + $weights['w3'] + $weights['w4'];
|
||||
if ($sum > 0.0) {
|
||||
$weights['w1'] /= $sum;
|
||||
$weights['w2'] /= $sum;
|
||||
$weights['w3'] /= $sum;
|
||||
$weights['w4'] /= $sum;
|
||||
}
|
||||
|
||||
return $weights;
|
||||
}
|
||||
|
||||
private function decodeCursorToOffset(?string $cursor): int
|
||||
{
|
||||
if ($cursor === null || $cursor === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
|
||||
if ($decoded === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$json = json_decode($decoded, true);
|
||||
if (! is_array($json)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, (int) Arr::get($json, 'offset', 0));
|
||||
}
|
||||
|
||||
private function encodeOffsetToCursor(int $offset): string
|
||||
{
|
||||
$payload = json_encode(['offset' => max(0, $offset)]);
|
||||
if (! is_string($payload)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||
*/
|
||||
private function extractCacheItems(?UserRecommendationCache $cache): array
|
||||
{
|
||||
if ($cache === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$raw = (array) ($cache->recommendations_json ?? []);
|
||||
$items = $raw['items'] ?? null;
|
||||
if (! is_array($items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$typed = [];
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$artworkId = (int) ($item['artwork_id'] ?? 0);
|
||||
if ($artworkId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$typed[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'score' => (float) ($item['score'] ?? 0.0),
|
||||
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||
];
|
||||
}
|
||||
|
||||
return $typed;
|
||||
}
|
||||
}
|
||||
45
app/Services/Recommendations/SimilarArtworksService.php
Normal file
45
app/Services/Recommendations/SimilarArtworksService.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SimilarArtworksService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
public function forArtwork(int $artworkId, int $limit = 12, ?string $algoVersion = null): Collection
|
||||
{
|
||||
$effectiveAlgo = $algoVersion ?: (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
||||
|
||||
$ids = DB::table('artwork_similarities')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('algo_version', $effectiveAlgo)
|
||||
->orderBy('rank')
|
||||
->limit(max(1, min($limit, 50)))
|
||||
->pluck('similar_artwork_id')
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->whereIn('id', $ids)
|
||||
->public()
|
||||
->published()
|
||||
->get();
|
||||
|
||||
$byId = $artworks->keyBy('id');
|
||||
|
||||
return collect($ids)
|
||||
->map(static fn (int $id) => $byId->get($id))
|
||||
->filter();
|
||||
}
|
||||
}
|
||||
162
app/Services/Recommendations/UserInterestProfileService.php
Normal file
162
app/Services/Recommendations/UserInterestProfileService.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use App\Models\UserInterestProfile;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class UserInterestProfileService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $eventMeta
|
||||
*/
|
||||
public function applyEvent(
|
||||
int $userId,
|
||||
string $eventType,
|
||||
int $artworkId,
|
||||
?int $categoryId,
|
||||
CarbonInterface $occurredAt,
|
||||
string $eventId,
|
||||
string $algoVersion,
|
||||
array $eventMeta = []
|
||||
): void {
|
||||
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
|
||||
$halfLifeHours = (float) config('discovery.decay.half_life_hours', 72);
|
||||
$weightMap = (array) config('discovery.weights', []);
|
||||
$eventWeight = (float) ($weightMap[$eventType] ?? 1.0);
|
||||
|
||||
DB::transaction(function () use (
|
||||
$userId,
|
||||
$categoryId,
|
||||
$artworkId,
|
||||
$occurredAt,
|
||||
$eventId,
|
||||
$algoVersion,
|
||||
$profileVersion,
|
||||
$halfLifeHours,
|
||||
$eventWeight,
|
||||
$eventMeta
|
||||
): void {
|
||||
$profile = UserInterestProfile::query()
|
||||
->where('user_id', $userId)
|
||||
->where('profile_version', $profileVersion)
|
||||
->where('algo_version', $algoVersion)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$rawScores = $profile !== null ? (array) ($profile->raw_scores_json ?? []) : [];
|
||||
$lastEventAt = $profile?->last_event_at;
|
||||
|
||||
if ($lastEventAt !== null && $occurredAt->greaterThan($lastEventAt)) {
|
||||
$hours = max(0.0, (float) $lastEventAt->diffInSeconds($occurredAt) / 3600);
|
||||
$rawScores = $this->applyRecencyDecay($rawScores, $hours, $halfLifeHours);
|
||||
}
|
||||
|
||||
$interestKey = $categoryId !== null
|
||||
? sprintf('category:%d', $categoryId)
|
||||
: sprintf('artwork:%d', $artworkId);
|
||||
|
||||
$rawScores[$interestKey] = (float) ($rawScores[$interestKey] ?? 0.0) + $eventWeight;
|
||||
|
||||
$rawScores = array_filter(
|
||||
$rawScores,
|
||||
static fn (mixed $value): bool => is_numeric($value) && (float) $value > 0.000001
|
||||
);
|
||||
|
||||
$normalizedScores = $this->normalizeScores($rawScores);
|
||||
$totalWeight = array_sum($rawScores);
|
||||
|
||||
$payload = [
|
||||
'user_id' => $userId,
|
||||
'profile_version' => $profileVersion,
|
||||
'algo_version' => $algoVersion,
|
||||
'raw_scores_json' => $rawScores,
|
||||
'normalized_scores_json' => $normalizedScores,
|
||||
'total_weight' => $totalWeight,
|
||||
'event_count' => $profile !== null ? ((int) $profile->event_count + 1) : 1,
|
||||
'last_event_at' => $lastEventAt === null || $occurredAt->greaterThan($lastEventAt)
|
||||
? $occurredAt
|
||||
: $lastEventAt,
|
||||
'half_life_hours' => $halfLifeHours,
|
||||
'updated_from_event_id' => $eventId,
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
if ($profile === null) {
|
||||
$payload['created_at'] = now();
|
||||
UserInterestProfile::query()->create($payload);
|
||||
return;
|
||||
}
|
||||
|
||||
$profile->fill($payload);
|
||||
$profile->save();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scores
|
||||
* @return array<string, float>
|
||||
*/
|
||||
public function applyRecencyDecay(array $scores, float $hoursElapsed, float $halfLifeHours): array
|
||||
{
|
||||
if ($hoursElapsed <= 0 || $halfLifeHours <= 0) {
|
||||
return $this->castToFloatScores($scores);
|
||||
}
|
||||
|
||||
$decayFactor = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
|
||||
$output = [];
|
||||
|
||||
foreach ($scores as $key => $score) {
|
||||
if (! is_numeric($score)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decayed = (float) $score * $decayFactor;
|
||||
if ($decayed > 0.000001) {
|
||||
$output[(string) $key] = $decayed;
|
||||
}
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scores
|
||||
* @return array<string, float>
|
||||
*/
|
||||
public function normalizeScores(array $scores): array
|
||||
{
|
||||
$typedScores = $this->castToFloatScores($scores);
|
||||
$sum = array_sum($typedScores);
|
||||
|
||||
if ($sum <= 0.0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($typedScores as $key => $score) {
|
||||
$normalized[$key] = $score / $sum;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scores
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private function castToFloatScores(array $scores): array
|
||||
{
|
||||
$output = [];
|
||||
foreach ($scores as $key => $score) {
|
||||
if (is_numeric($score) && (float) $score > 0.0) {
|
||||
$output[(string) $key] = (float) $score;
|
||||
}
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
39
app/Services/TagNormalizer.php
Normal file
39
app/Services/TagNormalizer.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
final class TagNormalizer
|
||||
{
|
||||
public function normalize(string $tag): string
|
||||
{
|
||||
$value = trim($tag);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = mb_strtolower($value, 'UTF-8');
|
||||
|
||||
// Remove emoji / symbols and keep only letters, numbers, whitespace and hyphens.
|
||||
// (Unicode safe: \p{L} letters, \p{N} numbers)
|
||||
$value = (string) preg_replace('/[^\p{L}\p{N}\s\-]+/u', '', $value);
|
||||
|
||||
// Normalize whitespace.
|
||||
$value = (string) preg_replace('/\s+/u', ' ', $value);
|
||||
$value = trim($value);
|
||||
|
||||
// Spaces -> hyphens and collapse repeats.
|
||||
$value = str_replace(' ', '-', $value);
|
||||
$value = (string) preg_replace('/\-+/u', '-', $value);
|
||||
$value = trim($value, "-\t\n\r\0\x0B");
|
||||
|
||||
$maxLength = (int) config('tags.max_length', 32);
|
||||
if ($maxLength > 0 && mb_strlen($value, 'UTF-8') > $maxLength) {
|
||||
$value = mb_substr($value, 0, $maxLength, 'UTF-8');
|
||||
$value = rtrim($value, '-');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
329
app/Services/TagService.php
Normal file
329
app/Services/TagService.php
Normal file
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\TagNormalizer;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class TagService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TagNormalizer $normalizer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createOrFindTag(string $rawTag): Tag
|
||||
{
|
||||
$normalized = $this->normalizer->normalize($rawTag);
|
||||
$this->validateNormalizedTag($normalized);
|
||||
|
||||
// Keep tags normalized in both name and slug (spec: normalize all tags).
|
||||
// Unique(slug) + Unique(name) prevents duplicates.
|
||||
return Tag::query()->firstOrCreate(
|
||||
['slug' => $normalized],
|
||||
['name' => $normalized, 'usage_count' => 0, 'is_active' => true]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tags
|
||||
*/
|
||||
public function attachUserTags(Artwork $artwork, array $tags): void
|
||||
{
|
||||
$normalized = $this->normalizeUserTags($tags);
|
||||
if ($normalized === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($artwork, $normalized): void {
|
||||
$tagIdsBySlug = [];
|
||||
foreach ($normalized as $tag) {
|
||||
$model = $this->createOrFindTag($tag);
|
||||
$tagIdsBySlug[$model->slug] = $model->id;
|
||||
}
|
||||
|
||||
$tagIds = array_values($tagIdsBySlug);
|
||||
|
||||
$existing = DB::table('artwork_tag')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->whereIn('tag_id', $tagIds)
|
||||
->pluck('source', 'tag_id')
|
||||
->all();
|
||||
|
||||
$toAttach = [];
|
||||
$toUpdate = [];
|
||||
$newlyAttachedTagIds = [];
|
||||
|
||||
foreach ($tagIds as $tagId) {
|
||||
$source = $existing[$tagId] ?? null;
|
||||
|
||||
if ($source === null) {
|
||||
$toAttach[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()];
|
||||
$newlyAttachedTagIds[] = $tagId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($source !== 'user') {
|
||||
// User tags take precedence over AI/system.
|
||||
$toUpdate[$tagId] = ['source' => 'user', 'confidence' => null];
|
||||
}
|
||||
}
|
||||
|
||||
if ($toAttach !== []) {
|
||||
$artwork->tags()->syncWithoutDetaching($toAttach);
|
||||
$this->incrementUsageCounts($newlyAttachedTagIds);
|
||||
}
|
||||
|
||||
foreach ($toUpdate as $tagId => $payload) {
|
||||
$artwork->tags()->updateExistingPivot($tagId, $payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{tag: string, confidence?: float|int|null}> $aiTags
|
||||
*/
|
||||
public function attachAiTags(Artwork $artwork, array $aiTags): void
|
||||
{
|
||||
if ($aiTags === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($artwork, $aiTags): void {
|
||||
$payloads = [];
|
||||
$newlyAttachedTagIds = [];
|
||||
|
||||
foreach ($aiTags as $row) {
|
||||
$raw = (string) ($row['tag'] ?? '');
|
||||
$confidence = $row['confidence'] ?? null;
|
||||
|
||||
$normalized = $this->normalizer->normalize($raw);
|
||||
if ($normalized === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// AI tagging must be optional: invalid/banned tags are skipped (not fatal).
|
||||
try {
|
||||
$this->validateNormalizedTag($normalized);
|
||||
} catch (ValidationException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = $this->createOrFindTag($normalized);
|
||||
|
||||
$existingSource = DB::table('artwork_tag')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('tag_id', $tag->id)
|
||||
->value('source');
|
||||
|
||||
if ($existingSource === 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existingSource === null) {
|
||||
$payloads[$tag->id] = [
|
||||
'source' => 'ai',
|
||||
'confidence' => is_numeric($confidence) ? (float) $confidence : null,
|
||||
'created_at' => now(),
|
||||
];
|
||||
$newlyAttachedTagIds[] = $tag->id;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existingSource === 'ai') {
|
||||
$artwork->tags()->updateExistingPivot($tag->id, [
|
||||
'confidence' => is_numeric($confidence) ? (float) $confidence : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($payloads !== []) {
|
||||
$artwork->tags()->syncWithoutDetaching($payloads);
|
||||
$this->incrementUsageCounts($newlyAttachedTagIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function detachTags(Artwork $artwork, array $tagSlugsOrIds): void
|
||||
{
|
||||
if ($tagSlugsOrIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tagIds = Tag::query()
|
||||
->whereIn('id', array_filter($tagSlugsOrIds, 'is_numeric'))
|
||||
->orWhereIn('slug', array_filter($tagSlugsOrIds, fn ($v) => is_string($v) && $v !== ''))
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if ($tagIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($artwork, $tagIds): void {
|
||||
$existing = DB::table('artwork_tag')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->whereIn('tag_id', $tagIds)
|
||||
->pluck('tag_id')
|
||||
->all();
|
||||
|
||||
if ($existing === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork->tags()->detach($existing);
|
||||
$this->decrementUsageCounts($existing);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync user tags (PUT semantics): replaces the set of user-origin tags.
|
||||
*
|
||||
* @param array<int, string> $tags
|
||||
*/
|
||||
public function syncTags(Artwork $artwork, array $tags): void
|
||||
{
|
||||
$normalized = $this->normalizeUserTags($tags);
|
||||
|
||||
DB::transaction(function () use ($artwork, $normalized): void {
|
||||
$desiredTagIds = [];
|
||||
foreach ($normalized as $tag) {
|
||||
$model = $this->createOrFindTag($tag);
|
||||
$desiredTagIds[] = $model->id;
|
||||
}
|
||||
|
||||
$desiredTagIds = array_values(array_unique($desiredTagIds));
|
||||
|
||||
$currentUserTagIds = DB::table('artwork_tag')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('source', 'user')
|
||||
->pluck('tag_id')
|
||||
->all();
|
||||
|
||||
$toDetach = array_values(array_diff($currentUserTagIds, $desiredTagIds));
|
||||
$toAttach = array_values(array_diff($desiredTagIds, $currentUserTagIds));
|
||||
|
||||
if ($toDetach !== []) {
|
||||
$artwork->tags()->detach($toDetach);
|
||||
$this->decrementUsageCounts($toDetach);
|
||||
}
|
||||
|
||||
if ($toAttach !== []) {
|
||||
$payload = [];
|
||||
foreach ($toAttach as $tagId) {
|
||||
$payload[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()];
|
||||
}
|
||||
$artwork->tags()->syncWithoutDetaching($payload);
|
||||
$this->incrementUsageCounts($toAttach);
|
||||
}
|
||||
|
||||
// Ensure desired tags are marked as user (user precedence).
|
||||
if ($desiredTagIds !== []) {
|
||||
$existingNonUser = DB::table('artwork_tag')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->whereIn('tag_id', $desiredTagIds)
|
||||
->where('source', '!=', 'user')
|
||||
->pluck('tag_id')
|
||||
->all();
|
||||
|
||||
foreach ($existingNonUser as $tagId) {
|
||||
$artwork->tags()->updateExistingPivot($tagId, ['source' => 'user', 'confidence' => null]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function updateUsageCount(Tag $tag): void
|
||||
{
|
||||
$count = (int) DB::table('artwork_tag')->where('tag_id', $tag->id)->count();
|
||||
$tag->forceFill(['usage_count' => $count])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tags
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeUserTags(array $tags): array
|
||||
{
|
||||
$max = (int) config('tags.max_user_tags', 15);
|
||||
if (count($tags) > $max) {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ["Too many tags (max {$max})."],
|
||||
]);
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($tags as $tag) {
|
||||
$value = $this->normalizer->normalize((string) $tag);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$this->validateNormalizedTag($value);
|
||||
$normalized[] = $value;
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function validateNormalizedTag(string $normalized): void
|
||||
{
|
||||
if ($normalized === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ['Invalid tag.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$banned = array_map('strval', (array) config('tags.banned', []));
|
||||
if ($banned !== [] && in_array($normalized, $banned, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ['Tag is not allowed.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$patterns = (array) config('tags.banned_regex', []);
|
||||
foreach ($patterns as $pattern) {
|
||||
$pattern = (string) $pattern;
|
||||
if ($pattern === '') {
|
||||
continue;
|
||||
}
|
||||
if (@preg_match($pattern, $normalized) === 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ['Tag is not allowed.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $tagIds
|
||||
*/
|
||||
private function incrementUsageCounts(array $tagIds): void
|
||||
{
|
||||
if ($tagIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
Tag::query()->whereIn('id', $tagIds)->increment('usage_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $tagIds
|
||||
*/
|
||||
private function decrementUsageCounts(array $tagIds): void
|
||||
{
|
||||
if ($tagIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Never allow negative counts.
|
||||
DB::table('tags')
|
||||
->whereIn('id', $tagIds)
|
||||
->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ThumbnailService
|
||||
{
|
||||
protected const CDN_HOST = 'http://files.skinbase.org';
|
||||
// Use the thumbnails CDN host (HTTPS)
|
||||
protected const CDN_HOST = 'https://files.skinbase.org';
|
||||
|
||||
protected const VALID_SIZES = ['sm','md','lg','xl'];
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Upload\Contracts;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
interface UploadDraftServiceInterface
|
||||
{
|
||||
/**
|
||||
* Create a new draft and return identifying info.
|
||||
*
|
||||
* @param array $attributes
|
||||
* @return array ['id' => string, 'path' => string, 'meta' => array]
|
||||
*/
|
||||
public function createDraft(array $attributes = []): array;
|
||||
|
||||
/**
|
||||
* Store the main uploaded file for the draft.
|
||||
*
|
||||
* @param string $draftId
|
||||
* @param UploadedFile $file
|
||||
* @return array Metadata about stored file (path, size, mime, hash)
|
||||
*/
|
||||
public function storeMainFile(string $draftId, UploadedFile $file): array;
|
||||
|
||||
/**
|
||||
* Store a screenshot/preview image for the draft.
|
||||
*
|
||||
* @param string $draftId
|
||||
* @param UploadedFile $file
|
||||
* @return array Metadata about stored screenshot
|
||||
*/
|
||||
public function storeScreenshot(string $draftId, UploadedFile $file): array;
|
||||
|
||||
/**
|
||||
* Calculate a content hash for a local file path or storage path.
|
||||
*
|
||||
* @param string $filePath
|
||||
* @return string
|
||||
*/
|
||||
public function calculateHash(string $filePath): string;
|
||||
|
||||
/**
|
||||
* Set an expiration timestamp for the draft.
|
||||
*
|
||||
* @param string $draftId
|
||||
* @param \Carbon\Carbon|null $expiresAt
|
||||
* @return bool
|
||||
*/
|
||||
public function setExpiration(string $draftId, ?\Carbon\Carbon $expiresAt = null): bool;
|
||||
}
|
||||
79
app/Services/Upload/PreviewService.php
Normal file
79
app/Services/Upload/PreviewService.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Upload;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class PreviewService
|
||||
{
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
||||
} catch (\Throwable $e) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function generateFromImage(string $uploadId, string $sourcePath): array
|
||||
{
|
||||
if ($this->manager === null) {
|
||||
throw new RuntimeException('PreviewService requires Intervention Image.');
|
||||
}
|
||||
|
||||
$disk = Storage::disk('local');
|
||||
if (! $disk->exists($sourcePath)) {
|
||||
return $this->generatePlaceholder($uploadId);
|
||||
}
|
||||
|
||||
$absolute = $disk->path($sourcePath);
|
||||
$previewPath = "tmp/drafts/{$uploadId}/preview.webp";
|
||||
$thumbPath = "tmp/drafts/{$uploadId}/thumb.webp";
|
||||
|
||||
$preview = $this->manager->read($absolute)->scaleDown(1280, 1280);
|
||||
$thumb = $this->manager->read($absolute)->cover(320, 320);
|
||||
|
||||
$previewEncoded = (string) $preview->encode(new \Intervention\Image\Encoders\WebpEncoder(85));
|
||||
$thumbEncoded = (string) $thumb->encode(new \Intervention\Image\Encoders\WebpEncoder(82));
|
||||
|
||||
$disk->put($previewPath, $previewEncoded);
|
||||
$disk->put($thumbPath, $thumbEncoded);
|
||||
|
||||
return [
|
||||
'preview_path' => $previewPath,
|
||||
'thumb_path' => $thumbPath,
|
||||
];
|
||||
}
|
||||
|
||||
public function generateFromArchive(string $uploadId, ?string $screenshotPath = null): array
|
||||
{
|
||||
if ($screenshotPath !== null && Storage::disk('local')->exists($screenshotPath)) {
|
||||
return $this->generateFromImage($uploadId, $screenshotPath);
|
||||
}
|
||||
|
||||
return $this->generatePlaceholder($uploadId);
|
||||
}
|
||||
|
||||
public function generatePlaceholder(string $uploadId): array
|
||||
{
|
||||
$disk = Storage::disk('local');
|
||||
$previewPath = "tmp/drafts/{$uploadId}/preview.webp";
|
||||
$thumbPath = "tmp/drafts/{$uploadId}/thumb.webp";
|
||||
|
||||
// 1x1 transparent webp
|
||||
$tinyWebp = base64_decode('UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAAAfQ//73v/+BiOh/AAA=');
|
||||
$disk->put($previewPath, $tinyWebp ?: '');
|
||||
$disk->put($thumbPath, $tinyWebp ?: '');
|
||||
|
||||
return [
|
||||
'preview_path' => $previewPath,
|
||||
'thumb_path' => $thumbPath,
|
||||
];
|
||||
}
|
||||
}
|
||||
105
app/Services/Upload/TagAnalysisService.php
Normal file
105
app/Services/Upload/TagAnalysisService.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Upload;
|
||||
|
||||
use App\Services\TagNormalizer;
|
||||
|
||||
final class TagAnalysisService
|
||||
{
|
||||
public function __construct(private readonly TagNormalizer $normalizer)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{tag:string,confidence:float,source:string}>
|
||||
*/
|
||||
public function analyze(string $filename, ?string $previewPath, ?string $categoryContext): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->extractFilenameTags($filename) as $tag) {
|
||||
$results[] = [
|
||||
'tag' => $tag,
|
||||
'confidence' => 0.72,
|
||||
'source' => 'filename',
|
||||
];
|
||||
}
|
||||
|
||||
if ($previewPath !== null && $previewPath !== '') {
|
||||
// Stub AI output for now (real model integration can replace this later)
|
||||
$results[] = [
|
||||
'tag' => 'ai-detected',
|
||||
'confidence' => 0.66,
|
||||
'source' => 'ai',
|
||||
];
|
||||
$results[] = [
|
||||
'tag' => 'visual-content',
|
||||
'confidence' => 0.61,
|
||||
'source' => 'ai',
|
||||
];
|
||||
}
|
||||
|
||||
if ($categoryContext !== null && $categoryContext !== '') {
|
||||
$normalized = $this->normalizer->normalize($categoryContext);
|
||||
if ($normalized !== '') {
|
||||
$results[] = [
|
||||
'tag' => $normalized,
|
||||
'confidence' => 0.60,
|
||||
'source' => 'manual',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->dedupe($results);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractFilenameTags(string $filename): array
|
||||
{
|
||||
$base = pathinfo($filename, PATHINFO_FILENAME) ?: $filename;
|
||||
$parts = preg_split('/[\s._\-]+/', mb_strtolower($base, 'UTF-8')) ?: [];
|
||||
|
||||
$tags = [];
|
||||
foreach ($parts as $part) {
|
||||
$normalized = $this->normalizer->normalize((string) $part);
|
||||
if ($normalized !== '' && mb_strlen($normalized, 'UTF-8') >= 3) {
|
||||
$tags[] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($tags));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{tag:string,confidence:float,source:string}> $rows
|
||||
* @return array<int, array{tag:string,confidence:float,source:string}>
|
||||
*/
|
||||
private function dedupe(array $rows): array
|
||||
{
|
||||
$best = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$tag = $this->normalizer->normalize((string) ($row['tag'] ?? ''));
|
||||
if ($tag === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$confidence = (float) ($row['confidence'] ?? 0.0);
|
||||
$source = (string) ($row['source'] ?? 'manual');
|
||||
|
||||
if (! isset($best[$tag]) || $best[$tag]['confidence'] < $confidence) {
|
||||
$best[$tag] = [
|
||||
'tag' => $tag,
|
||||
'confidence' => max(0.0, min(1.0, $confidence)),
|
||||
'source' => in_array($source, ['ai', 'filename', 'manual'], true) ? $source : 'manual',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($best);
|
||||
}
|
||||
}
|
||||
191
app/Services/Upload/UploadDraftService.php
Normal file
191
app/Services/Upload/UploadDraftService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Upload;
|
||||
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UploadDraftService implements UploadDraftServiceInterface
|
||||
{
|
||||
protected FilesystemManager $filesystem;
|
||||
protected FilesystemContract $disk;
|
||||
protected string $diskName;
|
||||
protected string $basePath = 'tmp/drafts';
|
||||
|
||||
public function __construct(FilesystemManager $filesystem, string $diskName = 'local')
|
||||
{
|
||||
$this->filesystem = $filesystem;
|
||||
$this->diskName = $diskName;
|
||||
$this->disk = $this->filesystem->disk($this->diskName);
|
||||
}
|
||||
|
||||
public function createDraft(array $attributes = []): array
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
$path = trim($this->basePath, '/') . '/' . $id;
|
||||
|
||||
if (! $this->disk->exists($path)) {
|
||||
$this->disk->makeDirectory($path);
|
||||
}
|
||||
|
||||
$meta = array_merge(['id' => $id, 'created_at' => Carbon::now()->toISOString()], $attributes);
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $id,
|
||||
'user_id' => (int) ($attributes['user_id'] ?? 0),
|
||||
'type' => (string) ($attributes['type'] ?? 'image'),
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'pending',
|
||||
'processing_state' => 'pending_scan',
|
||||
'expires_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->writeMeta($id, $meta);
|
||||
|
||||
return ['id' => $id, 'path' => $path, 'meta' => $meta];
|
||||
}
|
||||
|
||||
public function storeMainFile(string $draftId, UploadedFile $file): array
|
||||
{
|
||||
$dir = trim($this->basePath, '/') . '/' . $draftId . '/main';
|
||||
if (! $this->disk->exists($dir)) {
|
||||
$this->disk->makeDirectory($dir);
|
||||
}
|
||||
|
||||
$filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName());
|
||||
$storedPath = $this->disk->putFileAs($dir, $file, $filename);
|
||||
|
||||
$size = $this->safeSize($storedPath, $file);
|
||||
$mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath);
|
||||
$hash = $this->calculateHash($file->getRealPath() ?: $storedPath);
|
||||
|
||||
$info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash];
|
||||
|
||||
$meta = $this->readMeta($draftId);
|
||||
$meta['main_file'] = $info;
|
||||
$this->writeMeta($draftId, $meta);
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $draftId,
|
||||
'path' => $storedPath,
|
||||
'type' => 'main',
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'mime' => $mime,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function storeScreenshot(string $draftId, UploadedFile $file): array
|
||||
{
|
||||
$dir = trim($this->basePath, '/') . '/' . $draftId . '/screenshots';
|
||||
if (! $this->disk->exists($dir)) {
|
||||
$this->disk->makeDirectory($dir);
|
||||
}
|
||||
|
||||
$filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName());
|
||||
$storedPath = $this->disk->putFileAs($dir, $file, $filename);
|
||||
|
||||
$size = $this->safeSize($storedPath, $file);
|
||||
$mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath);
|
||||
$hash = $this->calculateHash($file->getRealPath() ?: $storedPath);
|
||||
|
||||
$info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash];
|
||||
|
||||
$meta = $this->readMeta($draftId);
|
||||
$meta['screenshots'][] = $info;
|
||||
$this->writeMeta($draftId, $meta);
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $draftId,
|
||||
'path' => $storedPath,
|
||||
'type' => 'screenshot',
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'mime' => $mime,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
public function calculateHash(string $filePath): string
|
||||
{
|
||||
// If path points to a local filesystem file
|
||||
if (is_file($filePath)) {
|
||||
return hash_file('sha256', $filePath);
|
||||
}
|
||||
|
||||
// If path is a storage-relative path
|
||||
if ($this->disk->exists($filePath)) {
|
||||
$contents = $this->disk->get($filePath);
|
||||
return hash('sha256', $contents);
|
||||
}
|
||||
|
||||
throw new \RuntimeException('File not found for hashing: ' . $filePath);
|
||||
}
|
||||
|
||||
public function setExpiration(string $draftId, ?Carbon $expiresAt = null): bool
|
||||
{
|
||||
$meta = $this->readMeta($draftId);
|
||||
$meta['expires_at'] = $expiresAt?->toISOString();
|
||||
$this->writeMeta($draftId, $meta);
|
||||
|
||||
DB::table('uploads')->where('id', $draftId)->update([
|
||||
'expires_at' => $expiresAt,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function metaPath(string $draftId): string
|
||||
{
|
||||
return trim($this->basePath, '/') . '/' . $draftId . '/meta.json';
|
||||
}
|
||||
|
||||
protected function readMeta(string $draftId): array
|
||||
{
|
||||
$path = $this->metaPath($draftId);
|
||||
if (! $this->disk->exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$raw = $this->disk->get($path);
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
protected function writeMeta(string $draftId, array $meta): void
|
||||
{
|
||||
$path = $this->metaPath($draftId);
|
||||
$this->disk->put($path, json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
protected function safeSize(string $storedPath, UploadedFile $file): int
|
||||
{
|
||||
try {
|
||||
return $this->disk->size($storedPath);
|
||||
} catch (\Throwable $e) {
|
||||
return (int) $file->getSize();
|
||||
}
|
||||
}
|
||||
|
||||
protected function safeMimeType(string $storedPath): ?string
|
||||
{
|
||||
try {
|
||||
return $this->disk->mimeType($storedPath);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/Services/Uploads/UploadAuditService.php
Normal file
19
app/Services/Uploads/UploadAuditService.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\Repositories\Uploads\AuditLogRepository;
|
||||
|
||||
final class UploadAuditService
|
||||
{
|
||||
public function __construct(private readonly AuditLogRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function log(?int $userId, string $action, string $ip, array $meta = []): void
|
||||
{
|
||||
$this->repository->log($userId, $action, $ip, $meta);
|
||||
}
|
||||
}
|
||||
84
app/Services/Uploads/UploadCancelService.php
Normal file
84
app/Services/Uploads/UploadCancelService.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadCancelService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadSessionRepository $sessions,
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly UploadAuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function cancel(string $sessionId, int $userId, string $ip): array
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
|
||||
$lockSeconds = (int) config('uploads.chunk.lock_seconds', 10);
|
||||
$lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5);
|
||||
$lock = Cache::lock('uploads:cancel:' . $sessionId, $lockSeconds);
|
||||
|
||||
try {
|
||||
$lock->block($lockWait);
|
||||
} catch (\Throwable $e) {
|
||||
$this->audit->log($userId, 'upload_cancel_locked', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
]);
|
||||
throw new RuntimeException('Upload is busy. Please retry.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (in_array($session->status, [UploadSessionStatus::CANCELLED, UploadSessionStatus::PROCESSED, UploadSessionStatus::QUARANTINED], true)) {
|
||||
$this->audit->log($userId, 'upload_cancel_noop', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
'status' => $session->status,
|
||||
]);
|
||||
|
||||
return [
|
||||
'session_id' => $sessionId,
|
||||
'status' => $session->status,
|
||||
];
|
||||
}
|
||||
|
||||
$this->safeDeleteTmp($session->tempPath);
|
||||
|
||||
$this->sessions->updateStatus($sessionId, UploadSessionStatus::CANCELLED);
|
||||
$this->sessions->updateProgress($sessionId, 0);
|
||||
$this->sessions->updateFailureReason($sessionId, 'cancelled');
|
||||
|
||||
$this->audit->log($userId, 'upload_cancelled', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'session_id' => $sessionId,
|
||||
'status' => UploadSessionStatus::CANCELLED,
|
||||
];
|
||||
} finally {
|
||||
optional($lock)->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function safeDeleteTmp(string $path): void
|
||||
{
|
||||
$tmpRoot = $this->storage->sectionPath('tmp');
|
||||
$realRoot = realpath($tmpRoot);
|
||||
$realPath = realpath($path);
|
||||
|
||||
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (File::exists($realPath)) {
|
||||
File::delete($realPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
204
app/Services/Uploads/UploadChunkService.php
Normal file
204
app/Services/Uploads/UploadChunkService.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\DTOs\Uploads\UploadChunkResult;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadChunkService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly UploadSessionRepository $sessions,
|
||||
private readonly UploadAuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function appendChunk(string $sessionId, string $chunkPath, int $offset, int $chunkSize, int $totalSize, int $userId, string $ip): UploadChunkResult
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
|
||||
$this->ensureTmpPath($session->tempPath);
|
||||
$this->ensureWritable($session->tempPath);
|
||||
$this->ensureChunkReadable($chunkPath, $chunkSize);
|
||||
$this->ensureLimits($totalSize, $chunkSize);
|
||||
|
||||
$lockSeconds = (int) config('uploads.chunk.lock_seconds', 10);
|
||||
$lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5);
|
||||
$lock = Cache::lock('uploads:chunk:' . $sessionId, $lockSeconds);
|
||||
|
||||
try {
|
||||
$lock->block($lockWait);
|
||||
} catch (\Throwable $e) {
|
||||
$this->audit->log($userId, 'upload_chunk_locked', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
]);
|
||||
throw new RuntimeException('Upload is busy. Please retry.');
|
||||
}
|
||||
|
||||
try {
|
||||
$currentSize = (int) filesize($session->tempPath);
|
||||
|
||||
if ($offset > $currentSize) {
|
||||
$this->audit->log($userId, 'upload_chunk_offset_mismatch', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
'offset' => $offset,
|
||||
'current_size' => $currentSize,
|
||||
]);
|
||||
throw new RuntimeException('Invalid chunk offset.');
|
||||
}
|
||||
|
||||
if ($offset < $currentSize) {
|
||||
if ($offset + $chunkSize <= $currentSize) {
|
||||
return $this->finalizeResult($sessionId, $totalSize, $currentSize);
|
||||
}
|
||||
|
||||
$this->audit->log($userId, 'upload_chunk_overlap', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
'offset' => $offset,
|
||||
'current_size' => $currentSize,
|
||||
]);
|
||||
throw new RuntimeException('Chunk overlap detected.');
|
||||
}
|
||||
|
||||
$written = $this->appendToFile($session->tempPath, $chunkPath, $offset, $chunkSize);
|
||||
$newSize = $currentSize + $written;
|
||||
|
||||
if ($newSize > $totalSize) {
|
||||
$this->audit->log($userId, 'upload_chunk_size_exceeded', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
'new_size' => $newSize,
|
||||
'total_size' => $totalSize,
|
||||
]);
|
||||
throw new RuntimeException('Upload exceeded expected size.');
|
||||
}
|
||||
|
||||
$this->sessions->updateStatus($sessionId, UploadSessionStatus::TMP);
|
||||
$result = $this->finalizeResult($sessionId, $totalSize, $newSize);
|
||||
|
||||
$this->audit->log($userId, 'upload_chunk_appended', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
'received_bytes' => $newSize,
|
||||
'total_size' => $totalSize,
|
||||
'progress' => $result->progress,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} finally {
|
||||
optional($lock)->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function finalizeResult(string $sessionId, int $totalSize, int $currentSize): UploadChunkResult
|
||||
{
|
||||
$progress = $totalSize > 0 ? (int) floor(($currentSize / $totalSize) * 100) : 0;
|
||||
$progress = min(90, max(0, $progress));
|
||||
$this->sessions->updateProgress($sessionId, $progress);
|
||||
|
||||
return new UploadChunkResult(
|
||||
$sessionId,
|
||||
UploadSessionStatus::TMP,
|
||||
$currentSize,
|
||||
$totalSize,
|
||||
$progress
|
||||
);
|
||||
}
|
||||
|
||||
private function ensureTmpPath(string $path): void
|
||||
{
|
||||
$tmpRoot = $this->storage->sectionPath('tmp');
|
||||
$realRoot = realpath($tmpRoot);
|
||||
$realPath = realpath($path);
|
||||
|
||||
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
|
||||
throw new RuntimeException('Invalid temp path.');
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureWritable(string $path): void
|
||||
{
|
||||
if (! File::exists($path)) {
|
||||
File::put($path, '');
|
||||
}
|
||||
|
||||
if (! is_writable($path)) {
|
||||
throw new RuntimeException('Upload path not writable.');
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureLimits(int $totalSize, int $chunkSize): void
|
||||
{
|
||||
$maxBytes = (int) config('uploads.max_size_mb', 0) * 1024 * 1024;
|
||||
if ($maxBytes > 0 && $totalSize > $maxBytes) {
|
||||
throw new RuntimeException('Upload exceeds max size.');
|
||||
}
|
||||
|
||||
$maxChunk = (int) config('uploads.chunk.max_bytes', 0);
|
||||
if ($maxChunk > 0 && $chunkSize > $maxChunk) {
|
||||
throw new RuntimeException('Chunk exceeds max size.');
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureChunkReadable(string $chunkPath, int $chunkSize): void
|
||||
{
|
||||
$exists = is_file($chunkPath);
|
||||
$readable = $exists ? is_readable($chunkPath) : false;
|
||||
$actualSize = $exists ? (int) @filesize($chunkPath) : null;
|
||||
|
||||
if (! $exists || ! $readable) {
|
||||
logger()->warning('Upload chunk unreadable or missing', [
|
||||
'chunk_path' => $chunkPath,
|
||||
'expected_size' => $chunkSize,
|
||||
'exists' => $exists,
|
||||
'readable' => $readable,
|
||||
'actual_size' => $actualSize,
|
||||
]);
|
||||
throw new RuntimeException('Upload chunk missing.');
|
||||
}
|
||||
|
||||
if ($actualSize !== $chunkSize) {
|
||||
logger()->warning('Upload chunk size mismatch', [
|
||||
'chunk_path' => $chunkPath,
|
||||
'expected_size' => $chunkSize,
|
||||
'actual_size' => $actualSize,
|
||||
]);
|
||||
throw new RuntimeException('Chunk size mismatch.');
|
||||
}
|
||||
}
|
||||
|
||||
private function appendToFile(string $targetPath, string $chunkPath, int $offset, int $chunkSize): int
|
||||
{
|
||||
$in = fopen($chunkPath, 'rb');
|
||||
if (! $in) {
|
||||
throw new RuntimeException('Unable to read upload chunk.');
|
||||
}
|
||||
|
||||
$out = fopen($targetPath, 'c+b');
|
||||
if (! $out) {
|
||||
fclose($in);
|
||||
throw new RuntimeException('Unable to write upload chunk.');
|
||||
}
|
||||
|
||||
if (fseek($out, $offset) !== 0) {
|
||||
fclose($in);
|
||||
fclose($out);
|
||||
throw new RuntimeException('Failed to seek in upload file.');
|
||||
}
|
||||
|
||||
$written = stream_copy_to_stream($in, $out, $chunkSize);
|
||||
fflush($out);
|
||||
fclose($in);
|
||||
fclose($out);
|
||||
|
||||
if ($written === false || (int) $written !== $chunkSize) {
|
||||
throw new RuntimeException('Incomplete chunk write.');
|
||||
}
|
||||
|
||||
return (int) $written;
|
||||
}
|
||||
}
|
||||
89
app/Services/Uploads/UploadDerivativesService.php
Normal file
89
app/Services/Uploads/UploadDerivativesService.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Intervention\Image\ImageManager as ImageManager;
|
||||
use Intervention\Image\Interfaces\ImageInterface as InterventionImageInterface;
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadDerivativesService
|
||||
{
|
||||
private bool $imageAvailable = false;
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct(private readonly UploadStorageService $storage)
|
||||
{
|
||||
// Intervention Image v3 uses ImageManager; instantiate appropriate driver
|
||||
try {
|
||||
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
||||
$this->imageAvailable = true;
|
||||
} catch (\Throwable $e) {
|
||||
logger()->warning('Intervention Image present but configuration failed: ' . $e->getMessage());
|
||||
$this->imageAvailable = false;
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function storeOriginal(string $sourcePath, string $hash): string
|
||||
{
|
||||
$this->assertImageAvailable();
|
||||
|
||||
$dir = $this->storage->ensureHashDirectory('originals', $hash);
|
||||
$target = $dir . DIRECTORY_SEPARATOR . 'orig.webp';
|
||||
$quality = (int) config('uploads.quality', 85);
|
||||
|
||||
/** @var InterventionImageInterface $img */
|
||||
$img = $this->manager->read($sourcePath);
|
||||
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
|
||||
$encoded = (string) $img->encode($encoder);
|
||||
File::put($target, $encoded);
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
public function generatePublicDerivatives(string $sourcePath, string $hash): array
|
||||
{
|
||||
$this->assertImageAvailable();
|
||||
$quality = (int) config('uploads.quality', 85);
|
||||
$variants = (array) config('uploads.derivatives', []);
|
||||
$dir = $this->storage->publicHashDirectory($hash);
|
||||
$written = [];
|
||||
|
||||
foreach ($variants as $variant => $options) {
|
||||
$variant = (string) $variant;
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $variant . '.webp';
|
||||
|
||||
/** @var InterventionImageInterface $img */
|
||||
$img = $this->manager->read($sourcePath);
|
||||
|
||||
if (isset($options['size'])) {
|
||||
$size = (int) $options['size'];
|
||||
$out = $img->cover($size, $size);
|
||||
} else {
|
||||
$max = (int) ($options['max'] ?? 0);
|
||||
if ($max <= 0) {
|
||||
$max = 2560;
|
||||
}
|
||||
|
||||
$out = $img->scaleDown($max, $max);
|
||||
}
|
||||
|
||||
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
|
||||
$encoded = (string) $out->encode($encoder);
|
||||
File::put($path, $encoded);
|
||||
$written[$variant] = $path;
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
|
||||
private function assertImageAvailable(): void
|
||||
{
|
||||
if (! $this->imageAvailable) {
|
||||
throw new RuntimeException('Intervention Image is not available.');
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/Services/Uploads/UploadHashService.php
Normal file
21
app/Services/Uploads/UploadHashService.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadHashService
|
||||
{
|
||||
public function hashFile(string $path): string
|
||||
{
|
||||
$hash = hash_file('sha256', $path);
|
||||
|
||||
if ($hash === false) {
|
||||
throw new RuntimeException('Failed to hash upload file.');
|
||||
}
|
||||
|
||||
return $hash;
|
||||
}
|
||||
}
|
||||
150
app/Services/Uploads/UploadPipelineService.php
Normal file
150
app/Services/Uploads/UploadPipelineService.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\DTOs\Uploads\UploadSessionData;
|
||||
use App\DTOs\Uploads\UploadInitResult;
|
||||
use App\DTOs\Uploads\UploadValidatedFile;
|
||||
use App\DTOs\Uploads\UploadScanResult;
|
||||
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
final class UploadPipelineService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly UploadSessionRepository $sessions,
|
||||
private readonly UploadValidationService $validator,
|
||||
private readonly UploadHashService $hasher,
|
||||
private readonly UploadScanService $scanner,
|
||||
private readonly UploadAuditService $audit,
|
||||
private readonly UploadDerivativesService $derivatives,
|
||||
private readonly ArtworkFileRepository $artworkFiles,
|
||||
private readonly UploadTokenService $tokens
|
||||
) {
|
||||
}
|
||||
|
||||
public function initSession(int $userId, string $ip): UploadInitResult
|
||||
{
|
||||
$dir = $this->storage->ensureSection('tmp');
|
||||
$filename = Str::uuid()->toString() . '.upload';
|
||||
$tempPath = $dir . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
File::put($tempPath, '');
|
||||
|
||||
$sessionId = (string) Str::uuid();
|
||||
$session = $this->sessions->create($sessionId, $userId, $tempPath, UploadSessionStatus::INIT, $ip);
|
||||
$token = $this->tokens->generate($sessionId, $userId);
|
||||
|
||||
$this->audit->log($userId, 'upload_init', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
]);
|
||||
|
||||
return new UploadInitResult($session->id, $token, $session->status);
|
||||
}
|
||||
|
||||
public function receiveToTmp(UploadedFile $file, int $userId, string $ip): UploadSessionData
|
||||
{
|
||||
$stored = $this->storage->storeUploadedFile($file, 'tmp');
|
||||
$sessionId = (string) Str::uuid();
|
||||
$session = $this->sessions->create($sessionId, $userId, $stored->path, UploadSessionStatus::TMP, $ip);
|
||||
$this->sessions->updateProgress($sessionId, 10);
|
||||
|
||||
$this->audit->log($userId, 'upload_received', $ip, [
|
||||
'session_id' => $sessionId,
|
||||
'size' => $stored->size,
|
||||
]);
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
public function validateAndHash(string $sessionId): UploadValidatedFile
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
$validation = $this->validator->validate($session->tempPath);
|
||||
|
||||
if (! $validation->ok) {
|
||||
$this->quarantine($session, $validation->reason);
|
||||
return new UploadValidatedFile($validation, null);
|
||||
}
|
||||
|
||||
$hash = $this->hasher->hashFile($session->tempPath);
|
||||
$this->sessions->updateStatus($sessionId, UploadSessionStatus::VALIDATED);
|
||||
$this->sessions->updateProgress($sessionId, 30);
|
||||
$this->audit->log($session->userId, 'upload_validated', $session->ip, [
|
||||
'session_id' => $sessionId,
|
||||
'hash' => $hash,
|
||||
]);
|
||||
|
||||
return new UploadValidatedFile($validation, $hash);
|
||||
}
|
||||
|
||||
public function scan(string $sessionId): UploadScanResult
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
$result = $this->scanner->scan($session->tempPath);
|
||||
|
||||
if (! $result->ok) {
|
||||
$this->quarantine($session, $result->reason);
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->sessions->updateStatus($sessionId, UploadSessionStatus::SCANNED);
|
||||
$this->sessions->updateProgress($sessionId, 50);
|
||||
$this->audit->log($session->userId, 'upload_scanned', $session->ip, [
|
||||
'session_id' => $sessionId,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function processAndPublish(string $sessionId, string $hash, int $artworkId): array
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
|
||||
$originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash);
|
||||
$originalRelative = $this->storage->sectionRelativePath('originals', $hash, 'orig.webp');
|
||||
$this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
|
||||
|
||||
$publicAbsolute = $this->derivatives->generatePublicDerivatives($session->tempPath, $hash);
|
||||
$publicRelative = [];
|
||||
|
||||
foreach ($publicAbsolute as $variant => $absolutePath) {
|
||||
$filename = $variant . '.webp';
|
||||
$relativePath = $this->storage->publicRelativePath($hash, $filename);
|
||||
$this->artworkFiles->upsert($artworkId, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
|
||||
$publicRelative[$variant] = $relativePath;
|
||||
}
|
||||
|
||||
$this->sessions->updateStatus($sessionId, UploadSessionStatus::PROCESSED);
|
||||
$this->sessions->updateProgress($sessionId, 100);
|
||||
$this->audit->log($session->userId, 'upload_processed', $session->ip, [
|
||||
'session_id' => $sessionId,
|
||||
'hash' => $hash,
|
||||
'artwork_id' => $artworkId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'orig' => $originalRelative,
|
||||
'public' => $publicRelative,
|
||||
];
|
||||
}
|
||||
|
||||
private function quarantine(UploadSessionData $session, string $reason): void
|
||||
{
|
||||
$newPath = $this->storage->moveToSection($session->tempPath, 'quarantine');
|
||||
$this->sessions->updateTempPath($session->id, $newPath);
|
||||
$this->sessions->updateStatus($session->id, UploadSessionStatus::QUARANTINED);
|
||||
$this->sessions->updateFailureReason($session->id, $reason);
|
||||
$this->sessions->updateProgress($session->id, 0);
|
||||
$this->audit->log($session->userId, 'upload_quarantined', $session->ip, [
|
||||
'session_id' => $session->id,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
app/Services/Uploads/UploadQuotaService.php
Normal file
36
app/Services/Uploads/UploadQuotaService.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadQuotaService
|
||||
{
|
||||
public function __construct(private readonly UploadSessionRepository $sessions)
|
||||
{
|
||||
}
|
||||
|
||||
public function enforce(int $userId): void
|
||||
{
|
||||
$activeLimit = (int) config('uploads.quotas.max_active_sessions', 0);
|
||||
if ($activeLimit > 0) {
|
||||
$active = $this->sessions->countActiveForUser($userId);
|
||||
if ($active >= $activeLimit) {
|
||||
throw new RuntimeException('Upload limit reached.');
|
||||
}
|
||||
}
|
||||
|
||||
$dailyLimit = (int) config('uploads.quotas.max_daily_sessions', 0);
|
||||
if ($dailyLimit > 0) {
|
||||
$since = CarbonImmutable::now()->startOfDay();
|
||||
$daily = $this->sessions->countForUserSince($userId, $since);
|
||||
if ($daily >= $dailyLimit) {
|
||||
throw new RuntimeException('Daily upload limit reached.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/Services/Uploads/UploadScanService.php
Normal file
45
app/Services/Uploads/UploadScanService.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\DTOs\Uploads\UploadScanResult;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
final class UploadScanService
|
||||
{
|
||||
public function scan(string $path): UploadScanResult
|
||||
{
|
||||
if (! (bool) config('uploads.scan.enabled', false)) {
|
||||
return UploadScanResult::clean();
|
||||
}
|
||||
|
||||
$command = config('uploads.scan.command', []);
|
||||
if (! is_array($command) || $command === []) {
|
||||
throw new RuntimeException('Upload scan enabled but no command configured.');
|
||||
}
|
||||
|
||||
$command = $this->buildCommand($command, $path);
|
||||
$process = new Process($command);
|
||||
$process->run();
|
||||
|
||||
if ($process->isSuccessful()) {
|
||||
return UploadScanResult::clean();
|
||||
}
|
||||
|
||||
if ($process->getExitCode() === 1) {
|
||||
return UploadScanResult::infected(trim($process->getOutput()));
|
||||
}
|
||||
|
||||
throw new RuntimeException('Upload scan failed: ' . trim($process->getErrorOutput()));
|
||||
}
|
||||
|
||||
private function buildCommand(array $command, string $path): array
|
||||
{
|
||||
return array_map(static function (string $part) use ($path): string {
|
||||
return $part === '{path}' ? $path : $part;
|
||||
}, $command);
|
||||
}
|
||||
}
|
||||
16
app/Services/Uploads/UploadSessionStatus.php
Normal file
16
app/Services/Uploads/UploadSessionStatus.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
final class UploadSessionStatus
|
||||
{
|
||||
public const INIT = 'init';
|
||||
public const TMP = 'tmp';
|
||||
public const VALIDATED = 'validated';
|
||||
public const SCANNED = 'scanned';
|
||||
public const PROCESSED = 'processed';
|
||||
public const QUARANTINED = 'quarantined';
|
||||
public const CANCELLED = 'cancelled';
|
||||
}
|
||||
50
app/Services/Uploads/UploadStatusService.php
Normal file
50
app/Services/Uploads/UploadStatusService.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
final class UploadStatusService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UploadSessionRepository $sessions,
|
||||
private readonly UploadStorageService $storage
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function get(string $sessionId): array
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
$receivedBytes = $this->safeFileSize($session->tempPath);
|
||||
|
||||
return [
|
||||
'session_id' => $session->id,
|
||||
'status' => $session->status,
|
||||
'progress' => $session->progress,
|
||||
'failure_reason' => $session->failureReason,
|
||||
'user_id' => $session->userId,
|
||||
'received_bytes' => $receivedBytes,
|
||||
];
|
||||
}
|
||||
|
||||
private function safeFileSize(string $path): int
|
||||
{
|
||||
$tmpRoot = $this->storage->sectionPath('tmp');
|
||||
$realRoot = realpath($tmpRoot);
|
||||
$realPath = realpath($path);
|
||||
|
||||
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (! File::exists($realPath)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) File::size($realPath);
|
||||
}
|
||||
}
|
||||
136
app/Services/Uploads/UploadStorageService.php
Normal file
136
app/Services/Uploads/UploadStorageService.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\DTOs\Uploads\UploadStoredFile;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadStorageService
|
||||
{
|
||||
public function sectionPath(string $section): string
|
||||
{
|
||||
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
|
||||
$paths = (array) config('uploads.paths');
|
||||
|
||||
if (! array_key_exists($section, $paths)) {
|
||||
throw new RuntimeException('Unknown upload storage section: ' . $section);
|
||||
}
|
||||
|
||||
return $root . DIRECTORY_SEPARATOR . trim((string) $paths[$section], DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
public function ensureSection(string $section): string
|
||||
{
|
||||
$path = $this->sectionPath($section);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
File::makeDirectory($path, 0755, true);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function storeUploadedFile(UploadedFile $file, string $section): UploadStoredFile
|
||||
{
|
||||
$dir = $this->ensureSection($section);
|
||||
$extension = $this->safeExtension($file);
|
||||
$filename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : '');
|
||||
|
||||
$file->move($dir, $filename);
|
||||
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
return UploadStoredFile::fromPath($path);
|
||||
}
|
||||
|
||||
public function moveToSection(string $path, string $section): string
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('Source file not found for move.');
|
||||
}
|
||||
|
||||
$dir = $this->ensureSection($section);
|
||||
$extension = (string) pathinfo($path, PATHINFO_EXTENSION);
|
||||
$filename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : '');
|
||||
$target = $dir . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
File::move($path, $target);
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
public function ensureHashDirectory(string $section, string $hash): string
|
||||
{
|
||||
$segments = $this->hashSegments($hash);
|
||||
$dir = $this->sectionPath($section) . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
|
||||
|
||||
if (! File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function publicHashDirectory(string $hash): string
|
||||
{
|
||||
$prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR);
|
||||
$base = $this->sectionPath('public') . DIRECTORY_SEPARATOR . $prefix;
|
||||
|
||||
if (! File::exists($base)) {
|
||||
File::makeDirectory($base, 0755, true);
|
||||
}
|
||||
|
||||
$segments = $this->hashSegments($hash);
|
||||
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
|
||||
|
||||
if (! File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function publicRelativePath(string $hash, string $filename): string
|
||||
{
|
||||
$prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR);
|
||||
$segments = $this->hashSegments($hash);
|
||||
|
||||
return $prefix . '/' . implode('/', $segments) . '/' . ltrim($filename, '/');
|
||||
}
|
||||
|
||||
public function sectionRelativePath(string $section, string $hash, string $filename): string
|
||||
{
|
||||
$segments = $this->hashSegments($hash);
|
||||
$section = trim($section, DIRECTORY_SEPARATOR);
|
||||
|
||||
return $section . '/' . implode('/', $segments) . '/' . ltrim($filename, '/');
|
||||
}
|
||||
|
||||
private function safeExtension(UploadedFile $file): string
|
||||
{
|
||||
$extension = (string) $file->guessExtension();
|
||||
$extension = strtolower($extension);
|
||||
|
||||
return preg_match('/^[a-z0-9]+$/', $extension) ? $extension : '';
|
||||
}
|
||||
|
||||
private function hashSegments(string $hash): array
|
||||
{
|
||||
$hash = strtolower($hash);
|
||||
$hash = preg_replace('/[^a-z0-9]/', '', $hash) ?? '';
|
||||
$hash = str_pad($hash, 6, '0');
|
||||
|
||||
$segments = [
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
substr($hash, 4, 2),
|
||||
];
|
||||
|
||||
return array_map(static fn (string $part): string => $part === '' ? '00' : $part, $segments);
|
||||
}
|
||||
}
|
||||
36
app/Services/Uploads/UploadTokenService.php
Normal file
36
app/Services/Uploads/UploadTokenService.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class UploadTokenService
|
||||
{
|
||||
public function generate(string $sessionId, int $userId): string
|
||||
{
|
||||
$token = Str::random(64);
|
||||
$ttl = (int) config('uploads.tokens.ttl_minutes', 60);
|
||||
|
||||
Cache::put($this->cacheKey($token), [
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $userId,
|
||||
], now()->addMinutes($ttl));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function get(string $token): ?array
|
||||
{
|
||||
$data = Cache::get($this->cacheKey($token));
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
private function cacheKey(string $token): string
|
||||
{
|
||||
return 'uploads:token:' . $token;
|
||||
}
|
||||
}
|
||||
112
app/Services/Uploads/UploadValidationService.php
Normal file
112
app/Services/Uploads/UploadValidationService.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\DTOs\Uploads\UploadValidationResult;
|
||||
|
||||
final class UploadValidationService
|
||||
{
|
||||
public function validate(string $path): UploadValidationResult
|
||||
{
|
||||
if (! is_file($path) || ! is_readable($path)) {
|
||||
return UploadValidationResult::fail('file_unreadable');
|
||||
}
|
||||
|
||||
$size = (int) filesize($path);
|
||||
$maxBytes = $this->maxSizeBytes();
|
||||
if ($maxBytes > 0 && $size > $maxBytes) {
|
||||
return UploadValidationResult::fail('file_too_large', null, null, null, $size);
|
||||
}
|
||||
|
||||
$mime = $this->detectMime($path);
|
||||
if ($mime === '' || ! in_array($mime, $this->allowedMimes(), true)) {
|
||||
return UploadValidationResult::fail('mime_not_allowed', null, null, $mime, $size);
|
||||
}
|
||||
|
||||
$info = @getimagesize($path);
|
||||
if (! $info || empty($info[0]) || empty($info[1])) {
|
||||
return UploadValidationResult::fail('invalid_image', null, null, $mime, $size);
|
||||
}
|
||||
|
||||
$width = (int) $info[0];
|
||||
$height = (int) $info[1];
|
||||
$maxPixels = $this->maxPixels();
|
||||
if ($maxPixels > 0 && ($width > $maxPixels || $height > $maxPixels)) {
|
||||
return UploadValidationResult::fail('image_too_large', $width, $height, $mime, $size);
|
||||
}
|
||||
|
||||
$data = @file_get_contents($path);
|
||||
if ($data === false) {
|
||||
return UploadValidationResult::fail('file_unreadable', $width, $height, $mime, $size);
|
||||
}
|
||||
|
||||
$image = @imagecreatefromstring($data);
|
||||
if ($image === false) {
|
||||
return UploadValidationResult::fail('decode_failed', $width, $height, $mime, $size);
|
||||
}
|
||||
|
||||
$reencodeOk = $this->reencodeTest($image, $mime);
|
||||
imagedestroy($image);
|
||||
|
||||
if (! $reencodeOk) {
|
||||
return UploadValidationResult::fail('reencode_failed', $width, $height, $mime, $size);
|
||||
}
|
||||
|
||||
return UploadValidationResult::ok($width, $height, $mime, $size);
|
||||
}
|
||||
|
||||
private function maxSizeBytes(): int
|
||||
{
|
||||
return (int) config('uploads.max_size_mb', 0) * 1024 * 1024;
|
||||
}
|
||||
|
||||
private function maxPixels(): int
|
||||
{
|
||||
return (int) config('uploads.max_pixels', 0);
|
||||
}
|
||||
|
||||
private function allowedMimes(): array
|
||||
{
|
||||
$allowed = (array) config('uploads.allowed_mimes', []);
|
||||
if ((bool) config('uploads.allow_gif', false)) {
|
||||
$allowed[] = 'image/gif';
|
||||
}
|
||||
|
||||
return array_values(array_unique($allowed));
|
||||
}
|
||||
|
||||
private function detectMime(string $path): string
|
||||
{
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($path);
|
||||
|
||||
return $mime ? (string) $mime : '';
|
||||
}
|
||||
|
||||
private function reencodeTest($image, string $mime): bool
|
||||
{
|
||||
ob_start();
|
||||
$result = false;
|
||||
|
||||
switch ($mime) {
|
||||
case 'image/jpeg':
|
||||
$result = function_exists('imagejpeg') ? imagejpeg($image, null, 80) : false;
|
||||
break;
|
||||
case 'image/png':
|
||||
$result = function_exists('imagepng') ? imagepng($image, null, 6) : false;
|
||||
break;
|
||||
case 'image/webp':
|
||||
$result = function_exists('imagewebp') ? imagewebp($image, null, 80) : false;
|
||||
break;
|
||||
case 'image/gif':
|
||||
$result = function_exists('imagegif') ? imagegif($image) : false;
|
||||
break;
|
||||
}
|
||||
|
||||
$data = ob_get_clean();
|
||||
|
||||
return (bool) $result && is_string($data) && $data !== '';
|
||||
}
|
||||
}
|
||||
95
app/Services/Vision/ArtworkEmbeddingClient.php
Normal file
95
app/Services/Vision/ArtworkEmbeddingClient.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
final class ArtworkEmbeddingClient
|
||||
{
|
||||
/**
|
||||
* @return array<int, float>
|
||||
*/
|
||||
public function embed(string $imageUrl, int $artworkId, string $sourceHash): array
|
||||
{
|
||||
$base = trim((string) config('vision.clip.base_url', ''));
|
||||
if ($base === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$endpoint = (string) config('recommendations.embedding.endpoint', '/embed');
|
||||
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
|
||||
|
||||
$timeout = (int) config('recommendations.embedding.timeout_seconds', 8);
|
||||
$connectTimeout = (int) config('recommendations.embedding.connect_timeout_seconds', 2);
|
||||
$retries = (int) config('recommendations.embedding.retries', 1);
|
||||
$delay = (int) config('recommendations.embedding.retry_delay_ms', 200);
|
||||
|
||||
$response = Http::acceptJson()
|
||||
->connectTimeout(max(1, $connectTimeout))
|
||||
->timeout(max(1, $timeout))
|
||||
->retry(max(0, $retries), max(0, $delay), throw: false)
|
||||
->post($url, [
|
||||
'image_url' => $imageUrl,
|
||||
'artwork_id' => $artworkId,
|
||||
'hash' => $sourceHash,
|
||||
]);
|
||||
|
||||
if (! $response->ok()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractEmbedding($response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $json
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function extractEmbedding(mixed $json): array
|
||||
{
|
||||
$candidate = null;
|
||||
|
||||
if (is_array($json) && $this->isNumericVector($json)) {
|
||||
$candidate = $json;
|
||||
} elseif (is_array($json) && isset($json['embedding']) && is_array($json['embedding'])) {
|
||||
$candidate = $json['embedding'];
|
||||
} elseif (is_array($json) && isset($json['data']['embedding']) && is_array($json['data']['embedding'])) {
|
||||
$candidate = $json['data']['embedding'];
|
||||
}
|
||||
|
||||
if (! is_array($candidate) || ! $this->isNumericVector($candidate)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$vector = array_map(static fn ($value): float => (float) $value, $candidate);
|
||||
$dim = count($vector);
|
||||
|
||||
$minDim = (int) config('recommendations.embedding.min_dim', 64);
|
||||
$maxDim = (int) config('recommendations.embedding.max_dim', 4096);
|
||||
if ($dim < $minDim || $dim > $maxDim) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $arr
|
||||
*/
|
||||
private function isNumericVector(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($arr as $value) {
|
||||
if (! is_numeric($value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user