Skip to content

How-to: Inhalts-Meldesystem

FT-Referenz: FT289 (NENE2-FT/reportlog) — Inhaltsberichte: Allowlisted-Gründe (ReportReason-Enum), UNIQUE(reporter_id, article_id) mit idempotenten 200 bei Duplikat, pending→resolved/dismissed-Zustandsmaschine, Nur-Moderator-Liste/Lösen/Abweisen, DB-Level-CHECK-Constraints, 32 Tests / 58 Assertions PASS.

Diese Anleitung zeigt, wie ein Inhalts-Meldesystem aufgebaut wird, bei dem Benutzer Inhalte markieren und Moderatoren Berichte prüfen und lösen.

Schema

sql
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    role TEXT NOT NULL DEFAULT 'user',
    created_at TEXT NOT NULL,
    CHECK (role IN ('user', 'moderator'))
);

CREATE TABLE reports (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reporter_id INTEGER NOT NULL,
    article_id INTEGER NOT NULL,
    reason TEXT NOT NULL,
    details TEXT,
    status TEXT NOT NULL DEFAULT 'pending',
    resolved_by INTEGER,
    resolved_at TEXT,
    resolution_note TEXT,
    created_at TEXT NOT NULL,
    UNIQUE (reporter_id, article_id),
    CHECK (status IN ('pending', 'resolved', 'dismissed')),
    CHECK (reason IN ('spam', 'harassment', 'misinformation', 'other')),
    FOREIGN KEY (reporter_id) REFERENCES users(id),
    FOREIGN KEY (article_id) REFERENCES articles(id),
    FOREIGN KEY (resolved_by) REFERENCES users(id)
);

DB-Level-CHECK-Constraints erzwingen Enum-Werte, auch wenn die Anwendungsvalidierung umgangen wird.

Endpunkte

MethodePfadAuthBeschreibung
POST/reportsX-User-IdBericht einreichen
GET/reportsModeratorAlle Berichte auflisten
GET/reports/{id}Melder oder ModeratorBericht abrufen
PUT/reports/{id}/resolveModeratorBericht lösen
PUT/reports/{id}/dismissModeratorBericht abweisen

ReportReason-Enum

php
enum ReportReason: string
{
    case Spam         = 'spam';
    case Harassment   = 'harassment';
    case Misinformation = 'misinformation';
    case Other        = 'other';
}

ReportReason::tryFrom($reasonStr) lehnt unbekannte Werte ab. Der Handler gibt gültige Gründe in der Fehlerantwort zurück:

php
$reason = ReportReason::tryFrom($reasonStr);
if ($reason === null) {
    $validReasons = array_map(fn(ReportReason $r) => $r->value, ReportReason::cases());
    return $this->responseFactory->create(['error' => 'invalid reason', 'valid_reasons' => $validReasons], 422);
}

Idempotente Berichtseinreichung

Wenn ein Benutzer denselben Artikel bereits gemeldet hat, wird der vorhandene Bericht mit 200 zurückgegeben (nicht 201):

php
$existing = $this->repository->findReportByReporterAndArticle($actorId, $articleId);
if ($existing !== null) {
    return $this->responseFactory->create($this->formatReport($existing), 200);
}

// Erstes Mal: 201 Created
$id = $this->repository->createReport(...);
return $this->responseFactory->create($this->formatReport(...), 201);

UNIQUE(reporter_id, article_id) sichert dies auf DB-Ebene ab. Die Anwendung prüft zuerst, um eine benutzerfreundliche Antwort zurückzugeben, aber der UNIQUE-Constraint ist das Sicherheitsnetz.

Status-Lebenszyklus

pending ──→ resolved (Moderator-Aktion)
       └──→ dismissed (Moderator-Aktion)

Einmal gelöst oder abgewiesen kann ein Bericht nicht mehr übergehen. Der Versuch, einen nicht-ausstehenden Bericht zu ändern, gibt 422 zurück:

php
if ($report['status'] !== 'pending') {
    return $this->responseFactory->create([
        'error' => 'report is not pending',
        'current_status' => $report['status'],
    ], 422);
}

Moderator-Rollenprüfung

php
$actor = $this->repository->findUserById($actorId);
if ($actor === null || $actor['role'] !== 'moderator') {
    return $this->responseFactory->create(['error' => 'moderator role required'], 403);
}

Die Rolle wird in der users-Tabelle gespeichert und bei jeder privilegierten Operation geprüft. Ein DB-Level-CHECK (role IN ('user', 'moderator')) verhindert das Einfügen ungültiger Rollen.

Zugriffskontrolle: Melder vs. Moderator

GET /reports/{id} ist sowohl für den ursprünglichen Melder als auch für Moderatoren zugänglich:

php
$isModerator = $actor['role'] === 'moderator';
$isReporter  = (int)$report['reporter_id'] === $actorId;

if (!$isModerator && !$isReporter) {
    return $this->responseFactory->create(['error' => 'access denied'], 403);
}

Melder können ihre eigenen Berichte einsehen, um den Status zu verfolgen. Moderatoren sehen alle Berichte.

Lösung mit Prüfpfad

php
$this->repository->updateReportStatus($id, $newStatus, $actorId, date('c'), $note);

resolved_by (Moderator-ID), resolved_at (Zeitstempel) und resolution_note (optional) erstellen einen Prüfpfad für jede Moderationsaktion.


Was Sie NICHT tun sollten

Anti-PatternRisiko
Freiform-Grund-String akzeptierenTippfehler, Injection, unendliche Kategorien; Enum-Allowlist verwenden
Kein UNIQUE(reporter_id, article_id)Gleicher Benutzer reicht Dutzende Berichte für gleichen Artikel ein; aufgeblähte Warteschlange
409 bei doppeltem Bericht zurückgebenRetry-sichere Idempotenz: Duplikat → 200 mit vorhandenem Bericht, kein Fehler
Übergang von resolved/dismissed erlaubenGelöster Bericht wird erneut geöffnet; Prüfpfad wird unzuverlässig
Keine Moderator-Rollenprüfung bei Liste/LösenBeliebiger Benutzer liest alle Berichte; Datenschutzverletzung + Prüfungsumgehung
Eigenen Bericht an anderen Benutzer zurückgebenIDOR — immer prüfen, ob Melder === Akteur oder Akteur Moderator ist
Kein resolution_note-FeldModeratoren können nicht kommunizieren, warum ein Bericht abgewiesen vs. gelöst wurde
Kein resolved_by-FeldKann nicht auditiert werden, welcher Moderator gehandelt hat
Nur DB-CHECK, keine App-ValidierungDB löst Exception bei ungültigem Grund aus; Benutzer erhält 500 statt 422

Veröffentlicht unter der MIT-Lizenz.