betrieb: Logs je Thema in Tagesdateien (billing, ki, monitoring, market); Doku: Duplicate-Content-Decision durch Anhang 2 abgelöst

- config/logging.php: Themen-Channels billing, ki, monitoring, market als
  daily-Dateien mit gemeinsamer Aufbewahrung (LOG_DAILY_DAYS), Stack-Default
  auf daily; Channel `testing` für die Suite.
- Fachliche Log-Aufrufe in Billing, KI-Jobs, OperationalAlert und
  Kursdaten auf Log::channel() umgestellt.
- phpunit.xml + TestCase: Suite schreibt ausschließlich nach
  testing-YYYY-MM-DD.log; LogChannelsTest sichert die Konfiguration.
- docs/weiteres: Decision-Update Duplicate-Content entfernt (überholt durch
  Roadmap-Anhang 2), Decision-Update Verlinkung & Backlinks als Referenz
  wieder abgelegt, README-Index nachgezogen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 13:35:42 +00:00
parent b8c6af5f10
commit 2ec61697b0
21 changed files with 338 additions and 107 deletions

View file

@ -22,9 +22,12 @@ PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
# Prod: auf `daily` stellen — der `single`-Channel wächst sonst unbegrenzt.
# Der daily-Channel rotiert mit LOG_DAILY_DAYS (Default 14, siehe Runbook).
LOG_STACK=single
# `daily`: eine Datei je Tag (laravel-YYYY-MM-DD.log), Rotation nach
# LOG_DAILY_DAYS. Fachliche Meldungen liegen daneben in eigenen Tagesdateien
# je Thema (billing-, ki-, monitoring-, market-, admin-slow-*.log) —
# siehe config/logging.php. `single` nur noch als Notnagel, wächst unbegrenzt.
LOG_STACK=daily
LOG_DAILY_DAYS=14
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
@ -255,7 +258,7 @@ CRAWLER_HITS_RECORDED_SINCE=2026-09-18
GSC_ENABLED=false
GSC_SERVICE_ACCOUNT_FILE=
GSC_PROPERTY_PRESSEECHO=sc-domain:presseecho.de
GSC_PROPERTY_BUSINESSPORTAL24=sc-domain:businessportal24.de
GSC_PROPERTY_BUSINESSPORTAL24=sc-domain:businessportal24.com
GSC_DAILY_BUDGET=300
GSC_CUSTOMER_VISIBLE=false

View file

@ -114,7 +114,7 @@ class CheckoutController extends Controller
'redirect' => route('me.bookings.index', ['checkout' => 'wechsel']),
]);
} catch (\Throwable $exception) {
Log::error('Tarifwechsel fehlgeschlagen.', [
Log::channel('billing')->error('Tarifwechsel fehlgeschlagen.', [
'user_id' => $user->id,
'plan' => $plan->slug,
'interval' => $interval,

View file

@ -24,7 +24,7 @@ class StripeInvoiceRedirectController extends Controller
$stripeInvoice = Cashier::stripe()->invoices->retrieve($invoice->stripe_invoice_id);
$hostedUrl = $stripeInvoice->hosted_invoice_url;
} catch (\Throwable $exception) {
Log::warning('Stripe-Rechnungsansicht nicht erreichbar.', [
Log::channel('billing')->warning('Stripe-Rechnungsansicht nicht erreichbar.', [
'invoice_id' => $invoice->id,
'stripe_invoice_id' => $invoice->stripe_invoice_id,
'error' => $exception->getMessage(),

View file

@ -165,14 +165,14 @@ class ClassifyPressRelease implements ShouldQueue
} catch (StaleClassificationException $exception) {
// Inhalt zwischen Audit und Publish-Sperre geändert: publish() hat
// bereits eine Prüfung für den aktuellen Stand eingeplant (U02).
Log::info('Auto-Veröffentlichung übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [
'press_release_id' => $pressRelease->id,
]);
} catch (QuotaExceededException $exception) {
// Kontingent zwischen Einreichung und KI-Freigabe aufgebraucht: PM
// bleibt in Review, damit Autor/Admin bewusst entscheiden — kein
// Job-Retry, der die Klassifikation sinnlos wiederholt.
Log::info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [
'press_release_id' => $pressRelease->id,
]);
}
@ -208,7 +208,7 @@ class ClassifyPressRelease implements ShouldQueue
try {
return $manager->driver($provider)->classify($pressRelease);
} catch (\Throwable $exception) {
Log::warning('KI-Klassifikation fiel auf den deterministischen Treiber zurück.', [
Log::channel('ki')->warning('KI-Klassifikation fiel auf den deterministischen Treiber zurück.', [
'press_release_id' => $pressRelease->id,
'provider' => $provider,
'error' => $exception->getMessage(),

View file

@ -124,7 +124,7 @@ class ScorePressRelease implements ShouldQueue
try {
return $manager->driver($provider)->score($pressRelease);
} catch (\Throwable $exception) {
Log::warning('Content-Score fiel auf den deterministischen Treiber zurück.', [
Log::channel('ki')->warning('Content-Score fiel auf den deterministischen Treiber zurück.', [
'press_release_id' => $pressRelease->id,
'provider' => $provider,
'error' => $exception->getMessage(),

View file

@ -192,7 +192,7 @@ class ProcessStripeWebhook
]);
if ($cleared > 0) {
Log::info('Vorgemerkter Tarifwechsel wurde von Stripe vollzogen.', [
Log::channel('billing')->info('Vorgemerkter Tarifwechsel wurde von Stripe vollzogen.', [
'stripe_subscription_id' => $stripeId,
'stripe_price' => $price,
]);
@ -217,7 +217,7 @@ class ProcessStripeWebhook
$user = Cashier::findBillable($stripeInvoice['customer'] ?? null);
if (! $user instanceof User) {
Log::warning('STR-Spiegelung übersprungen: kein Billable zum Stripe-Customer.', [
Log::channel('billing')->warning('STR-Spiegelung übersprungen: kein Billable zum Stripe-Customer.', [
'stripe_invoice_id' => $stripeInvoiceId,
'stripe_customer' => $stripeInvoice['customer'] ?? null,
]);
@ -239,7 +239,7 @@ class ProcessStripeWebhook
// (QA-Befund 07.07.: unsigned-Spalten → Webhook-500) — der Beleg
// selbst bleibt bei Stripe einsehbar.
if ($total <= 0) {
Log::info('STR-Spiegelung übersprungen: Stripe-Beleg ohne positiven Rechnungsbetrag (Gutschrift/Null-Rechnung).', [
Log::channel('billing')->info('STR-Spiegelung übersprungen: Stripe-Beleg ohne positiven Rechnungsbetrag (Gutschrift/Null-Rechnung).', [
'stripe_invoice_id' => $stripeInvoiceId,
'total_cents' => $total,
]);
@ -286,7 +286,7 @@ class ProcessStripeWebhook
return;
}
Log::info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [
Log::channel('billing')->info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [
'number' => $invoice->number,
'stripe_invoice_id' => $stripeInvoiceId,
]);
@ -351,7 +351,7 @@ class ProcessStripeWebhook
// _failed`. Erfüllt wird deshalb nur bei bestätigter Zahlung; beide
// Erfüllungspfade sind idempotent, doppelte Events bleiben folgenlos.
if (! in_array($session['payment_status'] ?? null, ['paid', 'no_payment_required'], true)) {
Log::info('Checkout-Session noch unbezahlt — Erfüllung wartet auf async_payment_succeeded.', [
Log::channel('billing')->info('Checkout-Session noch unbezahlt — Erfüllung wartet auf async_payment_succeeded.', [
'session_id' => $session['id'] ?? null,
'payment_status' => $session['payment_status'] ?? null,
]);
@ -372,7 +372,7 @@ class ProcessStripeWebhook
*/
private function logFailedAsyncPayment(array $session): void
{
Log::warning('Asynchrone Stripe-Zahlung fehlgeschlagen — keine Erfüllung.', [
Log::channel('billing')->warning('Asynchrone Stripe-Zahlung fehlgeschlagen — keine Erfüllung.', [
'session_id' => $session['id'] ?? null,
'single_purchase_id' => $session['metadata']['single_purchase_id'] ?? null,
'credit_topup_id' => $session['metadata']['credit_topup_id'] ?? null,
@ -402,7 +402,7 @@ class ProcessStripeWebhook
$session['payment_intent'] ?? null,
);
Log::info('Wallet-Aufladung gutgeschrieben.', ['credit_topup_id' => $topup->id]);
Log::channel('billing')->info('Wallet-Aufladung gutgeschrieben.', ['credit_topup_id' => $topup->id]);
}
/**
@ -459,7 +459,7 @@ class ProcessStripeWebhook
$purchase = $result['purchase'];
if ($result['marked_paid']) {
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
Log::channel('billing')->info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
// Stufe 5 der Konversionsleiter. recordFirstBooking() zählt je
// Firma nur den ersten Treffer, deshalb dürfen beide Zahlungspfade
@ -479,7 +479,7 @@ class ProcessStripeWebhook
Mail::to($buyer->email)->queue(new PressReleaseChangeApplied($buyer, $changedRelease, $changeRequest));
}
Log::info('Pressemitteilung nach bezahlter Änderung aktualisiert.', [
Log::channel('billing')->info('Pressemitteilung nach bezahlter Änderung aktualisiert.', [
'single_purchase_id' => $purchase->id,
'press_release_id' => $changedRelease->id,
]);
@ -497,7 +497,7 @@ class ProcessStripeWebhook
Mail::to($buyer->email)->queue(new PressReleaseDepublished($buyer, $pressRelease));
}
Log::info('Pressemitteilung depubliziert.', [
Log::channel('billing')->info('Pressemitteilung depubliziert.', [
'single_purchase_id' => $purchase->id,
'press_release_id' => $pressRelease->id,
]);
@ -522,7 +522,7 @@ class ProcessStripeWebhook
->first();
if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) {
Log::warning('Depublikation bezahlt, aber PM nicht (mehr) veröffentlicht.', [
Log::channel('billing')->warning('Depublikation bezahlt, aber PM nicht (mehr) veröffentlicht.', [
'single_purchase_id' => $purchase->id,
'press_release_id' => $purchase->press_release_id,
]);
@ -568,7 +568,7 @@ class ProcessStripeWebhook
->first();
if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) {
Log::warning('Änderung bezahlt, aber PM nicht (mehr) veröffentlicht.', [
Log::channel('billing')->warning('Änderung bezahlt, aber PM nicht (mehr) veröffentlicht.', [
'single_purchase_id' => $purchase->id,
'press_release_id' => $purchase->press_release_id,
]);
@ -581,7 +581,7 @@ class ProcessStripeWebhook
} catch (CorrectionTooLargeException) {
// Kauf bleibt bezahlt/unkonsumiert — Klärung durch den Admin,
// statt eine Obergrenzen-Verletzung stillschweigend zu übernehmen.
Log::error('Bezahlte Änderung überschreitet die Obergrenze — nicht ausgeführt.', [
Log::channel('billing')->error('Bezahlte Änderung überschreitet die Obergrenze — nicht ausgeführt.', [
'single_purchase_id' => $purchase->id,
'legal_request_id' => $legalRequest->id,
]);

View file

@ -186,7 +186,7 @@ class ManualInvoiceService
*/
private function hold(UserPaymentOption $option, string $reason, array $context): null
{
Log::warning('MAN-Rechnung übersprungen: '.$reason.'.', $context);
Log::channel('billing')->warning('MAN-Rechnung übersprungen: '.$reason.'.', $context);
$option->forceFill([
'billing_hold_reason' => $reason,

View file

@ -212,7 +212,7 @@ class StripeCheckoutService
try {
Cashier::stripe()->subscriptionSchedules->release($subscription->stripe_schedule_id);
} catch (InvalidRequestException $exception) {
Log::warning('Downgrade-Vormerkung war bei Stripe nicht (mehr) auflösbar.', [
Log::channel('billing')->warning('Downgrade-Vormerkung war bei Stripe nicht (mehr) auflösbar.', [
'subscription_id' => $subscription->id,
'stripe_schedule_id' => $subscription->stripe_schedule_id,
'error' => $exception->getMessage(),
@ -241,7 +241,7 @@ class StripeCheckoutService
$user->createOrGetStripeCustomer();
$user->syncStripeCustomerDetails();
} catch (\Throwable $exception) {
Log::warning('Stripe-Customer konnte nicht mit der Rechnungsadresse synchronisiert werden.', [
Log::channel('billing')->warning('Stripe-Customer konnte nicht mit der Rechnungsadresse synchronisiert werden.', [
'user_id' => $user->id,
'error' => $exception->getMessage(),
]);
@ -299,7 +299,7 @@ class StripeCheckoutService
$user->createTaxId('eu_vat', $vatId);
}
} catch (\Throwable $exception) {
Log::warning('USt-ID konnte nicht mit Stripe abgeglichen werden.', [
Log::channel('billing')->warning('USt-ID konnte nicht mit Stripe abgeglichen werden.', [
'user_id' => $user->id,
'error' => $exception->getMessage(),
]);
@ -332,7 +332,7 @@ class StripeCheckoutService
$check = $this->vatIdValidation->check($vatId, (string) $user->billingAddress?->country_code);
if (! $check['status']->isUsable()) {
Log::warning('USt-ID vor der Buchung nicht bestätigt — sie wird nicht an Stripe übergeben.', [
Log::channel('billing')->warning('USt-ID vor der Buchung nicht bestätigt — sie wird nicht an Stripe übergeben.', [
'user_id' => $user->id,
'vat_id' => $vatId,
'status' => $check['status']->value,

View file

@ -117,7 +117,7 @@ class VatIdValidationService
'angefragteUstid' => $vatId,
]);
} catch (Throwable $exception) {
Log::warning('eVatR-Abfrage fehlgeschlagen.', ['vat_id' => $vatId, 'error' => $exception->getMessage()]);
Log::channel('billing')->warning('eVatR-Abfrage fehlgeschlagen.', ['vat_id' => $vatId, 'error' => $exception->getMessage()]);
return $this->result(
VatIdCheckStatus::Unverified,

View file

@ -42,7 +42,7 @@ class CompanyProfileCheck
try {
return $this->classification->driver()->classify($proposed);
} catch (\Throwable $e) {
Log::warning('Profilprüfung (Rot-Ebene) fehlgeschlagen — deterministischer Fallback.', [
Log::channel('ki')->warning('Profilprüfung (Rot-Ebene) fehlgeschlagen — deterministischer Fallback.', [
'company_id' => $company->getKey(),
'error' => $e->getMessage(),
]);

View file

@ -59,7 +59,7 @@ class CorrectionService
try {
return $this->classification->driver()->classify($proposed);
} catch (\Throwable $e) {
Log::warning('KI-Vorprüfung (Spur D) fehlgeschlagen — deterministischer Fallback.', [
Log::channel('ki')->warning('KI-Vorprüfung (Spur D) fehlgeschlagen — deterministischer Fallback.', [
'press_release_id' => $pressRelease->id,
'error' => $e->getMessage(),
]);
@ -223,7 +223,7 @@ class CorrectionService
}
if (! $classification['has_changes']) {
Log::warning('Bezahlte Änderung ohne wirksame Feld-Änderung — nichts zu tun.', [
Log::channel('billing')->warning('Bezahlte Änderung ohne wirksame Feld-Änderung — nichts zu tun.', [
'legal_request_id' => $legalRequest->id,
'press_release_id' => $pressRelease->id,
]);

View file

@ -64,7 +64,7 @@ class MarketDataService
}
if ($slides === []) {
Log::warning('market:refresh lieferte keine Kursdaten — bestehender Cache-Stand bleibt erhalten.');
Log::channel('market')->warning('market:refresh lieferte keine Kursdaten — bestehender Cache-Stand bleibt erhalten.');
return $this->slides();
}
@ -94,13 +94,13 @@ class MarketDataService
'interval' => '1d',
]);
} catch (Throwable $exception) {
Log::warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'error' => $exception->getMessage()]);
Log::channel('market')->warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'error' => $exception->getMessage()]);
return null;
}
if (! $response->successful()) {
Log::warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'status' => $response->status()]);
Log::channel('market')->warning('Kursabruf fehlgeschlagen.', ['symbol' => $symbol, 'status' => $response->status()]);
return null;
}

View file

@ -20,7 +20,7 @@ class OperationalAlert
*/
public static function send(string $summary, array $context = []): void
{
Log::critical('[monitoring] '.$summary, $context);
Log::channel('monitoring')->critical('[monitoring] '.$summary, $context);
if (! config('monitoring.alerts.enabled')) {
return;
@ -35,7 +35,7 @@ class OperationalAlert
try {
Mail::to($recipient)->send(new OperationalAlertMail($summary, $context));
} catch (Throwable $e) {
Log::error('[monitoring] Alert-Mail konnte nicht versendet werden.', [
Log::channel('monitoring')->error('[monitoring] Alert-Mail konnte nicht versendet werden.', [
'summary' => $summary,
'error' => $e->getMessage(),
]);

View file

@ -5,6 +5,19 @@ use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
/**
* Themen-Channel: eine Tagesdatei je Thema, Rotation wie `daily`.
*
* @return array{driver: string, path: string, level: mixed, days: mixed, replace_placeholders: bool}
*/
$topicChannel = fn (string $topic): array => [
'driver' => 'daily',
'path' => storage_path("logs/{$topic}.log"),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
];
return [
/*
@ -54,7 +67,7 @@ return [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'single')),
'channels' => explode(',', env('LOG_STACK', 'daily')),
'ignore_exceptions' => false,
],
@ -73,6 +86,36 @@ return [
'replace_placeholders' => true,
],
/*
|----------------------------------------------------------------------
| Themen-Channels (je Thema eine Tagesdatei)
|----------------------------------------------------------------------
|
| Fachliche Meldungen landen nicht im allgemeinen `laravel-*.log`,
| sondern über `Log::channel('…')` in einer eigenen Tagesdatei unter
| storage/logs/<thema>-YYYY-MM-DD.log. Rotation wie `daily`
| (LOG_DAILY_DAYS). Das allgemeine Log behält Exceptions, `report()`
| und alles, was keinem Thema zugeordnet ist.
|
| billing Stripe-Webhooks, Checkout, Tarifwechsel, STR-/MAN-
| Rechnungen, USt-ID, Erfüllung bezahlter Vorgänge
| ki KI-Klassifikation, Content-Score, Profil- und
| Vorprüfung, Auto-Veröffentlichung
| monitoring Betriebs-Alerts (OperationalAlert): fehlgeschlagene
| Scheduler-Tasks/Queue-Jobs, Mail-Breaker, Bounces
| market Kursdaten-Abruf (market:refresh)
| admin_slow langsame Admin-Requests (eigene Schwellen/Env-Werte)
|
*/
'billing' => $topicChannel('billing'),
'ki' => $topicChannel('ki'),
'monitoring' => $topicChannel('monitoring'),
'market' => $topicChannel('market'),
'admin_slow' => [
'driver' => 'daily',
'path' => storage_path('logs/admin-slow.log'),
@ -81,6 +124,21 @@ return [
'replace_placeholders' => true,
],
/*
| Testsuite: phpunit.xml setzt LOG_CHANNEL=testing, und tests/TestCase.php
| biegt die Themen-Channels ebenfalls hierher um — Tests schreiben damit
| ausschließlich nach testing-YYYY-MM-DD.log und verwässern weder das
| allgemeine noch die Themen-Logs. Kurze Aufbewahrung, reine Diagnose.
*/
'testing' => [
'driver' => 'daily',
'path' => storage_path('logs/testing.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 3,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),

View file

@ -41,10 +41,10 @@ Sie verlinkt die zentralen Dokumente und sortiert sie nach „Was ist der aktuel
### `weiteres/` — Abgestimmte Decision-Updates (nächste Umsetzungsblöcke)
- [`Decision-Update Phase-2-Funktionen & Magic-Link-Änderungsprozess.md`](./weiteres/Decision-Update%20Phase-2-Funktionen%20&%20Magic-Link-%C3%84nderungsprozess.md) — Boost + Veröffentlichungsnachweis (Launch), Magic-Link-Zugangs-/Änderungsprozess, Phase-2-Funktionen (Vorab-Prüfung, Prüfzähler, kostenpflichtige Änderungspfade). **Noch nicht umgesetzt.**
- [`Decision-Update Duplicate-Content & Duplicate-Checking.md`](./weiteres/Decision-Update%20Duplicate-Content%20&%20Duplicate-Checking.md) — Cross-Portal-Duplicate-Content (SEO) vs. Duplikat-Erkennung eingereichter PMs. **Noch nicht umgesetzt.**
- Das Decision-Update „Duplicate-Content & Duplicate-Checking" (12.06.2026, „nichts bauen, nur Canonical-Hygiene") ist überholt und entfernt (18.09.2026): Die API reicht dieselbe Meldung auf beiden Portalen ein, deshalb gibt es seit Anhang 2 der Roadmap (`dev/2026-09-01/roadmap-anhang2-2026.md`) Textprüfsumme, kanonische Zuordnung je Kategorie und Quellenauskunft.
- [`Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md`](./weiteres/Detailplan%20Umsetzung%20Launch-Slice%20%28Magic-Link%2C%20Compliance%2C%20Bestandsschutz%2C%20Auth%29.md) — konkreter Umsetzungsplan (WS-1 bis WS-8) aus den beiden Decision-Updates + Merkliste; verbindliche Entscheidungen vom 15.06.2026 (Legacy-Posten bleiben, Pflicht-E-Mail-Verifizierung, Socialite freigegeben). **In Umsetzung** (WS-1, WS-6 ohne Google, WS-2 erledigt).
- [`Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md`](./weiteres/Sicherheit%20%26%20Deployment-Hinweise%20%28Auth%2C%20Rollen%2C%20Verifizierung%29.md) — **deployment-kritisch:** Reihenfolge der Migrationen (Verifizierungs-Backfill, Legacy-Rollen-Downgrade), der Sicherheits-Fix der Editor-Überberechtigung, Firmen-Scope-Verhaltensänderung und der Magic-Link-Kontaktzugang. Feed für die Live-Deployment-Checkliste (WS-7).
- Das Decision-Update „Verlinkung & Backlinks" ist umgesetzt (systemseitige `rel`-Auszeichnung, `PressReleaseLinkPolicy`, 12.06.2026) und wurde nach der Integration in §4 des Preisstruktur-Decision-Updates entfernt — Umsetzungsdetails im Hub-Flux-PROGRESS-Log.
- [`Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md`](./weiteres/Decision-Update%20Verlinkung%20&%20Backlinks%20in%20Pressemitteilungen.md) — systemseitige `rel`-Auszeichnung (`sponsored nofollow`), kein Verkauf von Dofollow-Backlinks. **Umgesetzt** (`PressReleaseLinkPolicy`, 12.06.2026); Kurzfassung in §4 des Preisstruktur-Decision-Updates. Als Referenz wieder abgelegt (18.09.2026), weil die Transparenzseite „kostenlos veröffentlichen" die Link-Policy erklärt.
### `user-admin/` — User-/Admin-Backend

View file

@ -1,65 +0,0 @@
**Version:** Juni 2026 **Datum:** 12.06.2026 **Status:** Abgestimmt – bereit zur Integration ins Konzept-/Decision-Log **Scope:** Klärung, ob das Portal doppelte/ähnliche Pressemitteilungen automatisch prüfen muss. Separates Thema, bewusst aus dem Phase-2/Magic-Link-Dokument herausgelöst.
---
## 1. Kontext
Beim Thema Cross-Portal-Veröffentlichung kam Duplicate-Content als SEO-Risiko auf. Daraus entstand die Folgefrage, ob das Portal doppelte oder ähnliche Pressemitteilungen automatisch erkennen/prüfen sollte. Wichtig: Hier werden zwei **verschiedene** Probleme vermischt, die getrennt zu betrachten sind.
---
## 2. Die zwei Duplicate-Szenarien
||Szenario A: System-Duplicate|Szenario B: Kunden-Duplicate|
|---|---|---|
|**Ursache**|Cross-Portal-Feature spiegelt dieselbe PM auf zwei Domains|Kunde legt manuell zweimal dasselbe an (zwei Firmen-Einträge, zwei ähnliche PMs)|
|**Wer trägt den Schaden**|das Portal (eigene Domains konkurrieren)|der Kunde selbst|
|**Häufigkeit**|systematisch (bei jedem Cross-Post)|seltener Randfall|
|**Status**|**gelöst durch Weglassen**|**kein aktiver Check**|
---
## 3. Szenario A – gelöst durch Weglassen
Das ursprüngliche SEO-Duplicate entstand ausschließlich durch das geplante Cross-Portal-Feature (eine PM, vom System auf presseecho.de **und** businessportal24.com gespiegelt). Da dieses Feature **bewusst nicht gebaut wird** (Portale optisch, inhaltlich und systemisch getrennt – siehe Decision-Update Phase 2/Magic-Link, Abschnitt 6), entsteht dieser Duplicate gar nicht erst.
**Kein automatischer Spiegel = kein automatischer Duplicate.** Es ist hier nichts zu prüfen.
---
## 4. Szenario B – bewusst kein automatisches Duplicate-Checking
Ein Kunde, der manuell zweimal dasselbe einstellt und zweimal zahlt, ist ein seltener Randfall. Ein automatischer Erkennungsmechanismus steht in keinem Verhältnis:
- **KI-Ähnlichkeitsprüfung** über alle PMs = laufende Kosten bei _jeder_ Veröffentlichung, für einen Randfall
- **DB-Vergleich** („gleiche Firma doppelt + ähnlicher Text") klingt simpel, ist es nicht: „ähnlich" sauber zu definieren (Schwellwert? gleicher Titel? gleicher erster Absatz?) ist die klassische Fuzzy-Matching-Falle – viel Tuning, trotzdem Fehlalarme
- **Gleiche Firma zweimal angelegt** ist oft **legitim** (verschiedene Abteilungen, verschiedene Pressekontakte) – kein verlässliches Duplicate-Signal
**Entscheidender Punkt:** Den SEO-Nachteil eines selbst erzeugten Duplikats trägt der Kunde, nicht das Portal. Die Leistung wurde erbracht (zwei Veröffentlichungen, zweimal bezahlt). Der Kunde muss nicht vor seiner eigenen Entscheidung geschützt werden.
---
## 5. Was stattdessen greift (kostenlos, sofort)
1. **Score deckt den relevanten Teil ab.** Die Red-Flag-/Content-Prüfung läuft ohnehin bei jeder PM. Plump kopierte Massen-/Müll-PMs fallen dort als Qualitätsproblem auf – das ist der eigentliche Spam-Filter, kein separates Duplicate-System nötig.
2. **Canonical-Tags sauber setzen.** Rein technisch, keine laufenden Kosten. Jede PM erhält ihre eigene kanonische URL – saubere SEO-Hygiene statt aktivem Prüfprozess.
3. **Phase-3-Option, nur falls es real wird.** Sollte sich im Betrieb zeigen, dass Duplikate tatsächlich ein Problem sind (unwahrscheinlich), lässt sich ein billiger **Hash-/Shingle-Vergleich** auf Titel + erste Absätze nachrüsten – algorithmisch, keine KI. Erst bauen, wenn Daten zeigen, dass es vorkommt.
---
## 6. Entscheidung
- **Kein automatisches Duplicate-Checking zum Launch.**
- Szenario A (System-Duplicate) ist durch den Verzicht auf Cross-Portal erledigt.
- Szenario B (Kunden-Duplicate) wird nicht aktiv geprüft; der Score fängt Spam/Müll ab, Canonical-Tags sorgen für technische Hygiene.
- Hash-/Shingle-Vergleich bleibt als **Phase-3-Option** notiert, nur bei nachgewiesenem Bedarf.
---
## 7. Anti-Zombie-Check
- ✅ Keine Kosten auf Vorrat für ein Phantomproblem
- ✅ Kunde wird nicht für legitime Mehrfachnutzung (zwei Abteilungen, zwei Kontakte) fälschlich blockiert
- ✅ Spam-Schutz läuft über den ohnehin vorhandenen Score, nicht über ein zweites paralleles System
- ✅ Technische SEO-Hygiene (Canonical) statt aktivem, fehleranfälligem Prüfprozess

View file

@ -0,0 +1,145 @@
**Version:** Juni 2026 **Datum:** 11.06.2026 **Status:** Abgestimmt – bereit zur Integration ins Konzept-/Decision-Log **Scope:** Behandlung von Links in Presseartikeln, `rel`-Auszeichnung, Linkangabe und Linktyp-Auswahl im Editor, Abgrenzung zum verkauften Dofollow-Backlink. Präzisiert den entsprechenden Punkt im Decision-Update „Preisstruktur & Veröffentlichungs-Flow".
---
## 1. Kontext & Klarstellung
In der Preis-Abstimmung war der Punkt „verkaufte Dofollow-Backlinks ausgeschlossen" zu pauschal formuliert und konnte als „keine Links" missverstanden werden. Das ist falsch. Links in Presseartikeln gehören selbstverständlich dazu. Die Frage ist nicht _ob_, sondern _wie ausgezeichnet_ und _wie verkauft_.
Es geht um zwei verschiedene Dinge, die beide „Backlink" heißen:
1. **Der Link im Presseartikel zur Kundenseite** – normaler, erwarteter Bestandteil jeder PM. Erlaubt, darf hervorgehoben werden. Muss technisch korrekt ausgezeichnet sein.
2. **Den „Dofollow-Backlink für SEO-Wert" als bezahltes Upsell verkaufen** – das ist die Falle und bleibt ausgeschlossen.
---
## 2. Grundregel (Google-konform)
**Externe Links in Pressemitteilungen werden systemseitig mit `rel="sponsored"` bzw. `rel="nofollow"` ausgezeichnet.**
Hintergrund: Google behandelt Links in Pressemitteilungen als Kategorie, die nofollow gehört – der Kunde setzt den Link selbst, es ist keine redaktionelle Empfehlung des Portals. Jeder über Bezahlung oder kommerzielle Vereinbarung entstandene Link muss entsprechend qualifiziert sein; eine fehlende Kennzeichnung von Paid-Placements ist selbst der Richtlinienverstoß. Für kombinierte Fälle (bezahlte Platzierung + kein Endorsement) ist `rel="sponsored nofollow"` zulässig.
**Konsequenz fürs Produkt:** Die `rel`-Auszeichnung ist **nicht kundenseitig wählbar**. Sie wird vom System anhand der Platzierungs-Kategorie automatisch gesetzt. Damit ist die Domain dauerhaft sauber und es gibt keinen Hebel, über den ein Kunde „dofollow" erzwingen könnte.
---
## 3. Wichtig: nofollow ≠ wertlos
Die alte Logik „nofollow = nutzlos" gilt seit 2019 nicht mehr. Google behandelt `nofollow`/`sponsored`/`ugc` seitdem als **Hinweis**, nicht als absolute Anweisung. Praktischer Wert für den Kunden bleibt:
- **Referral-Traffic** – direkte Klicks vom Presseartikel auf die Kundenseite
- **Marken-Assoziation & Kontext** – die Nennung selbst zählt
- **AI-/LLM-Sichtbarkeit** – Markennennungen aus Presseartikeln fließen zunehmend in Google AI Overviews und LLM-Antworten ein, wo Sichtbarkeit von Autorität abhängt, nicht nur von klassischen Backlinks
**Das ist das ehrliche, zukunftsfeste Verkaufsargument:** Sichtbarkeit, Reichweite und Auffindbarkeit – nicht „PageRank kaufen".
---
## 4. Linktypen im Überblick
|Element|Funktion|`rel` (systemseitig)|
|---|---|---|
|Firmen-Link im PM-Fließtext|Standard, immer möglich|`sponsored` / `nofollow`|
|**Unternehmens-CTA-Box** (Website, Newsroom, Kontakt) am PM-Ende|Hervorhebung, optisch abgesetzt|`sponsored` / `nofollow`|
|Link zur **Landingpage / Produktseite** (extern)|vom Kunden gewählt|`sponsored` / `nofollow`|
|Link zum **Unternehmensprofil auf dem Portal** (intern)|redaktionelle interne Verlinkung|**follow**|
**Der unterschätzte Punkt – die interne Verlinkung:** Ein follow-Link auf das **portaleigene** Unternehmensprofil ist regelkonform (interner, redaktioneller Link) und stärkt die **eigene** Domain-Architektur. Der Kunde bekommt eine sichtbare „Über das Unternehmen"-Verlinkung; das Portal baut gleichzeitig seine interne Linkstruktur auf, statt fremde Autorität zu verschenken.
---
## 5. Linkangabe & Linktyp-Auswahl im Editor
**Anforderung:** Beim Erstellen einer PM gibt der Kunde Links an und wählt einen Linktyp.
**Sauberes Design (Google-konform):** Der Kunde wählt **Ziel und Zweck** des Links – das System leitet daraus die korrekte `rel`-Auszeichnung ab. Die Begriffe „follow/nofollow" tauchen im Kunden-UI **nicht** auf.
Editor-Flow beim Hinzufügen eines Links:
1. **Ziel-URL** eingeben
2. **Link-Art** wählen (das ist die kundenseitige „Linktyp"-Auswahl):
|Link-Art (Auswahl im Editor)|Beispiel|Systemseitige Auszeichnung|
|---|---|---|
|Unternehmens-Website|startseite.de|extern → `sponsored`/`nofollow`|
|Spezifische Landingpage / Produktseite|aktion.startseite.de|extern → `sponsored`/`nofollow`|
|Newsroom / Unternehmensprofil auf dem Portal|(interner Profil-Link)|intern → `follow`|
|Kontakt / Social-Profil|LinkedIn etc.|extern → `sponsored`/`nofollow`|
3. **Darstellung** (optional, ggf. tarif-/boost-abhängig): Inline-Textlink **oder** hervorgehobene CTA-Box. Das betrifft die _Präsentation_, nicht den Link-Typ.
**Entscheidung festgehalten:** Die Linktyp-Auswahl steuert **Ziel und Darstellung**, nie das `rel`-Attribut. Das `rel` ist systemgesteuert. Damit hat der Kunde maximale Freiheit bei Ziel und Hervorhebung, ohne dass die Domain-Sicherheit kippt.
---
## 6. Was Produkt ist – und was tabu bleibt
||Status|
|---|---|
|Links in der PM (extern, korrekt ausgezeichnet)|✅ Standard, kostenlos Teil der Veröffentlichung|
|Hervorgehobene CTA-Box / Linkdarstellung|✅ als Hervorhebungs-/Boost-Feature verkaufbar|
|follow-Link auf portaleigenes Unternehmensprofil|✅ regelkonform, stärkt eigene Domain|
|Sichtbarkeit / Reichweite / Platzierung|✅ das eigentliche Verkaufsargument|
|„Dofollow-Backlink" als bezahltes Upsell|❌ Link-Scheme-Verstoß, Domain-Risiko, gegen Anti-Zombie|
|Kundenseitiger follow/nofollow-Schalter|❌ nicht im UI, `rel` bleibt systemgesteuert|
---
## 7. Anti-Zombie-Check (dieser Stand)
- ✅ Keine versteckten SEO-Versprechen – verkauft wird Sichtbarkeit, nicht PageRank
- ✅ Domain-Glaubwürdigkeit langfristig geschützt (korrekte `rel`-Auszeichnung systemseitig)
- ✅ Kunde behält volle Freiheit bei Linkziel und Hervorhebung
- ✅ Regelkonform gegenüber Google-Spam-Richtlinien, kein Graubereich
- ✅ Interne Verlinkung baut eigenes Asset statt fremde Autorität zu verschenken
---
## 8. Offene / spätere Punkte
- **Darstellungs-Stufung:** Soll die hervorgehobene CTA-Box ein eigenes Boost-/Tarif-Feature sein oder Standard? (Hängt am Boost-Konzept.)
- **Anzahl Links pro PM:** Sinnvolle Obergrenze definieren (gegen Link-Spam in der PM selbst, z. B. Profil + Website + 1–2 Kontextlinks).
- **Anchor-Text-Regeln:** Optional späterer Hinweis/Soft-Check gegen übermäßige Exact-Match-Anchors (Spam-Signal), eher Phase 2.
---
## 9. Korrektur am vorherigen Decision-Update
Im Dokument „Preisstruktur & Veröffentlichungs-Flow", Abschnitt 4, wird der Eintrag
> „Bewusst ausgeschlossen: Verkaufte Dofollow-Backlinks."
ersetzt durch:
> „Verlinkung: Links zur Kundenseite sind Standard-Bestandteil jeder PM, systemseitig als `sponsored`/`nofollow` ausgezeichnet. Hervorhebung der Linkdarstellung ist als Produkt-Feature möglich. **Tabu:** Verkauf von Dofollow-Backlinks und kundenseitige `rel`-Auswahl. Details siehe Decision-Update „Verlinkung & Backlinks"."
---
_SEO-/Richtlinien-Stand: Google-Spam-Policies inkl. Link-Spam-Enforcement 2024–2026; `nofollow`/`sponsored`/`ugc` als Hinweise seit September 2019. Vor produktiver Umsetzung der `rel`-Logik einmal gegen die dann aktuelle Google-Search-Central-Doku gegenprüfen._
---
## 10. Umsetzungsstand (12.06.2026)
**Umgesetzt:**
- `PressReleaseLinkPolicy` (app/Services/PressRelease/): systemseitige
`rel`-Auszeichnung beim Rendern — extern → `sponsored nofollow noopener`
+ `target="_blank"`; portalintern (konfigurierte Domains aus
`config/domains.php` inkl. www-Variante, relative Pfade) → **follow**;
`mailto:`/`tel:` → ohne `rel`/`target`. Autoren-`rel` wird immer
überschrieben (kein kundenseitiger Hebel). Greift in
`PressReleaseHtmlSanitizer::render()` und wirkt damit rückwirkend auf
alle gespeicherten Inhalte und auf jede Ausgabe (Panel-Vorschau heute,
Web-Detailseiten beim Relaunch).
- Editor-Hinweis (PM anlegen/bearbeiten): Links erwünscht, Auszeichnung
automatisch — bewusst ohne follow/nofollow-Auswahl im UI.
- §9-Korrektur im Decision-Update „Preisstruktur & Veröffentlichungs-Flow"
(Abschnitt 4) eingearbeitet.
**Offen (wie in §8 vorgesehen):** CTA-Box/Darstellungs-Stufung (hängt am
Boost-Konzept, 9I), Link-Obergrenze pro PM, Anchor-Text-Soft-Check
(Phase 2). Die Linktyp-Auswahl im Editor ist fürs `rel` nicht nötig
(systemseitig aus der Ziel-URL abgeleitet) — sie wird relevant, sobald die
CTA-Box als Darstellungs-Option kommt.

View file

@ -23,6 +23,7 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="LOG_CHANNEL" value="testing" force="true"/>
<env name="CLASSIFICATION_PROVIDER" value="deterministic" force="true"/>
<env name="CONTENT_SCORE_PROVIDER" value="deterministic" force="true"/>
<env name="OPENAI_API_KEY" value="" force="true"/>

View file

@ -0,0 +1,69 @@
<?php
use App\Services\Web\MarketDataService;
use App\Support\OperationalAlert;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Psr\Log\LoggerInterface;
/**
* Log-Aufteilung: je Thema eine Tagesdatei, das allgemeine Log bleibt für
* Exceptions und Unzugeordnetes. Die Suite selbst schreibt nur nach
* testing-*.log (phpunit.xml + TestCase::setUp).
*/
test('every topic channel writes its own daily file with the shared retention', function (string $channel, string $file) {
// TestCase biegt die Themen-Channels für die Suite um — hier zählt die
// ausgelieferte Konfiguration aus config/logging.php.
$config = require base_path('config/logging.php');
expect($config['channels'][$channel])
->toMatchArray([
'driver' => 'daily',
'path' => storage_path("logs/{$file}.log"),
'days' => 14,
]);
})->with([
'billing' => ['billing', 'billing'],
'ki' => ['ki', 'ki'],
'monitoring' => ['monitoring', 'monitoring'],
'market' => ['market', 'market'],
'admin_slow' => ['admin_slow', 'admin-slow'],
]);
test('the default stack rotates daily instead of growing one single file', function () {
$config = require base_path('config/logging.php');
expect($config['channels']['stack']['channels'])->toBe(['daily'])
->and($config['channels']['daily']['path'])->toBe(storage_path('logs/laravel.log'));
});
test('the test suite logs into the dedicated testing channel only', function () {
expect(config('logging.default'))->toBe('testing')
->and(config('logging.channels.testing.path'))->toBe(storage_path('logs/testing.log'));
foreach (['billing', 'ki', 'monitoring', 'market'] as $channel) {
expect(config("logging.channels.{$channel}.path"))->toBe(storage_path('logs/testing.log'));
}
});
test('operational alerts go to the monitoring channel', function () {
config()->set('monitoring.alerts.enabled', false);
$monitoringLog = Mockery::spy(LoggerInterface::class);
Log::shouldReceive('channel')->once()->with('monitoring')->andReturn($monitoringLog);
OperationalAlert::send('Bounce-Postfach konnte nicht gelesen werden');
$monitoringLog->shouldHaveReceived('critical')
->withArgs(fn (string $message): bool => str_contains($message, 'Bounce-Postfach'))
->once();
});
test('market data failures go to the market channel', function () {
Http::fake(['*' => Http::response('', 500)]);
$marketLog = Mockery::spy(LoggerInterface::class);
Log::shouldReceive('channel')->with('market')->andReturn($marketLog);
app(MarketDataService::class)->refresh();
$marketLog->shouldHaveReceived('warning')->atLeast()->once();
});

View file

@ -6,16 +6,18 @@ use Illuminate\Contracts\Queue\Job;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Psr\Log\LoggerInterface;
test('an alert always logs critical and mails the configured recipient', function () {
config()->set('monitoring.alerts.enabled', true);
config()->set('monitoring.alerts.mail_to', 'ops@example.com');
Mail::fake();
Log::spy();
$monitoringLog = Mockery::spy(LoggerInterface::class);
Log::shouldReceive('channel')->with('monitoring')->andReturn($monitoringLog);
OperationalAlert::send('Geplanter Task fehlgeschlagen: billing:generate-manual-invoices', ['exit_code' => 1]);
Log::shouldHaveReceived('critical')->once();
$monitoringLog->shouldHaveReceived('critical')->once();
Mail::assertSent(OperationalAlertMail::class, function (OperationalAlertMail $mail) {
return $mail->hasTo('ops@example.com')
&& str_contains($mail->summary, 'billing:generate-manual-invoices')

View file

@ -23,5 +23,23 @@ abstract class TestCase extends BaseTestCase
config('database.default'),
));
}
$this->routeTopicLogChannelsToTestingLog();
}
/**
* Die Themen-Channels (`Log::channel('billing')` usw.) umgehen den
* Default-Channel aus phpunit.xml und würden sonst in die echten
* Themen-Dateien unter storage/logs schreiben. Alles, was die Suite loggt,
* gehört nach testing-YYYY-MM-DD.log. `admin_slow` bleibt außen vor, weil
* seine Tests den Pfad selbst setzen bzw. prüfen.
*/
private function routeTopicLogChannelsToTestingLog(): void
{
$testing = config('logging.channels.testing');
foreach (['billing', 'ki', 'monitoring', 'market'] as $channel) {
config()->set("logging.channels.{$channel}", $testing);
}
}
}