images: Lebenszyklus — Veröffentlichungsgate, published_ever_at, Snapshot der veröffentlichten Fassung, Sofortlöschung, Legacy-Bereinigung, Beweiszugriff, Korrekturweg (Bildkennzeichnung Block E)
- publish()/changeStatusFromAdmin(): kein Bild aus Formular v2 ohne gültige Bestätigung (ImageRightsConfirmationRequiredException, Admin-Seite meldet es); published_ever_at an PM und Bildern; PublishedImageSnapshot (Cover + JSON mit Nachweis/Kennzeichnung) - deleteFromAdmin() und press-releases:purge-drafts löschen Bilder nie veröffentlichter Meldungen samt Original und Bestätigung sofort (E4, O1) - press-releases:backfill-published-ever (Status-Log), press-release-images:purge-inactive-legacy (E-B11, Trockenlauf Standard) - Admin-Route für Beweisoriginal und veröffentlichte Fassung nur mit Zweck, ActivityType EvidenceAccess; Architekturtest: original_path nur in vier Dateien - ImageCorrectionService + image_declaration_corrections (insert-only), withheld_at blendet das Bild öffentlich aus (publiclyVisible-Scope), Korrekturpanel auf der Admin-Seite - Systemstatus: fehlgeschlagene Herkunftsprüfungen der letzten 7 Tage Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
2124ffd1f2
commit
46b89e1d23
24 changed files with 1202 additions and 12 deletions
|
|
@ -192,6 +192,23 @@ class AdminSystemHealth
|
|||
: 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'),
|
||||
];
|
||||
|
||||
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
|
||||
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
|
||||
// nichts, muss aber sichtbar bleiben.
|
||||
$provenanceFailures = PressReleaseImage::query()
|
||||
->withTrashed()
|
||||
->where('provenance_status', 'error')
|
||||
->where('provenance_checked_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
$checks[] = [
|
||||
'key' => 'image_provenance',
|
||||
'label' => 'Herkunftsprüfung Bilder',
|
||||
'status' => $provenanceFailures === 0 ? self::StatusOk : self::StatusWarn,
|
||||
'detail' => $provenanceFailures === 0
|
||||
? 'Keine fehlgeschlagene Herkunftsprüfung in den letzten 7 Tagen.'
|
||||
: $provenanceFailures.' Bild-Upload(s) mit Status „nicht prüfbar“ in den letzten 7 Tagen (Imagick/exif prüfen).',
|
||||
];
|
||||
|
||||
// Ausstehende Medien-Privatisierung.
|
||||
$publicImages = PressReleaseImage::query()->withTrashed()->where('disk', 'public')->count();
|
||||
$publicAttachments = PressReleaseAttachment::query()->withTrashed()->where('disk', 'public')->count();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Veröffentlichungsgate (Bildkennzeichnung E.1): Ein Bild aus Formular v2
|
||||
* ohne gültige Rechtebestätigung zur aktuellen Datei — typisch ein
|
||||
* Admin-Upload, den der Kunde noch nicht bestätigt hat (E10).
|
||||
*/
|
||||
class ImageRightsConfirmationRequiredException extends RuntimeException
|
||||
{
|
||||
public function __construct(?string $message = null)
|
||||
{
|
||||
parent::__construct($message ?? __('Das Titelbild ist noch nicht vom Kontoinhaber bestätigt. Bis dahin kann die Meldung nicht mit Bild veröffentlicht werden.'));
|
||||
}
|
||||
}
|
||||
82
app/Services/PressRelease/Images/ImageCorrectionService.php
Normal file
82
app/Services/PressRelease/Images/ImageCorrectionService.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\Images;
|
||||
|
||||
use App\Enums\AiDeclarationSource;
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Models\ImageDeclarationCorrection;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Korrekturweg (Bildkennzeichnung E.6, Decision-Update S17): Der Admin
|
||||
* ändert Bildnachweis oder Portalkennzeichnung mit Pflichtgrund oder hält
|
||||
* das Bild vorläufig zurück. Kundenangabe, ursprüngliche Bestätigung und
|
||||
* Befund bleiben unverändert; jede Änderung ist insert-only protokolliert.
|
||||
*/
|
||||
class ImageCorrectionService
|
||||
{
|
||||
public function __construct(private readonly PublishedImageSnapshot $snapshots) {}
|
||||
|
||||
public function correctCredit(PressReleaseImage $image, User $admin, string $newCredit, string $reason): ImageDeclarationCorrection
|
||||
{
|
||||
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_CREDIT, $image->copyright, $newCredit, null, $reason, function () use ($image, $newCredit): void {
|
||||
$image->forceFill(['copyright' => $newCredit])->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function correctAiDeclaration(PressReleaseImage $image, User $admin, AiImageDeclaration $new, string $reason): ImageDeclarationCorrection
|
||||
{
|
||||
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_AI_DECLARATION, $image->ai_declaration?->value, $new->value, $image->ai_declaration_source?->value, $reason, function () use ($image, $new): void {
|
||||
$image->forceFill([
|
||||
'ai_declaration' => $new,
|
||||
'ai_declaration_source' => AiDeclarationSource::AdminCorrection,
|
||||
'ai_declaration_locked' => false,
|
||||
])->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function withhold(PressReleaseImage $image, User $admin, string $reason): ImageDeclarationCorrection
|
||||
{
|
||||
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_WITHHELD, '0', '1', null, $reason, function () use ($image): void {
|
||||
$image->forceFill(['withheld_at' => now()])->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function release(PressReleaseImage $image, User $admin, string $reason): ImageDeclarationCorrection
|
||||
{
|
||||
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_WITHHELD, '1', '0', null, $reason, function () use ($image): void {
|
||||
$image->forceFill(['withheld_at' => null])->save();
|
||||
});
|
||||
}
|
||||
|
||||
private function apply(PressReleaseImage $image, User $admin, string $field, ?string $old, ?string $new, ?string $oldSource, string $reason, \Closure $mutation): ImageDeclarationCorrection
|
||||
{
|
||||
$correction = DB::transaction(function () use ($image, $admin, $field, $old, $new, $oldSource, $reason, $mutation): ImageDeclarationCorrection {
|
||||
$mutation();
|
||||
|
||||
return ImageDeclarationCorrection::query()->create([
|
||||
'press_release_image_id' => $image->getKey(),
|
||||
'press_release_id' => $image->press_release_id,
|
||||
'admin_id' => $admin->getKey(),
|
||||
'field' => $field,
|
||||
'old_value' => $old,
|
||||
'new_value' => $new,
|
||||
'old_source' => $oldSource,
|
||||
'reason' => $reason,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
// Öffentliche Fassung hat sich geändert: Snapshot nachziehen und
|
||||
// Seiten-Caches verwerfen (D.5).
|
||||
if ($image->wasEverPublished()) {
|
||||
$this->snapshots->capture($image->fresh());
|
||||
WebCaches::forgetPublicPages();
|
||||
}
|
||||
|
||||
return $correction;
|
||||
}
|
||||
}
|
||||
85
app/Services/PressRelease/Images/PublishedImageSnapshot.php
Normal file
85
app/Services/PressRelease/Images/PublishedImageSnapshot.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\Images;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Support\ImageCredit;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Snapshot der veröffentlichten Bildfassung (Bildkennzeichnung E.2,
|
||||
* Decision-Update E2/S1): beim Veröffentlichen wird die ausgelieferte
|
||||
* Cover-Variante zusammen mit dem gerenderten Nachweis und der Kennzeichnung
|
||||
* auf den privaten Snapshot-Pfad kopiert. So lässt sich später belegen, was
|
||||
* mit welchem Nachweis öffentlich war — auch nach Korrektur oder Entfernung.
|
||||
*/
|
||||
class PublishedImageSnapshot
|
||||
{
|
||||
public function captureForPressRelease(PressRelease $pressRelease): void
|
||||
{
|
||||
$pressRelease->loadMissing('company');
|
||||
|
||||
foreach ($pressRelease->images()->whereNotNull('rules_version')->get() as $image) {
|
||||
$this->capture($image, $pressRelease);
|
||||
}
|
||||
}
|
||||
|
||||
public function capture(PressReleaseImage $image, ?PressRelease $pressRelease = null): void
|
||||
{
|
||||
$pressRelease ??= $image->pressRelease;
|
||||
|
||||
if ($pressRelease === null || $image->isLegacyRules() || $image->original_sha256 === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceDisk = Storage::disk($image->disk);
|
||||
$coverPath = $image->pathForVariant('cover') ?? $image->path;
|
||||
|
||||
if (! $sourceDisk->exists($coverPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bytes = (string) $sourceDisk->get($coverPath);
|
||||
$sha = hash('sha256', $bytes);
|
||||
|
||||
// Unverändert seit dem letzten Snapshot: nichts zu tun.
|
||||
if ($image->published_snapshot_sha256 !== null && hash_equals($image->published_snapshot_sha256, $sha) && $image->published_snapshot_path !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$credit = ImageCredit::for($image, $pressRelease->company);
|
||||
$extension = pathinfo($coverPath, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$base = sprintf(
|
||||
'%s/%d/%d-%s',
|
||||
config('images.published_snapshot_directory'),
|
||||
$pressRelease->getKey(),
|
||||
$image->getKey(),
|
||||
substr($sha, 0, 12),
|
||||
);
|
||||
|
||||
$targetDisk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
$targetDisk->put($base.'.'.$extension, $bytes);
|
||||
$targetDisk->put($base.'.json', (string) json_encode([
|
||||
'captured_at' => now()->toIso8601String(),
|
||||
'press_release_id' => $pressRelease->getKey(),
|
||||
'image_id' => $image->getKey(),
|
||||
'variant' => 'cover',
|
||||
'variant_sha256' => $sha,
|
||||
'original_sha256' => $image->original_sha256,
|
||||
'credit_line' => $credit->line(),
|
||||
'credit' => $credit->credit,
|
||||
'ai_declaration' => $image->ai_declaration?->value,
|
||||
'ai_declaration_source' => $image->ai_declaration_source?->value,
|
||||
'responsible_line' => $credit->responsibleLine,
|
||||
'confirmation_key' => $credit->confirmation?->legal_text_key,
|
||||
'confirmation_at' => $credit->confirmation?->confirmed_at?->toIso8601String(),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$image->forceFill([
|
||||
'published_snapshot_path' => $base.'.'.$extension,
|
||||
'published_snapshot_sha256' => $sha,
|
||||
'published_ever_at' => $image->published_ever_at ?? now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ use App\Scopes\PortalScope;
|
|||
use App\Services\ActivityLog;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use App\Services\PressRelease\Images\PublishedImageSnapshot;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -330,13 +332,20 @@ class PressReleaseService
|
|||
return ['blacklist' => ['reason' => $reason, 'word' => $word]];
|
||||
}
|
||||
|
||||
// Veröffentlichungsgate (Bildkennzeichnung E.1): kein Bild aus
|
||||
// Formular v2 ohne gültige Bestätigung zur aktuellen Datei.
|
||||
$this->assertImageRightsConfirmed($locked);
|
||||
|
||||
$this->consumePublishSlot($locked);
|
||||
|
||||
$locked->update([
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'published_at' => $this->resolvePublishedAt($locked, $publishedAtOverride),
|
||||
'published_ever_at' => $locked->published_ever_at ?? now(),
|
||||
]);
|
||||
|
||||
$this->markImagesPublished($locked);
|
||||
|
||||
$this->logStatusChange($locked, $previous, PressReleaseStatus::Published, null, $source);
|
||||
|
||||
return null;
|
||||
|
|
@ -344,6 +353,10 @@ class PressReleaseService
|
|||
|
||||
$pressRelease->refresh();
|
||||
|
||||
if ($outcome === null) {
|
||||
$this->capturePublishedImages($pressRelease);
|
||||
}
|
||||
|
||||
if (isset($outcome['stale'])) {
|
||||
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $outcome['stale']['current'])->onQueue('classification');
|
||||
|
||||
|
|
@ -586,6 +599,7 @@ class PressReleaseService
|
|||
$previous = $locked->status;
|
||||
|
||||
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
|
||||
$this->assertImageRightsConfirmed($locked);
|
||||
$this->consumePublishSlot($locked);
|
||||
}
|
||||
|
||||
|
|
@ -594,8 +608,15 @@ class PressReleaseService
|
|||
'published_at' => $status === PressReleaseStatus::Published
|
||||
? ($locked->published_at ?? now())
|
||||
: $locked->published_at,
|
||||
'published_ever_at' => $status === PressReleaseStatus::Published
|
||||
? ($locked->published_ever_at ?? now())
|
||||
: $locked->published_ever_at,
|
||||
]);
|
||||
|
||||
if ($status === PressReleaseStatus::Published) {
|
||||
$this->markImagesPublished($locked);
|
||||
}
|
||||
|
||||
if ($previous !== $status) {
|
||||
$this->logStatusChange($locked, $previous, $status, $reason, 'admin');
|
||||
}
|
||||
|
|
@ -606,6 +627,10 @@ class PressReleaseService
|
|||
});
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
if ($status === PressReleaseStatus::Published) {
|
||||
$this->capturePublishedImages($pressRelease);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteFromAdmin(PressRelease $pressRelease): void
|
||||
|
|
@ -626,9 +651,48 @@ class PressReleaseService
|
|||
return;
|
||||
}
|
||||
|
||||
// Nie veröffentlicht: Bilder samt Original und Bestätigung sofort
|
||||
// weg (Bildkennzeichnung E.3, Decision-Update E4).
|
||||
$remover = app(PressReleaseImageRemover::class);
|
||||
|
||||
foreach ($pressRelease->images()->get() as $image) {
|
||||
$remover->remove($image);
|
||||
}
|
||||
|
||||
$pressRelease->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichungsgate (E.1): Bilder aus Formular v2 brauchen eine
|
||||
* Bestätigung zur aktuellen Datei — sonst wartet ein Admin-Upload noch
|
||||
* auf den Kunden (E10). Bestandsbilder sind nicht betroffen.
|
||||
*/
|
||||
private function assertImageRightsConfirmed(PressRelease $pressRelease): void
|
||||
{
|
||||
$unconfirmed = $pressRelease->images()
|
||||
->whereNotNull('rules_version')
|
||||
->with('latestRightsConfirmation')
|
||||
->get()
|
||||
->first(fn (PressReleaseImage $image): bool => ! $image->hasValidRightsConfirmation());
|
||||
|
||||
if ($unconfirmed !== null) {
|
||||
throw new ImageRightsConfirmationRequiredException;
|
||||
}
|
||||
}
|
||||
|
||||
private function markImagesPublished(PressRelease $pressRelease): void
|
||||
{
|
||||
$pressRelease->images()->whereNull('published_ever_at')->update(['published_ever_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot der veröffentlichten Bildfassung nach dem Commit (E.2).
|
||||
*/
|
||||
private function capturePublishedImages(PressRelease $pressRelease): void
|
||||
{
|
||||
app(PublishedImageSnapshot::class)->captureForPressRelease($pressRelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernung mit 410 Gone: die Mitteilung verschwindet aus allen
|
||||
* öffentlichen Listen, ihre Detail-URL liefert aber weiterhin einen
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class CategoryPageData
|
|||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
];
|
||||
|
||||
// Gebuchte Boosts (Decision-Update §2.2): geboostete PMs der Branche
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class HomePageData
|
|||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query
|
||||
'images' => fn ($query) => $query->publiclyVisible()
|
||||
->orderByDesc('is_preview')
|
||||
->orderBy('sort_order')
|
||||
->limit(1),
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ class NewsroomDetailPageData
|
|||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
];
|
||||
|
||||
$leadRelease = $base()->with($with)->orderByDesc('published_at')->first();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class ReleaseDetailPageData
|
|||
'user:id,agency_name',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'category.parent.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'),
|
||||
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order'),
|
||||
'contacts',
|
||||
])
|
||||
// Doppelveröffentlichung mit gleichem Slug: dieselbe Wahl wie
|
||||
|
|
@ -107,7 +107,7 @@ class ReleaseDetailPageData
|
|||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
];
|
||||
|
||||
$companyReleases = $release->company_id
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue