feat: 이해관계자 페르소나 검증 4건 — 열람 감사 · 증거 보존 · 크론 생존 · 증거 정책 (1.3.0)
Some checks failed
verify / edge (push) Has been cancelled
verify / server (push) Has been cancelled

앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다.
5인 중 2인의 논증이 완전히, 2인이 부분적으로 무너졌다.

읽기 감사 — 이 시스템은 쓰기를 전부 감사했다. 임계값 한 번 바꾼 것까지 해시 체인에
들어간다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았다. 감시 시스템과 안전
시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼 7.5초·전송 전
얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 "본 사람"이 아니라 "요청한 사람"
목록이 되어 정작 누가 봤는지를 묻는 데 못 쓴다.

크론 생존 — 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다.
그런데 그 크론이 멈추면 아무도 판정하지 않는다. 통보·5분 확대·장치 이상 발행이 전부 그
한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 한다.

증거 보존 — 스냅샷 삭제가 나이 하나만 봤다. 기본 7일인데 중대재해 조사는 몇 주에서 몇
달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. 확인 처리로
자동 연장하지 않는 이유는 자동이면 사실상 전량 보존이 되어 7일 약속이 무너지기 때문이다.

증거 정책 — 프레임이 ergo_report 하나만 빼고 모든 이벤트에 붙었다. 체류 상한 초과도
포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안
주기적으로 본인 사진이 서버로 갔다. 위험해서가 아니라 오래 있어서. 기준을 명문화했다:
시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.

마이그레이션 2026-09-25-evidence-hold-and-view-audit.sql (README-UPLOAD 10번).

검증: phpunit 302 passed(신규 18, 스킵 0) · pytest(신규 18)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
This commit is contained in:
유창욱 2026-09-25 18:34:26 +09:00
parent 9db8958118
commit 3aca674a78
37 changed files with 1284 additions and 43 deletions

View file

@ -6,6 +6,63 @@
---
## 1.3.0 — 2026-09-25 · 읽기 감사 · 증거 보존 · 감시자 감시
이해관계자 5인(작업자·노조·안전관리자·공장장·설치기사) 관점으로 제품을 다시 검증한 결과
나온 네 가지입니다. 넷 다 같은 모양이었습니다 — **구현도 테스트도 초록인데, 그 기능이
존재해야 할 이유가 닿는 지점에서 아무 일도 일어나지 않는 상태.**
### 추가 — 열람·반출 감사기록
이 시스템은 쓰기를 전부 감사했습니다. 임계값 한 번 바꾼 것까지 해시 체인에 들어갑니다.
그런데 **작업자 사진을 열어보는 것은 하나도 남지 않았습니다.**
감시 시스템과 안전 시스템을 가르는 선이 정확히 거기입니다. 나머지 프라이버시 설계
(RAM 링버퍼 7.5초·전송 전 얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한
*주장* 에 머물러 있었습니다. 교섭에서 "지난달에 우리 조합원 사진 누가 봤습니까"에 답이
없으면 "안 봤습니다"는 증명이 아니라 주장입니다.
- 스냅샷 열람 → 감사 동작 `snapshot_view` (세션당 사진 1회)
- 라벨 데이터셋 대량 반출 → `label_export` (건수·기간과 함께)
### 추가 — 증거 보존 보류 (`hold_until`)
스냅샷은 나이만 보고 지워졌습니다. 기본 7일인데 중대재해 조사는 몇 주에서 몇 달이라,
**우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라지는** 상태였습니다.
admin 만, 건별로, 최대 1년, 감사기록과 함께 보류를 겁니다. 확인 처리만으로 자동 연장되지
않습니다 — 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다. 만료되면 사람이 해제하지
않아도 다시 삭제 대상이 됩니다. 사건 상세 화면에 버튼이 생깁니다.
### 추가 — 통보 디스패처 생존 표시
죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 **부재**로 판정합니다. 그런데 그
크론이 멈추면 아무도 판정하지 않습니다 — 통보·5분 미확인 확대·장치 이상 발행이 전부 그 한
프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 **그 침묵은 "사고 없음"과 구별되지
않습니다.** 관리자 화면이 이에 대해 하던 일은 "크론으로 실행 중이어야 합니다"라는 안내문
한 줄이었습니다.
이제 크론이 매 실행마다 시각을 남기고, 대시보드가 5분 넘게 갱신이 없으면 배너를 띄웁니다.
감시자의 생존은 감시자가 아닌 쪽이 읽어야 하기 때문입니다.
현장 경광등은 GPIO 직결이라 이 상황에서도 영향받지 않습니다.
### 변경 — 체류 상한 초과에는 사진을 붙이지 않습니다
증거 프레임은 `ergo_report` 하나만 빼고 **모든** 이벤트에 붙었습니다. 체류 상한 초과
(`overstay`)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 **정상 작업자가 규정대로
일하는 동안 주기적으로 본인 사진이 서버로 가는** 상태였습니다. 위험해서가 아니라 오래
있어서.
기준을 명문화했습니다 — **시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.**
규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이므로 그대로 둡니다.
### 마이그레이션
`2026-09-25-evidence-hold-and-view-audit.sql` — `move_events.hold_until` 컬럼과
`move_app_state` 표. README-UPLOAD.md 의 10번입니다.
---
## 1.2.1 — 2026-09-21 · 납품 정비 (기능 변경 없음)
감지 동작은 1.2.0 과 같습니다. 납품·운영에서 손으로 하던 일 세 가지를 닫았습니다.

View file

@ -1 +1 @@
1.2.1
1.3.0

View file

@ -194,3 +194,29 @@
(회전 도구 추가) 버전을 올렸다. 산출물 `move-detect-cafe24-1.2.1-20260921.zip` ·
`move-detect-pi-1.2.1-20260921-aarch64-cp311-hailo8.tar.gz`(91MB, MoveNet 폴백 포함)
## 2026-09-25 — 1.3.0 (이해관계자 페르소나 검증 4건)
앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·공장장·
설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다. **5인 중 2인의
논증이 완전히, 2인이 부분적으로 무너졌다.** 넷 다 "구현도 테스트도 초록인데 그 기능이 지켜야
할 것이 안 지켜지는" 같은 모양이었다.
- **열람 감사** — 쓰기는 임계값 변경까지 전부 해시 체인에 남는데 **사진 열람은 하나도 남지
않았다.** 감시 시스템과 안전 시스템을 가르는 선이 거기라서, 나머지 프라이버시 설계
(RAM 링버퍼·전송 전 블러·라이브뷰 부재)가 전부 검증 불가능한 주장이었다. `snapshot_view`
(세션당 1회) · `label_export`(건수·기간). 거부된 열람은 기록하지 않는다 — 그러면 감사 로그가
"본 사람"이 아니라 "요청한 사람" 목록이 되어 정작 쓸 수 없다
- **크론 생존** — 통보·5분 확대·`device_fault` 발행이 전부 한 프로세스에 걸려 있는데, 그게
멈추면 화면이 조용해지고 **그 침묵이 "사고 없음"과 구별되지 않았다.** 관리자 화면의 조치는
산문 안내 한 줄. 이제 크론이 실행 시각을 남기고 대시보드가 그 나이를 읽는다(5분 기준)
- **증거 보존 보류** — 스냅샷 삭제가 나이만 봤다. 기본 7일인데 중대재해 조사는 몇 주~몇 달이라
우리가 내건 목적이 8일째에 사라졌다. admin 만·건별·최대 1년·감사기록과 함께.
확인 처리로 자동 연장하지 않는다 — 자동이면 사실상 전량 보존이라 7일 약속이 무너진다
- **증거 정책** — 프레임이 `ergo_report` 빼고 **모든** 이벤트에 붙었다. 체류 상한 초과 포함이라
밀폐공간 기본값(30분) 기준 정상 작업자가 규정대로 일하는 동안 주기적으로 사진이 갔다.
기준 명문화: **시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다**
- 마이그레이션 `2026-09-25-evidence-hold-and-view-audit.sql`(README-UPLOAD 10번).
노조 대응 문서의 "가장 강한 카드가 비어 있다"던 항목이 닫혔고, 7일 원칙의 예외(보류)를
정직하게 적었다
- 산출물 `move-detect-cafe24-1.3.0-20260925.zip` · `move-detect-pi-1.3.0-20260925-aarch64-cp311-hailo8.tar.gz`

View file

@ -55,6 +55,8 @@
- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·
`backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) ·
`rotate_credentials.php`(장치 HMAC 키·관리자 비번 회전 — DB 미접속, 붙여넣을 SQL 만 출력).
`dispatch_alerts.php` 는 매 실행마다 `move_app_state['dispatch_alerts.last_run']` 을 남기고,
대시보드(`/api/devices` → `dispatcher`)가 그 나이를 읽어 크론이 멈춘 것을 표시한다.
전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·

View file

@ -1,9 +1,10 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
> **판정 (2026-09-21, 1.2.1): 적합 — 오프하드웨어 항목 전량 해소.**
> **판정 (2026-09-25, 1.3.0): 적합 — 오프하드웨어 항목 전량 해소.**
> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 +
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
> 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다.
> **5차 이해관계자 페르소나 검증(2026-09-25) 4/4** — 열람 감사·크론 생존·증거 보존·증거 정책.
> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐.
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
@ -532,6 +533,54 @@ config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발
---
## 5차 — 이해관계자 페르소나 검증 (2026-09-25, 1.3.0)
앞선 네 번은 전부 "이 코드가 제대로 도는가"를 물었다. 이번에는 **"이 기능이 존재해야 할
이유가 실제로 닿는가"** 를 물었다 — 작업자·노조 대표·안전관리자·공장장·설치기사 5인의
관점에서 각각 3단논법을 세우고, 전제를 코드로 확인한 뒤 반문으로 깼다.
5인 중 2인의 논증이 완전히 무너졌고 2인이 부분적으로 무너졌다. 넷 다 같은 모양이었다:
구현도 단위테스트도 초록인데, **그 기능이 지켜야 할 것이 지켜지지 않는 상태.**
- [x] **S5-1 [B]** 스냅샷 열람·대량 반출이 감사기록에 남지 않았다 (`SnapshotProxy::serve`,
`ExportController::labels`). 쓰기는 임계값 변경까지 전부 해시 체인에 남는데 읽기는 하나도
남지 않았다 — 감시 시스템과 안전 시스템을 가르는 선이 정확히 거기고, 나머지 프라이버시
설계 전부가 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
DONE — `snapshot_view`(세션당 사진 1회) · `label_export`(건수·기간). 거부된 열람은
기록하지 않는다(감사 로그가 "본 사람"이 아니라 "요청한 사람" 목록이 되면 쓸 수 없다).
기록 실패가 사진 열람을 막지 않되 세션 메모에 넣지 않아 다음 요청이 재시도한다.
테스트 `ReadAuditTest` 8건.
- [x] **S5-2 [B]** 통보 크론이 멈춘 것을 아무도 감지하지 못했다. 통보·5분 확대·`device_fault`
발행이 전부 `dispatch_alerts.php` 한 프로세스에 걸려 있는데, 그게 멈추면 화면은 조용해지고
그 침묵이 "사고 없음"과 구별되지 않는다. 관리자 화면의 조치는 산문 안내 한 줄이었다.
DONE — 크론이 `move_app_state` 에 실행 시각을 남기고 `/api/devices` 가 그 나이를 읽는다
(5분 기준). "한 번도 안 돎"과 "멈춤"을 구별한다 — 조치가 다르기 때문이다. 마이그레이션 전
배포에서 상태 패널 전체가 죽지 않도록 예외를 삼킨다. 테스트 `DispatcherLivenessTest`.
- [x] **S5-3 [H]** 스냅샷 삭제가 나이만 봤다(`purgeOlderThan`). 기본 7일인데 중대재해 조사는
몇 주~몇 달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다.
DONE — `move_events.hold_until`. admin 만, 건별로, 최대 1년, 감사기록과 함께. 확인 처리로
자동 연장하지 않는다(자동이면 사실상 전량 보존이 되어 7일 약속이 무너진다). 고아 스냅샷은
LEFT JOIN 으로 여전히 삭제 대상 — INNER JOIN 이면 영원히 안 지워지는 PIPA 누출이 된다.
테스트 `EvidenceHoldTest` 8건.
- [x] **S5-4 [H]** 증거 프레임이 `ergo_report` 하나만 빼고 **모든** 이벤트에 붙었다. 체류 상한
초과(`overstay`)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로
일하는 동안 주기적으로 본인 사진이 서버로 갔다 — 위험해서가 아니라 오래 있어서.
DONE — `edge/app.py` `NO_EVIDENCE_TYPES`. 기준은 "시간 경과 자체가 조건인 이벤트에는
사진을 붙이지 않는다". 규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이라 유지.
`run_once` 를 실제로 태워 같은 배치에서 한쪽은 증거를 들고 다른 쪽은 안 드는지 본다.
### 이번 검증에서 확인한 잔여 (코드로 닫을 수 없음)
- **노조 대표 열람 계정** — 감사기록이 생겼으므로 이제 의미를 갖는다. 계정 발급은 사업주 결정.
- **보관기간 화면 표시** — 설정 파일 안이 아니라 눈에 보이는 곳에. 다음 UI 개정에서.
- **SHA256SUMS 미서명** — `SIGN_KEY` 미설정이라 손상은 잡고 위변조는 못 잡는다. 폐쇄망 USB
전달 경로의 위협 모델에서는 수용 가능하고 문서에 정직하게 적혀 있다. 결정으로 기록한다.
---
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.

View file

@ -13,7 +13,9 @@
|---|---|---|---|
| 프레임 링버퍼 | **엣지 노드 RAM에만** | **90프레임 = 7.5초**(기본 12fps). 계속 덮어씀. **디스크에 쓰지 않음** | `edge/snapshot.py` `RingBuffer(maxlen=90)`, `TARGET_FPS=12` |
| 증거 스냅샷 | 서버 | **이벤트가 발생했을 때만 3장** — 이벤트 **2.5초 전·1초 전·그 순간**, 얼굴 블러 후 JPEG | `edge/app.py` `SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)` |
| 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제** | `Config::snapshotRetentionDays() = 7` |
| 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제**. 예외는 아래 '보존 보류' 한 가지 | `Config::snapshotRetentionDays() = 7` |
| 스냅샷 **열람** | 서버 | **누가 언제 어느 사진을 열었는지 감사기록에 남음** | `SnapshotProxy::recordView()`, 감사 동작 `snapshot_view` |
| 기록 **반출** | 서버 | 대량 내보내기도 건수와 함께 기록됨 | `ExportController`, 감사 동작 `label_export` |
| 이벤트 기록(텍스트) | 서버 | 기본 영구 — **개인 식별 정보 없음** | `Config::eventRetentionDays() = 0` |
> **"7.5초를 보관하는 것 아니냐"** — 그 7.5초는 열람할 수 있는 화면도 API도 없고, 12분의 1초마다
@ -106,22 +108,24 @@
- **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아
지우거나 고치면 검증에서 드러납니다
- **스냅샷 7일 자동 삭제**, 이후에는 통계와 텍스트 기록만 남음
- **열람도 기록됩니다** — 누가 언제 어느 사진을 열었는지가 같은 해시 체인에 남습니다.
쓰기만 남기고 읽기는 안 남기면 "안 봤다"는 주장에 그치는데, 이제 기록으로 답할 수 있습니다
- **오래 머물렀다는 이유로는 사진이 남지 않습니다** — 체류 상한 초과는 시간 경과만으로
발화하므로 증거 프레임을 붙이지 않습니다. 사진이 남는 것은 사람이 쓰러졌거나 위험구역에
들어갔거나 2인1조가 깨졌을 때입니다 (`edge/app.py` `NO_EVIDENCE_TYPES`)
### 아직 없는 것 — 보완을 권고합니다
### 남은 것 — 운영으로 해야 하는 두 가지
**스냅샷을 누가 열람했는지가 감사기록에 남지 않습니다.** 현재 감사기록은 쓰기
동작(확인·오탐·구역 변경·설정 변경·로그인)만 기록합니다(`server/src/**` 의
`audit->append` 호출부 기준).
열람 감사기록은 **구현되었습니다**(2026-09-25). 이 문서가 "가장 강한 카드가 비어 있다"고
지적했던 항목이고, 이제 누가 언제 어느 사진을 열었는지가 설정 변경과 같은 해시 체인에
남습니다. 대량 반출도 건수와 함께 기록됩니다.
이것은 노조 협의에서 **가장 강한 카드가 될 수 있는 항목이 비어 있는 것**입니다.
"아무도 함부로 보지 않는다"를 약속이 아니라 **기록으로** 만들 수 있습니다.
코드로 닫을 수 없는 두 가지가 남습니다 — 둘 다 사업주의 결정입니다.
권고 3가지:
1. **스냅샷 열람을 감사기록에 남긴다** — 누가, 언제, 어느 이벤트의 사진을 열었는지.
2. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
1. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
감시자를 감시할 수 있게 하는 것이 불신을 푸는 가장 빠른 길입니다.
3. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
기록이 생겼으므로 이제 이 권한이 실제로 의미를 가집니다.
2. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
---
@ -150,6 +154,25 @@
노조에게는 "사진은 일주일이면 사라지고, 남는 것은 몇 시에 무슨 일이 있었다는
기록뿐"이라고 설명할 수 있고, 사업주에게는 보존 의무를 충족한다고 설명할 수 있습니다.
#### 예외 하나 — 사고로 확정된 건의 증거 보존 (2026-09-25 추가)
중대재해 조사는 몇 주에서 몇 달입니다. 7일 규칙만 있으면 **조사가 시작되기도 전에 사진이
사라져**, 이 시스템을 도입한 목적("사고 후 원인 분석") 자체가 성립하지 않습니다.
그래서 **명시적 보존 보류**를 둡니다. 이것이 7일 원칙의 훼손이 아닌 이유:
| | 내용 |
|---|---|
| 걸 수 있는 사람 | **admin 만** (`manage_compliance`). 현장 운영자는 못 겁니다 |
| 대상 | 보류를 건 **그 사건 한 건**. 전체 보존으로 바뀌지 않습니다 |
| 기간 | 화면 기본 90일, **최대 1년**. 무기한 보류는 아예 불가능합니다 |
| 기록 | 누가 언제 얼마나 걸었는지 · 해제했는지가 감사 체인에 남습니다 |
| 만료 후 | 자동으로 다시 삭제 대상이 됩니다. 사람이 해제하지 않아도 됩니다 |
노조에는 이렇게 설명할 수 있습니다 — **"전부 지웁니다. 예외는 사고로 확정돼 조사가 걸린
건뿐이고, 그것도 최대 1년이며, 누가 걸었는지가 기록에 남습니다."** 확인 처리만으로 자동
연장되지 않는다는 점이 중요합니다: 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다.
### 5-3. 노조 협의는 이기고 지는 문제가 아니라 절차 문제입니다
**근로자참여 및 협력증진에 관한 법률 제20조**는 **"사업장 내 근로자 감시 설비의 설치"**

File diff suppressed because one or more lines are too long

View file

@ -6,7 +6,7 @@
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
버전 1.2.1 · 2026-09-21
버전 1.3.0 · 2026-09-25
---

View file

@ -14,6 +14,27 @@ from edge.events import Event, EventType, severity_for
# The server accepts at most 3 snapshots per event (EventValidator), so this is the cap.
SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)
# 사진을 붙이지 않는 유형.
#
# 기준은 "이 사진이 사건의 증거인가, 그 사람이 거기 있었다는 기록인가"이다. 넘어짐·추락·협착은
# 무슨 일이 일어났는지가 프레임 안에 있고, 단독작업·정원 초과는 규칙 위반 자체가 인원 수라서
# 사진이 그 수를 증명한다(관리자가 오탐인지 확인하는 수단이기도 하다). 아래 넷은 다르다:
#
# ERGO_REPORT — 카메라 단위 익명 통계. 프레임을 붙이면 통계가 감시 영상이 된다.
# OVERSTAY — 발화 조건이 시간 경과 하나뿐이다. "정상 작업을 오래 했다"는 사건이 아니고,
# 사진이 말해 주는 것은 그게 누구였고 무엇을 입었는지뿐이다. 그 사람이 실제로
# 쓰러지면 fall/no_motion 이 따로 발화하고 그쪽에는 증거가 붙는다.
# DEVICE_FAULT / MONITORING_DEGRADED — 장치 상태. 사람이 찍힐 이유가 없다.
#
# 이 목록이 짧게 유지되는 것이 중요하다. "정상 근무 중에는 아무것도 남지 않는다"는 우리가
# 작업자에게 한 약속인데, 시간이 조건인 유형에 사진을 붙이는 순간 그 약속이 거짓이 된다.
NO_EVIDENCE_TYPES = frozenset({
EventType.ERGO_REPORT,
EventType.OVERSTAY,
EventType.DEVICE_FAULT,
EventType.MONITORING_DEGRADED,
})
def _head_boxes(persons: list) -> list:
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed
@ -85,10 +106,8 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
for tr in lost:
pool.forget(tr.track_id)
if events:
# ERGO_REPORT is an anonymous aggregate (posture-exposure sums) — attaching a frame
# would turn a statistic into surveillance imagery. Capture only when a real incident
# needs evidence, and never attach snapshots to the ergo report itself.
evidential = [e for e in events if e.event_type != EventType.ERGO_REPORT]
# 증거가 필요한 사건에만 프레임을 뜬다 — NO_EVIDENCE_TYPES 주석이 그 경계를 정한다.
evidential = [e for e in events if e.event_type not in NO_EVIDENCE_TYPES]
snaps = []
if evidential:
# Evidence timeline, oldest→now: how the incident DEVELOPED, not just its end
@ -98,7 +117,7 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
frames, boxes = ring.timeline(now, SNAPSHOT_OFFSETS_S)
snaps = snapshotter.capture(frames, boxes)
for e in events:
sink.try_send(e, [] if e.event_type == EventType.ERGO_REPORT else snaps)
sink.try_send(e, [] if e.event_type in NO_EVIDENCE_TYPES else snaps)
return events

View file

@ -96,6 +96,7 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 |
| 8 | `2026-08-09-safety-platform.sql` | 통보 채널·억제·위험성평가·인수인계·성능 시계열 표, 감사로그 해시체인, 신규 감지 유형 |
| 9 | `2026-09-04-fall-from-height.sql` | 추락(`fall_from_height`)·동시 다발 쓰러짐(`multi_down`) 유형 허용 |
| 10 | `2026-09-25-evidence-hold-and-view-audit.sql` | 증거 보존 보류(`hold_until`) + 통보 크론 생존 기록(`move_app_state`) |
1. phpMyAdmin → 해당 DB → **SQL** 탭
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다

View file

@ -0,0 +1,36 @@
-- 1.3.0 — 증거 보존(legal hold) + 디스패처 생존 기록
--
-- 적용 조건: 2026-09-04-fall-from-height.sql 이후.
--
-- 왜 이 두 개가 한 마이그레이션인가: 둘 다 "조용한 실패"를 드러내는 같은 종류의 장치다.
--
-- 1) move_events.hold_until — 스냅샷 보존 보류
-- 스냅샷은 보존기간이 지나면 나이만 보고 지워졌다. 중대재해 조사는 몇 주에서 몇 달인데
-- 사진은 7일이면 사라진다. 우리가 노조와 고객에게 말해 온 목적("사고 후 원인 분석")이
-- 8일째에 증발하는 상태였다. 보류는 명시적 조작이어야 하고(자동 연장은 결국 전량 보존이
-- 되어 프라이버시 약속을 무너뜨린다) 감사에 남아야 한다.
--
-- 2) move_app_state — 크론이 마지막으로 돈 시각
-- 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그
-- 크론이 죽으면 아무도 판정하지 않는다. 통보·확대·장치 이상 발행이 전부 한 프로세스에
-- 걸려 있는데, 화면에는 "크론으로 실행 중이어야 합니다"라는 안내문만 있었다.
-- 감시자의 생존은 감시자가 아닌 쪽(운영자의 브라우저)이 읽어야 하므로, 크론이 자기
-- 실행 시각을 남기고 대시보드가 그 나이를 본다.
ALTER TABLE move_events
ADD COLUMN hold_until BIGINT UNSIGNED NULL AFTER false_positive_at;
-- 보존 정리는 "이 스냅샷의 이벤트가 보류 중인가"를 건마다 묻는다. 보류는 드물기 때문에
-- 전체 스캔이 아니라 보류된 것만 찾는 인덱스가 맞다.
CREATE INDEX ix_events_hold ON move_events (hold_until);
CREATE TABLE move_app_state (
k VARCHAR(64) NOT NULL,
v VARCHAR(255) NOT NULL,
updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (k)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 검증 쿼리 — 아래 둘이 각각 1행, 0행을 내면 성공입니다.
-- SHOW COLUMNS FROM move_events LIKE 'hold_until';
-- SELECT * FROM move_app_state;

View file

@ -992,6 +992,7 @@
})
.then(function (data) {
if (data && data.storage) renderStorage(data.storage);
if (data) renderDispatcher(data.dispatcher);
if (data && Array.isArray(data.devices)) {
deviceList = data.devices;
devicesLoaded = true;
@ -1036,6 +1037,35 @@
+ '보존기간 설정을 줄이거나 용량을 확보하세요.'));
}
// --- notification dispatcher liveness -----------------------------------------------
// 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그 크론이
// 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이 전부 그 한 프로세스에
// 걸려 있어서, 멈춘 순간부터 이 화면은 조용해지고 그 침묵은 "사고 없음"과 똑같이 보인다.
// 감시자의 생존은 감시자가 아닌 쪽이 읽어야 해서, 그 판정이 여기 있다.
var dispatchWarn = document.getElementById('dispatch-warn');
function renderDispatcher(d) {
if (!dispatchWarn) return;
if (!d || !d.stale) { dispatchWarn.hidden = true; return; }
dispatchWarn.hidden = false;
dispatchWarn.textContent = '';
var never = d.last_run_at === null || d.last_run_at === undefined;
dispatchWarn.appendChild(spanWith('warn-lead', never ? '통보가 설정되지 않았습니다' : '통보가 멈췄습니다'));
dispatchWarn.appendChild(document.createTextNode(never
? ' — 통보 디스패처가 한 번도 실행된 적이 없습니다. 크론에 tools/dispatch_alerts.php 를 1분 주기로 등록하세요. '
+ '등록 전까지 메일·웹훅·5분 미확인 확대·장치 이상 알림이 나가지 않습니다 (현장 경광등은 영향 없음).'
: ' — 마지막 실행 ' + fmtAgo(d.seconds_since) + ' 전 (기준 '
+ Math.round(d.stale_after_s / 60) + '분). 이 상태에서는 화면이 조용한 것이 "사고 없음"이라는 뜻이 아닙니다. '
+ '메일·웹훅·미확인 확대·장치 이상 알림이 전부 멈춰 있습니다 (현장 경광등은 영향 없음).'));
}
function fmtAgo(s) {
if (typeof s !== 'number') return '?';
if (s < 120) return s + '초';
if (s < 7200) return Math.floor(s / 60) + '분';
return Math.floor(s / 3600) + '시간';
}
// --- bulk acknowledge ----------------------------------------------------------------
// For clearing a backlog. Criticals are excluded ON PURPOSE and the button says so: a fall
// is exactly the row a human must look at, and a control that can clear one in a sweep is a

View file

@ -158,11 +158,13 @@
.catch(function () { /* the POST surfaces it as a 403 */ });
}
function post(path) {
return fetch(B + '/events/' + encodeURIComponent(id) + path, {
method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' }
}).then(function (res) {
function post(path, body) {
var opt = { method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } };
if (body !== undefined) {
opt.headers['Content-Type'] = 'application/json';
opt.body = JSON.stringify(body);
}
return fetch(B + '/events/' + encodeURIComponent(id) + path, opt).then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('action_failed:' + res.status);
return res;
@ -242,6 +244,60 @@
actionsBox.appendChild(fp);
}
// --- 증거 보존 보류 -------------------------------------------------------------------
// 스냅샷은 기본 7일 뒤 지워진다. 그 규칙이 없으면 작업자 사진이 영원히 쌓이므로 규칙 자체는
// 옳은데, 중대재해 조사는 몇 주에서 몇 달이라 조사가 시작되기도 전에 사진이 사라졌다.
//
// renderActions 와 분리한 이유: 저쪽은 확인·오탐이 끝나면 조기 반환한다. 보류가 필요한
// 상황은 바로 그 "이미 확인된 진짜 사고"라서, 같은 함수에 두면 정작 필요할 때 안 보인다.
var holdBox = document.getElementById('detail-hold');
var HOLD_DAYS = 90;
function renderHold() {
if (!holdBox || !currentEvent) return;
if (window.APP_ROLE !== 'admin') { holdBox.hidden = true; return; }
holdBox.hidden = false;
holdBox.textContent = '';
var until = currentEvent.hold_until;
var held = typeof until === 'number' && until * 1000 > Date.now();
var btn = document.createElement('button');
btn.type = 'button';
btn.className = held ? 'fp-btn' : 'detail-link-btn';
btn.textContent = held ? '보존 보류 해제' : '증거 보존 (' + HOLD_DAYS + '일)';
btn.setAttribute('aria-label', held
? '증거 보존 보류를 해제합니다. 이후 보존기간이 지난 스냅샷은 정리 대상이 됩니다.'
: '이 사건의 스냅샷을 ' + HOLD_DAYS + '일간 보존기간 정리에서 제외합니다.');
btn.addEventListener('click', function () {
btn.disabled = true;
post('/hold', { days: held ? 0 : HOLD_DAYS })
.then(function (res) { return res ? res.json() : null; })
.then(function (d) {
if (!d) return;
currentEvent.hold_until = d.hold_until;
renderHold();
renderMeta();
})
.catch(function () {
btn.disabled = false;
var p = document.createElement('p');
p.className = 'detail-action-note is-error';
p.textContent = '보존 보류 처리에 실패했습니다.';
holdBox.appendChild(p);
});
});
holdBox.appendChild(btn);
var note = document.createElement('p');
note.className = 'detail-action-note';
note.textContent = held
? '보존 보류 중 — ' + new Date(until * 1000).toLocaleDateString('ko-KR')
+ '까지 이 사건의 사진이 자동 삭제되지 않습니다.'
: '조사가 진행 중이면 보존을 걸어두세요. 걸지 않으면 보존기간이 지난 사진은 자동으로 지워집니다.';
holdBox.appendChild(note);
}
function renderMeta() {
var ev = currentEvent || {};
meta.textContent = '';
@ -374,6 +430,7 @@
renderHead();
renderMeta();
renderActions();
renderHold();
renderLinks();
renderSnapshots(data.snapshots);
}

View file

@ -42,7 +42,8 @@ use App\Auth\PhpSessionStore;
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository,
PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository};
PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository,
PdoAppStateRepository};
use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController};
@ -93,6 +94,7 @@ $handovers = new PdoHandoverRepository($pdo);
$risks = new PdoRiskControlRepository($pdo);
$metrics = new PdoDeviceMetricRepository($pdo);
$actions = new PdoActionRepository($pdo);
$appState = new PdoAppStateRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts);
@ -101,13 +103,13 @@ $auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController();
$list = new EventListController($events, $snaps, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $events, $sess);
$proxy = new SnapshotProxy($snaps, $events, $sess, $audit);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg);
$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg, $appState);
$heatmap = new HeatmapController($events, $sess);
$fpStats = new FpStatsController($events, $sess);
$report = new ReportController($events, $sess);
$export = new ExportController($events, $snaps, $sess);
$export = new ExportController($events, $snaps, $sess, $audit);
$actionsCtl = new ActionsController($actions, $events, $sess);
$adminCtl = new AdminConfigController($thr, $alerts, $devices, $audit, $sess);
$suppressCtl = new SuppressionController($suppressions, $events, $devices, $audit, $sess);
@ -132,6 +134,7 @@ $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('POST', '/events/{id}/hold', fn(Request $q, array $p) => $detail->hold($q, $p));
$r->add('GET', '/api/zone-presets', fn(Request $q) => $zoneCtl->presets($q));
$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));

View file

@ -151,6 +151,11 @@ CREATE TABLE move_events (
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,
-- 증거 보존 보류. 이 시각까지는 보존기간 정리가 이 사건의 스냅샷을 지우지 않는다.
-- 중대재해 조사는 몇 주~몇 달인데 스냅샷 기본 보존은 7일이라, 보류가 없으면 "사고 후
-- 원인 분석"이라는 목적 자체가 8일째에 사라진다. 자동 연장이 아니라 명시적 조작으로만
-- 걸리고(자동이면 결국 전량 보존이 되어 프라이버시 약속이 무너진다) 감사에 남는다.
hold_until 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
@ -160,6 +165,7 @@ CREATE TABLE move_events (
KEY ix_events_occurred_at (occurred_at),
KEY ix_events_severity_occurred_at (severity, occurred_at),
KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b)
KEY ix_events_hold (hold_until), -- 보존 정리가 보류된 건만 빠르게 걸러낸다
KEY ix_events_device (device_id),
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
CONSTRAINT chk_events_type CHECK (event_type IN (
@ -414,6 +420,23 @@ CREATE TABLE move_device_metrics (
KEY ix_device_metrics_device_ts (device_id, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 감시자의 생존 기록.
--
-- 죽은 노드는 스스로 신고할 수 없어서 크론(tools/dispatch_alerts.php)이 하트비트의 부재로
-- 판정한다. 그런데 그 크론이 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이
-- 전부 그 한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
-- 구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 하므로, 크론이 매 실행마다 자기
-- 시각을 여기 남기고 대시보드(/api/devices)가 그 나이를 본다.
--
-- 범용 키-값이지만 용도를 넓히지 말 것: 설정은 config.php, 임계값은 move_thresholds 가 있다.
-- 여기 들어갈 것은 "프로세스가 언제 살아 있었는가" 뿐이다.
CREATE TABLE move_app_state (
k VARCHAR(64) NOT NULL,
v VARCHAR(255) NOT NULL,
updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (k)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===========================================================================
-- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above):
-- do NOT copy the statements below by hand — they are SQL comments and will not

View file

@ -6,7 +6,7 @@ use App\Config;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Security\SiteScope;
use App\Repository\{DeviceRepository, DeviceMetricRepository};
use App\Repository\{DeviceRepository, DeviceMetricRepository, AppStateRepository};
/**
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already
@ -27,13 +27,22 @@ final class DeviceStatusController
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
private const OFFLINE_AFTER_S = 120;
/**
* 통보 디스패처(크론)가 이 시간 넘게 자기 실행을 남기지 않았으면 멈춘 것으로 본다.
* 크론은 1분 주기이므로 5배 여유 — 공유호스팅에서 한두 주기가 밀리는 것과 진짜로
* 죽은 것을 가른다. 이 판정이 필요한 이유는 크론이 죽으면 통보·5분 확대·장치 이상
* 발행이 한꺼번에 조용히 멈추고, 그 침묵이 "사고 없음"과 똑같이 보이기 때문이다.
*/
private const DISPATCHER_STALE_AFTER_S = 300;
/** Series window: 24h of 30s beats is ~2,880 points, which draws fine and downloads small. */
private const SERIES_WINDOW_S = 86400;
private const SERIES_LIMIT = 3000;
public function __construct(
private DeviceRepository $devices, private SessionStore $sess,
private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null
private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null,
private ?AppStateRepository $state = null
) {}
public function json(Request $req, int $now): Response
@ -63,7 +72,11 @@ final class DeviceStatusController
];
}, $this->devices->listStatus($site));
return Response::json(['devices' => $devices, 'storage' => $this->storage()], 200);
return Response::json([
'devices' => $devices,
'storage' => $this->storage(),
'dispatcher' => $this->dispatcher($now),
], 200);
}
/**
@ -122,4 +135,35 @@ final class DeviceStatusController
'low' => $freeMb < $minFreeMb,
];
}
/**
* 통보 디스패처의 생존.
*
* 이 판정만 브라우저가 읽어야 하는 이유: 크론이 죽으면 크론이 발행하는 어떤 경고도
* 나오지 않는다. device_fault 는 하트비트의 부재로 노드를 판정하지만, 그 판정을 하는
* 주체가 바로 이 크론이다. 그래서 크론의 부재는 크론 밖에서만 보인다.
*
* never = 아직 한 번도 안 돌았다(설치 직후 크론 미등록). 이걸 stale 과 구별하는 이유는
* 조치가 다르기 때문이다 — 전자는 등록하는 것이고 후자는 왜 멈췄는지 보는 것이다.
*
* @return array{last_run_at:int|null, seconds_since:int|null, stale:bool, stale_after_s:int}
*/
private function dispatcher(int $now): array
{
$row = null;
try {
$row = $this->state?->get('dispatch_alerts.last_run');
} catch (\Throwable) {
// 표가 아직 없는(마이그레이션 전) 배포에서 상태 패널 전체가 죽지 않게 한다.
// 그 경우 "한 번도 안 돌았다"로 보이고, 그것이 실제로 맞는 상태다.
}
$last = $row !== null ? (int)$row['updated_at'] : null;
$since = $last !== null ? max(0, $now - $last) : null;
return [
'last_run_at' => $last,
'seconds_since' => $since,
'stale' => $since === null || $since > self::DISPATCHER_STALE_AFTER_S,
'stale_after_s' => self::DISPATCHER_STALE_AFTER_S,
];
}
}

View file

@ -9,6 +9,10 @@ use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
final class EventDetailController
{
/** 1년. 이보다 긴 보존이 필요하면 사건을 따로 내보내는 것이 맞지, 사진을 계속
* 들고 있는 것이 맞지 않다. 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. */
private const MAX_HOLD_DAYS = 365;
public function __construct(
private EventRepository $events, private SnapshotRepository $snaps,
private AuditRepository $audit, private SessionStore $sess
@ -68,4 +72,44 @@ final class EventDetailController
$this->audit->append($user, 'false_positive', "event:$id", [], []);
return Response::json(['ok' => true], 200);
}
/**
* POST /events/{id}/hold — 증거 보존 보류 (본문: {"days": 1..365} 또는 {"days": 0} 해제).
*
* 스냅샷은 기본 7일 뒤 지워진다. 그 규칙이 없으면 작업자 사진이 영원히 쌓이므로 규칙
* 자체는 옳은데, 중대재해 조사는 몇 주에서 몇 달이라 조사가 시작되기도 전에 사진 증거가
* 사라졌다. "사고 후 원인 분석" 이라는 우리가 내건 목적이 8일째에 스스로 지워지는 상태.
*
* 자동 연장이 아닌 이유는 확인(acknowledge)에 얹으면 사실상 전량 보존이 되어 프라이버시
* 약속이 무너지기 때문이고, admin(manage_compliance)으로 막은 이유는 보존기간 연장이
* 운영 편의가 아니라 규정 판단이기 때문이다. 누가 언제 얼마나 늘렸는지가 감사에 남는다 —
* 노조에 "전부 지웁니다, 단 사고 확정 건은 조사 종료까지, 기록과 함께" 라고 말할 수 있는
* 근거가 이 한 행이다.
*/
public function hold(Request $req, array $params): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) {
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);
$raw = $req->json()['days'] ?? null;
if (!is_numeric($raw)) return Response::json(['error' => 'bad_days'], 400);
$days = (int)$raw;
if ($days < 0 || $days > self::MAX_HOLD_DAYS) return Response::json(['error' => 'bad_days'], 400);
$until = $days === 0 ? null : time() + $days * 86400;
if (!$this->events->setHold($id, $until)) return Response::json(['error' => 'not_found'], 404);
$user = (string)$this->sess->get('username');
$this->audit->append($user, $until === null ? 'evidence_hold_release' : 'evidence_hold',
"event:$id", ['hold_until' => $event['hold_until'] ?? null], ['hold_until' => $until]);
return Response::json(['ok' => true, 'hold_until' => $until], 200);
}
}

View file

@ -4,7 +4,7 @@ namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Repository\{EventRepository, SnapshotRepository};
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
/**
* GET /api/export/labels?days=N -> JSONL download.
@ -29,7 +29,7 @@ final class ExportController
public function __construct(
private EventRepository $events, private SnapshotRepository $snaps,
private SessionStore $sess
private SessionStore $sess, private ?AuditRepository $audit = null
) {}
public function labels(Request $req): Response
@ -69,6 +69,14 @@ final class ExportController
'snapshots' => $uuids[(int)$r['event_id']] ?? [],
], JSON_UNESCAPED_UNICODE);
}
// 대량 반출은 남긴다. 이 한 번의 호출이 최대 2만 건의 사건과 그에 딸린 스냅샷 uuid
// 전부를 파일 하나로 내보내는데, 기록이 없으면 "지난달에 우리 현장 데이터가 통째로
// 나간 적 있습니까"에 답할 수 없다. 열람 감사(snapshot_view)와 짝이며, 이쪽은 드물게
// 일어나므로 무조건 한 행씩 남긴다. 반출 사실을 남기는 것이지 반출을 막는 장치가
// 아니므로, 기록이 실패해도 파일은 내보낸다.
$this->audit?->append((string)$this->sess->get('username'), 'label_export',
'export:labels', [], ['days' => $days, 'rows' => count($lines), 'site' => $site]);
$body = implode("\n", $lines) . ($lines ? "\n" : '');
return Response::bytes($body, 'application/x-ndjson; charset=utf-8', 200, [
'Content-Disposition' => 'attachment; filename="labels-' . date('Ymd') . '-' . $days . 'd.jsonl"',

View file

@ -5,12 +5,19 @@ namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Security\SiteScope;
use App\Repository\{SnapshotRepository, EventRepository};
use App\Repository\{SnapshotRepository, EventRepository, AuditRepository};
final class SnapshotProxy
{
/**
* 세션당 기억할 열람 uuid 수. 넘으면 오래된 것부터 버리고, 버려진 uuid 를 다시 보면
* 감사에 한 줄 더 남는다 — 넘치면 **덜 기록**이 아니라 **더 기록**하는 방향으로 틀어진다.
*/
private const VIEWED_MEMO_CAP = 500;
public function __construct(
private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess
private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess,
private ?AuditRepository $audit = null
) {}
public function serve(Request $req, array $params): Response
@ -30,6 +37,7 @@ final class SnapshotProxy
return Response::json(['error' => 'not_found'], 404);
}
}
$this->recordView($uuid);
// A snapshot is immutable per uuid, so let the browser cache it. Without this the event
// list — which rebuilds every <img src=/snap/{uuid}> on each 5s poll — re-downloads every
// visible full-size JPEG (a PHP call + DB reads + file read) every 5 seconds per open
@ -38,4 +46,42 @@ final class SnapshotProxy
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200,
['Cache-Control' => 'private, max-age=86400, immutable']);
}
/**
* 작업자 사진을 누가 언제 봤는지 남긴다.
*
* 이 시스템은 쓰기를 전부 감사한다 — 임계값 한 번 바꾼 것까지 해시 체인에 들어간다.
* 그런데 읽기는 하나도 남지 않았다. 관리자가 조합원 사진 200장을 열어봐도 흔적이 없고,
* 교섭에서 "안 봤습니다"는 증명이 아니라 주장이다. 감시 시스템과 안전 시스템을 가르는
* 선이 정확히 여기라서, 나머지 프라이버시 설계(RAM 링버퍼·전송 전 블러·라이브뷰 부재)가
* 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
*
* 세션 안에서 같은 uuid 는 한 번만 기록한다. 목록 화면은 5초마다 <img> 를 다시 그리고
* (브라우저 캐시가 대부분 막지만 시크릿 창·캐시 해제는 그대로 들어온다), 감사 추가는
* 체인 꼬리에 행 잠금을 잡는 작업이라 장당 한 행씩 쓰면 공유호스팅에서 직렬화된다.
* "이 로그인 세션에서 이 사진을 봤다"는 노조가 묻는 질문에 답하기 충분하다.
*
* 기록 실패가 사진 열람을 막지는 않는다 — 감사가 안전 기능을 죽이면 안 된다. 다만 그
* 경우 메모에 넣지 않아서 다음 요청이 다시 시도한다.
*/
private function recordView(string $uuid): void
{
if ($this->audit === null) return;
$seen = $this->sess->get('snap_viewed');
$seen = is_array($seen) ? $seen : [];
if (in_array($uuid, $seen, true)) return;
try {
$this->audit->append((string)$this->sess->get('username'), 'snapshot_view',
"snapshot:$uuid", [], []);
} catch (\Throwable) {
return; // 메모에 안 넣는다 → 다음 요청에서 재시도
}
$seen[] = $uuid;
if (count($seen) > self::VIEWED_MEMO_CAP) {
$seen = array_slice($seen, -self::VIEWED_MEMO_CAP);
}
$this->sess->set('snap_viewed', $seen);
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Repository;
/**
* "이 프로세스가 언제 살아 있었는가" 만 담는 작은 키-값 저장소.
*
* 용도를 넓히지 말 것 — 설정은 config.php, 장치 임계값은 move_thresholds 가 이미 있다.
* 여기 들어갈 것은 자기 자신을 감시할 수 없는 프로세스의 생존 흔적뿐이다.
*/
interface AppStateRepository
{
/** 값과 기록 시각. 키가 없으면 null. */
public function get(string $key): ?array;
/** 키를 현재 값으로 덮어쓴다(upsert). */
public function set(string $key, string $value, int $now): void;
}

View file

@ -32,6 +32,16 @@ interface EventRepository
* the FIRST tagger is preserved. Returns false only when the event does not exist.
*/
public function markFalsePositive(int $id, string $user): bool;
/**
* 증거 보존 보류. $until 까지 이 사건의 스냅샷은 보존기간 정리에서 제외된다.
* null 을 주면 보류를 해제한다. 존재하지 않는 사건이면 false.
*
* 자동이 아니라 명시적 조작인 이유: 확인(acknowledge)만으로 자동 연장하면 사실상 전량
* 보존이 되어 "사고 순간만, 7일만" 이라는 프라이버시 약속이 무너진다. 반대로 보류가
* 아예 없으면 조사가 시작되기도 전에 증거가 사라진다. 그 사이를 사람의 판단으로 가른다.
*/
public function setHold(int $id, ?int $until): 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.

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\AppStateRepository;
use PDO;
final class PdoAppStateRepository implements AppStateRepository
{
public function __construct(private PDO $pdo) {}
/** @return array{value: string, updated_at: int}|null */
public function get(string $key): ?array
{
$st = $this->pdo->prepare('SELECT v, updated_at FROM move_app_state WHERE k = ?');
$st->execute([$key]);
$row = $st->fetch();
if ($row === false) return null;
return ['value' => (string)$row['v'], 'updated_at' => (int)$row['updated_at']];
}
public function set(string $key, string $value, int $now): void
{
// upsert 문법이 엔진마다 다르다. sqlite 통합 테스트가 납품되는 실제 SQL 을 돌리므로
// 둘 다 맞아야 한다 — MySQL 은 ON DUPLICATE KEY, sqlite 는 ON CONFLICT.
$mysql = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite';
$sql = $mysql
? 'INSERT INTO move_app_state (k, v, updated_at) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE v = VALUES(v), updated_at = VALUES(updated_at)'
: 'INSERT INTO move_app_state (k, v, updated_at) VALUES (?, ?, ?)
ON CONFLICT(k) DO UPDATE SET v = excluded.v, updated_at = excluded.updated_at';
$this->pdo->prepare($sql)->execute([$key, $value, $now]);
}
}

View file

@ -126,6 +126,21 @@ final class PdoEventRepository implements EventRepository
return $row === false ? null : self::mapRow($row);
}
public function setHold(int $id, ?int $until): bool
{
// acknowledge() 와 같은 이유로 존재 확인을 따로 한다 — MySQL PDO 는 기본적으로
// '변경된 행'을 세므로, 같은 값으로 다시 보류하면 rowCount() 가 0 이 된다.
$exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1');
$exists->execute([$id]);
if ($exists->fetchColumn() === false) return false;
$st = $this->pdo->prepare('UPDATE move_events SET hold_until = ? WHERE event_id = ?');
$st->bindValue(1, $until, $until === null ? PDO::PARAM_NULL : PDO::PARAM_INT);
$st->bindValue(2, $id, PDO::PARAM_INT);
$st->execute();
return true;
}
public function acknowledge(int $id, string $user): bool
{
// Existence is checked separately (rather than trusting UPDATE's

View file

@ -121,16 +121,28 @@ final class PdoSnapshotRepository implements SnapshotRepository
return $id === false ? null : (int)$id;
}
public function purgeOlderThan(int $cutoff, int $limit = 500): int
public function purgeOlderThan(int $cutoff, int $limit = 500, ?int $now = null): int
{
$now ??= time();
// Bounded batch: a single request must not stall unlinking thousands of files on a
// shared host. Repeated ingests drain the backlog. Files first, then rows — an orphan
// row (file gone, row left) is harmless; an orphan file (row gone, file left) is the
// PIPA leak we are closing, so never delete the row before its file.
// 보존 보류(legal hold)된 사건의 스냅샷은 나이가 지나도 남긴다. 보류가 없던 동안
// 이 삭제는 나이 하나만 봤고, 그래서 중대재해 조사가 시작되기도 전에(스냅샷 기본
// 7일) 사진 증거가 사라졌다 — 우리가 내세운 목적이 스스로 지워지는 상태였다.
// LEFT JOIN 인 이유: 이벤트 행이 먼저 정리된 고아 스냅샷은 보류가 아니라 잔여물이고,
// 그건 지우는 쪽이 맞다(hold_until 이 NULL 로 나와 자연히 대상이 된다).
$sel = $this->pdo->prepare(
'SELECT snapshot_id, file_uuid FROM move_event_snapshots WHERE created_at < ? LIMIT ?');
'SELECT s.snapshot_id, s.file_uuid
FROM move_event_snapshots s
LEFT JOIN move_events e ON e.event_id = s.event_id
WHERE s.created_at < ?
AND (e.hold_until IS NULL OR e.hold_until <= ?)
LIMIT ?');
$sel->bindValue(1, $cutoff, PDO::PARAM_INT);
$sel->bindValue(2, $limit, PDO::PARAM_INT);
$sel->bindValue(2, $now, PDO::PARAM_INT);
$sel->bindValue(3, $limit, PDO::PARAM_INT);
$sel->execute();
$rows = $sel->fetchAll();
if (!$rows) {

View file

@ -23,7 +23,9 @@ interface SnapshotRepository
public function latestForCamera(string $cameraId): ?array;
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
* worker images must not be kept forever. Returns the number purged. */
* worker images must not be kept forever. Returns the number purged.
* 증거 보존 보류(move_events.hold_until)가 걸린 사건의 스냅샷은 나이와 무관하게 남긴다 —
* 조사가 끝나기 전에 증거가 사라지면 이 제품의 목적 자체가 사라진다. */
public function purgeOlderThan(int $cutoff): int;
/** ALL snapshot uuids per event, as [event_id => [file_uuid, ...]] in stored order —

View file

@ -12,6 +12,7 @@ render_header('이벤트 상세');
<!-- 판정은 증거를 본 화면에서 끝난다. 예전에는 여기서 판단하고 목록으로 되돌아가
그 행을 다시 찾아 눌러야 했다 — 판단하는 자리에 판단 도구가 없었다. -->
<div class="detail-actions" id="detail-actions" hidden></div>
<div class="detail-actions" id="detail-hold" hidden></div>
</header>
<div class="detail-links">

View file

@ -108,6 +108,7 @@ render_header('이벤트');
<!-- 스냅샷 저장 공간이 차면 이미지 기록이 조용히 실패한다 (이벤트는 계속 쌓인다).
증거가 사라지고 있다는 사실을 대시보드가 먼저 말해야 한다. -->
<div id="storage-warn" class="storage-warn" role="status" hidden></div>
<div id="dispatch-warn" class="storage-warn" role="status" hidden></div>
<!-- Catch-up line for the operator who just sat down: what the last 24h held while
nobody was watching. Hidden until the numbers arrive (an empty banner is noise). -->
<div id="day-brief" class="day-brief" hidden></div>

View file

@ -26,6 +26,10 @@ if (!function_exists('view_url')) {
if (!function_exists('render_header')) {
function render_header(string $title): void {
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
// 표시용일 뿐 권한 판정이 아니다. 보존 보류 버튼을 admin 에게만 보여주려고 쓰는데,
// 실제 차단은 EventDetailController 의 manage_compliance 검사가 한다 — 여기를
// 조작해도 서버가 403 을 낸다.
$role = is_string($_SESSION['role'] ?? null) ? $_SESSION['role'] : null;
// Which primary nav tab is active is derived from the page title so no view
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
// 페이지 이름은 nav 라벨 · <h1> · <title> 세 곳이 같은 문자열을 쓴다. 예전에는
@ -51,6 +55,7 @@ if (!function_exists('render_header')) {
<link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
<script nonce="<?= view_e(\App\Security\Csp::nonce()) ?>">
window.APP_BASE =<?= json_encode(defined('APP_BASE') ? APP_BASE : '', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
window.APP_ROLE = <?= json_encode($role ?? '', JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script>
</head>

View file

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Auth\ArraySessionStore;
use App\Dashboard\{DeviceStatusController, EventDetailController};
use App\Http\Request;
use App\Repository\AppStateRepository;
use App\Security\Csrf;
use App\Tests\Support\{InMemoryDeviceRepository, InMemoryEventRepository,
InMemorySnapshotRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase;
/**
* 감시자의 생존, 그리고 증거 보존 보류의 권한·기록.
*
* 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그 크론이
* 멈추면 아무도 판정하지 않는다 — 통보·5분 미확인 확대·장치 이상 발행이 전부 그 한
* 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과 똑같이
* 보인다. 그래서 크론이 자기 실행 시각을 남기고, 크론이 아닌 쪽(대시보드)이 그 나이를 읽는다.
*/
final class DispatcherLivenessTest extends TestCase
{
private function state(?int $lastRun): AppStateRepository
{
return new class($lastRun) implements AppStateRepository {
public function __construct(private ?int $lastRun) {}
public function get(string $key): ?array
{
if ($key !== 'dispatch_alerts.last_run' || $this->lastRun === null) return null;
return ['value' => (string)$this->lastRun, 'updated_at' => $this->lastRun];
}
public function set(string $key, string $value, int $now): void {}
};
}
private function dispatcherBlock(?AppStateRepository $state): array
{
$sess = new ArraySessionStore();
$sess->set('user_id', 1);
$sess->set('site_scope', null);
$ctl = new DeviceStatusController(new InMemoryDeviceRepository(), $sess, null, null, $state);
$res = $ctl->json(new Request('GET', '/api/devices', [], '', []), 10_000);
return json_decode($res->body(), true)['dispatcher'];
}
public function test_a_recent_run_reads_as_alive(): void
{
$d = $this->dispatcherBlock($this->state(10_000 - 30));
$this->assertFalse($d['stale']);
$this->assertSame(30, $d['seconds_since']);
}
public function test_a_stopped_cron_is_visible(): void
{
// 크론이 5분 넘게 안 돌았다 = 통보가 통째로 멈춘 상태. 화면이 조용한 것이
// "사고 없음"이라는 뜻이 아니게 되는 지점이고, 그걸 사람이 알아야 한다.
$d = $this->dispatcherBlock($this->state(10_000 - 600));
$this->assertTrue($d['stale']);
$this->assertSame(600, $d['seconds_since']);
}
public function test_never_run_is_distinguishable_from_stopped(): void
{
// 조치가 다르다 — 전자는 크론을 등록하는 것이고 후자는 왜 멈췄는지 보는 것이다.
$d = $this->dispatcherBlock($this->state(null));
$this->assertTrue($d['stale']);
$this->assertNull($d['last_run_at']);
$this->assertNull($d['seconds_since']);
}
public function test_a_missing_state_table_does_not_break_the_status_panel(): void
{
// 마이그레이션 전 배포에서 상태 패널 전체가 죽으면, 이 기능을 넣은 대가로 기존
// 장치 감시를 잃는다. 그 경우 "한 번도 안 돌았다"로 보이고, 그게 실제로 맞다.
$throwing = new class implements AppStateRepository {
public function get(string $key): ?array { throw new \RuntimeException('no such table'); }
public function set(string $key, string $value, int $now): void {}
};
$d = $this->dispatcherBlock($throwing);
$this->assertTrue($d['stale']);
$this->assertNull($d['last_run_at']);
}
public function test_the_panel_still_works_without_the_repository(): void
{
$d = $this->dispatcherBlock(null);
$this->assertTrue($d['stale']);
}
// --- 증거 보존 보류 --------------------------------------------------------------
/** @return array{0:EventDetailController,1:InMemoryEventRepository,2:InMemoryAuditRepository,3:ArraySessionStore,4:int} */
private function detail(string $role): array
{
$events = new InMemoryEventRepository();
$id = $events->insert(['device_id' => 'pi-01', 'camera_id' => 'cam-01',
'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => 1000.0, 'site' => null]);
$audit = new InMemoryAuditRepository();
$sess = new ArraySessionStore();
$sess->set('user_id', 1); $sess->set('username', '관리자');
$sess->set('role', $role); $sess->set('site_scope', null);
$sess->set('csrf', 'tok');
return [new EventDetailController($events, new InMemorySnapshotRepository(), $audit, $sess),
$events, $audit, $sess, $id];
}
private function holdReq(mixed $days): Request
{
return new Request('POST', '/events/1/hold', ['X-Csrf-Token' => 'tok'],
json_encode(['days' => $days]), []);
}
public function test_an_admin_can_hold_evidence_and_it_is_recorded(): void
{
[$ctl, $events, $audit, , $id] = $this->detail('admin');
$res = $ctl->hold($this->holdReq(90), ['id' => $id]);
$this->assertSame(200, $res->status());
$this->assertNotNull($events->rows[$id]['hold_until']);
$this->assertSame('evidence_hold', $audit->log[0]['act'],
'보존기간 연장이 기록되지 않으면, 노조에 "사고 건만 기록과 함께 남긴다"고 말할 근거가 없습니다.');
}
public function test_releasing_a_hold_is_recorded_separately(): void
{
[$ctl, $events, $audit, , $id] = $this->detail('admin');
$ctl->hold($this->holdReq(90), ['id' => $id]);
$res = $ctl->hold($this->holdReq(0), ['id' => $id]);
$this->assertSame(200, $res->status());
$this->assertNull($events->rows[$id]['hold_until']);
$this->assertSame('evidence_hold_release', $audit->log[1]['act']);
}
public function test_an_operator_cannot_extend_retention(): void
{
// 보존기간 연장은 운영 편의가 아니라 규정 판단이다.
[$ctl, $events, $audit, , $id] = $this->detail('operator');
$this->assertSame(403, $ctl->hold($this->holdReq(90), ['id' => $id])->status());
$this->assertNull($events->rows[$id]['hold_until'] ?? null);
$this->assertSame([], $audit->log);
}
public function test_an_unbounded_hold_is_refused(): void
{
// 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다.
[$ctl, $events, , , $id] = $this->detail('admin');
foreach ([400, -1, 'forever', null] as $bad) {
$this->assertSame(400, $ctl->hold($this->holdReq($bad), ['id' => $id])->status(),
'days=' . var_export($bad, true) . ' 를 통과시켰습니다');
}
$this->assertNull($events->rows[$id]['hold_until'] ?? null);
}
public function test_a_hold_without_csrf_is_refused(): void
{
[$ctl, , $audit, , $id] = $this->detail('admin');
$req = new Request('POST', '/events/1/hold', [], json_encode(['days' => 90]), []);
$this->assertSame(403, $ctl->hold($req, ['id' => $id])->status());
$this->assertSame([], $audit->log);
}
}

View file

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoAppStateRepository};
use PDO;
use PHPUnit\Framework\TestCase;
/**
* 증거 보존 보류 — 보존기간 정리가 조사 중인 사건의 사진을 지우지 않는다.
*
* 이 결함은 조용했다. 스냅샷 삭제는 나이 하나만 봤고, 기본 보존은 7일이다. 중대재해 조사는
* 몇 주에서 몇 달이라, 우리가 고객과 노조에 말해 온 목적("사고 후 원인 분석")이 8일째에
* 스스로 증발했다. 아무 오류도 나지 않고, 로그도 남지 않고, 그때가 되어서야 없는 걸 안다.
*
* 납품되는 실제 SQL(LEFT JOIN + hold 조건)이 도는 곳이 여기다 — 다른 테스트의 인메모리
* 대역은 쿼리 의미를 PHP 로 다시 구현하므로 쿼리가 틀려도 통과한다.
*/
final class EvidenceHoldTest extends TestCase
{
private PDO $pdo;
private string $dir;
protected function setUp(): void
{
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
$this->markTestSkipped('pdo_sqlite not available on this host');
}
$this->pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->pdo->exec(<<<'SQL'
CREATE TABLE move_events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
camera_id TEXT NOT NULL,
event_type TEXT NOT NULL,
severity TEXT NOT NULL,
occurred_at REAL NOT NULL,
duration_s REAL NOT NULL DEFAULT 0,
hold_until INTEGER,
received_at INTEGER NOT NULL
);
CREATE TABLE move_event_snapshots (
snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
file_uuid TEXT NOT NULL UNIQUE,
content_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE move_app_state (
k TEXT PRIMARY KEY,
v TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
SQL);
$this->dir = sys_get_temp_dir() . '/move-hold-' . bin2hex(random_bytes(6));
mkdir($this->dir);
}
protected function tearDown(): void
{
if (!isset($this->dir) || !is_dir($this->dir)) return;
foreach (glob($this->dir . '/*') ?: [] as $f) @unlink($f);
@rmdir($this->dir);
}
/** 한 사건 + 그 사건의 스냅샷 1장. 반환값은 [event_id, file_uuid]. */
private function seed(int $createdAt, ?int $holdUntil = null): array
{
$st = $this->pdo->prepare(
'INSERT INTO move_events (device_id, camera_id, event_type, severity, occurred_at,
hold_until, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?)');
$st->execute(['pi-01', 'cam-01', 'fall', 'high', (float)$createdAt, $holdUntil, $createdAt]);
$eventId = (int)$this->pdo->lastInsertId();
$uuid = bin2hex(random_bytes(16));
file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
$this->pdo->prepare(
'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at)
VALUES (?, ?, ?, ?)')->execute([$eventId, $uuid, str_repeat('a', 64), $createdAt]);
return [$eventId, $uuid];
}
private function snaps(): PdoSnapshotRepository
{
return new PdoSnapshotRepository($this->pdo, $this->dir);
}
public function test_an_old_snapshot_is_purged_when_nothing_holds_it(): void
{
[, $uuid] = $this->seed(1_000);
$purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
$this->assertSame(1, $purged);
$this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg');
}
public function test_a_held_snapshot_survives_its_retention_window(): void
{
[, $uuid] = $this->seed(1_000, holdUntil: 9_000);
$purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
$this->assertSame(0, $purged, '조사 중인 사건의 사진이 보존기간 때문에 지워졌습니다.');
$this->assertFileExists($this->dir . '/' . $uuid . '.jpg');
}
public function test_the_hold_stops_protecting_once_it_expires(): void
{
// 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. 기간이 지나면 다시 대상이 된다.
[, $uuid] = $this->seed(1_000, holdUntil: 2_500);
$purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
$this->assertSame(1, $purged);
$this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg');
}
public function test_one_hold_does_not_shield_other_events(): void
{
// JOIN 조건이 틀리면(예: 보류 행이 하나라도 있으면 전부 남김) 프라이버시 기본값이
// 조용히 "영구 보존"으로 뒤집힌다. 한 건만 남고 나머지는 지워져야 한다.
[, $held] = $this->seed(1_000, holdUntil: 9_000);
[, $a] = $this->seed(1_000);
[, $b] = $this->seed(1_000);
$purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
$this->assertSame(2, $purged);
$this->assertFileExists($this->dir . '/' . $held . '.jpg');
$this->assertFileDoesNotExist($this->dir . '/' . $a . '.jpg');
$this->assertFileDoesNotExist($this->dir . '/' . $b . '.jpg');
}
public function test_an_orphan_snapshot_is_still_purged(): void
{
// 이벤트 행이 먼저 정리된 스냅샷은 보류가 아니라 잔여물이다. LEFT JOIN 이 NULL 을
// 내므로 대상이 되어야 한다 — INNER JOIN 으로 쓰면 이게 영원히 안 지워지고,
// 작업자 사진이 보존기간을 넘겨 디스크에 남는 PIPA 누출이 된다.
$uuid = bin2hex(random_bytes(16));
file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
$this->pdo->prepare(
'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at)
VALUES (?, ?, ?, ?)')->execute([9999, $uuid, str_repeat('b', 64), 1_000]);
$this->assertSame(1, $this->snaps()->purgeOlderThan(2_000, 500, 3_000));
$this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg');
}
public function test_setting_and_clearing_a_hold_round_trips(): void
{
[$id] = $this->seed(1_000);
$events = new PdoEventRepository($this->pdo);
$this->assertTrue($events->setHold($id, 9_000));
$this->assertSame(0, $this->snaps()->purgeOlderThan(2_000, 500, 3_000));
$this->assertTrue($events->setHold($id, null));
$this->assertSame(1, $this->snaps()->purgeOlderThan(2_000, 500, 3_000));
}
public function test_holding_a_missing_event_reports_failure(): void
{
$this->assertFalse((new PdoEventRepository($this->pdo))->setHold(4242, 9_000));
}
public function test_app_state_upsert_round_trips(): void
{
$state = new PdoAppStateRepository($this->pdo);
$this->assertNull($state->get('dispatch_alerts.last_run'));
$state->set('dispatch_alerts.last_run', '1000', 1000);
$this->assertSame(['value' => '1000', 'updated_at' => 1000],
$state->get('dispatch_alerts.last_run'));
// 덮어쓰기여야 한다. 행이 쌓이면 대시보드가 첫 행(옛날 시각)을 읽고 크론이 죽었다고
// 오판하거나, 그 반대로 영원히 살아있다고 본다.
$state->set('dispatch_alerts.last_run', '2000', 2000);
$this->assertSame(['value' => '2000', 'updated_at' => 2000],
$state->get('dispatch_alerts.last_run'));
$this->assertSame(1, (int)$this->pdo->query('SELECT COUNT(*) FROM move_app_state')->fetchColumn());
}
}

View file

@ -460,9 +460,16 @@ final class PdoIntegrationTest extends TestCase
acknowledged_at INTEGER,
false_positive_by TEXT,
false_positive_at INTEGER,
hold_until INTEGER,
received_at INTEGER NOT NULL
);
CREATE TABLE move_app_state (
k TEXT PRIMARY KEY,
v TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE move_zones (
zone_id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,

View file

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Auth\ArraySessionStore;
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository};
use App\Dashboard\{SnapshotProxy, ExportController};
use App\Http\Request;
use PHPUnit\Framework\TestCase;
/**
* 읽기도 감사에 남는다.
*
* 이 시스템은 쓰기를 전부 남긴다 — 임계값 한 번 바꾼 것까지 해시 체인에 들어간다. 그런데
* 작업자 사진을 열어보는 것과 사건 전체를 파일로 내보내는 것은 하나도 남지 않았다. 감시
* 시스템과 안전 시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼·전송
* 전 얼굴 블러·라이브뷰 부재)가 전부 검증 불가능한 주장에 머물러 있었다.
*
* "안 봤습니다"는 증명이 아니다. 교섭 테이블에서 답이 되는 것은 기록뿐이고, 이 테스트가
* 지키는 것이 그 기록이다.
*/
final class ReadAuditTest extends TestCase
{
private string $dir;
protected function setUp(): void
{
$this->dir = sys_get_temp_dir() . '/move-readaudit-' . bin2hex(random_bytes(6));
mkdir($this->dir);
}
protected function tearDown(): void
{
foreach (glob($this->dir . '/*') ?: [] as $f) @unlink($f);
@rmdir($this->dir);
}
/** @return array{0:SnapshotProxy,1:InMemoryAuditRepository,2:ArraySessionStore,3:string} */
private function proxy(?string $siteScope = null): array
{
$events = new InMemoryEventRepository();
$eventId = $events->insert([
'device_id' => 'pi-01', 'camera_id' => 'cam-01', 'event_type' => 'fall',
'severity' => 'high', 'occurred_at' => 1000.0, 'site' => 'A동',
]);
$uuid = bin2hex(random_bytes(16));
file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
$snaps = new InMemorySnapshotRepository($this->dir);
$snaps->add($eventId, $uuid, str_repeat('a', 64));
$audit = new InMemoryAuditRepository();
$sess = new ArraySessionStore();
$sess->set('user_id', 7);
$sess->set('username', '관리자');
$sess->set('role', 'admin');
$sess->set('site_scope', $siteScope);
return [new SnapshotProxy($snaps, $events, $sess, $audit), $audit, $sess, $uuid];
}
private function req(): Request
{
return new Request('GET', '/snap', [], '', []);
}
public function test_viewing_a_worker_image_is_recorded(): void
{
[$proxy, $audit, , $uuid] = $this->proxy();
$res = $proxy->serve($this->req(), ['uuid' => $uuid]);
$this->assertSame(200, $res->status());
$this->assertCount(1, $audit->log, '사진을 열었는데 감사에 아무것도 남지 않았습니다.');
$this->assertSame('snapshot_view', $audit->log[0]['act']);
$this->assertSame("snapshot:$uuid", $audit->log[0]['t']);
$this->assertSame('관리자', $audit->log[0]['a']);
}
public function test_the_same_image_in_one_session_is_recorded_once(): void
{
// 목록 화면은 5초마다 <img> 를 다시 그린다. 감사 추가는 해시 체인 꼬리에 행 잠금을
// 잡는 작업이라, 장당 한 행씩 쓰면 공유호스팅에서 직렬화된다. "이 로그인 세션에서
// 이 사진을 봤다"가 노조가 묻는 질문에 답하기 충분하다.
[$proxy, $audit, , $uuid] = $this->proxy();
for ($i = 0; $i < 5; $i++) $proxy->serve($this->req(), ['uuid' => $uuid]);
$this->assertCount(1, $audit->log);
}
public function test_a_different_image_is_recorded_separately(): void
{
[$proxy, $audit, , $uuid] = $this->proxy();
$proxy->serve($this->req(), ['uuid' => $uuid]);
// 없는 uuid 는 404 라 기록도 없다 — 기록은 "본 것"이지 "요청한 것"이 아니다.
$missing = $proxy->serve($this->req(), ['uuid' => bin2hex(random_bytes(16))]);
$this->assertSame(404, $missing->status());
$this->assertCount(1, $audit->log);
}
public function test_an_unauthenticated_request_records_nothing(): void
{
[$proxy, $audit, $sess, $uuid] = $this->proxy();
$sess->set('user_id', null);
$this->assertSame(401, $proxy->serve($this->req(), ['uuid' => $uuid])->status());
$this->assertSame([], $audit->log);
}
public function test_a_blocked_cross_site_read_records_nothing(): void
{
// 거부된 열람은 열람이 아니다. 여기서 기록하면 감사 로그가 "본 사람" 목록이 아니라
// "요청한 사람" 목록이 되어, 정작 누가 봤는지를 묻는 데 쓸 수 없게 된다.
[$proxy, $audit, , $uuid] = $this->proxy(siteScope: 'B동');
$this->assertSame(404, $proxy->serve($this->req(), ['uuid' => $uuid])->status());
$this->assertSame([], $audit->log);
}
public function test_a_failing_audit_does_not_block_the_image(): void
{
// 감사가 안전 기능을 죽이면 안 된다 — 사고 조사 중에 DB 가 흔들린다고 사진이 안 열리면
// 그건 프라이버시 보호가 아니라 장애다. 대신 세션 메모에 넣지 않아 다음에 재시도한다.
$events = new InMemoryEventRepository();
$eventId = $events->insert(['device_id' => 'pi-01', 'camera_id' => 'cam-01',
'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => 1000.0, 'site' => null]);
$uuid = bin2hex(random_bytes(16));
file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
$snaps = new InMemorySnapshotRepository($this->dir);
$snaps->add($eventId, $uuid, str_repeat('a', 64));
$audit = new class implements \App\Repository\AuditRepository {
public int $calls = 0;
public function append(string $a, string $act, string $t, array $b, array $af): void
{ $this->calls++; throw new \RuntimeException('db down'); }
};
$sess = new ArraySessionStore();
$sess->set('user_id', 7); $sess->set('username', '관리자'); $sess->set('site_scope', null);
$proxy = new SnapshotProxy($snaps, $events, $sess, $audit);
$this->assertSame(200, $proxy->serve($this->req(), ['uuid' => $uuid])->status());
$proxy->serve($this->req(), ['uuid' => $uuid]);
$this->assertSame(2, $audit->calls, '기록 실패 후 재시도하지 않았습니다 — 조용히 누락됩니다.');
}
public function test_a_bulk_export_is_recorded_with_its_size(): void
{
$events = new InMemoryEventRepository();
for ($i = 0; $i < 3; $i++) {
$events->insert(['device_id' => 'pi-01', 'camera_id' => 'cam-01', 'event_type' => 'fall',
'severity' => 'high', 'occurred_at' => (float)(time() - 100), 'site' => null]);
}
$audit = new InMemoryAuditRepository();
$sess = new ArraySessionStore();
$sess->set('user_id', 7); $sess->set('username', '관리자');
$sess->set('role', 'admin'); $sess->set('site_scope', null);
$export = new ExportController($events, new InMemorySnapshotRepository($this->dir), $sess, $audit);
$res = $export->labels(new Request('GET', '/api/export/labels', [], '', ['days' => '30']));
$this->assertSame(200, $res->status());
$this->assertCount(1, $audit->log, '최대 2만 건이 파일 하나로 나가는데 기록이 없습니다.');
$this->assertSame('label_export', $audit->log[0]['act']);
$this->assertSame(30, $audit->log[0]['af']['days']);
$this->assertSame(3, $audit->log[0]['af']['rows']);
}
public function test_a_forbidden_export_is_not_recorded_as_one(): void
{
$sess = new ArraySessionStore();
$sess->set('user_id', 7); $sess->set('username', '작업자');
$sess->set('role', 'viewer'); $sess->set('site_scope', null);
$audit = new InMemoryAuditRepository();
$export = new ExportController(new InMemoryEventRepository(),
new InMemorySnapshotRepository($this->dir), $sess, $audit);
$res = $export->labels(new Request('GET', '/api/export/labels', [], '', []));
$this->assertSame(403, $res->status());
$this->assertSame([], $audit->log);
}
}

View file

@ -34,6 +34,7 @@ final class RouteTableTest extends TestCase
'POST /api/events', 'POST /api/heartbeat', 'GET /api/config', 'GET /api/csrf',
'POST /login', 'POST /logout', 'GET /api/events', 'GET /api/devices',
'GET /events/{id}', 'POST /events/{id}/ack', 'POST /events/{id}/false-positive',
'POST /events/{id}/hold',
'GET /api/zones/{camera}', 'GET /api/zone-presets',
'GET /api/heatmap/{camera}', 'GET /api/fp-stats',
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',

View file

@ -67,6 +67,11 @@ 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; $this->rows[$id]['acknowledged_at'] = $this->rows[$id]['acknowledged_at'] ?? time(); return true; }
public function setHold(int $id, ?int $until): bool {
if (!isset($this->rows[$id])) return false;
$this->rows[$id]['hold_until'] = $until;
return true;
}
public function markFalsePositive(int $id, string $user): bool {
if (!isset($this->rows[$id])) return false;
$r = $this->rows[$id];
@ -210,11 +215,14 @@ final class InMemoryEventRepository implements EventRepository {
}
final class InMemorySnapshotRepository implements SnapshotRepository {
public array $rows = []; public array $paths = []; public array $created = [];
/** 실제 파일이 있어야 하는 테스트(SnapshotProxy 는 is_file() 을 본다)가 쓸 디렉터리.
* 기본값은 예전 그대로 /tmp — 경로 문자열만 쓰던 기존 테스트가 그대로 돈다. */
public function __construct(private string $dir = '/tmp') {}
public function add(int $e, string $uuid, string $hash): void {
// Mirror the DB's UNIQUE (event_id, content_hash): a re-sent event's identical bytes
// collide instead of duplicating, so the dedup recovery path is idempotent.
foreach ($this->rows[$e] ?? [] as $r) { if ($r['content_hash'] === $hash) return; }
$this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time();
$this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = rtrim($this->dir, '/\\') . "/$uuid.jpg"; $this->created[$uuid] = time();
}
public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
public function thumbUuidsFor(array $eventIds): array {

View file

@ -32,7 +32,7 @@ require __DIR__ . '/bootstrap.php';
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch, MultiDownWatch};
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
PdoDeviceRepository, PdoDeviceMetricRepository};
PdoDeviceRepository, PdoDeviceMetricRepository, PdoAppStateRepository};
[$cfg, $pdo] = tool_boot();
$args = tool_args($argv);
@ -179,6 +179,24 @@ if (!$dryRun) {
}
}
// --- 5. 생존 기록 ---------------------------------------------------------------------
// 여기까지 왔다는 것은 이 프로세스가 돌았다는 뜻이다. 통보·확대·장치 이상 발행이 전부 이 한
// 프로세스에 걸려 있어서, 크론이 멈추면 화면은 그냥 조용해지고 그 침묵은 "사고 없음"과
// 구별되지 않는다. 죽은 노드를 하트비트의 부재로 판정하는 바로 그 논리를 우리 자신에게도
// 적용한다 — 감시자의 생존은 감시자가 아닌 쪽(대시보드)이 읽는다.
//
// 단계별 예외 처리 밖에, 마지막에 무조건 남긴다: 어느 단계가 실패했더라도 프로세스는 살아
// 있었고, 여기서 구별하려는 것은 "통보가 틀렸다"가 아니라 "아무도 안 돌고 있다"이다.
// dry-run 은 남기지 않는다 — 사람이 손으로 확인해 본 것을 크론이 도는 증거로 삼으면,
// 크론이 죽은 뒤에도 화면이 초록으로 남는다.
if (!$dryRun) {
try {
(new PdoAppStateRepository($pdo))->set('dispatch_alerts.last_run', (string)$now, $now);
} catch (Throwable $e) {
fwrite(STDERR, date('c') . ' 생존 기록 실패(통보에는 영향 없음): ' . $e->getMessage() . PHP_EOL);
}
}
fwrite(STDOUT, sprintf(
"%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d health=%d multi=%d%s\n",
date('c'), $stats['considered'], $stats['sent'], $stats['suppressed'],

View file

@ -246,3 +246,54 @@ def test_two_persons_get_independent_smoothing():
# state leaked between tracks.
assert left_nose_x < 300
assert right_nose_x > 700
def test_overstay_goes_out_without_evidence_while_the_same_batch_keeps_it():
"""체류 상한 초과에는 사진이 붙지 않는다 — 같은 배치의 구역 침입에는 붙는 채로.
상수만 검사하면 배선이 끊겨도 초록이 뜬다(이 프로젝트가 감사마다 잡아온 모양이다).
그래서 실제 run_once 를 태워, 한 번의 전송에서 한쪽은 증거를 들고 다른 쪽은 안 드는지를
본다. 체류 초과의 발화 조건은 시간 경과 하나뿐이라, 여기 사진이 붙으면 정상 작업자가
규정대로 일하는 동안 주기적으로 본인 사진이 서버로 가는 시스템이 된다.
"""
from edge.detectors.zone import Zone
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
class _SnapSink:
def __init__(self): self.sent = []
def try_send(self, event, snapshots):
self.sent.append((event, snapshots)); return True
poly = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] # 프레임 전체
zone = Zone("z1", "restricted", poly, {"max_dwell_s": 1.0})
src = _Source()
pose = _ScriptedPose([[_person_at(320)], [_person_at(320)]])
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([zone], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
# 기본 설정 그대로. 바꾸는 것은 존 규칙(현장 설정)인 max_dwell_s 뿐이다.
occupancy=OccupancyWatchdog(OccupancyConfig()))
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _SnapSink()
# 트래커·스무더는 프레임 간에 공유한다 — 체류는 "같은 트랙이 계속 있었는가"라서,
# 매 호출 새 트래커를 주면 실제와 다른 조건에서 통과할 수 있다.
tracker, pool = IouTracker(), TrackSmootherPool()
times = iter([0.0, 1.5])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
by_type = {e.event_type: s for e, s in sink.sent}
assert EventType.OVERSTAY in by_type, "체류 상한 초과가 발화하지 않았습니다 — 테스트가 무의미합니다"
assert by_type[EventType.OVERSTAY] == [], \
"체류 상한 초과에 사진이 붙었습니다. 오래 있었다는 이유로 작업자 사진이 남습니다."
# 대조군: 같은 파이프라인에서 진짜 사건에는 증거가 붙어 있어야 한다. 이게 없으면
# 위 단언은 "증거 기능이 통째로 죽었다"로도 통과한다.
assert EventType.ZONE_RESTRICTED in by_type, "구역 침입이 발화하지 않았습니다"
assert by_type[EventType.ZONE_RESTRICTED], \
"구역 침입에서 증거가 사라졌습니다 — 사고 조사에서 볼 것이 없어집니다."

View file

@ -0,0 +1,55 @@
"""사진이 붙는 이벤트와 붙지 않는 이벤트.
이 경계는 성능이나 용량 문제가 아니라 우리가 작업자에게 한 약속이다 — "정상 근무 중에는
아무것도 남지 않는다". 발화 조건이 시간 경과 하나뿐인 유형(체류 상한 초과)에 사진을 붙이면
그 약속이 거짓이 되고, 시스템은 30분 넘게 그 구역에 있었다는 이유만으로 그 사람의 사진 3장을
서버에 남긴다. 위험해서가 아니라 오래 있어서.
반대 방향도 같은 무게로 잠근다: 넘어짐에서 사진이 빠지면 사고 조사에 쓸 것이 없다.
"""
import pytest
from edge.app import NO_EVIDENCE_TYPES
from edge.events import EventType
# 사진이 사건의 증거인 유형 — 무슨 일이 일어났는지가 프레임 안에 있거나(넘어짐·협착),
# 규칙 위반 자체가 인원 수라서 프레임이 그것을 증명한다(단독작업·정원 초과).
EVIDENTIAL = [
EventType.FALL, EventType.FALL_FROM_HEIGHT, EventType.NO_MOTION, EventType.IMMOBILITY,
EventType.GHOST, EventType.NEAR_FALL, EventType.PINCH,
EventType.ZONE_WARNING, EventType.ZONE_RESTRICTED,
EventType.LONE_WORKER, EventType.OVERCAPACITY,
]
@pytest.mark.parametrize("etype", EVIDENTIAL, ids=lambda e: e.value)
def test_incident_types_carry_evidence(etype):
assert etype not in NO_EVIDENCE_TYPES, (
f"{etype.value} 에서 증거 프레임이 빠졌습니다. 사고 조사에서 볼 것이 사라집니다.")
@pytest.mark.parametrize("etype", sorted(NO_EVIDENCE_TYPES, key=lambda e: e.value),
ids=lambda e: e.value)
def test_non_incident_types_carry_no_evidence(etype):
# 목록에 든 것이 의도한 넷인지 고정한다. 새 유형을 여기 넣으려면 이 테스트를 고쳐야 하고,
# 그 순간 "왜 이 유형은 사진이 필요 없는가"를 한 번 더 답하게 된다.
assert etype in {
EventType.ERGO_REPORT, EventType.OVERSTAY,
EventType.DEVICE_FAULT, EventType.MONITORING_DEGRADED,
}
def test_overstay_is_excluded_because_time_alone_is_not_an_incident():
"""가장 중요한 한 줄 — 되돌리면 감시 시스템이 된다.
체류 상한 초과는 사람이 구역에 머무른 시간만으로 발화한다. 프리셋 기본값은 밀폐공간
30분·화기작업 60분이라, 사진을 붙이면 정상 작업자가 규정대로 일하는 동안 주기적으로
본인 사진이 서버로 가는 시스템이 된다.
"""
assert EventType.OVERSTAY in NO_EVIDENCE_TYPES
def test_the_exclusion_list_stays_small():
# 이 목록이 길어지고 있다면 경계가 "증거냐 기록이냐"에서 다른 무언가로 밀린 것이다.
assert len(NO_EVIDENCE_TYPES) <= 6