docs: update design doc + add infrastructure runbook

- Remove Ollama as AI backend (never used); Mistral is primary throughout
- Add infrastructure.md: full Docker setup, queue architecture, prod
  deployment steps, env vars — prod has no staging ERP container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-19 05:59:21 +00:00
parent bf1af0a0bf
commit 371213dbbb
2 changed files with 560 additions and 24 deletions

View file

@ -29,8 +29,8 @@ src/
# Orchestriert Domain über Interfaces (Ports) # Orchestriert Domain über Interfaces (Ports)
Infrastructure/ Infrastructure/
Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter] Channel/ # EbayAdapter, FrappeErpAdapter, [FutureAdapter]
AI/ # OllamaClient, MistralClient (beide hinter OllamaClientInterface) AI/ # MistralClient hinter OllamaClientInterface (Interfacename historisch)
# OllamaVisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent # VisionAgent, SpecsResearchAgent, JsonCodingAgent, EbayTextAgent
Persistence/ # Doctrine Repositories (PostgreSQL) Persistence/ # Doctrine Repositories (PostgreSQL)
Logging/ # DatabaseLogHandler, ArchiveCommand Logging/ # DatabaseLogHandler, ArchiveCommand
Http/ # Symfony Controller, Webhook-Listener, EasyAdmin Http/ # Symfony Controller, Webhook-Listener, EasyAdmin
@ -38,7 +38,7 @@ src/
Storage/ # StorageManager Storage/ # StorageManager
``` ```
Jeder externe Dienst (eBay, Frappe ERP, Ollama, SMTP) implementiert ein Interface aus dem Application-Layer. Neue Plattform = neue Adapter-Klasse, Domain bleibt unberührt. Jeder externe Dienst (eBay, Frappe ERP, Mistral, SMTP) implementiert ein Interface aus dem Application-Layer. Neue Plattform = neue Adapter-Klasse, Domain bleibt unberührt.
### 2.2 Tech-Stack ### 2.2 Tech-Stack
@ -51,7 +51,7 @@ Jeder externe Dienst (eBay, Frappe ERP, Ollama, SMTP) implementiert ein Interfac
| Tests | PHPUnit 11 + Pest | | Tests | PHPUnit 11 + Pest |
| Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) | | Datenbank | PostgreSQL 17 (eine Instanz, drei Schemas: `app`, `logs`, `logs_archive`) |
| Cache / Queue | Redis 7 | | Cache / Queue | Redis 7 |
| AI | **Ollama** (lokal, SSH-Tunnel + autossh) **oder Mistral Cloud API** — per Alias in `services.yaml` umschaltbar; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar | | AI | **Mistral Cloud API** — Vision: `pixtral-12b-2409`, Text: `mistral-large-latest`; Modelle via `AI_TEXT_MODEL` / `AI_VISION_MODEL` env vars konfigurierbar |
| Web-Suche | Tavily API (`TAVILY_API_KEY`) — liefert strukturierte Suchergebnisse für SpecsResearchAgent | | Web-Suche | Tavily API (`TAVILY_API_KEY`) — liefert strukturierte Suchergebnisse für SpecsResearchAgent |
| Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) | | Admin-Panel | EasyAdmin 5 (`#[AdminDashboard]` Attribut, `type: easyadmin.routes` Loader) |
| Auth | Symfony Security + scheb/two-factor-bundle (TOTP) | | Auth | Symfony Security + scheb/two-factor-bundle (TOTP) |
@ -71,7 +71,7 @@ redis://...?queue_name=orders # Order-Processing, CustomerResolver, Invo
redis://...?queue_name=channel_sync # Listing-Publish, Bestand-Sync, Deaktivierung, Tracking redis://...?queue_name=channel_sync # Listing-Publish, Bestand-Sync, Deaktivierung, Tracking
``` ```
**Ollama-Ausfall:** AI-Pipeline-Jobs verbleiben in der Queue. Symfony Messenger wiederholt mit Backoff, nach 3 Versuchen → Failed Transport (persistent, nicht verloren). Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via `messenger:failed:retry` wenn Ollama wieder verfügbar. **AI-Backend-Ausfall:** AI-Pipeline-Jobs verbleiben in der Queue. Symfony Messenger wiederholt mit Backoff, nach 3 Versuchen → Failed Transport (persistent, nicht verloren). Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via `messenger:failed:retry` wenn das AI-Backend wieder erreichbar ist.
--- ---
@ -216,14 +216,14 @@ ApiKey
``` ```
1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline) 1. Foto-Upload → PhotoUploadMessage in Queue (redis://ai_pipeline)
2. OllamaVisionAgent — LLaVA liest Typenschild 2. VisionAgent — Mistral Pixtral liest Typenschild
Output: Hersteller, Modellname, Modellnummer, Seriennummer Output: Hersteller, Modellname, Modellnummer, Seriennummer
3. Model-Cache-Check — findCompletedByModelNumber() in DB 3. Model-Cache-Check — findCompletedByModelNumber() in DB
Treffer → copy ebayTitle/ebayDescription/specsText/attributes → Schritt 6 (kein AI mehr) Treffer → copy ebayTitle/ebayDescription/specsText/attributes → Schritt 6 (kein AI mehr)
Kein Treffer → weiter mit Schritt 4 Kein Treffer → weiter mit Schritt 4
4. SpecsResearchAgent — Tavily-Suche mit Modellbezeichnung → vollständige Specs (Freitext) 4. SpecsResearchAgent — Tavily-Suche mit Modellbezeichnung → vollständige Specs (Freitext)
Pflichtfeld-Liste kommt aus ArticleType.AttributeDefinitions ({{fields}}-Platzhalter im Prompt) Pflichtfeld-Liste kommt aus ArticleType.AttributeDefinitions ({{fields}}-Platzhalter im Prompt)
5. JsonCodingAgent — strukturierter Ollama-Call: Specs-Text → JSON gegen ArticleType-Schema 5. JsonCodingAgent — strukturierter Mistral-Call: Specs-Text → JSON gegen ArticleType-Schema
6. ValidationGate — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder) 6. ValidationGate — alle Pflichtfelder gesetzt? (Schema + eBay-Kategorie-Pflichtfelder)
✓ → Schritt 7 ✓ → Schritt 7
✗ → Retry ab Schritt 5 mit missing_fields im Prompt (max. 3×) ✗ → Retry ab Schritt 5 mit missing_fields im Prompt (max. 3×)
@ -352,7 +352,7 @@ Schema: logs_archive.log_entry # identische Struktur
### Dienste ### Dienste
- **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser) - **PostgreSQL:** App-User mit minimalen Rechten (kein Superuser)
- **Redis:** `requirepass` gesetzt, nur intern erreichbar - **Redis:** `requirepass` gesetzt, nur intern erreichbar
- **Ollama:** lokal, Zugriff nur via SSH-Tunnel (autossh für Persistenz + Auto-Reconnect) - **Mistral API:** Key in `.env.local`, nie in Git; HTTPS-only
- **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth - **Gitea:** Registrierung deaktiviert, nur Admin-Anlage, SSH-Key-Auth
### Applikation ### Applikation
@ -440,31 +440,21 @@ docker compose exec app php bin/console app:api-keys:create --env=prod
Der Key wird als bcrypt-Hash gespeichert. Prefix (erste 8 Zeichen) dient als Lookup-Key. Der Key wird als bcrypt-Hash gespeichert. Prefix (erste 8 Zeichen) dient als Lookup-Key.
Verwendung: `X-Api-Key: <rawKey>` HTTP-Header. Verwendung: `X-Api-Key: <rawKey>` HTTP-Header.
### 11.3 AI-Backend wechseln ### 11.3 AI-Konfiguration
**Ollama (Standard, lokal):** **Primär: Mistral Cloud API** (Standard, bereits so konfiguriert):
```yaml ```yaml
# config/services.yaml # config/services.yaml
App\Infrastructure\AI\OllamaClientInterface:
alias: App\Infrastructure\AI\OllamaClient
```
```dotenv
# .env
AI_TEXT_MODEL=${OLLAMA_TEXT_MODEL}
AI_VISION_MODEL=${OLLAMA_VISION_MODEL}
```
**Mistral Cloud API:**
```yaml
App\Infrastructure\AI\OllamaClientInterface: App\Infrastructure\AI\OllamaClientInterface:
alias: App\Infrastructure\AI\MistralClient alias: App\Infrastructure\AI\MistralClient
``` ```
```dotenv ```dotenv
AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL} # .env.local
AI_VISION_MODEL=${MISTRAL_VISION_MODEL}
MISTRAL_API_KEY=sk-... MISTRAL_API_KEY=sk-...
TAVILY_API_KEY=tvly-...
# Modelle in .env vorbelegt: mistral-large-latest / pixtral-12b-2409
``` ```
Vision erfordert Pixtral (`pixtral-12b-2409`). Nach Änderung: `cache:clear` + FPM reload. Nach Schlüsseländerung: `docker compose exec app php bin/console cache:clear`.
### 11.4 Pipeline starten ### 11.4 Pipeline starten

View file

@ -0,0 +1,546 @@
# SuperSeller3000 — Infrastruktur & Deployment
**Zuletzt aktualisiert:** 2026-05-19
**Zielumgebung:** VPS (Ubuntu/Debian), Docker Compose
**Domain:** `ss3k.schaunwama.de`
> Dieses Dokument beschreibt den vollständigen Stand der Docker-Infrastruktur und alle Schritte, um das System auf einem neuen Server hochzuziehen.
---
## 1. Übersicht der Services
| Service | Image / Build | Zweck |
|---|---|---|
| `app` | Build `docker/app/Dockerfile` | PHP 8.4-FPM — Symfony-Applikation |
| `caddy` | `caddy:2-alpine` | Reverse Proxy, Auto-HTTPS (Let's Encrypt) |
| `postgres` | `postgres:17-alpine` | Datenbank (Schemas: `app`, `logs`, `logs_archive`) |
| `redis` | `redis:7-alpine` | Queue-Backend + Session-Cache |
| `worker-ai` | Build `docker/app/Dockerfile` | Messenger Worker — Transport `ai_pipeline` |
| `worker-orders` | Build `docker/app/Dockerfile` | Messenger Worker — Transport `orders` |
| `worker-channel` | Build `docker/app/Dockerfile` | Messenger Worker — Transport `channel_sync` |
| `cron` | Build `docker/app/Dockerfile` | Log-Rotation täglich (`app:logs:rotate`) |
**Nicht im Prod-Setup:** kein Staging-ERP-Container. Frappe ERP läuft extern (eigene Instanz, `FRAPPE_ERP_BASE_URL` in `.env.local`).
---
## 2. Queue-Architektur (Symfony Messenger + Redis Streams)
Drei isolierte Redis-Streams — ein ausgefallener Worker blockiert die anderen nie.
### Transports & Retry-Strategie
| Transport | Redis Stream | Worker | max_retries | Delay / Backoff |
|---|---|---|---|---|
| `ai_pipeline` | `ai_pipeline` | `worker-ai` | 3 | 2 s · ×2 |
| `orders` | `orders` | `worker-orders` | 5 | 1 s · ×2 |
| `channel_sync` | `channel_sync` | `worker-channel` | 5 | 2 s · ×2 · max 60 s |
| `failed` | `failed` | — | — | persistent, nie verloren |
Nach Erschöpfung aller Retries landet die Message im `failed`-Transport. Replay: `messenger:failed:retry`.
### Messages pro Transport
#### `ai_pipeline``worker-ai`
Die Pipeline-Schritte werden sequenziell als separate Messages dispatcht — jeder Handler dispatcht den Nachfolger.
| Message | Handler-Logik |
|---|---|
| `PhotoUploadMessage` | Startet Pipeline A: `VisionAgent` (Mistral Pixtral) liest Typenschild → Hersteller, Modell, SN |
| `SpecsResearchMessage` | `SpecsResearchAgent`: Tavily-Suche mit Modellbezeichnung → Specs-Freitext; Pflichtfelder aus `ArticleType.AttributeDefinitions` (`{{fields}}`-Platzhalter im Prompt) |
| `JsonCodingMessage` | `JsonCodingAgent`: Specs-Text → strukturiertes JSON gegen ArticleType-Schema |
| `ValidationMessage` | Pflichtfelder vollständig? → weiter; sonst Retry ab `JsonCodingMessage` (max. 3×, `missing_fields` im Prompt) → `needs_review` |
| `DraftArticleMessage` | Article anlegen (status: `draft`), Inventurnummer vergeben, Attribute + Foto persistieren |
| `EbayTextMessage` | `EbayTextAgent`: eBay-Titel + Beschreibung aus Attributen generieren, an Article speichern |
| `PxeInventoryMessage` | Startet Pipeline B (PXE): PXE-Dump direkt an `JsonCodingAgent` — SpecsResearch entfällt |
**Model-Cache:** Nach `PhotoUploadMessage` prüft `findCompletedByModelNumber()` die DB. Treffer → alle Daten kopieren, `SpecsResearch`/`JsonCoding`/`EbayText` überspringen.
**Fehlerfall:** `PipelineJobFailureListener` fängt `WorkerMessageFailedEvent` ab und setzt `AIPipelineJob.status = failed` mit dem echten Fehlertext.
#### `orders``worker-orders`
| Message | Handler-Logik |
|---|---|
| `OrderReceivedMessage` | Vollständiger 13-Schritt-Order-Flow: Idempotenz-Check → atomarer Inventory-Lock (`stock - 1`) → `CustomerResolver` (Matching-Kaskade) → Order anlegen → Frappe Sales Invoice → PDF abrufen → `StorageManager` → Invoice-Record → PDF per SMTP an Lieferant → `UpdateStockOnChannelsMessage` oder `DeactivateListingMessage` dispatchen → Order: `completed` |
#### `channel_sync``worker-channel`
| Message | Handler-Logik |
|---|---|
| `PublishToChannelMessage` | `ChannelAdapterRegistry` → richtiger Adapter (z.B. `EbayAdapter`) → `publishListing()``ebay_listing_id` an Article zurückschreiben |
| `UpdateStockOnChannelsMessage` | `updateStock()` auf allen aktiven Plattformen des Artikels |
| `DeactivateListingMessage` | `deactivateListing()` — wird ausgelöst wenn `stock = 0` nach Verkauf |
| `TrackingPushMessage` | `pushTracking()` → Plattform als versandt markieren → `tracking_pushed_to_ebay_at` setzen |
### AI-Backend-Ausfall
AI-Worker-Messages verbleiben in der Queue. Messenger wiederholt mit Backoff, nach 3 Versuchen → `failed`-Transport. Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via `messenger:failed:retry` wenn das AI-Backend wieder erreichbar ist.
---
## 3. Netzwerk
Alle Services kommunizieren über ein internes Docker-Bridge-Network. Nur Caddy exponiert Ports nach außen.
```yaml
networks:
default:
driver: bridge
ipam:
config:
- subnet: 172.18.0.0/24
gateway: 172.18.0.1
```
Das Gateway `172.18.0.1` ist die Host-Adresse innerhalb des Docker-Netzes (erreichbar von allen Containern).
---
## 4. docker-compose.yml (Prod)
```yaml
services:
app:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
- ./docker/app/zz-fpm-pool.conf:/usr/local/etc/php-fpm.d/zzz-pool.conf:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- path: .env
required: true
- path: .env.local
required: false
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
- caddy_data:/data
depends_on:
- app
postgres:
image: postgres:17-alpine
env_file: .env
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 5s
retries: 5
worker-ai:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
command: php bin/console messenger:consume ai_pipeline --time-limit=3600 --memory-limit=256M
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
depends_on:
- postgres
- redis
env_file:
- path: .env
required: true
- path: .env.local
required: false
restart: unless-stopped
worker-orders:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
command: php bin/console messenger:consume orders --time-limit=3600 --memory-limit=256M
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
depends_on:
- postgres
- redis
env_file:
- path: .env
required: true
- path: .env.local
required: false
restart: unless-stopped
worker-channel:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
command: php bin/console messenger:consume channel_sync --time-limit=3600 --memory-limit=256M
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
depends_on:
- postgres
- redis
env_file:
- path: .env
required: true
- path: .env.local
required: false
restart: unless-stopped
cron:
build:
context: .
dockerfile: docker/app/Dockerfile
user: "1000:1000"
environment:
HOME: /tmp
command: >
sh -c "while true; do
php bin/console app:logs:rotate;
sleep 86400;
done"
volumes:
- .:/var/www
- ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
depends_on:
- postgres
env_file:
- path: .env
required: true
- path: .env.local
required: false
restart: unless-stopped
volumes:
postgres_data:
redis_data:
caddy_data:
networks:
default:
driver: bridge
ipam:
config:
- subnet: 172.18.0.0/24
gateway: 172.18.0.1
```
**Hinweis:** `docker-compose.override.yml` existiert nur für lokale Entwicklung (Port-Bindings für Postgres/Redis zum direkten Zugriff vom Host). Auf dem Prod-Server nicht verwenden.
---
## 4. Dockerfile — `docker/app/Dockerfile`
```dockerfile
FROM php:8.4-fpm-alpine
RUN apk add --no-cache \
postgresql-dev \
icu-dev \
libzip-dev \
unzip \
git \
$PHPIZE_DEPS \
&& docker-php-ext-install \
pdo_pgsql \
intl \
zip \
opcache \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del $PHPIZE_DEPS
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
COPY docker/app/php.ini /usr/local/etc/php/conf.d/app.ini
```
---
## 5. PHP-Konfiguration
### `docker/app/php.ini`
```ini
opcache.enable=0
memory_limit=256M
upload_max_filesize=20M
post_max_size=20M
```
`opcache.enable=0` weil der Dev-Build keinen Preload hat — in Prod kann auf `1` + `opcache.preload` umgestellt werden, wenn gewünscht.
### `docker/app/zz-fpm-pool.conf`
```ini
[www]
; SSE connections hold a worker for up to 90 s each — raise the pool ceiling
; so regular requests are not starved.
pm = dynamic
pm.max_children = 30
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
```
---
## 6. Caddyfile (Prod) — `docker/caddy/Caddyfile`
```
{
admin off
}
ss3k.schaunwama.de {
root * /var/www/public
php_fastcgi app:9000
file_server
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
}
}
```
**Prod enthält keinen `erpstaging`-Block.** Frappe ERP läuft auf einer eigenen Instanz (extern, `FRAPPE_ERP_BASE_URL` in `.env.local`).
> Aktueller Dev-Stand hat noch den `erpstaging.schaunwama.de`-Block für die Staging-ERP-Weiterleitung — der muss vor dem ersten Prod-Deploy entfernt werden.
---
## 7. Umgebungsvariablen
### `.env` (ohne Credentials, in Git)
```dotenv
APP_ENV=prod
APP_SECRET=change_me_in_env_local
POSTGRES_DB=superseller
POSTGRES_USER=superseller
POSTGRES_PASSWORD=change_me
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?serverVersion=17&charset=utf8"
REDIS_PASSWORD=change_me
REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
MESSENGER_TRANSPORT_DSN=redis://:${REDIS_PASSWORD}@redis:6379/messages
MAILER_DSN=smtp://localhost
TAVILY_API_KEY=
MISTRAL_BASE_URL=https://api.mistral.ai
MISTRAL_API_KEY=
MISTRAL_VISION_MODEL=pixtral-12b-2409
MISTRAL_TEXT_MODEL=mistral-large-latest
AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL}
AI_VISION_MODEL=${MISTRAL_VISION_MODEL}
EBAY_CLIENT_ID=
EBAY_CLIENT_SECRET=
EBAY_MARKETPLACE_ID=EBAY_DE
EBAY_API_BASE_URL=https://api.ebay.com
EBAY_OAUTH_BASE_URL=https://api.ebay.com
EBAY_VERIFICATION_TOKEN=
EBAY_ENDPOINT_URL=https://ss3k.schaunwama.de/webhooks/ebay
FRAPPE_ERP_BASE_URL=https://erp.example.com
FRAPPE_ERP_API_KEY=changeme
FRAPPE_ERP_API_SECRET=changeme
FRAPPE_GENERIC_ITEM_CODE=REFURB-HW
SUPPLIER_EMAIL=lieferant@example.com
SENDER_EMAIL=noreply@superseller3000.de
```
### `.env.local` (nur auf Server, nie in Git)
Alle echten Credentials überschreiben hier die Platzhalter aus `.env`:
```dotenv
APP_SECRET=<random-32-char-hex>
POSTGRES_PASSWORD=<sicheres-passwort>
REDIS_PASSWORD=<sicheres-passwort>
MAILER_DSN=smtp://user:pass@mailserver:587
TAVILY_API_KEY=tvly-...
MISTRAL_API_KEY=sk-...
EBAY_CLIENT_ID=...
EBAY_CLIENT_SECRET=...
EBAY_VERIFICATION_TOKEN=... # aus eBay Developer Portal
FRAPPE_ERP_BASE_URL=https://erp.meinefirma.de
FRAPPE_ERP_API_KEY=...
FRAPPE_ERP_API_SECRET=...
SUPPLIER_EMAIL=lieferant@meinefirma.de
SENDER_EMAIL=noreply@meinefirma.de
```
---
## 8. VPS-Voraussetzungen
```bash
# Docker + Compose Plugin
apt install docker.io docker-compose-plugin
# User 1000 muss existieren (PHP läuft als 1000:1000)
id -u superseller # sollte 1000 sein; ggf. anpassen
# UFW: nur Caddy-Ports + SSH
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
---
## 9. Erste Inbetriebnahme (Schritt für Schritt)
```bash
# 1. Repo klonen
git clone <gitea-url>/superseller3000 /home/superseller/SuperSeller3000
cd /home/superseller/SuperSeller3000
# 2. .env.local mit echten Credentials anlegen (siehe Abschnitt 7)
nano .env.local
# 3. Caddyfile prüfen — kein erpstaging-Block!
# (docker/caddy/Caddyfile muss nur den ss3k.schaunwama.de-Block enthalten)
# 4. Images bauen + DB hochfahren
docker compose up -d postgres redis
# warten bis postgres healthy
# 5. Composer install
docker compose run --rm app composer install --no-dev --optimize-autoloader
# 6. Datenbankmigrationen ausführen
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
# 7. Cache aufwärmen
docker compose run --rm app php bin/console cache:warmup --env=prod
# 8. Ersten Admin-User anlegen
docker compose run --rm app php bin/console app:users:create --env=prod
# 9. Ersten API-Key anlegen (Klartext wird nur einmal angezeigt)
docker compose run --rm app php bin/console app:api-keys:create --env=prod
# 10. Alle Services starten
docker compose up -d
# 11. eBay-Webhook registrieren (URL in .env.local: EBAY_ENDPOINT_URL)
# Im eBay Developer Portal: Notification URL = https://ss3k.schaunwama.de/webhooks/ebay
# Verification Token = EBAY_VERIFICATION_TOKEN
```
---
## 10. Laufender Betrieb
### Worker-Status prüfen
```bash
docker compose ps
docker compose logs -f worker-ai
docker compose logs -f worker-orders
docker compose logs -f worker-channel
```
### Failed Messages (nach Erschöpfung aller Retries)
```bash
# Übersicht
docker compose exec app php bin/console messenger:failed:show
# Einzelne Message erneut versuchen
docker compose exec app php bin/console messenger:failed:retry
# Alle nochmal
docker compose exec app php bin/console messenger:failed:retry --all
```
### Updates deployen
```bash
git pull
docker compose build app worker-ai worker-orders worker-channel cron
docker compose run --rm app composer install --no-dev --optimize-autoloader
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
docker compose run --rm app php bin/console cache:warmup --env=prod
docker compose up -d
```
### Logs (Admin-Panel)
`https://ss3k.schaunwama.de/admin`**Log Entries** — Fulltext-Suche, Filter nach Level/Channel/Zeitraum.
Rotation läuft täglich automatisch (Cron-Service): Einträge > 90 Tage → `logs_archive`, danach gelöscht.
### Backup
```bash
# PostgreSQL-Dump
docker compose exec postgres pg_dump -U superseller superseller | gzip > backup_$(date +%Y%m%d).sql.gz
# Artikel-Fotos und Rechnungs-PDFs (Docker Volumes)
docker run --rm -v superseller3000_postgres_data:/data -v $(pwd):/backup \
alpine tar czf /backup/volume_postgres_$(date +%Y%m%d).tar.gz /data
```
---
## 11. AI-Konfiguration
### Primäres Backend: Mistral Cloud API
Standardkonfiguration — `config/services.yaml` bereits so ausgeliefert:
```yaml
App\Infrastructure\AI\OllamaClientInterface:
alias: App\Infrastructure\AI\MistralClient
```
`.env.local`:
```dotenv
MISTRAL_API_KEY=sk-...
# Modelle sind in .env vorbelegt:
# AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL} → mistral-large-latest
# AI_VISION_MODEL=${MISTRAL_VISION_MODEL} → pixtral-12b-2409
```
Web-Suche (SpecsResearchAgent): **Tavily API**`TAVILY_API_KEY=tvly-...` in `.env.local`.
Nach Schlüsseländerung: `docker compose exec app php bin/console cache:clear`.