diff --git a/docs/INDEX.md b/docs/INDEX.md
index a6822f5..02f3889 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -1,5 +1,9 @@
# INDEX — 작업 기록 (완료 결과만)
+- **2026-08-06** 오탐 피드백 (결정 a: 통계만, 자동 튜닝 없음): 이벤트 오탐 태그
+ (`POST /events/{id}/false-positive`, ack 겸함, 최초 태거 보존) + `GET /api/fp-stats` +
+ 콘솔 오탐율 패널(30일, 30%+ 강조). PHPUnit 135 + Chrome 실렌더(태그→행 전환→패널 즉시 갱신).
+
- **2026-08-06** 대시보드 장치→카메라 트리 레일 + (device,camera) 쌍 카메라 필터 —
`eaff176`. PHPUnit 119 통과 + Chrome 실렌더 검증(트리 상태·필터·접기·콘솔 무오류).
- **2026-08-06** 감사 잔여 P2-6/7/8 종결 (ID 스왑 게이트 · sustained-prone FALLING ·
diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md
index 8e25336..2959515 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -31,8 +31,9 @@
- 진입: `public/index.php` (라우트 전부 여기), 뷰: `src/View/{layout,events,login,zones,event_detail}.php`
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
- `GET/POST /api/zones*` · `POST /login|/logout` · `GET /snap/{uuid}` ·
- `POST /events/{id}/ack` · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view`
+ `GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` ·
+ `GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그,
+ ack 겸함) · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view`
- `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF ·
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO)
- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) ·
diff --git a/server/migrations/2026-08-06-false-positive-tag.sql b/server/migrations/2026-08-06-false-positive-tag.sql
new file mode 100644
index 0000000..b87220c
--- /dev/null
+++ b/server/migrations/2026-08-06-false-positive-tag.sql
@@ -0,0 +1,6 @@
+-- Operator false-positive tag (오탐). Feeds per-camera/type FP-rate statistics only —
+-- by product decision the tag never auto-tunes edge thresholds; a human reads the rate
+-- and adjusts deliberately. Tagging also acknowledges the event (an FP is handled).
+ALTER TABLE move_events
+ ADD COLUMN false_positive_by VARCHAR(190) NULL AFTER acknowledged_at,
+ ADD COLUMN false_positive_at BIGINT UNSIGNED NULL AFTER false_positive_by;
diff --git a/server/public/assets/app.css b/server/public/assets/app.css
index 9ed8d67..ea2f553 100644
--- a/server/public/assets/app.css
+++ b/server/public/assets/app.css
@@ -334,8 +334,25 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; }
.chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit-ink); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; }
-.ack-btn { margin-left:auto; flex:none; min-width:4.5rem; text-align:center; }
-.ack-btn:disabled { color:var(--ink-3); background:none; border-color:transparent; box-shadow:none; font-family:var(--mono); font-size:.75rem; }
+/* Actions column: [확인][오탐] while open; a single mono verdict line once resolved. */
+.row-actions { margin-left:auto; flex:none; display:flex; align-items:center; gap:.4rem; }
+.ack-btn { flex:none; min-width:4.5rem; text-align:center; }
+/* 오탐 is the rarer, corrective action — visually secondary to 확인 so it can't be
+ fat-fingered as the default. */
+.fp-btn { flex:none; padding:.32rem .55rem; font-size:.75rem; color:var(--ink-3);
+ background:none; border-color:transparent; box-shadow:none; }
+.fp-btn:hover { color:var(--ink-2); border-color:var(--line); background:#fff; }
+.ack-done, .fp-done { color:var(--ink-3); font-family:var(--mono); font-size:.75rem; white-space:nowrap; }
+.fp-done { text-decoration:line-through; text-decoration-color:#c9d1da; }
+
+/* Rail: FP-rate report rows. */
+.fp-stats { display:flex; flex-direction:column; gap:.34rem; font-size:.74rem; }
+.fp-stats.is-empty { color:var(--ink-3); }
+.fp-row { display:flex; align-items:baseline; justify-content:space-between; gap:.6rem; min-width:0; }
+.fp-what { color:var(--ink-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.fp-what .fp-cam { font-family:var(--mono); font-weight:600; color:var(--ink-3); margin-right:.15rem; }
+.fp-rate { flex:none; font-family:var(--mono); color:var(--ink-3); }
+.fp-rate.is-hot { color:var(--warn-ink); font-weight:700; }
/* "new events arrived while paging" reload affordance — honest about a possible gap. */
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .6rem; padding:.45rem .7rem;
diff --git a/server/public/assets/app.js b/server/public/assets/app.js
index 71fe8f8..4434c3e 100644
--- a/server/public/assets/app.js
+++ b/server/public/assets/app.js
@@ -234,17 +234,38 @@
row.appendChild(meta);
- var btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'ack-btn';
- if (isAcked) {
- btn.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
- btn.disabled = true;
+ // Actions column. Resolution states are terminal and mutually exclusive in display:
+ // an FP tag wins (it is the more specific verdict — the event was also acknowledged
+ // server-side), then a plain acknowledgement, else the two action buttons.
+ var actions = document.createElement('div');
+ actions.className = 'row-actions';
+ var isFp = ev.false_positive_by !== null && ev.false_positive_by !== undefined && ev.false_positive_by !== '';
+ if (isFp) {
+ var fpDone = document.createElement('span');
+ fpDone.className = 'fp-done';
+ fpDone.textContent = '오탐 (' + String(ev.false_positive_by) + ')';
+ actions.appendChild(fpDone);
+ } else if (isAcked) {
+ var ackDone = document.createElement('span');
+ ackDone.className = 'ack-done';
+ ackDone.textContent = '확인됨 (' + String(ev.acknowledged_by) + ')';
+ actions.appendChild(ackDone);
} else {
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'ack-btn';
btn.textContent = '확인';
btn.addEventListener('click', function () { acknowledge(ev.event_id, btn); });
+ actions.appendChild(btn);
+ var fpBtn = document.createElement('button');
+ fpBtn.type = 'button';
+ fpBtn.className = 'fp-btn';
+ fpBtn.textContent = '오탐';
+ fpBtn.setAttribute('aria-label', '오탐으로 표시 (확인 처리됨, 오탐 통계에 집계)');
+ fpBtn.addEventListener('click', function () { markFalsePositive(ev.event_id, fpBtn, btn); });
+ actions.appendChild(fpBtn);
}
- row.appendChild(btn);
+ row.appendChild(actions);
return row;
}
@@ -269,16 +290,40 @@
});
}
- function patchOlderAck(eventId) {
+ function patchOlderAck(eventId, alsoFp) {
for (var i = 0; i < olderEvents.length; i++) {
var ev = olderEvents[i];
if (ev && ev.event_id === eventId && !isAcked(ev)) {
// New object, never mutate the existing one (keeps combinedEvents' dedup predictable).
- olderEvents[i] = Object.assign({}, ev, { acknowledged_by: window.APP_USER || '확인' });
+ var patch = { acknowledged_by: window.APP_USER || '확인' };
+ if (alsoFp) patch.false_positive_by = window.APP_USER || '오탐';
+ olderEvents[i] = Object.assign({}, ev, patch);
}
}
}
+ // The 오탐 tag: same terminality as an ack (the server also acknowledges), plus the event
+ // feeds the per-camera FP-rate panel. By product decision this is where the loop ENDS —
+ // the tag never auto-tunes edge thresholds; a human reads the rate and adjusts deliberately.
+ function markFalsePositive(eventId, fpBtn, ackBtn) {
+ fpBtn.disabled = true;
+ if (ackBtn) ackBtn.disabled = true;
+ fetch(B + '/events/' + encodeURIComponent(eventId) + '/false-positive', {
+ method: 'POST',
+ headers: { 'X-Csrf-Token': csrfToken || '' }
+ })
+ .then(function (res) {
+ if (!res.ok) throw new Error('fp_failed:' + res.status);
+ patchOlderAck(eventId, true);
+ loadFpStats(); // the panel should reflect the tag immediately, not in 60s
+ return loadEvents();
+ })
+ .catch(function () {
+ fpBtn.disabled = false;
+ if (ackBtn) ackBtn.disabled = false;
+ });
+ }
+
// Status strip: count UNACKNOWLEDGED events per severity from the same array the
// list renders. Stat tiles (not a chart): small integers a desk operator scans.
// Every tier tile is always drawn (zero -> muted) so the strip never reflows.
@@ -600,6 +645,51 @@
});
}
+ // --- rail: FP-rate panel ----------------------------------------------------------
+ // Per-(camera, type) false-positive rates over 30 days. Refreshed on its own slow cadence
+ // (60s) — tag volume changes slowly — plus immediately after the operator tags one.
+ var fpStatsRoot = document.getElementById('fp-stats-root');
+ var FP_STATS_INTERVAL_MS = 60000;
+ var FP_STATS_SHOWN = 5;
+
+ function renderFpStats(stats) {
+ if (!fpStatsRoot) return;
+ fpStatsRoot.textContent = '';
+ if (!stats || stats.length === 0) {
+ fpStatsRoot.textContent = '오탐 태그가 없습니다.';
+ fpStatsRoot.className = 'fp-stats is-empty';
+ return;
+ }
+ fpStatsRoot.className = 'fp-stats';
+ stats.slice(0, FP_STATS_SHOWN).forEach(function (s) {
+ var row = document.createElement('div');
+ row.className = 'fp-row';
+ var what = document.createElement('span');
+ what.className = 'fp-what';
+ var cam = document.createElement('span');
+ cam.className = 'fp-cam';
+ cam.textContent = s.camera_id;
+ what.appendChild(cam);
+ what.appendChild(document.createTextNode(' ' + (TYPE_LABELS[s.event_type] || s.event_type)));
+ row.appendChild(what);
+ var rate = document.createElement('span');
+ var pct = Math.round((s.fp / Math.max(1, s.total)) * 100);
+ rate.className = 'fp-rate' + (pct >= 30 ? ' is-hot' : '');
+ rate.textContent = pct + '% (' + s.fp + '/' + s.total + ')';
+ // 30%+ = 그 카메라·유형은 재튜닝 후보라는 뜻의 시각 신호 (텍스트가 이미 수치를 말한다).
+ row.appendChild(rate);
+ fpStatsRoot.appendChild(row);
+ });
+ }
+
+ function loadFpStats() {
+ if (!fpStatsRoot) return;
+ fetch(B + '/api/fp-stats?days=30', { headers: { Accept: 'application/json' } })
+ .then(function (res) { return res.ok ? res.json() : null; })
+ .then(function (data) { if (data) renderFpStats(data.stats); })
+ .catch(function () { /* 패널은 보조 정보 — 실패 시 마지막 상태 유지 */ });
+ }
+
document.addEventListener('click', unlockAudio);
// Filter bar (client-side) + "더 보기" (server-side cursor).
@@ -613,5 +703,7 @@
loadCsrfToken();
loadEvents();
loadDevices();
+ loadFpStats();
setInterval(function () { loadEvents(); loadDevices(); }, POLL_INTERVAL_MS);
+ setInterval(loadFpStats, FP_STATS_INTERVAL_MS);
})();
diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js
index b8e37f6..a3f853b 100644
--- a/server/public/assets/detail.js
+++ b/server/public/assets/detail.js
@@ -98,6 +98,7 @@
addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
addRow('지속(초)', textNode(ev.duration_s));
addRow('확인', textNode(ev.acknowledged_by || '미확인'));
+ if (ev.false_positive_by) addRow('오탐', textNode(ev.false_positive_by + ' 태그'));
if (!snapBox) return;
snapBox.textContent = '';
diff --git a/server/public/index.php b/server/public/index.php
index a479ec1..533bc83 100644
--- a/server/public/index.php
+++ b/server/public/index.php
@@ -46,7 +46,7 @@ use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
- DeviceStatusController, HeatmapController};
+ DeviceStatusController, HeatmapController, FpStatsController};
// Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so
// an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off
@@ -96,6 +96,7 @@ $proxy = new SnapshotProxy($snaps, $events, $sess);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
$deviceStatus = new DeviceStatusController($devices, $sess);
$heatmap = new HeatmapController($events, $sess);
+$fpStats = new FpStatsController($events, $sess);
$render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
@@ -113,8 +114,10 @@ $r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
$r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
+$r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $detail->falsePositive($q, $p));
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
$r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p));
+$r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q));
// Background frame for the zone editor: the newest snapshot this camera produced.
$r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p));
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
diff --git a/server/schema.sql b/server/schema.sql
index 5d15919..3a4743a 100644
--- a/server/schema.sql
+++ b/server/schema.sql
@@ -130,6 +130,8 @@ CREATE TABLE move_events (
spatial_y DOUBLE NULL, -- heatmap raw material; NULL for legacy/deviceless events
acknowledged_by VARCHAR(190) NULL,
acknowledged_at BIGINT UNSIGNED NULL,
+ false_positive_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning)
+ false_positive_at BIGINT UNSIGNED NULL,
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
PRIMARY KEY (event_id),
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
diff --git a/server/src/Dashboard/EventDetailController.php b/server/src/Dashboard/EventDetailController.php
index 344fff3..dce8805 100644
--- a/server/src/Dashboard/EventDetailController.php
+++ b/server/src/Dashboard/EventDetailController.php
@@ -48,4 +48,24 @@ final class EventDetailController
$this->audit->append($user, 'acknowledge', "event:$id", [], []);
return Response::json(['ok' => true], 200);
}
+
+ /** POST /events/{id}/false-positive — operator's 오탐 tag. Also acknowledges (an FP is a
+ * handled event; leaving it unacknowledged would keep it alarming). Feeds FP-rate stats
+ * only — by product decision it never auto-tunes edge thresholds. */
+ public function falsePositive(Request $req, array $params): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ // Same permission as acknowledge: tagging FP is the same "I handled this" judgement call.
+ if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) return Response::json(['error' => 'forbidden'], 403);
+ if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
+ return Response::json(['error' => 'csrf'], 403);
+ }
+ $id = (int)($params['id'] ?? 0);
+ $event = $this->events->find($id);
+ if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
+ $user = (string)$this->sess->get('username');
+ if (!$this->events->markFalsePositive($id, $user)) return Response::json(['error' => 'not_found'], 404);
+ $this->audit->append($user, 'false_positive', "event:$id", [], []);
+ return Response::json(['ok' => true], 200);
+ }
}
diff --git a/server/src/Dashboard/FpStatsController.php b/server/src/Dashboard/FpStatsController.php
new file mode 100644
index 0000000..3fc40dc
--- /dev/null
+++ b/server/src/Dashboard/FpStatsController.php
@@ -0,0 +1,39 @@
+ {stats: [{camera_id, event_type, total, fp}], days}
+ *
+ * Per-(camera, event type) false-positive rates for the console's FP panel. This is the
+ * whole feedback loop by design: a human reads the rate and re-tunes thresholds
+ * deliberately — the tag never adjusts edge sensitivity on its own.
+ */
+final class FpStatsController
+{
+ private const DEFAULT_DAYS = 30;
+ private const MAX_DAYS = 90;
+
+ public function __construct(private EventRepository $events, private SessionStore $sess) {}
+
+ public function json(Request $req): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ $days = self::clampDays($req->query('days'));
+ // Fail-closed site scoping, mirroring the event list.
+ $scope = $this->sess->get('site_scope');
+ $site = is_string($scope) ? $scope : null;
+ $stats = $this->events->fpStats((float)(time() - $days * 86400), $site);
+ return Response::json(['stats' => $stats, 'days' => $days], 200);
+ }
+
+ private static function clampDays(mixed $raw): int
+ {
+ if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS;
+ return max(1, min(self::MAX_DAYS, (int)$raw));
+ }
+}
diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php
index 20846f4..56ae28a 100644
--- a/server/src/Repository/EventRepository.php
+++ b/server/src/Repository/EventRepository.php
@@ -26,4 +26,17 @@ interface EventRepository
* site-scope filter as page(). Rows: {x, y, severity, event_type, occurred_at}.
*/
public function heatmapPoints(string $cameraId, float $sinceOccurredAt, ?string $site, int $limit): array;
+ /**
+ * Tag an event as a false positive AND acknowledge it (an FP is a handled event —
+ * leaving it unacknowledged would keep it alarming). Idempotent on the FP flag:
+ * the FIRST tagger is preserved. Returns false only when the event does not exist.
+ */
+ public function markFalsePositive(int $id, string $user): bool;
+ /**
+ * FP-rate aggregation for the stats panel: per (camera_id, event_type) totals and
+ * false-positive counts since $sinceOccurredAt, only groups with at least one FP.
+ * $site applies the same fail-closed scope filter as page().
+ * Rows: {camera_id, event_type, total, fp}.
+ */
+ public function fpStats(float $sinceOccurredAt, ?string $site): array;
}
diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php
index 3b3cd53..2876810 100644
--- a/server/src/Repository/Pdo/PdoEventRepository.php
+++ b/server/src/Repository/Pdo/PdoEventRepository.php
@@ -9,7 +9,8 @@ final class PdoEventRepository implements EventRepository
{
private const SELECT_COLUMNS =
'event_id, event_uuid, device_id, site, camera_id, event_type, severity, track_id, zone_id,
- occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at';
+ occurred_at, duration_s, metrics_json, spatial_x, spatial_y, acknowledged_by, acknowledged_at,
+ false_positive_by, false_positive_at';
public function __construct(private PDO $pdo) {}
@@ -160,6 +161,52 @@ final class PdoEventRepository implements EventRepository
], $st->fetchAll());
}
+ public function markFalsePositive(int $id, string $user): bool
+ {
+ // Same matched-vs-changed discipline as acknowledge(): existence checked separately,
+ // because a no-op UPDATE reports 0 affected rows on MySQL's default PDO settings.
+ $exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1');
+ $exists->execute([$id]);
+ if ($exists->fetchColumn() === false) return false;
+ $now = time();
+ // COALESCE keeps the FIRST tagger/acknowledger — a second tag must not rewrite who
+ // made the original call (it is an audit-relevant judgement).
+ $st = $this->pdo->prepare(
+ 'UPDATE move_events
+ SET false_positive_by = COALESCE(false_positive_by, ?),
+ false_positive_at = COALESCE(false_positive_at, ?),
+ acknowledged_by = COALESCE(acknowledged_by, ?),
+ acknowledged_at = COALESCE(acknowledged_at, ?)
+ WHERE event_id = ?'
+ );
+ $st->execute([$user, $now, $user, $now, $id]);
+ return true;
+ }
+
+ public function fpStats(float $sinceOccurredAt, ?string $site): array
+ {
+ $sql = 'SELECT camera_id, event_type, COUNT(*) AS total,
+ SUM(false_positive_by IS NOT NULL) AS fp
+ FROM move_events
+ WHERE occurred_at >= ?';
+ $binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]];
+ if ($site !== null) {
+ $sql .= ' AND site = ?';
+ $binds[] = [$site, PDO::PARAM_STR];
+ }
+ $sql .= ' GROUP BY camera_id, event_type HAVING fp > 0 ORDER BY fp DESC';
+ $st = $this->pdo->prepare($sql);
+ $pos = 1;
+ foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type);
+ $st->execute();
+ return array_map(static fn ($r) => [
+ 'camera_id' => $r['camera_id'],
+ 'event_type' => $r['event_type'],
+ 'total' => (int)$r['total'],
+ 'fp' => (int)$r['fp'],
+ ], $st->fetchAll());
+ }
+
private static function mapRow(array $row): array
{
return [
@@ -177,6 +224,8 @@ final class PdoEventRepository implements EventRepository
'metrics' => json_decode((string)($row['metrics_json'] ?? '[]'), true) ?? [],
'spatial_x' => $row['spatial_x'] !== null ? (float)$row['spatial_x'] : null,
'spatial_y' => $row['spatial_y'] !== null ? (float)$row['spatial_y'] : null,
+ 'false_positive_by' => $row['false_positive_by'] ?? null,
+ 'false_positive_at' => ($row['false_positive_at'] ?? null) !== null ? (int)$row['false_positive_at'] : null,
'acknowledged_by' => $row['acknowledged_by'],
'acknowledged_at' => $row['acknowledged_at'] !== null ? (int)$row['acknowledged_at'] : null,
];
diff --git a/server/src/View/events.php b/server/src/View/events.php
index cb2552a..f9af50b 100644
--- a/server/src/View/events.php
+++ b/server/src/View/events.php
@@ -29,6 +29,12 @@ render_header('이벤트');
+
diff --git a/server/tests/DashboardTest.php b/server/tests/DashboardTest.php
index cb66f09..46485da 100644
--- a/server/tests/DashboardTest.php
+++ b/server/tests/DashboardTest.php
@@ -82,6 +82,33 @@ final class DashboardTest extends TestCase
// acknowledge did not proceed
$this->assertNull($repo->find($id)['acknowledged_by']);
}
+ public function testFalsePositiveTagAcknowledgesAndKeepsFirstTagger(): void {
+ $repo = new InMemoryEventRepository();
+ $id = $repo->insert(['severity' => 'warning', 'event_type' => 'zone_warning']);
+ $req = new Request('POST', "/events/$id/false-positive", ['X-Csrf-Token' => 'tok'], '', [], []);
+ // viewer denied (same 'acknowledge' permission as ack)
+ $cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
+ $this->assertSame(403, $cv->falsePositive($req, ['id' => (string)$id])->status());
+ // operator tags: FP set AND acknowledged (an FP is a handled event)
+ $co = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
+ $this->assertSame(200, $co->falsePositive($req, ['id' => (string)$id])->status());
+ $row = $repo->find($id);
+ $this->assertSame('u', $row['false_positive_by']);
+ $this->assertSame('u', $row['acknowledged_by']);
+ // a second tag by another user must NOT rewrite the original judgement
+ $s2 = $this->authed('operator'); $s2->set('username', 'u2');
+ $c2 = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $s2);
+ $this->assertSame(200, $c2->falsePositive($req, ['id' => (string)$id])->status());
+ $this->assertSame('u', $repo->find($id)['false_positive_by']);
+ }
+ public function testFalsePositiveRejectsBadCsrf(): void {
+ $repo = new InMemoryEventRepository();
+ $id = $repo->insert(['severity' => 'warning', 'event_type' => 'zone_warning']);
+ $c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
+ $wrong = new Request('POST', "/events/$id/false-positive", ['X-Csrf-Token' => 'nope'], '', [], []);
+ $this->assertSame(403, $c->falsePositive($wrong, ['id' => (string)$id])->status());
+ $this->assertNull($repo->find($id)['false_positive_by'] ?? null);
+ }
public function testSnapshotProxyAuthAnd404(): void {
$snaps = new InMemorySnapshotRepository();
$events = new InMemoryEventRepository();
diff --git a/server/tests/FpStatsTest.php b/server/tests/FpStatsTest.php
new file mode 100644
index 0000000..d37df54
--- /dev/null
+++ b/server/tests/FpStatsTest.php
@@ -0,0 +1,62 @@
+set('user_id', 1);
+ if ($site !== null) $s->set('site_scope', $site);
+ return $s;
+ }
+
+ private function repo(): InMemoryEventRepository
+ {
+ $r = new InMemoryEventRepository();
+ $now = (float)time();
+ // cam-1 zone_warning: 3 events, 2 FP-tagged
+ $a = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]);
+ $b = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]);
+ $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]);
+ $r->markFalsePositive($a, 'u');
+ $r->markFalsePositive($b, 'u');
+ // cam-2 fall on another site: 1 event, 1 FP
+ $c = $r->insert(['camera_id' => 'cam-2', 'event_type' => 'fall', 'severity' => 'high', 'site' => 'b', 'occurred_at' => $now]);
+ $r->markFalsePositive($c, 'u');
+ // cam-3: events but no FP tags -> not in the report
+ $r->insert(['camera_id' => 'cam-3', 'event_type' => 'fall', 'severity' => 'high', 'site' => 'a', 'occurred_at' => $now]);
+ return $r;
+ }
+
+ public function testRequiresLogin(): void
+ {
+ $c = new FpStatsController($this->repo(), new ArraySessionStore());
+ $this->assertSame(401, $c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->status());
+ }
+
+ public function testAggregatesPerCameraAndType(): void
+ {
+ $c = new FpStatsController($this->repo(), $this->sess());
+ $data = json_decode($c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->body(), true);
+ $this->assertSame(30, $data['days']);
+ $this->assertCount(2, $data['stats']); // cam-3 (no FP) excluded
+ $this->assertSame(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'total' => 3, 'fp' => 2],
+ $data['stats'][0]); // sorted by fp desc
+ }
+
+ public function testSiteScopeFiltersStats(): void
+ {
+ $c = new FpStatsController($this->repo(), $this->sess('a'));
+ $data = json_decode($c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->body(), true);
+ $this->assertCount(1, $data['stats']);
+ $this->assertSame('cam-1', $data['stats'][0]['camera_id']);
+ }
+}
diff --git a/server/tests/PdoIntegrationTest.php b/server/tests/PdoIntegrationTest.php
index 826965b..0fdd128 100644
--- a/server/tests/PdoIntegrationTest.php
+++ b/server/tests/PdoIntegrationTest.php
@@ -170,6 +170,8 @@ final class PdoIntegrationTest extends TestCase
spatial_y REAL,
acknowledged_by TEXT,
acknowledged_at INTEGER,
+ false_positive_by TEXT,
+ false_positive_at INTEGER,
received_at INTEGER NOT NULL
);
diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php
index 5b0f7f6..530fc94 100644
--- a/server/tests/Support/InMemoryRepositories.php
+++ b/server/tests/Support/InMemoryRepositories.php
@@ -61,6 +61,29 @@ final class InMemoryEventRepository implements EventRepository {
}
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; }
+ public function markFalsePositive(int $id, string $user): bool {
+ if (!isset($this->rows[$id])) return false;
+ $r = $this->rows[$id];
+ $r['false_positive_by'] = $r['false_positive_by'] ?? $user;
+ $r['false_positive_at'] = $r['false_positive_at'] ?? time();
+ $r['acknowledged_by'] = ($r['acknowledged_by'] ?? null) !== null ? $r['acknowledged_by'] : $user;
+ $this->rows[$id] = $r;
+ return true;
+ }
+ public function fpStats(float $since, ?string $site): array {
+ $groups = [];
+ foreach ($this->rows as $r) {
+ if (($r['occurred_at'] ?? 0) < $since) continue;
+ if ($site !== null && ($r['site'] ?? null) !== $site) continue;
+ $key = ($r['camera_id'] ?? '') . '|' . ($r['event_type'] ?? '');
+ $groups[$key] = $groups[$key] ?? ['camera_id' => $r['camera_id'], 'event_type' => $r['event_type'], 'total' => 0, 'fp' => 0];
+ $groups[$key]['total']++;
+ if (($r['false_positive_by'] ?? null) !== null) $groups[$key]['fp']++;
+ }
+ $out = array_values(array_filter($groups, fn($g) => $g['fp'] > 0));
+ usort($out, fn($a, $b) => $b['fp'] <=> $a['fp']);
+ return $out;
+ }
public function heatmapPoints(string $cameraId, float $since, ?string $site, int $limit): array {
$r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId
&& ($x['occurred_at'] ?? 0) >= $since