move_detect/cloud/src/Repository/Pdo/PdoEventRepository.php
유창욱 c1fb22ee16 fix(cloud): deterministic event ordering + zone concurrent-delete detection
Self-review (Task 16) follow-up fixes:
- PdoEventRepository::recent(): add event_id DESC as a secondary sort key
  after occurred_at DESC so ties (equal device-reported timestamps) resolve
  deterministically, matching InMemoryEventRepository's insertion-order
  tie-break.
- PdoZoneRepository::updateOwned(): after the ownership-scoped UPDATE,
  disambiguate a 0-rowCount() result (MySQL reports "changed" rows, not
  "matched" rows) with a targeted existence check, so a genuine no-op
  resubmit (identical level/polygon) still succeeds while a zone deleted
  concurrently between the ownership check and the UPDATE now throws
  instead of save() silently reporting success for a zone that no longer
  exists.

Full suite still green: 59 tests, 114 assertions, 4 skipped, 0 failures,
0 warnings/deprecations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne4rU4FToTLHcBUMUvUnT1
2026-07-12 02:06:32 +09:00

105 lines
3.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\EventRepository;
use PDO;
final class PdoEventRepository implements EventRepository
{
private const SELECT_COLUMNS =
'event_id, device_id, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, acknowledged_by, acknowledged_at';
public function __construct(private PDO $pdo) {}
public function insert(array $event): int
{
$st = $this->pdo->prepare(
'INSERT INTO events
(device_id, camera_id, event_type, severity, track_id, zone_id,
occurred_at, duration_s, metrics_json, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$st->execute([
(string)$event['device_id'],
(string)$event['camera_id'],
(string)$event['event_type'],
(string)$event['severity'],
$event['track_id'] ?? null,
$event['zone_id'] ?? null,
(float)$event['occurred_at'],
(float)($event['duration_s'] ?? 0.0),
json_encode($event['metrics'] ?? [], JSON_THROW_ON_ERROR),
time(),
]);
return (int)$this->pdo->lastInsertId();
}
public function recent(int $limit, ?string $severity): array
{
if ($severity !== null) {
$st = $this->pdo->prepare(
'SELECT ' . self::SELECT_COLUMNS . '
FROM events WHERE severity = ?
ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
);
$st->bindValue(1, $severity, PDO::PARAM_STR);
$st->bindValue(2, $limit, PDO::PARAM_INT);
} else {
$st = $this->pdo->prepare(
'SELECT ' . self::SELECT_COLUMNS . '
FROM events
ORDER BY occurred_at DESC, event_id DESC LIMIT ?'
);
$st->bindValue(1, $limit, PDO::PARAM_INT);
}
$st->execute();
return array_map(self::mapRow(...), $st->fetchAll());
}
public function find(int $id): ?array
{
$st = $this->pdo->prepare('SELECT ' . self::SELECT_COLUMNS . ' FROM events WHERE event_id = ?');
$st->execute([$id]);
$row = $st->fetch();
return $row === false ? null : self::mapRow($row);
}
public function acknowledge(int $id, string $user): bool
{
// Existence is checked separately (rather than trusting UPDATE's
// rowCount()) because MySQL's PDO driver, by default, reports
// "changed rows" rather than "matched rows": re-acknowledging an
// already-acknowledged event by the same user would otherwise report
// 0 affected rows and this method would wrongly return false.
$exists = $this->pdo->prepare('SELECT 1 FROM events WHERE event_id = ? LIMIT 1');
$exists->execute([$id]);
if ($exists->fetchColumn() === false) {
return false;
}
$st = $this->pdo->prepare(
'UPDATE events SET acknowledged_by = ?, acknowledged_at = ? WHERE event_id = ?'
);
$st->execute([$user, time(), $id]);
return true;
}
private static function mapRow(array $row): array
{
return [
'event_id' => (int)$row['event_id'],
'device_id' => $row['device_id'],
'camera_id' => $row['camera_id'],
'event_type' => $row['event_type'],
'severity' => $row['severity'],
'track_id' => $row['track_id'] !== null ? (int)$row['track_id'] : null,
'zone_id' => $row['zone_id'],
'occurred_at' => (float)$row['occurred_at'],
'duration_s' => (float)$row['duration_s'],
'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [],
'acknowledged_by' => $row['acknowledged_by'],
'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null,
];
}
}