- dev/roadmap.md neu: alle offenen Punkte aus Plänen, Audits und Protokollen, gegen Code und Git abgeglichen (Stand v1.7.0) - Abgeschlossenes ins Archiv: 2026-09-01 → archive/2026-09-nutzergewinnung, 2026-09-11-audit → archive/2026-09-audit, lagacy-rechnungen → archive/legacy-rechnungen, Deploy-Protokoll v1.1.0, Umami-Snippets, Go-Live-Roadmap vom 20.07. - Aktiv bleiben eigene Ordner bildrechte/ (Plan Fassung 2, Decision-Update) und changelog/ (Decision-Update Changelog) - dev/README.md als Ordnerkarte neu, Verweise und zwei Code-Kommentare auf die neuen Pfade Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
430 lines
24 KiB
Markdown
430 lines
24 KiB
Markdown
# Deploy-Runbook — Pressekonto (Laravel-System)
|
||
|
||
**Stand:** 09.07.2026 · **Gilt für:** das neue Laravel-System (Hub pressekonto + Spokes presseecho/businessportal24).
|
||
`FTP-LIVE-DEPLOY.md` betrifft nur das Alt-Symfony-System und ist für dieses System **nicht** anwendbar.
|
||
|
||
> Referenz-Plan: `dev/archive/release/entwicklungsplan-release.md` (R5.2). Externe Voraussetzungen
|
||
> (Stripe-Live-Keys, echte Mail-/Kontaktadressen, Favicons, finale Rechtstexte) siehe dort.
|
||
|
||
---
|
||
|
||
## 1. Server-Voraussetzungen
|
||
|
||
- PHP 8.4 (FPM) mit Extensions: `pdo_mysql`, `mbstring`, `intl`, `gd` (Bildvarianten), `curl`, `zip`, `exif`, `imagick`
|
||
- `imagick` trägt die Herkunftsprüfung (IPTC/XMP) und den KI-Vorab-Check der Bilder. Fehlt es, laufen beide ins Leere, ohne etwas zu blockieren. Live fehlte es bis 08.10.2026. Einrichtung: `sudo apt install php8.4-imagick`, dann `sudo service php8.4-fpm reload` und `php artisan queue:restart`. Prüfen: `php -m | grep -i imagick` und Admin ▸ Reports ▸ Betriebsüberwachung, Zeile „PHP-Erweiterung Imagick“.
|
||
- MySQL/MariaDB (Prod: `DB_CONNECTION=mysql`; SQLite ist nur Dev)
|
||
- Node.js (LTS) + npm — nur zum Bauen der Assets (Builds sind **gitignored**, siehe Schritt 3)
|
||
- Webserver-vHosts für **alle drei Domains** auf dasselbe `public/` (Host-Routing macht die App)
|
||
- HTTPS-Zertifikate für alle drei Domains
|
||
- Prozess-Manager (systemd/Supervisor) für den Queue-Worker
|
||
|
||
**Wichtig (SEO):** Keine statische `public/robots.txt` anlegen — robots.txt und sitemap.xml
|
||
sind dynamische, host-aware Laravel-Routen; eine echte Datei würde sie verschatten.
|
||
|
||
## 2. `.env` (Produktion) — Solltabelle
|
||
|
||
Basis: `.env.example` kopieren und anpassen. Abweichend von den Dev-Defaults zwingend:
|
||
|
||
| Key | Prod-Wert |
|
||
|---|---|
|
||
| `APP_ENV` | `production` |
|
||
| `APP_DEBUG` | `false` |
|
||
| `APP_KEY` | `php artisan key:generate` (einmalig) |
|
||
| `APP_URL` / `APP_PORTAL_*` / `APP_PRESSEECHO_*` / `APP_BUSINESSPORTAL_*` | echte Domains (pressekonto.de, presseecho.de, businessportal24.com) |
|
||
| `SANCTUM_STATEFUL_DOMAINS` | echte Domains, kommasepariert |
|
||
| `DB_CONNECTION` + `DB_*` | MySQL-Zugangsdaten |
|
||
| `MAIL_MAILER` + `MAIL_*` | echter Mailer — **Default `log` versendet nichts!** |
|
||
| `MAIL_FROM_ADDRESS` | echte Absenderadresse |
|
||
| `LOG_STACK` | `daily` (der Default `single` wächst unbegrenzt; `daily` rotiert mit `LOG_DAILY_DAYS`, Default 14) |
|
||
| `MONITORING_ALERT_EMAIL` | Ops-Postfach für Betriebs-Alerts (R5.3); leer = Fallback auf `LEGAL_CONTACT_EMAIL` |
|
||
| `SESSION_SECURE_COOKIE` | `true` |
|
||
| `BASIC_AUTH_ENABLED` | Staging: `true` + User/Passwort · Live: `false` |
|
||
| `BILLING_ENFORCE_BOOKING` | `true` (Default) |
|
||
| `STRIPE_KEY/SECRET/WEBHOOK_SECRET` + `STRIPE_PRICE_SINGLE_PM` | **Live**-Werte [EXTERN R1.2] |
|
||
| `BILLING_OWN_VAT_ID` | echte USt-IdNr. |
|
||
| `OPENAI_API_KEY` | Prod-Key (ohne ihn keine Klassifikation/kein Score) |
|
||
| `CONTACT_EMAIL_*` | echte Empfängeradressen [EXTERN R2.1] |
|
||
| `GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI` | Live-OAuth-Client (Redirect-URI exakt) |
|
||
|
||
## 3. Build & Release-Schritte
|
||
|
||
```bash
|
||
# 1) Code auslegen (git pull / Release-Verzeichnis)
|
||
composer install --no-dev --optimize-autoloader
|
||
|
||
# 2) Assets bauen (Builds sind gitignored — auf dem Server oder in CI bauen
|
||
# und public/build/ mit ausliefern)
|
||
npm ci
|
||
npm run build # baut portal + web (public/build/portal, public/build/web)
|
||
|
||
# 3) Storage-Symlink (Firmenlogos, PM-Bilder)
|
||
php artisan storage:link
|
||
|
||
# 4) Datenbank
|
||
php artisan migrate --force
|
||
|
||
# 5) Seeder — NUR die Stammdaten-Seeder. DatabaseSeeder ist prod-sicher
|
||
# (Demo-User nur in local/testing), aber explizit ist besser:
|
||
php artisan db:seed --class=RolesAndPermissionsSeeder --force
|
||
php artisan db:seed --class=AdminPresetSeeder --force
|
||
php artisan db:seed --class=PlanSeeder --force
|
||
php artisan db:seed --class=PaymentOptionSeeder --force
|
||
php artisan db:seed --class=LegalDocumentSeeder --force
|
||
php artisan db:seed --class=RejectionCategorySeeder --force
|
||
|
||
# 5a) Nach frischem Seeding (Erst-Einrichtung oder DB-Reset): Stripe-Produkte/
|
||
# Preise neu verknüpfen — die Price-IDs stehen in der plans-Tabelle:
|
||
# php artisan billing:sync-stripe-plans
|
||
|
||
# 6) Caches bauen (bei JEDEM Deploy neu)
|
||
php artisan config:cache
|
||
php artisan route:cache
|
||
php artisan view:cache
|
||
php artisan event:cache
|
||
|
||
# 7) Queue-Worker neu starten (lädt neuen Code)
|
||
php artisan queue:restart
|
||
```
|
||
|
||
### 3a. Achtung: Migrationen auf `press_releases` (FULLTEXT erzwingt Tabellen-Kopie)
|
||
|
||
`press_releases` trägt zwei FULLTEXT-Indizes (`press_releases_admin_search_fulltext`
|
||
auf title+keywords, `press_releases_web_search_fulltext` auf title+subtitle+text+keywords).
|
||
Deshalb lehnt MySQL für **jede Spaltenänderung** ALGORITHM=INSTANT **und** INPLACE ab
|
||
("InnoDB presently supports one FULLTEXT index creation at a time") und fällt auf
|
||
ALGORITHM=COPY zurück — bei importiertem Bestand (~175k Zeilen, schwere Textspalten)
|
||
lief das auf Dev mit ~30 Zeilen/s, also **1,5 h+ pro ALTER** (beobachtet 08./09.07.2026
|
||
bei der KI-Kennzeichnungs-Migration). Nach dem Legacy-Import gilt das auf Prod genauso.
|
||
|
||
Bewährter Ablauf für solche Migrationen (Dev-erprobt, gesamt ~6 Min statt Stunden):
|
||
|
||
```sql
|
||
-- 1) FULLTEXT-Indizes droppen (Suche ist solange offline!)
|
||
ALTER TABLE press_releases DROP INDEX press_releases_admin_search_fulltext;
|
||
ALTER TABLE press_releases DROP INDEX press_releases_web_search_fulltext;
|
||
|
||
-- 2) Migration laufen lassen: php artisan migrate --force
|
||
-- (Spalten-ALTER geht jetzt per INPLACE in ~40 s; INSTANT bleibt wegen
|
||
-- der versteckten FTS_DOC_ID-Spalte auch ohne Indizes gesperrt)
|
||
|
||
-- 3) Indizes NACHEINANDER neu aufbauen (InnoDB baut nur einen FULLTEXT gleichzeitig;
|
||
-- Dev-Zeiten: admin ~40 s, web ~4,5 Min)
|
||
ALTER TABLE press_releases ADD FULLTEXT press_releases_admin_search_fulltext (title, keywords);
|
||
ALTER TABLE press_releases ADD FULLTEXT press_releases_web_search_fulltext (title, subtitle, text, keywords);
|
||
```
|
||
|
||
Dazu beachten:
|
||
|
||
- **Wartungsfenster einplanen**: zwischen Schritt 1 und 3 liefern Admin- und Web-Suche
|
||
Fehler/keine Treffer. Alternativ die volle COPY-Dauer akzeptieren (Suche bleibt online,
|
||
Schreibzugriffe auf PMs sind währenddessen aber gesperrt).
|
||
- **DB-Backup vorher** (gilt ohnehin, siehe Abschnitt 9).
|
||
- Solche Migrationen **wiederaufnehmbar** schreiben (`Schema::hasColumn`-Guards, kein
|
||
`->after()`), Vorlage: `2026_07_08_172023_add_ai_declaration_to_press_releases.php`.
|
||
Hintergrund: Laravel legt pro Spalte ein eigenes ALTER ab — bricht der Prozess
|
||
mittendrin ab, ist die Migration halb angewandt und ohne Guards nicht wiederholbar.
|
||
- Einen laufenden COPY-ALTER **nicht clientseitig killen**: der Server-Thread läuft als
|
||
Zombie weiter und Laravel reiht das Statement nach `KILL <id>` per Lost-Connection-Retry
|
||
erneut ein — es stauen sich doppelte ALTERs am Metadata-Lock. Falls doch nötig:
|
||
in MySQL `SHOW PROCESSLIST` prüfen und **alle** betroffenen Threads killen.
|
||
- Beim allerersten Go-Live ist das unkritisch: `php artisan migrate` läuft dort **vor**
|
||
dem Legacy-Import auf leeren Tabellen (Reihenfolge aus Abschnitt 7 einhalten).
|
||
|
||
## 4. Queue-Worker (Pflicht)
|
||
|
||
Die KI-Jobs (`ClassifyPressRelease`, `ScorePressRelease`) laufen auf der
|
||
database-Queue **`classification`** — ohne Worker bleibt jede eingereichte PM
|
||
in der Prüfung hängen.
|
||
|
||
systemd-Beispiel (`/etc/systemd/system/pressekonto-worker.service`):
|
||
|
||
```ini
|
||
[Unit]
|
||
Description=Pressekonto Queue Worker
|
||
After=network.target
|
||
|
||
[Service]
|
||
User=www-data
|
||
Restart=always
|
||
ExecStart=/usr/bin/php /var/www/pressekonto/artisan queue:work database \
|
||
--queue=classification,default --tries=3 --max-time=3600
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
Zeitbudgets (Audit F17, 18.09.2026): Die KI-Jobs (`ClassifyPressRelease`,
|
||
`ScorePressRelease`) tragen `$timeout = 120`, weil drei HTTP-Versuche à 30 s
|
||
die 60 s des Worker-Defaults sprengen. `retry_after` der Datenbank-Queue
|
||
steht deshalb auf 150 (`config/queue.php`, überschreibbar per
|
||
`DB_QUEUE_RETRY_AFTER`). Wer einen zweiten Worker startet, hält
|
||
`retry_after` über dem größten Job-Timeout — sonst greift ein Worker den
|
||
noch laufenden Job des anderen.
|
||
|
||
## 5. Scheduler / Cron (Pflicht)
|
||
|
||
Crontab des Web-Users:
|
||
|
||
```cron
|
||
* * * * * cd /var/www/pressekonto && php artisan schedule:run >> /dev/null 2>&1
|
||
```
|
||
|
||
Aktive Tasks (`php artisan schedule:list`, Stand 29.09.2026 — maßgeblich ist
|
||
`routes/console.php`; `ScheduleConfigurationTest` prüft Sperrdauer und
|
||
Minutenkollisionen):
|
||
|
||
| Zeitplan | Task | Ohne Cron passiert … |
|
||
|---|---|---|
|
||
| jede Minute | `press-releases:aggregate-views` | Aufrufzahlen bleiben stehen (Ereignisse sammeln sich) |
|
||
| alle 5 Min | `press-releases:publish-scheduled` | **geplante PMs werden nie veröffentlicht** |
|
||
| alle 10 Min | `mail:fetch-bounces` | Rückläufer werden nicht gesperrt |
|
||
| alle 15 Min | `market:refresh` | Börsendaten im Ticker veralten |
|
||
| täglich 03:00 | `magic-links:purge --days=30` | Token-Tabelle wächst |
|
||
| täglich 03:10 | `snapshots:purge` | Schwärzungs-Snapshots bleiben über 30 Tage liegen |
|
||
| täglich 03:20 | `archive:purge` | Beweisarchiv hält Einträge über die Frist |
|
||
| täglich 03:25 | `conversion:purge-events` | Konversionsereignisse und `email_bounces` über 395 Tage |
|
||
| täglich 03:30 | `archive:export-tombstones` | Restore-Abgleich ohne aktuellen Löschstand |
|
||
| täglich 03:35 | `archive:retry-pending` | offene Archivaufträge werden nicht nachgeholt |
|
||
| täglich 03:40 | `archive:sweep-orphans` | verwaiste Archivdateien bleiben (Quarantäne statt Löschen) |
|
||
| täglich 03:50 | `press-releases:purge-events` | Aufruf-, Crawler- und Feed-Protokolle über 395 Tage |
|
||
| täglich 04:15 | `billing:expire-grandfathering` | abgelaufene Bestandsschutz-Verträge laufen weiter |
|
||
| täglich 04:20 | `mail:check-domains --stale --limit=500` | DNS-Befunde der Kontaktdomains veralten |
|
||
| täglich 04:30 | `billing:generate-manual-invoices` | manuelle Rechnungen fehlen |
|
||
| täglich 05:10 | `press-releases:check-index-status` | Indexierungsbericht bekommt keine neuen Daten |
|
||
| So 04:00 | `press-releases:purge-drafts --days=180` | Alt-Entwürfe bleiben liegen |
|
||
| So 04:45 | `press-release-images:sweep-orphan-originals --execute --hours=48` | Originale abgebrochener Uploads bleiben liegen |
|
||
| monatl. 1. 00:05 | `press-releases:reset-monthly-quota` | **Kontingente werden nie zurückgesetzt** |
|
||
|
||
Jeder Task hält seine `withoutOverlapping()`-Sperre nur kurz über der
|
||
erwarteten Laufzeit (Audit K-09). Nach einem Absturz mitten im Lauf gibt
|
||
spätestens dann der nächste Lauf wieder frei; sofort mit
|
||
`php artisan schedule:clear-cache`.
|
||
|
||
**Monitoring (R5.3):** Jeder Scheduler-Task hat ein `->onFailure()`, der
|
||
Queue-Worker einen `Queue::failing()`-Listener. Fehlschläge werden als
|
||
`critical` ins Tageslog geschrieben **und** an `MONITORING_ALERT_EMAIL`
|
||
gemailt (Fallback `LEGAL_CONTACT_EMAIL`; abschaltbar über
|
||
`MONITORING_ALERTS_ENABLED=false`). Die Mail ist Best-Effort — der Log-Eintrag
|
||
ist die verlässliche Spur, daher `LOG_STACK=daily` setzen.
|
||
|
||
## 6. Stripe-Webhook
|
||
|
||
Endpoint in Stripe (Live-Mode) registrieren: `https://pressekonto.de/stripe/webhook`
|
||
→ Signing-Secret als `STRIPE_WEBHOOK_SECRET` in die `.env`.
|
||
Events: die Cashier-Defaults (Subscriptions, Invoices, Payment-Intents).
|
||
|
||
## 7. Erst-Einrichtung (nur beim allerersten Go-Live)
|
||
# Bilddaten per rsync kopieren (Zugangsdaten stehen im Passwortmanager, nicht im Repository)
|
||
|
||
rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/presseecho.de/web/thumbnails/ \
|
||
dev/migration/presseecho/thumbnails/
|
||
|
||
|
||
rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/presseecho.de/web/uploads/ \
|
||
dev/migration/presseecho/uploads/
|
||
|
||
|
||
|
||
rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/businessportal24.com/web/thumbnails/ \
|
||
dev/migration/businessportal24/thumbnails/
|
||
|
||
|
||
rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/businessportal24.com/web/uploads/ \
|
||
dev/migration/businessportal24/uploads/
|
||
|
||
|
||
1. Legacy-Datenimport (Reihenfolge beachten, read-only-Quellen konfiguriert):
|
||
`legacy:import` → `legacy:fix-timestamps` → `legacy:migrate-media`
|
||
(bzw. `legacy:sync-company-logos` / `legacy:sync-press-release-images`)
|
||
→ `legacy:archive-invoices` → `legacy:grandfather-subscriptions`.
|
||
2. Agentur-Attribution (läuft NACH dem Firmen-/User-Import aus Schritt 1,
|
||
Reihenfolge beachten): `legacy:migrate-agency-names` →
|
||
`companies:retire-agency-type`. Beide erst mit `--dry-run` (Reports unter
|
||
`storage/app/private/migration/` prüfen, insbes. Mehrfach-Namen,
|
||
Owner-lose Agentur-Firmen und geschützte Fälle), dann scharf.
|
||
Namen werden nie überschrieben; gelöscht wird nur soft und nur ohne
|
||
PMs/Kontakte/Dritt-Mitgliedschaften/Zahlungsoptions-Verknüpfung.
|
||
3. Ersten Admin-User anlegen (Prod hat keine Demo-User): normal registrieren
|
||
(inkl. E-Mail-Verifizierung), dann Rolle via Tinker setzen —
|
||
`syncRoles` ersetzt dabei alle bisherigen Rollen des Users:
|
||
|
||
```bash
|
||
php artisan tinker --execute 'App\Models\User::where("email", "mail@example.com")->firstOrFail()->syncRoles(["admin"]);'
|
||
```
|
||
4. Favicons je Domain deployen (Platzhalter-Generator reicht nicht) [EXTERN R5.4].
|
||
5. Smoke-Test: siehe Abschnitt 8.
|
||
|
||
|
||
1. Medien migrieren
|
||
|
||
# Erst prüfen, ob die Ordnerstruktur stimmt (zählt "Datei fehlt"/"Ziel fehlt")
|
||
php artisan legacy:migrate-media --portal=all --dry-run
|
||
|
||
# Dann scharf (kopiert + erzeugt Bildvarianten — dauert bei der Menge eine Weile)
|
||
php artisan legacy:migrate-media --portal=all
|
||
Kleine „Datei fehlt"-Restmengen sind bei Altbeständen normal (verwaiste DB-Einträge); große Zahlen heißen Ordnerproblem — dann melde dich mit der Ausgabe.
|
||
|
||
2. PM-Medien privatisieren (Pflicht, direkt danach)
|
||
|
||
php artisan press-releases:privatize-media
|
||
Verschiebt die PM-Bilder von der public- auf die private-Disk (Security-Hardening). Muss nach migrate-media laufen — und nach jedem späteren Medien-Lauf erneut.
|
||
|
||
3. Timestamps korrigieren (bewusst NACH den Medien)
|
||
|
||
php artisan legacy:fix-timestamps
|
||
Setzt created_at/updated_at/published_at authoritativ aus den Legacy-DBs. Nach den Medien deshalb, weil die Medien-Migration die Datensätze anfasst und updated_at neu stempelt — der Fix räumt das wieder auf. Wichtig fürs Frontend: Die Sortierung hängt an published_at.
|
||
|
||
4. Satelliten-Migrationen (jeweils erst --dry-run, dann scharf)
|
||
|
||
php artisan legacy:import-api-keys --dry-run # lokal waren es 406 Keys — Zahl vergleichen
|
||
php artisan legacy:archive-invoices --dry-run
|
||
php artisan legacy:grandfather-subscriptions --dry-run # MUSS nach archive-invoices (leitet sich aus dem Rechnungsarchiv ab)
|
||
php artisan legacy:import-billing-addresses --dry-run # Report: Kunden mit leeren/kaputten Alt-Adressen → manuell nachpflegen
|
||
php artisan legacy:migrate-agency-names --dry-run
|
||
php artisan companies:retire-agency-type --dry-run # NACH migrate-agency-names
|
||
Die Reports landen unter storage/app/private/migration/ — besonders bei den letzten beiden reinschauen (Mehrfach-Namen, Owner-lose Agentur-Firmen), bevor du sie scharf laufen lässt.
|
||
|
||
5. Das Gate: Gesamtverifikation
|
||
|
||
php artisan legacy:verify
|
||
JSON-Report prüfen → das ist deine Go/No-Go-Entscheidung für den Cutover. Wenn du magst, reich mir den Report hier rein, ich gehe ihn mit dir durch.
|
||
|
||
Danach: Stichproben im Browser
|
||
Newsroom-Übersicht, eine alte PM-Detailseite (lädt Bild + Anhang?), Firmenlogo, Archiv — und ein Blick in Admin ▸ Reports ▸ Betriebsüberwachung (alle Kacheln grün, besonders „Medien-Privatisierung").
|
||
|
||
|
||
## 8. Smoke-Test nach jedem Deploy
|
||
|
||
- [ ] Alle drei Domains laden (Hub-Landing, 2× Spoke-Home) über HTTPS
|
||
- [ ] `/robots.txt` + `/sitemap.xml` je Domain liefern hostkorrekte Inhalte
|
||
- [ ] Login am Portal funktioniert; `/admin` erreichbar
|
||
- [ ] Eine Test-PM einreichen → Klassifikation läuft durch (Worker-Check)
|
||
- [ ] Kontaktformular + Widerrufs-/Kündigungsformular versenden Mails (echter Mailer!)
|
||
- [ ] Stripe-Checkout im Live-Mode mit kleiner Testbuchung + Webhook-Eingang prüfen
|
||
- [ ] `storage/logs/laravel.log` auf Fehler prüfen
|
||
|
||
## 9. Rollback
|
||
|
||
- Code: vorheriges Release auschecken → Schritte 6–7 aus Abschnitt 3 wiederholen
|
||
(`config:cache` … `queue:restart`).
|
||
- Migrationen sind additiv gehalten; kein automatisches `migrate:rollback` in Prod —
|
||
im Fehlerfall DB-Backup einspielen (Backup **vor** jedem Deploy mit Migrationen!).
|
||
- **Nach jedem DB-Restore — Beweisarchiv in dieser Reihenfolge** (Umsetzungsaudit
|
||
29.09.2026, A-03/A-06/A-15):
|
||
1. **Sofort** `php artisan archive:sweep-orphans --pause` (der Sweep läuft
|
||
nachts um 03:40; nach einem DB-Restore fehlen jüngeren Einträgen die Zeilen,
|
||
ihre Payloads sähen verwaist aus). Seit 29.09. verschiebt der Sweep ohnehin nur
|
||
in die Quarantäne (`storage/app/archive/quarantine/JJJJ-MM-TT/…`,
|
||
`ARCHIVE_QUARANTINE_DAYS`, Standard 30 Tage) — die Pause verhindert auch das.
|
||
Harte Alternative ohne DB: `ARCHIVE_SWEEP_PAUSED=true` + `config:cache`.
|
||
2. `php artisan archive:reconcile-after-restore` — liest den täglich exportierten
|
||
Tombstone-Stand (`tombstones/latest.json`, seit 29.09. mit HMAC) **und** die
|
||
Tombstone-Zeilen der Datenbank, entfernt wiederhergestellte Payloads erneut und
|
||
löscht die mit dem Backup zurückgekommenen Kopien in der Anwendung
|
||
(Bestätigungen, Korrekturen, weich gelöschte Bildzeile) erneut. Setzt den
|
||
Sweep selbst aus. Zuordnung über den Payload-Pfad, nicht die ID: passt eine
|
||
Export-Zeile zu einer DB-Zeile mit gleicher ID, aber anderem Payload, wird
|
||
nichts angefasst („passen nicht zur Datenbank", Exit ≠ 0, Log prüfen).
|
||
„Signatur ungültig" = Export verworfen, nur DB-Tombstones abgeglichen,
|
||
Exit ≠ 0 — Export-Herkunft prüfen; fehlt ein Schlüssel in
|
||
`ARCHIVE_PREVIOUS_KEYS`? Ein **unsignierter** Export wird seit 02.10.2026
|
||
ebenfalls verworfen (ein entferntes `hmac` sähe genauso aus); nur wenn er
|
||
nachweislich von vor dem 29.09.2026 stammt, mit `--accept-unsigned`
|
||
wiederholen. Exit ≠ 0 sonst: mindestens eine Datei ließ sich nicht löschen
|
||
— Log prüfen, Lauf wiederholen.
|
||
3. `php artisan archive:reregister --dry-run`, dann `php artisan archive:reregister`
|
||
— legt für Payloads ohne Zeile (Einträge nach dem Backup-Zeitpunkt, auch aus der
|
||
Quarantäne) die Zeile aus `manifest.json` wieder an. Laut Tombstone-Export
|
||
gelöschte Payloads bleiben außen vor. **Bricht ab**, wenn der Export ungültig
|
||
oder unsigniert ist (wie Schritt 2, `--accept-unsigned` nur für Altexporte)
|
||
oder ganz fehlt — ohne Export lassen sich gelöschte Einträge nicht von
|
||
verlorenen unterscheiden; nur bewusst mit `--without-export`. Legal Hold
|
||
steht nicht im Manifest: wiederhergestellte Einträge (Zusammenfassung
|
||
`reregistered_at`) im Admin prüfen.
|
||
4. `php artisan archive:retry-pending` (offene Archivaufträge nachholen; Zähler im
|
||
Admin-Systemstatus) und `php artisan archive:sweep-orphans --dry-run`.
|
||
5. `php artisan archive:sweep-orphans --resume` — Kachel „Beweisarchiv-Orphan-Sweep"
|
||
im Systemstatus wird wieder grün.
|
||
- **Achtung bei Restore der Archiv-Disk:** Wird `storage/app/archive` selbst auf
|
||
einen älteren Stand zurückgesetzt, ist auch `tombstones/latest.json` alt. Dann
|
||
**vor** dem Abgleich den jüngsten Tombstone-Export von außerhalb des Hosts
|
||
einspielen (Kopie aus dem letzten Backup-Lauf *nach* dem letzten `archive:purge`)
|
||
oder sicherstellen, dass die Datenbank den aktuellen Stand hat — der Abgleich
|
||
nimmt dann die DB-Tombstones. Ein automatisches externes Exportziel ist nicht
|
||
gebaut (Betreiberentscheidung offen, Umsetzungsaudit 25.09.2026, 5.1 c).
|
||
- `ARCHIVE_KEY` (Beweisarchiv) und `CONFIRMATION_IP_KEY` liegen nur in der
|
||
Ploi-Umgebung. `ARCHIVE_KEY` getrennt vom DB-Backup sichern — ohne ihn ist das
|
||
Archiv unlesbar; erzeugen mit `php artisan archive:key`. Fehlt er (z. B. nach
|
||
`config:cache` ohne Schlüssel), meldet der Systemstatus „Beweisarchiv-Schlüssel"
|
||
kritisch und `archive:retry-pending` bricht ab, ohne Aufträge anzufassen.
|
||
- **Schlüsselrotation `ARCHIVE_KEY`** (A-15): neuen Schlüssel mit
|
||
`php artisan archive:key` erzeugen und getrennt sichern; den bisherigen Wert
|
||
**vorne** in `ARCHIVE_PREVIOUS_KEYS` (kommagetrennt) eintragen, den neuen als
|
||
`ARCHIVE_KEY` setzen, `config:cache`, `queue:restart`. Jeder Eintrag trägt die
|
||
ID seines Schlüssels (`archive_entries.key_id`); alte Einträge bleiben mit dem
|
||
früheren Schlüssel lesbar, neue werden mit dem neuen verschlüsselt. Einen
|
||
früheren Schlüssel nur entfernen, wenn der Systemstatus keine Einträge mit
|
||
unbekanntem Schlüssel meldet (praktisch: erst nach Ablauf aller damit
|
||
verschlüsselten Einträge). Anwaltsexporte werden mit einem per HKDF abgeleiteten
|
||
Schlüssel signiert; `php artisan archive:verify-export <zip>` prüft neue und alte
|
||
Exporte (vor dem 29.09. mit dem Archivschlüssel signiert) gegen alle bekannten
|
||
Schlüssel.
|
||
|
||
## 10. Betrieb nach Go-Live (Stand 20.07.2026 — System ist LIVE)
|
||
|
||
Live-Setup: Hetzner-Cloud-Server via **Ploi**, App unter `~/pressekonto.com`,
|
||
Deploys liefen bis `v1.1.0` per **SFTP-Datei-Upload**. **Seit `v1.1.3` (06.10.2026) per Git-Tag**
|
||
mit `dev/betrieb/deploy.sh`; Ablauf, einmalige Umstellung und Rollback in
|
||
`dev/betrieb/git-deploy.md`. Die SFTP-Checkliste unten ist historisch. Auf
|
||
Live nichts mehr per SFTP in `~/pressekonto.com` ablegen, auch keine Doku, sonst
|
||
stoppt der nächste Deploy an der Abweichungsprüfung. Echte Domains
|
||
(pressekonto.com, presseecho.de, businessportal24.com) inkl. www-DNS geschaltet,
|
||
Stripe im Live-Mode, `legacy:verify` 0 failures. Migration/Cutover: erledigt,
|
||
Doku archiviert unter `dev/archive/optimize/cutover-runbook.md`.
|
||
|
||
### SFTP-Deploy-Checkliste (jedes Mal)
|
||
|
||
0. **Version:** Ausgeliefert wird ein Git-Tag nach SemVer (`v1.1.0`; Feature-Deploy
|
||
mittlere Stelle, Hotfix letzte). Lokal `git describe --tags --always --dirty > RELEASE`
|
||
und die Datei `RELEASE` mit hochladen — daraus lesen `config('app.version')`,
|
||
GlitchTip, `/health` und der Systemstatus die Version. Ablauf eines Deploys mit
|
||
Paket statt Einzeldateien: `dev/archive/betrieb/deploy-v1.1.0.md`.
|
||
1. Geänderte Dateien hochladen. Bei geändertem `composer.lock`:
|
||
`composer install --no-dev --optimize-autoloader`, danach — falls
|
||
`public/vendor/livewire/` existiert — `php artisan vendor:publish --tag=livewire:assets --force`.
|
||
2. `php artisan view:clear && php artisan view:cache` — **immer bei
|
||
Blade-/Volt-Änderungen** (auch Livewire-Komponenten sind Blade-Dateien).
|
||
3. `php artisan config:cache` — nur bei `.env`- oder `config/`-Änderungen.
|
||
4. `php artisan migrate --force` — nur wenn neue Migrationen dabei sind
|
||
(vorher DB-Backup).
|
||
5. `php artisan queue:restart` — **immer, wenn PHP-Dateien dabei waren.**
|
||
Der Worker ist ein Daemon und hält alten Code im Speicher; ohne Restart
|
||
laufen Jobs (KI-Prüfung, Mails) mit dem alten Stand.
|
||
|
||
### Betriebs-Erkenntnisse aus der Go-Live-Woche
|
||
|
||
- **Worker-Queues:** Der Worker MUSS `--queue=classification,default` fahren
|
||
(Abschnitt 4) — sonst bleiben die KI-Jobs liegen und eingereichte PMs
|
||
hängen unbemerkt in der Prüfung.
|
||
- **Nach MySQL-Neustarts** hängen Worker mitunter in kaputten Verbindungen →
|
||
Worker in Ploi neu starten. Fehlgeschlagene Jobs danach **nicht pauschal**
|
||
mit `queue:retry all` wiederholen: erst `php artisan queue:failed` ansehen
|
||
und nur aktuelle Jobs gezielt per `queue:retry <uuid>` neu starten. Alte
|
||
Jobs tragen den Stand ihrer Klasse von damals — am 06.10.2026 scheiterten so
|
||
Veröffentlichungsmails vom 03./08.09. an einer seither erweiterten Vorlage
|
||
(GlitchTip #23), und erfolgreich wären sie als veraltete Mail rausgegangen.
|
||
- **Läuft kein Worker** (Systemstatus „Queue-Worker“ kritisch, `/health` 503,
|
||
Ende von `deploy.sh` mit Exit 3): `ps -eo pid,lstart,args | grep queue:work`.
|
||
Leer → in Ploi Supervisor und Site ▸ Queue neu starten. Notlösung ohne
|
||
sudo: `nohup php artisan queue:work database --queue=classification,default --tries=3 --max-time=3600 >> storage/logs/worker-manual.log 2>&1 &`
|
||
(endet nach einer Stunde von selbst).
|
||
- **Öffentliche Seitendaten** laufen über Stale-while-revalidate-Caching
|
||
(`App\Support\WebPageCache`, `Cache::flexible`) — nach `cache:clear` ist der
|
||
jeweils erste Aufruf pro Seite langsam (Kaltaufbau), danach dauerhaft schnell.
|
||
- **www-Hosts** werden zweifach behandelt: `RedirectWwwToApex`-Middleware
|
||
(301 auf die Apex-Domain) plus Host-Normalisierung in `DomainAssetContext`
|
||
als Sicherheitsnetz. Ploi kann keine Host-Redirects (nur Pfad-Redirects).
|
||
- **Betriebsüberwachung:** Admin ▸ Reports ▸ Betriebsüberwachung (Queue-Worker,
|
||
Medien-Privatisierung u. a.) regelmäßig prüfen; Fehlerlog unter
|
||
`storage/logs/laravel.log`.
|
||
- Offene Punkte und nächste Entwicklungen: `dev/roadmap.md`.
|