Skip to content

How-to: Gutschein-Rabattcode-API

FT-Referenz: FT302 (NENE2-FT/couponlog) — Gutschein-Rabattcode-API: Nur-Admin-Erstellung mit X-Admin-Key (hash_equals), CODE_PATTERN [A-Z0-9]{4,32} automatische Normalisierung zu Großbuchstaben, UNIQUE(coupon_id, user_id) verhindert doppeltes Einlösen, abgelaufen/erschöpft/Duplikat → 409, 26 Tests / 50 Assertions PASS.

Diese Anleitung zeigt, wie ein Gutscheinsystem aufgebaut wird, bei dem Admins Rabattcodes erstellen und Benutzer diese gegen Nutzungslimits und Ablaufdaten einlösen.

Schema

sql
CREATE TABLE IF NOT EXISTS coupons (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    code        TEXT    NOT NULL UNIQUE,
    discount    INTEGER NOT NULL,          -- in Cent, z.B. 500 = 5,00 €
    max_uses    INTEGER NOT NULL DEFAULT 1,
    used_count  INTEGER NOT NULL DEFAULT 0,
    expires_at  TEXT    NOT NULL,
    created_at  TEXT    NOT NULL
);

CREATE TABLE IF NOT EXISTS redemptions (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    coupon_id   INTEGER NOT NULL,
    user_id     INTEGER NOT NULL,
    redeemed_at TEXT    NOT NULL,
    UNIQUE (coupon_id, user_id),
    FOREIGN KEY (coupon_id) REFERENCES coupons(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons (code);

UNIQUE(coupon_id, user_id) verhindert, dass derselbe Benutzer denselben Gutschein zweimal einlöst. Der Index auf code beschleunigt die Suche nach Code-String.

Endpunkte

MethodePfadAuthBeschreibung
POST/couponsX-Admin-KeyGutschein erstellen (nur Admin)
GET/coupons/{code}Gutscheindetails abrufen
POST/coupons/{code}/redeemX-User-IdGutschein einlösen
GET/coupons/{code}/redemptionsX-Admin-KeyEinlösungen auflisten (nur Admin)

Admin-Authentifizierung — hash_equals

php
private function isAdmin(ServerRequestInterface $req): bool
{
    if ($this->adminKey === '') {
        return false;
    }
    return hash_equals($this->adminKey, $req->getHeaderLine('X-Admin-Key'));
}

hash_equals() verhindert Timing-Seitenkanal-Angriffe auf den Schlüsselvergleich. Wenn adminKey ein leerer String ist (Fehlkonfiguration), gibt isAdmin() false zurück — fail closed.

Gutscheincode-Format — CODE_PATTERN

php
private const string CODE_PATTERN = '/\A[A-Z0-9]{4,32}\z/';
  • Nur Großbuchstaben und alphanumerische Zeichen
  • 4–32 Zeichen
  • \A / \z-Anker (vollständiger String-Match, kein Teilstring)

Eingabecodes werden vor der Validierung zu Großbuchstaben normalisiert:

php
$code = strtoupper(trim((string) ($body['code'] ?? '')));
if ($code === '') {
    // Automatisch generieren, wenn nicht angegeben
    $code = strtoupper(bin2hex(random_bytes(6)));
}
if (!preg_match(self::CODE_PATTERN, $code)) {
    return $this->problem(422, 'validation-failed', 'code must be 4–32 uppercase alphanumeric chars.');
}

Ein Benutzer, der "summer50" sendet, erhält denselben Gutschein wie "SUMMER50" — das System normalisiert automatisch zu Großbuchstaben. pathCode() normalisiert auch Pfadparameter zu Großbuchstaben, sodass GET /coupons/summer50 und GET /coupons/SUMMER50 denselben Gutschein auflösen.

Gutscheinerstellungs-Validierung

php
$discount = $body['discount'] ?? null;
if (!is_int($discount) || $discount < 1 || $discount > 10000) {
    return $this->problem(422, 'validation-failed', 'discount must be integer 1–10000 (cents).');
}

$maxUses = $body['max_uses'] ?? 1;
if (!is_int($maxUses) || $maxUses < 1 || $maxUses > 100000) {
    return $this->problem(422, 'validation-failed', 'max_uses must be integer 1–100000.');
}

if (!preg_match(self::ISO_DATE_PATTERN, $expiresAt)) {
    return $this->problem(422, 'validation-failed', 'expires_at must be ISO 8601 datetime.');
}
  • discount: strenge is_int()-Prüfung — Floats wie 9.99 werden abgelehnt
  • max_uses: Standardwert 1, wenn nicht angegeben
  • expires_at: muss dem ISO-8601-Präfix \A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} entsprechen

Einlösen — Vier Fehlermodi

php
$result = $this->repo->redeem($code, $uid);

return match ($result) {
    'not_found'        => $this->problem(404, 'not-found', 'Coupon not found.'),
    'expired'          => $this->problem(409, 'conflict', 'Coupon has expired.'),
    'exhausted'        => $this->problem(409, 'conflict', 'Coupon usage limit reached.'),
    'already_redeemed' => $this->problem(409, 'conflict', 'You have already redeemed this coupon.'),
    default            => $this->json(['message' => 'Coupon redeemed successfully.']),
};

Alle Geschäftsregelverstoße geben 409 Conflict zurück (nicht 422). Der match-Ausdruck ist erschöpfend — der Default-Zweig wird nur bei einem erfolgreichen 'redeemed'-String aus dem Repository ausgelöst.

Benutzer-ID-Validierung

php
private function uid(ServerRequestInterface $req): ?int
{
    $raw = $req->getHeaderLine('X-User-Id');
    if ($raw === '' || !ctype_digit($raw) || strlen($raw) > 18) {
        return null;
    }
    $id = (int) $raw;
    return $id > 0 ? $id : null;
}
  • ctype_digit() — nur reine Ziffern-Strings akzeptiert (kein -, +, Leerzeichen)
  • strlen > 18 — verhindert Integer-Überlauf bei 64-Bit-PHP (PHP_INT_MAX hat 19 Stellen)
  • $id > 0 — Null-ID ist nicht gültig

Gibt null zurück → 400 Bad Request, wenn der Header fehlt oder fehlformatiert ist.

UNIQUE(coupon_id, user_id) — Idempotentes Einlösen

Der DB-Constraint verhindert doppeltes Einlösen auf Storage-Ebene. Die Anwendung prüft auch über das Repository vor dem Einfügen und gibt 'already_redeemed' zurück, anstatt auf eine DB-Exception zu warten.

Mehrere verschiedene Benutzer können denselben Gutschein einlösen (bis zu max_uses). Nur wenn derselbe Benutzer denselben Gutschein zweimal versucht, wird er blockiert.


Was Sie NICHT tun sollten

Anti-PatternRisiko
Einfaches == für Admin-Schlüssel-VergleichTiming-Angriff enthüllt Schlüssellänge / Teilübereinstimmungen
Leerer adminKey erlaubt Admin-ZugriffFehlkonfigurierter Admin-Schlüssel wird zu offenem Zugriff — fail closed
Groß-/Kleinschreibung-sensitiver Code-Lookup"summer50" und "SUMMER50" als unterschiedliche Gutscheine behandelt
discount ohne is_int()Float 9.99 wird akzeptiert; Bruchzahl-Cent korrumpiert das Ledger
422 für abgelaufen/erschöpftDies sind Geschäftszustands-Konflikte, keine Validierungsfehler — 409 verwenden
Kein UNIQUE(coupon_id, user_id)Race Condition erlaubt gleichzeitiges zweifaches Einlösen desselben Benutzers
Keine max_uses-ObergrenzeAngreifer erstellt Gutschein mit max_uses: 999999999 für effektiv unbegrenzten Rabatt
strlen > N bei Benutzer-ID überspringenSehr große Ganzzahl-Strings überlaufen (int)-Cast stillschweigend
Kein Index auf code-SpalteVollständiger Tabellen-Scan bei jeder Gutschein-Suche
Einlösungsliste an Nicht-Admin zurückgebenEnthüllt welche Benutzer-IDs eingelöst haben — Datenschutzverletzung

Veröffentlicht unter der MIT-Lizenz.