feat: 이해관계자 페르소나 검증 4건 — 열람 감사 · 증거 보존 · 크론 생존 · 증거 정책 (1.3.0)
앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 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:
parent
9db8958118
commit
3aca674a78
37 changed files with 1284 additions and 43 deletions
57
CHANGELOG.md
57
CHANGELOG.md
|
|
@ -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.1 — 2026-09-21 · 납품 정비 (기능 변경 없음)
|
||||||
|
|
||||||
감지 동작은 1.2.0 과 같습니다. 납품·운영에서 손으로 하던 일 세 가지를 닫았습니다.
|
감지 동작은 1.2.0 과 같습니다. 납품·운영에서 손으로 하던 일 세 가지를 닫았습니다.
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.2.1
|
1.3.0
|
||||||
|
|
|
||||||
|
|
@ -194,3 +194,29 @@
|
||||||
(회전 도구 추가) 버전을 올렸다. 산출물 `move-detect-cafe24-1.2.1-20260921.zip` ·
|
(회전 도구 추가) 버전을 올렸다. 산출물 `move-detect-cafe24-1.2.1-20260921.zip` ·
|
||||||
`move-detect-pi-1.2.1-20260921-aarch64-cp311-hailo8.tar.gz`(91MB, MoveNet 폴백 포함)
|
`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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@
|
||||||
- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·
|
- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·
|
||||||
`backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) ·
|
`backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) ·
|
||||||
`rotate_credentials.php`(장치 HMAC 키·관리자 비번 회전 — DB 미접속, 붙여넣을 SQL 만 출력).
|
`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 밖에 배치
|
전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치
|
||||||
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
|
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
|
||||||
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
# 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 +
|
> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 +
|
||||||
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
|
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
|
||||||
> 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다.
|
> 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다.
|
||||||
|
> **5차 이해관계자 페르소나 검증(2026-09-25) 4/4** — 열람 감사·크론 생존·증거 보존·증거 정책.
|
||||||
> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐.
|
> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐.
|
||||||
|
|
||||||
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
|
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 잔여 (실장비 커미셔닝에서만 검증 가능)
|
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
|
||||||
|
|
||||||
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
|
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 프레임 링버퍼 | **엣지 노드 RAM에만** | **90프레임 = 7.5초**(기본 12fps). 계속 덮어씀. **디스크에 쓰지 않음** | `edge/snapshot.py` `RingBuffer(maxlen=90)`, `TARGET_FPS=12` |
|
| 프레임 링버퍼 | **엣지 노드 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)` |
|
| 증거 스냅샷 | 서버 | **이벤트가 발생했을 때만 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` |
|
| 이벤트 기록(텍스트) | 서버 | 기본 영구 — **개인 식별 정보 없음** | `Config::eventRetentionDays() = 0` |
|
||||||
|
|
||||||
> **"7.5초를 보관하는 것 아니냐"** — 그 7.5초는 열람할 수 있는 화면도 API도 없고, 12분의 1초마다
|
> **"7.5초를 보관하는 것 아니냐"** — 그 7.5초는 열람할 수 있는 화면도 API도 없고, 12분의 1초마다
|
||||||
|
|
@ -106,22 +108,24 @@
|
||||||
- **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아
|
- **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아
|
||||||
지우거나 고치면 검증에서 드러납니다
|
지우거나 고치면 검증에서 드러납니다
|
||||||
- **스냅샷 7일 자동 삭제**, 이후에는 통계와 텍스트 기록만 남음
|
- **스냅샷 7일 자동 삭제**, 이후에는 통계와 텍스트 기록만 남음
|
||||||
|
- **열람도 기록됩니다** — 누가 언제 어느 사진을 열었는지가 같은 해시 체인에 남습니다.
|
||||||
|
쓰기만 남기고 읽기는 안 남기면 "안 봤다"는 주장에 그치는데, 이제 기록으로 답할 수 있습니다
|
||||||
|
- **오래 머물렀다는 이유로는 사진이 남지 않습니다** — 체류 상한 초과는 시간 경과만으로
|
||||||
|
발화하므로 증거 프레임을 붙이지 않습니다. 사진이 남는 것은 사람이 쓰러졌거나 위험구역에
|
||||||
|
들어갔거나 2인1조가 깨졌을 때입니다 (`edge/app.py` `NO_EVIDENCE_TYPES`)
|
||||||
|
|
||||||
### 아직 없는 것 — 보완을 권고합니다
|
### 남은 것 — 운영으로 해야 하는 두 가지
|
||||||
|
|
||||||
**스냅샷을 누가 열람했는지가 감사기록에 남지 않습니다.** 현재 감사기록은 쓰기
|
열람 감사기록은 **구현되었습니다**(2026-09-25). 이 문서가 "가장 강한 카드가 비어 있다"고
|
||||||
동작(확인·오탐·구역 변경·설정 변경·로그인)만 기록합니다(`server/src/**` 의
|
지적했던 항목이고, 이제 누가 언제 어느 사진을 열었는지가 설정 변경과 같은 해시 체인에
|
||||||
`audit->append` 호출부 기준).
|
남습니다. 대량 반출도 건수와 함께 기록됩니다.
|
||||||
|
|
||||||
이것은 노조 협의에서 **가장 강한 카드가 될 수 있는 항목이 비어 있는 것**입니다.
|
코드로 닫을 수 없는 두 가지가 남습니다 — 둘 다 사업주의 결정입니다.
|
||||||
"아무도 함부로 보지 않는다"를 약속이 아니라 **기록으로** 만들 수 있습니다.
|
|
||||||
|
|
||||||
권고 3가지:
|
1. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
|
||||||
|
|
||||||
1. **스냅샷 열람을 감사기록에 남긴다** — 누가, 언제, 어느 이벤트의 사진을 열었는지.
|
|
||||||
2. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
|
|
||||||
감시자를 감시할 수 있게 하는 것이 불신을 푸는 가장 빠른 길입니다.
|
감시자를 감시할 수 있게 하는 것이 불신을 푸는 가장 빠른 길입니다.
|
||||||
3. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
|
기록이 생겼으므로 이제 이 권한이 실제로 의미를 가집니다.
|
||||||
|
2. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -150,6 +154,25 @@
|
||||||
노조에게는 "사진은 일주일이면 사라지고, 남는 것은 몇 시에 무슨 일이 있었다는
|
노조에게는 "사진은 일주일이면 사라지고, 남는 것은 몇 시에 무슨 일이 있었다는
|
||||||
기록뿐"이라고 설명할 수 있고, 사업주에게는 보존 의무를 충족한다고 설명할 수 있습니다.
|
기록뿐"이라고 설명할 수 있고, 사업주에게는 보존 의무를 충족한다고 설명할 수 있습니다.
|
||||||
|
|
||||||
|
#### 예외 하나 — 사고로 확정된 건의 증거 보존 (2026-09-25 추가)
|
||||||
|
|
||||||
|
중대재해 조사는 몇 주에서 몇 달입니다. 7일 규칙만 있으면 **조사가 시작되기도 전에 사진이
|
||||||
|
사라져**, 이 시스템을 도입한 목적("사고 후 원인 분석") 자체가 성립하지 않습니다.
|
||||||
|
|
||||||
|
그래서 **명시적 보존 보류**를 둡니다. 이것이 7일 원칙의 훼손이 아닌 이유:
|
||||||
|
|
||||||
|
| | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 걸 수 있는 사람 | **admin 만** (`manage_compliance`). 현장 운영자는 못 겁니다 |
|
||||||
|
| 대상 | 보류를 건 **그 사건 한 건**. 전체 보존으로 바뀌지 않습니다 |
|
||||||
|
| 기간 | 화면 기본 90일, **최대 1년**. 무기한 보류는 아예 불가능합니다 |
|
||||||
|
| 기록 | 누가 언제 얼마나 걸었는지 · 해제했는지가 감사 체인에 남습니다 |
|
||||||
|
| 만료 후 | 자동으로 다시 삭제 대상이 됩니다. 사람이 해제하지 않아도 됩니다 |
|
||||||
|
|
||||||
|
노조에는 이렇게 설명할 수 있습니다 — **"전부 지웁니다. 예외는 사고로 확정돼 조사가 걸린
|
||||||
|
건뿐이고, 그것도 최대 1년이며, 누가 걸었는지가 기록에 남습니다."** 확인 처리만으로 자동
|
||||||
|
연장되지 않는다는 점이 중요합니다: 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다.
|
||||||
|
|
||||||
### 5-3. 노조 협의는 이기고 지는 문제가 아니라 절차 문제입니다
|
### 5-3. 노조 협의는 이기고 지는 문제가 아니라 절차 문제입니다
|
||||||
|
|
||||||
**근로자참여 및 협력증진에 관한 법률 제20조**는 **"사업장 내 근로자 감시 설비의 설치"**
|
**근로자참여 및 협력증진에 관한 법률 제20조**는 **"사업장 내 근로자 감시 설비의 설치"**
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,7 @@
|
||||||
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
|
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
|
||||||
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
|
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
|
||||||
|
|
||||||
버전 1.2.1 · 2026-09-21
|
버전 1.3.0 · 2026-09-25
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
29
edge/app.py
29
edge/app.py
|
|
@ -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.
|
# The server accepts at most 3 snapshots per event (EventValidator), so this is the cap.
|
||||||
SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)
|
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:
|
def _head_boxes(persons: list) -> list:
|
||||||
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed
|
# 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:
|
for tr in lost:
|
||||||
pool.forget(tr.track_id)
|
pool.forget(tr.track_id)
|
||||||
if events:
|
if events:
|
||||||
# ERGO_REPORT is an anonymous aggregate (posture-exposure sums) — attaching a frame
|
# 증거가 필요한 사건에만 프레임을 뜬다 — NO_EVIDENCE_TYPES 주석이 그 경계를 정한다.
|
||||||
# would turn a statistic into surveillance imagery. Capture only when a real incident
|
evidential = [e for e in events if e.event_type not in NO_EVIDENCE_TYPES]
|
||||||
# needs evidence, and never attach snapshots to the ergo report itself.
|
|
||||||
evidential = [e for e in events if e.event_type != EventType.ERGO_REPORT]
|
|
||||||
snaps = []
|
snaps = []
|
||||||
if evidential:
|
if evidential:
|
||||||
# Evidence timeline, oldest→now: how the incident DEVELOPED, not just its end
|
# 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)
|
frames, boxes = ring.timeline(now, SNAPSHOT_OFFSETS_S)
|
||||||
snaps = snapshotter.capture(frames, boxes)
|
snaps = snapshotter.capture(frames, boxes)
|
||||||
for e in events:
|
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
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
|
||||||
| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 |
|
| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 |
|
||||||
| 8 | `2026-08-09-safety-platform.sql` | 통보 채널·억제·위험성평가·인수인계·성능 시계열 표, 감사로그 해시체인, 신규 감지 유형 |
|
| 8 | `2026-08-09-safety-platform.sql` | 통보 채널·억제·위험성평가·인수인계·성능 시계열 표, 감사로그 해시체인, 신규 감지 유형 |
|
||||||
| 9 | `2026-09-04-fall-from-height.sql` | 추락(`fall_from_height`)·동시 다발 쓰러짐(`multi_down`) 유형 허용 |
|
| 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** 탭
|
1. phpMyAdmin → 해당 DB → **SQL** 탭
|
||||||
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
|
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -992,6 +992,7 @@
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (data && data.storage) renderStorage(data.storage);
|
if (data && data.storage) renderStorage(data.storage);
|
||||||
|
if (data) renderDispatcher(data.dispatcher);
|
||||||
if (data && Array.isArray(data.devices)) {
|
if (data && Array.isArray(data.devices)) {
|
||||||
deviceList = data.devices;
|
deviceList = data.devices;
|
||||||
devicesLoaded = true;
|
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 ----------------------------------------------------------------
|
// --- bulk acknowledge ----------------------------------------------------------------
|
||||||
// For clearing a backlog. Criticals are excluded ON PURPOSE and the button says so: a fall
|
// 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
|
// is exactly the row a human must look at, and a control that can clear one in a sweep is a
|
||||||
|
|
|
||||||
|
|
@ -158,11 +158,13 @@
|
||||||
.catch(function () { /* the POST surfaces it as a 403 */ });
|
.catch(function () { /* the POST surfaces it as a 403 */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
function post(path) {
|
function post(path, body) {
|
||||||
return fetch(B + '/events/' + encodeURIComponent(id) + path, {
|
var opt = { method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } };
|
||||||
method: 'POST',
|
if (body !== undefined) {
|
||||||
headers: { 'X-Csrf-Token': csrfToken || '' }
|
opt.headers['Content-Type'] = 'application/json';
|
||||||
}).then(function (res) {
|
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.status === 401) { window.location.href = B + '/login'; return null; }
|
||||||
if (!res.ok) throw new Error('action_failed:' + res.status);
|
if (!res.ok) throw new Error('action_failed:' + res.status);
|
||||||
return res;
|
return res;
|
||||||
|
|
@ -242,6 +244,60 @@
|
||||||
actionsBox.appendChild(fp);
|
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() {
|
function renderMeta() {
|
||||||
var ev = currentEvent || {};
|
var ev = currentEvent || {};
|
||||||
meta.textContent = '';
|
meta.textContent = '';
|
||||||
|
|
@ -374,6 +430,7 @@
|
||||||
renderHead();
|
renderHead();
|
||||||
renderMeta();
|
renderMeta();
|
||||||
renderActions();
|
renderActions();
|
||||||
|
renderHold();
|
||||||
renderLinks();
|
renderLinks();
|
||||||
renderSnapshots(data.snapshots);
|
renderSnapshots(data.snapshots);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ use App\Auth\PhpSessionStore;
|
||||||
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
|
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
|
||||||
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
|
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
|
||||||
PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository,
|
PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository,
|
||||||
PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository};
|
PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository,
|
||||||
|
PdoAppStateRepository};
|
||||||
use App\Ingest\IngestController;
|
use App\Ingest\IngestController;
|
||||||
use App\Api\{ConfigController, HeartbeatController};
|
use App\Api\{ConfigController, HeartbeatController};
|
||||||
use App\Auth\{AuthController, CsrfController};
|
use App\Auth\{AuthController, CsrfController};
|
||||||
|
|
@ -93,6 +94,7 @@ $handovers = new PdoHandoverRepository($pdo);
|
||||||
$risks = new PdoRiskControlRepository($pdo);
|
$risks = new PdoRiskControlRepository($pdo);
|
||||||
$metrics = new PdoDeviceMetricRepository($pdo);
|
$metrics = new PdoDeviceMetricRepository($pdo);
|
||||||
$actions = new PdoActionRepository($pdo);
|
$actions = new PdoActionRepository($pdo);
|
||||||
|
$appState = new PdoAppStateRepository($pdo);
|
||||||
|
|
||||||
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
|
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
|
||||||
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts);
|
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts);
|
||||||
|
|
@ -101,13 +103,13 @@ $auth = new AuthController($users, $audit, $rate);
|
||||||
$csrfCtl = new CsrfController();
|
$csrfCtl = new CsrfController();
|
||||||
$list = new EventListController($events, $snaps, $sess);
|
$list = new EventListController($events, $snaps, $sess);
|
||||||
$detail = new EventDetailController($events, $snaps, $audit, $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);
|
$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);
|
$heatmap = new HeatmapController($events, $sess);
|
||||||
$fpStats = new FpStatsController($events, $sess);
|
$fpStats = new FpStatsController($events, $sess);
|
||||||
$report = new ReportController($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);
|
$actionsCtl = new ActionsController($actions, $events, $sess);
|
||||||
$adminCtl = new AdminConfigController($thr, $alerts, $devices, $audit, $sess);
|
$adminCtl = new AdminConfigController($thr, $alerts, $devices, $audit, $sess);
|
||||||
$suppressCtl = new SuppressionController($suppressions, $events, $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('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}/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}/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/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/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/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p));
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,11 @@ CREATE TABLE move_events (
|
||||||
acknowledged_at BIGINT UNSIGNED 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_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning)
|
||||||
false_positive_at BIGINT UNSIGNED NULL,
|
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)
|
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
|
||||||
PRIMARY KEY (event_id),
|
PRIMARY KEY (event_id),
|
||||||
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
|
-- 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_occurred_at (occurred_at),
|
||||||
KEY ix_events_severity_occurred_at (severity, 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_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_device (device_id),
|
||||||
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
|
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
|
||||||
CONSTRAINT chk_events_type CHECK (event_type IN (
|
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)
|
KEY ix_device_metrics_device_ts (device_id, ts)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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):
|
-- 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
|
-- do NOT copy the statements below by hand — they are SQL comments and will not
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use App\Config;
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Auth\SessionStore;
|
use App\Auth\SessionStore;
|
||||||
use App\Security\SiteScope;
|
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
|
* 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).
|
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
|
||||||
private const OFFLINE_AFTER_S = 120;
|
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. */
|
/** 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_WINDOW_S = 86400;
|
||||||
private const SERIES_LIMIT = 3000;
|
private const SERIES_LIMIT = 3000;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private DeviceRepository $devices, private SessionStore $sess,
|
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
|
public function json(Request $req, int $now): Response
|
||||||
|
|
@ -63,7 +72,11 @@ final class DeviceStatusController
|
||||||
];
|
];
|
||||||
}, $this->devices->listStatus($site));
|
}, $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,
|
'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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
|
||||||
|
|
||||||
final class EventDetailController
|
final class EventDetailController
|
||||||
{
|
{
|
||||||
|
/** 1년. 이보다 긴 보존이 필요하면 사건을 따로 내보내는 것이 맞지, 사진을 계속
|
||||||
|
* 들고 있는 것이 맞지 않다. 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. */
|
||||||
|
private const MAX_HOLD_DAYS = 365;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EventRepository $events, private SnapshotRepository $snaps,
|
private EventRepository $events, private SnapshotRepository $snaps,
|
||||||
private AuditRepository $audit, private SessionStore $sess
|
private AuditRepository $audit, private SessionStore $sess
|
||||||
|
|
@ -68,4 +72,44 @@ final class EventDetailController
|
||||||
$this->audit->append($user, 'false_positive', "event:$id", [], []);
|
$this->audit->append($user, 'false_positive', "event:$id", [], []);
|
||||||
return Response::json(['ok' => true], 200);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace App\Dashboard;
|
||||||
|
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Auth\{SessionStore, Rbac};
|
use App\Auth\{SessionStore, Rbac};
|
||||||
use App\Repository\{EventRepository, SnapshotRepository};
|
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/export/labels?days=N -> JSONL download.
|
* GET /api/export/labels?days=N -> JSONL download.
|
||||||
|
|
@ -29,7 +29,7 @@ final class ExportController
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EventRepository $events, private SnapshotRepository $snaps,
|
private EventRepository $events, private SnapshotRepository $snaps,
|
||||||
private SessionStore $sess
|
private SessionStore $sess, private ?AuditRepository $audit = null
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function labels(Request $req): Response
|
public function labels(Request $req): Response
|
||||||
|
|
@ -69,6 +69,14 @@ final class ExportController
|
||||||
'snapshots' => $uuids[(int)$r['event_id']] ?? [],
|
'snapshots' => $uuids[(int)$r['event_id']] ?? [],
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], 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" : '');
|
$body = implode("\n", $lines) . ($lines ? "\n" : '');
|
||||||
return Response::bytes($body, 'application/x-ndjson; charset=utf-8', 200, [
|
return Response::bytes($body, 'application/x-ndjson; charset=utf-8', 200, [
|
||||||
'Content-Disposition' => 'attachment; filename="labels-' . date('Ymd') . '-' . $days . 'd.jsonl"',
|
'Content-Disposition' => 'attachment; filename="labels-' . date('Ymd') . '-' . $days . 'd.jsonl"',
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,19 @@ namespace App\Dashboard;
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Auth\SessionStore;
|
use App\Auth\SessionStore;
|
||||||
use App\Security\SiteScope;
|
use App\Security\SiteScope;
|
||||||
use App\Repository\{SnapshotRepository, EventRepository};
|
use App\Repository\{SnapshotRepository, EventRepository, AuditRepository};
|
||||||
|
|
||||||
final class SnapshotProxy
|
final class SnapshotProxy
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 세션당 기억할 열람 uuid 수. 넘으면 오래된 것부터 버리고, 버려진 uuid 를 다시 보면
|
||||||
|
* 감사에 한 줄 더 남는다 — 넘치면 **덜 기록**이 아니라 **더 기록**하는 방향으로 틀어진다.
|
||||||
|
*/
|
||||||
|
private const VIEWED_MEMO_CAP = 500;
|
||||||
|
|
||||||
public function __construct(
|
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
|
public function serve(Request $req, array $params): Response
|
||||||
|
|
@ -30,6 +37,7 @@ final class SnapshotProxy
|
||||||
return Response::json(['error' => 'not_found'], 404);
|
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
|
// 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
|
// 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
|
// 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,
|
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200,
|
||||||
['Cache-Control' => 'private, max-age=86400, immutable']);
|
['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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
server/src/Repository/AppStateRepository.php
Normal file
18
server/src/Repository/AppStateRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,16 @@ interface EventRepository
|
||||||
* the FIRST tagger is preserved. Returns false only when the event does not exist.
|
* the FIRST tagger is preserved. Returns false only when the event does not exist.
|
||||||
*/
|
*/
|
||||||
public function markFalsePositive(int $id, string $user): bool;
|
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
|
* 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.
|
* false-positive counts since $sinceOccurredAt, only groups with at least one FP.
|
||||||
|
|
|
||||||
34
server/src/Repository/Pdo/PdoAppStateRepository.php
Normal file
34
server/src/Repository/Pdo/PdoAppStateRepository.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -126,6 +126,21 @@ final class PdoEventRepository implements EventRepository
|
||||||
return $row === false ? null : self::mapRow($row);
|
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
|
public function acknowledge(int $id, string $user): bool
|
||||||
{
|
{
|
||||||
// Existence is checked separately (rather than trusting UPDATE's
|
// Existence is checked separately (rather than trusting UPDATE's
|
||||||
|
|
|
||||||
|
|
@ -121,16 +121,28 @@ final class PdoSnapshotRepository implements SnapshotRepository
|
||||||
return $id === false ? null : (int)$id;
|
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
|
// 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
|
// 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
|
// 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.
|
// 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(
|
$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(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();
|
$sel->execute();
|
||||||
$rows = $sel->fetchAll();
|
$rows = $sel->fetchAll();
|
||||||
if (!$rows) {
|
if (!$rows) {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ interface SnapshotRepository
|
||||||
public function latestForCamera(string $cameraId): ?array;
|
public function latestForCamera(string $cameraId): ?array;
|
||||||
|
|
||||||
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
|
/** 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;
|
public function purgeOlderThan(int $cutoff): int;
|
||||||
|
|
||||||
/** ALL snapshot uuids per event, as [event_id => [file_uuid, ...]] in stored order —
|
/** ALL snapshot uuids per event, as [event_id => [file_uuid, ...]] in stored order —
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ render_header('이벤트 상세');
|
||||||
<!-- 판정은 증거를 본 화면에서 끝난다. 예전에는 여기서 판단하고 목록으로 되돌아가
|
<!-- 판정은 증거를 본 화면에서 끝난다. 예전에는 여기서 판단하고 목록으로 되돌아가
|
||||||
그 행을 다시 찾아 눌러야 했다 — 판단하는 자리에 판단 도구가 없었다. -->
|
그 행을 다시 찾아 눌러야 했다 — 판단하는 자리에 판단 도구가 없었다. -->
|
||||||
<div class="detail-actions" id="detail-actions" hidden></div>
|
<div class="detail-actions" id="detail-actions" hidden></div>
|
||||||
|
<div class="detail-actions" id="detail-hold" hidden></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="detail-links">
|
<div class="detail-links">
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ render_header('이벤트');
|
||||||
<!-- 스냅샷 저장 공간이 차면 이미지 기록이 조용히 실패한다 (이벤트는 계속 쌓인다).
|
<!-- 스냅샷 저장 공간이 차면 이미지 기록이 조용히 실패한다 (이벤트는 계속 쌓인다).
|
||||||
증거가 사라지고 있다는 사실을 대시보드가 먼저 말해야 한다. -->
|
증거가 사라지고 있다는 사실을 대시보드가 먼저 말해야 한다. -->
|
||||||
<div id="storage-warn" class="storage-warn" role="status" hidden></div>
|
<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
|
<!-- 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). -->
|
nobody was watching. Hidden until the numbers arrive (an empty banner is noise). -->
|
||||||
<div id="day-brief" class="day-brief" hidden></div>
|
<div id="day-brief" class="day-brief" hidden></div>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ if (!function_exists('view_url')) {
|
||||||
if (!function_exists('render_header')) {
|
if (!function_exists('render_header')) {
|
||||||
function render_header(string $title): void {
|
function render_header(string $title): void {
|
||||||
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
|
$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
|
// 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.
|
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
|
||||||
// 페이지 이름은 nav 라벨 · <h1> · <title> 세 곳이 같은 문자열을 쓴다. 예전에는
|
// 페이지 이름은 nav 라벨 · <h1> · <title> 세 곳이 같은 문자열을 쓴다. 예전에는
|
||||||
|
|
@ -51,6 +55,7 @@ if (!function_exists('render_header')) {
|
||||||
<link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
|
<link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
|
||||||
<script nonce="<?= view_e(\App\Security\Csp::nonce()) ?>">
|
<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_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) ?>;
|
window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
167
server/tests/DispatcherLivenessTest.php
Normal file
167
server/tests/DispatcherLivenessTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
184
server/tests/EvidenceHoldTest.php
Normal file
184
server/tests/EvidenceHoldTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -460,9 +460,16 @@ final class PdoIntegrationTest extends TestCase
|
||||||
acknowledged_at INTEGER,
|
acknowledged_at INTEGER,
|
||||||
false_positive_by TEXT,
|
false_positive_by TEXT,
|
||||||
false_positive_at INTEGER,
|
false_positive_at INTEGER,
|
||||||
|
hold_until INTEGER,
|
||||||
received_at INTEGER NOT NULL
|
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 (
|
CREATE TABLE move_zones (
|
||||||
zone_id TEXT PRIMARY KEY,
|
zone_id TEXT PRIMARY KEY,
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
|
|
|
||||||
184
server/tests/ReadAuditTest.php
Normal file
184
server/tests/ReadAuditTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ final class RouteTableTest extends TestCase
|
||||||
'POST /api/events', 'POST /api/heartbeat', 'GET /api/config', 'GET /api/csrf',
|
'POST /api/events', 'POST /api/heartbeat', 'GET /api/config', 'GET /api/csrf',
|
||||||
'POST /login', 'POST /logout', 'GET /api/events', 'GET /api/devices',
|
'POST /login', 'POST /logout', 'GET /api/events', 'GET /api/devices',
|
||||||
'GET /events/{id}', 'POST /events/{id}/ack', 'POST /events/{id}/false-positive',
|
'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/zones/{camera}', 'GET /api/zone-presets',
|
||||||
'GET /api/heatmap/{camera}', 'GET /api/fp-stats',
|
'GET /api/heatmap/{camera}', 'GET /api/fp-stats',
|
||||||
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',
|
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,11 @@ final class InMemoryEventRepository implements EventRepository {
|
||||||
}
|
}
|
||||||
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
|
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 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 {
|
public function markFalsePositive(int $id, string $user): bool {
|
||||||
if (!isset($this->rows[$id])) return false;
|
if (!isset($this->rows[$id])) return false;
|
||||||
$r = $this->rows[$id];
|
$r = $this->rows[$id];
|
||||||
|
|
@ -210,11 +215,14 @@ final class InMemoryEventRepository implements EventRepository {
|
||||||
}
|
}
|
||||||
final class InMemorySnapshotRepository implements SnapshotRepository {
|
final class InMemorySnapshotRepository implements SnapshotRepository {
|
||||||
public array $rows = []; public array $paths = []; public array $created = [];
|
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 {
|
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
|
// 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.
|
// collide instead of duplicating, so the dedup recovery path is idempotent.
|
||||||
foreach ($this->rows[$e] ?? [] as $r) { if ($r['content_hash'] === $hash) return; }
|
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 forEvent(int $e): array { return $this->rows[$e] ?? []; }
|
||||||
public function thumbUuidsFor(array $eventIds): array {
|
public function thumbUuidsFor(array $eventIds): array {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch, MultiDownWatch};
|
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch, MultiDownWatch};
|
||||||
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
|
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
|
||||||
PdoDeviceRepository, PdoDeviceMetricRepository};
|
PdoDeviceRepository, PdoDeviceMetricRepository, PdoAppStateRepository};
|
||||||
|
|
||||||
[$cfg, $pdo] = tool_boot();
|
[$cfg, $pdo] = tool_boot();
|
||||||
$args = tool_args($argv);
|
$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(
|
fwrite(STDOUT, sprintf(
|
||||||
"%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d health=%d multi=%d%s\n",
|
"%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'],
|
date('c'), $stats['considered'], $stats['sent'], $stats['suppressed'],
|
||||||
|
|
|
||||||
|
|
@ -246,3 +246,54 @@ def test_two_persons_get_independent_smoothing():
|
||||||
# state leaked between tracks.
|
# state leaked between tracks.
|
||||||
assert left_nose_x < 300
|
assert left_nose_x < 300
|
||||||
assert right_nose_x > 700
|
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], \
|
||||||
|
"구역 침입에서 증거가 사라졌습니다 — 사고 조사에서 볼 것이 없어집니다."
|
||||||
|
|
|
||||||
55
tests/test_evidence_policy.py
Normal file
55
tests/test_evidence_policy.py
Normal 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
|
||||||
Loading…
Reference in a new issue