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:
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