Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\Vision\AiArtworkVectorSearchService;
use App\Services\Vision\ArtworkVectorIndexService;
use App\Services\Vision\ArtworkVectorMetadataService;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use App\Services\Vision\VectorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function bindVectorService(): void
{
$imageUrl = new ArtworkVisionImageUrl();
app()->instance(VectorService::class, new VectorService(
new AiArtworkVectorSearchService(new VectorGatewayClient(), $imageUrl),
new ArtworkVectorIndexService(new VectorGatewayClient(), $imageUrl, new ArtworkVectorMetadataService()),
));
}
it('indexes artworks by latest updated_at descending by default', function (): void {
$user = User::factory()->create();
$oldest = Artwork::factory()->for($user)->create([
'title' => 'Oldest artwork',
'slug' => 'oldest-artwork',
'hash' => str_repeat('a', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(3),
'updated_at' => now()->subDays(3),
]);
$middle = Artwork::factory()->for($user)->create([
'title' => 'Middle artwork',
'slug' => 'middle-artwork',
'hash' => str_repeat('b', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
]);
$latest = Artwork::factory()->for($user)->create([
'title' => 'Latest artwork',
'slug' => 'latest-artwork',
'hash' => str_repeat('c', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
'updated_at' => now()->subHour(),
]);
bindVectorService();
$code = Artisan::call('artworks:vectors-index', [
'--dry-run' => true,
'--public-only' => true,
'--limit' => 3,
'--batch' => 3,
]);
$output = Artisan::output();
expect($code)->toBe(0);
$latestPos = strpos($output, 'Processing artwork=' . $latest->id);
$middlePos = strpos($output, 'Processing artwork=' . $middle->id);
$oldestPos = strpos($output, 'Processing artwork=' . $oldest->id);
expect($latestPos)->not->toBeFalse()
->and($middlePos)->not->toBeFalse()
->and($oldestPos)->not->toBeFalse()
->and($latestPos)->toBeLessThan($middlePos)
->and($middlePos)->toBeLessThan($oldestPos);
});
it('supports legacy id ascending order when explicitly requested', function (): void {
$user = User::factory()->create();
$first = Artwork::factory()->for($user)->create([
'title' => 'First artwork',
'slug' => 'first-artwork-' . Str::lower(Str::random(4)),
'hash' => str_repeat('d', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
'updated_at' => now(),
]);
$second = Artwork::factory()->for($user)->create([
'title' => 'Second artwork',
'slug' => 'second-artwork-' . Str::lower(Str::random(4)),
'hash' => str_repeat('e', 32),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
]);
bindVectorService();
$code = Artisan::call('artworks:vectors-index', [
'--dry-run' => true,
'--public-only' => true,
'--limit' => 2,
'--batch' => 2,
'--order' => 'id-asc',
]);
$output = Artisan::output();
expect($code)->toBe(0);
$firstPos = strpos($output, 'Processing artwork=' . $first->id);
$secondPos = strpos($output, 'Processing artwork=' . $second->id);
expect($firstPos)->not->toBeFalse()
->and($secondPos)->not->toBeFalse()
->and($firstPos)->toBeLessThan($secondPos);
});

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use App\Enums\ModerationContentType;
use App\Models\ContentModerationActionLog;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\ContentModerationFinding;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Log\Events\MessageLogged;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event;
uses(RefreshDatabase::class);
it('scans artwork comments and descriptions and stores suspicious findings', function (): void {
$artwork = Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'content' => 'Visit my site at https://promo.pornsite.com now',
'raw_content' => 'Visit my site at https://promo.pornsite.com now',
]);
$code = Artisan::call('skinbase:scan-content-moderation');
expect($code)->toBe(0)
->and(ContentModerationFinding::query()->count())->toBe(2)
->and(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkComment)->exists())->toBeTrue()
->and(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkDescription)->exists())->toBeTrue();
});
it('does not create duplicate findings for unchanged content', function (): void {
$artwork = Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
Artisan::call('skinbase:scan-content-moderation', ['--only' => 'descriptions']);
Artisan::call('skinbase:scan-content-moderation', ['--only' => 'descriptions']);
expect(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkDescription)->count())->toBe(1)
->and(ContentModerationFinding::query()->first()?->content_id)->toBe($artwork->id);
});
it('supports dry runs without persisting findings', function (): void {
Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$code = Artisan::call('skinbase:scan-content-moderation', ['--dry-run' => true]);
expect($code)->toBe(0)
->and(ContentModerationFinding::query()->count())->toBe(0);
});
it('logs a command summary after scanning', function (): void {
Event::fake([MessageLogged::class]);
Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$code = Artisan::call('skinbase:scan-content-moderation', ['--only' => 'descriptions']);
expect($code)->toBe(0);
Event::assertDispatched(MessageLogged::class, function (MessageLogged $event): bool {
return $event->level === 'info'
&& $event->message === 'Content moderation scan complete.'
&& ($event->context['targets'] ?? []) === ['artwork_description']
&& ($event->context['counts']['scanned'] ?? 0) === 1
&& ($event->context['counts']['flagged'] ?? 0) === 1;
});
});
it('shows target progress and verbose finding details while scanning', function (): void {
Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$code = Artisan::call('skinbase:scan-content-moderation', [
'--only' => 'descriptions',
'--verbose' => true,
]);
$output = Artisan::output();
expect($code)->toBe(0)
->and($output)->toContain('Starting content moderation scan...')
->and($output)->toContain('Scanning Artwork Description entries...')
->and($output)->toContain('[artwork_description #')
->and($output)->toContain('flagged')
->and($output)->toContain('Finished Artwork Description: scanned=1, flagged=1');
});
it('auto hides critical comment spam while keeping the finding', function (): void {
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'content' => 'Buy followers now at https://promo.pornsite.com and claim your crypto giveaway',
'raw_content' => 'Buy followers now at https://promo.pornsite.com and claim your crypto giveaway',
'is_approved' => true,
]);
$code = Artisan::call('skinbase:scan-content-moderation', ['--only' => 'comments']);
$finding = ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkComment)->first();
expect($code)->toBe(0)
->and($finding)->not->toBeNull()
->and($finding?->is_auto_hidden)->toBeTrue()
->and($finding?->action_taken)->toBe('auto_hide_comment')
->and($comment->fresh()->is_approved)->toBeFalse();
});
it('rescans existing findings with the latest rules', function (): void {
$artwork = Artwork::factory()->create([
'description' => 'Buy followers at https://promo.pornsite.com and win a crypto giveaway now',
]);
$finding = ContentModerationFinding::query()->create([
'content_type' => ModerationContentType::ArtworkDescription->value,
'content_id' => $artwork->id,
'artwork_id' => $artwork->id,
'user_id' => $artwork->user_id,
'status' => 'pending',
'severity' => 'high',
'score' => 90,
'content_hash' => hash('sha256', 'old-hash'),
'scanner_version' => '1.0',
'content_snapshot' => 'old snapshot',
]);
$code = Artisan::call('skinbase:rescan-content-moderation', ['--only' => 'descriptions']);
$scannerVersion = (string) config('content_moderation.scanner_version');
expect($code)->toBe(0)
->and(ContentModerationFinding::query()->where('content_type', ModerationContentType::ArtworkDescription)->where('content_id', $artwork->id)->where('scanner_version', $scannerVersion)->exists())->toBeTrue()
->and(ContentModerationActionLog::query()->where('action_type', 'rescan')->exists())->toBeTrue();
});