Compare commits

...
3 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 29f59adc6b feat: Kommando zum Nachschlagen der Google-Place-ID
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m37s
Die Place-ID steht nicht in der Google Cloud Console — dort gibt es nur
den API-Schluessel. Sie gehoert zum Eintrag bei Google Maps und muss
ueber die Places API nachgeschlagen werden.

  pnpm places:find "Wabenhain Altenmünster"

gibt Name, Anschrift und GOOGLE_PLACE_ID je Treffer aus.

Das Kommando ist zugleich die schnellste Probe auf die Einrichtung:
kommt eine ID heraus, sind "Places API (New)", Abrechnung und Schluessel
richtig gesetzt und die Startseite kann die Rezensionen abrufen. Kommt
eine Ablehnung, nennt sie die drei tatsaechlich moeglichen Ursachen
(API nicht aktiviert, keine Abrechnung, OAuth-Client statt
API-Schluessel bzw. Referrer-Beschraenkung) — statt wie Google pauschal
auf den Schluessel zu zeigen.

Die Feldmaske fordert nur id, displayName und formattedAddress an: die
API rechnet nach angeforderten Feldern ab.

tsc: 0 Fehler. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:32:05 +02:00
Nick SzaunigandClaude Opus 5 6639da130e fix: Google-Rezensionen auf "Places API (New)" umgestellt
Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid",
obwohl der Schluessel keinerlei Einschraenkung traegt.

Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt
maps.googleapis.com/maps/api/place/details/json auf, und Google laesst
die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten
Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht
den Schluessel, sondern die API — wer der Meldung folgt, sucht
zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als
"Legacy-Hinweis" bereits vorhergesagt.

Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des
Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen
landet) und einer engen X-Goog-FieldMask — die API rechnet nach
angeforderten Feldern ab.

Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es
tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am
Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt.

Zwei Verhaltensaenderungen, beide unvermeidbar:
  - Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf
    Rezensionen in Googles eigener Reihenfolge.
  - Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne
    Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte
    Zuordnung zur Quelle.

README und .env.example nennen jetzt alle vier noetigen Schritte und
weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den
Abruf blockiert — er laeuft serverseitig.

Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt
einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt
je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen
60 der voreingestellt 100 PostgreSQL-Verbindungen.

tsc: 0 Fehler. vitest: 336/336 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:16:19 +02:00
Nick SzaunigandClaude Opus 5 9b10a9f0c5 perf: Checkout haelt gleichzeitigem Andrang stand
Ausgangsfrage: Was passiert, wenn 20 oder 100 Leute zugleich bestellen?
20 waren unproblematisch, 100 nicht. Drei Engpaesse, alle im Checkout.

1. Serialisierungskonflikte landeten ungefiltert bei der Kundschaft

Die Bestellanlage laeuft unter "Serializable". Bricht die Datenbank eine
von zwei widerspruechlichen Transaktionen ab (P2034), ist das die
VORGESEHENE Antwort auf Gleichzeitigkeit und kein Sachfehler — erwartet
wird, dass der Aufrufer es noch einmal versucht. Genau das fehlte: der
Abbruch wurde direkt als 409 durchgereicht ("bitte in ein paar Sekunden
erneut versuchen"). Weil alle Bestellungen desselben Produkts auf
dieselbe Lagerbestandszeile zugreifen, sind solche Konflikte bei Andrang
der Normalfall — ein Teil der Kundschaft haette ausgerechnet im Moment
der Kaufentscheidung eine Fehlermeldung bekommen.

Jetzt bis zu vier Versuche mit wachsender, zufaellig gestreuter
Wartezeit. Ohne den Zufallsanteil starten alle abgebrochenen Anfragen
gleichzeitig neu und kollidieren wieder. Wiederholt wird ausschliesslich
dieser Fehlerfall: "ausverkauft" und "Gutschein aufgebraucht" sind
Sachentscheidungen und faenden beim zweiten Versuch dasselbe vor.

2. Zu enger Verbindungspool, zu knappe Transaktionsfristen

Der Postgres-Adapter lief mit der Voreinstellung von node-postgres: 10
Verbindungen. Eine Bestellung haelt ihre Verbindung ueber die gesamte
Transaktion, und Prisma gibt einer Transaktion nur 2 Sekunden, um
ueberhaupt eine zu bekommen (maxWait), sowie 5 Sekunden zum Ausfuehren.
Sind alle zehn belegt, scheitern weitere Checkouts nicht an der Sache,
sondern am Warten.

Pool auf 20 (PostgreSQL erlaubt voreingestellt 100 Verbindungen, daneben
brauchen CronJob, Sicherung und Konsole noch welche; die Anwendung laeuft
mit einer Instanz). maxWait 10s, timeout 15s — Puffer fuer die
Warteschlange, nicht fuer lange Rechnungen. Dazu eine Zeitgrenze beim
Holen einer Verbindung, damit sich unter Last nichts mehr lautlos
aufstaut.

Der MariaDB-Zweig bleibt unveraendert: sein Adapter nimmt entweder die
Verbindungszeichenkette ODER ein Einstellungsobjekt, nicht beides.
Produktiv laeuft PostgreSQL.

3. Drei Regressionstests

Wiederholung fuehrt zum Erfolg, nach vier Versuchen wird aufgegeben, und
bei "ausverkauft" wird NICHT wiederholt.

tsc: 0 Fehler. vitest: 336/336 gruen (zuvor 333). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:10:41 +02:00
9 changed files with 456 additions and 75 deletions

No files matched your search

+5 -1
View File
@@ -62,7 +62,11 @@ SITE_ADDRESS=
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite # Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
# automatisch die statischen Beispiel-Stimmen als Fallback. # automatisch die statischen Beispiel-Stimmen als Fallback.
# API-Key: Google-Cloud-Projekt mit aktivierter "Places API". # API-Key: Google-Cloud-Projekt mit aktivierter "Places API (New)" — NICHT
# die gleichnamige alte API, die in neuen Projekten nicht mehr aktivierbar
# ist. Ausserdem noetig: Abrechnung am Projekt aktiv, und der Wert muss ein
# API-Schluessel sein (ein OAuth-Client funktioniert nicht). Keine
# Referrer-Beschraenkung setzen — der Abruf erfolgt serverseitig.
# Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id # Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id
GOOGLE_PLACES_API_KEY= GOOGLE_PLACES_API_KEY=
GOOGLE_PLACE_ID= GOOGLE_PLACE_ID=
+26 -8
View File
@@ -209,22 +209,40 @@ Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag),
Die Bewertungen-Sektion der Startseite zeigt echte Google-Rezensionen, sobald `GOOGLE_PLACES_API_KEY` (Secret) und `GOOGLE_PLACE_ID` (ConfigMap) gesetzt sind. Ohne diese Werte greifen die statischen Beispiel-Stimmen. Die Bewertungen-Sektion der Startseite zeigt echte Google-Rezensionen, sobald `GOOGLE_PLACES_API_KEY` (Secret) und `GOOGLE_PLACE_ID` (ConfigMap) gesetzt sind. Ohne diese Werte greifen die statischen Beispiel-Stimmen.
**Einrichtung:** Google-Cloud-Projekt anlegen, darin die **Places API** aktivieren, einen API-Schlüssel erzeugen und ihn auf diese eine API einschränken. Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher nicht; sinnvoll ist eine IP-Beschränkung auf die ausgehende Adresse des Clusters. Die Place-ID des eigenen Eintrags findet sich über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id). **Verwendet wird die `Places API (New)`** (`places.googleapis.com/v1`), nicht der alte `place/details`-Endpunkt. Das ist keine Geschmacksfrage: Google lässt die alte „Places API" in seit März 2025 angelegten Cloud-Projekten gar nicht mehr aktivieren.
**Fehler werden jetzt im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen: **Einrichtung — alle vier Schritte sind nötig:**
1. **API aktivieren:** In der Cloud Console unter *APIs & Dienste → Bibliothek* nach `Places API (New)` suchen und aktivieren. Es gibt dort zwei fast gleich heißende Einträge — die alte „Places API" ist die falsche, und die „Maps JavaScript API" hilft hier ebenfalls nicht.
2. **Abrechnung aktivieren:** Ohne hinterlegtes Rechnungskonto antwortet auch die neue API mit einem Berechtigungsfehler. Das kostenlose Kontingent deckt diese Nutzung (ein Abruf pro Stunde) bei Weitem ab.
3. **API-Schlüssel erzeugen:** *APIs & Dienste → Anmeldedaten → Anmeldedaten erstellen → API-Schlüssel*. Ein **OAuth-Client ist etwas anderes** und funktioniert hier nicht — er ist für Anmeldungen im Namen eines Nutzers gedacht, nicht für Serverabrufe.
4. **Werte setzen:** Schlüssel als `GOOGLE_PLACES_API_KEY` (Secret), Place-ID als `GOOGLE_PLACE_ID` (ConfigMap).
**Die Place-ID steht nicht in der Cloud Console.** Sie gehört zum Eintrag bei Google Maps, nicht zum Projekt. Mit gesetztem Schlüssel liefert sie dieses Kommando:
```bash
pnpm places:find "Wabenhain Altenmünster"
```
Es gibt Name, Anschrift und `GOOGLE_PLACE_ID=…` je Treffer aus — und ist zugleich die schnellste Probe auf die Einrichtung: kommt eine ID heraus, stimmen API, Abrechnung und Schlüssel. Andernfalls benennt es die Ursache. Ohne Schlüssel geht es auch über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id).
Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher **nicht** und würde den Abruf blockieren. Sinnvoll ist stattdessen eine API-Beschränkung auf „Places API (New)" und optional eine IP-Beschränkung auf die ausgehende Adresse des Clusters.
**Fehler werden im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen:
| Meldung im Log | Bedeutung | | Meldung im Log | Bedeutung |
|----------------|-----------| |----------------|-----------|
| `REQUEST_DENIED` | Schlüssel ungültig, oder die Places API ist im Projekt nicht aktiviert | | `… API … not enabled` / `SERVICE_DISABLED` | „Places API (New)" ist im Projekt nicht aktiviert |
| `INVALID_REQUEST` | Parameter falsch | | `The provided API key is invalid` | Meist: alter Endpunkt oder alte API — bei dieser Fassung des Codes eher ein tatsächlich falscher Schlüssel |
| `NOT_FOUND` / `ZERO_RESULTS` | Die Place-ID passt zu keinem Eintrag | | `billing … not enabled` | Kein Rechnungskonto am Projekt |
| `OVER_QUERY_LIMIT` | Kontingent erschöpft oder keine Abrechnung hinterlegt | | `HTTP 404` | Die Place-ID passt zu keinem Eintrag |
| `API keys with referer restrictions …` | Der Schlüssel ist auf HTTP-Referrer beschränkt; für Serverabrufe untauglich |
Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die Kosten niedrig hält, und nach fünf Sekunden abgebrochen, damit eine nicht antwortende Google-API nicht die Startseite aufhält. Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die Kosten niedrig hält, und nach fünf Sekunden abgebrochen, damit eine nicht antwortende Google-API nicht die Startseite aufhält.
**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) werden übersprungen, sie wären sonst leere Sprechblasen. **Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) oder ohne Namen werden übersprungen: die einen wären leere Sprechblasen, den anderen fehlte die verlangte Quellenangabe.
> **Legacy-Hinweis:** Genutzt wird der klassische `place/details`-Endpunkt. Google führt ihn als *Legacy* und stellt neue Projekte auf die Places API (New) um (`places.googleapis.com/v1`). Meldet das Log dauerhaft `REQUEST_DENIED`, obwohl Schlüssel und Abrechnung stimmen, ist das der wahrscheinliche Grund — dann ist die Umstellung auf die neue API nötig. > **Hinweis zur Sortierung:** Der alte Endpunkt kannte `reviews_sort=newest`. Die neue API bietet das nicht an und liefert bis zu fünf Rezensionen in Googles eigener Reihenfolge.
## E-Mail-Versand ## E-Mail-Versand
+9
View File
@@ -44,6 +44,15 @@ data:
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere # Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
# implizites TLS. # implizites TLS.
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
#
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
# in 05-app-deployment.yaml. Drei Instanzen mit je 20 belegen 60 der
# voreingestellt 100 Verbindungen von PostgreSQL — daneben brauchen der
# Aufraeum-CronJob, die Sicherung und gelegentlich eine Konsole noch
# welche. Entweder diesen Wert senken oder max_connections anheben.
DATABASE_POOL_SIZE: "20"
SMTP_PORT: "587" SMTP_PORT: "587"
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath # Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
+1
View File
@@ -18,6 +18,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"preflight": "tsx scripts/preflight.ts", "preflight": "tsx scripts/preflight.ts",
"places:find": "tsx scripts/find-place-id.ts",
"orders:release-stale": "tsx scripts/release-stale-orders.ts" "orders:release-stale": "tsx scripts/release-stale-orders.ts"
}, },
"dependencies": { "dependencies": {
+102
View File
@@ -0,0 +1,102 @@
/**
* Sucht die Place-ID eines Google-Maps-Eintrags.
*
* Die Place-ID steht NICHT in der Google Cloud Console — dort gibt es nur den
* API-Schluessel. Sie gehoert zum Eintrag bei Google Maps und muss ueber die
* Places API nachgeschlagen werden. Genau das tut dieses Skript.
*
* Es ist zugleich der schnellste Weg, die Einrichtung zu pruefen: kommt hier
* eine ID heraus, sind API, Abrechnung und Schluessel richtig gesetzt und die
* Startseite kann die Rezensionen abrufen. Kommt stattdessen eine
* Fehlermeldung, benennt sie die Ursache — anders als die Meldung der alten
* API, die bei nicht aktivierter Schnittstelle behauptet, der Schluessel sei
* ungueltig.
*
* Aufruf:
* pnpm places:find "Wabenhain Altenmünster"
*/
import "dotenv/config";
const SEARCH_URL = "https://places.googleapis.com/v1/places:searchText";
const TIMEOUT_MS = 10_000;
interface SearchResponse {
places?: {
id?: string;
displayName?: { text?: string };
formattedAddress?: string;
}[];
error?: { message?: string; status?: string };
}
function fail(message: string): never {
console.error(`\n✖ ${message}\n`);
process.exit(1);
}
async function main(): Promise<void> {
const query = process.argv.slice(2).join(" ").trim();
if (!query) {
fail('Bitte einen Suchbegriff angeben, z.B.: pnpm places:find "Wabenhain Altenmünster"');
}
const apiKey = process.env.GOOGLE_PLACES_API_KEY?.trim();
if (!apiKey) {
fail(
"GOOGLE_PLACES_API_KEY ist nicht gesetzt. Den API-Schluessel aus der Google Cloud Console\n" +
" in die .env eintragen (Anmeldedaten → API-Schluessel, NICHT OAuth-Client)."
);
}
const response = await fetch(SEARCH_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": apiKey,
// Nur die drei Felder, die unten ausgegeben werden — die API rechnet
// nach angeforderten Feldern ab.
"X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress",
},
body: JSON.stringify({ textQuery: query, languageCode: "de" }),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const data = (await response.json().catch(() => null)) as SearchResponse | null;
if (!response.ok || data?.error) {
const detail = data?.error?.message ?? `HTTP ${response.status}`;
fail(
`Google hat die Anfrage abgelehnt: ${detail}\n\n` +
" Die drei haeufigsten Ursachen, in dieser Reihenfolge:\n" +
' 1. "Places API (New)" ist im Projekt nicht aktiviert (die gleichnamige ALTE\n' +
" API reicht nicht — sie laesst sich in neuen Projekten gar nicht mehr\n" +
" aktivieren).\n" +
" 2. Am Projekt ist keine Abrechnung hinterlegt.\n" +
" 3. Der Wert ist ein OAuth-Client statt eines API-Schluessels, oder der\n" +
" Schluessel traegt eine Referrer-Beschraenkung — dieser Abruf laeuft\n" +
" serverseitig und hat keinen Referrer."
);
}
const places = data?.places ?? [];
if (places.length === 0) {
fail(`Kein Eintrag zu "${query}" gefunden. Genauer suchen, z.B. mit Ort oder Strasse.`);
}
console.log(`\nTreffer für "${query}":\n`);
for (const place of places) {
console.log(` ${place.displayName?.text ?? "(ohne Namen)"}`);
console.log(` ${place.formattedAddress ?? "(ohne Adresse)"}`);
console.log(` GOOGLE_PLACE_ID=${place.id ?? "(ohne ID)"}\n`);
}
console.log(
"Den passenden Wert als GOOGLE_PLACE_ID eintragen — in der .env fuer lokal,\n" +
"in k8s/01-configmap.yaml fuer den Cluster.\n"
);
}
main().catch((error: unknown) => {
// Netzwerkfehler und Zeitueberschreitungen landen hier; alles Fachliche ist
// oben bereits mit einer deutlicheren Meldung abgefangen.
fail(`Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "Unbekannter Fehler"}`);
});
+53
View File
@@ -10,6 +10,7 @@
* ohne dass irgendwo ein Fehler sichtbar wurde. * ohne dass irgendwo ein Fehler sichtbar wurde.
*/ */
import { describe, test, expect, beforeEach, vi } from "vitest"; import { describe, test, expect, beforeEach, vi } from "vitest";
import { Prisma } from "@/generated/prisma/client";
const sessionsCreate = vi.fn(); const sessionsCreate = vi.fn();
const sessionsExpire = vi.fn(); const sessionsExpire = vi.fn();
@@ -215,6 +216,58 @@ describe("Checkout-Route", () => {
}); });
}); });
describe("Wenn viele gleichzeitig bestellen", () => {
/**
* Bei "Serializable" bricht die Datenbank eine von zwei widerspruechlichen
* Transaktionen ab (P2034). Das ist die vorgesehene Antwort auf
* Gleichzeitigkeit, kein Sachfehler — und weil alle Bestellungen desselben
* Produkts auf dieselbe Bestandszeile zugreifen, ist das bei Andrang der
* Normalfall. Frueher landete jeder dieser Abbrueche als Fehlermeldung bei
* der Kundschaft.
*/
function serializationConflict(): Error {
return new Prisma.PrismaClientKnownRequestError("could not serialize access", {
code: "P2034",
clientVersion: "7",
});
}
test("wiederholt die Bestellanlage und kommt durch", async () => {
const runTransaction = async (arg: unknown) =>
typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg;
db.$transaction
.mockRejectedValueOnce(serializationConflict())
.mockRejectedValueOnce(serializationConflict())
.mockImplementation(runTransaction);
const response = await post();
expect(response.status).toBe(200);
expect(db.$transaction).toHaveBeenCalledTimes(3);
expect(sessionsCreate).toHaveBeenCalledOnce();
});
test("gibt nach vier vergeblichen Versuchen auf, statt endlos zu wiederholen", async () => {
db.$transaction.mockRejectedValue(serializationConflict());
const response = await post();
expect(response.status).toBe(409);
expect(db.$transaction).toHaveBeenCalledTimes(4);
});
test("wiederholt NICHT, wenn die Ware schlicht ausverkauft ist", async () => {
// Eine Sachentscheidung faende beim zweiten Versuch dasselbe Ergebnis
// vor — die Wiederholung wuerde die Antwort nur verzoegern.
db.inventory.updateMany.mockResolvedValue({ count: 0 });
const response = await post();
expect(response.status).toBe(409);
expect(db.$transaction).toHaveBeenCalledOnce();
});
});
describe("Wenn Stripe nicht mitspielt", () => { describe("Wenn Stripe nicht mitspielt", () => {
test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => { test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => {
// Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID // Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID
+94 -8
View File
@@ -31,6 +31,42 @@ class DiscountCodeExhaustedError extends Error {}
// ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall. // ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall.
const SERIALIZATION_CONFLICT_CODE = "P2034"; const SERIALIZATION_CONFLICT_CODE = "P2034";
/**
* Wie oft eine an einem Serialisierungskonflikt gescheiterte Bestellanlage
* wiederholt wird, bevor die Kundschaft einen Fehler sieht.
*
* Ein solcher Konflikt ist KEIN Fehler, sondern die vorgesehene Antwort der
* Datenbank auf zwei gleichzeitige Transaktionen, die sich widersprechen
* koennten — bei "Serializable" bricht sie eine davon ab, damit nicht beide
* auf veralteten Staenden weiterrechnen. Erwartet wird, dass der Aufrufer es
* einfach noch einmal versucht.
*
* Genau das fehlte: der Abbruch wurde direkt als 409 an die Kundschaft
* durchgereicht ("bitte in ein paar Sekunden erneut versuchen"). Bei wenigen
* Bestellungen faellt das nie auf, bei 100 gleichzeitigen schon — alle
* greifen auf dieselbe Lagerbestandszeile zu, Konflikte sind dann der
* Normalfall und nicht die Ausnahme. Ein Teil der Kundschaft bekaeme
* ausgerechnet im Moment der Kaufentscheidung eine Fehlermeldung.
*
* Vier Versuche, weil jeder die Transaktion vollstaendig neu ausfuehrt
* (inklusive frischer Bestandspruefung) und viermal hintereinander zu
* verlieren unwahrscheinlich ist. Bleibt es dabei, ist der Andrang so gross,
* dass "gleich nochmal" die ehrliche Antwort ist.
*/
const SERIALIZATION_MAX_ATTEMPTS = 4;
/**
* Wartezeit zwischen zwei Versuchen, mit Zufallsanteil.
*
* Ohne den Zufallsanteil starten alle gleichzeitig abgebrochenen Anfragen
* ihren naechsten Versuch im selben Moment und kollidieren erneut — dasselbe
* Problem, nur verschoben.
*/
function serializationBackoffMs(attempt: number): number {
const base = 25 * 2 ** (attempt - 1);
return base + Math.random() * base;
}
/** /**
* Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24 * Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24
* Stunden zu und nimmt ohne Angabe die 24 Stunden. * Stunden zu und nimmt ohne Angabe die 24 Stunden.
@@ -49,6 +85,37 @@ const SERIALIZATION_CONFLICT_CODE = "P2034";
*/ */
const CHECKOUT_SESSION_TTL_MINUTES = 32; const CHECKOUT_SESSION_TTL_MINUTES = 32;
/**
* Fuehrt die Bestellanlage aus und wiederholt sie, wenn die Datenbank sie
* wegen eines Serialisierungskonflikts abgebrochen hat.
*
* Wiederholt wird AUSSCHLIESSLICH dieser eine Fehlerfall. "Ausverkauft" und
* "Gutschein aufgebraucht" sind Sachentscheidungen — sie faenden beim zweiten
* Versuch dasselbe Ergebnis vor, und eine Wiederholung wuerde die Antwort nur
* verzoegern.
*/
async function withSerializationRetry<T>(run: () => Promise<T>): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await run();
} catch (error: unknown) {
const isConflict =
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === SERIALIZATION_CONFLICT_CODE;
if (!isConflict || attempt >= SERIALIZATION_MAX_ATTEMPTS) throw error;
// Auf "info" statt "error": das ist der vorgesehene Ablauf, kein
// Stoerfall. Sichtbar bleibt es trotzdem, weil eine Haeufung hier das
// erste Anzeichen dafuer waere, dass der Andrang die Datenbank an ihre
// Grenze bringt.
console.info(
`[checkout] Serialisierungskonflikt bei der Bestellanlage (Versuch ${attempt} von ${SERIALIZATION_MAX_ATTEMPTS}), wird wiederholt.`
);
await new Promise((resolve) => setTimeout(resolve, serializationBackoffMs(attempt)));
}
}
}
function generateOrderNumber(): string { function generateOrderNumber(): string {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
// Kryptografisch zufaellig statt Math.random(): nicht erratbar und // Kryptografisch zufaellig statt Math.random(): nicht erratbar und
@@ -263,13 +330,17 @@ export async function POST(request: Request) {
// Leute abbrechen. // Leute abbrechen.
const cancelToken = randomBytes(32).toString("hex"); const cancelToken = randomBytes(32).toString("hex");
let order; // Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany
try { // (quantity >= bestellt) verhindert Ueberverkauf auch bei gleichzeitigen
// Bestellanlage und Bestandsreservierung atomar: das bedingte // Bestellungen — schlaegt eine Position fehl, wird die gesamte Transaktion
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei // (inkl. bereits reservierter Positionen) verworfen.
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die //
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen. // Als eigene Funktion, damit sie bei einem Serialisierungskonflikt
order = await prisma.$transaction( // vollstaendig wiederholt werden kann (siehe SERIALIZATION_MAX_ATTEMPTS).
// Jeder Versuch liest den Bestand frisch — eine Wiederholung kann also
// durchaus zu "ausverkauft" fuehren, und das ist dann auch richtig so.
const runOrderTransaction = () =>
prisma.$transaction(
async (tx) => { async (tx) => {
for (const item of orderItemsData) { for (const item of orderItemsData) {
const reserved = await tx.inventory.updateMany({ const reserved = await tx.inventory.updateMany({
@@ -340,8 +411,23 @@ export async function POST(request: Request) {
// gleichzeitigen Transaktionen, die beide denselben knapp begrenzten // gleichzeitigen Transaktionen, die beide denselben knapp begrenzten
// Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab // Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab
// (statt beide auf Basis veralteter Zaehlerstaende durchzulassen). // (statt beide auf Basis veralteter Zaehlerstaende durchzulassen).
{ isolationLevel: "Serializable" } {
isolationLevel: "Serializable",
// Prisma gibt einer Transaktion voreingestellt 2 Sekunden, um
// ueberhaupt eine Verbindung zu bekommen, und 5 Sekunden zum
// Ausfuehren. Bei starkem Andrang sind alle Verbindungen belegt und
// eine Bestellung scheitert dann nicht an der Sache, sondern am
// Warten — mitten in der Kaufentscheidung. Die Transaktion selbst
// bleibt kurz; die groesszuegigen Werte sind Puffer fuer die
// Warteschlange, keine Einladung, lange zu rechnen.
maxWait: 10_000,
timeout: 15_000,
}
); );
let order;
try {
order = await withSerializationRetry(runOrderTransaction);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof OutOfStockError) { if (error instanceof OutOfStockError) {
return NextResponse.json( return NextResponse.json(
+62 -1
View File
@@ -1,6 +1,54 @@
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { PrismaMariaDb } from "@prisma/adapter-mariadb";
/**
* Gleichzeitig offene Datenbankverbindungen dieses Prozesses.
*
* Ohne Angabe nimmt node-postgres 10. Das reicht im Alltag mühelos, wird aber
* beim Andrang zur Obergrenze: eine Bestellung haelt ihre Verbindung ueber
* die gesamte Transaktion (Bestandsreservierung, Gutscheinpruefung,
* Bestellanlage), und Prisma gibt einer Transaktion nur begrenzt Zeit,
* ueberhaupt eine Verbindung zu bekommen (maxWait). Sind alle zehn belegt,
* scheitern weitere Checkouts nicht an der Sache, sondern am Warten.
*
* 20 ist bewusst moderat: PostgreSQL erlaubt in der Voreinstellung 100
* gleichzeitige Verbindungen, und daneben brauchen noch der Aufraeum-CronJob,
* die Sicherung und gelegentlich eine Konsole welche. Die Anwendung laeuft
* ausserdem mit einer einzigen Instanz (siehe k8s/05-app-deployment.yaml) —
* bei mehreren Instanzen multipliziert sich dieser Wert und will neu bedacht
* werden.
*/
const DEFAULT_POOL_SIZE = 20;
/**
* Der Wert gilt PRO INSTANZ. Wer die Anwendung auf mehrere Instanzen
* hochzieht, multipliziert damit auch die Verbindungen: drei Instanzen mit je
* 20 belegen 60 der voreingestellt 100 Verbindungen von PostgreSQL, und
* daneben brauchen der Aufraeum-CronJob, die Sicherung und gelegentlich eine
* Konsole noch welche. Deshalb einstellbar statt fest verdrahtet — beim
* Hochskalieren ist das der Wert, der mitwandern muss.
*/
function poolSize(): number {
const raw = process.env.DATABASE_POOL_SIZE?.trim();
if (!raw) return DEFAULT_POOL_SIZE;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) {
console.error(
`[db] DATABASE_POOL_SIZE="${raw}" ist keine ganze Zahl groesser 0 — es wird mit ${DEFAULT_POOL_SIZE} gearbeitet.`
);
return DEFAULT_POOL_SIZE;
}
return parsed;
}
/**
* Wie lange eine Anfrage auf eine freie Verbindung wartet, bevor sie
* aufgibt. Lieber ein klarer Fehler nach zehn Sekunden als eine Seite, die
* ewig laedt — und im Log ein Hinweis darauf, dass der Pool zu klein ist.
*/
const POOL_ACQUIRE_TIMEOUT_MS = 10_000;
/** /**
* Prisma 7 liest die Verbindung nicht mehr aus dem Schema, sondern erfordert * Prisma 7 liest die Verbindung nicht mehr aus dem Schema, sondern erfordert
* einen expliziten Treiber-Adapter im Client — welcher, haengt von * einen expliziten Treiber-Adapter im Client — welcher, haengt von
@@ -16,7 +64,20 @@ export function createDbAdapter(): PrismaPg | PrismaMariaDb {
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt."); if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
if (provider === "mysql") { if (provider === "mysql") {
// Bewusst unveraendert die Verbindungszeichenkette: der MariaDB-Adapter
// nimmt entweder sie ODER ein Einstellungsobjekt aus Host, Benutzer,
// Passwort und Datenbank — beides zugleich geht nicht, die Zeichenkette
// muesste also zerlegt werden. Der Produktivbetrieb laeuft auf
// PostgreSQL (DATABASE_PROVIDER-Vorgabe); fuer MySQL gilt weiterhin die
// Voreinstellung des Treibers.
return new PrismaMariaDb(connectionString); return new PrismaMariaDb(connectionString);
} }
return new PrismaPg({ connectionString }); return new PrismaPg({
connectionString,
max: poolSize(),
// Ohne Zeitgrenze wartet eine Anfrage unbegrenzt auf eine freie
// Verbindung. Unter Andrang staut sich dann alles lautlos auf, statt dass
// irgendwo ein Fehler sichtbar wuerde.
connectionTimeoutMillis: POOL_ACQUIRE_TIMEOUT_MS,
});
} }
+104 -57
View File
@@ -12,35 +12,71 @@ export interface GoogleReview {
profilePhotoUrl: string | null; profilePhotoUrl: string | null;
} }
/** Google antwortet auch bei Fehlern mit HTTP 200 — die Aussage steckt in "status". */
const GOOGLE_TIMEOUT_MS = 5000; const GOOGLE_TIMEOUT_MS = 5000;
interface PlacesDetailsResponse { /**
result?: { * Nur diese Felder werden angefordert. Die Places API rechnet nach
rating?: number; * angeforderten Feldern ab — eine Maske mit "*" waere teurer und lieferte
user_ratings_total?: number; * Daten, die diese Seite nie anzeigt.
reviews?: { */
author_name: string; const FIELD_MASK = "rating,userRatingCount,reviews";
rating: number;
text?: string; interface PlacesTextV1 {
relative_time_description: string; text?: string;
author_url?: string; }
profile_photo_url?: string;
}[]; interface PlacesReviewV1 {
rating?: number;
relativePublishTimeDescription?: string;
text?: PlacesTextV1;
originalText?: PlacesTextV1;
authorAttribution?: {
displayName?: string;
uri?: string;
photoUri?: string;
};
}
interface PlacesDetailsV1Response {
rating?: number;
userRatingCount?: number;
reviews?: PlacesReviewV1[];
/** Nur im Fehlerfall gesetzt — anders als frueher antwortet die API dann auch mit einem Fehlerstatus. */
error?: {
code?: number;
message?: string;
status?: string;
}; };
status: string;
error_message?: string;
} }
/** /**
* Ruft aktuelle Google-Rezensionen fuer den in GOOGLE_PLACE_ID hinterlegten * Ruft aktuelle Google-Rezensionen fuer den in GOOGLE_PLACE_ID hinterlegten
* Google-Maps-Eintrag ab (Places API, Legacy "Place Details"-Endpoint). * Google-Maps-Eintrag ab. Ohne konfigurierte Zugangsdaten wird null
* Ohne konfigurierte Zugangsdaten wird null zurueckgegeben, sodass die * zurueckgegeben, sodass die Startseite auf die statischen Beispiel-Stimmen
* Startseite auf die statischen Beispiel-Stimmen zurueckfaellt. * zurueckfaellt.
* *
* Setup: Google-Cloud-Projekt mit aktivierter "Places API", API-Key als * Verwendet wird die "Places API (New)" (places.googleapis.com/v1), NICHT der
* GOOGLE_PLACES_API_KEY, die Place-ID des Google-Maps-Eintrags als * alte Endpunkt maps.googleapis.com/maps/api/place/details/json. Das ist kein
* GOOGLE_PLACE_ID (zu finden ueber https://developers.google.com/maps/documentation/places/web-service/place-id). * Geschmacksurteil: Google laesst die alte "Places API" in seit Maerz 2025
* angelegten Cloud-Projekten gar nicht mehr aktivieren. Ein Aufruf des alten
* Endpunkts wird dort beantwortet mit
*
* REQUEST_DENIED — The provided API key is invalid.
*
* also mit einer Aussage ueber den Schluessel, obwohl der Schluessel in
* Ordnung ist und keinerlei Einschraenkung traegt. Wer dieser Meldung folgt,
* sucht den Fehler zwangslaeufig an der falschen Stelle.
*
* Einrichtung (siehe auch README, Abschnitt "Google-Rezensionen"):
* 1. In der Google Cloud Console die API "Places API (New)" aktivieren —
* nicht die gleichnamige alte, nicht die Maps JavaScript API.
* 2. Abrechnung fuer das Projekt aktivieren; ohne sie antwortet auch die
* neue API mit einem Berechtigungsfehler.
* 3. Unter "Anmeldedaten" einen API-SCHLUESSEL erzeugen. Ein OAuth-Client
* ist etwas anderes und funktioniert hier nicht.
* 4. Schluessel als GOOGLE_PLACES_API_KEY setzen, die Place-ID als
* GOOGLE_PLACE_ID (zu finden ueber
* https://developers.google.com/maps/documentation/places/web-service/place-id).
*/ */
export async function fetchGoogleReviews(): Promise<{ export async function fetchGoogleReviews(): Promise<{
reviews: GoogleReview[]; reviews: GoogleReview[];
@@ -51,60 +87,71 @@ export async function fetchGoogleReviews(): Promise<{
const placeId = process.env.GOOGLE_PLACE_ID; const placeId = process.env.GOOGLE_PLACE_ID;
if (!apiKey || !placeId) return null; if (!apiKey || !placeId) return null;
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json"); // Die Place-ID wird mal als "ChIJ..." und mal als "places/ChIJ..." notiert —
url.searchParams.set("place_id", placeId); // der Pfad braucht sie genau einmal mit Praefix.
// "reviews" (Plural!) — mit "review" antwortet Google mit INVALID_REQUEST, const resourceName = placeId.startsWith("places/") ? placeId : `places/${placeId}`;
// und weil jeder Fehlerfall hier still auf die Beispiel-Stimmen
// zurueckfaellt, sah die Startseite dabei voellig normal aus. Genau so const url = new URL(`https://places.googleapis.com/v1/${resourceName}`);
// blieb der Tippfehler unbemerkt. url.searchParams.set("languageCode", "de");
url.searchParams.set("fields", "reviews,rating,user_ratings_total");
url.searchParams.set("language", "de");
url.searchParams.set("reviews_sort", "newest");
url.searchParams.set("key", apiKey);
try { try {
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten, // 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten, ohne
// ohne die Rezensionen spuerbar veralten zu lassen. // die Rezensionen spuerbar veralten zu lassen.
const response = await fetch(url, { const response = await fetch(url, {
headers: {
// Der Schluessel gehoert bei dieser API in den Header, nicht in die
// Adresszeile — so steht er nicht in fremden Zugriffsprotokollen.
"X-Goog-Api-Key": apiKey,
"X-Goog-FieldMask": FIELD_MASK,
},
next: { revalidate: 3600 }, next: { revalidate: 3600 },
// Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden // Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden
// fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich. // fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich.
signal: AbortSignal.timeout(GOOGLE_TIMEOUT_MS), signal: AbortSignal.timeout(GOOGLE_TIMEOUT_MS),
}); });
if (!response.ok) {
console.warn(`[google-reviews] Places API antwortete mit HTTP ${response.status}.`);
return null;
}
const data = (await response.json()) as PlacesDetailsResponse; const data = (await response.json().catch(() => null)) as PlacesDetailsV1Response | null;
if (data.status !== "OK" || !data.result) {
// Laut melden statt still zurueckfallen: "REQUEST_DENIED" (Schluessel if (!response.ok || data?.error) {
// ungueltig oder Places API nicht aktiviert), "INVALID_REQUEST" // Laut melden statt still zurueckfallen: ein falscher Schluessel, eine
// (Parameter falsch) und "NOT_FOUND" (Place-ID stimmt nicht) sehen auf // nicht aktivierte API und eine unbekannte Place-ID sehen auf der
// der Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen. // Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
const detail = data?.error?.message ?? `HTTP ${response.status}`;
console.warn( console.warn(
`[google-reviews] Places API meldet "${data.status}"${data.error_message ? `: ${data.error_message}` : ""}.` `[google-reviews] Places API (New) hat den Abruf abgelehnt: ${detail}. Zu pruefen: Ist "Places API (New)" im Google-Cloud-Projekt aktiviert, ist die Abrechnung aktiv, und ist GOOGLE_PLACES_API_KEY ein API-Schluessel (kein OAuth-Client)?`
); );
return null; return null;
} }
if (!data) {
console.warn("[google-reviews] Antwort der Places API war kein gueltiges JSON.");
return null;
}
const reviews: GoogleReview[] = (data.result.reviews ?? []) const reviews: GoogleReview[] = (data.reviews ?? [])
.map((review) => {
// "text" ist die (ggf. uebersetzte) Fassung in languageCode,
// "originalText" die Originalsprache. Fehlt die Uebersetzung, ist das
// Original immer noch besser als gar kein Zitat.
const text = (review.text?.text ?? review.originalText?.text ?? "").trim();
const author = review.authorAttribution?.displayName?.trim() ?? "";
return {
author,
rating: review.rating ?? 0,
text,
relativeTime: review.relativePublishTimeDescription ?? "",
authorUrl: review.authorAttribution?.uri ?? null,
profilePhotoUrl: review.authorAttribution?.photoUri ?? null,
};
})
// Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der // Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der
// Startseite waeren sie eine leere Sprechblase. // Startseite waeren sie eine leere Sprechblase. Ohne Namen fehlt zudem
.filter((r) => r.text?.trim()) // die von den Nutzungsbedingungen verlangte Zuordnung zur Quelle.
.map((r) => ({ .filter((review) => review.text.length > 0 && review.author.length > 0);
author: r.author_name,
rating: r.rating,
text: r.text!.trim(),
relativeTime: r.relative_time_description,
authorUrl: r.author_url ?? null,
profilePhotoUrl: r.profile_photo_url ?? null,
}));
return { return {
reviews, reviews,
rating: data.result.rating ?? null, rating: data.rating ?? null,
totalCount: data.result.user_ratings_total ?? null, totalCount: data.userRatingCount ?? null,
}; };
} catch (error: unknown) { } catch (error: unknown) {
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen — // Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —