Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
@@ -269,6 +269,27 @@ final class ArtworkSearchService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rising: sorted by heat_score (recalculated every 15 min).
|
||||
*
|
||||
* Surfaces artworks with rapid recent engagement growth.
|
||||
* Restricts to last 30 days, sorted by heat_score DESC.
|
||||
*/
|
||||
public function discoverRising(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$cutoff = now()->subDays(30)->toDateString();
|
||||
|
||||
return Cache::remember("discover.rising.{$page}", 120, function () use ($perPage, $cutoff) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh: newest uploads first.
|
||||
*/
|
||||
|
||||
@@ -44,6 +44,7 @@ final class HomepageService
|
||||
{
|
||||
return [
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'rising' => $this->getRising(),
|
||||
'trending' => $this->getTrending(),
|
||||
'fresh' => $this->getFreshUploads(),
|
||||
'tags' => $this->getPopularTags(),
|
||||
@@ -74,6 +75,7 @@ final class HomepageService
|
||||
'hero' => $this->getHeroArtwork(),
|
||||
'for_you' => $this->getForYouPreview($user),
|
||||
'from_following' => $this->getFollowingFeed($user, $prefs),
|
||||
'rising' => $this->getRising(),
|
||||
'trending' => $this->getTrending(),
|
||||
'fresh' => $this->getFreshUploads(),
|
||||
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
|
||||
@@ -132,6 +134,65 @@ final class HomepageService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min).
|
||||
*
|
||||
* Surfaces artworks with the fastest recent engagement growth.
|
||||
* Falls back to DB ORDER BY heat_score if Meilisearch is unavailable.
|
||||
*/
|
||||
public function getRising(int $limit = 10): array
|
||||
{
|
||||
$cutoff = now()->subDays(30)->toDateString();
|
||||
|
||||
return Cache::remember("homepage.rising.{$limit}", 120, function () use ($limit, $cutoff): array {
|
||||
try {
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
|
||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
|
||||
])
|
||||
->paginate($limit, 'page', 1);
|
||||
|
||||
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
|
||||
|
||||
if ($results->isEmpty()) {
|
||||
return $this->getRisingFromDb($limit);
|
||||
}
|
||||
|
||||
return $results->getCollection()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('HomepageService::getRising Meilisearch unavailable, DB fallback', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->getRisingFromDb($limit);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DB-only fallback for rising (Meilisearch unavailable).
|
||||
*/
|
||||
private function getRisingFromDb(int $limit): array
|
||||
{
|
||||
return Artwork::public()
|
||||
->published()
|
||||
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->where('artworks.published_at', '>=', now()->subDays(30))
|
||||
->orderByDesc('artwork_stats.heat_score')
|
||||
->orderByDesc('artwork_stats.engagement_velocity')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($a) => $this->serializeArtwork($a))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
|
||||
*
|
||||
|
||||
@@ -49,7 +49,7 @@ class LegacyService
|
||||
$featured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Featured Artwork',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
];
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class LegacyService
|
||||
$memberFeatured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Members Pick',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
'votes' => 0,
|
||||
];
|
||||
@@ -106,7 +106,7 @@ class LegacyService
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'Sample Artwork',
|
||||
'picture' => 'gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
'category_name' => 'Photography',
|
||||
],
|
||||
@@ -282,7 +282,7 @@ class LegacyService
|
||||
} else {
|
||||
$row->ext = null;
|
||||
$row->encoded = null;
|
||||
$row->thumb_url = '/gfx/sb_join.jpg';
|
||||
$row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$row->thumb_srcset = null;
|
||||
}
|
||||
|
||||
|
||||
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal file
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RecArtworkRec;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Runtime service for the Similar Artworks hybrid recommender (spec §8).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Try precomputed similar_hybrid list
|
||||
* 2. Else similar_visual (if enabled)
|
||||
* 3. Else similar_tags
|
||||
* 4. Else similar_behavior
|
||||
* 5. Else trending fallback in the same category/content_type
|
||||
*
|
||||
* Lists are cached in Redis/cache with a configurable TTL.
|
||||
* Hydration fetches artworks in one query, preserving stored order.
|
||||
* An author-cap diversity filter is applied at runtime as a final check.
|
||||
*/
|
||||
final class HybridSimilarArtworksService
|
||||
{
|
||||
private const FALLBACK_ORDER = [
|
||||
'similar_hybrid',
|
||||
'similar_visual',
|
||||
'similar_tags',
|
||||
'similar_behavior',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get similar artworks for the given artwork.
|
||||
*
|
||||
* @param string|null $type null|'similar'='hybrid fallback', 'visual', 'tags', 'behavior'
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
public function forArtwork(int $artworkId, int $limit = 12, ?string $type = null): Collection
|
||||
{
|
||||
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
|
||||
$cacheTtl = (int) config('recommendations.similarity.cache_ttl', 6 * 3600);
|
||||
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
|
||||
$vectorEnabled = (bool) config('recommendations.similarity.vector_enabled', false);
|
||||
|
||||
$typeSuffix = $type && $type !== 'similar' ? ":{$type}" : '';
|
||||
$cacheKey = "rec:artwork:{$artworkId}:similar:{$modelVersion}{$typeSuffix}";
|
||||
|
||||
$ids = Cache::remember($cacheKey, $cacheTtl, function () use (
|
||||
$artworkId, $modelVersion, $vectorEnabled, $type
|
||||
): array {
|
||||
return $this->resolveIds($artworkId, $modelVersion, $vectorEnabled, $type);
|
||||
});
|
||||
|
||||
if ($ids === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Take requested limit + buffer for author-diversity filtering
|
||||
$idSlice = array_slice($ids, 0, $limit * 3);
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->whereIn('id', $idSlice)
|
||||
->public()
|
||||
->published()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Preserve precomputed order + apply author cap
|
||||
$authorCounts = [];
|
||||
$result = [];
|
||||
|
||||
foreach ($idSlice as $id) {
|
||||
/** @var Artwork|null $artwork */
|
||||
$artwork = $artworks->get($id);
|
||||
if (! $artwork) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$authorId = $artwork->user_id;
|
||||
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
|
||||
if ($authorCounts[$authorId] > $maxPerAuthor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = $artwork;
|
||||
if (count($result) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return collect($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the precomputed ID list, falling through rec types.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function resolveIds(int $artworkId, string $modelVersion, bool $vectorEnabled, ?string $type = null): array
|
||||
{
|
||||
// If a specific type was requested, try only that type + trending fallback
|
||||
if ($type && $type !== 'similar') {
|
||||
$recType = match ($type) {
|
||||
'visual' => 'similar_visual',
|
||||
'tags' => 'similar_tags',
|
||||
'behavior' => 'similar_behavior',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($recType) {
|
||||
$rec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('rec_type', $recType)
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if ($rec && is_array($rec->recs) && $rec->recs !== []) {
|
||||
return array_map('intval', $rec->recs);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->trendingFallback($artworkId);
|
||||
}
|
||||
|
||||
// Default: hybrid fallback chain
|
||||
$tryTypes = $vectorEnabled
|
||||
? self::FALLBACK_ORDER
|
||||
: array_filter(self::FALLBACK_ORDER, fn (string $t) => $t !== 'similar_visual');
|
||||
|
||||
foreach ($tryTypes as $recType) {
|
||||
$rec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('rec_type', $recType)
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if ($rec && is_array($rec->recs) && $rec->recs !== []) {
|
||||
return array_map('intval', $rec->recs);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trending fallback (category-scoped) ────────────────────────────────
|
||||
return $this->trendingFallback($artworkId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending fallback: fetch recent popular artworks in the same category.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function trendingFallback(int $artworkId): array
|
||||
{
|
||||
$catIds = DB::table('artwork_category')
|
||||
->where('artwork_id', $artworkId)
|
||||
->pluck('category_id')
|
||||
->all();
|
||||
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->where('id', '!=', $artworkId);
|
||||
|
||||
if ($catIds !== []) {
|
||||
$query->whereHas('categories', function ($q) use ($catIds) {
|
||||
$q->whereIn('categories.id', $catIds);
|
||||
});
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('published_at')
|
||||
->limit(30)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* pgvector adapter — uses the artwork_embeddings table with cosine similarity.
|
||||
*
|
||||
* Requires PostgreSQL with the pgvector extension installed.
|
||||
* Schema: artwork_embeddings (artwork_id PK, model, dims, embedding vector(N), ...)
|
||||
*
|
||||
* Spec §9 Option A.
|
||||
*/
|
||||
final class PgvectorAdapter implements VectorAdapterInterface
|
||||
{
|
||||
public function querySimilar(int $artworkId, int $topK = 100): array
|
||||
{
|
||||
// Fetch reference embedding
|
||||
$ref = DB::table('artwork_embeddings')
|
||||
->where('artwork_id', $artworkId)
|
||||
->select('embedding_json')
|
||||
->first();
|
||||
|
||||
if (! $ref || ! $ref->embedding_json) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$embedding = json_decode($ref->embedding_json, true);
|
||||
if (! is_array($embedding) || $embedding === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// pgvector cosine distance operator: <=>
|
||||
// Score = 1 - distance (higher = more similar)
|
||||
$vecLiteral = '[' . implode(',', array_map('floatval', $embedding)) . ']';
|
||||
|
||||
try {
|
||||
$rows = DB::select(
|
||||
"SELECT artwork_id, 1 - (embedding_json::vector <=> ?::vector) AS score
|
||||
FROM artwork_embeddings
|
||||
WHERE artwork_id != ?
|
||||
ORDER BY embedding_json::vector <=> ?::vector
|
||||
LIMIT ?",
|
||||
[$vecLiteral, $artworkId, $vecLiteral, $topK]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PgvectorAdapter] Query failed: {$e->getMessage()}");
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn ($row) => [
|
||||
'artwork_id' => (int) $row->artwork_id,
|
||||
'score' => (float) $row->score,
|
||||
], $rows);
|
||||
}
|
||||
|
||||
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void
|
||||
{
|
||||
$json = json_encode($embedding);
|
||||
|
||||
DB::table('artwork_embeddings')->updateOrInsert(
|
||||
['artwork_id' => $artworkId],
|
||||
[
|
||||
'embedding_json' => $json,
|
||||
'model' => $metadata['model'] ?? 'clip',
|
||||
'model_version' => $metadata['model_version'] ?? 'v1',
|
||||
'dim' => count($embedding),
|
||||
'is_normalized' => $metadata['is_normalized'] ?? true,
|
||||
'generated_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteEmbedding(int $artworkId): void
|
||||
{
|
||||
DB::table('artwork_embeddings')
|
||||
->where('artwork_id', $artworkId)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Managed vector DB adapter (Pinecone-style REST API).
|
||||
*
|
||||
* Spec §9 Option B.
|
||||
*
|
||||
* Configuration:
|
||||
* recommendations.similarity.pinecone.api_key
|
||||
* recommendations.similarity.pinecone.index_host
|
||||
* recommendations.similarity.pinecone.index_name
|
||||
* recommendations.similarity.pinecone.namespace
|
||||
* recommendations.similarity.pinecone.top_k
|
||||
*/
|
||||
final class PineconeAdapter implements VectorAdapterInterface
|
||||
{
|
||||
private function apiKey(): string
|
||||
{
|
||||
return (string) config('recommendations.similarity.pinecone.api_key', '');
|
||||
}
|
||||
|
||||
private function host(): string
|
||||
{
|
||||
return rtrim((string) config('recommendations.similarity.pinecone.index_host', ''), '/');
|
||||
}
|
||||
|
||||
private function namespace(): string
|
||||
{
|
||||
return (string) config('recommendations.similarity.pinecone.namespace', '');
|
||||
}
|
||||
|
||||
public function querySimilar(int $artworkId, int $topK = 100): array
|
||||
{
|
||||
$configTopK = (int) config('recommendations.similarity.pinecone.top_k', 100);
|
||||
$effectiveTopK = min($topK, $configTopK);
|
||||
|
||||
$vectorId = "artwork:{$artworkId}";
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Api-Key' => $this->apiKey(),
|
||||
'Content-Type' => 'application/json',
|
||||
])->timeout(10)->post("{$this->host()}/query", array_filter([
|
||||
'id' => $vectorId,
|
||||
'topK' => $effectiveTopK + 1, // +1 to exclude self
|
||||
'includeMetadata' => true,
|
||||
'namespace' => $this->namespace() ?: null,
|
||||
'filter' => [
|
||||
'is_active' => ['$eq' => true],
|
||||
],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning("[PineconeAdapter] Query failed: HTTP {$response->status()}");
|
||||
return [];
|
||||
}
|
||||
|
||||
$matches = $response->json('matches', []);
|
||||
|
||||
$results = [];
|
||||
foreach ($matches as $match) {
|
||||
$matchId = $match['id'] ?? '';
|
||||
// Extract artwork ID from "artwork:123" format
|
||||
if (! str_starts_with($matchId, 'artwork:')) {
|
||||
continue;
|
||||
}
|
||||
$matchArtworkId = (int) substr($matchId, 8);
|
||||
if ($matchArtworkId === $artworkId) {
|
||||
continue; // skip self
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'artwork_id' => $matchArtworkId,
|
||||
'score' => (float) ($match['score'] ?? 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PineconeAdapter] Query exception: {$e->getMessage()}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void
|
||||
{
|
||||
$vectorId = "artwork:{$artworkId}";
|
||||
|
||||
// Spec §9B: metadata should include category_id, content_type, author_id, is_active, nsfw
|
||||
$pineconeMetadata = array_merge([
|
||||
'is_active' => true,
|
||||
'category_id' => $metadata['category_id'] ?? null,
|
||||
'content_type' => $metadata['content_type'] ?? null,
|
||||
'author_id' => $metadata['author_id'] ?? null,
|
||||
'nsfw' => $metadata['nsfw'] ?? false,
|
||||
], array_diff_key($metadata, array_flip([
|
||||
'category_id', 'content_type', 'author_id', 'nsfw', 'is_active',
|
||||
])));
|
||||
|
||||
// Remove null values (Pinecone doesn't accept nulls in metadata)
|
||||
$pineconeMetadata = array_filter($pineconeMetadata, fn ($v) => $v !== null);
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Api-Key' => $this->apiKey(),
|
||||
'Content-Type' => 'application/json',
|
||||
])->timeout(10)->post("{$this->host()}/vectors/upsert", array_filter([
|
||||
'vectors' => [
|
||||
[
|
||||
'id' => $vectorId,
|
||||
'values' => array_map('floatval', $embedding),
|
||||
'metadata' => $pineconeMetadata,
|
||||
],
|
||||
],
|
||||
'namespace' => $this->namespace() ?: null,
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning("[PineconeAdapter] Upsert failed for artwork {$artworkId}: HTTP {$response->status()}");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PineconeAdapter] Upsert exception for artwork {$artworkId}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteEmbedding(int $artworkId): void
|
||||
{
|
||||
$vectorId = "artwork:{$artworkId}";
|
||||
|
||||
try {
|
||||
Http::withHeaders([
|
||||
'Api-Key' => $this->apiKey(),
|
||||
'Content-Type' => 'application/json',
|
||||
])->timeout(10)->post("{$this->host()}/vectors/delete", array_filter([
|
||||
'ids' => [$vectorId],
|
||||
'namespace' => $this->namespace() ?: null,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[PineconeAdapter] Delete exception for artwork {$artworkId}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Factory to resolve the configured VectorAdapterInterface implementation.
|
||||
*/
|
||||
final class VectorAdapterFactory
|
||||
{
|
||||
/**
|
||||
* @return VectorAdapterInterface|null null when vector similarity is disabled
|
||||
*/
|
||||
public static function make(): ?VectorAdapterInterface
|
||||
{
|
||||
if (! (bool) config('recommendations.similarity.vector_enabled', false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$adapter = (string) config('recommendations.similarity.vector_adapter', 'pgvector');
|
||||
|
||||
return match ($adapter) {
|
||||
'pgvector' => new PgvectorAdapter(),
|
||||
'pinecone' => new PineconeAdapter(),
|
||||
default => self::fallback($adapter),
|
||||
};
|
||||
}
|
||||
|
||||
private static function fallback(string $adapter): PgvectorAdapter
|
||||
{
|
||||
Log::warning("[VectorAdapterFactory] Unknown adapter '{$adapter}', falling back to pgvector.");
|
||||
return new PgvectorAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Recommendations\VectorSimilarity;
|
||||
|
||||
/**
|
||||
* Contract for vector-similarity adapters (pgvector, Pinecone, etc.).
|
||||
*
|
||||
* Each adapter can query nearest-neighbor artworks for a given artwork ID
|
||||
* and return an ordered list of (artwork_id, score) pairs.
|
||||
*/
|
||||
interface VectorAdapterInterface
|
||||
{
|
||||
/**
|
||||
* Find the most visually similar artworks.
|
||||
*
|
||||
* @param int $artworkId Source artwork
|
||||
* @param int $topK Max neighbors to return
|
||||
* @return list<array{artwork_id: int, score: float}> Ordered by score descending
|
||||
*/
|
||||
public function querySimilar(int $artworkId, int $topK = 100): array;
|
||||
|
||||
/**
|
||||
* Upsert an artwork embedding into the vector store.
|
||||
*
|
||||
* @param int $artworkId
|
||||
* @param array $embedding Raw float vector
|
||||
* @param array $metadata Optional metadata (category, author, etc.)
|
||||
*/
|
||||
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void;
|
||||
|
||||
/**
|
||||
* Delete an artwork embedding from the vector store.
|
||||
*/
|
||||
public function deleteEmbedding(int $artworkId): void;
|
||||
}
|
||||
209
app/Services/Studio/StudioArtworkQueryService.php
Normal file
209
app/Services/Studio/StudioArtworkQueryService.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Handles artwork listing queries for Studio, using Meilisearch with DB fallback.
|
||||
*/
|
||||
final class StudioArtworkQueryService
|
||||
{
|
||||
/**
|
||||
* List artworks for a creator with search, filter, and sort via Meilisearch.
|
||||
*
|
||||
* Supported $filters keys:
|
||||
* q string — free-text search
|
||||
* status string — published|draft|archived
|
||||
* category string — category slug
|
||||
* tags array — tag slugs
|
||||
* date_from string — Y-m-d
|
||||
* date_to string — Y-m-d
|
||||
* performance string — rising|top|low
|
||||
* sort string — created_at:desc (default), ranking_score:desc, heat_score:desc, etc.
|
||||
*/
|
||||
public function list(int $userId, array $filters = [], int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
// Skip Meilisearch when driver is null (e.g. in tests)
|
||||
$driver = config('scout.driver');
|
||||
if (empty($driver) || $driver === 'null') {
|
||||
return $this->listViaDatabase($userId, $filters, $perPage);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->listViaMeilisearch($userId, $filters, $perPage);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Meilisearch unavailable, falling back to DB', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return $this->listViaDatabase($userId, $filters, $perPage);
|
||||
}
|
||||
}
|
||||
|
||||
private function listViaMeilisearch(int $userId, array $filters, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$q = $filters['q'] ?? '';
|
||||
$filterParts = ["author_id = {$userId}"];
|
||||
$sort = [];
|
||||
|
||||
// Status filter
|
||||
$status = $filters['status'] ?? null;
|
||||
if ($status === 'published') {
|
||||
$filterParts[] = 'is_public = true AND is_approved = true';
|
||||
} elseif ($status === 'draft') {
|
||||
$filterParts[] = 'is_public = false';
|
||||
}
|
||||
// archived handled at DB level since Meili doesn't see soft-deleted
|
||||
|
||||
// Category filter
|
||||
if (!empty($filters['category'])) {
|
||||
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (!empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$filterParts[] = 'tags = "' . addslashes((string) $tag) . '"';
|
||||
}
|
||||
}
|
||||
|
||||
// Date range
|
||||
if (!empty($filters['date_from'])) {
|
||||
$filterParts[] = 'created_at >= "' . $filters['date_from'] . '"';
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$filterParts[] = 'created_at <= "' . $filters['date_to'] . '"';
|
||||
}
|
||||
|
||||
// Performance quick filters
|
||||
if (!empty($filters['performance'])) {
|
||||
match ($filters['performance']) {
|
||||
'rising' => $filterParts[] = 'heat_score > 5',
|
||||
'top' => $filterParts[] = 'ranking_score > 50',
|
||||
'low' => $filterParts[] = 'views < 10',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortParam = $filters['sort'] ?? 'created_at:desc';
|
||||
$validSortFields = [
|
||||
'created_at', 'ranking_score', 'heat_score',
|
||||
'views', 'likes', 'shares_count',
|
||||
'downloads', 'comments_count', 'favorites_count',
|
||||
];
|
||||
$parts = explode(':', $sortParam);
|
||||
if (count($parts) === 2 && in_array($parts[0], $validSortFields, true)) {
|
||||
$sort[] = $parts[0] . ':' . ($parts[1] === 'asc' ? 'asc' : 'desc');
|
||||
}
|
||||
|
||||
$options = ['filter' => implode(' AND ', $filterParts)];
|
||||
if ($sort !== []) {
|
||||
$options['sort'] = $sort;
|
||||
}
|
||||
|
||||
return Artwork::search($q ?: '')
|
||||
->options($options)
|
||||
->query(fn (Builder $query) => $query
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->withCount(['comments', 'downloads'])
|
||||
)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
private function listViaDatabase(int $userId, array $filters, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$query = Artwork::where('user_id', $userId)
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->withCount(['comments', 'downloads']);
|
||||
|
||||
$status = $filters['status'] ?? null;
|
||||
if ($status === 'published') {
|
||||
$query->where('is_public', true)->where('is_approved', true);
|
||||
} elseif ($status === 'draft') {
|
||||
$query->where('is_public', false);
|
||||
} elseif ($status === 'archived') {
|
||||
$query->onlyTrashed();
|
||||
} else {
|
||||
// Show all except archived by default
|
||||
$query->whereNull('deleted_at');
|
||||
}
|
||||
|
||||
// Free-text search
|
||||
if (!empty($filters['q'])) {
|
||||
$q = $filters['q'];
|
||||
$query->where(function (Builder $w) use ($q) {
|
||||
$w->where('title', 'LIKE', "%{$q}%")
|
||||
->orWhereHas('tags', fn (Builder $t) => $t->where('slug', 'LIKE', "%{$q}%"));
|
||||
});
|
||||
}
|
||||
|
||||
// Category
|
||||
if (!empty($filters['category'])) {
|
||||
$query->whereHas('categories', fn (Builder $c) => $c->where('slug', $filters['category']));
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (!empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$query->whereHas('tags', fn (Builder $t) => $t->where('slug', $tag));
|
||||
}
|
||||
}
|
||||
|
||||
// Date range
|
||||
if (!empty($filters['date_from'])) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
// Performance
|
||||
if (!empty($filters['performance'])) {
|
||||
$query->whereHas('stats', function (Builder $s) use ($filters) {
|
||||
match ($filters['performance']) {
|
||||
'rising' => $s->where('heat_score', '>', 5),
|
||||
'top' => $s->where('ranking_score', '>', 50),
|
||||
'low' => $s->where('views', '<', 10),
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortParam = $filters['sort'] ?? 'created_at:desc';
|
||||
$parts = explode(':', $sortParam);
|
||||
$sortField = $parts[0] ?? 'created_at';
|
||||
$sortDir = ($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$dbSortMap = [
|
||||
'created_at' => 'artworks.created_at',
|
||||
'ranking_score' => 'ranking_score',
|
||||
'heat_score' => 'heat_score',
|
||||
'views' => 'views',
|
||||
'likes' => 'favorites',
|
||||
'shares_count' => 'shares_count',
|
||||
'downloads' => 'downloads',
|
||||
'comments_count' => 'comments_count',
|
||||
'favorites_count' => 'favorites',
|
||||
];
|
||||
|
||||
$statsSortFields = ['ranking_score', 'heat_score', 'views', 'likes', 'shares_count', 'downloads', 'comments_count', 'favorites_count'];
|
||||
|
||||
if (in_array($sortField, $statsSortFields, true)) {
|
||||
$dbCol = $dbSortMap[$sortField] ?? $sortField;
|
||||
$query->leftJoin('artwork_stats', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->orderBy("artwork_stats.{$dbCol}", $sortDir)
|
||||
->select('artworks.*');
|
||||
} else {
|
||||
$query->orderBy($dbSortMap[$sortField] ?? 'artworks.created_at', $sortDir);
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
}
|
||||
165
app/Services/Studio/StudioBulkActionService.php
Normal file
165
app/Services/Studio/StudioBulkActionService.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Handles bulk operations on artworks for the Studio module.
|
||||
*/
|
||||
final class StudioBulkActionService
|
||||
{
|
||||
/**
|
||||
* Execute a bulk action on the given artwork IDs, enforcing ownership.
|
||||
*
|
||||
* @param int $userId The authenticated user ID
|
||||
* @param string $action publish|unpublish|archive|unarchive|delete|change_category|add_tags|remove_tags
|
||||
* @param array $artworkIds Array of artwork IDs
|
||||
* @param array $params Extra params (category_id, tag_ids)
|
||||
* @return array{success: int, failed: int, errors: array}
|
||||
*/
|
||||
public function execute(int $userId, string $action, array $artworkIds, array $params = []): array
|
||||
{
|
||||
$result = ['success' => 0, 'failed' => 0, 'errors' => []];
|
||||
|
||||
// Validate ownership — fetch only artworks belonging to this user
|
||||
$query = Artwork::where('user_id', $userId);
|
||||
if ($action === 'unarchive') {
|
||||
$query->onlyTrashed();
|
||||
}
|
||||
$artworks = $query->whereIn('id', $artworkIds)->get();
|
||||
|
||||
$foundIds = $artworks->pluck('id')->all();
|
||||
$missingIds = array_diff($artworkIds, $foundIds);
|
||||
foreach ($missingIds as $id) {
|
||||
$result['failed']++;
|
||||
$result['errors'][] = "Artwork #{$id}: not found or not owned by you";
|
||||
}
|
||||
|
||||
if ($artworks->isEmpty()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($artworks as $artwork) {
|
||||
$this->applyAction($artwork, $action, $params);
|
||||
$result['success']++;
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Reindex affected artworks in Meilisearch
|
||||
$this->reindexArtworks($artworks);
|
||||
|
||||
Log::info('Studio bulk action completed', [
|
||||
'user_id' => $userId,
|
||||
'action' => $action,
|
||||
'count' => $result['success'],
|
||||
'ids' => $foundIds,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
$result['failed'] += $result['success'];
|
||||
$result['success'] = 0;
|
||||
$result['errors'][] = 'Transaction failed: ' . $e->getMessage();
|
||||
|
||||
Log::error('Studio bulk action failed', [
|
||||
'user_id' => $userId,
|
||||
'action' => $action,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function applyAction(Artwork $artwork, string $action, array $params): void
|
||||
{
|
||||
match ($action) {
|
||||
'publish' => $this->publish($artwork),
|
||||
'unpublish' => $this->unpublish($artwork),
|
||||
'archive' => $artwork->delete(), // Soft delete
|
||||
'unarchive' => $artwork->restore(),
|
||||
'delete' => $artwork->forceDelete(),
|
||||
'change_category' => $this->changeCategory($artwork, $params),
|
||||
'add_tags' => $this->addTags($artwork, $params),
|
||||
'remove_tags' => $this->removeTags($artwork, $params),
|
||||
default => throw new \InvalidArgumentException("Unknown action: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
private function publish(Artwork $artwork): void
|
||||
{
|
||||
$artwork->update([
|
||||
'is_public' => true,
|
||||
'published_at' => $artwork->published_at ?? now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function unpublish(Artwork $artwork): void
|
||||
{
|
||||
$artwork->update(['is_public' => false]);
|
||||
}
|
||||
|
||||
private function changeCategory(Artwork $artwork, array $params): void
|
||||
{
|
||||
if (empty($params['category_id'])) {
|
||||
throw new \InvalidArgumentException('category_id required for change_category');
|
||||
}
|
||||
|
||||
$artwork->categories()->sync([(int) $params['category_id']]);
|
||||
}
|
||||
|
||||
private function addTags(Artwork $artwork, array $params): void
|
||||
{
|
||||
if (empty($params['tag_ids'])) {
|
||||
throw new \InvalidArgumentException('tag_ids required for add_tags');
|
||||
}
|
||||
|
||||
$pivotData = [];
|
||||
foreach ((array) $params['tag_ids'] as $tagId) {
|
||||
$pivotData[(int) $tagId] = ['source' => 'studio_bulk', 'confidence' => 1.0];
|
||||
}
|
||||
|
||||
$artwork->tags()->syncWithoutDetaching($pivotData);
|
||||
|
||||
// Increment usage counts
|
||||
Tag::whereIn('id', array_keys($pivotData))
|
||||
->increment('usage_count');
|
||||
}
|
||||
|
||||
private function removeTags(Artwork $artwork, array $params): void
|
||||
{
|
||||
if (empty($params['tag_ids'])) {
|
||||
throw new \InvalidArgumentException('tag_ids required for remove_tags');
|
||||
}
|
||||
|
||||
$tagIds = array_map('intval', (array) $params['tag_ids']);
|
||||
$artwork->tags()->detach($tagIds);
|
||||
|
||||
Tag::whereIn('id', $tagIds)
|
||||
->where('usage_count', '>', 0)
|
||||
->decrement('usage_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Meilisearch reindex for the given artworks.
|
||||
*/
|
||||
private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void
|
||||
{
|
||||
try {
|
||||
$artworks->each->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Failed to reindex artworks after bulk action', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
229
app/Services/Studio/StudioMetricsService.php
Normal file
229
app/Services/Studio/StudioMetricsService.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkStats;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Provides dashboard KPI data for the Studio overview page.
|
||||
*/
|
||||
final class StudioMetricsService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get dashboard KPI metrics for a creator.
|
||||
*
|
||||
* @return array{total_artworks: int, views_30d: int, favourites_30d: int, shares_30d: int, followers: int}
|
||||
*/
|
||||
public function getDashboardKpis(int $userId): array
|
||||
{
|
||||
$cacheKey = "studio.kpi.{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
|
||||
$totalArtworks = Artwork::where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
// Aggregate stats from artwork_stats for this user's artworks
|
||||
$statsAgg = DB::table('artwork_stats')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('
|
||||
COALESCE(SUM(artwork_stats.views), 0) as total_views,
|
||||
COALESCE(SUM(artwork_stats.favorites), 0) as total_favourites,
|
||||
COALESCE(SUM(artwork_stats.shares_count), 0) as total_shares
|
||||
')
|
||||
->first();
|
||||
|
||||
// Views in last 30 days from hourly snapshots if available, fallback to totals
|
||||
$views30d = 0;
|
||||
try {
|
||||
if (\Illuminate\Support\Facades\Schema::hasTable('artwork_metric_snapshots_hourly')) {
|
||||
$views30d = (int) DB::table('artwork_metric_snapshots_hourly')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_metric_snapshots_hourly.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artwork_metric_snapshots_hourly.bucket_hour', '>=', now()->subDays(30))
|
||||
->sum('artwork_metric_snapshots_hourly.views_count');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Table or column doesn't exist — fall back to totals
|
||||
}
|
||||
|
||||
if ($views30d === 0) {
|
||||
$views30d = (int) ($statsAgg->total_views ?? 0);
|
||||
}
|
||||
|
||||
$followers = DB::table('user_followers')
|
||||
->where('user_id', $userId)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_artworks' => $totalArtworks,
|
||||
'views_30d' => $views30d,
|
||||
'favourites_30d' => (int) ($statsAgg->total_favourites ?? 0),
|
||||
'shares_30d' => (int) ($statsAgg->total_shares ?? 0),
|
||||
'followers' => $followers,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top performing artworks for a creator in the last 7 days.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getTopPerformers(int $userId, int $limit = 6): \Illuminate\Support\Collection
|
||||
{
|
||||
$cacheKey = "studio.top_performers.{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId, $limit) {
|
||||
return Artwork::where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->with(['stats', 'tags'])
|
||||
->whereHas('stats')
|
||||
->orderByDesc(
|
||||
ArtworkStats::select('heat_score')
|
||||
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
|
||||
->limit(1)
|
||||
)
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (Artwork $art) => [
|
||||
'id' => $art->id,
|
||||
'title' => $art->title,
|
||||
'slug' => $art->slug,
|
||||
'thumb_url' => $art->thumbUrl('md'),
|
||||
'favourites' => (int) ($art->stats?->favorites ?? 0),
|
||||
'shares' => (int) ($art->stats?->shares_count ?? 0),
|
||||
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
|
||||
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent comments on a creator's artworks.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getRecentComments(int $userId, int $limit = 5): \Illuminate\Support\Collection
|
||||
{
|
||||
return DB::table('artwork_comments')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
|
||||
->join('users', 'users.id', '=', 'artwork_comments.user_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artwork_comments.deleted_at')
|
||||
->orderByDesc('artwork_comments.created_at')
|
||||
->limit($limit)
|
||||
->select([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.content as body',
|
||||
'artwork_comments.created_at',
|
||||
'users.name as author_name',
|
||||
'users.username as author_username',
|
||||
'artworks.title as artwork_title',
|
||||
'artworks.slug as artwork_slug',
|
||||
])
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate analytics across all artworks for the Studio Analytics page.
|
||||
*
|
||||
* @return array{totals: array, top_artworks: array, content_breakdown: array}
|
||||
*/
|
||||
public function getAnalyticsOverview(int $userId): array
|
||||
{
|
||||
$cacheKey = "studio.analytics_overview.{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
|
||||
// Totals
|
||||
$totals = DB::table('artwork_stats')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('
|
||||
COALESCE(SUM(artwork_stats.views), 0) as views,
|
||||
COALESCE(SUM(artwork_stats.favorites), 0) as favourites,
|
||||
COALESCE(SUM(artwork_stats.shares_count), 0) as shares,
|
||||
COALESCE(SUM(artwork_stats.downloads), 0) as downloads,
|
||||
COALESCE(SUM(artwork_stats.comments_count), 0) as comments,
|
||||
COALESCE(AVG(artwork_stats.ranking_score), 0) as avg_ranking,
|
||||
COALESCE(AVG(artwork_stats.heat_score), 0) as avg_heat
|
||||
')
|
||||
->first();
|
||||
|
||||
// Top 10 artworks by ranking score
|
||||
$topArtworks = Artwork::where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->with(['stats'])
|
||||
->whereHas('stats')
|
||||
->orderByDesc(
|
||||
ArtworkStats::select('ranking_score')
|
||||
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
|
||||
->limit(1)
|
||||
)
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (Artwork $art) => [
|
||||
'id' => $art->id,
|
||||
'title' => $art->title,
|
||||
'slug' => $art->slug,
|
||||
'thumb_url' => $art->thumbUrl('sq'),
|
||||
'views' => (int) ($art->stats?->views ?? 0),
|
||||
'favourites' => (int) ($art->stats?->favorites ?? 0),
|
||||
'shares' => (int) ($art->stats?->shares_count ?? 0),
|
||||
'downloads' => (int) ($art->stats?->downloads ?? 0),
|
||||
'comments' => (int) ($art->stats?->comments_count ?? 0),
|
||||
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
|
||||
]);
|
||||
|
||||
// Content type breakdown
|
||||
$contentBreakdown = DB::table('artworks')
|
||||
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
|
||||
->join('categories', 'categories.id', '=', 'artwork_category.category_id')
|
||||
->join('content_types', 'content_types.id', '=', 'categories.content_type_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->groupBy('content_types.id', 'content_types.name', 'content_types.slug')
|
||||
->select([
|
||||
'content_types.name',
|
||||
'content_types.slug',
|
||||
DB::raw('COUNT(DISTINCT artworks.id) as count'),
|
||||
])
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'name' => $row->name,
|
||||
'slug' => $row->slug,
|
||||
'count' => (int) $row->count,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'totals' => [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'favourites' => (int) ($totals->favourites ?? 0),
|
||||
'shares' => (int) ($totals->shares ?? 0),
|
||||
'downloads' => (int) ($totals->downloads ?? 0),
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'avg_ranking' => round((float) ($totals->avg_ranking ?? 0), 1),
|
||||
'avg_heat' => round((float) ($totals->avg_heat ?? 0), 1),
|
||||
],
|
||||
'top_artworks' => $topArtworks->values()->all(),
|
||||
'content_breakdown' => $contentBreakdown,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Jobs\RecComputeSimilarByTagsJob;
|
||||
use App\Jobs\RecComputeSimilarHybridJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\TagNormalizer;
|
||||
@@ -346,5 +348,12 @@ final class TagService
|
||||
private function queueReindex(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
|
||||
// §7.5 On-demand: recompute tag/hybrid similarity when tags change.
|
||||
// Pivot syncs don't trigger the Artwork "updated" event, so we dispatch here.
|
||||
if ($artwork->is_public && $artwork->published_at) {
|
||||
RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30));
|
||||
RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user