feat: upload wizard refactor + vision AI tags + artwork versioning
Upload wizard:
- Refactored UploadWizard into modular steps (Step1FileUpload, Step2Details, Step3Publish)
- Extracted reusable hooks: useUploadMachine, useFileValidation, useVisionTags
- Extracted reusable components: CategorySelector, ContentTypeSelector
- Added TagPicker component (studio-style list picker with AI badge + new-tag insertion)
- Fixed TagInput auto-open bug (hasFocusedRef guard)
- Replaced TagInput with TagPicker in UploadSidebar
Vision AI tag suggestions:
- Add UploadVisionSuggestController: sync POST /api/uploads/{id}/vision-suggest
- Calls vision.klevze.net/analyze/all on upload completion (before step 2 opens)
- Two-phase useVisionTags: immediate gateway call + background DB polling
- Trigger fires on uploadReady (not step change) so tags arrive before user sees step 2
- Added vision.gateway config block with VISION_GATEWAY_URL env
Artwork versioning system:
- ArtworkVersion / ArtworkVersionEvent models
- ArtworkVersioningService: createNewVersion, restoreVersion, rate limiting, ranking decay
- Migrations: artwork_versions, artwork_version_events, versioning columns on artworks
- Studio API routes: GET versions, POST restore/{version_id}
- Feature tests: ArtworkVersioningTest (13 cases)
This commit is contained in:
212
app/Http/Controllers/Api/UploadVisionSuggestController.php
Normal file
212
app/Http/Controllers/Api/UploadVisionSuggestController.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\TagNormalizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Synchronous Vision tag suggestions for the upload wizard.
|
||||
*
|
||||
* POST /api/uploads/{id}/vision-suggest
|
||||
*
|
||||
* Calls the Vision gateway (/analyze/all) synchronously and returns
|
||||
* normalised tag suggestions immediately — without going through the queue.
|
||||
* The queue-based AutoTagArtworkJob still runs in the background and writes
|
||||
* to the DB; this endpoint gives the user instant pre-fill on Step 2.
|
||||
*/
|
||||
final class UploadVisionSuggestController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TagNormalizer $normalizer,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id, Request $request): JsonResponse
|
||||
{
|
||||
if (! (bool) config('vision.enabled', true)) {
|
||||
return response()->json(['tags' => [], 'vision_enabled' => false]);
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->findOrFail($id);
|
||||
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||
|
||||
$imageUrl = $this->buildImageUrl((string) $artwork->hash);
|
||||
if ($imageUrl === null) {
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'image_url_unavailable',
|
||||
]);
|
||||
}
|
||||
|
||||
$gatewayBase = trim((string) config('vision.gateway.base_url', config('vision.clip.base_url', '')));
|
||||
if ($gatewayBase === '') {
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'gateway_not_configured',
|
||||
]);
|
||||
}
|
||||
|
||||
$url = rtrim($gatewayBase, '/') . '/analyze/all';
|
||||
$limit = min(20, max(5, (int) ($request->query('limit', 10))));
|
||||
$timeout = (int) config('vision.gateway.timeout_seconds', 10);
|
||||
$cTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3);
|
||||
$ref = (string) Str::uuid();
|
||||
|
||||
try {
|
||||
/** @var \Illuminate\Http\Client\Response $response */
|
||||
$response = Http::acceptJson()
|
||||
->connectTimeout(max(1, $cTimeout))
|
||||
->timeout(max(1, $timeout))
|
||||
->withHeaders(['X-Request-ID' => $ref])
|
||||
->post($url, [
|
||||
'url' => $imageUrl,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
if (! $response->ok()) {
|
||||
Log::warning('vision-suggest: non-ok response', [
|
||||
'ref' => $ref,
|
||||
'artwork_id' => $id,
|
||||
'status' => $response->status(),
|
||||
'body' => Str::limit($response->body(), 400),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'gateway_error_' . $response->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tags = $this->parseGatewayResponse($response->json());
|
||||
|
||||
return response()->json([
|
||||
'tags' => $tags,
|
||||
'vision_enabled' => true,
|
||||
'source' => 'gateway_sync',
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('vision-suggest: request failed', [
|
||||
'ref' => $ref,
|
||||
'artwork_id' => $id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'gateway_exception',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildImageUrl(string $hash): ?string
|
||||
{
|
||||
$base = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
|
||||
$clean = str_pad($clean, 6, '0');
|
||||
$seg = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
|
||||
$variant = (string) config('vision.image_variant', 'md');
|
||||
|
||||
return $base . '/img/' . implode('/', $seg) . '/' . $variant . '.webp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the /analyze/all gateway response.
|
||||
*
|
||||
* The gateway returns a unified object:
|
||||
* { clip: [{tag, confidence}], blip: ["caption1"], yolo: [{tag, confidence}] }
|
||||
* or a flat list of tags directly.
|
||||
*
|
||||
* @param mixed $json
|
||||
* @return array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>
|
||||
*/
|
||||
private function parseGatewayResponse(mixed $json): array
|
||||
{
|
||||
$raw = [];
|
||||
|
||||
if (! is_array($json)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Unified gateway response
|
||||
if (isset($json['clip']) && is_array($json['clip'])) {
|
||||
foreach ($json['clip'] as $item) {
|
||||
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'clip'];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($json['yolo']) && is_array($json['yolo'])) {
|
||||
foreach ($json['yolo'] as $item) {
|
||||
$raw[] = ['tag' => $item['tag'] ?? $item['label'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'yolo'];
|
||||
}
|
||||
}
|
||||
|
||||
// Flat lists
|
||||
if ($raw === []) {
|
||||
$list = $json['tags'] ?? $json['data'] ?? $json;
|
||||
if (is_array($list)) {
|
||||
foreach ($list as $item) {
|
||||
if (is_array($item)) {
|
||||
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? $item['label'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'vision'];
|
||||
} elseif (is_string($item)) {
|
||||
$raw[] = ['tag' => $item, 'confidence' => null, 'source' => 'vision'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by slug, keep highest confidence
|
||||
$bySlug = [];
|
||||
foreach ($raw as $r) {
|
||||
$slug = $this->normalizer->normalize((string) ($r['tag'] ?? ''));
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
$conf = isset($r['confidence']) && is_numeric($r['confidence']) ? (float) $r['confidence'] : null;
|
||||
|
||||
if (! isset($bySlug[$slug]) || ($conf !== null && $conf > (float) ($bySlug[$slug]['confidence'] ?? 0))) {
|
||||
$bySlug[$slug] = [
|
||||
'name' => ucwords(str_replace(['-', '_'], ' ', $slug)),
|
||||
'slug' => $slug,
|
||||
'confidence' => $conf,
|
||||
'source' => $r['source'] ?? 'vision',
|
||||
'is_ai' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence desc
|
||||
$sorted = array_values($bySlug);
|
||||
usort($sorted, static fn ($a, $b) => ($b['confidence'] ?? 0) <=> ($a['confidence'] ?? 0));
|
||||
|
||||
return $sorted;
|
||||
}
|
||||
|
||||
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void
|
||||
{
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
if ((int) $artwork->user_id !== (int) $user->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ArtworkVersion;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\ArtworkVersioningService;
|
||||
use App\Services\Studio\StudioArtworkQueryService;
|
||||
use App\Services\Studio\StudioBulkActionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
|
||||
/**
|
||||
* JSON API endpoints for the Studio artwork manager.
|
||||
@@ -19,6 +24,8 @@ final class StudioArtworksApiController extends Controller
|
||||
public function __construct(
|
||||
private readonly StudioArtworkQueryService $queryService,
|
||||
private readonly StudioBulkActionService $bulkService,
|
||||
private readonly ArtworkVersioningService $versioningService,
|
||||
private readonly ArtworkSearchIndexer $searchIndexer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -274,50 +281,66 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/{id}/replace-file
|
||||
* Replace the artwork's primary image file and regenerate derivatives.
|
||||
* Replace the artwork's primary image file — creates a new immutable version.
|
||||
*
|
||||
* Accepts an optional `change_note` text field alongside the file.
|
||||
*/
|
||||
public function replaceFile(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50MB
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
|
||||
'change_note' => 'sometimes|nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getRealPath();
|
||||
// ── Rate-limit gate (before expensive file processing) ────────────
|
||||
try {
|
||||
$this->versioningService->rateLimitCheck($request->user()->id, $artwork->id);
|
||||
} catch (TooManyRequestsHttpException $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash
|
||||
$hash = hash_file('sha256', $tempPath);
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getRealPath();
|
||||
$hash = hash_file('sha256', $tempPath);
|
||||
|
||||
// Reject identical files early (before any disk writes)
|
||||
if ($artwork->hash === $hash) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'The uploaded file is identical to the current version.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
|
||||
|
||||
// Store original
|
||||
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
|
||||
// 1. Store original on disk
|
||||
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp');
|
||||
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
|
||||
|
||||
// Generate public derivatives
|
||||
// 2. Generate public derivatives (thumbnails)
|
||||
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
|
||||
foreach ($publicAbsolute as $variant => $absolutePath) {
|
||||
$filename = $variant . '.webp';
|
||||
$relativePath = $storage->publicRelativePath($hash, $filename);
|
||||
$relativePath = $storage->publicRelativePath($hash, $variant . '.webp');
|
||||
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
|
||||
}
|
||||
|
||||
// Get dimensions
|
||||
$dimensions = @getimagesize($tempPath);
|
||||
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : $artwork->width;
|
||||
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : $artwork->height;
|
||||
// 3. Get new dimensions
|
||||
$dims = @getimagesize($tempPath);
|
||||
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
|
||||
$height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height;
|
||||
$size = (int) filesize($originalPath);
|
||||
|
||||
// Update artwork record
|
||||
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
|
||||
$artwork->update([
|
||||
'file_name' => 'orig.webp',
|
||||
'file_path' => '',
|
||||
'file_size' => (int) filesize($originalPath),
|
||||
'file_size' => $size,
|
||||
'mime_type' => 'image/webp',
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'webp',
|
||||
@@ -326,24 +349,145 @@ final class StudioArtworksApiController extends Controller
|
||||
'height' => max(1, $height),
|
||||
]);
|
||||
|
||||
// 5. Create version record, apply ranking protection, audit log
|
||||
$version = $this->versioningService->createNewVersion(
|
||||
$artwork,
|
||||
$originalRelative,
|
||||
$hash,
|
||||
max(1, $width),
|
||||
max(1, $height),
|
||||
$size,
|
||||
$request->user()->id,
|
||||
$request->input('change_note'),
|
||||
);
|
||||
|
||||
// 6. Reindex in Meilisearch (non-blocking)
|
||||
try {
|
||||
$this->searchIndexer->update($artwork);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 7. CDN cache bust — purge thumbnail paths for the old hash
|
||||
$this->purgeCdnCache($artwork, $hash);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'file_size' => $artwork->file_size,
|
||||
'version_number' => $version->version_number,
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('replaceFile: processing error', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/studio/artworks/{id}/versions
|
||||
* Return version history for an artwork (newest first).
|
||||
*/
|
||||
public function versions(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get();
|
||||
|
||||
return response()->json([
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
],
|
||||
'versions' => $versions->map(fn (ArtworkVersion $v) => [
|
||||
'id' => $v->id,
|
||||
'version_number' => $v->version_number,
|
||||
'file_path' => $v->file_path,
|
||||
'file_hash' => $v->file_hash,
|
||||
'width' => $v->width,
|
||||
'height' => $v->height,
|
||||
'file_size' => $v->file_size,
|
||||
'change_note' => $v->change_note,
|
||||
'is_current' => $v->is_current,
|
||||
'created_at' => $v->created_at?->toIso8601String(),
|
||||
])->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/{id}/restore/{version_id}
|
||||
* Restore an earlier version (cloned as a new current version).
|
||||
*/
|
||||
public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$version = ArtworkVersion::where('artwork_id', $artwork->id)->findOrFail($versionId);
|
||||
|
||||
if ($version->is_current) {
|
||||
return response()->json(['success' => false, 'error' => 'This version is already the current version.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$newVersion = $this->versioningService->restoreVersion($version, $artwork, $request->user()->id);
|
||||
|
||||
// Sync artwork file fields back to restored version dimensions
|
||||
$artwork->update([
|
||||
'width' => max(1, (int) $version->width),
|
||||
'height' => max(1, (int) $version->height),
|
||||
'file_size' => (int) $version->file_size,
|
||||
]);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
// Reindex
|
||||
try {
|
||||
$artwork->searchable();
|
||||
$this->searchIndexer->update($artwork);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'file_size' => $artwork->file_size,
|
||||
'success' => true,
|
||||
'version_number' => $newVersion->version_number,
|
||||
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
|
||||
]);
|
||||
} catch (TooManyRequestsHttpException $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'File processing failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge CDN thumbnail cache for the artwork.
|
||||
*
|
||||
* This is best-effort; failures are logged but never fatal.
|
||||
* Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed.
|
||||
*/
|
||||
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
|
||||
{
|
||||
try {
|
||||
$purgeUrl = config('cdn.purge_url');
|
||||
if (empty($purgeUrl)) {
|
||||
Log::debug('CDN purge skipped — cdn.purge_url not configured', ['artwork_id' => $artwork->id]);
|
||||
return;
|
||||
}
|
||||
|
||||
$paths = array_map(
|
||||
fn (string $size) => "/thumbs/{$oldHash}/{$size}.webp",
|
||||
['sm', 'md', 'lg', 'xl']
|
||||
);
|
||||
|
||||
\Illuminate\Support\Facades\Http::timeout(5)->post($purgeUrl, ['paths' => $paths]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,9 @@ final class StudioController extends Controller
|
||||
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
|
||||
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
// Versioning
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
],
|
||||
'contentTypes' => $this->getCategories(),
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use App\Services\ThumbnailService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Scout\Searchable;
|
||||
@@ -47,13 +48,20 @@ class Artwork extends Model
|
||||
'published_at',
|
||||
'hash',
|
||||
'thumb_ext',
|
||||
'file_ext'
|
||||
'file_ext',
|
||||
// Versioning
|
||||
'current_version_id',
|
||||
'version_count',
|
||||
'version_updated_at',
|
||||
'requires_reapproval',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_public' => 'boolean',
|
||||
'is_approved' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'is_public' => 'boolean',
|
||||
'is_approved' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'version_updated_at' => 'datetime',
|
||||
'requires_reapproval' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -193,6 +201,18 @@ class Artwork extends Model
|
||||
return $this->hasMany(ArtworkAward::class);
|
||||
}
|
||||
|
||||
/** All file versions for this artwork (oldest first). */
|
||||
public function versions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkVersion::class)->orderBy('version_number');
|
||||
}
|
||||
|
||||
/** The currently active version record. */
|
||||
public function currentVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ArtworkVersion::class, 'current_version_id');
|
||||
}
|
||||
|
||||
public function awardStat(): HasOne
|
||||
{
|
||||
return $this->hasOne(ArtworkAwardStat::class);
|
||||
|
||||
44
app/Models/ArtworkVersion.php
Normal file
44
app/Models/ArtworkVersion.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkVersion
|
||||
*
|
||||
* Represents a single immutable snapshot of an artwork file.
|
||||
* Only one version per artwork is marked is_current = true at a time.
|
||||
*/
|
||||
class ArtworkVersion extends Model
|
||||
{
|
||||
protected $table = 'artwork_versions';
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'version_number',
|
||||
'file_path',
|
||||
'file_hash',
|
||||
'width',
|
||||
'height',
|
||||
'file_size',
|
||||
'change_note',
|
||||
'is_current',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_current' => 'boolean',
|
||||
'version_number' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
}
|
||||
35
app/Models/ArtworkVersionEvent.php
Normal file
35
app/Models/ArtworkVersionEvent.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkVersionEvent
|
||||
*
|
||||
* Audit log for all version-related actions (create_version, restore_version).
|
||||
*/
|
||||
class ArtworkVersionEvent extends Model
|
||||
{
|
||||
protected $table = 'artwork_version_events';
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'action',
|
||||
'version_id',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
248
app/Services/ArtworkVersioningService.php
Normal file
248
app/Services/ArtworkVersioningService.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkVersion;
|
||||
use App\Models\ArtworkVersionEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
|
||||
/**
|
||||
* ArtworkVersioningService
|
||||
*
|
||||
* Manages non-destructive file replacement for artworks.
|
||||
*
|
||||
* Guarantees:
|
||||
* - All replacements create a new version row; originals are never deleted.
|
||||
* - Engagement (views, favourites, downloads) is never reset.
|
||||
* - Ranking scores receive a small protective decay on each replacement.
|
||||
* - Abusive rapid replacement is blocked by rate limits.
|
||||
* - Major visual changes trigger requires_reapproval flag.
|
||||
*/
|
||||
final class ArtworkVersioningService
|
||||
{
|
||||
// ── Rate-limit thresholds ─────────────────────────────────────────────
|
||||
private const MAX_PER_HOUR = 3;
|
||||
private const MAX_PER_DAY = 10;
|
||||
|
||||
// ── Reapproval: flag when dimension changes beyond this fraction ──────
|
||||
private const DIMENSION_CHANGE_THRESHOLD = 0.5; // 50 % change triggers re-approval
|
||||
|
||||
// ── Ranking decay applied per replacement ─────────────────────────────
|
||||
private const RANKING_DECAY_FACTOR = 0.93; // 7 % decay
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new version for an artwork after a file replacement.
|
||||
*
|
||||
* This is the primary entry-point called by the controller.
|
||||
*
|
||||
* @param Artwork $artwork The artwork being updated.
|
||||
* @param string $filePath Relative path stored for this version.
|
||||
* @param string $fileHash SHA-256 hex hash of the new file.
|
||||
* @param int $width New file width in pixels.
|
||||
* @param int $height New file height in pixels.
|
||||
* @param int $fileSize New file size in bytes.
|
||||
* @param int $userId ID of the acting user (for audit log).
|
||||
* @param string|null $changeNote Optional user-supplied change note.
|
||||
*
|
||||
* @throws TooManyRequestsHttpException When rate limit is exceeded.
|
||||
* @throws \RuntimeException When the hash is identical to the current file.
|
||||
*/
|
||||
public function createNewVersion(
|
||||
Artwork $artwork,
|
||||
string $filePath,
|
||||
string $fileHash,
|
||||
int $width,
|
||||
int $height,
|
||||
int $fileSize,
|
||||
int $userId,
|
||||
?string $changeNote = null,
|
||||
): ArtworkVersion {
|
||||
// 1. Rate limit check
|
||||
$this->rateLimitCheck($userId, $artwork->id);
|
||||
|
||||
// 2. Reject identical file
|
||||
if ($artwork->hash === $fileHash) {
|
||||
throw new \RuntimeException('The uploaded file is identical to the current version. No new version created.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use (
|
||||
$artwork, $filePath, $fileHash, $width, $height, $fileSize, $userId, $changeNote
|
||||
): ArtworkVersion {
|
||||
// 3. Determine next version number
|
||||
$nextNumber = ($artwork->version_count ?? 1) + 1;
|
||||
|
||||
// 4. Mark all previous versions as not current
|
||||
$artwork->versions()->update(['is_current' => false]);
|
||||
|
||||
// 5. Insert new version row
|
||||
$version = ArtworkVersion::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'version_number' => $nextNumber,
|
||||
'file_path' => $filePath,
|
||||
'file_hash' => $fileHash,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'file_size' => $fileSize,
|
||||
'change_note' => $changeNote,
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
// 6. Check whether moderation re-review is required
|
||||
$needsReapproval = $this->shouldRequireReapproval($artwork, $width, $height);
|
||||
|
||||
// 7. Update artwork metadata (no engagement data touched)
|
||||
$artwork->update([
|
||||
'current_version_id' => $version->id,
|
||||
'version_count' => $nextNumber,
|
||||
'version_updated_at' => now(),
|
||||
'requires_reapproval' => $needsReapproval,
|
||||
]);
|
||||
|
||||
// 8. Ranking protection — apply small decay
|
||||
$this->applyRankingProtection($artwork);
|
||||
|
||||
// 9. Audit log
|
||||
ArtworkVersionEvent::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $userId,
|
||||
'action' => 'create_version',
|
||||
'version_id' => $version->id,
|
||||
]);
|
||||
|
||||
// 10. Increment hourly/daily counters for rate limiting
|
||||
$this->incrementRateLimitCounters($userId, $artwork->id);
|
||||
|
||||
return $version;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a previous version by cloning it as a new (current) version.
|
||||
*
|
||||
* The restored file is treated as a brand-new version so the history
|
||||
* remains strictly append-only and the version counter always increases.
|
||||
*
|
||||
* @throws TooManyRequestsHttpException When rate limit is exceeded.
|
||||
*/
|
||||
public function restoreVersion(
|
||||
ArtworkVersion $version,
|
||||
Artwork $artwork,
|
||||
int $userId,
|
||||
): ArtworkVersion {
|
||||
return $this->createNewVersion(
|
||||
$artwork,
|
||||
$version->file_path,
|
||||
$version->file_hash,
|
||||
(int) $version->width,
|
||||
(int) $version->height,
|
||||
(int) $version->file_size,
|
||||
$userId,
|
||||
"Restored from version {$version->version_number}",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the new file warrants a moderation re-check.
|
||||
*
|
||||
* Triggers when either dimension changes by more than the threshold.
|
||||
*/
|
||||
public function shouldRequireReapproval(Artwork $artwork, int $newWidth, int $newHeight): bool
|
||||
{
|
||||
// First version upload — no existing dimensions to compare
|
||||
if (!$artwork->width || !$artwork->height) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$widthChange = abs($newWidth - $artwork->width) / max($artwork->width, 1);
|
||||
$heightChange = abs($newHeight - $artwork->height) / max($artwork->height, 1);
|
||||
|
||||
return $widthChange > self::DIMENSION_CHANGE_THRESHOLD
|
||||
|| $heightChange > self::DIMENSION_CHANGE_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a small protective decay (7 %) to ranking and heat scores.
|
||||
*
|
||||
* This prevents creators from gaming the ranking algorithm by rapidly
|
||||
* cycling file versions to refresh discovery signals.
|
||||
* Engagement totals (views, favourites, downloads) are NOT touched.
|
||||
*/
|
||||
public function applyRankingProtection(Artwork $artwork): void
|
||||
{
|
||||
try {
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->update([
|
||||
'ranking_score' => DB::raw('ranking_score * ' . self::RANKING_DECAY_FACTOR),
|
||||
'heat_score' => DB::raw('heat_score * ' . self::RANKING_DECAY_FACTOR),
|
||||
'engagement_velocity' => DB::raw('engagement_velocity * ' . self::RANKING_DECAY_FACTOR),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Non-fatal — log and continue so the version is still saved.
|
||||
Log::warning('ArtworkVersioningService: ranking protection failed', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw TooManyRequestsHttpException when the user has exceeded either:
|
||||
* • 3 replacements per hour for this artwork
|
||||
* • 10 replacements per day for this user (across all artworks)
|
||||
*/
|
||||
public function rateLimitCheck(int $userId, int $artworkId): void
|
||||
{
|
||||
$hourKey = "artwork_version:hour:{$userId}:{$artworkId}";
|
||||
$dayKey = "artwork_version:day:{$userId}";
|
||||
|
||||
$hourCount = (int) Cache::get($hourKey, 0);
|
||||
$dayCount = (int) Cache::get($dayKey, 0);
|
||||
|
||||
if ($hourCount >= self::MAX_PER_HOUR) {
|
||||
throw new TooManyRequestsHttpException(
|
||||
3600,
|
||||
'You have replaced this artwork too many times in the last hour. Please wait before trying again.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($dayCount >= self::MAX_PER_DAY) {
|
||||
throw new TooManyRequestsHttpException(
|
||||
86400,
|
||||
'You have reached the daily replacement limit. Please wait until tomorrow.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
private function incrementRateLimitCounters(int $userId, int $artworkId): void
|
||||
{
|
||||
$hourKey = "artwork_version:hour:{$userId}:{$artworkId}";
|
||||
$dayKey = "artwork_version:day:{$userId}";
|
||||
|
||||
// Hourly counter — expires in 1 hour
|
||||
if (Cache::has($hourKey)) {
|
||||
Cache::increment($hourKey);
|
||||
} else {
|
||||
Cache::put($hourKey, 1, 3600);
|
||||
}
|
||||
|
||||
// Daily counter — expires at midnight (or 24 hours from first hit)
|
||||
if (Cache::has($dayKey)) {
|
||||
Cache::increment($dayKey);
|
||||
} else {
|
||||
Cache::put($dayKey, 1, 86400);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user