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:
parent
5122624687
commit
85d7c2b0d5
21 changed files with 338 additions and 107 deletions
11
.env.example
11
.env.example
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"/>
|
||||
|
|
|
|||
69
tests/Feature/LogChannelsTest.php
Normal file
69
tests/Feature/LogChannelsTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue