fix: Race Condition bei DHL-Versandmarken-Erstellung

Zwei schnell aufeinanderfolgende Klicks auf "DHL-Versandmarke
erstellen" konnten beide den findUnique-Check bestehen und zwei
echte, kostenpflichtige DHL-Sendungen anlegen (gleiche Race-Condition
wie beim zuvor gefixten Retouren-Erstattungs-Bug). Ein reines
create()-mit-Unique-Constraint waere hier nicht ausreichend gewesen,
da Shipment-Datensaetze bereits ohne DHL existieren koennen
(automatisch bei Statuswechsel auf VERSENDET, oder manuell erfasst).
Fix: Serializable-Transaktion claimt den Datensatz atomar mit einem
Sentinel-Wert, bevor der DHL-Aufruf ueberhaupt startet; bei einem
fehlschlagenden DHL-Aufruf wird der Sentinel wieder entfernt.

Gefunden bei eigener Nachpruefung der neuen Admin-Actions dieser
Session (apicore-sync.ts war bereits sauber idempotent, kein Fix
noetig).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 08:52:00 +02:00
1 parent 154b75e0d8
commit 9e8da18875
1 file changed
+45
+45
View File
@@ -7,6 +7,11 @@ import { getCompanyInfo } from "@/lib/company-info";
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl"; import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
import { requireAdmin, fail, str } from "./_shared"; import { requireAdmin, fail, str } from "./_shared";
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
// daher eindeutig von jeder realen Sendungsnummer unterscheidbar.
const DHL_CLAIM_SENTINEL = "__dhl_label_pending__";
/** /**
* Erstellt per DHL Parcel DE Shipping API eine Versandmarke fuer eine * Erstellt per DHL Parcel DE Shipping API eine Versandmarke fuer eine
* bezahlte Bestellung und speichert die zurueckgegebene Sendungsnummer im * bezahlte Bestellung und speichert die zurueckgegebene Sendungsnummer im
@@ -42,6 +47,38 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst."); fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
} }
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
// findUnique-Check allein ist nicht ausreichend — zwei schnell
// aufeinanderfolgende Klicks koennten sonst beide passieren und zwei
// echte, kostenpflichtige DHL-Sendungen anlegen (gleiche Race-Condition
// wie beim Retouren-Erstattungs-Fix). Ein Shipment-Datensatz kann bereits
// OHNE DHL existieren (z.B. automatisch beim Statuswechsel auf
// "VERSENDET", siehe admin/actions/orders.ts, oder manuell erfasst) —
// ein reines "create()" mit Unique-Constraint waere daher in diesen
// Faellen faelschlich blockiert. Serializable-Transaktion + Sentinel-Wert
// statt der echten Sendungsnummer loest beide Faelle korrekt atomar.
let claimed: boolean;
try {
claimed = await prisma.$transaction(
async (tx) => {
const existing = await tx.shipment.findUnique({ where: { orderId }, select: { trackingNumber: true } });
if (existing?.trackingNumber) return false;
await tx.shipment.upsert({
where: { orderId },
create: { orderId, carrier: "DHL", trackingNumber: DHL_CLAIM_SENTINEL },
update: { carrier: "DHL", trackingNumber: DHL_CLAIM_SENTINEL },
});
return true;
},
{ isolationLevel: "Serializable" }
);
} catch {
claimed = false;
}
if (!claimed) {
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
}
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0); const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
const company = await getCompanyInfo(); const company = await getCompanyInfo();
@@ -76,6 +113,14 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
}, },
}); });
} catch (error: unknown) { } catch (error: unknown) {
// Claim zuruecksetzen, damit ein erneuter Versuch moeglich ist — nur
// wenn der Sentinel-Wert seitdem nicht durch etwas anderes (z.B. eine
// manuelle Tracking-Eingabe waehrend der laufenden Anfrage) ersetzt
// wurde, um dessen Aenderung nicht versehentlich zu ueberschreiben.
await prisma.shipment
.updateMany({ where: { orderId, trackingNumber: DHL_CLAIM_SENTINEL }, data: { trackingNumber: null } })
.catch(() => {});
if (error instanceof DhlNotConfiguredError) { if (error instanceof DhlNotConfiguredError) {
fail(detailPath, error.message); fail(detailPath, error.message);
} }