Improve creator studio browsing and versioning

This commit is contained in:
2026-04-16 15:01:15 +02:00
parent 56eaa3bcbf
commit cdd42a0186
12 changed files with 728 additions and 140 deletions

View File

@@ -7,8 +7,6 @@ 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;
@@ -40,6 +38,148 @@ final class ArtworkVersioningService
// ──────────────────────────────────────────────────────────────────────
/**
* Capture the current artwork media state as a revision snapshot.
*
* @return array{artwork: array<string, mixed>, files: array<int, array<string, mixed>>}
*/
public function captureArtworkSnapshot(Artwork $artwork): array
{
return [
'artwork' => [
'file_name' => (string) ($artwork->file_name ?? ''),
'file_path' => (string) ($artwork->file_path ?? ''),
'hash' => (string) ($artwork->hash ?? ''),
'file_ext' => (string) ($artwork->file_ext ?? ''),
'thumb_ext' => (string) ($artwork->thumb_ext ?? ''),
'file_size' => (int) ($artwork->file_size ?? 0),
'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'),
'width' => (int) ($artwork->width ?? 0),
'height' => (int) ($artwork->height ?? 0),
],
'files' => DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->orderBy('variant')
->get(['variant', 'path', 'mime', 'size'])
->map(fn ($row): array => [
'variant' => (string) ($row->variant ?? ''),
'path' => (string) ($row->path ?? ''),
'mime' => (string) ($row->mime ?? 'application/octet-stream'),
'size' => (int) ($row->size ?? 0),
])
->values()
->all(),
];
}
/**
* Create a new immutable revision from a fully materialized artwork snapshot.
*
* @param array<string, mixed> $snapshot
* @param array<string, mixed>|null $previousSnapshot
*/
public function createVersionFromSnapshot(
Artwork $artwork,
array $snapshot,
int $userId,
?string $changeNote = null,
?array $previousSnapshot = null,
): ArtworkVersion {
$normalizedSnapshot = $this->normalizeSnapshot($snapshot);
$previous = $this->normalizeSnapshot($previousSnapshot ?? $this->captureArtworkSnapshot($artwork));
$this->rateLimitCheck($userId, $artwork->id);
return DB::transaction(function () use ($artwork, $normalizedSnapshot, $previous, $userId, $changeNote): ArtworkVersion {
$this->ensureBaselineVersion($artwork, $previous, $userId);
$nextNumber = max((int) $artwork->versions()->max('version_number'), 0) + 1;
$meta = $this->snapshotArtworkMeta($normalizedSnapshot);
$artwork->versions()->update(['is_current' => false]);
$version = ArtworkVersion::create([
'artwork_id' => $artwork->id,
'version_number' => $nextNumber,
'file_path' => $meta['file_path'],
'file_hash' => $meta['hash'],
'width' => $meta['width'],
'height' => $meta['height'],
'file_size' => $meta['file_size'],
'change_note' => $changeNote,
'snapshot_json' => $normalizedSnapshot,
'is_current' => true,
]);
$needsReapproval = $this->shouldRequireReapprovalFromSnapshots($previous, $normalizedSnapshot);
$artwork->update([
'current_version_id' => $version->id,
'version_count' => $nextNumber,
'version_updated_at' => now(),
'requires_reapproval' => $needsReapproval,
]);
$this->applyRankingProtection($artwork);
ArtworkVersionEvent::create([
'artwork_id' => $artwork->id,
'user_id' => $userId,
'action' => 'create_version',
'version_id' => $version->id,
]);
$this->incrementRateLimitCounters($userId, $artwork->id);
return $version;
});
}
/**
* Apply a stored snapshot back onto the artwork row and artwork_files table.
*
* @param array<string, mixed> $snapshot
*/
public function applySnapshot(Artwork $artwork, array $snapshot): void
{
$normalizedSnapshot = $this->normalizeSnapshot($snapshot);
$meta = $this->snapshotArtworkMeta($normalizedSnapshot);
DB::transaction(function () use ($artwork, $normalizedSnapshot, $meta): void {
DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->delete();
$rows = collect($normalizedSnapshot['files'] ?? [])
->filter(fn (array $row): bool => ($row['variant'] ?? '') !== '' && ($row['path'] ?? '') !== '')
->map(fn (array $row): array => [
'artwork_id' => $artwork->id,
'variant' => (string) $row['variant'],
'path' => (string) $row['path'],
'mime' => (string) ($row['mime'] ?? 'application/octet-stream'),
'size' => (int) ($row['size'] ?? 0),
])
->values()
->all();
if ($rows !== []) {
DB::table('artwork_files')->insert($rows);
}
$artwork->update([
'file_name' => $meta['file_name'],
'file_path' => $meta['file_path'],
'hash' => $meta['hash'],
'file_ext' => $meta['file_ext'],
'thumb_ext' => $meta['thumb_ext'],
'file_size' => $meta['file_size'],
'mime_type' => $meta['mime_type'],
'width' => $meta['width'],
'height' => $meta['height'],
]);
});
}
/**
* Create a new version for an artwork after a file replacement.
*
@@ -67,63 +207,22 @@ final class ArtworkVersioningService
int $userId,
?string $changeNote = null,
): ArtworkVersion {
// 1. Rate limit check
$this->rateLimitCheck($userId, $artwork->id);
$snapshot = [
'artwork' => [
'file_name' => (string) ($artwork->file_name ?? 'artwork'),
'file_path' => $filePath,
'hash' => $fileHash,
'file_ext' => (string) ($artwork->file_ext ?? ''),
'thumb_ext' => (string) ($artwork->thumb_ext ?? ''),
'file_size' => $fileSize,
'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'),
'width' => $width,
'height' => $height,
],
'files' => [],
];
// 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;
});
return $this->createVersionFromSnapshot($artwork, $snapshot, $userId, $changeNote);
}
/**
@@ -139,15 +238,33 @@ final class ArtworkVersioningService
Artwork $artwork,
int $userId,
): ArtworkVersion {
return $this->createNewVersion(
$previousSnapshot = $this->captureArtworkSnapshot($artwork);
$snapshot = is_array($version->snapshot_json)
? $this->normalizeSnapshot($version->snapshot_json)
: $this->normalizeSnapshot([
'artwork' => array_merge($previousSnapshot['artwork'] ?? [], [
'file_name' => (string) ($artwork->file_name ?? 'artwork'),
'file_path' => $version->file_path,
'hash' => $version->file_hash,
'file_ext' => (string) ($artwork->file_ext ?? ''),
'thumb_ext' => (string) ($artwork->thumb_ext ?? ''),
'file_size' => (int) $version->file_size,
'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'),
'width' => (int) $version->width,
'height' => (int) $version->height,
]),
'files' => $previousSnapshot['files'] ?? [],
]);
$this->applySnapshot($artwork, $snapshot);
$artwork->refresh();
return $this->createVersionFromSnapshot(
$artwork,
$version->file_path,
$version->file_hash,
(int) $version->width,
(int) $version->height,
(int) $version->file_size,
$snapshot,
$userId,
"Restored from version {$version->version_number}",
$previousSnapshot,
);
}
@@ -170,6 +287,26 @@ final class ArtworkVersioningService
|| $heightChange > self::DIMENSION_CHANGE_THRESHOLD;
}
/**
* @param array<string, mixed> $previousSnapshot
* @param array<string, mixed> $newSnapshot
*/
public function shouldRequireReapprovalFromSnapshots(array $previousSnapshot, array $newSnapshot): bool
{
$previous = $this->snapshotArtworkMeta($previousSnapshot);
$next = $this->snapshotArtworkMeta($newSnapshot);
if (($previous['width'] ?? 0) <= 0 || ($previous['height'] ?? 0) <= 0) {
return false;
}
$widthChange = abs($next['width'] - $previous['width']) / max($previous['width'], 1);
$heightChange = abs($next['height'] - $previous['height']) / max($previous['height'], 1);
return $widthChange > self::DIMENSION_CHANGE_THRESHOLD
|| $heightChange > self::DIMENSION_CHANGE_THRESHOLD;
}
/**
* Apply a small protective decay (7 %) to ranking and heat scores.
*
@@ -245,4 +382,103 @@ final class ArtworkVersioningService
Cache::put($dayKey, 1, 86400);
}
}
/**
* @param array<string, mixed>|null $snapshot
*/
private function ensureBaselineVersion(Artwork $artwork, ?array $snapshot, int $userId): ?ArtworkVersion
{
if ($artwork->versions()->exists()) {
return null;
}
$normalizedSnapshot = $this->normalizeSnapshot($snapshot ?? $this->captureArtworkSnapshot($artwork));
$meta = $this->snapshotArtworkMeta($normalizedSnapshot);
$baselineNumber = max(1, (int) ($artwork->version_count ?? 1));
$version = ArtworkVersion::create([
'artwork_id' => $artwork->id,
'version_number' => $baselineNumber,
'file_path' => $meta['file_path'],
'file_hash' => $meta['hash'],
'width' => $meta['width'],
'height' => $meta['height'],
'file_size' => $meta['file_size'],
'change_note' => 'Baseline snapshot',
'snapshot_json' => $normalizedSnapshot,
'is_current' => true,
]);
$artwork->update([
'current_version_id' => $version->id,
'version_count' => $baselineNumber,
'version_updated_at' => now(),
]);
ArtworkVersionEvent::create([
'artwork_id' => $artwork->id,
'user_id' => $userId,
'action' => 'baseline_snapshot',
'version_id' => $version->id,
]);
return $version;
}
/**
* @param array<string, mixed> $snapshot
* @return array{file_name: string, file_path: string, hash: string, file_ext: string, thumb_ext: string, file_size: int, mime_type: string, width: int, height: int}
*/
private function snapshotArtworkMeta(array $snapshot): array
{
$normalized = $this->normalizeSnapshot($snapshot);
$artwork = $normalized['artwork'];
return [
'file_name' => (string) ($artwork['file_name'] ?? 'artwork'),
'file_path' => (string) ($artwork['file_path'] ?? ''),
'hash' => (string) ($artwork['hash'] ?? ''),
'file_ext' => (string) ($artwork['file_ext'] ?? ''),
'thumb_ext' => (string) ($artwork['thumb_ext'] ?? ''),
'file_size' => (int) ($artwork['file_size'] ?? 0),
'mime_type' => (string) ($artwork['mime_type'] ?? 'application/octet-stream'),
'width' => max(0, (int) ($artwork['width'] ?? 0)),
'height' => max(0, (int) ($artwork['height'] ?? 0)),
];
}
/**
* @param array<string, mixed>|null $snapshot
* @return array{artwork: array<string, mixed>, files: array<int, array<string, mixed>>}
*/
private function normalizeSnapshot(?array $snapshot): array
{
$artwork = is_array($snapshot['artwork'] ?? null) ? $snapshot['artwork'] : [];
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
return [
'artwork' => [
'file_name' => (string) ($artwork['file_name'] ?? 'artwork'),
'file_path' => (string) ($artwork['file_path'] ?? ''),
'hash' => (string) ($artwork['hash'] ?? ''),
'file_ext' => (string) ($artwork['file_ext'] ?? ''),
'thumb_ext' => (string) ($artwork['thumb_ext'] ?? ''),
'file_size' => (int) ($artwork['file_size'] ?? 0),
'mime_type' => (string) ($artwork['mime_type'] ?? 'application/octet-stream'),
'width' => max(0, (int) ($artwork['width'] ?? 0)),
'height' => max(0, (int) ($artwork['height'] ?? 0)),
],
'files' => collect($files)
->filter(fn ($file): bool => is_array($file))
->map(fn (array $file): array => [
'variant' => (string) ($file['variant'] ?? ''),
'path' => (string) ($file['path'] ?? ''),
'mime' => (string) ($file['mime'] ?? 'application/octet-stream'),
'size' => (int) ($file['size'] ?? 0),
])
->filter(fn (array $file): bool => $file['variant'] !== '' && $file['path'] !== '')
->values()
->all(),
];
}
}