fix: Race Conditions bei Gutscheincode-Einloesung und Retouren-Erstattung
- Checkout: Gutscheincode-Nutzungslimit wird jetzt innerhalb derselben Serializable-Transaktion wie die Bestellanlage frisch nachgeprueft, statt nur einmalig vorab. Zwei gleichzeitige Checkouts mit demselben maxUses:1-Code konnten sich sonst beide durchsetzen. - Admin/Retouren: Erstattungsstatus wird jetzt atomar (bedingtes Update, ANGENOMMEN -> ERSTATTET) VOR dem Stripe-Aufruf gesetzt statt danach. Ein Doppelklick auf "Erstatten" konnte sonst zwei echte Stripe-Erstattungen ausloesen. Bei einem fehlschlagenden Stripe-Aufruf wird der Status wieder zurueckgesetzt, damit erneut versucht werden kann. Beide Luecken kamen aus dem Security-Assessment dieser Session. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
b6db9254a9
commit
c59913b75b
2 files changed
+107
-38
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { checkoutSchema } from "@/lib/validation";
|
||||
import { getProductsByIds } from "@/lib/data";
|
||||
@@ -14,6 +15,18 @@ import { getBaseUrl } from "@/lib/url";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
|
||||
class OutOfStockError extends Error {}
|
||||
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
|
||||
// — zwei zeitgleiche Checkouts mit demselben maxUses:1-Code koennten ihn
|
||||
// beide bestehen, bevor eine der beiden Bestellungen tatsaechlich angelegt
|
||||
// ist (TOCTOU). Dieser Fehlertyp markiert den verbindlichen Re-Check direkt
|
||||
// vor dem Anlegen der Bestellung (s.u., innerhalb der Serializable-Transaktion).
|
||||
class DiscountCodeExhaustedError extends Error {}
|
||||
// Postgres/MySQL brechen bei "Serializable"-Isolation eine von zwei
|
||||
// widerspruechlichen, gleichzeitigen Transaktionen mit einem
|
||||
// Serialisierungsfehler ab, statt still beide durchzulassen — das ist der
|
||||
// eigentliche Schutz gegen die Race-Condition, der explizite Re-Check oben
|
||||
// ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall.
|
||||
const SERIALIZATION_CONFLICT_CODE = "P2034";
|
||||
|
||||
function generateOrderNumber(): string {
|
||||
const year = new Date().getFullYear();
|
||||
@@ -173,43 +186,71 @@ export async function POST(request: Request) {
|
||||
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
|
||||
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
|
||||
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
|
||||
order = await prisma.$transaction(async (tx) => {
|
||||
for (const item of orderItemsData) {
|
||||
const reserved = await tx.inventory.updateMany({
|
||||
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
||||
data: { quantity: { decrement: item.quantity } },
|
||||
});
|
||||
if (reserved.count === 0) {
|
||||
throw new OutOfStockError(item.productName);
|
||||
order = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
for (const item of orderItemsData) {
|
||||
const reserved = await tx.inventory.updateMany({
|
||||
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
||||
data: { quantity: { decrement: item.quantity } },
|
||||
});
|
||||
if (reserved.count === 0) {
|
||||
throw new OutOfStockError(item.productName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.order.create({
|
||||
data: {
|
||||
orderNumber: generateOrderNumber(),
|
||||
customerId: customer?.id,
|
||||
locale,
|
||||
fulfillmentMethod: input.fulfillmentMethod,
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
phone: input.phone,
|
||||
street: input.street ?? "",
|
||||
zip: input.zip ?? "",
|
||||
city: input.city ?? "",
|
||||
country: input.country,
|
||||
subtotalCents,
|
||||
shippingCents,
|
||||
discountCents,
|
||||
discountCodeId: discountCode?.id,
|
||||
totalCents,
|
||||
giftMessage: input.giftMessage || null,
|
||||
items: {
|
||||
create: orderItemsData,
|
||||
// Verbindlicher Re-Check direkt vor dem Anlegen der Bestellung: der
|
||||
// fruehe pendingUses-Check oben ist nur ein schneller Vorabfilter
|
||||
// (bessere Fehlermeldung fuer den Normalfall), dieser hier — innerhalb
|
||||
// derselben Serializable-Transaktion wie die Bestellanlage, mit einem
|
||||
// frischen Datenbank-Stand statt der aeusseren (potenziell leicht
|
||||
// veralteten) Momentaufnahme — ist der tatsaechlich verbindliche
|
||||
// Schutz gegen zwei gleichzeitige Checkouts mit demselben knapp
|
||||
// begrenzten Code.
|
||||
if (discountCode) {
|
||||
const freshCode = await tx.discountCode.findUnique({ where: { id: discountCode.id } });
|
||||
if (freshCode && freshCode.maxUses !== null) {
|
||||
const pendingUses = await tx.order.count({
|
||||
where: { discountCodeId: freshCode.id, status: "NEU" },
|
||||
});
|
||||
if (freshCode.usedCount + pendingUses >= freshCode.maxUses) {
|
||||
throw new DiscountCodeExhaustedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.order.create({
|
||||
data: {
|
||||
orderNumber: generateOrderNumber(),
|
||||
customerId: customer?.id,
|
||||
locale,
|
||||
fulfillmentMethod: input.fulfillmentMethod,
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
phone: input.phone,
|
||||
street: input.street ?? "",
|
||||
zip: input.zip ?? "",
|
||||
city: input.city ?? "",
|
||||
country: input.country,
|
||||
subtotalCents,
|
||||
shippingCents,
|
||||
discountCents,
|
||||
discountCodeId: discountCode?.id,
|
||||
totalCents,
|
||||
giftMessage: input.giftMessage || null,
|
||||
items: {
|
||||
create: orderItemsData,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
// Serializable statt der DB-Voreinstellung ("Read Committed"): erst
|
||||
// dadurch erkennt die Datenbank selbst den Konflikt zwischen zwei
|
||||
// gleichzeitigen Transaktionen, die beide denselben knapp begrenzten
|
||||
// Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab
|
||||
// (statt beide auf Basis veralteter Zaehlerstaende durchzulassen).
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof OutOfStockError) {
|
||||
return NextResponse.json(
|
||||
@@ -219,6 +260,18 @@ export async function POST(request: Request) {
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
if (error instanceof DiscountCodeExhaustedError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === SERIALIZATION_CONFLICT_CODE) {
|
||||
return NextResponse.json(
|
||||
{ error: "Kurzzeitig zu viele gleichzeitige Bestellungen. Bitte versuche es in ein paar Sekunden erneut." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user